Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
26f525a
fix(webui): let Escape undo a message edit instead of stranding the t…
RickyYii Aug 26, 2026
47f0dd5
Fence message edit cancellation to its session
lihongguang-0014 Sep 1, 2026
927319c
Preserve message edit ownership on cancellation
lihongguang-0014 Sep 1, 2026
b45391d
Cancel stale sends after message edit rollback
lihongguang-0014 Sep 1, 2026
e15a974
Fence stale edit ownership from queued sends
lihongguang-0014 Sep 1, 2026
6d63e61
Harden message edit ownership across retries
lihongguang-0014 Sep 2, 2026
a99e82c
fix(webui): isolate recovered receipt streams
lihongguang-0014 Sep 2, 2026
b17e2ea
fix(webui): isolate recovered receipts from edits
lihongguang-0014 Sep 2, 2026
e886fc8
fix(webui): fence edit-owned history recovery
lihongguang-0014 Sep 2, 2026
2d29d72
fix(webui): close edit recovery ownership races
lihongguang-0014 Sep 2, 2026
beb6ded
fix(webui): settle isolated receipt lifecycles
lihongguang-0014 Sep 2, 2026
c9ee32d
Make recovered receipt settlement crash-safe
lihongguang-0014 Sep 2, 2026
956225a
Complete background receipt handoff recovery
lihongguang-0014 Sep 2, 2026
2d56253
Keep receipt recovery alive through WAL deletion
lihongguang-0014 Sep 2, 2026
e643a32
Fence background receipt recovery by session
lihongguang-0014 Sep 2, 2026
951adc4
Quarantine automatic background receipt recovery
lihongguang-0014 Sep 2, 2026
84def13
Preserve regenerate receipt adoption
lihongguang-0014 Sep 2, 2026
4ebe861
Replay superseded edit receipts in background
lihongguang-0014 Sep 3, 2026
9028084
Retire rejected background receipts durably
lihongguang-0014 Sep 3, 2026
3f91f88
Complete background receipt recovery
lihongguang-0014 Sep 3, 2026
2fc6c49
Fence recovered receipt projection and retries
lihongguang-0014 Sep 3, 2026
8019aa9
Serialize rejected receipt continuation
lihongguang-0014 Sep 3, 2026
1aa1b04
Defer receipt projection until ACK
lihongguang-0014 Sep 3, 2026
bb80dfb
Harden accepted receipt recovery
lihongguang-0014 Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function harness(over: {
safari?: boolean
slashOpen?: boolean
filteredSlashCmds?: ChatSlashCommand[]
cancelMessageEdit?: () => boolean
} = {}) {
const inputText = ref(over.inputText ?? '')
const spies = {
Expand All @@ -56,6 +57,7 @@ function harness(over: {
closeSlashMenu: vi.fn(),
completeSlashCmd: vi.fn(),
activateSlashCmd: vi.fn(),
cancelMessageEdit: vi.fn(over.cancelMessageEdit ?? (() => false)),
}
const api = useChatComposerShortcuts({
inputText,
Expand Down Expand Up @@ -294,3 +296,76 @@ describe('useChatComposerShortcuts', () => {
})
})
})

describe('Escape and message edits', () => {
it('cancels an uncommitted edit instead of clearing the composer', () => {
// #1372: edit mode empties the transcript on the first click, and Escape
// used to clear the draft and leave that empty state on screen. Cancelling
// the edit is the whole action — the composer is restored by the cancel
// itself, so Escape must not go on to blank it.
const { api, inputText, spies } = harness({
inputText: 'B',
cancelMessageEdit: () => true,
})

const e = keydown({ key: 'Escape', target: field('B', 'end') })
api.onTextareaKeydown(e)

expect(spies.cancelMessageEdit).toHaveBeenCalledOnce()
expect(e.preventDefault).toHaveBeenCalledOnce()
expect(inputText.value).toBe('B')
})

it('offers the cancel even when the composer has been emptied by hand', () => {
// The old guard required a non-empty draft, so clearing the box first left
// no way out of the truncated transcript at all.
const { api, spies } = harness({ inputText: '', cancelMessageEdit: () => true })

api.onTextareaKeydown(keydown({ key: 'Escape', target: field('', 'end') }))

expect(spies.cancelMessageEdit).toHaveBeenCalledOnce()
})

it('offers the cancel before the pending-queue guard', () => {
const { api, spies } = harness({
inputText: 'B',
pendingQueue: QUEUE,
cancelMessageEdit: () => true,
})
const e = keydown({ key: 'Escape', target: field('B', 'end') })

api.onTextareaKeydown(e)

expect(spies.cancelMessageEdit).toHaveBeenCalledOnce()
expect(e.preventDefault).toHaveBeenCalledOnce()
})

it('still clears the draft when there is no edit to cancel', () => {
const { api, inputText, spies } = harness({ inputText: 'just a draft' })

const e = keydown({ key: 'Escape', target: field('just a draft', 'end') })
api.onTextareaKeydown(e)

expect(spies.cancelMessageEdit).toHaveBeenCalledOnce()
expect(inputText.value).toBe('')
expect(e.preventDefault).toHaveBeenCalledOnce()
})

it('leaves the slash menu Escape alone', () => {
// Escape closes the menu first; an edit underneath it is not touched until
// the menu is out of the way.
const { api, spies } = harness({
inputText: '/co',
slashOpen: true,
filteredSlashCmds: [
{ name: '/coding', cmd: '/coding', label: '/coding', desc: '' },
] as unknown as ChatSlashCommand[],
cancelMessageEdit: () => true,
})

api.onTextareaKeydown(keydown({ key: 'Escape', target: field('/co', 'end') }))

expect(spies.closeSlashMenu).toHaveBeenCalledOnce()
expect(spies.cancelMessageEdit).not.toHaveBeenCalled()
})
})
33 changes: 27 additions & 6 deletions opensquilla-webui/src/composables/chat/useChatComposerShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export interface UseChatComposerShortcutsOptions {
popPendingTail: () => boolean
enqueuePendingInput: (text: string) => boolean | Promise<boolean>
sendCurrentInput: () => void
/**
* Undo an uncommitted message edit, returning whether it had one to undo.
* Escape has to offer this before it clears the composer: edit mode has no
* other exit, and clearing the draft on its own leaves the truncated
* transcript on screen (#1372).
*/
cancelMessageEdit?: () => boolean
isSafariWebKit?: () => boolean
}

Expand Down Expand Up @@ -116,12 +123,26 @@ export function useChatComposerShortcuts(options: UseChatComposerShortcutsOption
}
}

if (e.key === 'Escape' && !options.isStreaming.value && options.pendingQueue.value.length === 0 && options.inputText.value) {
e.preventDefault()
clearTextareaUndoState()
options.inputText.value = ''
options.autoResizeTextarea()
return
if (e.key === 'Escape') {
// An uncommitted edit outranks clearing the draft, and is checked before
// the non-empty-input guard below: emptying the composer by hand must not
// strand the user in a truncated transcript with no way out.
if (options.cancelMessageEdit?.()) {
e.preventDefault()
clearTextareaUndoState()
return
}
if (
!options.isStreaming.value
&& options.pendingQueue.value.length === 0
&& options.inputText.value
) {
e.preventDefault()
clearTextareaUndoState()
options.inputText.value = ''
options.autoResizeTextarea()
return
}
}

if (e.key === 'ArrowUp' && e.altKey && caretAtStart && options.pendingQueue.value.length > 0) {
Expand Down
198 changes: 198 additions & 0 deletions opensquilla-webui/src/composables/chat/useChatHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1955,6 +1955,81 @@ describe('useChatHistory canonical pagination', () => {
)
})

it('defers a forward-bridge result when Edit starts during an after-page read', async () => {
vi.useFakeTimers()
try {
let resolveBridge!: (value: SessionReadHistoryPageFixture) => void
const bridgeResponse = new Promise<SessionReadHistoryPageFixture>(resolve => {
resolveBridge = resolve
})
const { api, readHistory, historyFixture, messages } = makeHistory(false)
historyFixture
.mockResolvedValueOnce({
messages: [historyMessage('m1')],
hasMore: true,
oldestCursor: 'cursor-1',
newestCursor: 'cursor-1',
canonicalAvailable: true,
})
.mockResolvedValueOnce({
messages: [historyMessage('m0')],
hasMore: false,
oldestCursor: 'cursor-0',
newestCursor: 'cursor-0',
canonicalAvailable: true,
})
.mockResolvedValueOnce({
messages: [historyMessage('m9')],
hasMore: false,
oldestCursor: 'cursor-9',
newestCursor: 'cursor-9',
canonicalAvailable: true,
})
.mockImplementationOnce(() => bridgeResponse)
.mockResolvedValueOnce({
messages: [historyMessage('m9')],
hasMore: false,
oldestCursor: 'cursor-9',
newestCursor: 'cursor-9',
canonicalAvailable: true,
})
.mockResolvedValueOnce({
messages: [historyMessage('m2'), historyMessage('m9')],
hasMore: false,
oldestCursor: 'cursor-2',
newestCursor: 'cursor-9',
canonicalAvailable: true,
})

await api.loadHistory()
await api.loadEarlierHistory()
const refresh = api.loadHistory()
await vi.waitFor(() => expect(readHistory).toHaveBeenCalledTimes(4))
const editOwnerRef = messages.value
api.holdHistorySync()
resolveBridge({
messages: [historyMessage('m2'), historyMessage('m9')],
hasMore: false,
oldestCursor: 'cursor-2',
newestCursor: 'cursor-9',
canonicalAvailable: true,
})
await refresh

expect(messages.value).toBe(editOwnerRef)
expect(messages.value.map(message => message.messageId)).toEqual(['m0', 'm1'])

api.releaseHistorySync()
await vi.advanceTimersByTimeAsync(50)
await vi.advanceTimersByTimeAsync(0)

expect(readHistory).toHaveBeenCalledTimes(6)
expect(messages.value.map(message => message.messageId)).toEqual(['m0', 'm1', 'm2', 'm9'])
} finally {
vi.useRealTimers()
}
})

it('bounds each disconnected forward bridge and resumes from the saved cursor', async () => {
const { api, readHistory, historyFixture, messages } = makeHistory(false)
historyFixture
Expand Down Expand Up @@ -2405,6 +2480,129 @@ describe('useChatHistory canonical pagination', () => {
}
})

it('pauses a terminal history timer until Edit releases it', async () => {
vi.useFakeTimers()
try {
const initialEditOwner: ChatMessage[] = [{
role: 'user',
text: 'edit-owned transcript',
ts: null,
messageId: 'edit-owner',
}]
const { api, readHistory, messages } = makeHistory(false, {
messages: initialEditOwner,
response: {
messages: [historyMessage('canonical-after-escape')],
hasMore: false,
oldestCursor: null,
},
})
const editOwner = messages.value

api.scheduleHistorySync()
api.holdHistorySync()
await vi.advanceTimersByTimeAsync(50)

expect(readHistory).not.toHaveBeenCalled()
expect(messages.value).toBe(editOwner)

api.releaseHistorySync()
await vi.advanceTimersByTimeAsync(50)
await vi.advanceTimersByTimeAsync(0)

expect(readHistory).toHaveBeenCalledOnce()
expect(messages.value.map(message => message.messageId)).toEqual([
'canonical-after-escape',
])
} finally {
vi.useRealTimers()
}
})

it('drops an old Edit hold at a session boundary so the new draft can sync', async () => {
vi.useFakeTimers()
try {
const sessionKey = ref('agent:main:webchat:old')
const { api, readHistory, historyFixture, messages } = makeHistory(false, { sessionKey })
historyFixture.mockResolvedValueOnce({
messages: [historyMessage('new-session-terminal')],
hasMore: false,
oldestCursor: null,
})

api.holdHistorySync()
api.scheduleHistorySync()
sessionKey.value = 'agent:main:webchat:new-draft'
api.releaseHistorySync()

api.scheduleHistorySync()
await vi.advanceTimersByTimeAsync(50)
await vi.advanceTimersByTimeAsync(0)

expect(readHistory).toHaveBeenCalledOnce()
expect(messages.value.map(message => message.messageId)).toEqual([
'new-session-terminal',
])
} finally {
vi.useRealTimers()
}
})

it('defers an in-flight history replacement until Edit releases it', async () => {
vi.useFakeTimers()
try {
let resolveWhileEditing!: (value: SessionReadHistoryPageFixture) => void
const responseWhileEditing = new Promise<SessionReadHistoryPageFixture>(resolve => {
resolveWhileEditing = resolve
})
const initialEditOwner: ChatMessage[] = [{
role: 'user',
text: 'edit-owned transcript',
ts: null,
messageId: 'edit-owner',
}]
const { api, readHistory, historyFixture, messages } = makeHistory(false, {
messages: initialEditOwner,
})
const editOwner = messages.value
historyFixture
.mockImplementationOnce(() => responseWhileEditing)
.mockResolvedValueOnce({
messages: [historyMessage('canonical-after-escape')],
hasMore: false,
oldestCursor: null,
})

// A terminal schedules the sync; Edit starts after its timer has already
// launched the read but before that read can replace the transcript.
api.scheduleHistorySync()
await vi.advanceTimersByTimeAsync(50)
expect(readHistory).toHaveBeenCalledOnce()
api.holdHistorySync()
resolveWhileEditing({
messages: [historyMessage('canonical-during-edit')],
hasMore: false,
oldestCursor: null,
})
await vi.advanceTimersByTimeAsync(0)

expect(messages.value).toBe(editOwner)
expect(messages.value.map(message => message.messageId)).toEqual(['edit-owner'])

// Escape releases the hold and exactly one deferred refresh applies.
api.releaseHistorySync()
await vi.advanceTimersByTimeAsync(50)
await vi.advanceTimersByTimeAsync(0)

expect(readHistory).toHaveBeenCalledTimes(2)
expect(messages.value.map(message => message.messageId)).toEqual([
'canonical-after-escape',
])
} finally {
vi.useRealTimers()
}
})

it('keeps the new session loading when a stale request fails first', async () => {
const sessionKey = ref('agent:main:webchat:old')
let rejectOld!: (reason: Error) => void
Expand Down
Loading
Loading