From da06bcfb780beb0e9edb49d769210505b9e22cc7 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 8 Oct 2026 19:59:05 -0700
Subject: [PATCH 01/12] feat(native-chat): edit a queued message in place on
the host
A queued card's Edit used to copy its text into the composer and delete the
card, so the message lost its place, an agent-mail card lost its sender, and
while a question or approval replaced the composer the text vanished.
The execution host now edits the card itself:
- agentSession.queuedMessageUpdate replaces only the card's text, as a
compare-and-set on the body fingerprint the client last saw. It keeps the
id, position, sender, kind, send mode and attachments; a command card is not
editable. A resent Save answers from the card as it stands (unchanged or
changed), so a lost answer never overwrites newer text.
- agentSession.queuedMessageEditHold acquires, renews and releases an
in-memory edit lease per caller and edit. While one is live, automatic
delivery stops at the card (earlier cards still send, later ones wait); an
explicit Send still overrides. Leases expire after two minutes on their own,
re-derive validity from the card on every read, and die with the
conversation. Release uses cleanup admission.
- The published card carries an optional editHeld marker, never `paused`.
- Both methods sit behind the new agent-session.queued-message-edit.v1
capability, independent of the dark mid-turn queue capability, and are on
the mobile allowlist for the phone follow-up.
---
.../journal-queued-messages.ts | 26 +-
.../agent-session-journal/journal-store.ts | 1 +
.../queued-message-edit-leases.test.ts | 145 +++++++++
.../queued-message-edit-leases.ts | 172 ++++++++++
.../queued-message-not-consumable.ts | 11 +
.../queued-message-pause.test.ts | 2 +-
.../queued-message-pause.ts | 13 +-
.../queued-message-store.test.ts | 7 +-
.../queued-message-text-update.test.ts | 149 +++++++++
.../queued-message-text-update.ts | 66 ++++
.../structured-agent-session-host.ts | 4 +-
...ructured-agent-session-queued-edit.test.ts | 307 ++++++++++++++++++
.../structured-agent-session-queued-edit.ts | 123 +++++++
...tructured-agent-session-queued-messages.ts | 17 +-
...ructured-agent-session-queued-mutations.ts | 4 +-
...ctured-agent-session-queued-publication.ts | 15 +-
...ed-agent-session-queued-published-bytes.ts | 8 +-
.../structured-agent-session-queued-wiring.ts | 36 +-
src/main/runtime/mobile-rpc-allowlist.test.ts | 2 +
...ructured-agent-session-queued-edit.test.ts | 88 +++++
...structured-agent-session-queued-methods.ts | 29 +-
.../structured-agent-session-schemas.ts | 4 +
...untime-rpc-mobile-agent-session-methods.ts | 2 +
.../agent-session-queued-message-wire.ts | 23 ++
src/shared/protocol-version.ts | 7 +
src/shared/queued-message-text-edit.ts | 40 +++
.../rpc-params-catalog.generated.ts | 6 +
...ctured-agent-session-queued-edit-params.ts | 27 ++
28 files changed, 1290 insertions(+), 44 deletions(-)
create mode 100644 src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts
create mode 100644 src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts
create mode 100644 src/main/native-chat/agent-session-journal/queued-message-not-consumable.ts
create mode 100644 src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts
create mode 100644 src/main/native-chat/agent-session-journal/queued-message-text-update.ts
create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts
create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts
create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-queued-edit.test.ts
create mode 100644 src/shared/queued-message-text-edit.ts
create mode 100644 src/shared/rpc-contract/structured-agent-session-queued-edit-params.ts
diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts
index 5b0e2580c45e..dacb186bac11 100644
--- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts
+++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts
@@ -47,6 +47,9 @@ import {
import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards'
import type { JournalAttachmentClaim } from './journal-submission-hook'
import type { JournalWriteBody, JournalWriteResult } from './journal-write-queue'
+import { QueuedMessageEditLeases } from './queued-message-edit-leases'
+import { QueuedMessageNotConsumableError } from './queued-message-not-consumable'
+import { updateQueuedMessageText, type QueuedMessageTextUpdate } from './queued-message-text-update'
/** Tombstones must outlive the window in which their operation id could still be admitted as new. */
export const QUEUED_MESSAGE_REPLAY_WINDOW_MS =
@@ -71,6 +74,7 @@ export type JournalQueuedMessagesDeps = {
}
export class JournalQueuedMessages {
+ readonly editLeases = new QueuedMessageEditLeases(() => this.list())
/** Bumped on every draft-table write, so publication memos recompute only when they must. */
private changeRevision = 0
private listed: { revision: number; rows: readonly QueuedMessageRow[] } | null = null
@@ -106,6 +110,15 @@ export class JournalQueuedMessages {
return getQueuedMessage(this.deps.database().db, this.deps.sessionId, messageId)
}
+ /** An in-place text edit; a changed card spends every edit lease on its old text. */
+ update(input: QueuedMessageTextUpdate): Promise> {
+ return this.transact(
+ (db) => updateQueuedMessageText(db, this.deps.sessionId, input),
+ (result) => result.status === 'updated',
+ () => this.editLeases.retire(input.messageId)
+ )
+ }
+
/** Replay receipts for one caller-scoped operation key. */
receipts(settledByOp: string): QueuedMessageRow[] {
return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp)
@@ -290,7 +303,8 @@ export class JournalQueuedMessages {
// this guards any pause-relevant row written off that lane from overtaking a held card.
const cards = listQueuedMessages(db, this.deps.sessionId)
const pauses = this.derivePauses(cards)
- if (nextSendableQueuedCard(pauses, cards)?.messageId !== input.messageId) {
+ const held = this.editLeases.heldIds(cards)
+ if (nextSendableQueuedCard(pauses, cards, held)?.messageId !== input.messageId) {
throw new QueuedMessageNotConsumableError(input.messageId, input.expect)
}
}
@@ -369,13 +383,3 @@ export class JournalQueuedMessages {
).then(() => undefined)
}
}
-
-export class QueuedMessageNotConsumableError extends Error {
- constructor(
- readonly messageId: string,
- readonly expected: 'waiting' | 'returned'
- ) {
- super(`queued message ${messageId} is no longer ${expected}`)
- this.name = 'QueuedMessageNotConsumableError'
- }
-}
diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts
index 06bdfba25fea..a65c382b1135 100644
--- a/src/main/native-chat/agent-session-journal/journal-store.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store.ts
@@ -172,6 +172,7 @@ export class AgentSessionJournal {
/** Refuses every later write and resolves once the admitted ones have landed. Holds no
* connection, so there is nothing to release and nothing that can fail. */
close(): Promise {
+ this.queuedMessages.editLeases.dispose()
this.queue.markClosed()
return this.queue.drain()
}
diff --git a/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts
new file mode 100644
index 000000000000..e712ef18a35e
--- /dev/null
+++ b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts
@@ -0,0 +1,145 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { QueuedMessageEditLeases, QUEUED_MESSAGE_EDIT_LEASE_MS } from './queued-message-edit-leases'
+import type { QueuedMessageRow } from './queued-message-table'
+
+function card(messageId = 'card'): QueuedMessageRow {
+ return {
+ sessionId: 'folder-session',
+ messageId,
+ position: 1,
+ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
+ fingerprint: 'base',
+ createdAt: 1,
+ hostInstance: 'host-a',
+ state: 'waiting',
+ holdReason: null,
+ returnedReason: null,
+ returnedRejection: null,
+ settledAt: null,
+ settledByOp: null,
+ consumedAs: null,
+ carriedFrom: null,
+ queuedAt: null
+ }
+}
+const desktop = { callerKey: 'desktop', editId: 'edit-1' }
+const base = { messageId: 'card', fingerprint: 'base' }
+let rows: QueuedMessageRow[]
+let leases: QueuedMessageEditLeases
+let clock: number
+const wake = vi.fn()
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ clock = 0
+ rows = [card()]
+ wake.mockReset()
+ leases = new QueuedMessageEditLeases(
+ () => rows,
+ () => clock
+ )
+ leases.onDeadline(wake)
+})
+afterEach(() => {
+ leases.dispose()
+ vi.useRealTimers()
+})
+
+function advance(ms: number): void {
+ clock += ms
+ vi.advanceTimersByTime(ms)
+}
+
+describe('queued edit leases', () => {
+ it('a repeated acquire answers the same lease without extending it', () => {
+ expect(leases.acquire(desktop, base)).toMatchObject({ status: 'held', remainingMs: 120_000 })
+ advance(30_000)
+ expect(leases.acquire(desktop, base)).toMatchObject({ status: 'held', remainingMs: 90_000 })
+ })
+
+ it('acquire refuses a card that is gone or whose text moved on', () => {
+ expect(leases.acquire(desktop, { ...base, fingerprint: 'stale' })).toEqual({
+ status: 'changed'
+ })
+ expect(leases.acquire(desktop, { ...base, messageId: 'other' })).toEqual({ status: 'gone' })
+ rows[0]!.state = 'dispatched'
+ expect(leases.acquire(desktop, base)).toEqual({ status: 'gone' })
+ expect(leases.heldIds().size).toBe(0)
+ })
+
+ it('expires on its own and wakes the queue with no other activity', () => {
+ leases.acquire(desktop, base)
+ advance(QUEUED_MESSAGE_EDIT_LEASE_MS - 1)
+ expect(wake).not.toHaveBeenCalled()
+ expect(leases.heldIds().has('card')).toBe(true)
+ advance(1)
+ expect(wake).toHaveBeenCalledOnce()
+ expect(leases.heldIds().size).toBe(0)
+ })
+
+ it('renewal extends the deadline; a lapsed lease is not revived', () => {
+ leases.acquire(desktop, base)
+ advance(100_000)
+ expect(leases.renew(desktop, 'card')).toMatchObject({ status: 'held', remainingMs: 120_000 })
+ advance(119_999)
+ expect(wake).not.toHaveBeenCalled()
+ advance(1)
+ expect(wake).toHaveBeenCalledOnce()
+ expect(leases.renew(desktop, 'card')).toEqual({ status: 'expired' })
+ })
+
+ it('release frees only the exact caller and edit, never another editor on the same card', () => {
+ leases.acquire(desktop, base)
+ leases.acquire({ callerKey: 'phone', editId: 'edit-1' }, base)
+ leases.acquire({ ...desktop, editId: 'edit-2' }, base)
+ leases.release(desktop, 'card')
+ leases.release(desktop, 'card')
+ expect(leases.heldIds().has('card')).toBe(true)
+ leases.release({ callerKey: 'phone', editId: 'edit-1' }, 'card')
+ expect(leases.heldIds().has('card')).toBe(true)
+ leases.release({ ...desktop, editId: 'edit-2' }, 'card')
+ expect(leases.heldIds().size).toBe(0)
+ })
+
+ it.each(['text changed', 'sent', 'deleted', 'handed off and returned'] as const)(
+ 'every read re-derives: a card whose %s holds nothing',
+ (change) => {
+ leases.acquire(desktop, base)
+ const row = rows[0]!
+ if (change === 'text changed') {
+ row.fingerprint = 'newer'
+ } else if (change === 'sent') {
+ row.state = 'dispatched'
+ } else if (change === 'deleted') {
+ rows = []
+ } else {
+ Object.assign(row, { state: 'returned', consumedAs: 'submission-2' })
+ }
+ expect(leases.heldIds().size).toBe(0)
+ expect(leases.renew(desktop, 'card')).toEqual({ status: 'expired' })
+ }
+ )
+
+ it('a save retires every lease on the card; others stay', () => {
+ rows.push(card('second'))
+ leases.acquire(desktop, base)
+ leases.acquire({ callerKey: 'phone', editId: 'edit-9' }, base)
+ leases.acquire({ ...desktop, editId: 'edit-2' }, { ...base, messageId: 'second' })
+ leases.retire('card')
+ expect([...leases.heldIds()]).toEqual(['second'])
+ })
+
+ it('reads the rows it is given inside a transaction', () => {
+ leases.acquire(desktop, base)
+ expect(leases.heldIds([{ ...card(), state: 'withdrawn' }]).size).toBe(0)
+ })
+
+ it('dispose drops every lease and deadline; a closed handle grants none', () => {
+ leases.acquire(desktop, base)
+ leases.dispose()
+ advance(QUEUED_MESSAGE_EDIT_LEASE_MS)
+ expect(wake).not.toHaveBeenCalled()
+ expect(leases.acquire(desktop, base)).toEqual({ status: 'gone' })
+ expect(vi.getTimerCount()).toBe(0)
+ })
+})
diff --git a/src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts
new file mode 100644
index 000000000000..d25b749fdc3d
--- /dev/null
+++ b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts
@@ -0,0 +1,172 @@
+// In-memory edit leases for queued cards: while a person edits a card in place, automatic
+// delivery stops at it (`nextSendableQueuedCard`). Nothing here is durable. Each lease dies on
+// its own deadline, and every read re-derives validity from the card it holds, so a lost
+// release, a send, a delete or another client's save never leaves a card held.
+
+import type { AgentSessionQueuedMessageEditHoldResult } from '../../../shared/agent-session-wire'
+import { isUnsettledQueuedMessage, type QueuedMessageRow } from './queued-message-table'
+
+export const QUEUED_MESSAGE_EDIT_LEASE_MS = 120_000
+
+type EditLeaseKey = { callerKey: string; editId: string }
+
+type EditLease = Pick & {
+ until: number
+}
+
+/** One per journal handle: closing the conversation drops every lease with it. */
+export class QueuedMessageEditLeases {
+ private readonly leases = new Map()
+ private timer: ReturnType | undefined
+ private timerAt: number | null = null
+ private wake: (() => void) | undefined
+ private disposed = false
+
+ constructor(
+ private readonly rows: () => readonly QueuedMessageRow[],
+ private readonly now: () => number = () => performance.now()
+ ) {}
+
+ /** Called once a deadline passes, so the queue re-derives with no other journal activity. */
+ onDeadline(wake: () => void): void {
+ this.wake = wake
+ }
+
+ /** Cards a live lease holds. `rows` is the caller's own read inside a transaction. */
+ heldIds(rows: readonly QueuedMessageRow[] = this.rows()): ReadonlySet {
+ if (this.leases.size === 0) {
+ return new Set()
+ }
+ const byId = new Map(rows.map((row) => [row.messageId, row]))
+ const now = this.now()
+ for (const [key, lease] of this.leases) {
+ const row = byId.get(lease.messageId)
+ if (lease.until <= now || !row || !leaseMatchesRow(lease, row)) {
+ this.leases.delete(key)
+ }
+ }
+ this.schedule()
+ return new Set([...this.leases.values()].map((lease) => lease.messageId))
+ }
+
+ /** Idempotent per caller and edit: a repeat answers the same lease without extending it. */
+ acquire(
+ key: EditLeaseKey,
+ input: { messageId: string; fingerprint: string }
+ ): AgentSessionQueuedMessageEditHoldResult {
+ this.heldIds()
+ const existing = this.leases.get(leaseKey(key))
+ if (existing) {
+ return existing.messageId === input.messageId && existing.fingerprint === input.fingerprint
+ ? this.answer(existing)
+ : { status: 'changed' }
+ }
+ const row = this.rows().find((entry) => entry.messageId === input.messageId)
+ if (this.disposed || !row || !isUnsettledQueuedMessage(row)) {
+ return { status: 'gone' }
+ }
+ if (row.fingerprint !== input.fingerprint) {
+ return { status: 'changed' }
+ }
+ const lease: EditLease = {
+ messageId: row.messageId,
+ fingerprint: row.fingerprint,
+ state: row.state,
+ consumedAs: row.consumedAs,
+ until: this.now() + QUEUED_MESSAGE_EDIT_LEASE_MS
+ }
+ this.leases.set(leaseKey(key), lease)
+ this.schedule()
+ return this.answer(lease)
+ }
+
+ /** A lease that lapsed or lost its card is not revived: the editor acquires again. */
+ renew(key: EditLeaseKey, messageId: string): AgentSessionQueuedMessageEditHoldResult {
+ this.heldIds()
+ const lease = this.leases.get(leaseKey(key))
+ if (!lease || lease.messageId !== messageId) {
+ return { status: 'expired' }
+ }
+ lease.until = this.now() + QUEUED_MESSAGE_EDIT_LEASE_MS
+ this.schedule()
+ return this.answer(lease)
+ }
+
+ /** Only the exact caller and edit release: never another editor's lease on the same card. */
+ release(key: EditLeaseKey, messageId: string): void {
+ if (this.leases.get(leaseKey(key))?.messageId === messageId) {
+ this.leases.delete(leaseKey(key))
+ this.schedule()
+ }
+ }
+
+ /** A save changed the card: every lease on its old text is spent. */
+ retire(messageId: string): void {
+ for (const [key, lease] of this.leases) {
+ if (lease.messageId === messageId) {
+ this.leases.delete(key)
+ }
+ }
+ this.schedule()
+ }
+
+ dispose(): void {
+ this.disposed = true
+ this.leases.clear()
+ this.schedule()
+ }
+
+ private answer(lease: EditLease): AgentSessionQueuedMessageEditHoldResult {
+ return {
+ status: 'held',
+ fingerprint: lease.fingerprint,
+ leaseDurationMs: QUEUED_MESSAGE_EDIT_LEASE_MS,
+ remainingMs: Math.max(0, lease.until - this.now())
+ }
+ }
+
+ /** One timer, at the nearest deadline; unchanged when that deadline is. */
+ private schedule(): void {
+ const next =
+ this.leases.size === 0 ? null : Math.min(...[...this.leases.values()].map((l) => l.until))
+ if (next === this.timerAt) {
+ return
+ }
+ clearTimeout(this.timer)
+ this.timer = undefined
+ this.timerAt = next
+ if (next === null) {
+ return
+ }
+ this.timer = setTimeout(
+ () => {
+ this.timer = undefined
+ this.timerAt = null
+ const now = this.now()
+ for (const [key, lease] of this.leases) {
+ if (lease.until <= now) {
+ this.leases.delete(key)
+ }
+ }
+ this.schedule()
+ this.wake?.()
+ },
+ Math.max(1, next - this.now())
+ )
+ this.timer.unref?.()
+ }
+}
+
+function leaseKey(key: EditLeaseKey): string {
+ return JSON.stringify([key.callerKey, key.editId])
+}
+
+/** A hand-off or a return in between re-scopes the card even when its text is unchanged. */
+function leaseMatchesRow(lease: EditLease, row: QueuedMessageRow): boolean {
+ return (
+ isUnsettledQueuedMessage(row) &&
+ row.fingerprint === lease.fingerprint &&
+ row.state === lease.state &&
+ row.consumedAs === lease.consumedAs
+ )
+}
diff --git a/src/main/native-chat/agent-session-journal/queued-message-not-consumable.ts b/src/main/native-chat/agent-session-journal/queued-message-not-consumable.ts
new file mode 100644
index 000000000000..86ae3dbc41a6
--- /dev/null
+++ b/src/main/native-chat/agent-session-journal/queued-message-not-consumable.ts
@@ -0,0 +1,11 @@
+/** A consume's compare-and-set lost: the card is no longer in the state the caller read, so the
+ * whole append it rode in rolls back. */
+export class QueuedMessageNotConsumableError extends Error {
+ constructor(
+ readonly messageId: string,
+ readonly expected: 'waiting' | 'returned'
+ ) {
+ super(`queued message ${messageId} is no longer ${expected}`)
+ this.name = 'QueuedMessageNotConsumableError'
+ }
+}
diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts
index dabc1d64335d..ea649c17809f 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts
@@ -17,7 +17,7 @@ import {
liveTestJournalRows,
updateTestJournalRowJson
} from './journal-host-database-test-support'
-import { QueuedMessageNotConsumableError } from './journal-queued-messages'
+import { QueuedMessageNotConsumableError } from './queued-message-not-consumable'
import { applyJournalRow, createJournalReducerState } from './journal-reducer'
import { parseJournalRow } from './journal-row-schema'
import type { AgentSessionJournal } from './journal-store'
diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts
index a7a652d81b7c..a561782cd8b0 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts
@@ -217,19 +217,20 @@ export function queuePauseHolding(
}
/** The card the queue sends next: the oldest waiting one with no hold of its own, unless a
- * returned card or one a pause holds comes first. The queue never reorders, so a newer card never
- * overtakes one a pause holds; a card held on its own is passed over. The drain's pick and its
- * consume both read this. */
-export function nextSendableQueuedCard(
+ * returned card, one a pause holds, or one being edited (`editHeld`) comes first. The queue never
+ * reorders, so a newer card never overtakes either; a card held on its own is passed over. The
+ * drain's pick and its consume both read this. */
+export function nextSendableQueuedCard(
pauses: readonly DerivedQueuePause[],
- cards: readonly T[]
+ cards: readonly T[],
+ editHeld: ReadonlySet = new Set()
): T | null {
for (const card of cards) {
if (card.state === 'returned' || queuePauseHolding(pauses, card)) {
return null
}
if (card.state === 'waiting' && card.holdReason === null) {
- return card
+ return editHeld.has(card.messageId) ? null : card
}
}
return null
diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts
index 02e07bc53ee6..b63cac121fee 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts
@@ -16,11 +16,8 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure-
import Database from '../../sqlite/sync-database'
import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
import { journalDatabasePath } from './journal-host-database'
-import {
- JournalQueuedMessages,
- QUEUED_MESSAGE_REPLAY_WINDOW_MS,
- QueuedMessageNotConsumableError
-} from './journal-queued-messages'
+import { JournalQueuedMessages, QUEUED_MESSAGE_REPLAY_WINDOW_MS } from './journal-queued-messages'
+import { QueuedMessageNotConsumableError } from './queued-message-not-consumable'
import type { AgentSessionJournal } from './journal-store'
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
import {
diff --git a/src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts b/src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts
new file mode 100644
index 000000000000..e9b57abf158d
--- /dev/null
+++ b/src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts
@@ -0,0 +1,149 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import Database from '../../sqlite/sync-database'
+import { ensureQueuedMessagesTable } from './queued-message-schema'
+import { getQueuedMessage, insertQueuedMessage } from './queued-message-table'
+import { updateQueuedMessageText } from './queued-message-text-update'
+import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
+import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation'
+import { MAX_PROMPT_BYTES } from '../../../shared/rpc-contract/structured-agent-session-params'
+
+let db: Database.Database
+const sessionId = 'folder-session'
+const initial: AgentJournalMessageItem = {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: 'base' }]
+}
+beforeEach(() => {
+ db = new Database(':memory:')
+ ensureQueuedMessagesTable(db)
+})
+afterEach(() => db.close())
+function insert(body = initial, messageId = 'card') {
+ return insertQueuedMessage(db, {
+ sessionId,
+ messageId,
+ body,
+ fingerprint: agentSessionSendBodyFingerprint(sessionId, body),
+ hostInstance: 'remote-host',
+ carriedFrom: 'old-folder-session',
+ queuedAt: { epoch: 'epoch', sequence: 4 },
+ now: 1,
+ holdReason: 'send_failed'
+ })
+}
+function update(text: string, fingerprint = agentSessionSendBodyFingerprint(sessionId, initial)) {
+ return updateQueuedMessageText(db, sessionId, {
+ messageId: 'card',
+ expectedBodyFingerprint: fingerprint,
+ text
+ })
+}
+
+describe('atomic queued text update', () => {
+ it('preserves identity, cursor, order, sender, kind, role, send mode and attachment order', () => {
+ const body: AgentJournalMessageItem = {
+ ...initial,
+ sentAs: 'goal',
+ from: { kind: 'agent', senders: [], orchestration: null },
+ blocks: [
+ { type: 'image-ref', path: 'before.png' },
+ { type: 'text', text: 'one' },
+ { type: 'image-ref', url: 'https://example.com/image.png' },
+ { type: 'text', text: 'two' }
+ ]
+ }
+ const before = insert(body)
+ expect(update('edited', before.fingerprint).status).toBe('updated')
+ const after = getQueuedMessage(db, sessionId, 'card')!
+ expect(after).toEqual({
+ ...before,
+ body: {
+ ...body,
+ blocks: [body.blocks[0]!, { type: 'text', text: 'edited' }, body.blocks[2]!]
+ },
+ fingerprint: after.fingerprint
+ })
+ expect(after.fingerprint).toBe(agentSessionSendBodyFingerprint(sessionId, after.body))
+ })
+ it('retains returned-card refusal and failure hold rather than requeueing', () => {
+ insert()
+ db.prepare(
+ "UPDATE queued_messages SET state = 'returned', returned_reason = 'refused', consumed_as = 'submission' WHERE message_id = 'card'"
+ ).run()
+ const before = getQueuedMessage(db, sessionId, 'card')!
+ expect(update('edit').status).toBe('updated')
+ expect(getQueuedMessage(db, sessionId, 'card')).toMatchObject({
+ state: 'returned',
+ returnedReason: before.returnedReason,
+ consumedAs: before.consumedAs,
+ holdReason: before.holdReason,
+ position: before.position
+ })
+ })
+ it('checks the resulting body before the expected base, making lost-answer retry safe', () => {
+ insert()
+ expect(update('edit').status).toBe('updated')
+ expect(update('edit').status).toBe('unchanged')
+ expect(update('other').status).toBe('changed')
+ })
+ it('accepts semantic A/B/A and preserves the latest host-owned sender', () => {
+ insert()
+ const base = agentSessionSendBodyFingerprint(sessionId, initial)
+ update('B')
+ const second = getQueuedMessage(db, sessionId, 'card')!
+ update('base', second.fingerprint)
+ const from: NonNullable = {
+ kind: 'agent',
+ senders: [],
+ orchestration: null
+ }
+ const latestBody = { ...initial, from }
+ db.prepare("UPDATE queued_messages SET body_json = ? WHERE message_id = 'card'").run(
+ JSON.stringify(latestBody)
+ )
+ expect(agentSessionSendBodyFingerprint(sessionId, latestBody)).toBe(base)
+ expect(update('C', base).status).toBe('updated')
+ expect(getQueuedMessage(db, sessionId, 'card')?.body.from).toEqual(from)
+ })
+ it('refuses empty, oversized, command and unsupported bodies without modifying them', () => {
+ insert()
+ expect(update(' ').status).toBe('not-editable')
+ expect(update('é'.repeat(MAX_PROMPT_BYTES)).status).toBe('not-editable')
+ db.prepare('DELETE FROM queued_messages').run()
+ const command = insert({ ...initial, command: { name: 'compact' } })
+ expect(update('edit', command.fingerprint).status).toBe('not-editable')
+ expect(getQueuedMessage(db, sessionId, 'card')).toEqual(command)
+ db.prepare('DELETE FROM queued_messages').run()
+ const unsupported = insert({
+ ...initial,
+ blocks: [{ type: 'tool-result', callId: 'tool', output: 'retained' }]
+ })
+ expect(update('edit', unsupported.fingerprint).status).toBe('not-editable')
+ expect(getQueuedMessage(db, sessionId, 'card')).toEqual(unsupported)
+ })
+ it('allows empty text with a retained attachment, inserting text before it', () => {
+ const before = insert({ ...initial, blocks: [{ type: 'image-ref', path: 'file.png' }] })
+ expect(update('', before.fingerprint).status).toBe('updated')
+ expect(getQueuedMessage(db, sessionId, 'card')?.body.blocks).toEqual([
+ { type: 'text', text: '' },
+ { type: 'image-ref', path: 'file.png' }
+ ])
+ })
+ it('a failed transaction rolls back body and fingerprint together', () => {
+ const before = insert()
+ db.exec('BEGIN IMMEDIATE')
+ update('edit')
+ db.exec('ROLLBACK')
+ expect(getQueuedMessage(db, sessionId, 'card')).toEqual(before)
+ })
+ it('dispatch, withdrawal and a pruned tombstone all answer gone', () => {
+ insert()
+ db.prepare("UPDATE queued_messages SET state = 'dispatched' WHERE message_id = 'card'").run()
+ expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'dispatched' })
+ db.prepare("UPDATE queued_messages SET state = 'withdrawn' WHERE message_id = 'card'").run()
+ expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'withdrawn' })
+ db.prepare('DELETE FROM queued_messages').run()
+ expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'missing' })
+ })
+})
diff --git a/src/main/native-chat/agent-session-journal/queued-message-text-update.ts b/src/main/native-chat/agent-session-journal/queued-message-text-update.ts
new file mode 100644
index 000000000000..1c631b37c82f
--- /dev/null
+++ b/src/main/native-chat/agent-session-journal/queued-message-text-update.ts
@@ -0,0 +1,66 @@
+import type Database from '../../sqlite/sync-database'
+import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
+import type { AgentSessionQueuedMessageUpdateResult } from '../../../shared/agent-session-wire'
+import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation'
+import { queuedMessageWithEditedText } from '../../../shared/queued-message-text-edit'
+import {
+ getQueuedMessage,
+ isUnsettledQueuedMessage,
+ type QueuedMessageRow
+} from './queued-message-table'
+
+export type QueuedMessageTextUpdate = {
+ messageId: string
+ expectedBodyFingerprint: string
+ text: string
+}
+
+/** Compare-and-set on the body fingerprint. The resulting body is checked first, so a retried
+ * Save whose first answer was lost reads `unchanged`, and one a newer edit superseded reads
+ * `changed`: a retry never overwrites newer text. */
+export function queuedMessageTextUpdateDecision(
+ row: QueuedMessageRow | null,
+ sessionId: string,
+ input: QueuedMessageTextUpdate
+):
+ | AgentSessionQueuedMessageUpdateResult
+ | { status: 'ready'; body: AgentJournalMessageItem; fingerprint: string } {
+ const { messageId } = input
+ if (!row || !isUnsettledQueuedMessage(row)) {
+ const disposition = !row ? 'missing' : row.state === 'dispatched' ? 'dispatched' : 'withdrawn'
+ return { status: 'gone', messageId, disposition }
+ }
+ const body = queuedMessageWithEditedText(row.body, input.text)
+ if (!body) {
+ return { status: 'not-editable', messageId }
+ }
+ const fingerprint = agentSessionSendBodyFingerprint(sessionId, body)
+ if (row.fingerprint === fingerprint) {
+ return { status: 'unchanged', messageId, fingerprint }
+ }
+ if (row.fingerprint !== input.expectedBodyFingerprint) {
+ return { status: 'changed', messageId }
+ }
+ return { status: 'ready', body, fingerprint }
+}
+
+/** Inside the journal's draft transaction; only body and fingerprint change, so the card keeps
+ * its id, position, state, holds and hand-off link. */
+export function updateQueuedMessageText(
+ db: Database.Database,
+ sessionId: string,
+ input: QueuedMessageTextUpdate
+): AgentSessionQueuedMessageUpdateResult {
+ const decision = queuedMessageTextUpdateDecision(
+ getQueuedMessage(db, sessionId, input.messageId),
+ sessionId,
+ input
+ )
+ if (decision.status !== 'ready') {
+ return decision
+ }
+ db.prepare(
+ 'UPDATE queued_messages SET body_json = ?, fingerprint = ? WHERE session_id = ? AND message_id = ?'
+ ).run(JSON.stringify(decision.body), decision.fingerprint, sessionId, input.messageId)
+ return { status: 'updated', messageId: input.messageId, fingerprint: decision.fingerprint }
+}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
index 1a08095a82ec..f9ab7df5c20d 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
@@ -65,7 +65,7 @@ export class StructuredAgentSessionHost {
},
deliverSettleEdge: (id, journal) => this.conversationDelivery.afterSettleEdge(id, journal),
logger: sessionLogger.deferredStructuredAgentSessionLogger(() => this.deps.logger),
- onOpened: (sessionId) => this.queued.drain.schedule(sessionId),
+ onOpened: (sessionId) => this.queued.onOpened(sessionId),
now: () => this.now()
})
private readonly queued = wireStructuredAgentSessionQueuedMessages(this.sessions, () =>
@@ -301,6 +301,8 @@ export class StructuredAgentSessionHost {
queuedMessageSend = this.queued.queuedMessageSend
queuedMessageDelete = this.queued.queuedMessageDelete
queuedMessagesResume = this.queued.queuedMessagesResume
+ queuedMessageUpdate = this.queued.queuedMessageUpdate
+ queuedMessageEditHold = this.queued.queuedMessageEditHold
waitForSendSettlement = this.clientDelivery.waitForSendSettlement
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts
new file mode 100644
index 000000000000..82e7bca7bd9a
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts
@@ -0,0 +1,307 @@
+// Editing a queued card in place, through the real host, journal and drain: Save keeps the card's
+// identity, an edit lease stops automatic delivery at the card (and so at everything behind it),
+// and no lease outlives its deadline, its card, or its conversation.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ createQueuedMessageTestRig,
+ eventually,
+ QUEUED_RIG_CALLER,
+ type QueuedMessageTestRig
+} from './structured-agent-session-queued-message-rig.test-fixture'
+import {
+ HOST_TEST_SESSION,
+ hostTestMessage,
+ hostTestOperationId
+} from './structured-agent-session-host-test-data'
+import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation'
+import {
+ readQueuePublication,
+ structuredQueueSendGate
+} from './structured-agent-session-queued-publication'
+import { shouldQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages'
+import { QUEUED_MESSAGES_PUBLISHED_MAX_BYTES } from './structured-agent-session-queued-published-bytes'
+import { QUEUED_MESSAGE_EDIT_LEASE_MS } from '../agent-session-journal/queued-message-edit-leases'
+
+let rig: QueuedMessageTestRig
+beforeEach(async () => {
+ rig = await createQueuedMessageTestRig()
+})
+afterEach(() => rig.dispose())
+
+function journal() {
+ const value = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal
+ if (!value) {
+ throw new Error('missing test conversation')
+ }
+ return value
+}
+async function queue(text: string, options?: { internal: true }) {
+ const result = await rig.send(text, 'queue-if-active', options).result
+ if (!result.ok || !('queued' in result.value)) {
+ throw new Error('not queued')
+ }
+ return result.value.queued.messageId
+}
+function fingerprint(id: string) {
+ const card = journal().queuedMessages.get(id)
+ if (!card) {
+ throw new Error('missing card')
+ }
+ return card.fingerprint
+}
+function update(messageId: string, expected: string, text: string, operationId?: string) {
+ const fields = { messageId, expectedBodyFingerprint: expected, text }
+ return rig.host.queuedMessageUpdate(QUEUED_RIG_CALLER, {
+ envelope: rig.envelope(
+ fields,
+ 'agentSession.queuedMessageUpdate',
+ operationId ?? hostTestOperationId()
+ ),
+ ...fields
+ })
+}
+function hold(
+ messageId: string,
+ action: 'acquire' | 'renew' | 'release' = 'acquire',
+ editId = 'edit-1',
+ caller = QUEUED_RIG_CALLER
+) {
+ const common = { sessionId: HOST_TEST_SESSION, messageId, editId }
+ return rig.host.queuedMessageEditHold(
+ caller,
+ action === 'acquire'
+ ? { ...common, action, expectedBodyFingerprint: fingerprint(messageId) }
+ : { ...common, action }
+ )
+}
+function publication() {
+ return readQueuePublication(journal(), structuredQueueSendGate(rig.store, HOST_TEST_SESSION))
+}
+function published(id: string) {
+ return publication().queuedMessages.find((message) => message.messageId === id)
+}
+
+describe('saving a queued card in place', () => {
+ it('keeps id, position and sender; text that reads like a command stays text', async () => {
+ await rig.workingSend()
+ const from = { kind: 'agent' as const, senders: [], orchestration: null }
+ const queued = await rig.send('original', 'queue-if-active', { internal: true, from }).result
+ if (!queued.ok || !('queued' in queued.value)) {
+ throw new Error('not queued')
+ }
+ const id = queued.value.queued.messageId
+ const before = journal().queuedMessages.get(id)!
+ expect(await update(id, before.fingerprint, '/compact')).toMatchObject({
+ ok: true,
+ value: { status: 'updated' }
+ })
+ const body = { ...before.body, blocks: [{ type: 'text' as const, text: '/compact' }] }
+ expect(journal().queuedMessages.get(id)).toEqual({
+ ...before,
+ body,
+ fingerprint: agentSessionSendBodyFingerprint(HOST_TEST_SESSION, body)
+ })
+ expect(published(id)?.body).toEqual(body)
+ })
+
+ it('a resent Save reads the card as it stands and never overwrites newer text', async () => {
+ await rig.workingSend()
+ const id = await queue('base')
+ const base = fingerprint(id)
+ const operation = hostTestOperationId()
+ expect(await update(id, base, 'mine', operation)).toMatchObject({
+ value: { status: 'updated' }
+ })
+ expect(await update(id, base, 'mine', operation)).toMatchObject({
+ value: { status: 'unchanged' }
+ })
+ expect(await update(id, fingerprint(id), 'newer')).toMatchObject({
+ value: { status: 'updated' }
+ })
+ expect(await update(id, base, 'mine', operation)).toMatchObject({
+ value: { status: 'changed' }
+ })
+ expect(await update(id, base, 'another draft')).toMatchObject({
+ value: { status: 'changed' }
+ })
+ expect(journal().queuedMessages.get(id)?.body.blocks).toEqual([{ type: 'text', text: 'newer' }])
+ })
+
+ it('after Send or Delete elsewhere, Save answers gone and recreates nothing', async () => {
+ await rig.workingSend()
+ const deleted = await queue('deleted')
+ const sent = await queue('sent')
+ const bases = [fingerprint(deleted), fingerprint(sent)] as const
+ await rig.deleteQueued(deleted)
+ await rig.sendNow(sent)
+ expect(await update(deleted, bases[0], 'edit')).toMatchObject({
+ value: { status: 'gone', disposition: 'withdrawn' }
+ })
+ expect(await update(sent, bases[1], 'edit')).toMatchObject({
+ value: { status: 'gone', disposition: 'dispatched' }
+ })
+ expect(await rig.drafts()).toEqual([])
+ })
+
+ it('counts the edited card once against the queue size bound', async () => {
+ await rig.workingSend()
+ const big = await queue('x'.repeat(QUEUED_MESSAGES_PUBLISHED_MAX_BYTES - 120 * 1024))
+ const small = await queue('small')
+ expect(await update(small, fingerprint(small), 'y'.repeat(200 * 1024))).toMatchObject({
+ ok: false,
+ refusal: { details: { reason: 'queueTooLarge' } }
+ })
+ expect(journal().queuedMessages.get(small)?.body.blocks).toEqual([
+ { type: 'text', text: 'small' }
+ ])
+ expect(await update(big, fingerprint(big), 'z'.repeat(250 * 1024))).toMatchObject({
+ ok: true,
+ value: { status: 'updated' }
+ })
+ })
+
+ it('a command card is not editable', async () => {
+ await rig.workingSend()
+ const body = { ...hostTestMessage('/compact'), command: { name: 'compact' } }
+ const command = await journal().queuedMessages.insert({
+ messageId: 'command-card',
+ body,
+ fingerprint: agentSessionSendBodyFingerprint(HOST_TEST_SESSION, body),
+ hostInstance: 'host'
+ })
+ expect(await hold(command.messageId)).toEqual({ status: 'not-editable' })
+ expect(await update(command.messageId, command.fingerprint, 'text')).toMatchObject({
+ value: { status: 'not-editable' }
+ })
+ })
+})
+
+describe('edit leases and automatic delivery', () => {
+ it('earlier cards deliver; the edited card and those behind it wait; a new send still queues', async () => {
+ const work = await rig.workingSend()
+ const first = await queue('first')
+ const edited = await queue('edited')
+ const last = await queue('last')
+ expect(await hold(edited)).toMatchObject({ status: 'held', leaseDurationMs: 120_000 })
+ expect(published(edited)?.editHeld).toBe(true)
+ expect(published(edited)?.paused).toBeUndefined()
+ await rig.settleAccepted(work, 'work-done')
+ await eventually(async () => expect(await rig.handoff(first)).toBeDefined())
+ await rig.settleAccepted(await rig.handoffId(first), 'first-done')
+ expect(publication().nextQueuedMessageId).toBeNull()
+ expect(journal().queuedMessages.get(edited)?.state).toBe('waiting')
+ expect(journal().queuedMessages.get(last)?.state).toBe('waiting')
+ expect(
+ shouldQueueStructuredAgentSessionSend({
+ journal: journal(),
+ ...structuredQueueSendGate(rig.store, HOST_TEST_SESSION)()
+ })
+ ).toBe(true)
+ // An explicit Send still overrides, and spends the lease with the card.
+ expect(await rig.sendNow(edited)).toMatchObject({ ok: true })
+ expect(journal().queuedMessages.editLeases.heldIds().size).toBe(0)
+ })
+
+ it('Save then release lets the edited card drain with its new text', async () => {
+ const work = await rig.workingSend()
+ const id = await queue('before')
+ await hold(id)
+ await rig.settleAccepted(work, 'work-done')
+ expect(await update(id, fingerprint(id), 'after')).toMatchObject({
+ value: { status: 'updated' }
+ })
+ expect(published(id)?.editHeld).toBeUndefined()
+ await hold(id, 'release')
+ await eventually(async () => expect(await rig.handoff(id)).toBeDefined())
+ const submissions = (await rig.host.journalSnapshot(HOST_TEST_SESSION)).submissions
+ expect(submissions.at(-1)?.payloadFingerprint).toBe(
+ agentSessionSendBodyFingerprint(HOST_TEST_SESSION, {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: 'after' }]
+ })
+ )
+ })
+
+ it("one editor's release keeps another's hold; a renewal re-sends nothing", async () => {
+ await rig.workingSend()
+ const id = await queue('base')
+ await hold(id)
+ await hold(id, 'acquire', 'edit-1', { callerKey: 'phone' })
+ const before = publication()
+ expect(await hold(id, 'renew')).toMatchObject({ status: 'held' })
+ expect(publication()).toBe(before)
+ expect(await hold(id, 'release')).toEqual({ status: 'released' })
+ expect(published(id)?.editHeld).toBe(true)
+ await hold(id, 'release', 'edit-1', { callerKey: 'phone' })
+ expect(published(id)?.editHeld).toBeUndefined()
+ })
+
+ it('acquiring after delivery won answers gone; Save without a lease still works', async () => {
+ await rig.workingSend()
+ const id = await queue('base')
+ expect(await update(id, fingerprint(id), 'saved')).toMatchObject({
+ value: { status: 'updated' }
+ })
+ await rig.sendNow(id)
+ expect(
+ await rig.host.queuedMessageEditHold(QUEUED_RIG_CALLER, {
+ sessionId: HOST_TEST_SESSION,
+ messageId: id,
+ editId: 'late',
+ action: 'acquire',
+ expectedBodyFingerprint: 'f'.repeat(64)
+ })
+ ).toEqual({ status: 'gone' })
+ })
+
+ it('a lapsed lease wakes delivery with no journal activity, even when publishing fails', async () => {
+ const work = await rig.workingSend()
+ const id = await queue('edited')
+ let clock = 0
+ const time = vi.spyOn(performance, 'now').mockImplementation(() => clock)
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+ try {
+ await hold(id)
+ await rig.settleAccepted(work, 'work-done')
+ await vi.advanceTimersByTimeAsync(0)
+ expect(publication().nextQueuedMessageId).toBeNull()
+ const publish = vi
+ .spyOn(rig.host.collaboratorsForTests().subscribers, 'publish')
+ .mockImplementationOnce(() => {
+ throw new Error('publication failed')
+ })
+ clock = QUEUED_MESSAGE_EDIT_LEASE_MS
+ await vi.advanceTimersByTimeAsync(QUEUED_MESSAGE_EDIT_LEASE_MS)
+ publish.mockRestore()
+ vi.useRealTimers()
+ await eventually(async () => expect(await rig.handoff(id)).toBeDefined())
+ } finally {
+ vi.useRealTimers()
+ time.mockRestore()
+ }
+ })
+
+ it('Stop and Resume leave the edit hold alone and never publish it as a pause', async () => {
+ await rig.workingSend()
+ const id = await queue('base')
+ await hold(id)
+ await rig.stop()
+ await rig.resume()
+ expect(published(id)).toMatchObject({ editHeld: true })
+ expect(published(id)?.paused).toBeUndefined()
+ expect(publication().nextQueuedMessageId).toBeNull()
+ })
+
+ it('release never opens a closed conversation; closing drops every lease', async () => {
+ await rig.workingSend()
+ const id = await queue('base')
+ await hold(id)
+ const leases = journal().queuedMessages.editLeases
+ await rig.host.close(HOST_TEST_SESSION, 'evict')
+ expect(leases.heldIds([]).size).toBe(0)
+ expect(await hold(id, 'release')).toEqual({ status: 'released' })
+ expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)).toBeUndefined()
+ })
+})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts
new file mode 100644
index 000000000000..6a17d8851f7f
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts
@@ -0,0 +1,123 @@
+// Editing a queued card in place: Save is a compare-and-set on the card's body fingerprint, and
+// an edit lease keeps automatic delivery from sending the card while someone types. The card keeps
+// its id, position, sender and attachments; only its text changes.
+
+import type {
+ AgentSessionMutationEnvelope,
+ AgentSessionMutationResult,
+ AgentSessionQueuedMessageEditHoldParams,
+ AgentSessionQueuedMessageEditHoldResult,
+ AgentSessionQueuedMessageUpdateResult
+} from '../../../shared/agent-session-wire'
+import { queuedMessageEditableText } from '../../../shared/queued-message-text-edit'
+import { queuedMessageTextUpdateDecision } from '../agent-session-journal/queued-message-text-update'
+import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context'
+import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types'
+import type { MutationPlan } from './structured-agent-session-mutation-plans'
+import { mutateQueued } from './structured-agent-session-queued-mutations'
+import { queuedMessagesPublishedBytesRefusal } from './structured-agent-session-queued-published-bytes'
+
+/** Save. Editing sends nothing, so it pays no send admission, like Delete. A resend under the
+ * same id simply runs again: the compare-and-set answers it from the card as it stands. */
+export function updateQueuedStructuredAgentMessage(
+ context: StructuredAgentSessionMutationContext,
+ caller: StructuredAgentSessionCaller,
+ params: {
+ envelope: AgentSessionMutationEnvelope
+ messageId: string
+ expectedBodyFingerprint: string
+ text: string
+ }
+): Promise> {
+ const { envelope, ...fields } = params
+ const plan: MutationPlan = {
+ method: 'agentSession.queuedMessageUpdate',
+ fields,
+ conversationWrite: true,
+ run: async (ctx) => {
+ const queued = ctx.journal.queuedMessages
+ const decision = queuedMessageTextUpdateDecision(
+ queued.get(fields.messageId),
+ ctx.sessionId,
+ fields
+ )
+ if (decision.status !== 'ready') {
+ return { ok: true, value: decision }
+ }
+ // Editing an agent's card is still the person's action, so it may use their reserve.
+ const refusal = queuedMessagesPublishedBytesRefusal(
+ ctx.journal,
+ decision.body,
+ true,
+ fields.messageId
+ )
+ return refusal ? { ok: false, refusal } : { ok: true, value: await queued.update(fields) }
+ },
+ replay: () => null,
+ rerunWhenReplayMissing: () => true
+ }
+ return mutateQueued(context, caller, envelope, plan)
+}
+
+/** Acquire, renew and release an edit lease. Ephemeral: no ledger row, no agent started, and a
+ * release never opens a conversation (a closed one already dropped its leases). */
+export function holdQueuedStructuredAgentMessageEdit(
+ context: StructuredAgentSessionMutationContext,
+ caller: StructuredAgentSessionCaller,
+ params: AgentSessionQueuedMessageEditHoldParams,
+ touch: (sessionId: string) => void
+): Promise {
+ const { sessionId, messageId } = params
+ const key = { callerKey: caller.callerKey, editId: params.editId }
+ return context.serialize(sessionId, async () => {
+ if (params.action === 'release') {
+ const journal = context.sessions.get(sessionId)?.journal
+ journal?.queuedMessages.editLeases.release(key, messageId)
+ if (journal) {
+ republish(context, sessionId, journal)
+ }
+ return { status: 'released' }
+ }
+ const session = await context.openConversation(sessionId)
+ if (!session) {
+ return { status: 'gone' }
+ }
+ // An open editor is activity: the idle sweep must not close the conversation under it.
+ touch(sessionId)
+ const queued = session.journal.queuedMessages
+ let result: AgentSessionQueuedMessageEditHoldResult
+ if (params.action === 'acquire') {
+ const row = queued.get(messageId)
+ result =
+ row && queuedMessageEditableText(row.body) === null
+ ? { status: 'not-editable' }
+ : queued.editLeases.acquire(key, {
+ messageId,
+ fingerprint: params.expectedBodyFingerprint
+ })
+ } else {
+ result = queued.editLeases.renew(key, messageId)
+ }
+ republish(context, sessionId, session.journal)
+ return result
+ })
+}
+
+/** Leases live outside the journal, so no commit announces them: publish the held marker and
+ * wake the drain here. A failed publish is logged; the lease still stands. */
+export function republish(
+ context: StructuredAgentSessionMutationContext,
+ sessionId: string,
+ journal: Parameters[1]
+): void {
+ try {
+ context.publish(sessionId, journal)
+ } catch (error) {
+ context.deps.logger.warn('publishing a queued edit hold failed', {
+ scope: 'queued-edit-hold',
+ sessionId,
+ error
+ })
+ }
+ context.wakeQueuedDrain?.(sessionId)
+}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
index 249396d38b5d..212924851a38 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
@@ -22,7 +22,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
-import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages'
+import { QueuedMessageNotConsumableError } from '../agent-session-journal/queued-message-not-consumable'
import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table'
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
import {
@@ -57,16 +57,19 @@ export function pendingPromptExists(journal: Pick
+ journal: Pick,
+ automatic: boolean
): QueuedMessageRow | null {
const rows = journal.queuedMessages.list()
// Nothing waiting costs no pause derivation: this runs on every journal publish.
if (!rows.some((row) => row.state === 'waiting')) {
return null
}
- return nextSendableQueuedCard(structuredQueuePauses(journal), rows)
+ const edited = automatic ? journal.queuedMessages.editLeases.heldIds(rows) : undefined
+ return nextSendableQueuedCard(structuredQueuePauses(journal), rows, edited)
}
/**
@@ -125,7 +128,7 @@ export function nextStructuredQueuedMessage(input: {
record: AgentSessionRecord | null
fence: number
}): QueuedMessageRow | null {
- const next = oldestActionableQueuedMessage(input.journal)
+ const next = oldestActionableQueuedMessage(input.journal, true)
const { journal, fence } = input
// The gate's cheap `working` first: publication asks on every streamed frame, and the gate's
// prompt check walks the whole fold.
@@ -160,7 +163,7 @@ export function shouldQueueStructuredAgentSessionSend(input: {
if (hold !== null) {
return true
}
- return oldestActionableQueuedMessage(input.journal) !== null
+ return oldestActionableQueuedMessage(input.journal, false) !== null
}
/**
@@ -289,7 +292,7 @@ export class StructuredAgentSessionQueuedMessageDrain {
try {
if (
!journal.queuedMessages.settlementOwed() &&
- (oldestActionableQueuedMessage(journal) === null ||
+ (oldestActionableQueuedMessage(journal, true) === null ||
isStructuredAgentSessionMainAgentWorking(
journal.activeTurnId(),
journal.submissions(),
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts
index f57723ed61c7..136a7dcc15d7 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts
@@ -15,7 +15,7 @@ import type {
AgentSessionSendResult
} from '../../../shared/agent-session-wire'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
-import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages'
+import { QueuedMessageNotConsumableError } from '../agent-session-journal/queued-message-not-consumable'
import {
isJournalWrittenByNewerOrca,
journalOpenRefusal
@@ -141,7 +141,7 @@ export async function carryQueuedMessagesToClearReplacement(
/** Draft actions run like any mutation: admitted on the session's lane, the
* conversation opened for the write. */
-function mutateQueued(
+export function mutateQueued(
context: StructuredAgentSessionMutationContext,
caller: StructuredAgentSessionCaller,
envelope: AgentSessionMutationEnvelope,
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts
index d48d9bf27c39..fd8c2d553fcd 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts
@@ -42,8 +42,12 @@ export function structuredQueueSendGate(
}
/** Waiting and returned rows only. `paused` is a per-card hold (a failed conversion); a person's
- * Stop pauses the queue, published once beside it. */
-function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] {
+ * Stop pauses the queue, published once beside it. `editHeld` is never `paused`: an older client
+ * would caption it with a hold whose release it does not own. */
+function computePublishedQueuedMessages(
+ journal: AgentSessionJournal,
+ edited: ReadonlySet
+): AgentSessionQueuedMessage[] {
const published: AgentSessionQueuedMessage[] = []
for (const row of journal.queuedMessages.list()) {
if (row.state !== 'waiting' && row.state !== 'returned') {
@@ -55,6 +59,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess
position: row.position,
body: row.body,
state: row.state,
+ ...(edited.has(row.messageId) ? { editHeld: true as const } : {}),
...(held ? { paused: true as const } : {}),
// The stored reason is a typed marker; an unknown one reads as a plain hold.
...(held && isPublishedPausedReason(row.holdReason) ? { pausedReason: row.holdReason } : {}),
@@ -82,12 +87,14 @@ const listMemos = new WeakMap()
const publications = new WeakMap()
function readPublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] {
- const key = String(journal.queuedMessages.revision())
+ // A renewal moves no held set, so it re-sends nothing.
+ const edited = journal.queuedMessages.editLeases.heldIds()
+ const key = JSON.stringify([journal.queuedMessages.revision(), ...edited])
const memo = listMemos.get(journal)
if (memo && memo.key === key) {
return memo.list
}
- const list = computePublishedQueuedMessages(journal)
+ const list = computePublishedQueuedMessages(journal, edited)
// Belt for the identity dedup: equal recomputed content keeps the previous reference.
const serialized = JSON.stringify(list)
if (memo && memo.serialized === serialized) {
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.ts
index 4e1222b83429..e55450018906 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.ts
@@ -20,14 +20,16 @@ export const QUEUED_MESSAGES_PUBLISHED_MAX_BYTES =
export const QUEUED_MESSAGES_PERSON_RESERVE_BYTES = MAX_PROMPT_BYTES + 1024
/** Refused readably rather than trimmed, when the published cards would outgrow their frame room.
- * Another agent's card must also leave the person's reserve free. */
+ * Another agent's card must also leave the person's reserve free. `replacing` is an edit's own
+ * card, whose old body the new one takes the place of. */
export function queuedMessagesPublishedBytesRefusal(
journal: AgentSessionJournal,
body: AgentJournalMessageItem,
- fromPerson: boolean
+ fromPerson: boolean,
+ replacing?: string
): AgentSessionWireRefusal | null {
const bytes = unsettledQueuedMessages(journal).reduce(
- (sum, row) => sum + publishedBodyBytes(row.body),
+ (sum, row) => (row.messageId === replacing ? sum : sum + publishedBodyBytes(row.body)),
publishedBodyBytes(body)
)
const limit = fromPerson
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts
index a5ddfeb64748..bf7a61823268 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts
@@ -1,5 +1,5 @@
// Host wiring for mid-turn queueing: builds the serialized drain from the
-// host's mutation context and exposes the draft actions (Send, Delete, Resume), so the host
+// host's mutation context and exposes the draft actions (Send, Delete, Resume, Edit), so the host
// class stays a description of its surface.
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
@@ -15,6 +15,11 @@ import {
sendQueuedStructuredAgentMessage
} from './structured-agent-session-queued-mutations'
import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger'
+import {
+ holdQueuedStructuredAgentMessageEdit,
+ republish,
+ updateQueuedStructuredAgentMessage
+} from './structured-agent-session-queued-edit'
/** `sessions` are the live conversations (their `touch` is the idle sweep's activity renewal,
* which the drain's schedule rides); everything else comes from the host's mutation context,
@@ -37,6 +42,35 @@ export function wireStructuredAgentSessionQueuedMessages(
})
return {
drain,
+ /** A conversation (re)opened: its edit leases wake the queue when a deadline passes. */
+ onOpened: (sessionId: string) => {
+ const journal = sessions.get(sessionId)?.journal
+ journal?.queuedMessages.editLeases.onDeadline(() => {
+ void context()
+ .serialize(sessionId, async () => {
+ if (sessions.get(sessionId)?.journal === journal) {
+ republish(context(), sessionId, journal)
+ }
+ })
+ .catch((error: unknown) =>
+ context().deps.logger.warn('expiring a queued edit hold failed', {
+ scope: 'queued-edit-hold',
+ sessionId,
+ error
+ })
+ )
+ })
+ drain.schedule(sessionId)
+ },
+ queuedMessageUpdate: (
+ caller: StructuredAgentSessionCaller,
+ params: Parameters[2]
+ ) => updateQueuedStructuredAgentMessage(context(), caller, params),
+ queuedMessageEditHold: (
+ caller: StructuredAgentSessionCaller,
+ params: Parameters[2]
+ ) =>
+ holdQueuedStructuredAgentMessageEdit(context(), caller, params, (id) => sessions.touch(id)),
/** Every journal publish: turn, submission, prompt, command and Stop
* settlements are all commits, and each re-derives the drain's gates. */
onJournalActivity: (sessionId: string) => {
diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts
index e761fd9e04c4..9ce687d486de 100644
--- a/src/main/runtime/mobile-rpc-allowlist.test.ts
+++ b/src/main/runtime/mobile-rpc-allowlist.test.ts
@@ -156,6 +156,8 @@ describe('mobile RPC allowlist', () => {
'agentSession.cancel',
'agentSession.queuedMessageSend',
'agentSession.queuedMessageDelete',
+ 'agentSession.queuedMessageUpdate',
+ 'agentSession.queuedMessageEditHold',
'agentSession.queuedMessagesResume',
'agentSession.close',
'agentSession.respondToApproval',
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-queued-edit.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-queued-edit.test.ts
new file mode 100644
index 000000000000..a2bb215e160e
--- /dev/null
+++ b/src/main/runtime/rpc/methods/structured-agent-session-queued-edit.test.ts
@@ -0,0 +1,88 @@
+// The two in-place edit methods on the wire: registered and advertised apart from the dark
+// mid-turn queue, the caller taken from the authenticated transport, strict params, and a lease
+// release that cleanup admission always lets through.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
+ RUNTIME_CAPABILITIES
+} from '../../../../shared/protocol-version'
+import {
+ call,
+ clearStructuredHostStub,
+ envelope,
+ hostCalls,
+ installStructuredHostStub,
+ SESSION,
+ STRUCTURED_CLIENT
+} from './structured-agent-session-rpc.test-fixture'
+import {
+ QueuedMessageActionParams,
+ QueuedMessageEditHoldParams,
+ QueuedMessageUpdateParams
+} from './structured-agent-session-schemas'
+
+const update = {
+ envelope: envelope(),
+ messageId: 'card',
+ expectedBodyFingerprint: 'a'.repeat(64),
+ text: 'updated'
+}
+const acquire = {
+ sessionId: SESSION,
+ messageId: 'card',
+ editId: 'edit',
+ action: 'acquire',
+ expectedBodyFingerprint: 'a'.repeat(64)
+}
+beforeEach(() => {
+ installStructuredHostStub()
+ hostCalls.queuedMessageUpdate = vi.fn(async () => ({ ok: true, value: { status: 'updated' } }))
+ hostCalls.queuedMessageEditHold = vi.fn(async () => ({ status: 'held' }))
+})
+afterEach(clearStructuredHostStub)
+
+describe('queued edit methods', () => {
+ it('the host advertises editing on its own, with mid-turn queueing still dark', () => {
+ expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY)
+ expect(RUNTIME_CAPABILITIES).not.toContain(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY)
+ })
+
+ it.each([
+ ['agentSession.queuedMessageUpdate', update, 'queuedMessageUpdate'],
+ ['agentSession.queuedMessageEditHold', acquire, 'queuedMessageEditHold']
+ ] as const)('%s reaches the host as the authenticated caller', async (method, params, name) => {
+ expect(await call(method, params, STRUCTURED_CLIENT)).toMatchObject({ ok: true })
+ expect(hostCalls[name]).toHaveBeenCalledWith({ callerKey: 'trusted-local:runtime' }, params)
+ })
+
+ it('a release passes cleanup admission even for an audience that cannot start this agent', async () => {
+ hostCalls.sessionAgent = vi.fn(() => 'pi')
+ const release = { sessionId: SESSION, messageId: 'card', editId: 'edit', action: 'release' }
+ expect(
+ await call('agentSession.queuedMessageEditHold', acquire, STRUCTURED_CLIENT)
+ ).toMatchObject({ ok: false })
+ expect(
+ await call('agentSession.queuedMessageEditHold', release, STRUCTURED_CLIENT)
+ ).toMatchObject({ ok: true })
+ })
+
+ it('strict params refuse a forged caller, oversized text and unknown fields', () => {
+ expect(QueuedMessageUpdateParams.safeParse(update).success).toBe(true)
+ expect(
+ QueuedMessageUpdateParams.safeParse({ ...update, text: 'é'.repeat(256 * 1024) }).success
+ ).toBe(false)
+ expect(QueuedMessageUpdateParams.safeParse({ ...update, sentAs: 'goal' }).success).toBe(false)
+ expect(QueuedMessageEditHoldParams.safeParse(acquire).success).toBe(true)
+ expect(
+ QueuedMessageEditHoldParams.safeParse({ ...acquire, callerKey: 'another-client' }).success
+ ).toBe(false)
+ expect(QueuedMessageEditHoldParams.safeParse({ ...acquire, action: 'renew' }).success).toBe(
+ false
+ )
+ expect(
+ QueuedMessageActionParams.safeParse({ envelope: envelope(), messageId: 'card' }).success
+ ).toBe(true)
+ })
+})
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts
index 5689330e3c3b..821bfd6f1717 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts
@@ -1,18 +1,41 @@
-// The queued-message actions: Send-now and Delete on one card, and Resume on a
-// paused queue. Not gated on agent-session.queued-messages.v1: a host without it
-// still publishes the cards it kept unsent. A host older than the queue lacks them.
+// The queued-message actions: Send-now, Delete and in-place Edit on one card, and Resume on a
+// paused queue. Not gated on agent-session.queued-messages.v1: a host without it still publishes
+// the cards it kept unsent. A host older than the queue, or than edit-v1, lacks them.
import { defineMethod } from '../core'
import {
requireStructuredSessionHost as requireSessionHost,
+ requireStructuredCleanupHost,
structuredCallerFor as callerFor
} from './structured-agent-session-gate'
import {
QueuedMessageActionParams,
+ QueuedMessageUpdateParams,
+ QueuedMessageEditHoldParams,
QueuedMessagesResumeParams
} from './structured-agent-session-schemas'
export const STRUCTURED_AGENT_SESSION_QUEUED_METHODS = [
+ defineMethod({
+ name: 'agentSession.queuedMessageUpdate',
+ permission: 'workspace',
+ params: QueuedMessageUpdateParams,
+ handler: async (params, ctx) =>
+ requireSessionHost(ctx, params.envelope.sessionId).queuedMessageUpdate(callerFor(ctx), params)
+ }),
+ defineMethod({
+ name: 'agentSession.queuedMessageEditHold',
+ permission: 'workspace',
+ params: QueuedMessageEditHoldParams,
+ handler: async (params, ctx) => {
+ // Releasing a lease is cleanup: no admission condition may strand it until its deadline.
+ const host =
+ params.action === 'release'
+ ? requireStructuredCleanupHost(ctx)
+ : requireSessionHost(ctx, params.sessionId)
+ return host.queuedMessageEditHold(callerFor(ctx), params)
+ }
+ }),
defineMethod({
name: 'agentSession.queuedMessageSend',
permission: 'workspace',
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts
index 1f749d50490d..76ce9c1b8249 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts
@@ -34,3 +34,7 @@ export {
UnsubscribeParams
} from '../../../../shared/rpc-contract/structured-agent-session-params'
export { ContinueInterruptedParams } from '../../../../shared/rpc-contract/structured-agent-session-continue-params'
+export {
+ QueuedMessageUpdateParams,
+ QueuedMessageEditHoldParams
+} from '../../../../shared/rpc-contract/structured-agent-session-queued-edit-params'
diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts
index 56b30d837a5d..6ceb45937d8f 100644
--- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts
+++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts
@@ -8,6 +8,8 @@ export const MOBILE_AGENT_SESSION_RPC_METHODS = [
'agentSession.cancel',
'agentSession.queuedMessageSend',
'agentSession.queuedMessageDelete',
+ 'agentSession.queuedMessageUpdate',
+ 'agentSession.queuedMessageEditHold',
'agentSession.queuedMessagesResume',
'agentSession.close',
'agentSession.respondToApproval',
diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts
index d10d263aa024..ac80de57c6ce 100644
--- a/src/shared/agent-session-queued-message-wire.ts
+++ b/src/shared/agent-session-queued-message-wire.ts
@@ -40,6 +40,9 @@ export type AgentSessionQueuedMessage = {
state: 'waiting' | 'returned'
/** This one card is held, whatever the queue's pause: its conversion failed. */
paused?: true
+ /** Someone is editing this card in place: automatic delivery waits at it until the edit ends
+ * or its lease expires. Absent on older hosts, and never paired with `paused`. */
+ editHeld?: true
/** Why it is held, as a marker the client localizes: 'send_failed' ("couldn't send"); only an
* explicit Send releases it. A client must treat an unknown marker as a plain hold, so a newer
* host can add one. The queue-level pause is `queuePause`, published beside the list. */
@@ -59,3 +62,23 @@ export type AgentSessionQueuedMessageDeleteResult =
/** `dispatched` means it already became a submission; `missing` covers a
* pruned tombstone. Replays answer from tombstone receipts. */
| { deleted: false; messageId: string; disposition: 'dispatched' | 'withdrawn' | 'missing' }
+
+/** `agentSession.queuedMessageUpdate` (`agent-session.queued-message-edit.v1`). `unchanged`: the
+ * card already reads as asked, which is also how a resent Save whose answer was lost succeeds.
+ * `changed`: someone else's edit landed first; nothing was overwritten. */
+export type AgentSessionQueuedMessageUpdateResult =
+ | { status: 'updated' | 'unchanged'; messageId: string; fingerprint: string }
+ | { status: 'changed' | 'not-editable'; messageId: string }
+ | { status: 'gone'; messageId: string; disposition: 'dispatched' | 'withdrawn' | 'missing' }
+
+/** `agentSession.queuedMessageEditHold`: one editor's lease, keyed by the authenticated caller and
+ * `editId`. It expires on its own; renewing extends it, and release frees only this editor's. */
+export type AgentSessionQueuedMessageEditHoldParams = {
+ sessionId: string
+ messageId: string
+ editId: string
+} & ({ action: 'acquire'; expectedBodyFingerprint: string } | { action: 'renew' | 'release' })
+
+export type AgentSessionQueuedMessageEditHoldResult =
+ | { status: 'held'; fingerprint: string; leaseDurationMs: number; remainingMs: number }
+ | { status: 'released' | 'expired' | 'changed' | 'gone' | 'not-editable' }
diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts
index 95da4aba339d..c78241c8c773 100644
--- a/src/shared/protocol-version.ts
+++ b/src/shared/protocol-version.ts
@@ -250,6 +250,12 @@ export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY =
// gates the rollout.
export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY =
'agent-session.queued-messages.v1' as const
+// Why: a host advertising this edits a queued card in place (`agentSession.queuedMessageUpdate`)
+// and holds it from automatic delivery while edited (`agentSession.queuedMessageEditHold`). Apart
+// from queued-messages.v1: kept and agent-mail cards are editable with mid-turn queueing still off.
+// A client offers Edit only when the host advertises it; there is no copy-and-delete fallback.
+export const AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY =
+ 'agent-session.queued-message-edit.v1' as const
// Why: `agentSession.conversationCommand`'s params are strict, so an older host rejects
// `delivery`. A host advertising this holds a /compact sent while the agent works as a queued
// card instead of refusing it. Clients ask only when queued-messages.v1 is advertised too:
@@ -394,6 +400,7 @@ export const RUNTIME_CAPABILITIES = [
AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY,
AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY,
AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY,
...STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES,
...AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES,
diff --git a/src/shared/queued-message-text-edit.ts b/src/shared/queued-message-text-edit.ts
new file mode 100644
index 000000000000..219232d24909
--- /dev/null
+++ b/src/shared/queued-message-text-edit.ts
@@ -0,0 +1,40 @@
+import type { AgentJournalMessageItem } from './agent-session-journal-types'
+import { MAX_PROMPT_BYTES } from './rpc-contract/structured-agent-session-params'
+
+/** The text an in-place edit starts from, or null when the card is not editable: a command is
+ * not a draft, and a block this edit cannot carry over unchanged refuses rather than drops. */
+export function queuedMessageEditableText(body: AgentJournalMessageItem): string | null {
+ if (body.command || body.blocks.some((b) => b.type !== 'text' && b.type !== 'image-ref')) {
+ return null
+ }
+ return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n')
+}
+
+/** The body with its text replaced by one block at the first text block's place; attachments,
+ * sender, kind and send mode stay. Null when the result would not be a sendable draft. */
+export function queuedMessageWithEditedText(
+ body: AgentJournalMessageItem,
+ text: string
+): AgentJournalMessageItem | null {
+ if (
+ queuedMessageEditableText(body) === null ||
+ new TextEncoder().encode(text).length > MAX_PROMPT_BYTES ||
+ (!text.trim() && !body.blocks.some((block) => block.type === 'image-ref'))
+ ) {
+ return null
+ }
+ const blocks: AgentJournalMessageItem['blocks'] = []
+ let placed = false
+ for (const block of body.blocks) {
+ if (block.type !== 'text') {
+ blocks.push(block)
+ } else if (!placed) {
+ blocks.push({ type: 'text', text })
+ placed = true
+ }
+ }
+ if (!placed) {
+ blocks.unshift({ type: 'text', text })
+ }
+ return { ...body, blocks }
+}
diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts
index ce0481c1f171..10a2a802ae02 100644
--- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts
+++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts
@@ -515,6 +515,10 @@ import {
ThreadGoalParams,
UnsubscribeParams
} from './structured-agent-session-params'
+import {
+ QueuedMessageEditHoldParams,
+ QueuedMessageUpdateParams
+} from './structured-agent-session-queued-edit-params'
import { TerminalAdoptOrphans } from './terminal-orphan-params'
import { TerminalQuickCommandsUpdate } from './terminal-quick-command-params'
import {
@@ -622,7 +626,9 @@ export const RPC_PARAMS_BY_METHOD = {
'agentSession.modelCatalog': ModelCatalogParams,
'agentSession.options': OptionsParams,
'agentSession.queuedMessageDelete': QueuedMessageActionParams,
+ 'agentSession.queuedMessageEditHold': QueuedMessageEditHoldParams,
'agentSession.queuedMessageSend': QueuedMessageActionParams,
+ 'agentSession.queuedMessageUpdate': QueuedMessageUpdateParams,
'agentSession.queuedMessagesResume': QueuedMessagesResumeParams,
'agentSession.readVisual': ReadVisualParams,
'agentSession.release': HoldParams,
diff --git a/src/shared/rpc-contract/structured-agent-session-queued-edit-params.ts b/src/shared/rpc-contract/structured-agent-session-queued-edit-params.ts
new file mode 100644
index 000000000000..2bbf1f8abd4b
--- /dev/null
+++ b/src/shared/rpc-contract/structured-agent-session-queued-edit-params.ts
@@ -0,0 +1,27 @@
+import { z } from 'zod'
+import { Identifier, SessionId } from './structured-agent-session-identifiers'
+import { MutationEnvelope, MAX_PROMPT_BYTES } from './structured-agent-session-params'
+
+export const QueuedMessageUpdateParams = z
+ .object({
+ envelope: MutationEnvelope,
+ messageId: Identifier('Invalid queued message id'),
+ expectedBodyFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
+ text: z.string().refine((text) => new TextEncoder().encode(text).length <= MAX_PROMPT_BYTES)
+ })
+ .strict()
+
+const QueuedMessageEditIdentity = z.object({
+ sessionId: SessionId,
+ messageId: Identifier('Invalid queued message id'),
+ editId: Identifier('Invalid edit id')
+})
+
+export const QueuedMessageEditHoldParams = z.discriminatedUnion('action', [
+ QueuedMessageEditIdentity.extend({
+ action: z.literal('acquire'),
+ expectedBodyFingerprint: z.string().regex(/^[0-9a-f]{64}$/)
+ }).strict(),
+ QueuedMessageEditIdentity.extend({ action: z.literal('renew') }).strict(),
+ QueuedMessageEditIdentity.extend({ action: z.literal('release') }).strict()
+])
From 7a745136d904cf2e797f412d349919ac216e0d0c Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 8 Oct 2026 19:59:27 -0700
Subject: [PATCH 02/12] feat(native-chat): edit a queued card inline on desktop
Edit message now opens an editor inside the queued card instead of moving the
text into the composer. The card keeps its place and sender, and the editor
stays usable while a question or approval holds the composer's slot (it does
not lose focus to the prompt card).
- Enter or Cmd/Ctrl+Enter saves, Shift+Enter is a newline, Escape cancels;
IME composition keys never save or cancel. Save and Cancel replace the
card's own actions while it is edited; one editor per pane.
- The editor holds the host's edit lease, renewing every 30 seconds and taking
it again after a lapse. Lease failures are only logged: they never block
typing, Save or Cancel.
- If the card is sent or removed elsewhere mid-edit, a clean edit just
closes; changed text goes into the chat box after any existing draft, with
one toast. A Save that lands and then sends counts as success.
- Against a host without agent-session.queued-message-edit.v1 the card offers
no Edit (no copy-and-delete fallback); an empty overflow menu is hidden.
---
.../NativeChatQueuedMessageCard.tsx | 94 +++--
.../NativeChatQueuedMessageEditor.test.tsx | 139 +++++++
.../NativeChatQueuedMessageEditor.tsx | 81 ++++
.../NativeChatQueuedMessageList.test.tsx | 52 ++-
.../NativeChatQueuedMessageList.tsx | 21 +-
...native-chat-submission-navigation.test.tsx | 2 +
.../structured-agent-session-queued-cards.ts | 3 +
...uctured-agent-session-queued-edit-lease.ts | 95 +++++
.../use-native-chat-prompt-card-focus.ts | 7 +-
...uctured-agent-session-queued-edit.test.tsx | 359 ++++++++++++++++++
...se-structured-agent-session-queued-edit.ts | 281 ++++++++++++++
...ent-session-queued-messages.clear.test.tsx | 11 +-
...nt-session-queued-messages.resume.test.tsx | 14 +-
...red-agent-session-queued-messages.test.tsx | 89 +----
...tructured-agent-session-queued-messages.ts | 71 ++--
.../use-structured-agent-session.ts | 9 +-
...ructured-native-chat-submit-reveal.test.ts | 2 +
src/renderer/src/i18n/locales/en.json | 8 +-
src/renderer/src/i18n/locales/es.json | 8 +-
src/renderer/src/i18n/locales/fr.json | 8 +-
src/renderer/src/i18n/locales/ja.json | 8 +-
src/renderer/src/i18n/locales/ko.json | 8 +-
src/renderer/src/i18n/locales/zh.json | 8 +-
...tructured-agent-session-host-capability.ts | 12 +
24 files changed, 1217 insertions(+), 173 deletions(-)
create mode 100644 src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx
create mode 100644 src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx
create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-queued-edit-lease.ts
create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx
create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
index 0b83b794b055..953697efc0e6 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
@@ -31,6 +31,8 @@ import { queuedCardSenderLine } from './native-chat-agent-message-sender-label'
import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices'
import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice'
+import { NativeChatQueuedMessageEditor } from './NativeChatQueuedMessageEditor'
+import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit'
/** The visible caption under the text; the default waiting hold needs none. */
export function queuedMessageCardCaption(
@@ -133,6 +135,8 @@ export function NativeChatQueuedMessageCard({
onSteer,
onDelete,
onEdit,
+ editor,
+ editDisabled = false,
onTurnOffQueueing
}: {
card: QueuedMessageCard
@@ -144,7 +148,12 @@ export function NativeChatQueuedMessageCard({
steerHeld?: boolean
onSteer: () => void
onDelete: () => void
- onEdit: () => void
+ /** Absent when the host cannot edit in place: the card then offers no Edit at all. */
+ onEdit?: () => void
+ /** This card is being edited here: the editor replaces its text and its actions. */
+ editor?: QueuedMessageInlineEditor
+ /** Another card in this pane is being edited. */
+ editDisabled?: boolean
/** Absent when the host does not queue sends, so there is nothing to turn off. */
onTurnOffQueueing?: () => void
}): React.JSX.Element {
@@ -156,7 +165,9 @@ export function NativeChatQueuedMessageCard({
{returned || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? (
@@ -169,9 +180,13 @@ export function NativeChatQueuedMessageCard({
{queuedCardSenderLine(card.from)}
) : null}
-
- {card.text}
-
+ {editor ? (
+
+ ) : (
+
+ {card.text}
+
+ )}
{caption ? (
{/* Nothing acts on a send still on its way: the host holds no card for it yet. */}
- {card.hold === 'sending' ? null : (
+ {card.hold === 'sending' || editor ? null : (
<>
{/* A command never steers: its Send shows only while the agent is idle. */}
{card.waitsForAgent ? null : (
@@ -196,7 +211,8 @@ export function NativeChatQueuedMessageCard({
variant="ghost"
size="xs"
onClick={onSteer}
- disabled={steerHeld}
+ // Someone is editing it on another device; their Save, Cancel or lapse frees it.
+ disabled={steerHeld || card.editHeld}
>
{sendNow.steers ? (
@@ -232,38 +248,40 @@ export function NativeChatQueuedMessageCard({
{translate('components.native-chat.queuedMessages.delete', 'Delete')}
-
-
-
-
-
-
-
- {/* A command's text is not a draft: edited, it would become a message. */}
- {card.command ? null : (
-
-
- {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')}
-
- )}
- {onTurnOffQueueing ? (
-
- {translate(
- 'components.native-chat.queuedMessages.turnOffQueueing',
- 'Turn off queueing'
+ {(!card.command && onEdit) || onTurnOffQueueing ? (
+
+
+
- ) : null}
-
-
+ >
+
+
+
+
+ {/* A command's text is not a draft: edited, it would become a message. */}
+ {card.command || !onEdit ? null : (
+
+
+ {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')}
+
+ )}
+ {onTurnOffQueueing ? (
+
+ {translate(
+ 'components.native-chat.queuedMessages.turnOffQueueing',
+ 'Turn off queueing'
+ )}
+
+ ) : null}
+
+
+ ) : null}
>
)}
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx
new file mode 100644
index 000000000000..a2f37995f299
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx
@@ -0,0 +1,139 @@
+// @vitest-environment happy-dom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { NativeChatQueuedMessageEditor } from './NativeChatQueuedMessageEditor'
+import { NativeChatApprovalCard } from './NativeChatApprovalCard'
+import { NativeChatQuestionCard } from './NativeChatQuestionCard'
+import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit'
+
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+afterEach(cleanup)
+function editor(): QueuedMessageInlineEditor {
+ return {
+ messageId: 'mail',
+ text: 'full message',
+ acquiring: false,
+ saving: false,
+ canSave: true,
+ change: vi.fn(),
+ save: vi.fn(),
+ cancel: vi.fn()
+ }
+}
+function view(
+ edit: QueuedMessageInlineEditor,
+ prompt: 'question' | 'approval' | null,
+ answer = vi.fn(),
+ promptCancel = vi.fn()
+) {
+ return (
+
+
+ {prompt === 'approval' ? (
+
+ ) : null}
+ {prompt === 'question' ? (
+
+ ) : null}
+
+ )
+}
+describe('queued card textarea', () => {
+ it.each(['question', 'approval'] as const)(
+ 'stays mounted and focused when a %s opens, with separate Save/Cancel',
+ (prompt) => {
+ const edit = editor()
+ const answer = vi.fn()
+ const promptCancel = vi.fn()
+ const { rerender } = render(view(edit, null, answer, promptCancel))
+ const field = screen.getByRole('textbox', { name: 'Edit message' })
+ expect(document.activeElement).toBe(field)
+ rerender(view(edit, prompt, answer, promptCancel))
+ expect(document.activeElement).toBe(field)
+ fireEvent.keyDown(field, { key: 'Enter' })
+ expect(edit.save).toHaveBeenCalledOnce()
+ expect(answer).not.toHaveBeenCalled()
+ fireEvent.keyDown(field, { key: 'Escape' })
+ expect(edit.cancel).toHaveBeenCalledOnce()
+ expect(promptCancel).not.toHaveBeenCalled()
+ }
+ )
+ it.each(['question', 'approval'] as const)(
+ 'focuses and saves with a %s already open',
+ (prompt) => {
+ const edit = editor()
+ render(view(edit, prompt))
+ const field = screen.getByRole('textbox', { name: 'Edit message' })
+ expect(document.activeElement).toBe(field)
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+ expect(edit.save).toHaveBeenCalledOnce()
+ }
+ )
+ it('Enter and the platform modifier save, Shift+Enter is a newline and handled keys do not bubble', () => {
+ const edit = editor()
+ const parent = vi.fn()
+ render(
+
+
+
+ )
+ const field = screen.getByRole('textbox')
+ fireEvent.keyDown(field, { key: 'Enter' })
+ expect(edit.save).toHaveBeenCalledOnce()
+ expect(parent).not.toHaveBeenCalled()
+ fireEvent.keyDown(field, { key: 'Enter', shiftKey: true })
+ expect(edit.save).toHaveBeenCalledOnce()
+ for (const [platform, modifier] of [
+ ['Macintosh', { metaKey: true }],
+ ['Linux', { ctrlKey: true }],
+ ['Windows', { ctrlKey: true }]
+ ] as const) {
+ vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(platform)
+ fireEvent.keyDown(field, { key: 'Enter', ...modifier })
+ }
+ expect(edit.save).toHaveBeenCalledTimes(4)
+ vi.restoreAllMocks()
+ })
+ it('IME confirmation and Escape never commit/cancel or reach the prompt', () => {
+ const edit = editor()
+ const parent = vi.fn()
+ render(
+
+
+
+ )
+ const field = screen.getByRole('textbox')
+ fireEvent.compositionStart(field)
+ fireEvent.keyDown(field, { key: 'Enter', isComposing: true })
+ fireEvent.keyDown(field, { key: 'Escape', isComposing: true })
+ fireEvent.compositionEnd(field)
+ fireEvent.keyDown(field, { key: 'Enter', keyCode: 229 })
+ expect(edit.save).not.toHaveBeenCalled()
+ expect(edit.cancel).not.toHaveBeenCalled()
+ expect(parent).not.toHaveBeenCalled()
+ })
+ it('acquisition seeds a read-only field with Cancel always usable', () => {
+ const edit = { ...editor(), acquiring: true }
+ render( )
+ expect(screen.getByRole('textbox').getAttribute('readonly')).not.toBeNull()
+ expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(edit.cancel).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx
new file mode 100644
index 000000000000..083864daee7a
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx
@@ -0,0 +1,81 @@
+import { useLayoutEffect, useRef } from 'react'
+import { Textarea } from '@/components/ui/textarea'
+import { Button } from '@/components/ui/button'
+import { translate } from '@/i18n/i18n'
+import { isMacPlatform } from './native-chat-shortcut'
+import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit'
+
+/** The inline editor that replaces a queued card's text while it is edited. It lives in the card,
+ * not the composer, so it stays usable while a question or approval holds the composer's slot. */
+export function NativeChatQueuedMessageEditor({
+ editor
+}: {
+ editor: QueuedMessageInlineEditor
+}): React.JSX.Element {
+ const field = useRef(null)
+ useLayoutEffect(() => {
+ if (editor.acquiring) {
+ return
+ }
+ const textarea = field.current
+ textarea?.focus({ preventScroll: true })
+ const list = textarea?.closest('ul')
+ const row = textarea?.closest('li')
+ if (list && row) {
+ const rowBounds = row.getBoundingClientRect()
+ const listBounds = list.getBoundingClientRect()
+ if (rowBounds.top < listBounds.top) {
+ list.scrollTop += rowBounds.top - listBounds.top
+ } else if (rowBounds.bottom > listBounds.bottom) {
+ list.scrollTop += rowBounds.bottom - listBounds.bottom
+ }
+ }
+ }, [editor.acquiring])
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx
index 99d1e42f007e..bbe308ed183b 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx
@@ -62,7 +62,11 @@ function card(overrides: Partial & { messageId: string }): Qu
function controller(
cards: QueuedMessageCard[],
pause: { reason: string } | null = null,
- queueCapable = true
+ queueCapable = true,
+ edits: Pick = {
+ editCapable: true,
+ editor: undefined
+ }
): StructuredAgentSessionQueuedMessagesController & {
steer: ReturnType
remove: ReturnType
@@ -72,6 +76,7 @@ function controller(
return {
cards,
queueCapable,
+ ...edits,
pause,
resume: vi.fn(async () => false),
resuming: false,
@@ -115,7 +120,13 @@ function renderHeldQueue(
isWorking: false,
composerScopeKey: undefined,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list only awaits mutate; its answer is never read.
- mutate: mutate as StructuredAgentSessionMutate
+ mutate: mutate as StructuredAgentSessionMutate,
+ editTransport: {
+ target: { kind: 'local' },
+ sessionId: 'session',
+ capable: false,
+ write: async () => ({ kind: 'dropped' })
+ }
})
return
}
@@ -677,6 +688,43 @@ describe('NativeChatQueuedMessageList', () => {
expect(mocks.updateSettings).toHaveBeenCalledWith({ nativeChatQueueFollowUps: false })
})
+ it('a host that cannot edit in place offers no Edit, and an empty menu is not shown', async () => {
+ const owner = controller([card({ messageId: 'kept', hold: 'paused' })], null, false, {
+ editCapable: false,
+ editor: undefined
+ })
+ renderList(owner)
+ expect(screen.queryByRole('button', { name: 'More actions' })).toBeNull()
+ expect(screen.getByRole('button', { name: 'Delete' })).toBeTruthy()
+ })
+
+ it('the card being edited shows its editor in place of its text and actions; others cannot open one', async () => {
+ const editor = {
+ messageId: 'draft-1',
+ text: 'editing',
+ acquiring: false,
+ saving: false,
+ canSave: true,
+ change: vi.fn(),
+ save: vi.fn(),
+ cancel: vi.fn()
+ }
+ const owner = controller(
+ [card({ messageId: 'draft-1' }), card({ messageId: 'draft-2', position: 2 })],
+ null,
+ true,
+ { editCapable: true, editor }
+ )
+ renderList(owner)
+ const [edited, other] = screen.getAllByRole('listitem')
+ expect(within(edited!).getByRole('textbox', { name: 'Edit message' })).toBeTruthy()
+ expect(within(edited!).queryByRole('button', { name: 'Delete' })).toBeNull()
+ expect(within(edited!).queryByText('text of draft-1')).toBeNull()
+ fireEvent.pointerDown(within(other!).getByRole('button', { name: 'More actions' }))
+ const item = await screen.findByRole('menuitem', { name: 'Edit message' })
+ expect(item.getAttribute('aria-disabled')).toBe('true')
+ })
+
// A kept message shows as a card even where the host does not queue sends; there the setting
// and the chord would do nothing, so neither is offered.
it.each([
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx
index f1e5a0a3130d..c1db6dea6fad 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx
@@ -1,4 +1,4 @@
-import { useLayoutEffect, useRef } from 'react'
+import { useEffect, useLayoutEffect, useRef } from 'react'
import { Pause, Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAppStore } from '../../store'
@@ -24,7 +24,7 @@ export function NativeChatQueuedMessageList({
statedFailures?: readonly AgentSessionFailureFact[]
/** The chat reads Stopping: no card steers into the turn a Stop is ending. */
steerHeld?: boolean
- /** Where focus goes once Steer, Edit or Delete takes the focused card away. */
+ /** Where focus goes once Steer, Delete or a closing edit takes the focused card away. */
focusComposer?: () => void
}): React.JSX.Element {
const updateSettings = useAppStore((store) => store.updateSettings)
@@ -59,6 +59,15 @@ export function NativeChatQueuedMessageList({
}
})
}
+ const editingId = controller.editor?.messageId
+ const wasEditing = useRef(editingId)
+ useEffect(() => {
+ const closed = wasEditing.current !== undefined && editingId === undefined
+ wasEditing.current = editingId
+ if (closed) {
+ refocusAfter(Promise.resolve())
+ }
+ })
return (
{cards.length > 0 ? (
@@ -91,7 +100,13 @@ export function NativeChatQueuedMessageList({
steerHeld={steerHeld}
onSteer={() => refocusAfter(controller.steer(card.messageId))}
onDelete={() => refocusAfter(controller.remove(card.messageId))}
- onEdit={() => refocusAfter(controller.edit(card.messageId))}
+ onEdit={
+ controller.editCapable ? () => void controller.edit(card.messageId) : undefined
+ }
+ editor={
+ controller.editor?.messageId === card.messageId ? controller.editor : undefined
+ }
+ editDisabled={controller.editor !== undefined}
onTurnOffQueueing={turnOffQueueing}
/>
))}
diff --git a/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx b/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx
index 15f16c1be603..e4e05a6c3df4 100644
--- a/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx
+++ b/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx
@@ -397,6 +397,8 @@ it('reveals a structured prompt answer at the press, before the host accepts it'
respond: async () => host.promise,
queuedMessages: {
queueCapable: true,
+ editCapable: false,
+ editor: undefined,
cards: [],
pause: null,
resuming: false,
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts
index 4192b552d791..a9f16bf0f4e2 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts
@@ -34,6 +34,8 @@ export type QueuedMessageCard = {
/** The draft's text blocks joined; drafts are text-only in v1. */
text: string
state: 'waiting' | 'returned'
+ /** Someone is editing it in place, here or on another device: it does not send meanwhile. */
+ editHeld?: true
hold: QueuedMessageCardHold
/** A conversation command such as /compact: it never steers into a running turn. */
command?: true
@@ -90,6 +92,7 @@ export function projectQueuedMessageCards(
position: message.position,
text: queuedMessageCardText(message.body),
state: message.state,
+ ...(message.editHeld ? { editHeld: true as const } : {}),
hold,
...(message.body.command !== undefined
? {
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-lease.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-lease.ts
new file mode 100644
index 000000000000..2fa00e5d07ec
--- /dev/null
+++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-edit-lease.ts
@@ -0,0 +1,95 @@
+// One inline editor's lease on the execution host: acquired when the editor opens, renewed while
+// it stays open, released when it closes. The host expires it on its own, so a renewal or release
+// that fails is only logged; nothing here ever blocks typing, Save or Cancel.
+
+import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
+import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
+import type {
+ AgentSessionQueuedMessageEditHoldParams,
+ AgentSessionQueuedMessageEditHoldResult
+} from '../../../../shared/agent-session-wire'
+
+/** A quarter of the host's two-minute lease, so one lost renewal never lets it lapse. */
+export const QUEUED_EDIT_LEASE_RENEW_MS = 30_000
+
+export type QueuedEditLease = {
+ /** The host's first answer; a transport failure rejects it. */
+ acquired: Promise
+ /** Stops renewing and releases, after any request still on its way. */
+ end: () => void
+}
+
+function logLeaseFailure(error: unknown): void {
+ console.warn('[queued-message-edit] edit hold request failed', error)
+}
+
+export function startQueuedEditLease(input: {
+ target: RuntimeClientTarget
+ sessionId: string
+ messageId: string
+ editId: string
+ expectedBodyFingerprint: string
+}): QueuedEditLease {
+ const { target, sessionId, messageId, editId, expectedBodyFingerprint } = input
+ let ended = false
+ let timer: ReturnType | undefined
+ const call = (
+ action: AgentSessionQueuedMessageEditHoldParams['action']
+ ): Promise =>
+ callStructuredAgentSession(
+ target,
+ 'agentSession.queuedMessageEditHold',
+ {
+ sessionId,
+ messageId,
+ editId,
+ action,
+ ...(action === 'acquire' ? { expectedBodyFingerprint } : {})
+ }
+ )
+ const acquired = call('acquire')
+ // Requests go one at a time, so a release never overtakes the acquire it releases.
+ let chain: Promise = acquired.catch(() => undefined)
+ let held = false
+ const send = (action: 'acquire' | 'renew'): void => {
+ chain = chain
+ .then(() => (ended ? undefined : call(action)))
+ .then(keepAlive, (error: unknown) => {
+ logLeaseFailure(error)
+ keepAlive(undefined)
+ })
+ }
+ const keepAlive = (answer: AgentSessionQueuedMessageEditHoldResult | undefined): void => {
+ if (ended || !answer) {
+ // A failed request is tried again on the next beat; the host's deadline covers the gap.
+ if (!ended) {
+ timer = setTimeout(() => send(held ? 'renew' : 'acquire'), QUEUED_EDIT_LEASE_RENEW_MS)
+ }
+ return
+ }
+ if (answer.status === 'expired') {
+ // A lapsed lease (a host restart, a reopened chat) is taken again at once for the same text.
+ held = false
+ send('acquire')
+ } else if (answer.status === 'held') {
+ held = true
+ timer = setTimeout(() => send('renew'), QUEUED_EDIT_LEASE_RENEW_MS)
+ }
+ // `changed`, `gone` and `not-editable` are final: the card moved on, nothing is left to hold.
+ }
+ void acquired.then(keepAlive, (error: unknown) => {
+ logLeaseFailure(error)
+ keepAlive(undefined)
+ })
+ return {
+ acquired,
+ end: () => {
+ if (ended) {
+ return
+ }
+ ended = true
+ clearTimeout(timer)
+ chain = chain.then(() => call('release')).catch(logLeaseFailure)
+ }
+ }
+}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-prompt-card-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-prompt-card-focus.ts
index 957c0211a448..1c667cccc6a7 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-prompt-card-focus.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-prompt-card-focus.ts
@@ -24,7 +24,12 @@ export function useNativeChatPromptCardFocus(
useLayoutEffect(() => {
const card = cardRef.current
const active = document.activeElement
- if (shouldFocus && isInNativeChatPaneOf(card, active) && !card?.contains(active)) {
+ if (
+ shouldFocus &&
+ isInNativeChatPaneOf(card, active) &&
+ !card?.contains(active) &&
+ !active?.closest('[data-queued-message-editor]')
+ ) {
card?.focus()
}
}, [cardRef, shouldFocus, step])
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx
new file mode 100644
index 000000000000..711ed4de5bf6
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx
@@ -0,0 +1,359 @@
+// @vitest-environment happy-dom
+
+// The inline editor's lifecycle against a scripted host: what it acquires and saves, and that
+// typing is never lost — a card that leaves mid-edit puts dirty text in the chat box, and lease
+// bookkeeping failing never gates typing, Save or Cancel.
+
+import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { toast } from 'sonner'
+import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
+import type {
+ AgentSessionQueuedMessage,
+ AgentSessionQueuedMessageEditHoldParams,
+ AgentSessionQueuedMessageEditHoldResult,
+ AgentSessionQueuedMessageUpdateResult
+} from '../../../../shared/agent-session-wire'
+import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation'
+import { useStructuredAgentSessionQueuedEdit } from './use-structured-agent-session-queued-edit'
+import { projectQueuedMessageCards } from './structured-agent-session-queued-cards'
+import {
+ clearNativeChatDraftCacheForTests,
+ readNativeChatDraftCache,
+ writeNativeChatDraftCache
+} from './native-chat-draft-cache'
+import {
+ createMemoryNativeChatComposerDraftStorage,
+ setNativeChatComposerDraftStorageForTests
+} from './native-chat-composer-draft-storage'
+import type {
+ StructuredAgentSessionWrite,
+ StructuredAgentSessionWriteOutcome
+} from './use-structured-agent-session-mutate'
+
+const HELD: AgentSessionQueuedMessageEditHoldResult = {
+ status: 'held',
+ fingerprint: 'f'.repeat(64),
+ leaseDurationMs: 120_000,
+ remainingMs: 120_000
+}
+const rpc = vi.hoisted(() =>
+ vi.fn(
+ async (
+ _target: unknown,
+ _method: string,
+ params: AgentSessionQueuedMessageEditHoldParams
+ ): Promise =>
+ params.action === 'release' ? { status: 'released' } : HELD
+ )
+)
+vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc }))
+vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+
+const SESSION = 'folder-session'
+const SCOPE = 'pane-draft'
+const message: AgentSessionQueuedMessage = {
+ messageId: 'card',
+ position: 2,
+ state: 'waiting',
+ body: {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: 'original' }],
+ from: { kind: 'agent', senders: [], orchestration: null }
+ }
+}
+const BASE = agentSessionSendBodyFingerprint(SESSION, message.body)
+
+type Outcome = StructuredAgentSessionWriteOutcome
+let answer: () => Promise
+const writeSpy = vi.fn(async (..._args: unknown[]) => answer())
+// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this harness answers only queuedMessageUpdate, with that method's scripted outcome.
+const write = writeSpy as StructuredAgentSessionWrite
+
+type Props = {
+ messages: AgentSessionQueuedMessage[] | null
+ sessionId?: string
+ submissions?: AgentJournalSubmission[]
+}
+function harness(capable = true, initial: Props = { messages: [message] }) {
+ return renderHook(
+ (props: Props) => {
+ const submissions = props.submissions ?? []
+ return useStructuredAgentSessionQueuedEdit({
+ transport: {
+ target: { kind: 'environment', environmentId: 'remote-host' },
+ sessionId: props.sessionId ?? SESSION,
+ capable,
+ write
+ },
+ messages: props.messages,
+ cards: projectQueuedMessageCards(props.messages, submissions, { hasPendingPrompt: true }),
+ submissions,
+ composerScopeKey: SCOPE
+ })
+ },
+ { initialProps: initial }
+ )
+}
+type Hook = ReturnType
+async function begin(hook: Hook) {
+ await act(() => hook.result.current.begin('card'))
+}
+function holdCalls(action: string) {
+ return rpc.mock.calls.filter((call) => call[2].action === action)
+}
+function done(value: AgentSessionQueuedMessageUpdateResult): () => Promise {
+ return async () => ({ kind: 'done', value })
+}
+
+beforeEach(() => {
+ rpc.mockClear()
+ writeSpy.mockClear()
+ vi.mocked(toast.error).mockClear()
+ clearNativeChatDraftCacheForTests()
+ setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage())
+ answer = done({ status: 'updated', messageId: 'card', fingerprint: 'a'.repeat(64) })
+})
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+})
+
+describe('inline queued editor', () => {
+ it('holds the card on its host, saves against the full body, and leaves the chat box alone', async () => {
+ writeNativeChatDraftCache(SCOPE, 'existing composer')
+ const hook = harness()
+ await begin(hook)
+ expect(rpc).toHaveBeenCalledWith(
+ { kind: 'environment', environmentId: 'remote-host' },
+ 'agentSession.queuedMessageEditHold',
+ expect.objectContaining({
+ sessionId: SESSION,
+ action: 'acquire',
+ expectedBodyFingerprint: BASE
+ })
+ )
+ expect(hook.result.current.editor).toMatchObject({ text: 'original', acquiring: false })
+ act(() => hook.result.current.editor?.change('edited'))
+ act(() => hook.result.current.editor?.save())
+ await waitFor(() => expect(hook.result.current.editor).toBeUndefined())
+ expect(writeSpy).toHaveBeenCalledWith(
+ 'agentSession.queuedMessageUpdate',
+ 'agentSession.queuedMessageUpdate',
+ { messageId: 'card', expectedBodyFingerprint: BASE, text: 'edited' }
+ )
+ await waitFor(() => expect(holdCalls('release')).toHaveLength(1))
+ expect(holdCalls('release')[0]?.[2].editId).toBe(holdCalls('acquire')[0]?.[2].editId)
+ expect(readNativeChatDraftCache(SCOPE)).toBe('existing composer')
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ it('a host that cannot edit in place gets no editor and no request', async () => {
+ const hook = harness(false)
+ await begin(hook)
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(rpc).not.toHaveBeenCalled()
+ })
+
+ it('opens one editor per pane', async () => {
+ const second = { ...message, messageId: 'second', position: 3 }
+ const hook = harness(true, { messages: [message, second] })
+ await begin(hook)
+ await act(() => hook.result.current.begin('second'))
+ expect(hook.result.current.editor?.messageId).toBe('card')
+ expect(holdCalls('acquire')).toHaveLength(1)
+ })
+
+ it('Save with the text unchanged writes nothing', async () => {
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.save())
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(writeSpy).not.toHaveBeenCalled()
+ })
+
+ it('Cancel during a slow acquire closes at once and releases that edit after it lands', async () => {
+ let finish: (value: AgentSessionQueuedMessageEditHoldResult) => void = () => {}
+ rpc.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve
+ })
+ )
+ const hook = harness()
+ let pending: Promise = Promise.resolve()
+ act(() => {
+ pending = hook.result.current.begin('card')
+ })
+ expect(hook.result.current.editor?.acquiring).toBe(true)
+ act(() => hook.result.current.editor?.cancel())
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(holdCalls('release')).toHaveLength(0)
+ await act(async () => {
+ finish(HELD)
+ await pending
+ })
+ await waitFor(() => expect(holdCalls('release')).toHaveLength(1))
+ expect(writeSpy).not.toHaveBeenCalled()
+ })
+
+ it('a card sent elsewhere moves dirty typing into the chat box, after its draft, with one toast', async () => {
+ writeNativeChatDraftCache(SCOPE, 'existing')
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('unsaved'))
+ hook.rerender({ messages: [] })
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('existing\n\nunsaved')
+ expect(toast.error).toHaveBeenCalledTimes(1)
+ hook.rerender({ messages: [] })
+ expect(toast.error).toHaveBeenCalledTimes(1)
+ })
+
+ it('a clean edit whose card leaves just closes; an unloaded list proves nothing', async () => {
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('typing'))
+ hook.rerender({ messages: null })
+ expect(hook.result.current.editor?.text).toBe('typing')
+ act(() => hook.result.current.editor?.change('original'))
+ hook.rerender({ messages: [] })
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('')
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ it('changed keeps the typing and the original base; a second Save never overwrites', async () => {
+ answer = done({ status: 'changed', messageId: 'card' })
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('local'))
+ act(() => hook.result.current.editor?.save())
+ await waitFor(() => expect(toast.error).toHaveBeenCalledOnce())
+ const remote = {
+ ...message,
+ body: { ...message.body, blocks: [{ type: 'text' as const, text: 'remote' }] }
+ }
+ hook.rerender({ messages: [remote] })
+ expect(hook.result.current.editor).toMatchObject({ text: 'local', saving: false })
+ act(() => hook.result.current.editor?.save())
+ await waitFor(() => expect(writeSpy).toHaveBeenCalledTimes(2))
+ expect(writeSpy.mock.calls[1]?.[2]).toEqual(writeSpy.mock.calls[0]?.[2])
+ })
+
+ it('a refused Save says why once and keeps the editor', async () => {
+ answer = async () => ({
+ kind: 'not-done',
+ notice: 'Too much text is waiting in the queue.',
+ failure: { kind: 'failed' }
+ })
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('long'))
+ act(() => hook.result.current.editor?.save())
+ await waitFor(() =>
+ expect(toast.error).toHaveBeenCalledWith('Too much text is waiting in the queue.')
+ )
+ expect(hook.result.current.editor).toMatchObject({ text: 'long', saving: false })
+ })
+
+ it('Save answering gone puts the typing in the chat box; another chat drops the edit', async () => {
+ answer = done({ status: 'gone', messageId: 'card', disposition: 'dispatched' })
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('unsaved'))
+ act(() => hook.result.current.editor?.save())
+ await waitFor(() => expect(hook.result.current.editor).toBeUndefined())
+ expect(readNativeChatDraftCache(SCOPE)).toBe('unsaved')
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('another draft'))
+ hook.rerender({ messages: [message], sessionId: 'another-session' })
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('unsaved')
+ await waitFor(() => expect(holdCalls('release')).toHaveLength(2))
+ })
+
+ it('a Save that lands and then drains reads as success, even with its answer lost', async () => {
+ let finish: (outcome: Outcome) => void = () => {}
+ answer = () =>
+ new Promise((resolve) => {
+ finish = resolve
+ })
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('saved text'))
+ act(() => hook.result.current.editor?.save())
+ const saved = agentSessionSendBodyFingerprint(SESSION, {
+ ...message.body,
+ blocks: [{ type: 'text', text: 'saved text' }]
+ })
+ const submission: AgentJournalSubmission = {
+ clientMessageId: 'submission',
+ queuedMessageId: 'card',
+ payloadFingerprint: saved,
+ fence: 3,
+ dispatchState: 'pending',
+ providerItemId: null,
+ reason: null,
+ submittedAt: 1,
+ resolvedAt: null
+ }
+ hook.rerender({ messages: [], submissions: [submission] })
+ expect(hook.result.current.editor?.saving).toBe(true)
+ await act(async () =>
+ finish({ kind: 'not-done', notice: 'lost', failure: { kind: 'unconfirmed' } })
+ )
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(toast.error).not.toHaveBeenCalled()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('')
+ })
+
+ it('a failed acquire, renewal or release never gates typing, Save or Cancel, and says nothing', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ vi.useFakeTimers()
+ rpc.mockRejectedValueOnce(new Error('disconnected'))
+ const hook = harness()
+ await act(async () => {
+ await hook.result.current.begin('card')
+ })
+ expect(hook.result.current.editor?.acquiring).toBe(false)
+ act(() => hook.result.current.editor?.change('still typing'))
+ // The lease keeps trying on its own beat: acquire again, then renew.
+ await act(() => vi.advanceTimersByTimeAsync(30_000))
+ expect(holdCalls('acquire')).toHaveLength(2)
+ rpc.mockRejectedValueOnce(new Error('renewal failed'))
+ await act(() => vi.advanceTimersByTimeAsync(30_000))
+ expect(holdCalls('renew')).toHaveLength(1)
+ rpc.mockRejectedValueOnce(new Error('release failed'))
+ act(() => hook.result.current.editor?.cancel())
+ await act(() => vi.advanceTimersByTimeAsync(0))
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(holdCalls('release')).toHaveLength(1)
+ // Ended: no more beats.
+ await act(() => vi.advanceTimersByTimeAsync(120_000))
+ expect(rpc).toHaveBeenCalledTimes(4)
+ expect(toast.error).not.toHaveBeenCalled()
+ warn.mockRestore()
+ })
+
+ it('a lapsed lease is taken again at once for the same text', async () => {
+ vi.useFakeTimers()
+ const hook = harness()
+ await begin(hook)
+ rpc.mockResolvedValueOnce({ status: 'expired' })
+ await act(() => vi.advanceTimersByTimeAsync(30_000))
+ expect(holdCalls('acquire')).toHaveLength(2)
+ expect(holdCalls('acquire')[1]?.[2]).toMatchObject({ expectedBodyFingerprint: BASE })
+ act(() => hook.result.current.editor?.cancel())
+ })
+
+ it('a card edited or sent before the hold lands closes the editor quietly', async () => {
+ rpc.mockResolvedValueOnce({ status: 'changed' })
+ const hook = harness()
+ await begin(hook)
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
new file mode 100644
index 000000000000..8e94a2df3fed
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
@@ -0,0 +1,281 @@
+// Editing one queued card in place, inside the card itself, so it works while a question or an
+// approval holds the composer's slot. The host owns the card; this pane holds only the typing,
+// an edit lease that keeps automatic delivery off the card, and Save's compare-and-set.
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { toast } from 'sonner'
+import { translate } from '@/i18n/i18n'
+import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
+import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability'
+import type {
+ AgentSessionQueuedMessage,
+ AgentSessionQueuedMessageUpdateResult
+} from '../../../../shared/agent-session-wire'
+import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
+import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation'
+import {
+ queuedMessageEditableText,
+ queuedMessageWithEditedText
+} from '../../../../shared/queued-message-text-edit'
+import { appendNativeChatDraftCache } from './native-chat-draft-cache'
+import { structuredSessionOperationId } from './structured-agent-session-operation-id'
+import {
+ startQueuedEditLease,
+ type QueuedEditLease
+} from './structured-agent-session-queued-edit-lease'
+import type { QueuedMessageCard } from './structured-agent-session-queued-cards'
+import type { StructuredAgentSessionWrite } from './use-structured-agent-session-mutate'
+
+export type QueuedEditTransport = {
+ target: RuntimeClientTarget
+ sessionId: string
+ /** The host advertises `agent-session.queued-message-edit.v1`; without it there is no Edit. */
+ capable: boolean
+ write: StructuredAgentSessionWrite
+}
+
+export type QueuedMessageInlineEditor = {
+ messageId: string
+ text: string
+ /** The host has not answered the lease yet: the field shows the text, read-only. */
+ acquiring: boolean
+ saving: boolean
+ canSave: boolean
+ change: (text: string) => void
+ save: () => void
+ cancel: () => void
+}
+
+type Edit = {
+ scope: string
+ messageId: string
+ body: AgentSessionQueuedMessage['body']
+ originalText: string
+ text: string
+ /** The body Save expects to replace; fixed for the edit, so a newer text is never overwritten. */
+ baseFingerprint: string
+ acquiring: boolean
+ saving: boolean
+ lease: QueuedEditLease
+}
+
+function editNotice(key: 'editChanged' | 'editFailed' | 'editGone'): string {
+ switch (key) {
+ case 'editChanged':
+ return translate(
+ 'components.native-chat.queuedMessages.editChanged',
+ 'This message changed while you were editing. Cancel and edit it again to change the latest text.'
+ )
+ case 'editGone':
+ return translate(
+ 'components.native-chat.queuedMessages.editGone',
+ 'This message was already sent or removed. Your edit is in the chat box.'
+ )
+ default:
+ return translate(
+ 'components.native-chat.queuedMessages.editFailed',
+ "This message can't be edited."
+ )
+ }
+}
+
+export function useStructuredAgentSessionQueuedEdit(args: {
+ transport: QueuedEditTransport
+ /** The host's published cards, for each card's full body. */
+ messages: readonly AgentSessionQueuedMessage[] | null
+ /** The cards this pane shows; a card that leaves them has left the queue. */
+ cards: readonly QueuedMessageCard[]
+ submissions: readonly AgentJournalSubmission[]
+ composerScopeKey: string | undefined
+}) {
+ const { transport, messages, cards, submissions, composerScopeKey } = args
+ const { target, sessionId, capable, write } = transport
+ const scope = `${structuredAgentSessionHostKey(target)}:${sessionId}:${composerScopeKey ?? ''}`
+ const active = useRef(null)
+ const [shown, setShown] = useState(null)
+ const latest = useRef({ cards, submissions })
+ useEffect(() => {
+ latest.current = { cards, submissions }
+ }, [cards, submissions])
+
+ const render = useCallback((edit: Edit | null) => {
+ active.current = edit
+ setShown(edit ? { ...edit } : null)
+ }, [])
+ const close = useCallback(() => {
+ const edit = active.current
+ render(null)
+ edit?.lease.end()
+ }, [render])
+ /** The card left the queue under the editor: typing is never thrown away, it goes to the
+ * chat box, after whatever draft is already there. */
+ const gone = useCallback(
+ (edit: Edit) => {
+ if (active.current !== edit) {
+ return
+ }
+ if (edit.text !== edit.originalText && composerScopeKey) {
+ appendNativeChatDraftCache(composerScopeKey, edit.text)
+ toast.error(editNotice('editGone'))
+ }
+ close()
+ },
+ [close, composerScopeKey]
+ )
+
+ // Another chat, host or composer: the edit belongs to the one it started in.
+ useEffect(
+ () => () => {
+ const edit = active.current
+ active.current = null
+ edit?.lease.end()
+ },
+ [scope]
+ )
+
+ const begin = useCallback(
+ async (messageId: string): Promise => {
+ // One editor per pane: another card's Edit waits for this one's Save or Cancel.
+ if (active.current || !capable || !composerScopeKey) {
+ return
+ }
+ const message = messages?.find((entry) => entry.messageId === messageId)
+ const text = message ? queuedMessageEditableText(message.body) : null
+ if (!message || text === null) {
+ return
+ }
+ const baseFingerprint = agentSessionSendBodyFingerprint(sessionId, message.body)
+ const lease = startQueuedEditLease({
+ target,
+ sessionId,
+ messageId,
+ editId: structuredSessionOperationId(),
+ expectedBodyFingerprint: baseFingerprint
+ })
+ const edit: Edit = {
+ scope,
+ messageId,
+ body: message.body,
+ originalText: text,
+ text,
+ baseFingerprint,
+ acquiring: true,
+ saving: false,
+ lease
+ }
+ render(edit)
+ const answer = await lease.acquired.catch(() => null)
+ if (!isOpen(active, edit)) {
+ return
+ }
+ if (answer?.status === 'gone' || answer?.status === 'changed') {
+ // Sent, removed or edited elsewhere before the edit began: the card shows what happened.
+ close()
+ } else if (answer?.status === 'not-editable') {
+ toast.error(editNotice('editFailed'))
+ close()
+ } else {
+ // An unanswered lease never gates typing: the lease keeps trying, and Save checks the text.
+ edit.acquiring = false
+ render(edit)
+ }
+ },
+ [capable, close, composerScopeKey, messages, render, scope, sessionId, target]
+ )
+
+ const change = useCallback(
+ (text: string) => {
+ const edit = active.current
+ if (edit && !edit.acquiring && !edit.saving) {
+ edit.text = text
+ render(edit)
+ }
+ },
+ [render]
+ )
+
+ const save = useCallback(async () => {
+ const edit = active.current
+ if (!edit || edit.acquiring || edit.saving) {
+ return
+ }
+ if (edit.text === edit.originalText) {
+ close()
+ return
+ }
+ const body = queuedMessageWithEditedText(edit.body, edit.text)
+ if (!body) {
+ return
+ }
+ edit.saving = true
+ render(edit)
+ const result = await write(
+ 'agentSession.queuedMessageUpdate',
+ 'agentSession.queuedMessageUpdate',
+ { messageId: edit.messageId, expectedBodyFingerprint: edit.baseFingerprint, text: edit.text }
+ )
+ if (!isOpen(active, edit)) {
+ return
+ }
+ edit.saving = false
+ const status = result.kind === 'done' ? result.value.status : null
+ if (status === 'updated' || status === 'unchanged' || result.kind === 'dropped') {
+ close()
+ } else if (status === 'gone') {
+ gone(edit)
+ } else if (result.kind === 'not-done' && !onScreen(latest.current.cards, edit.messageId)) {
+ // The answer was lost and the card has since left: it went out with this Save's text, or
+ // it went before the Save could land.
+ const desired = agentSessionSendBodyFingerprint(sessionId, body)
+ const sentAsSaved = latest.current.submissions.some(
+ (entry) => entry.queuedMessageId === edit.messageId && entry.payloadFingerprint === desired
+ )
+ if (sentAsSaved) {
+ close()
+ } else {
+ gone(edit)
+ }
+ } else {
+ toast.error(
+ result.kind === 'not-done'
+ ? result.notice
+ : editNotice(status === 'changed' ? 'editChanged' : 'editFailed')
+ )
+ render(edit)
+ }
+ }, [close, gone, render, sessionId, write])
+
+ // Sent or removed elsewhere, or a lapsed lease lost the race to delivery. While a Save is out,
+ // its answer decides instead: its own text going out right after it lands is a success.
+ // An unloaded list (`null`) proves nothing: a stream that went quiet did not send the card.
+ useEffect(() => {
+ const edit = active.current
+ if (edit && !edit.saving && messages !== null && !onScreen(cards, edit.messageId)) {
+ gone(edit)
+ }
+ }, [cards, gone, messages, shown])
+
+ const editor: QueuedMessageInlineEditor | undefined =
+ shown && shown.scope === scope
+ ? {
+ messageId: shown.messageId,
+ text: shown.text,
+ acquiring: shown.acquiring,
+ saving: shown.saving,
+ canSave: queuedMessageWithEditedText(shown.body, shown.text) !== null,
+ change,
+ save: () => void save(),
+ cancel: close
+ }
+ : undefined
+ return { editor, begin, capable }
+}
+
+/** A read of the ref, not a narrowing: an await may have closed or replaced the edit. */
+function isOpen(active: { current: Edit | null }, edit: Edit): boolean {
+ return active.current === edit
+}
+
+function onScreen(cards: readonly QueuedMessageCard[], messageId: string): boolean {
+ return cards.some((card) => card.messageId === messageId)
+}
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx
index ab6d7fd7263c..cc149f0a8782 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx
@@ -13,6 +13,14 @@ import type {
import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import { NativeChatQueueSendConfirmDialog } from './NativeChatQueueSendConfirmDialog'
+import type { QueuedEditTransport } from './use-structured-agent-session-queued-edit'
+
+const NO_EDIT: QueuedEditTransport = {
+ target: { kind: 'local' },
+ sessionId: 'session',
+ capable: false,
+ write: async () => ({ kind: 'dropped' })
+}
const STOPPED = { reason: 'stopped' } as const
@@ -51,7 +59,8 @@ function renderController(
isWorking: options.isWorking === true,
composerScopeKey: undefined,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only awaits mutate; the stub answers Delete's shape.
- mutate: mutate as unknown as StructuredAgentSessionMutate
+ mutate: mutate as unknown as StructuredAgentSessionMutate,
+ editTransport: NO_EDIT
})
)
return { ...view, mutate }
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx
index 37d4ead942e9..d404bcc0a6c0 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx
@@ -30,6 +30,14 @@ import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-
import { useStructuredNativeChatSubmitReveal } from './use-structured-native-chat-submit-reveal'
import { useNativeChatMessageListHandle } from './use-native-chat-reveal-latest'
import { useNativeChatTranscriptScroll } from './use-native-chat-transcript-scroll'
+import type { QueuedEditTransport } from './use-structured-agent-session-queued-edit'
+
+const NO_EDIT: QueuedEditTransport = {
+ target: { kind: 'local' },
+ sessionId: 'session',
+ capable: false,
+ write: async () => ({ kind: 'dropped' })
+}
const RESUMED = {
ok: true,
@@ -72,7 +80,8 @@ function renderController(initialProps: ControllerInput = {}) {
hasPendingPrompt: input.hasPendingPrompt ?? false,
isWorking: input.isWorking ?? false,
composerScopeKey: undefined,
- mutate
+ mutate,
+ editTransport: NO_EDIT
})
},
{ initialProps }
@@ -286,7 +295,8 @@ function useQueue(sessionId = 'session-1', fence: number | null = 1) {
hasPendingPrompt: false,
isWorking: false,
composerScopeKey: undefined,
- mutate
+ mutate,
+ editTransport: NO_EDIT
})
}
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx
index 5d1f642071fc..f9cd690fb67f 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx
@@ -1,8 +1,7 @@
// @vitest-environment happy-dom
-// The queued-message controller: which RPC each card action issues, and Edit's
-// copy-first order — the card's shown text is in the composer before the Delete
-// goes out, so no RPC outcome can lose it.
+// The queued-message controller: which RPC each card action issues, and that a host which cannot
+// edit in place gets no Edit at all, never the old copy-and-delete.
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -11,17 +10,15 @@ import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
-import { toast } from 'sonner'
import {
clearNativeChatDraftCacheForTests,
readNativeChatDraftCache
} from './native-chat-draft-cache'
import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages'
-import {
- createMemoryNativeChatComposerDraftStorage,
- setNativeChatComposerDraftStorageForTests
-} from './native-chat-composer-draft-storage'
-import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
+import type {
+ StructuredAgentSessionMutate,
+ StructuredAgentSessionWrite
+} from './use-structured-agent-session-mutate'
type MutateCall = [string, string, Record]
@@ -34,6 +31,7 @@ function draft(id: string, position: number): AgentSessionQueuedMessage {
}
const SCOPE = 'tab-1:pane-scope'
+const write: StructuredAgentSessionWrite = vi.fn(async () => ({ kind: 'dropped' as const }))
function createHarness(
overrides: {
@@ -58,7 +56,8 @@ function createHarness(
isWorking: false,
composerScopeKey: 'composerScopeKey' in overrides ? overrides.composerScopeKey : SCOPE,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each scripted answer is the result shape of the one mutate it responds to; generic erasure cannot express that.
- mutate: mutate as StructuredAgentSessionMutate
+ mutate: mutate as StructuredAgentSessionMutate,
+ editTransport: { target: { kind: 'local' }, sessionId: 'session', capable: false, write }
})
)
return { ...rendered, mutate, mutateCalls }
@@ -110,74 +109,22 @@ describe('queued message actions', () => {
expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
- it('Edit puts the shown text in the composer BEFORE the Delete goes out, then deletes', async () => {
- let draftWhenDeleteArrived: string | null = null
+ it('the steer chord consumes a held newest card without sending it or choosing an earlier card', () => {
const harness = createHarness({
- mutateResult: (call) => {
- draftWhenDeleteArrived = readNativeChatDraftCache(SCOPE)
- return { deleted: true, messageId: call[2].messageId }
- }
+ queuedMessages: [draft('earlier', 1), { ...draft('newest', 2), editHeld: true }]
})
- await act(() => harness.result.current.edit('draft-1'))
- expect(harness.mutateCalls).toEqual([
- [
- 'agentSession.queuedMessageDelete',
- 'agentSession.queuedMessageDelete',
- { messageId: 'draft-1' }
- ]
- ])
- // Copy-first: the composer already held the text when the RPC was issued.
- expect(draftWhenDeleteArrived).toBe('text of draft-1')
- expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1')
+ expect(harness.result.current.steerNewest()).toBe(true)
+ expect(harness.mutate).not.toHaveBeenCalled()
})
- it('the text survives a failed Delete: the card stays and the composer keeps the copy', async () => {
- // mutate answers null for a refused or lost write; the card remains on the host's list.
+ it('a host that cannot edit in place offers no Edit and never copies or deletes', async () => {
const harness = createHarness()
await act(() => harness.result.current.edit('draft-1'))
- expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1')
- expect(harness.result.current.cards.map((card) => card.messageId)).toEqual([
- 'draft-1',
- 'draft-2'
- ])
- })
-
- it('an Edit whose draft was already dispatched says so, and the copy stays', async () => {
- const harness = createHarness({
- mutateResult: (call) => ({
- deleted: false,
- messageId: call[2].messageId,
- disposition: 'dispatched'
- })
- })
- await act(() => harness.result.current.edit('draft-1'))
- expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1')
- expect(toast.error).toHaveBeenCalledWith('Already sent — your text is still in the composer.')
- })
-
- it('Edit deletes the card only once storage holds the text, and keeps it when storage refuses', async () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
- const storage = createMemoryNativeChatComposerDraftStorage()
- storage.refuseWrites = true
- setNativeChatComposerDraftStorageForTests(storage)
- try {
- const harness = createHarness()
- await act(() => harness.result.current.edit('draft-1'))
- expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1')
- expect(harness.mutate).not.toHaveBeenCalled()
-
- storage.refuseWrites = false
- await act(() => harness.result.current.edit('draft-2'))
- expect(harness.mutate).toHaveBeenCalledTimes(1)
- } finally {
- warn.mockRestore()
- }
- })
-
- it('Edit with no composer to hold the text deletes nothing', async () => {
- const harness = createHarness({ composerScopeKey: undefined })
- await act(() => harness.result.current.edit('draft-1'))
+ expect(harness.result.current.editCapable).toBe(false)
+ expect(harness.result.current.editor).toBeUndefined()
expect(harness.mutate).not.toHaveBeenCalled()
+ expect(write).not.toHaveBeenCalled()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
it('a second press on a card while its action is in flight sends nothing more', async () => {
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts
index b56d5b0eb916..6f0954b107a7 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts
@@ -1,9 +1,7 @@
// Host-held drafts as this pane acts on them: the card list, Send-now (Steer),
-// Delete, Edit, the Cmd/Ctrl+Enter steer chord, Resume of a held queue, and
+// Delete, in-place Edit, the Cmd/Ctrl+Enter steer chord, Resume of a held queue, and
// clearing it before a new message.
-// Everything durable lives on the host, and no draft text ever travels back over
-// the wire: Edit copies the text the card already shows into the composer,
-// locally, before deleting the draft, so no RPC outcome can lose it.
+// Everything durable lives on the host; an open editor holds only its own typing.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
@@ -16,9 +14,12 @@ import type {
AgentSessionQueuePause,
AgentSessionSendResult
} from '../../../../shared/agent-session-wire'
-import { appendNativeChatDraftCache } from './native-chat-draft-cache'
+import {
+ useStructuredAgentSessionQueuedEdit,
+ type QueuedEditTransport,
+ type QueuedMessageInlineEditor
+} from './use-structured-agent-session-queued-edit'
import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends'
-import { nativeChatComposerDraftWriteSettled } from './native-chat-composer-draft-store'
import {
newestSteerableQueuedMessageCard,
projectQueuedMessageCards,
@@ -46,7 +47,11 @@ export type StructuredAgentSessionQueuedMessagesController = {
/** Send-now into the running turn; the transcript shows it at delivery position. */
steer: (messageId: string) => Promise
remove: (messageId: string) => Promise
- /** Copy the card's shown text into the composer, then delete the draft. */
+ /** The host edits cards in place; without it a card offers no Edit. */
+ editCapable: boolean
+ /** The card being edited in this pane, at most one. */
+ editor: QueuedMessageInlineEditor | undefined
+ /** Opens the card's inline editor. */
edit: (messageId: string) => Promise
/** Cmd/Ctrl+Enter: Send-now the newest card. False when there is none to steer. */
steerNewest: () => boolean
@@ -85,6 +90,7 @@ export function useStructuredAgentSessionQueuedMessages(args: {
sending?: readonly StructuredAgentSessionPendingSend[]
composerScopeKey: string | undefined
mutate: StructuredAgentSessionMutate
+ editTransport: QueuedEditTransport
}): StructuredAgentSessionQueuedMessagesController {
const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args
const { isWorking, queuePause } = args
@@ -169,39 +175,15 @@ export function useStructuredAgentSessionQueuedMessages(args: {
[removeCard]
)
- const edit = useCallback(
- (messageId: string): Promise =>
- actOnce(messageId, async () => {
- // The text is copied FIRST, from the card this pane already shows — a local move,
- // never a wire payload. Without a composer to hold it, deleting would destroy it,
- // so the draft then stays a card.
- const card = cardsRef.current.find((entry) => entry.messageId === messageId)
- if (!card || card.command || !composerScopeKey) {
- return
- }
- appendNativeChatDraftCache(composerScopeKey, card.text)
- // The card goes only once storage holds the draft; a refused save keeps it.
- if (!(await nativeChatComposerDraftWriteSettled(composerScopeKey))) {
- return
- }
- const result = await mutate(
- 'agentSession.queuedMessageDelete',
- 'agentSession.queuedMessageDelete',
- { messageId }
- )
- if (result && !result.deleted && result.disposition === 'dispatched') {
- toast.error(
- translate(
- 'components.native-chat.queuedMessages.editAlreadySent',
- 'Already sent — your text is still in the composer.'
- )
- )
- }
- // A failed Delete leaves the card: the text shows in both places, visibly, never lost.
- }),
- [actOnce, composerScopeKey, mutate]
- )
+ const editing = useStructuredAgentSessionQueuedEdit({
+ transport: args.editTransport,
+ messages: queuedMessages,
+ cards,
+ submissions,
+ composerScopeKey
+ })
+ const editedId = editing.editor?.messageId
const steerNewest = useCallback((): boolean => {
if (!enabled) {
return false
@@ -210,9 +192,12 @@ export function useStructuredAgentSessionQueuedMessages(args: {
if (!newest) {
return false
}
- void steer(newest.messageId)
+ // A card being edited, here or elsewhere, is not sent by the chord, nor is another in its place.
+ if (!newest.editHeld && editedId !== newest.messageId) {
+ void steer(newest.messageId)
+ }
return true
- }, [enabled, steer])
+ }, [editedId, enabled, steer])
const resumingRef = useRef(false)
const [resuming, setResuming] = useState(false)
@@ -265,7 +250,9 @@ export function useStructuredAgentSessionQueuedMessages(args: {
resuming,
steer,
remove,
- edit,
+ edit: editing.begin,
+ editCapable: editing.capable,
+ editor: editing.editor,
steerNewest,
queueResume,
queueHold
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts
index d21cb5b29a97..f428b6f9bb8c 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts
@@ -9,6 +9,7 @@ import { takeBackStructuredLaunchPrompts } from '@/lib/structured-agent-session-
import { supportsStructuredAgentSessionPromptCancel } from '@/runtime/structured-agent-session-client'
import {
useStructuredAgentSessionHostQueuesCommands,
+ useStructuredAgentSessionHostEditsQueuedMessages,
useStructuredAgentSessionHostQueuesMessagesState
} from '@/runtime/structured-agent-session-host-capability'
import { structuredAgentSessionStopControl } from './structured-agent-session-stop-control'
@@ -208,6 +209,11 @@ export function useStructuredAgentSession(args: {
transcriptPending,
transportState.submissions
)
+ const editCapable = useStructuredAgentSessionHostEditsQueuedMessages(target)
+ const editTransport = useMemo(
+ () => ({ target, sessionId, capable: editCapable, write }),
+ [editCapable, sessionId, target, write]
+ )
const queuedController = useStructuredAgentSessionQueuedMessages({
// Its published list, pause and submissions; the rest is named below.
...transportState,
@@ -217,7 +223,8 @@ export function useStructuredAgentSession(args: {
// Hidden from the transcript, a queue send on its way reads as sending among the cards.
sending: pending,
composerScopeKey,
- mutate
+ mutate,
+ editTransport
})
return {
epoch: state.epoch,
diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts
index 69767fb79282..6e64073a0aea 100644
--- a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts
+++ b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts
@@ -19,6 +19,8 @@ it('reveals before launch Retry, but waits for a successful queue Resume', async
steer: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
edit: vi.fn(async () => {}),
+ editCapable: false,
+ editor: undefined,
steerNewest: vi.fn(() => false),
queueResume: undefined,
queueHold: undefined
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 140dc56c828b..7e13a6e28563 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -18630,7 +18630,6 @@
"turnOffQueueing": "Turn off queueing",
"listLabel": "Queued messages",
"alreadySent": "This message was already sent.",
- "editAlreadySent": "Already sent — your text is still in the composer.",
"withdrawnHold": "Stopped before it was sent",
"pausedSendFailed": "Couldn't send — press Send to retry.",
"paused": "Paused",
@@ -18644,7 +18643,12 @@
"sendConfirmClear": "Clear queue",
"sendConfirmSend": "Send message",
"from": "From {{names}}",
- "pausedSendFailedWaiting": "Couldn't send — press Send to retry once the agent finishes."
+ "pausedSendFailedWaiting": "Couldn't send — press Send to retry once the agent finishes.",
+ "save": "Save",
+ "cancel": "Cancel",
+ "editChanged": "This message changed while you were editing. Cancel and edit it again to change the latest text.",
+ "editFailed": "This message can't be edited.",
+ "editGone": "This message was already sent or removed. Your edit is in the chat box."
},
"thought": "Thought",
"thoughtForDuration": "Thought for {{duration}}",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 903faac31db7..04dccc6438a7 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -18231,7 +18231,6 @@
},
"queuedMessages": {
"alreadySent": "Este mensaje ya se envió.",
- "editAlreadySent": "Ya se envió — tu texto sigue en el cuadro de mensaje.",
"listLabel": "Mensajes en cola",
"behindReturnedHold": "En espera: un mensaje anterior necesita atención",
"awaitingAnswerHold": "Esperando tu respuesta",
@@ -18256,7 +18255,12 @@
"sendConfirmClear": "Vaciar cola",
"sendConfirmSend": "Enviar mensaje",
"from": "De {{names}}",
- "pausedSendFailedWaiting": "No se pudo enviar. Cuando el agente termine, pulsa Enviar para reintentar."
+ "pausedSendFailedWaiting": "No se pudo enviar. Cuando el agente termine, pulsa Enviar para reintentar.",
+ "save": "Guardar",
+ "cancel": "Cancelar",
+ "editChanged": "Este mensaje cambió mientras lo editabas. Cancela y vuelve a editarlo para cambiar el texto más reciente.",
+ "editFailed": "Este mensaje no se puede editar.",
+ "editGone": "Este mensaje ya se envió o se eliminó. Tu edición está en el cuadro de chat."
},
"thought": "Pensó",
"thoughtForDuration": "Pensó durante {{duration}}",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index ad5cfc6b400d..4f49d766d91a 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -18468,7 +18468,6 @@
},
"queuedMessages": {
"alreadySent": "Ce message a déjà été envoyé.",
- "editAlreadySent": "Déjà envoyé — votre texte reste dans la zone de saisie.",
"listLabel": "Messages en file d'attente",
"behindReturnedHold": "En attente — un message précédent nécessite votre attention",
"awaitingAnswerHold": "En attente de votre réponse",
@@ -18493,7 +18492,12 @@
"sendConfirmClear": "Vider la file d'attente",
"sendConfirmSend": "Envoyer le message",
"from": "De {{names}}",
- "pausedSendFailedWaiting": "Envoi impossible. Quand l'agent aura terminé, appuyez sur Envoyer pour réessayer."
+ "pausedSendFailedWaiting": "Envoi impossible. Quand l'agent aura terminé, appuyez sur Envoyer pour réessayer.",
+ "save": "Enregistrer",
+ "cancel": "Annuler",
+ "editChanged": "Ce message a changé pendant que vous le modifiiez. Annulez puis modifiez-le à nouveau pour changer le texte le plus récent.",
+ "editFailed": "Ce message ne peut pas être modifié.",
+ "editGone": "Ce message a déjà été envoyé ou supprimé. Votre modification se trouve dans le champ de discussion."
},
"thought": "A réfléchi",
"thoughtForDuration": "A réfléchi pendant {{duration}}",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 3aa840dfaa01..855814d0c0a6 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -18402,7 +18402,6 @@
},
"queuedMessages": {
"alreadySent": "このメッセージはすでに送信されています。",
- "editAlreadySent": "すでに送信されています — テキストは入力欄に残っています。",
"listLabel": "キュー内のメッセージ",
"behindReturnedHold": "待機中 — 前のメッセージの対応が必要です",
"awaitingAnswerHold": "回答を待っています",
@@ -18427,7 +18426,12 @@
"sendConfirmClear": "キューを消去",
"sendConfirmSend": "メッセージを送信",
"from": "送信元: {{names}}",
- "pausedSendFailedWaiting": "送信できませんでした。Agent の処理が終わったら「送信」を押して再試行してください。"
+ "pausedSendFailedWaiting": "送信できませんでした。Agent の処理が終わったら「送信」を押して再試行してください。",
+ "save": "保存",
+ "cancel": "キャンセル",
+ "editChanged": "編集中にこのメッセージが変更されました。キャンセルしてもう一度編集すると、最新のテキストを変更できます。",
+ "editFailed": "このメッセージは編集できません。",
+ "editGone": "このメッセージはすでに送信または削除されています。編集したテキストはチャット入力欄にあります。"
},
"thought": "考えました",
"thoughtForDuration": "{{duration}} 考えました",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 43a423f16585..66f135acf025 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -18402,7 +18402,6 @@
},
"queuedMessages": {
"alreadySent": "이 메시지는 이미 전송되었습니다.",
- "editAlreadySent": "이미 전송되었습니다 — 텍스트는 입력창에 남아 있습니다.",
"listLabel": "대기 중인 메시지",
"behindReturnedHold": "대기 중 — 앞의 메시지에 확인이 필요합니다",
"awaitingAnswerHold": "답변을 기다리는 중",
@@ -18427,7 +18426,12 @@
"sendConfirmClear": "대기열 지우기",
"sendConfirmSend": "메시지 보내기",
"from": "보낸 사람: {{names}}",
- "pausedSendFailedWaiting": "보낼 수 없습니다. 에이전트가 끝나면 보내기를 눌러 다시 시도하세요."
+ "pausedSendFailedWaiting": "보낼 수 없습니다. 에이전트가 끝나면 보내기를 눌러 다시 시도하세요.",
+ "save": "저장",
+ "cancel": "취소",
+ "editChanged": "편집하는 동안 이 메시지가 변경되었습니다. 취소한 다음 다시 편집하면 최신 내용을 변경할 수 있습니다.",
+ "editFailed": "이 메시지는 편집할 수 없습니다.",
+ "editGone": "이 메시지는 이미 전송되었거나 삭제되었습니다. 편집한 내용은 채팅 입력란에 있습니다."
},
"thought": "생각함",
"thoughtForDuration": "{{duration}} 동안 생각함",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 9057858199aa..bbbf87c80a03 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -18367,7 +18367,6 @@
},
"queuedMessages": {
"alreadySent": "此消息已发送。",
- "editAlreadySent": "已发送 — 你的文本仍在输入框中。",
"listLabel": "排队的消息",
"behindReturnedHold": "等待中 — 前面有一条消息需要处理",
"awaitingAnswerHold": "等待你的回答",
@@ -18392,7 +18391,12 @@
"sendConfirmClear": "清空队列",
"sendConfirmSend": "发送消息",
"from": "来自 {{names}}",
- "pausedSendFailedWaiting": "无法发送。智能体完成后,点按发送以重试。"
+ "pausedSendFailedWaiting": "无法发送。智能体完成后,点按发送以重试。",
+ "save": "保存",
+ "cancel": "取消",
+ "editChanged": "你编辑时此消息已更改。请取消并重新编辑,以更改最新内容。",
+ "editFailed": "此消息无法编辑。",
+ "editGone": "此消息已发送或移除。你编辑的内容已放入聊天输入框。"
},
"thought": "已思考",
"thoughtForDuration": "思考了 {{duration}}",
diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.ts b/src/renderer/src/runtime/structured-agent-session-host-capability.ts
index cf1d8c4baa22..30c1b40553a2 100644
--- a/src/renderer/src/runtime/structured-agent-session-host-capability.ts
+++ b/src/renderer/src/runtime/structured-agent-session-host-capability.ts
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import {
AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
AGENT_SESSION_REWIND_RECOVERY_CAPABILITY,
type RuntimeCapability
@@ -127,3 +128,14 @@ export function useStructuredAgentSessionHostQueuesCommands(target: RuntimeClien
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY
)
}
+
+/** Whether the host edits a queued card in place and holds it from delivery while edited. Until
+ * it says so, and against an older host, a card offers no Edit. */
+export function useStructuredAgentSessionHostEditsQueuedMessages(
+ target: RuntimeClientTarget
+): boolean {
+ return useStructuredAgentSessionHostCapability(
+ target,
+ AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY
+ )
+}
From 63225c85d73283d5697cb369a49f7cd22cb0538d Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 8 Oct 2026 20:05:11 -0700
Subject: [PATCH 03/12] fix(native-chat): name every queued-edit notice in its
switch
---
.../native-chat/use-structured-agent-session-queued-edit.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
index 8e94a2df3fed..2504b0b4b31a 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
@@ -71,7 +71,7 @@ function editNotice(key: 'editChanged' | 'editFailed' | 'editGone'): string {
'components.native-chat.queuedMessages.editGone',
'This message was already sent or removed. Your edit is in the chat box.'
)
- default:
+ case 'editFailed':
return translate(
'components.native-chat.queuedMessages.editFailed',
"This message can't be edited."
From 9325bc5fd161977234a0edc5ce8dd2b9089c4462 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 8 Oct 2026 23:39:36 -0700
Subject: [PATCH 04/12] fix(native-chat): keep queued-edit typing safe and stop
disabling Send elsewhere
- Save with an unreadable answer (dropped for a moved runtime fence, or lost)
now reads the published card: saved text shown -> close; card gone -> the
sent-as-saved / chat-box path; otherwise keep the editor quietly.
- Send and Steer stay enabled on a card edited on another device; the
published editHeld marker is gone (no reader left). Automatic delivery
still stops at an edited card on the host.
- Save answering changed moves the typing to the chat box and reopens the
editor on the newer text. Notices say when the chat box is hidden by a
question or approval.
- Closing the pane or switching chats with unsaved typing appends it to that
chat's box quietly.
- Lease renewal no longer publishes, so an open editor is not idle-sweep
activity.
- The consume compare-and-set also matches the body fingerprint.
- Saving unchanged text keeps the original blocks.
---
.../journal-queued-messages.ts | 2 +-
.../journal-submission-hook.ts | 8 +-
...queued-message-bookkeeping-failure.test.ts | 7 +-
.../queued-message-store.test.ts | 25 ++++
.../queued-message-table.ts | 7 +-
.../queued-message-text-update.test.ts | 37 +++++-
...ructured-agent-session-queued-edit.test.ts | 18 ++-
.../structured-agent-session-queued-edit.ts | 26 ++--
...ctured-agent-session-queued-publication.ts | 15 +--
.../structured-agent-session-queued-wiring.ts | 3 +-
.../NativeChatQueuedMessageCard.tsx | 3 +-
.../structured-agent-session-queued-cards.ts | 3 -
...uctured-agent-session-queued-edit.test.tsx | 125 ++++++++++++++++--
...se-structured-agent-session-queued-edit.ts | 109 +++++++++++----
...red-agent-session-queued-messages.test.tsx | 8 --
...tructured-agent-session-queued-messages.ts | 11 +-
src/renderer/src/i18n/locales/en.json | 6 +-
src/renderer/src/i18n/locales/es.json | 6 +-
src/renderer/src/i18n/locales/fr.json | 6 +-
src/renderer/src/i18n/locales/ja.json | 6 +-
src/renderer/src/i18n/locales/ko.json | 6 +-
src/renderer/src/i18n/locales/zh.json | 6 +-
.../agent-session-queued-message-wire.ts | 3 -
src/shared/queued-message-text-edit.ts | 4 +
24 files changed, 334 insertions(+), 116 deletions(-)
diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts
index dacb186bac11..90515379154c 100644
--- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts
+++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts
@@ -290,7 +290,7 @@ export class JournalQueuedMessages {
* throws so the whole append — draft transition AND submission row — rolls back. */
consumeInTransaction(
db: Database.Database,
- input: JournalSubmissionConsume & { consumedAs: string }
+ input: JournalSubmissionConsume & { consumedAs: string; fingerprint: string }
): void {
const { db: own } = this.deps.database()
if (own !== db) {
diff --git a/src/main/native-chat/agent-session-journal/journal-submission-hook.ts b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts
index 84be3ab91c98..36893658f90a 100644
--- a/src/main/native-chat/agent-session-journal/journal-submission-hook.ts
+++ b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts
@@ -18,12 +18,16 @@ export type JournalAttachmentClaim = (
export function journalSubmissionHook(
queuedMessages: JournalQueuedMessages,
claimAttachments: JournalAttachmentClaim,
- input: Pick,
+ input: Pick,
consume: JournalSubmissionConsume | undefined
): JournalRowTransactionHook {
return (db) => {
if (consume) {
- queuedMessages.consumeInTransaction(db, { ...consume, consumedAs: input.clientMessageId })
+ queuedMessages.consumeInTransaction(db, {
+ ...consume,
+ consumedAs: input.clientMessageId,
+ fingerprint: input.payloadFingerprint
+ })
}
claimAttachments(db, input.body, input.origin === 'client' && !consume)
}
diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts
index 5f9a95a53d20..bc8478602d01 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts
@@ -179,7 +179,12 @@ describe('draft bookkeeping inside a journal append', () => {
try {
await expect(
journal.appendSubmission(
- { clientMessageId: 'sub-draft-1', payloadFingerprint: 'fp', body: BODY, fence: 0 },
+ {
+ clientMessageId: 'sub-draft-1',
+ payloadFingerprint: 'fp-draft-1',
+ body: BODY,
+ fence: 0
+ },
{ messageId: 'draft-1', expect: 'waiting', settledByOp: null }
)
).rejects.toThrow('SQLITE_FULL')
diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts
index b63cac121fee..7b67e6458c70 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts
@@ -316,6 +316,31 @@ describe('consume', () => {
expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn')
})
+ it('a consume carrying text the card no longer holds loses: an edit since the read never sends the old text', async () => {
+ const journal = await open()
+ await queueDraft(journal, 'draft-1', 'old')
+ const read = journal.queuedMessages.get('draft-1')!
+ await journal.queuedMessages.update({
+ messageId: 'draft-1',
+ expectedBodyFingerprint: read.fingerprint,
+ text: 'new'
+ })
+ await expect(
+ journal.appendSubmission(
+ {
+ clientMessageId: 'sub-draft-1',
+ payloadFingerprint: read.fingerprint,
+ body: read.body,
+ fence: 0,
+ handoverRecorded: true
+ },
+ { messageId: 'draft-1', expect: 'waiting', settledByOp: null }
+ )
+ ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError)
+ expect(journal.submissions()).toHaveLength(0)
+ expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting')
+ })
+
it('a failed submission insert rolls the draft transition back', async () => {
const journal = await open()
await queueDraft(journal, 'draft-1')
diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts
index aaaef6d4fa65..879db1fa1c38 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-table.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts
@@ -157,6 +157,8 @@ export function consumeQueuedMessageInTransaction(
sessionId: string
messageId: string
expect: 'waiting' | 'returned'
+ /** The body being sent: a card edited since it was read is not consumed with its old text. */
+ fingerprint: string
/** The fresh submission id; never the draft's own id. */
consumedAs: string
settledByOp: string | null
@@ -173,7 +175,7 @@ export function consumeQueuedMessageInTransaction(
`UPDATE queued_messages
SET state = 'dispatched', hold_reason = NULL, returned_reason = NULL, returned_rejection = NULL,
settled_at = ?, settled_by_op = ?, consumed_as = ?, host_instance = COALESCE(?, host_instance)
- WHERE session_id = ? AND message_id = ? AND state = ?`
+ WHERE session_id = ? AND message_id = ? AND state = ? AND fingerprint = ?`
)
.run(
input.now,
@@ -182,7 +184,8 @@ export function consumeQueuedMessageInTransaction(
input.hostInstance ?? null,
input.sessionId,
input.messageId,
- input.expect
+ input.expect,
+ input.fingerprint
)
return Number(changed.changes ?? 0) === 1
}
diff --git a/src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts b/src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts
index e9b57abf158d..96be06f6c76e 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts
@@ -66,6 +66,18 @@ describe('atomic queued text update', () => {
})
expect(after.fingerprint).toBe(agentSessionSendBodyFingerprint(sessionId, after.body))
})
+ it('saving the same text keeps several text blocks as they are', () => {
+ const body: AgentJournalMessageItem = {
+ ...initial,
+ blocks: [
+ { type: 'text', text: 'one' },
+ { type: 'text', text: 'two' }
+ ]
+ }
+ const before = insert(body)
+ expect(update('one\ntwo', before.fingerprint).status).toBe('unchanged')
+ expect(getQueuedMessage(db, sessionId, 'card')).toEqual(before)
+ })
it('retains returned-card refusal and failure hold rather than requeueing', () => {
insert()
db.prepare(
@@ -122,13 +134,34 @@ describe('atomic queued text update', () => {
expect(update('edit', unsupported.fingerprint).status).toBe('not-editable')
expect(getQueuedMessage(db, sessionId, 'card')).toEqual(unsupported)
})
- it('allows empty text with a retained attachment, inserting text before it', () => {
- const before = insert({ ...initial, blocks: [{ type: 'image-ref', path: 'file.png' }] })
+ it('allows empty text with a retained attachment, and inserts text before one with none', () => {
+ const before = insert({
+ ...initial,
+ blocks: [
+ { type: 'text', text: 'caption' },
+ { type: 'image-ref', path: 'file.png' }
+ ]
+ })
expect(update('', before.fingerprint).status).toBe('updated')
expect(getQueuedMessage(db, sessionId, 'card')?.body.blocks).toEqual([
{ type: 'text', text: '' },
{ type: 'image-ref', path: 'file.png' }
])
+ const imageOnly = insert(
+ { ...initial, blocks: [{ type: 'image-ref', path: 'file.png' }] },
+ 'image'
+ )
+ expect(
+ updateQueuedMessageText(db, sessionId, {
+ messageId: 'image',
+ expectedBodyFingerprint: imageOnly.fingerprint,
+ text: 'added'
+ }).status
+ ).toBe('updated')
+ expect(getQueuedMessage(db, sessionId, 'image')?.body.blocks).toEqual([
+ { type: 'text', text: 'added' },
+ { type: 'image-ref', path: 'file.png' }
+ ])
})
it('a failed transaction rolls back body and fingerprint together', () => {
const before = insert()
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts
index 82e7bca7bd9a..c7edc44d7cf4 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts
@@ -81,6 +81,9 @@ function publication() {
function published(id: string) {
return publication().queuedMessages.find((message) => message.messageId === id)
}
+function held() {
+ return [...journal().queuedMessages.editLeases.heldIds()]
+}
describe('saving a queued card in place', () => {
it('keeps id, position and sender; text that reads like a command stays text', async () => {
@@ -184,7 +187,7 @@ describe('edit leases and automatic delivery', () => {
const edited = await queue('edited')
const last = await queue('last')
expect(await hold(edited)).toMatchObject({ status: 'held', leaseDurationMs: 120_000 })
- expect(published(edited)?.editHeld).toBe(true)
+ expect(held()).toEqual([edited])
expect(published(edited)?.paused).toBeUndefined()
await rig.settleAccepted(work, 'work-done')
await eventually(async () => expect(await rig.handoff(first)).toBeDefined())
@@ -211,7 +214,7 @@ describe('edit leases and automatic delivery', () => {
expect(await update(id, fingerprint(id), 'after')).toMatchObject({
value: { status: 'updated' }
})
- expect(published(id)?.editHeld).toBeUndefined()
+ expect(held()).toEqual([])
await hold(id, 'release')
await eventually(async () => expect(await rig.handoff(id)).toBeDefined())
const submissions = (await rig.host.journalSnapshot(HOST_TEST_SESSION)).submissions
@@ -224,18 +227,21 @@ describe('edit leases and automatic delivery', () => {
)
})
- it("one editor's release keeps another's hold; a renewal re-sends nothing", async () => {
+ it("one editor's release keeps another's hold; a renewal re-sends nothing and is not activity", async () => {
await rig.workingSend()
const id = await queue('base')
await hold(id)
await hold(id, 'acquire', 'edit-1', { callerKey: 'phone' })
const before = publication()
+ const touch = vi.spyOn(rig.host.collaboratorsForTests().sessions, 'touch')
expect(await hold(id, 'renew')).toMatchObject({ status: 'held' })
+ // An open editor does not keep the agent's process from its idle stop.
+ expect(touch).not.toHaveBeenCalled()
expect(publication()).toBe(before)
expect(await hold(id, 'release')).toEqual({ status: 'released' })
- expect(published(id)?.editHeld).toBe(true)
+ expect(held()).toEqual([id])
await hold(id, 'release', 'edit-1', { callerKey: 'phone' })
- expect(published(id)?.editHeld).toBeUndefined()
+ expect(held()).toEqual([])
})
it('acquiring after delivery won answers gone; Save without a lease still works', async () => {
@@ -289,7 +295,7 @@ describe('edit leases and automatic delivery', () => {
await hold(id)
await rig.stop()
await rig.resume()
- expect(published(id)).toMatchObject({ editHeld: true })
+ expect(held()).toEqual([id])
expect(published(id)?.paused).toBeUndefined()
expect(publication().nextQueuedMessageId).toBeNull()
})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts
index 6a17d8851f7f..815fcbe5f5c4 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts
@@ -64,8 +64,7 @@ export function updateQueuedStructuredAgentMessage(
export function holdQueuedStructuredAgentMessageEdit(
context: StructuredAgentSessionMutationContext,
caller: StructuredAgentSessionCaller,
- params: AgentSessionQueuedMessageEditHoldParams,
- touch: (sessionId: string) => void
+ params: AgentSessionQueuedMessageEditHoldParams
): Promise {
const { sessionId, messageId } = params
const key = { callerKey: caller.callerKey, editId: params.editId }
@@ -82,22 +81,17 @@ export function holdQueuedStructuredAgentMessageEdit(
if (!session) {
return { status: 'gone' }
}
- // An open editor is activity: the idle sweep must not close the conversation under it.
- touch(sessionId)
const queued = session.journal.queuedMessages
- let result: AgentSessionQueuedMessageEditHoldResult
- if (params.action === 'acquire') {
- const row = queued.get(messageId)
- result =
- row && queuedMessageEditableText(row.body) === null
- ? { status: 'not-editable' }
- : queued.editLeases.acquire(key, {
- messageId,
- fingerprint: params.expectedBodyFingerprint
- })
- } else {
- result = queued.editLeases.renew(key, messageId)
+ if (params.action !== 'acquire') {
+ // Moves no held card, so nothing to publish; and a publish is idle-sweep activity, which an
+ // editor left open must not renew forever.
+ return queued.editLeases.renew(key, messageId)
}
+ const row = queued.get(messageId)
+ const result: AgentSessionQueuedMessageEditHoldResult =
+ row && queuedMessageEditableText(row.body) === null
+ ? { status: 'not-editable' }
+ : queued.editLeases.acquire(key, { messageId, fingerprint: params.expectedBodyFingerprint })
republish(context, sessionId, session.journal)
return result
})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts
index fd8c2d553fcd..d48d9bf27c39 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts
@@ -42,12 +42,8 @@ export function structuredQueueSendGate(
}
/** Waiting and returned rows only. `paused` is a per-card hold (a failed conversion); a person's
- * Stop pauses the queue, published once beside it. `editHeld` is never `paused`: an older client
- * would caption it with a hold whose release it does not own. */
-function computePublishedQueuedMessages(
- journal: AgentSessionJournal,
- edited: ReadonlySet
-): AgentSessionQueuedMessage[] {
+ * Stop pauses the queue, published once beside it. */
+function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] {
const published: AgentSessionQueuedMessage[] = []
for (const row of journal.queuedMessages.list()) {
if (row.state !== 'waiting' && row.state !== 'returned') {
@@ -59,7 +55,6 @@ function computePublishedQueuedMessages(
position: row.position,
body: row.body,
state: row.state,
- ...(edited.has(row.messageId) ? { editHeld: true as const } : {}),
...(held ? { paused: true as const } : {}),
// The stored reason is a typed marker; an unknown one reads as a plain hold.
...(held && isPublishedPausedReason(row.holdReason) ? { pausedReason: row.holdReason } : {}),
@@ -87,14 +82,12 @@ const listMemos = new WeakMap()
const publications = new WeakMap()
function readPublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] {
- // A renewal moves no held set, so it re-sends nothing.
- const edited = journal.queuedMessages.editLeases.heldIds()
- const key = JSON.stringify([journal.queuedMessages.revision(), ...edited])
+ const key = String(journal.queuedMessages.revision())
const memo = listMemos.get(journal)
if (memo && memo.key === key) {
return memo.list
}
- const list = computePublishedQueuedMessages(journal, edited)
+ const list = computePublishedQueuedMessages(journal)
// Belt for the identity dedup: equal recomputed content keeps the previous reference.
const serialized = JSON.stringify(list)
if (memo && memo.serialized === serialized) {
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts
index bf7a61823268..fc8078ad649f 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts
@@ -69,8 +69,7 @@ export function wireStructuredAgentSessionQueuedMessages(
queuedMessageEditHold: (
caller: StructuredAgentSessionCaller,
params: Parameters[2]
- ) =>
- holdQueuedStructuredAgentMessageEdit(context(), caller, params, (id) => sessions.touch(id)),
+ ) => holdQueuedStructuredAgentMessageEdit(context(), caller, params),
/** Every journal publish: turn, submission, prompt, command and Stop
* settlements are all commits, and each re-derives the drain's gates. */
onJournalActivity: (sessionId: string) => {
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
index 953697efc0e6..052ef4e92d53 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
@@ -211,8 +211,7 @@ export function NativeChatQueuedMessageCard({
variant="ghost"
size="xs"
onClick={onSteer}
- // Someone is editing it on another device; their Save, Cancel or lapse frees it.
- disabled={steerHeld || card.editHeld}
+ disabled={steerHeld}
>
{sendNow.steers ? (
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts
index a9f16bf0f4e2..4192b552d791 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts
@@ -34,8 +34,6 @@ export type QueuedMessageCard = {
/** The draft's text blocks joined; drafts are text-only in v1. */
text: string
state: 'waiting' | 'returned'
- /** Someone is editing it in place, here or on another device: it does not send meanwhile. */
- editHeld?: true
hold: QueuedMessageCardHold
/** A conversation command such as /compact: it never steers into a running turn. */
command?: true
@@ -92,7 +90,6 @@ export function projectQueuedMessageCards(
position: message.position,
text: queuedMessageCardText(message.body),
state: message.state,
- ...(message.editHeld ? { editHeld: true as const } : {}),
hold,
...(message.body.command !== undefined
? {
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx
index 711ed4de5bf6..ef702f474671 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx
@@ -76,6 +76,7 @@ type Props = {
messages: AgentSessionQueuedMessage[] | null
sessionId?: string
submissions?: AgentJournalSubmission[]
+ promptOpen?: boolean
}
function harness(capable = true, initial: Props = { messages: [message] }) {
return renderHook(
@@ -91,7 +92,8 @@ function harness(capable = true, initial: Props = { messages: [message] }) {
messages: props.messages,
cards: projectQueuedMessageCards(props.messages, submissions, { hasPendingPrompt: true }),
submissions,
- composerScopeKey: SCOPE
+ composerScopeKey: SCOPE,
+ promptOpen: props.promptOpen ?? false
})
},
{ initialProps: initial }
@@ -107,6 +109,9 @@ function holdCalls(action: string) {
function done(value: AgentSessionQueuedMessageUpdateResult): () => Promise {
return async () => ({ kind: 'done', value })
}
+function withText(text: string): AgentSessionQueuedMessage {
+ return { ...message, body: { ...message.body, blocks: [{ type: 'text', text }] } }
+}
beforeEach(() => {
rpc.mockClear()
@@ -225,22 +230,42 @@ describe('inline queued editor', () => {
expect(toast.error).not.toHaveBeenCalled()
})
- it('changed keeps the typing and the original base; a second Save never overwrites', async () => {
+ it('changed puts the typing in the chat box and reopens on the newer text once it shows', async () => {
+ writeNativeChatDraftCache(SCOPE, 'existing')
answer = done({ status: 'changed', messageId: 'card' })
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('local'))
act(() => hook.result.current.editor?.save())
- await waitFor(() => expect(toast.error).toHaveBeenCalledOnce())
- const remote = {
- ...message,
- body: { ...message.body, blocks: [{ type: 'text' as const, text: 'remote' }] }
- }
+ await waitFor(() => expect(hook.result.current.editor).toBeUndefined())
+ expect(readNativeChatDraftCache(SCOPE)).toBe('existing\n\nlocal')
+ expect(toast.error).toHaveBeenCalledExactlyOnceWith(
+ 'This message changed while you were editing. Your edit is in the chat box.'
+ )
+ // The newer text has not reached this pane yet: nothing opens on the old one.
+ hook.rerender({ messages: [message] })
+ expect(hook.result.current.editor).toBeUndefined()
+ const remote = withText('remote')
hook.rerender({ messages: [remote] })
- expect(hook.result.current.editor).toMatchObject({ text: 'local', saving: false })
+ await waitFor(() => expect(hook.result.current.editor).toMatchObject({ text: 'remote' }))
+ expect(holdCalls('acquire')[1]?.[2]).toMatchObject({
+ expectedBodyFingerprint: agentSessionSendBodyFingerprint(SESSION, remote.body)
+ })
+ expect(writeSpy).toHaveBeenCalledOnce()
+ expect(toast.error).toHaveBeenCalledOnce()
+ })
+
+ it('while a question holds the chat box, the notice says when the edit will be there', async () => {
+ answer = done({ status: 'gone', messageId: 'card', disposition: 'dispatched' })
+ const hook = harness(true, { messages: [message], promptOpen: true })
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('unsaved'))
act(() => hook.result.current.editor?.save())
- await waitFor(() => expect(writeSpy).toHaveBeenCalledTimes(2))
- expect(writeSpy.mock.calls[1]?.[2]).toEqual(writeSpy.mock.calls[0]?.[2])
+ await waitFor(() => expect(hook.result.current.editor).toBeUndefined())
+ expect(toast.error).toHaveBeenCalledExactlyOnceWith(
+ 'This message was already sent or removed. Your edit will be in the chat box after you answer the agent.'
+ )
+ expect(readNativeChatDraftCache(SCOPE)).toBe('unsaved')
})
it('a refused Save says why once and keeps the editor', async () => {
@@ -259,7 +284,7 @@ describe('inline queued editor', () => {
expect(hook.result.current.editor).toMatchObject({ text: 'long', saving: false })
})
- it('Save answering gone puts the typing in the chat box; another chat drops the edit', async () => {
+ it('Save answering gone puts the typing in the chat box; another chat keeps it there quietly', async () => {
answer = done({ status: 'gone', messageId: 'card', disposition: 'dispatched' })
const hook = harness()
await begin(hook)
@@ -267,14 +292,92 @@ describe('inline queued editor', () => {
act(() => hook.result.current.editor?.save())
await waitFor(() => expect(hook.result.current.editor).toBeUndefined())
expect(readNativeChatDraftCache(SCOPE)).toBe('unsaved')
+ expect(toast.error).toHaveBeenCalledOnce()
await begin(hook)
act(() => hook.result.current.editor?.change('another draft'))
hook.rerender({ messages: [message], sessionId: 'another-session' })
expect(hook.result.current.editor).toBeUndefined()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('unsaved\n\nanother draft')
+ expect(toast.error).toHaveBeenCalledOnce()
+ await waitFor(() => expect(holdCalls('release')).toHaveLength(2))
+ })
+
+ it('closing the pane keeps unsaved typing in the chat box, quietly; clean edits leave nothing', async () => {
+ const dirty = harness()
+ await begin(dirty)
+ act(() => dirty.result.current.editor?.change('unsaved'))
+ dirty.unmount()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('unsaved')
+ const clean = harness()
+ await begin(clean)
+ clean.unmount()
expect(readNativeChatDraftCache(SCOPE)).toBe('unsaved')
+ expect(toast.error).not.toHaveBeenCalled()
await waitFor(() => expect(holdCalls('release')).toHaveLength(2))
})
+ describe('a Save whose answer this pane cannot read reads the published card', () => {
+ const dropped: () => Promise = async () => ({ kind: 'dropped' })
+ function deferred() {
+ let finish: (outcome: Outcome) => void = () => {}
+ answer = () =>
+ new Promise((resolve) => {
+ finish = resolve
+ })
+ return (outcome: Outcome) => act(async () => finish(outcome))
+ }
+
+ it('the card already shows the saved text: the editor closes, quietly', async () => {
+ const finish = deferred()
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('saved'))
+ act(() => hook.result.current.editor?.save())
+ hook.rerender({ messages: [withText('saved')] })
+ await finish({ kind: 'dropped' })
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(toast.error).not.toHaveBeenCalled()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('')
+ })
+
+ it('a lost answer for a Save that landed says nothing either', async () => {
+ const finish = deferred()
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('saved'))
+ act(() => hook.result.current.editor?.save())
+ hook.rerender({ messages: [withText('saved')] })
+ await finish({ kind: 'not-done', notice: 'lost', failure: { kind: 'unconfirmed' } })
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ it('the card still shows other text: the editor stays as it was, quietly', async () => {
+ answer = dropped
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('typed'))
+ act(() => hook.result.current.editor?.save())
+ await waitFor(() => expect(hook.result.current.editor?.saving).toBe(false))
+ expect(hook.result.current.editor).toMatchObject({ text: 'typed' })
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ it('the card left with its old text (sent elsewhere first): the typing goes to the chat box', async () => {
+ const finish = deferred()
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('typed'))
+ act(() => hook.result.current.editor?.save())
+ hook.rerender({ messages: [] })
+ expect(hook.result.current.editor?.saving).toBe(true)
+ await finish({ kind: 'dropped' })
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('typed')
+ expect(toast.error).toHaveBeenCalledOnce()
+ })
+ })
+
it('a Save that lands and then drains reads as success, even with its answer lost', async () => {
let finish: (outcome: Outcome) => void = () => {}
answer = () =>
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
index 2504b0b4b31a..a423c76bb28d 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
@@ -48,6 +48,8 @@ export type QueuedMessageInlineEditor = {
type Edit = {
scope: string
+ /** The chat box this edit's typing falls back to; the one it started in, never another. */
+ draftKey: string
messageId: string
body: AgentSessionQueuedMessage['body']
originalText: string
@@ -59,18 +61,31 @@ type Edit = {
lease: QueuedEditLease
}
-function editNotice(key: 'editChanged' | 'editFailed' | 'editGone'): string {
+/** A question or approval holds the chat box's place, so the text there is out of sight until then. */
+function editNotice(
+ key: 'editChanged' | 'editChangedPrompt' | 'editFailed' | 'editGone' | 'editGonePrompt'
+): string {
switch (key) {
case 'editChanged':
return translate(
'components.native-chat.queuedMessages.editChanged',
- 'This message changed while you were editing. Cancel and edit it again to change the latest text.'
+ 'This message changed while you were editing. Your edit is in the chat box.'
+ )
+ case 'editChangedPrompt':
+ return translate(
+ 'components.native-chat.queuedMessages.editChangedPrompt',
+ 'This message changed while you were editing. Your edit will be in the chat box after you answer the agent.'
)
case 'editGone':
return translate(
'components.native-chat.queuedMessages.editGone',
'This message was already sent or removed. Your edit is in the chat box.'
)
+ case 'editGonePrompt':
+ return translate(
+ 'components.native-chat.queuedMessages.editGonePrompt',
+ 'This message was already sent or removed. Your edit will be in the chat box after you answer the agent.'
+ )
case 'editFailed':
return translate(
'components.native-chat.queuedMessages.editFailed',
@@ -87,16 +102,20 @@ export function useStructuredAgentSessionQueuedEdit(args: {
cards: readonly QueuedMessageCard[]
submissions: readonly AgentJournalSubmission[]
composerScopeKey: string | undefined
+ /** A question or approval holds the chat box's place. */
+ promptOpen: boolean
}) {
- const { transport, messages, cards, submissions, composerScopeKey } = args
+ const { transport, messages, cards, submissions, composerScopeKey, promptOpen } = args
const { target, sessionId, capable, write } = transport
const scope = `${structuredAgentSessionHostKey(target)}:${sessionId}:${composerScopeKey ?? ''}`
const active = useRef(null)
const [shown, setShown] = useState(null)
- const latest = useRef({ cards, submissions })
+ /** Save answered `changed`: the editor reopens on the newer text once this pane shows it. */
+ const reopen = useRef<{ scope: string; messageId: string; stale: string } | null>(null)
+ const latest = useRef({ messages, cards, submissions, promptOpen })
useEffect(() => {
- latest.current = { cards, submissions }
- }, [cards, submissions])
+ latest.current = { messages, cards, submissions, promptOpen }
+ }, [messages, cards, submissions, promptOpen])
const render = useCallback((edit: Edit | null) => {
active.current = edit
@@ -114,20 +133,25 @@ export function useStructuredAgentSessionQueuedEdit(args: {
if (active.current !== edit) {
return
}
- if (edit.text !== edit.originalText && composerScopeKey) {
- appendNativeChatDraftCache(composerScopeKey, edit.text)
- toast.error(editNotice('editGone'))
+ if (edit.text !== edit.originalText) {
+ appendNativeChatDraftCache(edit.draftKey, edit.text)
+ toast.error(editNotice(latest.current.promptOpen ? 'editGonePrompt' : 'editGone'))
}
close()
},
- [close, composerScopeKey]
+ [close]
)
- // Another chat, host or composer: the edit belongs to the one it started in.
+ // Another chat, host or composer, or the pane going away: unsaved typing goes quietly to the
+ // chat box of the chat it was typed in.
useEffect(
() => () => {
const edit = active.current
active.current = null
+ reopen.current = null
+ if (edit && edit.text !== edit.originalText) {
+ appendNativeChatDraftCache(edit.draftKey, edit.text)
+ }
edit?.lease.end()
},
[scope]
@@ -136,6 +160,7 @@ export function useStructuredAgentSessionQueuedEdit(args: {
const begin = useCallback(
async (messageId: string): Promise => {
// One editor per pane: another card's Edit waits for this one's Save or Cancel.
+ reopen.current = null
if (active.current || !capable || !composerScopeKey) {
return
}
@@ -154,6 +179,7 @@ export function useStructuredAgentSessionQueuedEdit(args: {
})
const edit: Edit = {
scope,
+ draftKey: composerScopeKey,
messageId,
body: message.body,
originalText: text,
@@ -218,15 +244,33 @@ export function useStructuredAgentSessionQueuedEdit(args: {
return
}
edit.saving = false
- const status = result.kind === 'done' ? result.value.status : null
- if (status === 'updated' || status === 'unchanged' || result.kind === 'dropped') {
- close()
- } else if (status === 'gone') {
- gone(edit)
- } else if (result.kind === 'not-done' && !onScreen(latest.current.cards, edit.messageId)) {
- // The answer was lost and the card has since left: it went out with this Save's text, or
- // it went before the Save could land.
- const desired = agentSessionSendBodyFingerprint(sessionId, body)
+ if (result.kind === 'done') {
+ const { status } = result.value
+ if (status === 'updated' || status === 'unchanged') {
+ close()
+ } else if (status === 'gone') {
+ gone(edit)
+ } else if (status === 'changed') {
+ // Someone else's edit landed first: the typing goes to the chat box, and the editor
+ // opens again on the newer text.
+ appendNativeChatDraftCache(edit.draftKey, edit.text)
+ toast.error(editNotice(latest.current.promptOpen ? 'editChangedPrompt' : 'editChanged'))
+ reopen.current = { scope, messageId: edit.messageId, stale: edit.baseFingerprint }
+ close()
+ } else {
+ toast.error(editNotice('editFailed'))
+ render(edit)
+ }
+ return
+ }
+ // No answer this pane can read (a lost reply, or one dropped for a moved runtime fence, which
+ // a text edit does not depend on): the published card says whether this Save landed.
+ const listed = latest.current.messages?.find((entry) => entry.messageId === edit.messageId)
+ const desired = agentSessionSendBodyFingerprint(sessionId, body)
+ if (latest.current.messages === null) {
+ render(edit)
+ } else if (!listed || !onScreen(latest.current.cards, edit.messageId)) {
+ // The card has left: it went out with this Save's text, or before the Save could land.
const sentAsSaved = latest.current.submissions.some(
(entry) => entry.queuedMessageId === edit.messageId && entry.payloadFingerprint === desired
)
@@ -235,15 +279,16 @@ export function useStructuredAgentSessionQueuedEdit(args: {
} else {
gone(edit)
}
+ } else if (agentSessionSendBodyFingerprint(sessionId, listed.body) === desired) {
+ close()
} else {
- toast.error(
- result.kind === 'not-done'
- ? result.notice
- : editNotice(status === 'changed' ? 'editChanged' : 'editFailed')
- )
+ // A refusal says why; an unread answer leaves the editor as it was for Save again.
+ if (result.kind === 'not-done') {
+ toast.error(result.notice)
+ }
render(edit)
}
- }, [close, gone, render, sessionId, write])
+ }, [close, gone, render, scope, sessionId, write])
// Sent or removed elsewhere, or a lapsed lease lost the race to delivery. While a Save is out,
// its answer decides instead: its own text going out right after it lands is a success.
@@ -253,7 +298,17 @@ export function useStructuredAgentSessionQueuedEdit(args: {
if (edit && !edit.saving && messages !== null && !onScreen(cards, edit.messageId)) {
gone(edit)
}
- }, [cards, gone, messages, shown])
+ const pending = reopen.current
+ if (!pending || edit || messages === null) {
+ return
+ }
+ const listed = messages.find((entry) => entry.messageId === pending.messageId)
+ if (pending.scope !== scope || !listed || !onScreen(cards, pending.messageId)) {
+ reopen.current = null
+ } else if (agentSessionSendBodyFingerprint(sessionId, listed.body) !== pending.stale) {
+ void begin(pending.messageId)
+ }
+ }, [begin, cards, gone, messages, scope, sessionId, shown])
const editor: QueuedMessageInlineEditor | undefined =
shown && shown.scope === scope
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx
index f9cd690fb67f..d4a34cbdee47 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx
@@ -109,14 +109,6 @@ describe('queued message actions', () => {
expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
- it('the steer chord consumes a held newest card without sending it or choosing an earlier card', () => {
- const harness = createHarness({
- queuedMessages: [draft('earlier', 1), { ...draft('newest', 2), editHeld: true }]
- })
- expect(harness.result.current.steerNewest()).toBe(true)
- expect(harness.mutate).not.toHaveBeenCalled()
- })
-
it('a host that cannot edit in place offers no Edit and never copies or deletes', async () => {
const harness = createHarness()
await act(() => harness.result.current.edit('draft-1'))
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts
index 6f0954b107a7..cd6342a6140e 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts
@@ -180,10 +180,10 @@ export function useStructuredAgentSessionQueuedMessages(args: {
messages: queuedMessages,
cards,
submissions,
- composerScopeKey
+ composerScopeKey,
+ promptOpen: hasPendingPrompt
})
- const editedId = editing.editor?.messageId
const steerNewest = useCallback((): boolean => {
if (!enabled) {
return false
@@ -192,12 +192,9 @@ export function useStructuredAgentSessionQueuedMessages(args: {
if (!newest) {
return false
}
- // A card being edited, here or elsewhere, is not sent by the chord, nor is another in its place.
- if (!newest.editHeld && editedId !== newest.messageId) {
- void steer(newest.messageId)
- }
+ void steer(newest.messageId)
return true
- }, [editedId, enabled, steer])
+ }, [enabled, steer])
const resumingRef = useRef(false)
const [resuming, setResuming] = useState(false)
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 7e13a6e28563..f7a7527db52b 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -18646,9 +18646,11 @@
"pausedSendFailedWaiting": "Couldn't send — press Send to retry once the agent finishes.",
"save": "Save",
"cancel": "Cancel",
- "editChanged": "This message changed while you were editing. Cancel and edit it again to change the latest text.",
+ "editChanged": "This message changed while you were editing. Your edit is in the chat box.",
+ "editChangedPrompt": "This message changed while you were editing. Your edit will be in the chat box after you answer the agent.",
"editFailed": "This message can't be edited.",
- "editGone": "This message was already sent or removed. Your edit is in the chat box."
+ "editGone": "This message was already sent or removed. Your edit is in the chat box.",
+ "editGonePrompt": "This message was already sent or removed. Your edit will be in the chat box after you answer the agent."
},
"thought": "Thought",
"thoughtForDuration": "Thought for {{duration}}",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 04dccc6438a7..803325cb90d9 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -18258,9 +18258,11 @@
"pausedSendFailedWaiting": "No se pudo enviar. Cuando el agente termine, pulsa Enviar para reintentar.",
"save": "Guardar",
"cancel": "Cancelar",
- "editChanged": "Este mensaje cambió mientras lo editabas. Cancela y vuelve a editarlo para cambiar el texto más reciente.",
+ "editChanged": "Este mensaje cambió mientras lo editabas. Tu edición está en el cuadro de chat.",
+ "editChangedPrompt": "Este mensaje cambió mientras lo editabas. Tu edición estará en el cuadro de chat cuando respondas al agente.",
"editFailed": "Este mensaje no se puede editar.",
- "editGone": "Este mensaje ya se envió o se eliminó. Tu edición está en el cuadro de chat."
+ "editGone": "Este mensaje ya se envió o se eliminó. Tu edición está en el cuadro de chat.",
+ "editGonePrompt": "Este mensaje ya se envió o se eliminó. Tu edición estará en el cuadro de chat cuando respondas al agente."
},
"thought": "Pensó",
"thoughtForDuration": "Pensó durante {{duration}}",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index 4f49d766d91a..40ac228e29bc 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -18495,9 +18495,11 @@
"pausedSendFailedWaiting": "Envoi impossible. Quand l'agent aura terminé, appuyez sur Envoyer pour réessayer.",
"save": "Enregistrer",
"cancel": "Annuler",
- "editChanged": "Ce message a changé pendant que vous le modifiiez. Annulez puis modifiez-le à nouveau pour changer le texte le plus récent.",
+ "editChanged": "Ce message a changé pendant que vous le modifiiez. Votre modification se trouve dans le champ de discussion.",
+ "editChangedPrompt": "Ce message a changé pendant que vous le modifiiez. Votre modification sera dans le champ de discussion une fois que vous aurez répondu à l'agent.",
"editFailed": "Ce message ne peut pas être modifié.",
- "editGone": "Ce message a déjà été envoyé ou supprimé. Votre modification se trouve dans le champ de discussion."
+ "editGone": "Ce message a déjà été envoyé ou supprimé. Votre modification se trouve dans le champ de discussion.",
+ "editGonePrompt": "Ce message a déjà été envoyé ou supprimé. Votre modification sera dans le champ de discussion une fois que vous aurez répondu à l'agent."
},
"thought": "A réfléchi",
"thoughtForDuration": "A réfléchi pendant {{duration}}",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 855814d0c0a6..8baf1373c46e 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -18429,9 +18429,11 @@
"pausedSendFailedWaiting": "送信できませんでした。Agent の処理が終わったら「送信」を押して再試行してください。",
"save": "保存",
"cancel": "キャンセル",
- "editChanged": "編集中にこのメッセージが変更されました。キャンセルしてもう一度編集すると、最新のテキストを変更できます。",
+ "editChanged": "編集中にこのメッセージが変更されました。編集したテキストはチャット入力欄にあります。",
+ "editChangedPrompt": "編集中にこのメッセージが変更されました。エージェントに回答すると、編集したテキストがチャット入力欄に表示されます。",
"editFailed": "このメッセージは編集できません。",
- "editGone": "このメッセージはすでに送信または削除されています。編集したテキストはチャット入力欄にあります。"
+ "editGone": "このメッセージはすでに送信または削除されています。編集したテキストはチャット入力欄にあります。",
+ "editGonePrompt": "このメッセージはすでに送信または削除されています。エージェントに回答すると、編集したテキストがチャット入力欄に表示されます。"
},
"thought": "考えました",
"thoughtForDuration": "{{duration}} 考えました",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 66f135acf025..dfda18e522f7 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -18429,9 +18429,11 @@
"pausedSendFailedWaiting": "보낼 수 없습니다. 에이전트가 끝나면 보내기를 눌러 다시 시도하세요.",
"save": "저장",
"cancel": "취소",
- "editChanged": "편집하는 동안 이 메시지가 변경되었습니다. 취소한 다음 다시 편집하면 최신 내용을 변경할 수 있습니다.",
+ "editChanged": "편집하는 동안 이 메시지가 변경되었습니다. 편집한 내용은 채팅 입력란에 있습니다.",
+ "editChangedPrompt": "편집하는 동안 이 메시지가 변경되었습니다. 에이전트에 답하면 편집한 내용이 채팅 입력란에 표시됩니다.",
"editFailed": "이 메시지는 편집할 수 없습니다.",
- "editGone": "이 메시지는 이미 전송되었거나 삭제되었습니다. 편집한 내용은 채팅 입력란에 있습니다."
+ "editGone": "이 메시지는 이미 전송되었거나 삭제되었습니다. 편집한 내용은 채팅 입력란에 있습니다.",
+ "editGonePrompt": "이 메시지는 이미 전송되었거나 삭제되었습니다. 에이전트에 답하면 편집한 내용이 채팅 입력란에 표시됩니다."
},
"thought": "생각함",
"thoughtForDuration": "{{duration}} 동안 생각함",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index bbbf87c80a03..c46d440b0e1c 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -18394,9 +18394,11 @@
"pausedSendFailedWaiting": "无法发送。智能体完成后,点按发送以重试。",
"save": "保存",
"cancel": "取消",
- "editChanged": "你编辑时此消息已更改。请取消并重新编辑,以更改最新内容。",
+ "editChanged": "你编辑时此消息已更改。你编辑的内容已放入聊天输入框。",
+ "editChangedPrompt": "你编辑时此消息已更改。回答智能体后,你编辑的内容会出现在聊天输入框中。",
"editFailed": "此消息无法编辑。",
- "editGone": "此消息已发送或移除。你编辑的内容已放入聊天输入框。"
+ "editGone": "此消息已发送或移除。你编辑的内容已放入聊天输入框。",
+ "editGonePrompt": "此消息已发送或移除。回答智能体后,你编辑的内容会出现在聊天输入框中。"
},
"thought": "已思考",
"thoughtForDuration": "思考了 {{duration}}",
diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts
index ac80de57c6ce..c2e9ddbbf633 100644
--- a/src/shared/agent-session-queued-message-wire.ts
+++ b/src/shared/agent-session-queued-message-wire.ts
@@ -40,9 +40,6 @@ export type AgentSessionQueuedMessage = {
state: 'waiting' | 'returned'
/** This one card is held, whatever the queue's pause: its conversion failed. */
paused?: true
- /** Someone is editing this card in place: automatic delivery waits at it until the edit ends
- * or its lease expires. Absent on older hosts, and never paired with `paused`. */
- editHeld?: true
/** Why it is held, as a marker the client localizes: 'send_failed' ("couldn't send"); only an
* explicit Send releases it. A client must treat an unknown marker as a plain hold, so a newer
* host can add one. The queue-level pause is `queuePause`, published beside the list. */
diff --git a/src/shared/queued-message-text-edit.ts b/src/shared/queued-message-text-edit.ts
index 219232d24909..47c1f5100939 100644
--- a/src/shared/queued-message-text-edit.ts
+++ b/src/shared/queued-message-text-edit.ts
@@ -23,6 +23,10 @@ export function queuedMessageWithEditedText(
) {
return null
}
+ // Unchanged text keeps its blocks, so a Save that changes nothing is a no-op.
+ if (text === queuedMessageEditableText(body)) {
+ return body
+ }
const blocks: AgentJournalMessageItem['blocks'] = []
let placed = false
for (const block of body.blocks) {
From 3a4d7e7629130dbfc3e95d029c86fb3297500809 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 8 Oct 2026 23:53:43 -0700
Subject: [PATCH 05/12] fix(native-chat): keep Agent in the Japanese
queued-edit notices, as the catalog does
---
src/renderer/src/i18n/locales/ja.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 935389abbaf7..1741a617e637 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -18446,10 +18446,10 @@
"save": "保存",
"cancel": "キャンセル",
"editChanged": "編集中にこのメッセージが変更されました。編集したテキストはチャット入力欄にあります。",
- "editChangedPrompt": "編集中にこのメッセージが変更されました。エージェントに回答すると、編集したテキストがチャット入力欄に表示されます。",
+ "editChangedPrompt": "編集中にこのメッセージが変更されました。Agent に回答すると、編集したテキストがチャット入力欄に表示されます。",
"editFailed": "このメッセージは編集できません。",
"editGone": "このメッセージはすでに送信または削除されています。編集したテキストはチャット入力欄にあります。",
- "editGonePrompt": "このメッセージはすでに送信または削除されています。エージェントに回答すると、編集したテキストがチャット入力欄に表示されます。"
+ "editGonePrompt": "このメッセージはすでに送信または削除されています。Agent に回答すると、編集したテキストがチャット入力欄に表示されます。"
},
"thought": "考えました",
"thoughtForDuration": "{{duration}} 考えました",
From 52b0d547bf9c8b2b3f6b2ed2a2d2047bfd1abee6 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 9 Oct 2026 06:13:22 -0700
Subject: [PATCH 06/12] fix(native-chat): close a queued edit its own Save
already landed, wake the queue for a pruned lease, keep typing across a
reload
A Save whose answer was lost now closes once the card shows the saved text,
and a card that went out with exactly the typed text closes quietly instead
of copying it to the chat box. A renew that prunes another editor's lapsed
lease wakes the queue in place of the timer it cancels. A reload, which
unmounts nothing, hands unsaved typing to the chat box like closing the pane.
---
.../queued-message-edit-leases.test.ts | 14 ++++
.../queued-message-edit-leases.ts | 6 ++
.../structured-agent-session-queued-edit.ts | 8 +-
...uctured-agent-session-queued-edit.test.tsx | 73 +++++++++++++++----
...se-structured-agent-session-queued-edit.ts | 55 ++++++++++----
5 files changed, 122 insertions(+), 34 deletions(-)
diff --git a/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts
index e712ef18a35e..7d85e0a58c5b 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts
@@ -88,6 +88,20 @@ describe('queued edit leases', () => {
expect(leases.renew(desktop, 'card')).toEqual({ status: 'expired' })
})
+ it("a renew that prunes another editor's lapsed lease before its timer runs still wakes the queue", () => {
+ rows.push({ ...card('second'), position: 2, fingerprint: 'second-base' })
+ const phone = { callerKey: 'phone', editId: 'edit-9' }
+ leases.acquire(desktop, base)
+ clock = 10_000
+ leases.acquire(phone, { messageId: 'second', fingerprint: 'second-base' })
+ // The first deadline has passed on the host clock; its timer is late and has not run.
+ clock = QUEUED_MESSAGE_EDIT_LEASE_MS + 1
+ expect(leases.renew(phone, 'second')).toMatchObject({ status: 'held' })
+ expect([...leases.heldIds()]).toEqual(['second'])
+ vi.advanceTimersByTime(QUEUED_MESSAGE_EDIT_LEASE_MS - 1)
+ expect(wake).toHaveBeenCalledOnce()
+ })
+
it('release frees only the exact caller and edit, never another editor on the same card', () => {
leases.acquire(desktop, base)
leases.acquire({ callerKey: 'phone', editId: 'edit-1' }, base)
diff --git a/src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts
index d25b749fdc3d..18016f97bc81 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts
@@ -39,13 +39,19 @@ export class QueuedMessageEditLeases {
}
const byId = new Map(rows.map((row) => [row.messageId, row]))
const now = this.now()
+ let lapsed = false
for (const [key, lease] of this.leases) {
const row = byId.get(lease.messageId)
+ lapsed ||= lease.until <= now
if (lease.until <= now || !row || !leaseMatchesRow(lease, row)) {
this.leases.delete(key)
}
}
this.schedule()
+ // A deadline pruned here before its timer ran: that timer is gone, so this wakes in its place.
+ if (lapsed) {
+ this.wake?.()
+ }
return new Set([...this.leases.values()].map((lease) => lease.messageId))
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts
index 815fcbe5f5c4..d7cc959662e6 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts
@@ -83,8 +83,8 @@ export function holdQueuedStructuredAgentMessageEdit(
}
const queued = session.journal.queuedMessages
if (params.action !== 'acquire') {
- // Moves no held card, so nothing to publish; and a publish is idle-sweep activity, which an
- // editor left open must not renew forever.
+ // Holds nothing new, so nothing to publish (a lapsed lease it prunes wakes the queue itself);
+ // and a publish is idle-sweep activity, which an editor left open must not renew forever.
return queued.editLeases.renew(key, messageId)
}
const row = queued.get(messageId)
@@ -97,8 +97,8 @@ export function holdQueuedStructuredAgentMessageEdit(
})
}
-/** Leases live outside the journal, so no commit announces them: publish the held marker and
- * wake the drain here. A failed publish is logged; the lease still stands. */
+/** Leases live outside the journal, so no commit announces them: publish the next card to send
+ * and wake the drain here. A failed publish is logged; the lease still stands. */
export function republish(
context: StructuredAgentSessionMutationContext,
sessionId: string,
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx
index ef702f474671..061e28913bad 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.test.tsx
@@ -112,6 +112,19 @@ function done(value: AgentSessionQueuedMessageUpdateResult): () => Promise {
rpc.mockClear()
@@ -316,6 +329,21 @@ describe('inline queued editor', () => {
await waitFor(() => expect(holdCalls('release')).toHaveLength(2))
})
+ it('a reload, which unmounts nothing, keeps unsaved typing in the chat box once', async () => {
+ writeNativeChatDraftCache(SCOPE, 'existing')
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('unsaved'))
+ act(() => {
+ window.dispatchEvent(new Event('pagehide'))
+ })
+ expect(readNativeChatDraftCache(SCOPE)).toBe('existing\n\nunsaved')
+ hook.unmount()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('existing\n\nunsaved')
+ expect(toast.error).not.toHaveBeenCalled()
+ await waitFor(() => expect(holdCalls('release')).toHaveLength(1))
+ })
+
describe('a Save whose answer this pane cannot read reads the published card', () => {
const dropped: () => Promise = async () => ({ kind: 'dropped' })
function deferred() {
@@ -363,6 +391,34 @@ describe('inline queued editor', () => {
expect(toast.error).not.toHaveBeenCalled()
})
+ it('an answer that beats its own publication closes once the card shows the saved text, before it drains', async () => {
+ answer = dropped
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('saved text'))
+ act(() => hook.result.current.editor?.save())
+ await waitFor(() => expect(hook.result.current.editor?.saving).toBe(false))
+ expect(hook.result.current.editor).toMatchObject({ text: 'saved text' })
+ hook.rerender({ messages: [withText('saved text')] })
+ expect(hook.result.current.editor).toBeUndefined()
+ hook.rerender({ messages: [], submissions: [submittedAs('saved text')] })
+ expect(toast.error).not.toHaveBeenCalled()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('')
+ })
+
+ it('a card that drains with exactly the typed text, unseen, closes quietly too', async () => {
+ answer = dropped
+ const hook = harness()
+ await begin(hook)
+ act(() => hook.result.current.editor?.change('saved text'))
+ act(() => hook.result.current.editor?.save())
+ await waitFor(() => expect(hook.result.current.editor?.saving).toBe(false))
+ hook.rerender({ messages: [], submissions: [submittedAs('saved text')] })
+ expect(hook.result.current.editor).toBeUndefined()
+ expect(toast.error).not.toHaveBeenCalled()
+ expect(readNativeChatDraftCache(SCOPE)).toBe('')
+ })
+
it('the card left with its old text (sent elsewhere first): the typing goes to the chat box', async () => {
const finish = deferred()
const hook = harness()
@@ -388,22 +444,7 @@ describe('inline queued editor', () => {
await begin(hook)
act(() => hook.result.current.editor?.change('saved text'))
act(() => hook.result.current.editor?.save())
- const saved = agentSessionSendBodyFingerprint(SESSION, {
- ...message.body,
- blocks: [{ type: 'text', text: 'saved text' }]
- })
- const submission: AgentJournalSubmission = {
- clientMessageId: 'submission',
- queuedMessageId: 'card',
- payloadFingerprint: saved,
- fence: 3,
- dispatchState: 'pending',
- providerItemId: null,
- reason: null,
- submittedAt: 1,
- resolvedAt: null
- }
- hook.rerender({ messages: [], submissions: [submission] })
+ hook.rerender({ messages: [], submissions: [submittedAs('saved text')] })
expect(hook.result.current.editor?.saving).toBe(true)
await act(async () =>
finish({ kind: 'not-done', notice: 'lost', failure: { kind: 'unconfirmed' } })
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
index a423c76bb28d..b409f778d5bb 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
@@ -142,10 +142,10 @@ export function useStructuredAgentSessionQueuedEdit(args: {
[close]
)
- // Another chat, host or composer, or the pane going away: unsaved typing goes quietly to the
- // chat box of the chat it was typed in.
- useEffect(
- () => () => {
+ // Another chat, host or composer, the pane going away, or a reload (which unmounts nothing):
+ // unsaved typing goes quietly to the chat box of the chat it was typed in.
+ useEffect(() => {
+ const handOff = (): void => {
const edit = active.current
active.current = null
reopen.current = null
@@ -153,9 +153,13 @@ export function useStructuredAgentSessionQueuedEdit(args: {
appendNativeChatDraftCache(edit.draftKey, edit.text)
}
edit?.lease.end()
- },
- [scope]
- )
+ }
+ window.addEventListener('pagehide', handOff)
+ return () => {
+ window.removeEventListener('pagehide', handOff)
+ handOff()
+ }
+ }, [scope])
const begin = useCallback(
async (messageId: string): Promise => {
@@ -271,10 +275,7 @@ export function useStructuredAgentSessionQueuedEdit(args: {
render(edit)
} else if (!listed || !onScreen(latest.current.cards, edit.messageId)) {
// The card has left: it went out with this Save's text, or before the Save could land.
- const sentAsSaved = latest.current.submissions.some(
- (entry) => entry.queuedMessageId === edit.messageId && entry.payloadFingerprint === desired
- )
- if (sentAsSaved) {
+ if (sentWith(latest.current.submissions, edit.messageId, desired)) {
close()
} else {
gone(edit)
@@ -295,8 +296,23 @@ export function useStructuredAgentSessionQueuedEdit(args: {
// An unloaded list (`null`) proves nothing: a stream that went quiet did not send the card.
useEffect(() => {
const edit = active.current
- if (edit && !edit.saving && messages !== null && !onScreen(cards, edit.messageId)) {
- gone(edit)
+ if (edit && !edit.saving && messages !== null) {
+ const typed = queuedMessageWithEditedText(edit.body, edit.text)
+ const desired = typed ? agentSessionSendBodyFingerprint(sessionId, typed) : null
+ const listed = messages.find((entry) => entry.messageId === edit.messageId)
+ if (!listed || !onScreen(cards, edit.messageId)) {
+ if (desired !== null && sentWith(submissions, edit.messageId, desired)) {
+ close()
+ } else {
+ gone(edit)
+ }
+ } else if (
+ desired !== edit.baseFingerprint &&
+ agentSessionSendBodyFingerprint(sessionId, listed.body) === desired
+ ) {
+ // The card already holds what is typed, as after a Save whose answer was lost.
+ close()
+ }
}
const pending = reopen.current
if (!pending || edit || messages === null) {
@@ -308,7 +324,7 @@ export function useStructuredAgentSessionQueuedEdit(args: {
} else if (agentSessionSendBodyFingerprint(sessionId, listed.body) !== pending.stale) {
void begin(pending.messageId)
}
- }, [begin, cards, gone, messages, scope, sessionId, shown])
+ }, [begin, cards, close, gone, messages, scope, sessionId, shown, submissions])
const editor: QueuedMessageInlineEditor | undefined =
shown && shown.scope === scope
@@ -331,6 +347,17 @@ function isOpen(active: { current: Edit | null }, edit: Edit): boolean {
return active.current === edit
}
+/** The card went out holding exactly this body. */
+function sentWith(
+ submissions: readonly AgentJournalSubmission[],
+ messageId: string,
+ fingerprint: string
+): boolean {
+ return submissions.some(
+ (entry) => entry.queuedMessageId === messageId && entry.payloadFingerprint === fingerprint
+ )
+}
+
function onScreen(cards: readonly QueuedMessageCard[], messageId: string): boolean {
return cards.some((card) => card.messageId === messageId)
}
From 08216725f4a269d06ccac4808f4991f90a294e40 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 9 Oct 2026 06:34:39 -0700
Subject: [PATCH 07/12] refactor(native-chat): move the queued-edit notices to
their own module
---
.../queued-message-edit-notices.ts | 36 +++++++++++++++
...se-structured-agent-session-queued-edit.ts | 45 +++----------------
2 files changed, 43 insertions(+), 38 deletions(-)
create mode 100644 src/renderer/src/components/native-chat/queued-message-edit-notices.ts
diff --git a/src/renderer/src/components/native-chat/queued-message-edit-notices.ts b/src/renderer/src/components/native-chat/queued-message-edit-notices.ts
new file mode 100644
index 000000000000..c8428f0375eb
--- /dev/null
+++ b/src/renderer/src/components/native-chat/queued-message-edit-notices.ts
@@ -0,0 +1,36 @@
+// What a queued edit tells the person when its card changed or left under it, or refused a Save.
+
+import { translate } from '@/i18n/i18n'
+
+/** A question or approval holds the chat box's place, so the text there is out of sight until then. */
+export function queuedEditNotice(
+ key: 'editChanged' | 'editChangedPrompt' | 'editFailed' | 'editGone' | 'editGonePrompt'
+): string {
+ switch (key) {
+ case 'editChanged':
+ return translate(
+ 'components.native-chat.queuedMessages.editChanged',
+ 'This message changed while you were editing. Your edit is in the chat box.'
+ )
+ case 'editChangedPrompt':
+ return translate(
+ 'components.native-chat.queuedMessages.editChangedPrompt',
+ 'This message changed while you were editing. Your edit will be in the chat box after you answer the agent.'
+ )
+ case 'editGone':
+ return translate(
+ 'components.native-chat.queuedMessages.editGone',
+ 'This message was already sent or removed. Your edit is in the chat box.'
+ )
+ case 'editGonePrompt':
+ return translate(
+ 'components.native-chat.queuedMessages.editGonePrompt',
+ 'This message was already sent or removed. Your edit will be in the chat box after you answer the agent.'
+ )
+ case 'editFailed':
+ return translate(
+ 'components.native-chat.queuedMessages.editFailed',
+ "This message can't be edited."
+ )
+ }
+}
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
index b409f778d5bb..08181e3c4590 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-edit.ts
@@ -4,7 +4,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
-import { translate } from '@/i18n/i18n'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability'
import type {
@@ -18,6 +17,7 @@ import {
queuedMessageWithEditedText
} from '../../../../shared/queued-message-text-edit'
import { appendNativeChatDraftCache } from './native-chat-draft-cache'
+import { queuedEditNotice } from './queued-message-edit-notices'
import { structuredSessionOperationId } from './structured-agent-session-operation-id'
import {
startQueuedEditLease,
@@ -61,39 +61,6 @@ type Edit = {
lease: QueuedEditLease
}
-/** A question or approval holds the chat box's place, so the text there is out of sight until then. */
-function editNotice(
- key: 'editChanged' | 'editChangedPrompt' | 'editFailed' | 'editGone' | 'editGonePrompt'
-): string {
- switch (key) {
- case 'editChanged':
- return translate(
- 'components.native-chat.queuedMessages.editChanged',
- 'This message changed while you were editing. Your edit is in the chat box.'
- )
- case 'editChangedPrompt':
- return translate(
- 'components.native-chat.queuedMessages.editChangedPrompt',
- 'This message changed while you were editing. Your edit will be in the chat box after you answer the agent.'
- )
- case 'editGone':
- return translate(
- 'components.native-chat.queuedMessages.editGone',
- 'This message was already sent or removed. Your edit is in the chat box.'
- )
- case 'editGonePrompt':
- return translate(
- 'components.native-chat.queuedMessages.editGonePrompt',
- 'This message was already sent or removed. Your edit will be in the chat box after you answer the agent.'
- )
- case 'editFailed':
- return translate(
- 'components.native-chat.queuedMessages.editFailed',
- "This message can't be edited."
- )
- }
-}
-
export function useStructuredAgentSessionQueuedEdit(args: {
transport: QueuedEditTransport
/** The host's published cards, for each card's full body. */
@@ -135,7 +102,7 @@ export function useStructuredAgentSessionQueuedEdit(args: {
}
if (edit.text !== edit.originalText) {
appendNativeChatDraftCache(edit.draftKey, edit.text)
- toast.error(editNotice(latest.current.promptOpen ? 'editGonePrompt' : 'editGone'))
+ toast.error(queuedEditNotice(latest.current.promptOpen ? 'editGonePrompt' : 'editGone'))
}
close()
},
@@ -202,7 +169,7 @@ export function useStructuredAgentSessionQueuedEdit(args: {
// Sent, removed or edited elsewhere before the edit began: the card shows what happened.
close()
} else if (answer?.status === 'not-editable') {
- toast.error(editNotice('editFailed'))
+ toast.error(queuedEditNotice('editFailed'))
close()
} else {
// An unanswered lease never gates typing: the lease keeps trying, and Save checks the text.
@@ -258,11 +225,13 @@ export function useStructuredAgentSessionQueuedEdit(args: {
// Someone else's edit landed first: the typing goes to the chat box, and the editor
// opens again on the newer text.
appendNativeChatDraftCache(edit.draftKey, edit.text)
- toast.error(editNotice(latest.current.promptOpen ? 'editChangedPrompt' : 'editChanged'))
+ toast.error(
+ queuedEditNotice(latest.current.promptOpen ? 'editChangedPrompt' : 'editChanged')
+ )
reopen.current = { scope, messageId: edit.messageId, stale: edit.baseFingerprint }
close()
} else {
- toast.error(editNotice('editFailed'))
+ toast.error(queuedEditNotice('editFailed'))
render(edit)
}
return
From 922d21007cc515a54d3fb388262f63a082ebf749 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 9 Oct 2026 06:57:22 -0700
Subject: [PATCH 08/12] fix(native-chat): unref the edit-lease timer in a way
the phone's typecheck accepts
The lease module reaches the mobile test program through journal-store, where
setTimeout answers a number, so `timer.unref?.()` failed mobile's tests
typecheck ratchet.
---
.../queued-message-edit-leases.ts | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts
index 18016f97bc81..d698ca1d5eb0 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts
@@ -159,7 +159,16 @@ export class QueuedMessageEditLeases {
},
Math.max(1, next - this.now())
)
- this.timer.unref?.()
+ // Read untyped: this module also compiles into the phone's tests, where a timer is a number.
+ const timer: unknown = this.timer
+ if (
+ typeof timer === 'object' &&
+ timer &&
+ 'unref' in timer &&
+ typeof timer.unref === 'function'
+ ) {
+ timer.unref()
+ }
}
}
From 1b53321d4d28ee3528b3b5154c071d5b55673a09 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 9 Oct 2026 07:09:22 -0700
Subject: [PATCH 09/12] test(cross-version-wire): call the queued-edit methods
in both skew directions
The manifest must name every agentSession method; the two edit methods
reach their host methods on a new build and are only called by clients
on a host advertising agent-session.queued-message-edit.v1.
---
.../structured-agent-session-host-fixture.ts | 7 +++++++
.../structured-agent-session-surface-calls.ts | 12 ++++++++++++
.../structured-agent-session-surface-manifest.ts | 12 ++++++++++++
3 files changed, 31 insertions(+)
diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts
index 949457d4f63a..2ef7b3efaab5 100644
--- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts
+++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts
@@ -58,6 +58,13 @@ export function structuredHostStub(
queuedMessageSend: vi.fn(async () => ({ ok: true, replayed: false })),
queuedMessageDelete: vi.fn(async () => ({ ok: true, replayed: false })),
queuedMessagesResume: vi.fn(async () => ({ ok: true, replayed: false })),
+ queuedMessageUpdate: vi.fn(async () => ({ ok: true, replayed: false })),
+ queuedMessageEditHold: vi.fn(async () => ({
+ status: 'held' as const,
+ fingerprint: 'a'.repeat(64),
+ leaseDurationMs: 120_000,
+ remainingMs: 120_000
+ })),
rewind: vi.fn(async () => ({
ok: true,
replayed: false,
diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts
index f24ea7224509..d07919e299fe 100644
--- a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts
+++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts
@@ -59,6 +59,18 @@ export const STRUCTURED_CALLS: {
hostMethod: 'queuedMessagesResume',
result: { ok: true, replayed: false }
},
+ // Editing a queued card in place. Clients call these only on a host advertising
+ // `agent-session.queued-message-edit.v1`; an older host answers `method_not_found`.
+ {
+ method: 'agentSession.queuedMessageUpdate',
+ hostMethod: 'queuedMessageUpdate',
+ result: { ok: true, replayed: false }
+ },
+ {
+ method: 'agentSession.queuedMessageEditHold',
+ hostMethod: 'queuedMessageEditHold',
+ result: { status: 'held' }
+ },
{
method: REWIND_METHOD,
hostMethod: 'rewind',
diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts
index 3aac5e704dc1..3fd520c5c3a5 100644
--- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts
+++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts
@@ -151,6 +151,18 @@ export function paramsFor(method: string): unknown {
}
case 'agentSession.queuedMessagesResume':
return { envelope: envelope({ method, fields: {}, fence }) }
+ case 'agentSession.queuedMessageUpdate': {
+ const fields = { messageId: 'queued-1', expectedBodyFingerprint: 'a'.repeat(64), text: 'hi' }
+ return { envelope: envelope({ method, fields, fence }), ...fields }
+ }
+ case 'agentSession.queuedMessageEditHold':
+ return {
+ sessionId: SESSION,
+ messageId: 'queued-1',
+ editId: 'edit-1',
+ action: 'acquire',
+ expectedBodyFingerprint: 'a'.repeat(64)
+ }
case 'agentSession.respondToApproval':
case 'agentSession.respondToQuestion': {
const fields = { itemId: 'item-1', expectedRevision: 1, optionId: 'allow' }
From bd623d6af02ed5a4c02f04d8d6029d8c853cb202 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 9 Oct 2026 07:27:14 -0700
Subject: [PATCH 10/12] test(rpc): count the two queued-edit methods on the
structured surface
---
src/main/runtime/rpc/methods/structured-agent-session.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts
index e287472bb6ce..f67dd8ed74fb 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts
@@ -175,7 +175,7 @@ describe('capability gating', () => {
}
// Bump deliberately: the whole agentSession.* surface is behind the structured capability,
// so an additive method is invisible to old clients and needs no protocol bump.
- expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(34)
+ expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(36)
})
it('hides the surface from a declared client that did not advertise it', async () => {
From 8617fdef4d0a4fa194eda8abb15565503fc39cff Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 9 Oct 2026 15:43:21 -0700
Subject: [PATCH 11/12] fix(native-chat): lay out the inline queued-message
editor as a header, field and Save footer
While a card is edited, its row shows a pencil, "Editing message", the sender and an X that
cancels; the field at full width; and a footer with the key hint and Save. The card's queue icon
and its status line are hidden until editing ends.
---
.../NativeChatQueuedMessageCard.tsx | 35 ++++++----
.../NativeChatQueuedMessageEditor.test.tsx | 64 +++++++++++++++++--
.../NativeChatQueuedMessageEditor.tsx | 50 ++++++++++++---
...ChatQueuedMessageList.inline-edit.test.tsx | 44 ++++++++++++-
src/renderer/src/i18n/locales/en.json | 4 +-
src/renderer/src/i18n/locales/es.json | 4 +-
src/renderer/src/i18n/locales/fr.json | 4 +-
src/renderer/src/i18n/locales/ja.json | 4 +-
src/renderer/src/i18n/locales/ko.json | 4 +-
src/renderer/src/i18n/locales/zh.json | 4 +-
10 files changed, 182 insertions(+), 35 deletions(-)
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
index 6fe69560d854..c372efbe93dc 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
@@ -173,29 +173,36 @@ export function NativeChatQueuedMessageCard({
const [expanded, setExpanded] = useState(false)
const [clipped, measureLine] = useNativeChatClippedLine(false)
const textId = useId()
+ const sender = card.from ? (
+
+ ) : null
+ if (editor) {
+ // The editor's own header stands in for the icon, the actions and the caption.
+ return (
+
+
+
+ )
+ }
return (
-
+
{returned || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? (
) : (
)}
- {card.from ? (
-
- ) : null}
- {editor ? (
-
- ) : expanded ? null : (
+ {sender}
+ {expanded ? null : (
{card.text}
@@ -212,7 +219,7 @@ export function NativeChatQueuedMessageCard({
) : null}
- {!editor && (expanded || clipped) ? (
+ {expanded || clipped ? (
) : null}
{/* Nothing acts on a send still on its way: the host holds no card for it yet. */}
- {card.hold === 'sending' || editor ? null : (
+ {card.hold === 'sending' ? null : (
<>
{/* A command never steers: its Send shows only while the agent is idle. */}
{card.waitsForAgent ? null : (
@@ -307,7 +314,7 @@ export function NativeChatQueuedMessageCard({
>
)}
- {expanded && !editor ? (
+ {expanded ? (
// Indented to the text, past the icon slot and its gap.
({ translate: (_key: string, fallback: string) => fallback }))
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string, values?: Record) =>
+ fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => values?.[name] ?? '')
+}))
afterEach(cleanup)
function editor(): QueuedMessageInlineEditor {
return {
@@ -35,7 +38,10 @@ function view(
{prompt === 'approval' ? (
{
it.each(['question', 'approval'] as const)(
- 'stays mounted and focused when a %s opens, with separate Save/Cancel',
+ 'stays mounted and focused when a %s opens, its keys apart from the prompt',
(prompt) => {
const edit = editor()
const answer = vi.fn()
@@ -128,12 +140,52 @@ describe('queued card textarea', () => {
expect(edit.cancel).not.toHaveBeenCalled()
expect(parent).not.toHaveBeenCalled()
})
- it('acquisition seeds a read-only field with Cancel always usable', () => {
+ it('acquisition seeds a read-only field with the X always usable', () => {
const edit = { ...editor(), acquiring: true }
render( )
expect(screen.getByRole('textbox').getAttribute('readonly')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
- fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel editing' }))
expect(edit.cancel).toHaveBeenCalledOnce()
})
+ it('the header X cancels and saves nothing; Save is the only footer action', () => {
+ const edit = editor()
+ render( )
+ expect(screen.getByText('Editing message')).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel editing' }))
+ expect(edit.cancel).toHaveBeenCalledOnce()
+ expect(edit.save).not.toHaveBeenCalled()
+ expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull()
+ expect(
+ screen
+ .getAllByRole('button')
+ .map((button) => button.getAttribute('aria-label') ?? button.textContent)
+ ).toEqual(['Cancel editing', 'Save'])
+ })
+ it.each([
+ { state: 'saving', edit: { saving: true } },
+ { state: 'unchanged', edit: { canSave: false } }
+ ])('Save is disabled while $state and enabled otherwise', ({ edit: overrides }) => {
+ const edit = { ...editor(), ...overrides }
+ const { rerender } = render( )
+ const save = screen.getByRole('button', { name: 'Save' })
+ expect(save.hasAttribute('disabled')).toBe(true)
+ fireEvent.click(save)
+ expect(edit.save).not.toHaveBeenCalled()
+ rerender( )
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+ expect(edit.save).toHaveBeenCalledOnce()
+ })
+ it('the key hint names the platform newline chord', () => {
+ for (const [platform, chord] of [
+ ['Macintosh', '⇧ Enter'],
+ ['Windows', 'Shift+Enter']
+ ] as const) {
+ vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(platform)
+ const { unmount } = render( )
+ expect(screen.getByText(`Enter to save · ${chord} for a new line`)).toBeTruthy()
+ unmount()
+ }
+ vi.restoreAllMocks()
+ })
})
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx
index 083864daee7a..005635ef1246 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx
@@ -1,17 +1,22 @@
-import { useLayoutEffect, useRef } from 'react'
+import { useLayoutEffect, useRef, type ReactNode } from 'react'
+import { Pencil, X } from 'lucide-react'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { isMacPlatform } from './native-chat-shortcut'
import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit'
-/** The inline editor that replaces a queued card's text while it is edited. It lives in the card,
+/** The inline editor that replaces a queued card's row while it is edited. It lives in the card,
* not the composer, so it stays usable while a question or approval holds the composer's slot. */
export function NativeChatQueuedMessageEditor({
- editor
+ editor,
+ sender
}: {
editor: QueuedMessageInlineEditor
+ /** The card's "From " line, kept beside the label. */
+ sender?: ReactNode
}): React.JSX.Element {
+ const isMac = isMacPlatform()
const field = useRef(null)
useLayoutEffect(() => {
if (editor.acquiring) {
@@ -32,7 +37,32 @@ export function NativeChatQueuedMessageEditor({
}
}, [editor.acquiring])
return (
-
+
+
+
+
+ {translate('components.native-chat.queuedMessages.editingMessage', 'Editing message')}
+
+ {sender ? (
+ <>
+
·
+
{sender}
+ >
+ ) : null}
+
+
+
+
-
+
+
+ {translate(
+ 'components.native-chat.queuedMessages.editKeysHint',
+ 'Enter to save · {{newline}} for a new line',
+ { newline: isMac ? '⇧ Enter' : 'Shift+Enter' }
+ )}
+
{translate('components.native-chat.queuedMessages.save', 'Save')}
-
- {translate('components.native-chat.queuedMessages.cancel', 'Cancel')}
-
)
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx
index e543da753bd4..1b170c031a4d 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.inline-edit.test.tsx
@@ -8,7 +8,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
// A card's sender line opens and names agents through modules this test does not exercise.
-vi.mock('@/lib/open-agent-message-sender', () => ({ openAgentMessageSender: vi.fn() }))
+vi.mock('@/lib/open-agent-message-sender', () => ({
+ openAgentMessageSender: vi.fn()
+}))
vi.mock('@/runtime/structured-conversation-name', () => ({
useStructuredChatTabConversationName: () => null
}))
@@ -104,6 +106,46 @@ describe('NativeChatQueuedMessageList inline edit', () => {
expect(item.getAttribute('aria-disabled')).toBe('true')
})
+ it('an edited card shows the editing header with its sender and hides its caption until editing ends', () => {
+ const editor = {
+ messageId: 'mail',
+ text: 'editing',
+ acquiring: false,
+ saving: false,
+ canSave: true,
+ change: vi.fn(),
+ save: vi.fn(),
+ cancel: vi.fn()
+ }
+ const cards = [
+ card({
+ messageId: 'mail',
+ hold: 'awaiting-answer',
+ from: { kind: 'agent', senders: [], orchestration: null }
+ })
+ ]
+ const { rerender } = renderList(controller(cards, null, true, { editCapable: true, editor }))
+ const row = screen.getByRole('listitem')
+ expect(within(row).getByText('Editing message')).toBeTruthy()
+ expect(within(row).getByText('From')).toBeTruthy()
+ expect(within(row).queryByText('Waiting for your answer')).toBeNull()
+ fireEvent.click(within(row).getByRole('button', { name: 'Cancel editing' }))
+ expect(editor.cancel).toHaveBeenCalledOnce()
+ rerender(
+
+
+
+ )
+ expect(screen.getByText('Waiting for your answer')).toBeTruthy()
+ expect(screen.queryByText('Editing message')).toBeNull()
+ })
+
it.each([
{ prompt: true, expected: 'the question' },
{ prompt: false, expected: 'the composer' }
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 3aea758e9a1e..b45d57cdf726 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -18662,7 +18662,9 @@
"fromSender": "From",
"pausedSendFailedWaiting": "Couldn't send — press Send to retry once the agent finishes.",
"save": "Save",
- "cancel": "Cancel",
+ "editingMessage": "Editing message",
+ "cancelEditing": "Cancel editing",
+ "editKeysHint": "Enter to save · {{newline}} for a new line",
"editChanged": "This message changed while you were editing. Your edit is in the chat box.",
"editChangedPrompt": "This message changed while you were editing. Your edit will be in the chat box after you answer the agent.",
"editFailed": "This message can't be edited.",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index c9f6bcbb2e5a..39de316170bd 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -18272,7 +18272,9 @@
"fromSender": "De",
"pausedSendFailedWaiting": "No se pudo enviar. Cuando el agente termine, pulsa Enviar para reintentar.",
"save": "Guardar",
- "cancel": "Cancelar",
+ "editingMessage": "Editando mensaje",
+ "cancelEditing": "Cancelar edición",
+ "editKeysHint": "Enter para guardar · {{newline}} para una nueva línea",
"editChanged": "Este mensaje cambió mientras lo editabas. Tu edición está en el cuadro de chat.",
"editChangedPrompt": "Este mensaje cambió mientras lo editabas. Tu edición estará en el cuadro de chat cuando respondas al agente.",
"editFailed": "Este mensaje no se puede editar.",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index c7658578d0da..6b7eeceafb3d 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -18510,7 +18510,9 @@
"fromSender": "De",
"pausedSendFailedWaiting": "Envoi impossible. Quand l'agent aura terminé, appuyez sur Envoyer pour réessayer.",
"save": "Enregistrer",
- "cancel": "Annuler",
+ "editingMessage": "Modification du message",
+ "cancelEditing": "Annuler la modification",
+ "editKeysHint": "Entrée pour enregistrer · {{newline}} pour un retour à la ligne",
"editChanged": "Ce message a changé pendant que vous le modifiiez. Votre modification se trouve dans le champ de discussion.",
"editChangedPrompt": "Ce message a changé pendant que vous le modifiiez. Votre modification sera dans le champ de discussion une fois que vous aurez répondu à l'agent.",
"editFailed": "Ce message ne peut pas être modifié.",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 1741a617e637..e118ef634ae0 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -18444,7 +18444,9 @@
"fromSender": "送信元:",
"pausedSendFailedWaiting": "送信できませんでした。Agent の処理が終わったら「送信」を押して再試行してください。",
"save": "保存",
- "cancel": "キャンセル",
+ "editingMessage": "メッセージを編集中",
+ "cancelEditing": "編集をキャンセル",
+ "editKeysHint": "Enter で保存 · {{newline}} で改行",
"editChanged": "編集中にこのメッセージが変更されました。編集したテキストはチャット入力欄にあります。",
"editChangedPrompt": "編集中にこのメッセージが変更されました。Agent に回答すると、編集したテキストがチャット入力欄に表示されます。",
"editFailed": "このメッセージは編集できません。",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 2f05c327a4f5..7912b031c756 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -18444,7 +18444,9 @@
"fromSender": "보낸 사람:",
"pausedSendFailedWaiting": "보낼 수 없습니다. 에이전트가 끝나면 보내기를 눌러 다시 시도하세요.",
"save": "저장",
- "cancel": "취소",
+ "editingMessage": "메시지 편집 중",
+ "cancelEditing": "편집 취소",
+ "editKeysHint": "Enter로 저장 · {{newline}}로 줄 바꿈",
"editChanged": "편집하는 동안 이 메시지가 변경되었습니다. 편집한 내용은 채팅 입력란에 있습니다.",
"editChangedPrompt": "편집하는 동안 이 메시지가 변경되었습니다. 에이전트에 답하면 편집한 내용이 채팅 입력란에 표시됩니다.",
"editFailed": "이 메시지는 편집할 수 없습니다.",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 048831a7bf6f..8462c7309b3f 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -18409,7 +18409,9 @@
"fromSender": "来自",
"pausedSendFailedWaiting": "无法发送。智能体完成后,点按发送以重试。",
"save": "保存",
- "cancel": "取消",
+ "editingMessage": "正在编辑消息",
+ "cancelEditing": "取消编辑",
+ "editKeysHint": "按 Enter 保存 · 按 {{newline}} 换行",
"editChanged": "你编辑时此消息已更改。你编辑的内容已放入聊天输入框。",
"editChangedPrompt": "你编辑时此消息已更改。回答智能体后,你编辑的内容会出现在聊天输入框中。",
"editFailed": "此消息无法编辑。",
From b507748b53ea4b0386bb6ebcb7323ff5bf855cd2 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 9 Oct 2026 16:32:56 -0700
Subject: [PATCH 12/12] fix(native-chat): show the Enter key on the
queued-message editor's Save button
The editor footer's muted key hint is gone. Save now carries the same inline
key badge as the new-workspace composer's Create button, extracted into a
shared ButtonKeyHint so both buttons render one badge.
---
.../src/components/ButtonKeyHint.test.tsx | 27 +++++++++++++++++++
src/renderer/src/components/ButtonKeyHint.tsx | 21 +++++++++++++++
.../NativeChatQueuedMessageEditor.test.tsx | 15 ++++++-----
.../NativeChatQueuedMessageEditor.tsx | 12 +++------
.../NewWorkspaceComposerFooter.tsx | 8 +++---
src/renderer/src/i18n/locales/en.json | 1 -
src/renderer/src/i18n/locales/es.json | 1 -
src/renderer/src/i18n/locales/fr.json | 1 -
src/renderer/src/i18n/locales/ja.json | 1 -
src/renderer/src/i18n/locales/ko.json | 1 -
src/renderer/src/i18n/locales/zh.json | 1 -
11 files changed, 62 insertions(+), 27 deletions(-)
create mode 100644 src/renderer/src/components/ButtonKeyHint.test.tsx
create mode 100644 src/renderer/src/components/ButtonKeyHint.tsx
diff --git a/src/renderer/src/components/ButtonKeyHint.test.tsx b/src/renderer/src/components/ButtonKeyHint.test.tsx
new file mode 100644
index 000000000000..669951688e41
--- /dev/null
+++ b/src/renderer/src/components/ButtonKeyHint.test.tsx
@@ -0,0 +1,27 @@
+// @vitest-environment happy-dom
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Button } from '@/components/ui/button'
+import { ButtonKeyHint } from './ButtonKeyHint'
+
+afterEach(cleanup)
+
+describe('ButtonKeyHint', () => {
+ it('shows the modifier before the Enter glyph without joining the button name', () => {
+ render(
+
+ Create worktree
+
+
+ )
+ const button = screen.getByRole('button', { name: 'Create worktree' })
+ const badge = button.querySelector('[aria-hidden="true"]')
+ expect(badge?.textContent).toBe('⌘')
+ expect(badge?.querySelector('svg')).toBeTruthy()
+ })
+ it('shows only the Enter glyph when plain Enter submits', () => {
+ const { container } = render(
)
+ expect(container.firstElementChild?.textContent).toBe('')
+ expect(container.querySelector('svg')).toBeTruthy()
+ })
+})
diff --git a/src/renderer/src/components/ButtonKeyHint.tsx b/src/renderer/src/components/ButtonKeyHint.tsx
new file mode 100644
index 000000000000..23dc2ac44c2a
--- /dev/null
+++ b/src/renderer/src/components/ButtonKeyHint.tsx
@@ -0,0 +1,21 @@
+import React from 'react'
+import { CornerDownLeft } from 'lucide-react'
+
+/** The Enter-key badge that trails a filled primary button's label ("Create worktree ⌘↵").
+ * Decorative: the button's name is its label, not the glyphs. */
+export function ButtonKeyHint({
+ modifierLabel
+}: {
+ /** The platform modifier shown before ↵ (e.g. "⌘" or "Ctrl"); omit when plain Enter submits. */
+ modifierLabel?: string
+}): React.JSX.Element {
+ return (
+
+ {modifierLabel ? {modifierLabel} : null}
+
+
+ )
+}
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx
index 1b22368ed7fc..5afab04acf62 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx
@@ -176,14 +176,15 @@ describe('queued card textarea', () => {
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(edit.save).toHaveBeenCalledOnce()
})
- it('the key hint names the platform newline chord', () => {
- for (const [platform, chord] of [
- ['Macintosh', '⇧ Enter'],
- ['Windows', 'Shift+Enter']
- ] as const) {
+ it('Save carries a decorative Enter badge and no key hint text', () => {
+ for (const platform of ['Macintosh', 'Windows']) {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(platform)
- const { unmount } = render(
)
- expect(screen.getByText(`Enter to save · ${chord} for a new line`)).toBeTruthy()
+ const { container, unmount } = render(
)
+ const save = screen.getByRole('button', { name: 'Save' })
+ const badge = save.querySelector('[aria-hidden="true"]')
+ expect(badge?.querySelector('svg')).toBeTruthy()
+ expect(badge?.textContent).toBe('')
+ expect(container.textContent).not.toMatch(/Shift|⇧|new line/)
unmount()
}
vi.restoreAllMocks()
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx
index 005635ef1246..71754511ed02 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx
@@ -3,6 +3,7 @@ import { Pencil, X } from 'lucide-react'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
+import { ButtonKeyHint } from '@/components/ButtonKeyHint'
import { isMacPlatform } from './native-chat-shortcut'
import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit'
@@ -16,7 +17,6 @@ export function NativeChatQueuedMessageEditor({
/** The card's "From
" line, kept beside the label. */
sender?: ReactNode
}): React.JSX.Element {
- const isMac = isMacPlatform()
const field = useRef(null)
useLayoutEffect(() => {
if (editor.acquiring) {
@@ -93,14 +93,7 @@ export function NativeChatQueuedMessageEditor({
}
}}
/>
-
-
- {translate(
- 'components.native-chat.queuedMessages.editKeysHint',
- 'Enter to save · {{newline}} for a new line',
- { newline: isMac ? '⇧ Enter' : 'Shift+Enter' }
- )}
-
+
{translate('components.native-chat.queuedMessages.save', 'Save')}
+
diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerFooter.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerFooter.tsx
index 275328ff81ee..1afe5fee5445 100644
--- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerFooter.tsx
+++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerFooter.tsx
@@ -1,9 +1,10 @@
import React from 'react'
-import { CornerDownLeft, LoaderCircle } from 'lucide-react'
+import { LoaderCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { SwitchIndicator } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
+import { ButtonKeyHint } from '@/components/ButtonKeyHint'
import type { NewWorkspaceComposerCardProps } from './new-workspace-composer-card-props'
type NewWorkspaceComposerFooterProps = Pick<
@@ -78,10 +79,7 @@ export function NewWorkspaceComposerFooter({
>
{creating ? : null}
{primaryActionLabel}
-
- {submitShortcutModifierLabel}
-
-
+
>
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index b45d57cdf726..375b7bb69ddc 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -18664,7 +18664,6 @@
"save": "Save",
"editingMessage": "Editing message",
"cancelEditing": "Cancel editing",
- "editKeysHint": "Enter to save · {{newline}} for a new line",
"editChanged": "This message changed while you were editing. Your edit is in the chat box.",
"editChangedPrompt": "This message changed while you were editing. Your edit will be in the chat box after you answer the agent.",
"editFailed": "This message can't be edited.",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 39de316170bd..03dcf315b0a3 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -18274,7 +18274,6 @@
"save": "Guardar",
"editingMessage": "Editando mensaje",
"cancelEditing": "Cancelar edición",
- "editKeysHint": "Enter para guardar · {{newline}} para una nueva línea",
"editChanged": "Este mensaje cambió mientras lo editabas. Tu edición está en el cuadro de chat.",
"editChangedPrompt": "Este mensaje cambió mientras lo editabas. Tu edición estará en el cuadro de chat cuando respondas al agente.",
"editFailed": "Este mensaje no se puede editar.",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index 6b7eeceafb3d..6cfc7617a1dd 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -18512,7 +18512,6 @@
"save": "Enregistrer",
"editingMessage": "Modification du message",
"cancelEditing": "Annuler la modification",
- "editKeysHint": "Entrée pour enregistrer · {{newline}} pour un retour à la ligne",
"editChanged": "Ce message a changé pendant que vous le modifiiez. Votre modification se trouve dans le champ de discussion.",
"editChangedPrompt": "Ce message a changé pendant que vous le modifiiez. Votre modification sera dans le champ de discussion une fois que vous aurez répondu à l'agent.",
"editFailed": "Ce message ne peut pas être modifié.",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index e118ef634ae0..ceb853036a6b 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -18446,7 +18446,6 @@
"save": "保存",
"editingMessage": "メッセージを編集中",
"cancelEditing": "編集をキャンセル",
- "editKeysHint": "Enter で保存 · {{newline}} で改行",
"editChanged": "編集中にこのメッセージが変更されました。編集したテキストはチャット入力欄にあります。",
"editChangedPrompt": "編集中にこのメッセージが変更されました。Agent に回答すると、編集したテキストがチャット入力欄に表示されます。",
"editFailed": "このメッセージは編集できません。",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 7912b031c756..591f0d0f9ecf 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -18446,7 +18446,6 @@
"save": "저장",
"editingMessage": "메시지 편집 중",
"cancelEditing": "편집 취소",
- "editKeysHint": "Enter로 저장 · {{newline}}로 줄 바꿈",
"editChanged": "편집하는 동안 이 메시지가 변경되었습니다. 편집한 내용은 채팅 입력란에 있습니다.",
"editChangedPrompt": "편집하는 동안 이 메시지가 변경되었습니다. 에이전트에 답하면 편집한 내용이 채팅 입력란에 표시됩니다.",
"editFailed": "이 메시지는 편집할 수 없습니다.",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 8462c7309b3f..61210e84d871 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -18411,7 +18411,6 @@
"save": "保存",
"editingMessage": "正在编辑消息",
"cancelEditing": "取消编辑",
- "editKeysHint": "按 Enter 保存 · 按 {{newline}} 换行",
"editChanged": "你编辑时此消息已更改。你编辑的内容已放入聊天输入框。",
"editChangedPrompt": "你编辑时此消息已更改。回答智能体后,你编辑的内容会出现在聊天输入框中。",
"editFailed": "此消息无法编辑。",