You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I searched existing issues and did not find a duplicate.
I am describing a concrete problem or use case, not just a vague idea.
Area
apps/web
Problem or use case
I often know what I want an agent to do next, but can't start it right now — a turn is already running in that project, I'm out of usage for the window, the task depends on something that isn't merged yet, or I want to think about the wording before spending a run.
Today an unsent draft is invisible, singular, and local:
Invisible. A draft does not appear anywhere in Sidebar V2. It exists only inside the composer of whatever route I'm on, so navigating away leaves no reminder that a prompt is sitting there, and no way to see at a glance which projects have pending work.
Singular. There is at most one draft per project — composerDraftStore exposes getDraftThreadByProjectRef(projectRef) — and "new thread" reuses that project's existing draft rather than creating another. I can't line up two or three prompts for the same repo.
Local. Drafts live in localStorage under a ~5MB origin-wide quota shared with the prompt stash, and never leave the browser profile that created them.
The net effect is that there's no way to build up a short list of "tasks I want to run in this project, but not yet."
Proposed solution
Make a draft an actual thread that hasn't taken its first turn yet, rather than a client-side record that becomes a thread at send time.
The pieces for this already exist and are already separated correctly:
thread.create is a standalone command whose decider (apps/server/src/orchestration/decider.ts) does nothing but check that the project exists and the thread ID is unused, then emit thread.created. It has no filesystem or git side effects.
Worktree preparation and setup scripts are separate bootstrap steps, sequenced after thread.create inside the turn-start handler in apps/server/src/ws.ts (prepareWorktree, runSetupScript).
The client already allocates the thread's aggregate ID when the draft is created; it just holds it locally and defers thread.create into thread.turn.start's bootstrap.createThread at send time.
So the change is: dispatch thread.create when the draft is started, instead of deferring it to send. Environment materialization stays exactly where it is — on the first turn — so a draft thread costs nothing but a row.
Everything else follows from that:
Drafts appear in Sidebar V2 automatically, because they are threads. They need a status label: resolveSidebarV2Status (apps/web/src/components/Sidebar.logic.ts) currently returns ready when a thread has no session, so it needs a branch returning draft for a thread that has never had a turn, and SidebarV2.tsx's topStatus block needs a corresponding Draft label in the slot that shows Working / Approval / Input / Failed / Woke / Done.
Multiple drafts per project become free. Threads are already many-per-project. "New thread" creates another draft thread; the one-draft-per-project mapping in composerDraftStore goes away rather than being generalized.
Drafts persist server-side and sync across clients, because thread state already does. The same pending list shows up in the desktop app, the web client, and mobile.
The localStorage quota problem disappears, and draft images can use the existing server attachment store — which is keyed by thread id (createAttachmentId(threadId) in apps/server/src/attachmentStore.ts), so a draft thread fits it directly, where a DraftId does not.
Two gaps this does not close for free, and which are the real work:
The unsent prompt text needs a home. Thread content today arrives as a message on thread.turn.start; an unsent prompt is not a message yet. This needs a small addition — a thread.draft.set command and event carrying text plus attachment references, and a column on the thread projection to hold it. This is the one genuinely new piece of persistence.
title is a required TrimmedNonEmptyString on thread.create, but a draft has no title until it's sent (today the title is derived from titleSeed at send). Drafts need either a nullable title or an explicit placeholder that the sidebar renders as the draft's prompt text.
Why this matters
It closes the gap between "I thought of a task" and "I ran the task" without leaving the app or holding it in my head — specifically when the reason you can't run it right now is temporary: a turn is already in flight, you've hit a usage limit and want everything queued for the reset, the task depends on a review or merge that hasn't landed, or you're switching between repos and want each one's next step visible where you already look.
Modeling drafts as threads also removes a class of bug rather than adding one. #4647 is caused precisely by the current split: the client holds a pre-allocated thread ID, defers thread.create to send, and when bootstrap fails and emits thread.deleted, the persisted draft keeps retrying thread.create against a tombstoned aggregate ID — permanently, across restarts. If the thread is created when the draft is created, there is no create-at-send-time step to retry against a dead ID, and bootstrap failure becomes an ordinary retryable turn failure on a thread that already exists.
It also makes the existing draft feature discoverable. Right now a draft you left behind is silently invisible until you happen to return to the same route, which is a small but real way to lose written work.
Smallest useful scope
Create the thread at draft time, add a draft status to resolveSidebarV2Status, and render a Draft label in Sidebar V2 — with the draft's prompt text still held client-side, exactly as it is now.
That alone makes pending work visible, and makes multiple drafts per project fall out, because they are threads. Server-side draft text (thread.draft.set) is a second step that adds cross-device sync and removes the quota ceiling, and can land separately.
Alternatives considered
Keep drafts client-side and just render them in the sidebar. Less server work, and a draft stays editable when the server is unreachable. But it keeps the ~5MB shared quota, keeps the list pinned to one browser profile, requires generalizing the per-project draft mapping to a list by hand, needs a parallel "draft row" concept in a sidebar that otherwise renders threads, cannot reuse the thread-keyed attachment store, and leaves [Bug]: Failed bootstrap leaves new-thread draft pinned to a deleted thread ID #4647's tombstoned-ID path in place.
A dedicated server-side draft table, separate from threads. Removes the quota and sync problems but duplicates a lot of what threads already provide — per-project grouping, attachments, environment and model selection, ordering, deletion — and still needs a second row type in the sidebar. Modeling drafts as threads reuses all of it.
The prompt stash (shipped) is the closest existing feature: up to 20 provider-agnostic prompts with images, restorable into any thread. It doesn't solve this — stashed prompts live in a composer menu rather than the sidebar, the stash is one flat global list rather than per-project, and restoring deliberately drops the provider and model selection, which is right for its "move this prompt elsewhere" purpose but wrong for "this task is ready to run as configured."
[Feature]: To-Do List & Queue/Steer Messages #4550 (To-Do List & Queue/Steer Messages) asks for a per-project to-do popup — an overlapping problem solved with a new surface holding free-text notes. This is narrower and reuses threads, so every entry is directly runnable rather than a note to retype.
[Feature]: Scheduled Prompts #3624 (Scheduled Prompts) covers "run this later" via a timestamp, scoped to existing threads and explicitly excluding new threads. Good when you know when; this is for when you only know what.
Abandoned drafts become real rows in the event store. Every started-and-forgotten draft is now a thread.created event and a projection row, where today it's a localStorage entry that gets overwritten. Creating the thread only once the composer is non-empty avoids the worst of it, but some cleanup story — auto-delete of empty draft threads after N days, or a bulk clear — is probably needed rather than optional.
Write amplification on draft text. The prompt changes on every keystroke. If thread.draft.set is dispatched per change it becomes an RPC and a SQLite write per debounce interval per client, and if it's an event-sourced event it also accumulates in the event log. [Bug]: Per-chunk assistant persistence causes progressive live output lag and rapid SQLite growth #5110 tracks an existing case of this shape (per-chunk persistence causing progressive lag and rapid SQLite growth). Draft text should be coalesced, debounced, and probably flushed on blur or navigation — and it may be a case for a mutable projection column updated in place rather than an event per edit.
A draft status must not leak into places that assume a live thread. Anything iterating threads — filters, counts, "needs review" style prioritization ([Feature]: Prioritize Sidebar V2 threads that need review #4695), mobile thread lists, notifications — will start seeing threads with no session and no turns. Draft threads should be excluded from attention/unread treatment by default, since they're waiting on the user, not the agent.
Sync introduces conflicts. Once draft text is server-side, two clients editing one draft need a resolution rule. Last-write-wins on an updatedAt is likely sufficient for drafts, but should be stated rather than left implicit. [Bug]: Thread interaction and runtime modes do not sync between desktop clients #5278 (thread modes not syncing between desktop clients) is a fair indicator of how much "this syncs correctly everywhere" actually costs.
Offline regression. A client that can't reach the server currently keeps working on a local draft. Creating the thread up front means draft creation now requires the server, unless a local buffer is retained as a write-through cache.
Deletion semantics. Deleting a draft is now a thread.delete, so it should be possible directly from the sidebar row without opening it, and shouldn't prompt with the same confirmation weight as deleting a thread with real history (confirmThreadDelete).
Sidebar noise. Drafts occupy rows in a list otherwise about active work. Suppressing empty drafts and giving Draft a low-prominence treatment should contain this, but it deserves a look on projects with many threads.
This discussion was converted from issue #5534 on August 15, 2026 09:49.
Heading
Bold
Italic
Quote
Code
Link
Numbered list
Unordered list
Task list
Attach files
Mention
Reference
Menu
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Before submitting
Area
apps/web
Problem or use case
I often know what I want an agent to do next, but can't start it right now — a turn is already running in that project, I'm out of usage for the window, the task depends on something that isn't merged yet, or I want to think about the wording before spending a run.
Today an unsent draft is invisible, singular, and local:
composerDraftStoreexposesgetDraftThreadByProjectRef(projectRef)— and "new thread" reuses that project's existing draft rather than creating another. I can't line up two or three prompts for the same repo.localStorageunder a ~5MB origin-wide quota shared with the prompt stash, and never leave the browser profile that created them.The net effect is that there's no way to build up a short list of "tasks I want to run in this project, but not yet."
Proposed solution
Make a draft an actual thread that hasn't taken its first turn yet, rather than a client-side record that becomes a thread at send time.
The pieces for this already exist and are already separated correctly:
thread.createis a standalone command whose decider (apps/server/src/orchestration/decider.ts) does nothing but check that the project exists and the thread ID is unused, then emitthread.created. It has no filesystem or git side effects.thread.createinside the turn-start handler inapps/server/src/ws.ts(prepareWorktree,runSetupScript).thread.createintothread.turn.start'sbootstrap.createThreadat send time.So the change is: dispatch
thread.createwhen the draft is started, instead of deferring it to send. Environment materialization stays exactly where it is — on the first turn — so a draft thread costs nothing but a row.Everything else follows from that:
resolveSidebarV2Status(apps/web/src/components/Sidebar.logic.ts) currently returnsreadywhen a thread has no session, so it needs a branch returningdraftfor a thread that has never had a turn, andSidebarV2.tsx'stopStatusblock needs a correspondingDraftlabel in the slot that showsWorking/Approval/Input/Failed/Woke/Done.composerDraftStoregoes away rather than being generalized.createAttachmentId(threadId)inapps/server/src/attachmentStore.ts), so a draft thread fits it directly, where aDraftIddoes not.Two gaps this does not close for free, and which are the real work:
thread.turn.start; an unsent prompt is not a message yet. This needs a small addition — athread.draft.setcommand and event carrying text plus attachment references, and a column on the thread projection to hold it. This is the one genuinely new piece of persistence.titleis a requiredTrimmedNonEmptyStringonthread.create, but a draft has no title until it's sent (today the title is derived fromtitleSeedat send). Drafts need either a nullable title or an explicit placeholder that the sidebar renders as the draft's prompt text.Why this matters
It closes the gap between "I thought of a task" and "I ran the task" without leaving the app or holding it in my head — specifically when the reason you can't run it right now is temporary: a turn is already in flight, you've hit a usage limit and want everything queued for the reset, the task depends on a review or merge that hasn't landed, or you're switching between repos and want each one's next step visible where you already look.
Modeling drafts as threads also removes a class of bug rather than adding one. #4647 is caused precisely by the current split: the client holds a pre-allocated thread ID, defers
thread.createto send, and when bootstrap fails and emitsthread.deleted, the persisted draft keeps retryingthread.createagainst a tombstoned aggregate ID — permanently, across restarts. If the thread is created when the draft is created, there is no create-at-send-time step to retry against a dead ID, and bootstrap failure becomes an ordinary retryable turn failure on a thread that already exists.It also makes the existing draft feature discoverable. Right now a draft you left behind is silently invisible until you happen to return to the same route, which is a small but real way to lose written work.
Smallest useful scope
Create the thread at draft time, add a
draftstatus toresolveSidebarV2Status, and render aDraftlabel in Sidebar V2 — with the draft's prompt text still held client-side, exactly as it is now.That alone makes pending work visible, and makes multiple drafts per project fall out, because they are threads. Server-side draft text (
thread.draft.set) is a second step that adds cross-device sync and removes the quota ceiling, and can land separately.Alternatives considered
Risks or tradeoffs
thread.createdevent and a projection row, where today it's a localStorage entry that gets overwritten. Creating the thread only once the composer is non-empty avoids the worst of it, but some cleanup story — auto-delete of empty draft threads after N days, or a bulk clear — is probably needed rather than optional.thread.draft.setis dispatched per change it becomes an RPC and a SQLite write per debounce interval per client, and if it's an event-sourced event it also accumulates in the event log. [Bug]: Per-chunk assistant persistence causes progressive live output lag and rapid SQLite growth #5110 tracks an existing case of this shape (per-chunk persistence causing progressive lag and rapid SQLite growth). Draft text should be coalesced, debounced, and probably flushed on blur or navigation — and it may be a case for a mutable projection column updated in place rather than an event per edit.draftstatus must not leak into places that assume a live thread. Anything iterating threads — filters, counts, "needs review" style prioritization ([Feature]: Prioritize Sidebar V2 threads that need review #4695), mobile thread lists, notifications — will start seeing threads with no session and no turns. Draft threads should be excluded from attention/unread treatment by default, since they're waiting on the user, not the agent.updatedAtis likely sufficient for drafts, but should be stated rather than left implicit. [Bug]: Thread interaction and runtime modes do not sync between desktop clients #5278 (thread modes not syncing between desktop clients) is a fair indicator of how much "this syncs correctly everywhere" actually costs.thread.delete, so it should be possible directly from the sidebar row without opening it, and shouldn't prompt with the same confirmation weight as deleting a thread with real history (confirmThreadDelete).Drafta low-prominence treatment should contain this, but it deserves a look on projects with many threads.Examples or references
apps/web/src/composerDraftStore.ts(DraftId,getDraftThreadByProjectRef,markPromotedDraftThread),apps/web/src/components/Sidebar.logic.ts(resolveSidebarV2Status),apps/web/src/components/SidebarV2.tsx(thetopStatuslabel block),apps/web/src/promptStashStore.tspackages/contracts/src/orchestration.ts(ThreadCreateCommand,ThreadTurnStartBootstrap),apps/server/src/orchestration/decider.ts(the side-effect-freethread.createbranch),apps/server/src/ws.ts(bootstrap sequencing),apps/server/src/attachmentStore.ts(thread-keyed attachment ids)Contribution
All reactions