diff --git a/backend/src/Taskdeck.Application/Services/ChatService.cs b/backend/src/Taskdeck.Application/Services/ChatService.cs index 346f041936..eb9d8109f7 100644 --- a/backend/src/Taskdeck.Application/Services/ChatService.cs +++ b/backend/src/Taskdeck.Application/Services/ChatService.cs @@ -1462,10 +1462,7 @@ private static ChatSessionDto MapSessionToDto(ChatSession session) session.Status, session.CreatedAt, session.UpdatedAt, - // The UI and recovery logic consume this as a turn transcript. EF does not guarantee - // Include collection order, so return the causal creation order explicitly. session.Messages - .OrderBy(message => message.CreatedAt) .Select(MapMessageToDto) .ToList() ); diff --git a/backend/src/Taskdeck.Domain/Entities/ChatSession.cs b/backend/src/Taskdeck.Domain/Entities/ChatSession.cs index 31b1019fcf..b67dce8e35 100644 --- a/backend/src/Taskdeck.Domain/Entities/ChatSession.cs +++ b/backend/src/Taskdeck.Domain/Entities/ChatSession.cs @@ -11,7 +11,14 @@ public class ChatSession : Entity public ChatSessionStatus Status { get; private set; } private readonly List _messages = new(); - public IReadOnlyList Messages => _messages.AsReadOnly(); + // EF Core populates the backing field during relationship fixup and does not promise + // collection order. Expose a fresh, immutable transcript snapshot in chronological order, with + // Id as a deterministic tie-break, so every consumer observes the same history. + public IReadOnlyList Messages => _messages + .OrderBy(message => message.CreatedAt) + .ThenBy(message => message.Id) + .ToList() + .AsReadOnly(); private ChatSession() { } // EF Core diff --git a/backend/tests/Taskdeck.Api.Tests/ChatSessionRepositoryConcurrencyTests.cs b/backend/tests/Taskdeck.Api.Tests/ChatSessionRepositoryConcurrencyTests.cs index 9179438ef5..f9bc1dacdf 100644 --- a/backend/tests/Taskdeck.Api.Tests/ChatSessionRepositoryConcurrencyTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ChatSessionRepositoryConcurrencyTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; using Taskdeck.Api.Tests.Support; +using Taskdeck.Domain.Common; using Taskdeck.Domain.Entities; using Taskdeck.Infrastructure.Persistence; using Taskdeck.Infrastructure.Repositories; @@ -70,6 +71,64 @@ public async Task TryBindBoardAsync_ConcurrentSameBoard_LoserRereadsAuthoritativ "the CAS loser must not resolve an idempotent same-board race from its stale tracked entity"); } + [Fact] + public async Task GetByIdWithMessagesAsync_ShouldKeepTrackedNavigationChronological_AfterDescendingFixup() + { + var options = new DbContextOptionsBuilder() + .UseSqlite(TestSqlite.ConnectionString(_dbPath)) + .Options; + + var userId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + await using (var seedDb = new TaskdeckDbContext(options)) + { + await seedDb.Database.MigrateAsync(); + var user = new User( + $"chat-order-{Guid.NewGuid():N}"[..20], + $"chat-order-{Guid.NewGuid():N}@example.com", + "hash"); + var session = new ChatSession(user.Id, "Ordered history"); + typeof(Entity).GetProperty(nameof(Entity.Id))!.SetValue(session, sessionId); + var oldest = new ChatMessage(session.Id, ChatMessageRole.User, "Original instruction"); + var clarification = new ChatMessage( + session.Id, + ChatMessageRole.Assistant, + "What should I call it?", + "clarification"); + var answer = new ChatMessage(session.Id, ChatMessageRole.User, "Ship notes"); + var baseTime = DateTimeOffset.UtcNow.AddMinutes(-3); + SetCreatedAt(oldest, baseTime); + SetCreatedAt(clarification, baseTime.AddMinutes(1)); + SetCreatedAt(answer, baseTime.AddMinutes(2)); + session.AddMessage(oldest); + session.AddMessage(clarification); + session.AddMessage(answer); + seedDb.AddRange(user, session); + await seedDb.SaveChangesAsync(); + userId = user.Id; + } + + await using var db = new TaskdeckDbContext(options); + var trackedSession = await db.ChatSessions.FindAsync(sessionId); + trackedSession.Should().NotBeNull(); + trackedSession!.UserId.Should().Be(userId); + + // SQLite cannot translate DateTimeOffset ordering. Use the persisted column directly to + // reproduce descending materialization and let EF relationship fixup populate the tracked + // session navigation in that order. + await db.ChatMessages + .FromSqlInterpolated($"SELECT * FROM ChatMessages WHERE SessionId = {sessionId} ORDER BY CreatedAt DESC") + .LoadAsync(); + + trackedSession.Messages.Select(message => message.Content).Should().Equal( + "Original instruction", + "What should I call it?", + "Ship notes"); + } + + private static void SetCreatedAt(Entity entity, DateTimeOffset timestamp) + => typeof(Entity).GetProperty(nameof(Entity.CreatedAt))!.SetValue(entity, timestamp); + public void Dispose() { try diff --git a/backend/tests/Taskdeck.Application.Tests/Services/ChatServiceClarificationTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/ChatServiceClarificationTests.cs index a6e523c35a..154322cc74 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/ChatServiceClarificationTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/ChatServiceClarificationTests.cs @@ -250,6 +250,78 @@ public async Task SendMessage_ShouldAttemptOriginalIntentWithPlainClarificationA result.Value.ProposalId.Should().Be(proposalId); } + [Fact] + public async Task SendMessage_ShouldRecoverOriginalIntent_WhenPersistedHistoryArrivesScrambled() + { + var userId = Guid.NewGuid(); + var boardId = Guid.NewGuid(); + var proposalId = Guid.NewGuid(); + var session = new ChatSession(userId, "Scrambled clarification", boardId); + var original = new ChatMessage( + session.Id, + ChatMessageRole.User, + "create card for the release follow-up"); + var clarification = new ChatMessage( + session.Id, + ChatMessageRole.Assistant, + "What should the card be called?", + "clarification"); + var baseTime = DateTimeOffset.UtcNow.AddMinutes(-2); + SetCreatedAt(original, baseTime); + SetCreatedAt(clarification, baseTime.AddMinutes(1)); + + // EF navigation fixup can expose persisted rows in a different order than creation time. + session.AddMessage(clarification); + session.AddMessage(original); + _chatSessionRepoMock + .Setup(r => r.GetByIdWithMessagesAsync(session.Id, default)) + .ReturnsAsync(session); + _plannerMock + .Setup(planner => planner.ParseInstructionAsync( + It.Is(instruction => + instruction.Contains("create card for the release follow-up") && + instruction.Contains("Clarification answer: Ship notes")), + userId, + boardId, + It.IsAny(), + ProposalSourceType.Chat, + session.Id.ToString(), + It.IsAny())) + .ReturnsAsync(Result.Success(new ProposalDto( + proposalId, + ProposalSourceType.Chat, + null, + boardId, + userId, + ProposalStatus.PendingReview, + RiskLevel.Low, + "Create release follow-up", + null, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + DateTime.UtcNow.AddHours(1), + null, + null, + null, + null, + "corr", + new List()))); + + var result = await _service.SendMessageAsync( + session.Id, + userId, + new SendChatMessageDto("Ship notes"), + default); + + result.IsSuccess.Should().BeTrue(); + result.Value.MessageType.Should().Be("proposal-reference"); + result.Value.ProposalId.Should().Be(proposalId); + } + + private static void SetCreatedAt(Entity entity, DateTimeOffset timestamp) + => typeof(Entity).GetProperty(nameof(Entity.CreatedAt))!.SetValue(entity, timestamp); + [Fact] public async Task MockProvider_ShouldReturnClarification_ForAmbiguousInput() { diff --git a/backend/tests/Taskdeck.Application.Tests/Services/ChatServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/ChatServiceTests.cs index 461cdb7cb4..5d14bd52d2 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/ChatServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/ChatServiceTests.cs @@ -1086,6 +1086,50 @@ public async Task StreamResponseAsync_ShouldPassServerDerivedAttributionToProvid capturedRequest.Attribution.CorrelationId.Should().NotBeNullOrWhiteSpace(); } + [Fact] + public async Task StreamResponseAsync_ShouldSendChronologicalHistory_WhenTrackedMessagesArriveScrambled() + { + var userId = Guid.NewGuid(); + var session = new ChatSession(userId, "Scrambled stream history", Guid.NewGuid()); + var original = new ChatMessage( + session.Id, + ChatMessageRole.User, + "create card for the release follow-up"); + var clarification = new ChatMessage( + session.Id, + ChatMessageRole.Assistant, + "What should the card be called?", + "clarification"); + var answer = new ChatMessage(session.Id, ChatMessageRole.User, "Ship notes"); + var baseTime = DateTimeOffset.UtcNow.AddMinutes(-3); + SetCreatedAt(original, baseTime); + SetCreatedAt(clarification, baseTime.AddMinutes(1)); + SetCreatedAt(answer, baseTime.AddMinutes(2)); + + session.AddMessage(answer); + session.AddMessage(clarification); + session.AddMessage(original); + ChatCompletionRequest? capturedRequest = null; + _chatSessionRepoMock + .Setup(r => r.GetByIdWithMessagesAsync(session.Id, default)) + .ReturnsAsync(session); + _llmProviderMock + .Setup(p => p.StreamAsync(It.IsAny(), default)) + .Returns((ChatCompletionRequest request, CancellationToken _) => + { + capturedRequest = request; + return StreamEvents(); + }); + + await foreach (var _ in _service.StreamResponseAsync(session.Id, userId, default)) { } + + capturedRequest.Should().NotBeNull(); + capturedRequest!.Messages.Select(message => message.Content).Should().Equal( + "create card for the release follow-up", + "What should the card be called?", + "Ship notes"); + } + [Fact] public async Task GetProviderHealthAsync_ShouldSurfaceProviderStatus() { @@ -3393,6 +3437,9 @@ private static async IAsyncEnumerable StreamEvents() await Task.CompletedTask; } + private static void SetCreatedAt(Entity entity, DateTimeOffset timestamp) + => typeof(Entity).GetProperty(nameof(Entity.CreatedAt))!.SetValue(entity, timestamp); + private static async IAsyncEnumerable StreamEventsWithUsage() { yield return new LlmTokenEvent("hello", false); diff --git a/backend/tests/Taskdeck.Domain.Tests/Entities/ChatSessionStateMachineTests.cs b/backend/tests/Taskdeck.Domain.Tests/Entities/ChatSessionStateMachineTests.cs index ecca63d27d..c59a21f3fa 100644 --- a/backend/tests/Taskdeck.Domain.Tests/Entities/ChatSessionStateMachineTests.cs +++ b/backend/tests/Taskdeck.Domain.Tests/Entities/ChatSessionStateMachineTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Taskdeck.Domain.Common; using Taskdeck.Domain.Entities; using Taskdeck.Domain.Exceptions; using Xunit; @@ -291,6 +292,36 @@ public void Active_AddMultipleMessages_PreservesOrder() session.Messages[1].Content.Should().Be("Second"); } + [Fact] + public void Messages_SortsByCreatedAtThenId_WhenTrackedCollectionIsScrambled() + { + var session = CreateActiveSession(); + var baseTime = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var oldest = new ChatMessage(session.Id, ChatMessageRole.User, "Oldest", "text"); + var tieLaterId = new ChatMessage(session.Id, ChatMessageRole.Assistant, "Tie later", "text"); + var newest = new ChatMessage(session.Id, ChatMessageRole.User, "Newest", "text"); + var tieEarlierId = new ChatMessage(session.Id, ChatMessageRole.Assistant, "Tie earlier", "text"); + + SetId(oldest, Guid.Parse("00000000-0000-0000-0000-000000000004")); + SetId(tieLaterId, Guid.Parse("00000000-0000-0000-0000-000000000003")); + SetId(newest, Guid.Parse("00000000-0000-0000-0000-000000000001")); + SetId(tieEarlierId, Guid.Parse("00000000-0000-0000-0000-000000000002")); + SetCreatedAt(oldest, baseTime); + SetCreatedAt(tieLaterId, baseTime.AddMinutes(1)); + SetCreatedAt(newest, baseTime.AddMinutes(2)); + SetCreatedAt(tieEarlierId, baseTime.AddMinutes(1)); + + // Simulate a provider/ORM collection whose materialization order is unrelated to the + // transcript's causal order, including a same-timestamp tie. + session.AddMessage(newest); + session.AddMessage(tieLaterId); + session.AddMessage(oldest); + session.AddMessage(tieEarlierId); + + session.Messages.Select(message => message.Content).Should().Equal( + "Oldest", "Tie earlier", "Tie later", "Newest"); + } + [Fact] public void Archived_AddMessage_Throws() { @@ -356,5 +387,11 @@ public void UpdateTitle_WorksOnArchivedSession() session.Title.Should().Be("Archived title update"); } + private static void SetId(Entity entity, Guid id) + => typeof(Entity).GetProperty(nameof(Entity.Id))!.SetValue(entity, id); + + private static void SetCreatedAt(Entity entity, DateTimeOffset timestamp) + => typeof(Entity).GetProperty(nameof(Entity.CreatedAt))!.SetValue(entity, timestamp); + #endregion } 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/components/paper/PaperShortcutsOverlay.spec.ts b/frontend/taskdeck-web/src/tests/components/paper/PaperShortcutsOverlay.spec.ts index 1c244c3bf3..430f30d2f7 100644 --- a/frontend/taskdeck-web/src/tests/components/paper/PaperShortcutsOverlay.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/paper/PaperShortcutsOverlay.spec.ts @@ -8,8 +8,10 @@ import reviewKeymapSource from '../../../composables/useReviewKeymap.ts?raw' import boardViewSource from '../../../views/BoardView.vue?raw' import { APP_SHELL_SHORTCUT_BINDINGS, + bindingAppliesToSkin, formatShortcut, KEYBOARD_HELP_SHORTCUT, + PAPER_SHORTCUT_BINDINGS, PAPER_SHORTCUT_GROUPS, SHORTCUT_HANDLER_CONTRACTS, strokeMatches, @@ -197,6 +199,40 @@ describe('PaperShortcutsOverlay', () => { expect(displayedIds).not.toContain('workspace-review') }) + it('hides and restores every Paper review-keymap row with the automation flag', () => { + const featureFlags = useFeatureFlagStore() + const reviewKeymapIds = PAPER_SHORTCUT_BINDINGS + .filter((binding) => binding.handlerOwner === 'review-keymap') + .map((binding) => binding.id) + const nonReviewIds = PAPER_SHORTCUT_BINDINGS + .filter((binding) => binding.group !== undefined + && binding.handlerOwner !== 'review-keymap' + && bindingAppliesToSkin(binding, 'paper') + && binding.flag === undefined) + .map((binding) => binding.id) + + expect(reviewKeymapIds).toHaveLength(6) + expect(PAPER_SHORTCUT_BINDINGS + .filter((binding) => binding.handlerOwner === 'review-keymap') + .every((binding) => binding.flag === 'newAutomation')).toBe(true) + + featureFlags.flags.newAutomation = false + wrapper = mount(PaperShortcutsOverlay, { props: { visible: true }, attachTo: document.body }) + const disabledIds = Array.from( + teleportContent().querySelectorAll('[data-shortcut-id]'), + ).map((row) => row.dataset.shortcutId) + expect(disabledIds).not.toEqual(expect.arrayContaining(reviewKeymapIds)) + expect(disabledIds).toEqual(expect.arrayContaining(nonReviewIds)) + wrapper.unmount() + + featureFlags.flags.newAutomation = true + wrapper = mount(PaperShortcutsOverlay, { props: { visible: true }, attachTo: document.body }) + const enabledIds = Array.from( + teleportContent().querySelectorAll('[data-shortcut-id]'), + ).map((row) => row.dataset.shortcutId) + expect(enabledIds).toEqual(expect.arrayContaining(reviewKeymapIds)) + }) + it('does not advertise an undo shortcut that the product does not implement', () => { wrapper = mount(PaperShortcutsOverlay, { props: { visible: true }, attachTo: document.body }) const root = teleportContent().querySelector('[data-paper-shortcuts]') as HTMLElement 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/tests/views/paper/review/ReviewRevisionEditor.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/review/ReviewRevisionEditor.spec.ts index 3143a32e5c..2b74168868 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/review/ReviewRevisionEditor.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/review/ReviewRevisionEditor.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { mount } from '@vue/test-utils' +import { i18n } from '../../../../i18n' import ReviewRevisionEditor from '../../../../views/paper/review/ReviewRevisionEditor.vue' function mountEditor(operationsPayload: string, revisionChanged = false) { @@ -57,17 +58,23 @@ describe('ReviewRevisionEditor', () => { }) it('keeps provenance and diff inspection reachable from the editor actions', async () => { + const previousLocale = i18n.global.locale.value + i18n.global.locale.value = 'it' const wrapper = mountEditor('{"title":"Original"}') const provenance = wrapper.get('[data-testid="revision-inspect-provenance"]') const diff = wrapper.get('[data-testid="revision-inspect-diff"]') expect(provenance.element.tagName).toBe('BUTTON') expect(diff.element.tagName).toBe('BUTTON') + expect(provenance.text()).toBe('Mostra o nascondi il pannello di provenienza') + expect(diff.text()).toBe('Anteprima del diff nel dettaglio della scheda') await provenance.trigger('click') await diff.trigger('click') expect(wrapper.emitted('toggle-provenance')).toHaveLength(1) expect(wrapper.emitted('preview-diff')).toHaveLength(1) + wrapper.unmount() + i18n.global.locale.value = previousLocale }) it('announces a collaborator revision without replacing the typed draft (#2215 D3aB)', async () => { diff --git a/frontend/taskdeck-web/src/utils/keyboardShortcuts.ts b/frontend/taskdeck-web/src/utils/keyboardShortcuts.ts index 9e5132f8ea..49b7e124ae 100644 --- a/frontend/taskdeck-web/src/utils/keyboardShortcuts.ts +++ b/frontend/taskdeck-web/src/utils/keyboardShortcuts.ts @@ -341,6 +341,7 @@ export const PAPER_SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ handlerOwner: 'review-keymap', handlerEvidence: "case 'Enter':", skins: ['paper'], + flag: 'newAutomation', }, { id: 'review-reject', @@ -350,6 +351,7 @@ export const PAPER_SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ handlerOwner: 'review-keymap', handlerEvidence: "case 'Backspace':", skins: ['paper'], + flag: 'newAutomation', }, { id: 'review-request-edit', @@ -359,6 +361,7 @@ export const PAPER_SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ handlerOwner: 'review-keymap', handlerEvidence: "if (k === 'e')", skins: ['paper'], + flag: 'newAutomation', }, { id: 'review-defer', @@ -368,6 +371,7 @@ export const PAPER_SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ handlerOwner: 'review-keymap', handlerEvidence: "if (k === 'd')", skins: ['paper'], + flag: 'newAutomation', }, { id: 'review-provenance', @@ -378,6 +382,7 @@ export const PAPER_SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ handlerOwner: 'review-keymap', handlerEvidence: "if (k === 'p')", skins: ['paper'], + flag: 'newAutomation', }, { id: 'review-preview-diff', @@ -388,6 +393,7 @@ export const PAPER_SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ handlerOwner: 'review-keymap', handlerEvidence: "case ' ':", skins: ['paper'], + flag: 'newAutomation', }, { id: 'board-next-card', 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" />