From 59631b8b2633692f93c82da05103184cd4d40440 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:31:28 -0700 Subject: [PATCH] fix(native-chat): edit a queued message in place, even while a question is open (#26886) * 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. * 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. * fix(native-chat): name every queued-edit notice in its switch * 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. * fix(native-chat): keep Agent in the Japanese queued-edit notices, as the catalog does * 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. * refactor(native-chat): move the queued-edit notices to their own module * 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. * 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. * test(rpc): count the two queued-edit methods on the structured surface * 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. * 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. --- .../journal-queued-messages.ts | 61 +-- .../journal-store-contracts.ts | 22 + .../agent-session-journal/journal-store.ts | 1 + .../journal-submission-hook.ts | 8 +- ...queued-message-bookkeeping-failure.test.ts | 7 +- .../queued-message-edit-leases.test.ts | 159 ++++++ .../queued-message-edit-leases.ts | 187 +++++++ .../queued-message-pause.test.ts | 2 +- .../queued-message-pause.ts | 13 +- .../queued-message-settlement.ts | 15 + .../queued-message-store.test.ts | 32 +- .../queued-message-table.ts | 7 +- .../queued-message-text-update.test.ts | 181 +++++++ .../queued-message-text-update.ts | 66 +++ .../structured-agent-session-host.ts | 4 +- ...ructured-agent-session-queued-edit.test.ts | 335 ++++++++++++ .../structured-agent-session-queued-edit.ts | 117 ++++ ...tructured-agent-session-queued-messages.ts | 17 +- ...ructured-agent-session-queued-mutations.ts | 4 +- ...ed-agent-session-queued-published-bytes.ts | 8 +- .../structured-agent-session-queued-wiring.ts | 35 +- 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 + .../methods/structured-agent-session.test.ts | 2 +- ...untime-rpc-mobile-agent-session-methods.ts | 2 + .../src/components/ButtonKeyHint.test.tsx | 27 + src/renderer/src/components/ButtonKeyHint.tsx | 21 + .../NativeChatQueuedMessageCard.tsx | 101 ++-- .../NativeChatQueuedMessageEditor.test.tsx | 192 +++++++ .../NativeChatQueuedMessageEditor.tsx | 109 ++++ ...ChatQueuedMessageList.inline-edit.test.tsx | 191 +++++++ .../NativeChatQueuedMessageList.test.tsx | 10 +- .../NativeChatQueuedMessageList.tsx | 33 +- ...native-chat-submission-navigation.test.tsx | 2 + .../queued-message-edit-notices.ts | 36 ++ ...uctured-agent-session-queued-edit-lease.ts | 95 ++++ .../use-native-chat-prompt-card-focus.ts | 5 +- ...uctured-agent-session-queued-edit.test.tsx | 503 ++++++++++++++++++ ...se-structured-agent-session-queued-edit.ts | 332 ++++++++++++ ...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 | 64 +-- .../use-structured-agent-session.ts | 9 +- ...ructured-native-chat-submit-reveal.test.ts | 2 + .../NewWorkspaceComposerFooter.tsx | 8 +- src/renderer/src/i18n/locales/en.json | 11 +- src/renderer/src/i18n/locales/es.json | 11 +- src/renderer/src/i18n/locales/fr.json | 11 +- src/renderer/src/i18n/locales/ja.json | 11 +- src/renderer/src/i18n/locales/ko.json | 11 +- src/renderer/src/i18n/locales/zh.json | 11 +- ...tructured-agent-session-host-capability.ts | 12 + .../agent-session-queued-message-wire.ts | 20 + src/shared/protocol-version.ts | 7 + src/shared/queued-message-text-edit.ts | 44 ++ .../rpc-params-catalog.generated.ts | 6 + ...ctured-agent-session-queued-edit-params.ts | 27 + .../structured-agent-session-host-fixture.ts | 7 + .../structured-agent-session-surface-calls.ts | 12 + ...ructured-agent-session-surface-manifest.ts | 12 + 63 files changed, 3216 insertions(+), 259 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-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/renderer/src/components/ButtonKeyHint.test.tsx create mode 100644 src/renderer/src/components/ButtonKeyHint.tsx 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/NativeChatQueuedMessageList.inline-edit.test.tsx create mode 100644 src/renderer/src/components/native-chat/queued-message-edit-notices.ts 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 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 3337727689c..1a66fc156bf 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 @@ -5,19 +5,14 @@ // working status, teardown, or the idle sweep. import type Database from '../../sqlite/sync-database' -import type { - AgentJournalCursor, - AgentJournalMessageItem -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, AGENT_SESSION_OPERATION_FUTURE_SKEW_MS } from '../../../shared/agent-session-host-authority' -import type { JournalHostDatabase } from './journal-host-database' -import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' import type { JournalOperationReceipt } from './journal-row-writer' -import type { JournalSubmissionConsume } from './journal-store-contracts' +import type { JournalQueuedMessagesDeps, JournalSubmissionConsume } from './journal-store-contracts' import { holdQueuedMessages } from './queued-message-holds' import { deriveQueuePauses, @@ -41,38 +36,22 @@ import { moveQueuedMessages, type QueuedMessagePositionMove } from './queued-mes import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' import { queuedMessageSettlementOwed, + queuedMessagesAwaitReopenMark, settleOwedQueuedMessages, settleQueuedMessagesForRow } from './queued-message-settlement' import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards' -import type { JournalAttachmentClaim } from './journal-submission-hook' -import type { JournalWriteBody, JournalWriteResult } from './journal-write-queue' +import type { JournalWriteResult } from './journal-write-queue' import { QueuedMessageNotConsumableError } from './queued-message-consume-error' -export { QueuedMessageNotConsumableError } from './queued-message-consume-error' +import { QueuedMessageEditLeases } from './queued-message-edit-leases' +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 = AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS -export type JournalQueuedMessagesDeps = { - sessionId: string - now: () => number - serialize: (run: JournalWriteBody) => Promise - database: () => JournalHostDatabase - readOnly: () => boolean - state: () => JournalReducerState - /** Where the reopen's pause begins when this handle could not mark it (`reopenFloor`). */ - reopenFloor: () => AgentJournalCursor | null - /** The journal's own commit notification. Every standalone draft-table - * transaction that changed rows fires it after COMMIT, so a draft or hold - * change publishes and wakes the drain through the same path a journal row - * does — no call site can forget. In-transaction consume and the returned - * transition already ride their row's own commit. */ - committed: () => void - claimAttachments: JournalAttachmentClaim -} - 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 @@ -108,6 +87,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) @@ -168,17 +156,9 @@ export class JournalQueuedMessages { return this.derivePauses(this.list()) } - /** A card waits, or is mid-hand-off and may come back to waiting: a chat that stops running - * marks it (`AgentSessionJournal.markQueueReopen`). */ + /** `queuedMessagesAwaitReopenMark`. */ awaitReopenMark(): boolean { - const { submissions } = this.deps.state() - return this.list().some((row) => { - if (row.state === 'waiting') { - return true - } - const handOff = row.consumedAs ? submissions.get(row.consumedAs)?.dispatchState : undefined - return row.state === 'dispatched' && (handOff === 'pending' || handOff === 'unknown') - }) + return queuedMessagesAwaitReopenMark(this.list(), this.deps.state().submissions) } /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ @@ -289,7 +269,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) { @@ -302,7 +282,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) } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 290a5961036..f9f7502ea8b 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -15,6 +15,9 @@ import type { AgentSessionMessageSource } from '../../../shared/agent-session-me import type { JournalHostDatabase } from './journal-host-database' import type { JournalLifecycleMutationInput } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' +import type { JournalReducerState } from './journal-reducer' +import type { JournalAttachmentClaim } from './journal-submission-hook' +import type { JournalWriteBody } from './journal-write-queue' export type AgentSessionJournalOptions = { identity: AgentSessionJournalIdentity @@ -124,3 +127,22 @@ export type JournalItemAppendInput = { body: AgentJournalItemBody options: JournalItemAppendOptions } + +/** What `JournalQueuedMessages` reads from the journal that owns it. */ +export type JournalQueuedMessagesDeps = { + sessionId: string + now: () => number + serialize: (run: JournalWriteBody) => Promise + database: () => JournalHostDatabase + readOnly: () => boolean + state: () => JournalReducerState + /** Where the reopen's pause begins when this handle could not mark it (`reopenFloor`). */ + reopenFloor: () => AgentJournalCursor | null + /** The journal's own commit notification. Every standalone draft-table + * transaction that changed rows fires it after COMMIT, so a draft or hold + * change publishes and wakes the drain through the same path a journal row + * does — no call site can forget. In-transaction consume and the returned + * transition already ride their row's own commit. */ + committed: () => void + claimAttachments: JournalAttachmentClaim +} 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 25e3a7e8d86..5b226baf221 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -181,6 +181,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/journal-submission-hook.ts b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts index 84be3ab91c9..36893658f90 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 5f9a95a53d2..bc8478602d0 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-edit-leases.test.ts b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts new file mode 100644 index 00000000000..7d85e0a58c5 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts @@ -0,0 +1,159 @@ +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("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) + 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 00000000000..d698ca1d5eb --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.ts @@ -0,0 +1,187 @@ +// 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() + 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)) + } + + /** 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()) + ) + // 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() + } + } +} + +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-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index bedd7bd8351..8064412b49b 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 @@ -16,7 +16,7 @@ import { liveTestJournalRows, updateTestJournalRowJson } from './journal-host-database-test-support' -import { QueuedMessageNotConsumableError } from './journal-queued-messages' +import { QueuedMessageNotConsumableError } from './queued-message-consume-error' 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 688265f7c27..b0289cbfe33 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 @@ -256,19 +256,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-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index dffe87f3af0..212f572fd6c 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -37,6 +37,21 @@ export function queuedMessageSettlementOwed( ) } +/** A card waits, or is mid-hand-off and may come back to waiting: a chat that stops running + * marks it (`AgentSessionJournal.markQueueReopen`). */ +export function queuedMessagesAwaitReopenMark( + rows: readonly QueuedMessageRow[], + submissions: Submissions +): boolean { + return rows.some((row) => { + if (row.state === 'waiting') { + return true + } + const handOff = row.consumedAs ? submissions.get(row.consumedAs)?.dispatchState : undefined + return row.state === 'dispatched' && (handOff === 'pending' || handOff === 'unknown') + }) +} + /** Applies each owed settlement, and withdraws each waiting draft an applied echo proves * delivered (`draftsDeliveredByAppliedEcho`); returns how many drafts changed. */ export function settleOwedQueuedMessages( 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 02e07bc53ee..991b384b640 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-consume-error' import type { AgentSessionJournal } from './journal-store' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { @@ -319,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 303ea060c63..9f255980448 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 @@ -155,6 +155,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 @@ -171,7 +173,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, @@ -180,7 +182,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 new file mode 100644 index 00000000000..d48ce57179d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts @@ -0,0 +1,181 @@ +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', + 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('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( + "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, 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() + 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 00000000000..1c631b37c82 --- /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 072bb865c34..681dd3088a8 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 @@ -64,7 +64,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, () => @@ -296,6 +296,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 00000000000..9e4280fb54f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts @@ -0,0 +1,335 @@ +// 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) +} +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 () => { + 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(held()).toEqual([edited]) + 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(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 + 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 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(held()).toEqual([id]) + await hold(id, 'release', 'edit-1', { callerKey: 'phone' }) + expect(held()).toEqual([]) + }) + + 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(held()).toEqual([id]) + expect(published(id)?.paused).toBeUndefined() + expect(publication().nextQueuedMessageId).toBeNull() + }) + + it('an edit open across /clear keeps its hold and saves into the same card', async () => { + const working = await rig.workingSend() + const id = await queue('before clear') + await hold(id) + await rig.stop() + await rig.settleAccepted(working, 'answer') + const fields = { command: 'clear' as const } + expect( + await rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + ).toMatchObject({ ok: true }) + expect(held()).toEqual([id]) + expect(await update(id, fingerprint(id), 'after clear')).toMatchObject({ + value: { status: 'updated' } + }) + expect(published(id)?.body.blocks).toEqual([{ type: 'text', text: 'after clear' }]) + expect(publication().queuePause).toBeNull() + expect(journal().queuedMessages.get(id)?.state).toBe('waiting') + }) + + 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 00000000000..d7cc959662e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.ts @@ -0,0 +1,117 @@ +// 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 +): 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' } + } + const queued = session.journal.queuedMessages + if (params.action !== 'acquire') { + // 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) + 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 + }) +} + +/** 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, + 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 249396d38b5..ac85a5ed8bf 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-consume-error' 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 4c8c2ab2e6c..ab8a9384f09 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-consume-error' import { isJournalWrittenByNewerOrca, journalOpenRefusal @@ -72,7 +72,7 @@ export async function withdrawQueuedMessagesForOperation( /** 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-published-bytes.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-published-bytes.ts index 4e1222b8342..e5545001890 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 a5ddfeb6474..fc8078ad649 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,34 @@ 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), /** 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 e761fd9e04c..9ce687d486d 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 00000000000..a2bb215e160 --- /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 5689330e3c3..821bfd6f171 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 1f749d50490..76ce9c1b824 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/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index e287472bb6c..f67dd8ed74f 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 () => { 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 56b30d837a5..6ceb45937d8 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/renderer/src/components/ButtonKeyHint.test.tsx b/src/renderer/src/components/ButtonKeyHint.test.tsx new file mode 100644 index 00000000000..669951688e4 --- /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( + + ) + 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 00000000000..23dc2ac44c2 --- /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/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 9201e87ef45..c372efbe93d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -35,6 +35,8 @@ import { useNativeChatClippedLine } from './use-native-chat-clipped-line' 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( @@ -138,6 +140,8 @@ export function NativeChatQueuedMessageCard({ onSteer, onDelete, onEdit, + editor, + editDisabled = false, onTurnOffQueueing }: { card: QueuedMessageCard @@ -151,7 +155,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 { @@ -164,6 +173,21 @@ 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 (
  • )}
    - {card.from ? ( - - ) : null} + {sender} {expanded ? null : (

    {card.text} @@ -256,38 +274,43 @@ 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 ? ( + + + + + + {/* 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 00000000000..5afab04acf6 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.test.tsx @@ -0,0 +1,192 @@ +// @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, values?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => values?.[name] ?? '') +})) +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, its keys apart from the prompt', + (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 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 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('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 { 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 new file mode 100644 index 00000000000..71754511ed0 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageEditor.tsx @@ -0,0 +1,109 @@ +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 { ButtonKeyHint } from '@/components/ButtonKeyHint' +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 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, + sender +}: { + editor: QueuedMessageInlineEditor + /** The card's "From " line, kept beside the label. */ + sender?: ReactNode +}): 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 ( +
    +
    + + + {translate('components.native-chat.queuedMessages.editingMessage', 'Editing message')} + + {sender ? ( + <> + · +
    {sender}
    + + ) : null} + +
    +