Skip to content
Merged
101 changes: 101 additions & 0 deletions docs/plans/2026-09-24-pending-prompt-sending.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Lock the composer and show the prompt as "Sending…" in the feed

Issue: #1181

## Problem

Pressing Enter in an agent composer leaves the prompt sitting in the composer,
still editable, until the provider accepts it. For Claude that acceptance is
main seeing the prompt land in the JSONL transcript, which can take seconds. The
only signals that anything happened are the Send button turning into `…` and a
`Sending` chip. Claude also paints no user row until the committed transcript
row arrives, because it never had an optimistic row
(`usesOptimisticUserEcho: false`). The result reads as "my Enter did nothing",
and the editable draft invites a second Enter or edits to text that is already
on its way.

## Behavior

1. **Enter locks the composer.** While `promptDelivery.kind === 'sending'` the
textarea is read-only and dimmed, and editing keys, history recall, slash
mode, paste, type-to-focus and paste-to-focus are ignored. So are Escape and
Ctrl+C: main refuses raw writes for the whole main-owned delivery, so they
could not reach the agent anyway (review finding, see below).
2. **The text moves into the feed.** The composer VIEW is emptied on Enter (the
draft stays in the store), and the prompt appears at once as a user row at reduced opacity with a
`Sending…` caption. When the provider accepts it, the row goes to full
opacity (echo providers) or is handed to the committed row (Claude).
3. **"Sent" means the provider accepted it** (decision A): Claude's JSONL
acknowledgement, OpenCode's HTTP handoff, Codex's completed PTY writes. This
matches the point where `promptDelivery` already leaves `sending`.
4. **Every provider** gets the pending row (decision B). Claude gets it from a
new pending-only optimistic entry that lives exactly as long as the send.
5. **Queued behind a running turn** (decision C): the composer unlocks on the
queue acceptance. For Claude the pending row is removed and Claude's own
queue-operation strip shows the prompt. Codex and OpenCode already put
mid-turn prompts in the queued strip instead of the feed, which is unchanged.
6. **Failure** puts the prompt back. On any failed send the pending row is
removed and the lock lifts, which makes the still-stored draft (text and
images) visible again. The existing `uncertain` banner still blocks a plain
resend when something may have reached the provider.

## Design

- **Composer.** The draft stays in the store for the whole send. TileLeaf
hides it from the composer view while locked, and on acceptance
`draftAfterAcceptance` strips the sent prompt, keeping text another writer
(dictation, templates, reply-to-selection) added at either end.
`ComposerInput` gains an optional `locked` prop (the phone client does not
pass it). `submitCurrentDraft` also refuses while `sending`, because the
per-mount in-flight ref does not survive a remount.
*Revised in review:* the first version cleared the store at Enter and
restored it on failure. That lost the prompt on a renderer reload mid-send
(autosave persists only `draftInput`), and restored a failed prompt into a
retired session after a replacement (only current draft fields transfer).
- **Pending identity.** `promptDelivery.sending` carries the submission id
(`pasteId`). The optimistic entry uuid becomes
`optimistic-codex-user:<submissionId>` when a submission id exists, so the
row that is still sending can be named exactly. Previously it was
`Date.now()`, and nothing parsed the suffix.
- **Claude pending row.** New `addPendingPromptEntry` /
`removePendingPromptEntry` actions append and remove a plain optimistic entry
with that uuid. They deliberately do NOT reuse `addOptimisticCodexUserEntry`:
its mid-turn branch writes `queuedMessages`, which for Claude is owned by the
queue-operation reducer. The entry is removed on acceptance (any kind) and on
failure.
- **Order barrier in main.** Claude's acceptance is main seeing the JSONL line,
but that line waits in the JSONL coalescer until the next `setImmediate`,
while the invoke reply resumes in a microtask. The `session:deliver-prompt`
handler now calls `flushJsonl(sessionId)` before replying, so the committed
row always reaches the renderer before the acceptance that removes the
pending row. Without it, the prompt blinked out of the feed.
- **Painting.** Feed gets `pendingEntryUuid`, and the matching `entry` item is
drawn at reduced opacity with a `Sending…` caption. Ledger rows, ordering and
ownership are untouched by the pending flag, so the identity-cache contract
(D11) holds.
- **Repeated-prompt ownership fix (in blast radius).** `userTextKeys` was a
set, so a committed `continue` from an hour ago "owned" a new optimistic
`continue`, and the new row vanished instantly. It becomes a map from key to
the newest committed timestamp. An optimistic row is owned only by a committed
user row at or after its submit time, minus a small clock tolerance. Rows
without timestamps keep the old presence rule, which leaves Grok (whose
mapper emits no timestamps) on the old behavior.
- **Pasted prompts.** A committed user row's ownership key is the inner text
of Claude's `<pasted_content>` envelope (`unwrapClaudePastedContent`, the
same strict unwrap main uses for acceptance), so a pasted prompt's pending
row is owned by its committed twin instead of painting beside it.

## Not in scope

- The `Sending` WorkIndicator chip stays. It is driven by `streamPhase` and
carries the elapsed clock, while the row caption is about the prompt itself.
- Remote-companion and MCP prompts go through main, not the composer, so they
get no pending row.

## Verification

- Unit: `draftAfterFailure`, ownership (a repeated prompt is not owned by an
older committed twin but is owned by a newer one).
- Renderer: the composer is read-only while sending, and the pending entry
renders dimmed with the caption.
- `npx tsc -b`, then the full vitest run once at the end.
15 changes: 14 additions & 1 deletion src/main/ipc/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
loadOlderHistoryChunk,
} from '@main/sessions/historyLoader.js'
import { resolveTranscriptPaths } from '@main/sessions/transcriptPaths.js'
import { flushJsonl } from '@main/sessions/jsonlCoalescer.js'
import type { SessionSpawnOptions } from '@preload/api/types.js'
import type {
SessionKillOptions,
Expand Down Expand Up @@ -370,8 +371,20 @@ export function registerSessionIpc(
})
}
: undefined
return await manager.deliverPromptToAgent(sessionId, prompt, imagePaths, record, deliveryId,
const result = await manager.deliverPromptToAgent(sessionId, prompt, imagePaths, record, deliveryId,
options?.requireEmptyNativeComposer === true ? { requireEmptyNativeComposer: true } : undefined)
// ORDER BARRIER (#1181): send the committed rows before the answer.
// Claude's acceptance IS main seeing the prompt's JSONL line, and that
// line is buffered in the JSONL coalescer and sent on the next
// setImmediate. The reply to this invoke would otherwise overtake it,
// because the await above resumes in a microtask. The renderer removes
// its pending "Sending…" row the moment the reply lands. Without this
// flush the prompt blinked out of the feed until the batch arrived
// (PR #1183 review, Claude 1). Both messages then travel the same
// renderer channel in this order. Flushing early costs nothing: it is
// the same batch, just sent now, and an empty buffer is a no-op.
flushJsonl(sessionId)
return result
},
)

Expand Down
5 changes: 4 additions & 1 deletion src/providers/codex/renderer/transcript/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
type ResultPart,
} from '@providers/shared/renderer/protocols/media/imageAttachment'
import { boundedJsonPreview } from '@renderer/lib/text/boundedJson'
// One marker for every layer that recognizes a locally minted prompt row;
// see its module for why it is not a per-file literal.
import { OPTIMISTIC_PROMPT_UUID_PREFIX } from '@renderer/session-runtime/optimisticPrompt'

// Codex rollout primitives + conversion helpers.
//
Expand Down Expand Up @@ -47,7 +50,7 @@ export function extractCodexProviderSessionId(entry: Record<string, unknown>): s
* entries array doesn't confuse the check. */
export function isOptimisticCodexUserEntry(entry: Entry | undefined): boolean {
if (!entry || entry.type !== 'user') return false
return typeof entry.uuid === 'string' && entry.uuid.startsWith('optimistic-codex-user:')
return typeof entry.uuid === 'string' && entry.uuid.startsWith(OPTIMISTIC_PROMPT_UUID_PREFIX)
}

/** Parse a Codex-emitted JSON payload safely. Returns null on any
Expand Down
45 changes: 38 additions & 7 deletions src/renderer/src/features/feed/ui/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,18 @@ type Props = {
* auto-scrolls into view when the value changes.
*/
pickerSelectedUuid?: string | null
/**
* UUID of the optimistic prompt row whose send has not settled yet (#1181).
* That row paints dimmed with a `Sending…` caption until the provider
* accepts or rejects the prompt.
*
* WHY a prop keyed by uuid and not a flag on the ledger row: "still sending"
* is presentation over a row the ledger already decided to show. Putting it
* in the candidate would make every send and every settle rebuild ledger
* rows and bust the identity cache (D11) during the hottest repaint window.
* It would also teach ordering and ownership about a state they must ignore.
*/
pendingEntryUuid?: string | null
/**
* Instance id (`data-code-block-id`) of the code block currently
* highlighted by the "Copy Code Block" picker. Null when that
Expand Down Expand Up @@ -310,6 +322,7 @@ function FeedImpl({
turnStartedAt = null,
tailMode = false,
pickerSelectedUuid = null,
pendingEntryUuid = null,
codeBlockSelectedId = null,
workspaceRoot = null,
onScrollInfo,
Expand Down Expand Up @@ -981,6 +994,10 @@ function FeedImpl({
const uuid = e.uuid
const selected =
pickerSelectedUuid != null && uuid === pickerSelectedUuid
// A pending row is by construction the newest prompt, so it always
// mounts eagerly; skipping LazyEntry also keeps the caption from
// appearing before the row it describes.
const pending = pendingEntryUuid != null && uuid === pendingEntryUuid
// WHY eager rendering keys off committed-entry ordinal, not
// render-item index: semantic/work rows now live in the
// same ordered list, but markdown parse cost still belongs to
Expand Down Expand Up @@ -1014,13 +1031,27 @@ function FeedImpl({
: undefined
}
>
<LazyEntry
eager={eager}
suspended={bootstrapping}
scrollerRef={scrollerRef}
>
<EntryRow entry={e} />
</LazyEntry>
{pending ? (
// Dimmed, not hidden or badged elsewhere: the prompt is shown
// in the exact place and form it will keep once accepted, so
// settling is only an opacity change and nothing jumps. The
// caption says what the dimming means. aria-busy tells
// assistive tech the same thing the opacity tells sighted users.
<div className="opacity-50 transition-opacity duration-150" aria-busy="true">
<EntryRow entry={e} />
{/* 22px = MarkerRow's marker column (w-3) plus its gap-2.5,
so the caption sits under the prompt text, not the ❯. */}
<div className="mt-0.5 pl-[22px] text-[10px] text-muted">Sending…</div>
</div>
) : (
<LazyEntry
eager={eager}
suspended={bootstrapping}
scrollerRef={scrollerRef}
>
<EntryRow entry={e} />
</LazyEntry>
)}
</div>
</RenderDebugBoundary>
)
Expand Down
10 changes: 4 additions & 6 deletions src/renderer/src/rendering/adapter/collectLedgerInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import {
} from '@renderer/rendering/observations/semantic'
import type { SemanticBlockLike, SemanticTurnLike } from '@renderer/rendering/observations/semantic'
import { createUnknownRegistry } from '@renderer/rendering/model/unknowns'
// Shared optimistic-row marker (Codex-named, provider-neutral); see its module
// for why every layer reads one constant instead of its own literal.
import { OPTIMISTIC_PROMPT_UUID_PREFIX } from '@renderer/session-runtime/optimisticPrompt'
import type { UnknownBehavior } from '@renderer/rendering/model/types'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -102,11 +105,6 @@ export type LedgerInputBundle = {
collectorDecisions: readonly OwnershipDecision[]
}

/** Shared optimistic-row marker. Codex-named for history (it shipped for
* Codex first) but provider-neutral: every echo provider's submit path
* mints this prefix. Source of truth: workspace/hook/actions/streaming.ts. */
const OPTIMISTIC_UUID_PREFIX = 'optimistic-codex-user:'

function toTurnLike(turn: RuntimeSemanticTurn): SemanticTurnLike {
return {
turnId: turn.turnId,
Expand Down Expand Up @@ -256,7 +254,7 @@ export function createLedgerInputAdapter(): (slices: RuntimeLedgerSlices) => Led
const committedRows: RawCommittedEntry[] = []
const optimisticRows: OptimisticPromptLike[] = []
for (const e of slices.entries) {
if (e.uuid?.startsWith(OPTIMISTIC_UUID_PREFIX)) {
if (e.uuid?.startsWith(OPTIMISTIC_PROMPT_UUID_PREFIX)) {
optimisticRows.push({
uuid: e.uuid,
text: optimisticTextOf(e),
Expand Down
96 changes: 96 additions & 0 deletions src/renderer/src/rendering/model/ownership.optimisticTwin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'

import { collectCommittedCandidates } from '@renderer/rendering/observations/committed'
import { collectOptimisticCandidates } from '@renderer/rendering/observations/local'
import {
OPTIMISTIC_TWIN_CLOCK_TOLERANCE_MS,
SUPPRESSION_POLICY,
buildCommittedOwnership,
decideLiveCandidate,
} from '@renderer/rendering/model/ownership'
import pastedTypedPrompt from '../../../../../testing/fixtures/prompt-acceptance/pasted-typed-prompt-2026-09-20.json'

// #1181: an optimistic prompt row is owned only by a committed user row with
// the same text that is NOT OLDER than the submit.
//
// The failure this pins: ownership used to be text presence alone, so the
// second `continue` of a session was "owned" by the first one from an hour
// earlier and was rejected the moment it was minted. The prompt left the
// composer at Enter and appeared nowhere until the transcript caught up.
// Driven through the real collectors so the timestamps are the ones production
// derives (entry.timestamp for committed rows, submit time for optimistic).

const T = 1_700_000_000_000
const iso = (ms: number) => new Date(ms).toISOString()

function decide(committedAtMs: number | null, submittedAtMs: number | null) {
const committed = collectCommittedCandidates(
[{
uuid: 'c1',
type: 'user',
...(committedAtMs === null ? {} : { timestamp: iso(committedAtMs) }),
message: { role: 'user', content: 'continue' },
}],
'claude',
's1',
)
const [optimistic] = collectOptimisticCandidates(
[{ uuid: 'optimistic-codex-user:sub-1', text: 'continue', submittedAtMs }],
'claude',
's1',
)
return decideLiveCandidate(
optimistic!,
buildCommittedOwnership(committed.candidates),
SUPPRESSION_POLICY.claude,
)
}

describe('optimistic prompt ownership by a committed twin', () => {
it('an older committed prompt with the same text does not own a new submit', () => {
expect(decide(T - 60 * 60_000, T)).toMatchObject({ selected: true })
})

it('the committed row of THIS submit owns it (the normal handoff)', () => {
expect(decide(T + 350, T)).toMatchObject({
selected: false,
reason: 'optimistic-owned-by-committed',
})
})

it('absorbs small skew between the renderer clock and the provider clock', () => {
expect(decide(T - OPTIMISTIC_TWIN_CLOCK_TOLERANCE_MS + 1, T)).toMatchObject({ selected: false })
expect(decide(T - OPTIMISTIC_TWIN_CLOCK_TOLERANCE_MS - 1, T)).toMatchObject({ selected: true })
})

it('keeps the presence rule when either side has no timestamp', () => {
// A missing time proves nothing, so the old conservative behavior stands.
expect(decide(null, T)).toMatchObject({ selected: false })
expect(decide(T - 60 * 60_000, null)).toMatchObject({ selected: false })
})

it('a pasted Claude prompt is owned through its <pasted_content> envelope', () => {
// Claude commits a pasted prompt inside its own envelope while the pending
// row carries the typed text. Without unwrapping, both rows painted until
// the send settled (PR #1183 review). The committed row is the real
// recording, not a hand-written shape: the collector keys typed-prompt
// recognition on fields (permissionMode) an invented row would omit.
const entry = pastedTypedPrompt.transcriptEntry
const committedAtMs = Date.parse(entry.timestamp)
const typed = entry.message.content
.replace(/^<pasted_content id="cade">\n/, '')
.replace(/\n<\/pasted_content id="cade">$/, '')
const committed = collectCommittedCandidates([entry], 'claude', 's1')
expect(committed.candidates).toHaveLength(1)
const [optimistic] = collectOptimisticCandidates(
[{ uuid: 'optimistic-codex-user:sub-2', text: typed, submittedAtMs: committedAtMs - 300 }],
'claude',
's1',
)
expect(decideLiveCandidate(
optimistic!,
buildCommittedOwnership(committed.candidates),
SUPPRESSION_POLICY.claude,
)).toMatchObject({ selected: false, reason: 'optimistic-owned-by-committed' })
})
})
Loading
Loading