Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions frontend/taskdeck-web/src/components/chat/ChatMessageList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const props = defineProps<{
bindingMessageId: string | null
boardBindingError: string | null
boardBindingReceipt: string | null
boardLoadError: string | null
}>()

const emit = defineEmits<{
Expand All @@ -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<Set<string>>(new Set())
Expand Down Expand Up @@ -240,6 +242,18 @@ function bindSelectedBoard(messageId: string) {
{{ sendingMessage ? 'Continuing...' : 'Continue retained instruction' }}
</button>
</template>
<template v-else-if="boardLoadError">
Comment thread
Chris0Jeky marked this conversation as resolved.
<p class="td-board-recovery__error" role="alert">
Unable to load writable boards: {{ boardLoadError }}
</p>
<button
class="td-btn td-btn--secondary td-btn--sm"
:disabled="loadingBoards"
@click="emit('reload-boards')"
>
{{ loadingBoards ? 'Retrying...' : 'Retry loading boards' }}
</button>
</template>
<template v-else-if="loadingBoards">
<p class="td-board-recovery__copy">Loading writable boards...</p>
</template>
Expand Down
61 changes: 55 additions & 6 deletions frontend/taskdeck-web/src/composables/useAutomationChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -28,10 +28,13 @@ export function useAutomationChat() {
const bindingMessageId = ref<string | null>(null)
const boardBindingError = ref<string | null>(null)
const boardBindingReceipt = ref<string | null>(null)
const boardOptionsLoadError = ref<string | null>(null)
let boardOptionsRequest: Promise<boolean> | null = null
let sessionSelectionGeneration = 0
let boardBindingGeneration = 0
let requestedSessionId: string | null = null
let localMessageSequence = 0
const localMessagesBySession = new Map<string, ChatMessage[]>()
const chatHealth = ref<ChatProviderHealth | null>(null)
const chatHealthLoadError = ref<string | null>(null)

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -476,6 +523,7 @@ export function useAutomationChat() {

onScopeDispose(() => {
isDisposed = true
localMessagesBySession.clear()
stopWatch()
})

Expand All @@ -492,6 +540,7 @@ export function useAutomationChat() {
bindingMessageId,
boardBindingError,
boardBindingReceipt,
boardOptionsLoadError,
chatHealth,
chatHealthLoadError,
newSessionTitle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function mountList(overrides: Record<string, unknown> = {}) {
bindingMessageId: null,
boardBindingError: null,
boardBindingReceipt: null,
boardLoadError: null,
...overrides,
},
})
Expand Down Expand Up @@ -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',
Expand Down
172 changes: 172 additions & 0 deletions frontend/taskdeck-web/src/tests/composables/useAutomationChat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] }
Expand Down Expand Up @@ -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<unknown[]>((_, 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<unknown[]>((_, 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([
Expand Down
Loading
Loading