From 040c94d231cd0cf052cc5ea3197cb83c653d132a Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 9 Sep 2026 14:10:55 +0100 Subject: [PATCH] Preserve chat recovery transcript state --- .../src/components/chat/ChatMessageList.vue | 14 ++ .../src/composables/useAutomationChat.ts | 61 ++++++- .../components/chat/ChatMessageList.spec.ts | 12 ++ .../composables/useAutomationChat.spec.ts | 172 ++++++++++++++++++ .../src/views/AutomationChatView.vue | 3 + 5 files changed, 256 insertions(+), 6 deletions(-) diff --git a/frontend/taskdeck-web/src/components/chat/ChatMessageList.vue b/frontend/taskdeck-web/src/components/chat/ChatMessageList.vue index b579145028..7919daac8e 100644 --- a/frontend/taskdeck-web/src/components/chat/ChatMessageList.vue +++ b/frontend/taskdeck-web/src/components/chat/ChatMessageList.vue @@ -21,6 +21,7 @@ const props = defineProps<{ bindingMessageId: string | null boardBindingError: string | null boardBindingReceipt: string | null + boardLoadError: string | null }>() const emit = defineEmits<{ @@ -29,6 +30,7 @@ const emit = defineEmits<{ (e: 'bind-board', messageId: string, boardId: string): void (e: 'continue-instruction', messageId: string): void (e: 'open-boards'): void + (e: 'reload-boards'): void }>() const expandedHintIds = ref>(new Set()) @@ -240,6 +242,18 @@ function bindSelectedBoard(messageId: string) { {{ sendingMessage ? 'Continuing...' : 'Continue retained instruction' }} + diff --git a/frontend/taskdeck-web/src/composables/useAutomationChat.ts b/frontend/taskdeck-web/src/composables/useAutomationChat.ts index 0aa014a00c..7bcdff08e8 100644 --- a/frontend/taskdeck-web/src/composables/useAutomationChat.ts +++ b/frontend/taskdeck-web/src/composables/useAutomationChat.ts @@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router' import { chatApi } from '../api/chatApi' import { boardsApi } from '../api/boardsApi' import { useToastStore } from '../store/toastStore' -import type { ChatProviderHealth, ChatSession } from '../types/chat' +import type { ChatMessage, ChatProviderHealth, ChatSession } from '../types/chat' import type { Board } from '../types/board' import { normalizeChatRole } from '../utils/chat' import { getErrorDisplay } from './useErrorMapper' @@ -28,10 +28,13 @@ export function useAutomationChat() { const bindingMessageId = ref(null) const boardBindingError = ref(null) const boardBindingReceipt = ref(null) + const boardOptionsLoadError = ref(null) let boardOptionsRequest: Promise | null = null let sessionSelectionGeneration = 0 let boardBindingGeneration = 0 let requestedSessionId: string | null = null + let localMessageSequence = 0 + const localMessagesBySession = new Map() const chatHealth = ref(null) const chatHealthLoadError = ref(null) @@ -134,6 +137,47 @@ export function useAutomationChat() { const queryBoardId = computed(() => normalizeBoardIdQueryParam(route.query.boardId)) + function createLocalUserMessage(sessionId: string, content: string, assistantCreatedAt: string): ChatMessage { + const assistantTimestamp = Date.parse(assistantCreatedAt) + const createdAt = Number.isFinite(assistantTimestamp) + ? new Date(assistantTimestamp - 1).toISOString() + : new Date().toISOString() + + // Local-only identity: this message is merged into the visible transcript, + // never sent back through the chat API. The sequence keeps IDs unique within + // this composable while the timestamp fixes the user/reply ordering. + localMessageSequence += 1 + return { + id: `local-user-${sessionId}-${localMessageSequence}`, + sessionId, + role: 'User', + content, + messageType: 'text', + proposalId: null, + tokenUsage: null, + createdAt, + } + } + + function retainLocalMessages(sessionId: string, messages: ChatMessage[]): ChatMessage[] { + const existing = localMessagesBySession.get(sessionId) ?? [] + const byId = new Map(existing.map((message) => [message.id, message])) + for (const message of messages) { + byId.set(message.id, message) + } + const retained = [...byId.values()] + localMessagesBySession.set(sessionId, retained) + return retained + } + + function mergeLocalMessages(messages: ChatMessage[], localMessages: ChatMessage[]): ChatMessage[] { + const knownIds = new Set(messages.map((message) => message.id)) + return [ + ...messages, + ...localMessages.filter((message) => !knownIds.has(message.id)), + ] + } + function normalizeSelectedBoardId(rawValue: string): string | null { const trimmed = rawValue.trim() if (!trimmed) { @@ -227,6 +271,7 @@ export function useAutomationChat() { try { const result = await chatApi.getSession(sessionId) if (isDisposed || selectionGeneration !== sessionSelectionGeneration) return + localMessagesBySession.delete(sessionId) selectedSession.value = result } catch (e: unknown) { if (isDisposed || selectionGeneration !== sessionSelectionGeneration) return @@ -239,6 +284,7 @@ export function useAutomationChat() { try { const result = await chatApi.getSession(sessionId) if (isDisposed || requestedSessionId !== sessionId || selectedSession.value?.id !== sessionId) return + localMessagesBySession.delete(sessionId) selectedSession.value = result const sessionIndex = sessions.value.findIndex((session) => session.id === sessionId) if (sessionIndex >= 0) sessions.value.splice(sessionIndex, 1, result) @@ -322,12 +368,11 @@ export function useAutomationChat() { if (requestedSessionId === sessionId && selectedSession.value?.id === sessionId) { messageContent.value = '' const currentSession = selectedSession.value + const localUserMessage = createLocalUserMessage(sessionId, content, sentMessage.createdAt) + const retainedLocalMessages = retainLocalMessages(sessionId, [localUserMessage, sentMessage]) selectedSession.value = { ...currentSession, - recentMessages: [ - ...currentSession.recentMessages.filter((message) => message.id !== sentMessage.id), - sentMessage, - ], + recentMessages: mergeLocalMessages(currentSession.recentMessages, retainedLocalMessages), } await refreshSelectedSession(sessionId) } @@ -402,13 +447,15 @@ export function useAutomationChat() { request = (async () => { try { loadingBoards.value = true + boardOptionsLoadError.value = null const result = await boardsApi.getBoards() if (isDisposed) return false availableBoards.value = result return true } catch (e: unknown) { if (isDisposed) return false - toast.error(getErrorDisplay(e, 'Failed to load boards').message) + boardOptionsLoadError.value = getErrorDisplay(e, 'Failed to load boards').message + toast.error(boardOptionsLoadError.value) return false } finally { if (!isDisposed) loadingBoards.value = false @@ -476,6 +523,7 @@ export function useAutomationChat() { onScopeDispose(() => { isDisposed = true + localMessagesBySession.clear() stopWatch() }) @@ -492,6 +540,7 @@ export function useAutomationChat() { bindingMessageId, boardBindingError, boardBindingReceipt, + boardOptionsLoadError, chatHealth, chatHealthLoadError, newSessionTitle, diff --git a/frontend/taskdeck-web/src/tests/components/chat/ChatMessageList.spec.ts b/frontend/taskdeck-web/src/tests/components/chat/ChatMessageList.spec.ts index 2d627bd0aa..a724908266 100644 --- a/frontend/taskdeck-web/src/tests/components/chat/ChatMessageList.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/chat/ChatMessageList.spec.ts @@ -64,6 +64,7 @@ function mountList(overrides: Record = {}) { bindingMessageId: null, boardBindingError: null, boardBindingReceipt: null, + boardLoadError: null, ...overrides, }, }) @@ -101,6 +102,17 @@ describe('ChatMessageList board recovery', () => { expect(wrapper.emitted('open-boards')).toHaveLength(1) }) + it('keeps board-load failure separate from the no-board state and offers retry', async () => { + const wrapper = mountList({ boardLoadError: 'Boards unavailable' }) + + expect(wrapper.text()).toContain('Boards unavailable') + expect(wrapper.text()).toContain('Retry loading boards') + expect(wrapper.text()).not.toContain('no active boards you can edit') + + await wrapper.get('button.td-btn--secondary').trigger('click') + expect(wrapper.emitted('reload-boards')).toHaveLength(1) + }) + it('shows a binding receipt and waits for explicit continuation', async () => { const wrapper = mountList({ selectedSessionBoardId: 'board-1', diff --git a/frontend/taskdeck-web/src/tests/composables/useAutomationChat.spec.ts b/frontend/taskdeck-web/src/tests/composables/useAutomationChat.spec.ts index 90496b571a..bc25bafe33 100644 --- a/frontend/taskdeck-web/src/tests/composables/useAutomationChat.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useAutomationChat.spec.ts @@ -392,6 +392,113 @@ describe('useAutomationChat', () => { }) describe('session response races', () => { + it('does not add a local turn before the send request succeeds', async () => { + const session = { id: 's1', title: 'First', boardId: null, recentMessages: [] } + const deferred = createDeferred<{ + id: string; sessionId: string; role: number; messageType: string; proposalId: null; + tokenUsage: number; content: string; createdAt: string; + }>() + chatApiMocks.getMySessions.mockResolvedValue([session]) + chatApiMocks.getSession.mockResolvedValue(session) + chatApiMocks.sendMessage.mockReturnValue(deferred.promise) + + const { useAutomationChat } = await loadComposable() + const chat = useAutomationChat() + await vi.waitFor(() => expect(chat.selectedSession.value?.id).toBe('s1')) + + chat.messageContent.value = 'new instruction' + const pendingSend = chat.handleSendMessage() + await Promise.resolve() + + expect(chat.selectedSession.value?.recentMessages).toEqual([]) + + deferred.resolve({ + id: 'reply-1', sessionId: 's1', role: 1, messageType: 'text', proposalId: null, + tokenUsage: 12, content: 'Done', createdAt: '2026-05-16T10:01:00Z', + }) + await pendingSend + }) + + it('retains the just-submitted instruction when the immediate refresh fails', async () => { + const oldUser = { + id: 'old-user', sessionId: 's1', role: 0, messageType: 'text', + proposalId: null, tokenUsage: null, content: 'older instruction', + createdAt: '2026-05-16T10:00:00Z', + } + const oldRecovery = { + id: 'old-recovery', sessionId: 's1', role: 1, messageType: 'action-needs-board', + proposalId: null, tokenUsage: 12, content: 'No board linked', + createdAt: '2026-05-16T10:01:00Z', + } + const session = { id: 's1', title: 'First', boardId: null, recentMessages: [oldUser, oldRecovery] } + const reply = { + id: 'new-recovery', sessionId: 's1', role: 1, messageType: 'action-needs-board', + proposalId: null, tokenUsage: 12, content: 'No board linked for the new instruction', + createdAt: '2026-05-16T10:03:00Z', + } + chatApiMocks.getMySessions.mockResolvedValue([session]) + chatApiMocks.getSession + .mockResolvedValueOnce(session) + .mockRejectedValueOnce(new Error('refresh failed')) + chatApiMocks.sendMessage.mockResolvedValue(reply) + + const { useAutomationChat } = await loadComposable() + const chat = useAutomationChat() + await vi.waitFor(() => expect(chat.selectedSession.value?.id).toBe('s1')) + + chat.messageContent.value = 'new instruction' + await chat.handleSendMessage() + + expect(chat.selectedSession.value?.recentMessages.map((message) => message.content)).toEqual([ + 'older instruction', + 'No board linked', + 'new instruction', + 'No board linked for the new instruction', + ]) + expect(chat.selectedSession.value?.recentMessages[2]?.id).toMatch(/^local-/) + expect(chat.pendingBoardRecovery.value).toEqual({ + messageId: 'new-recovery', + instruction: 'new instruction', + }) + }) + + it('replaces retained local messages with the next authoritative session result', async () => { + const session = { id: 's1', title: 'First', boardId: null, recentMessages: [] } + const reply = { + id: 'reply-1', sessionId: 's1', role: 1, messageType: 'text', + proposalId: null, tokenUsage: 12, content: 'Done', + createdAt: '2026-05-16T10:01:00Z', + } + const authoritative = { + ...session, + recentMessages: [ + { + id: 'server-user-1', sessionId: 's1', role: 0, messageType: 'text', + proposalId: null, tokenUsage: null, content: 'new instruction', + createdAt: '2026-05-16T10:00:59Z', + }, + reply, + ], + } + chatApiMocks.getMySessions.mockResolvedValue([session]) + chatApiMocks.getSession + .mockResolvedValueOnce(session) + .mockRejectedValueOnce(new Error('refresh failed')) + .mockResolvedValueOnce(authoritative) + chatApiMocks.sendMessage.mockResolvedValue(reply) + + const { useAutomationChat } = await loadComposable() + const chat = useAutomationChat() + await vi.waitFor(() => expect(chat.selectedSession.value?.id).toBe('s1')) + + chat.messageContent.value = 'new instruction' + await chat.handleSendMessage() + await chat.loadSession('s1') + + expect(chat.selectedSession.value?.recentMessages).toEqual(authoritative.recentMessages) + expect(chat.selectedSession.value?.recentMessages.filter((message) => message.id === 'reply-1')).toHaveLength(1) + }) + it('does not switch back when a send response completes after another session is selected', async () => { const first = { id: 's1', title: 'First', boardId: null, recentMessages: [] } const second = { id: 's2', title: 'Second', boardId: null, recentMessages: [] } @@ -487,6 +594,71 @@ describe('useAutomationChat', () => { }) }) + describe('board loading recovery', () => { + it('keeps a board-load error available instead of presenting unavailable boards as empty', async () => { + boardsApiMocks.getBoards.mockRejectedValue(new Error('Boards unavailable')) + + const { useAutomationChat } = await loadComposable() + const chat = useAutomationChat() + + await vi.waitFor(() => expect(chat.boardOptionsLoadError.value).toBe('Boards unavailable')) + expect(chat.eligibleBoards.value).toEqual([]) + expect(chat.boardOptionsLoadError.value).toBe('Boards unavailable') + }) + + it('clears the board-load error only after an explicit retry succeeds', async () => { + boardsApiMocks.getBoards + .mockRejectedValueOnce(new Error('Boards unavailable')) + .mockResolvedValueOnce([ + { id: 'b1', name: 'Release Board', description: null, isArchived: false, canWrite: true }, + ]) + + const { useAutomationChat } = await loadComposable() + const chat = useAutomationChat() + await vi.waitFor(() => expect(chat.boardOptionsLoadError.value).toBe('Boards unavailable')) + + await expect(chat.loadBoardOptions()).resolves.toBe(true) + + expect(chat.boardOptionsLoadError.value).toBeNull() + expect(chat.eligibleBoards.value.map((board) => board.id)).toEqual(['b1']) + }) + + it('shows loading during retry and keeps the failure when retry also fails', async () => { + let rejectRetry!: (reason?: unknown) => void + const retryPromise = new Promise((_, reject) => { rejectRetry = reject }) + boardsApiMocks.getBoards + .mockRejectedValueOnce(new Error('Boards unavailable')) + .mockReturnValueOnce(retryPromise) + + const { useAutomationChat } = await loadComposable() + const chat = useAutomationChat() + await vi.waitFor(() => expect(chat.boardOptionsLoadError.value).toBe('Boards unavailable')) + + const pendingRetry = chat.loadBoardOptions() + expect(chat.loadingBoards.value).toBe(true) + expect(chat.boardOptionsLoadError.value).toBeNull() + + rejectRetry(new Error('Still unavailable')) + await expect(pendingRetry).resolves.toBe(false) + expect(chat.loadingBoards.value).toBe(false) + expect(chat.boardOptionsLoadError.value).toBe('Still unavailable') + }) + + it('does not write a late board-load error after disposal', async () => { + let rejectBoards!: (reason?: unknown) => void + boardsApiMocks.getBoards.mockReturnValue(new Promise((_, reject) => { rejectBoards = reject })) + + const { useAutomationChat } = await loadComposable() + const chat = useAutomationChat() + for (const fn of scopeDisposeFns) fn() + + rejectBoards(new Error('late board failure')) + await vi.waitFor(() => expect(boardsApiMocks.getBoards).toHaveBeenCalled()) + + expect(chat.boardOptionsLoadError.value).toBeNull() + }) + }) + describe('openProposalReview', () => { it('navigates to workspace review with proposal hash', async () => { chatApiMocks.getMySessions.mockResolvedValue([ diff --git a/frontend/taskdeck-web/src/views/AutomationChatView.vue b/frontend/taskdeck-web/src/views/AutomationChatView.vue index b645f01e55..c8ff330c7b 100644 --- a/frontend/taskdeck-web/src/views/AutomationChatView.vue +++ b/frontend/taskdeck-web/src/views/AutomationChatView.vue @@ -19,6 +19,7 @@ const { bindingMessageId, boardBindingError, boardBindingReceipt, + boardOptionsLoadError, chatHealth, chatHealthLoadError, newSessionTitle, @@ -121,11 +122,13 @@ const { :binding-message-id="bindingMessageId" :board-binding-error="boardBindingError" :board-binding-receipt="boardBindingReceipt" + :board-load-error="boardOptionsLoadError" @apply-hint-suggestion="applyHintSuggestion" @open-proposal-review="openProposalReview" @bind-board="bindBoardToPendingTurn" @continue-instruction="continuePendingInstruction" @open-boards="openRoute('/workspace/boards')" + @reload-boards="loadBoardOptions" />