Finalize assistant and dashboard health updates
This commit is contained in:
@@ -260,7 +260,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen && initialized && conversationId) {
|
||||
if (isOpen && initialized && conversationId && !loading) {
|
||||
const currentFirstConversationId = messages.length
|
||||
? messages[0].conversation_id
|
||||
: conversationId;
|
||||
@@ -276,7 +276,7 @@
|
||||
* @PRE: text is non-empty command text.
|
||||
* @POST: user message appears at the end of messages list.
|
||||
*/
|
||||
function appendLocalUserMessage(text) {
|
||||
function appendLocalUserMessage(text, targetConversationId = conversationId) {
|
||||
console.log("[AssistantChatPanel][message][appendLocalUserMessage][START]");
|
||||
messages = [
|
||||
...messages,
|
||||
@@ -285,6 +285,7 @@
|
||||
role: "user",
|
||||
text,
|
||||
created_at: new Date().toISOString(),
|
||||
conversation_id: targetConversationId || null,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -296,7 +297,10 @@
|
||||
* @PRE: response follows assistant message response contract.
|
||||
* @POST: assistant message appended with state/task/actions metadata.
|
||||
*/
|
||||
function appendAssistantResponse(response) {
|
||||
function appendAssistantResponse(
|
||||
response,
|
||||
targetConversationId = conversationId,
|
||||
) {
|
||||
// prettier-ignore
|
||||
console.log("[AssistantChatPanel][message][appendAssistantResponse][START]");
|
||||
messages = [
|
||||
@@ -310,6 +314,7 @@
|
||||
confirmation_id: response.confirmation_id || null,
|
||||
actions: response.actions || [],
|
||||
created_at: response.created_at,
|
||||
conversation_id: response.conversation_id || targetConversationId || null,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -349,14 +354,15 @@
|
||||
console.log("[AssistantChatPanel][message][handleSend][START]");
|
||||
const text = input.trim();
|
||||
if (!text || loading) return;
|
||||
const requestConversationId = conversationId;
|
||||
|
||||
appendLocalUserMessage(text);
|
||||
appendLocalUserMessage(text, requestConversationId);
|
||||
input = "";
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const response = await sendAssistantMessage({
|
||||
conversation_id: conversationId,
|
||||
conversation_id: requestConversationId,
|
||||
message: text,
|
||||
});
|
||||
|
||||
@@ -364,7 +370,10 @@
|
||||
setAssistantConversationId(response.conversation_id);
|
||||
}
|
||||
|
||||
appendAssistantResponse(response);
|
||||
appendAssistantResponse(
|
||||
response,
|
||||
response.conversation_id || requestConversationId,
|
||||
);
|
||||
await loadConversations(true);
|
||||
} catch (err) {
|
||||
appendAssistantResponse({
|
||||
@@ -414,6 +423,7 @@
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `conv-${Date.now()}`;
|
||||
historyLoadVersion += 1;
|
||||
setAssistantConversationId(newId);
|
||||
messages = [];
|
||||
historyPage = 1;
|
||||
|
||||
@@ -67,6 +67,15 @@ describe('AssistantChatPanel integration contract', () => {
|
||||
expect(source).toContain('goto("/reports")');
|
||||
});
|
||||
|
||||
it('guards first-message optimistic flow from premature history overwrite', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('if (isOpen && initialized && conversationId && !loading)');
|
||||
expect(source).toContain('function appendLocalUserMessage(text, targetConversationId = conversationId)');
|
||||
expect(source).toContain('conversation_id: targetConversationId || null');
|
||||
expect(source).toContain('historyLoadVersion += 1;');
|
||||
});
|
||||
|
||||
it('uses i18n bindings for assistant UI labels', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// [DEF:frontend.src.lib.components.assistant.__tests__.assistant_first_message_integration:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: assistant, integration-test, optimistic-message, conversation-race
|
||||
// @PURPOSE: Verify first optimistic user message stays visible while a new conversation request is pending.
|
||||
// @LAYER: UI Tests
|
||||
// @RELATION: VERIFIES -> frontend/src/lib/components/assistant/AssistantChatPanel.svelte
|
||||
// @INVARIANT: Starting a new conversation must not trigger history reload that overwrites the first local user message.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import AssistantChatPanel from '../AssistantChatPanel.svelte';
|
||||
import * as assistantApi from '$lib/api/assistant';
|
||||
|
||||
const { assistantState } = vi.hoisted(() => ({
|
||||
assistantState: (() => {
|
||||
let value = { isOpen: true, conversationId: null };
|
||||
const subscribers = new Set();
|
||||
return {
|
||||
subscribe(fn) {
|
||||
subscribers.add(fn);
|
||||
fn(value);
|
||||
return () => subscribers.delete(fn);
|
||||
},
|
||||
set(next) {
|
||||
value = next;
|
||||
subscribers.forEach((fn) => fn(value));
|
||||
},
|
||||
update(updater) {
|
||||
value = updater(value);
|
||||
subscribers.forEach((fn) => fn(value));
|
||||
},
|
||||
};
|
||||
})(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/assistant', () => ({
|
||||
getAssistantHistory: vi.fn(() =>
|
||||
Promise.resolve({ items: [], total: 0, has_next: false }),
|
||||
),
|
||||
getAssistantConversations: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
items: [],
|
||||
total: 0,
|
||||
has_next: false,
|
||||
active_total: 0,
|
||||
archived_total: 0,
|
||||
}),
|
||||
),
|
||||
deleteAssistantConversation: vi.fn(),
|
||||
confirmAssistantOperation: vi.fn(),
|
||||
cancelAssistantOperation: vi.fn(),
|
||||
sendAssistantMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api', () => ({
|
||||
api: {
|
||||
getLlmStatus: vi.fn(() => Promise.resolve({ configured: true, reason: 'ok' })),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/assistantChat', () => ({
|
||||
assistantChatStore: {
|
||||
subscribe: assistantState.subscribe,
|
||||
},
|
||||
closeAssistantChat: vi.fn(),
|
||||
setAssistantConversationId: vi.fn((conversationId) => {
|
||||
assistantState.update((state) => ({ ...state, conversationId }));
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/taskDrawer.js', () => ({
|
||||
openDrawerForTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$app/navigation', () => ({
|
||||
goto: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../services/gitService.js', () => ({
|
||||
gitService: {
|
||||
getDiff: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('$lib/i18n', () => ({
|
||||
t: {
|
||||
subscribe: (fn) => {
|
||||
fn({
|
||||
assistant: {
|
||||
title: 'Assistant',
|
||||
close: 'Close',
|
||||
conversations: 'Conversations',
|
||||
new: 'New',
|
||||
active: 'Active',
|
||||
archived: 'Archived',
|
||||
more: 'More',
|
||||
loading_history: 'Loading history',
|
||||
loading_older: 'Loading older',
|
||||
try_commands: 'Try commands',
|
||||
sample_command_branch: 'branch',
|
||||
sample_command_migration: 'migration',
|
||||
sample_command_status: 'status',
|
||||
you: 'You',
|
||||
assistant: 'Assistant',
|
||||
thinking: 'Thinking',
|
||||
input_placeholder: 'Type command',
|
||||
send: 'Send',
|
||||
conversation: 'Conversation',
|
||||
states: {
|
||||
success: 'Success',
|
||||
failed: 'Failed',
|
||||
started: 'Started',
|
||||
},
|
||||
open_task_drawer: 'Open task drawer',
|
||||
},
|
||||
dashboard: {},
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// [DEF:assistant_first_message_tests:Function]
|
||||
// @TIER: STANDARD
|
||||
// @PURPOSE: Guard optimistic first-message UX against history reload race in new conversation flow.
|
||||
// @PRE: Assistant panel renders with open state and mocked network dependencies.
|
||||
// @POST: First user message remains visible before pending send request resolves.
|
||||
describe('AssistantChatPanel first message flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
assistantState.set({ isOpen: true, conversationId: null });
|
||||
});
|
||||
|
||||
it('keeps the first optimistic user message visible during pending send in a new conversation', async () => {
|
||||
let resolveSend;
|
||||
assistantApi.sendAssistantMessage.mockImplementation(
|
||||
(payload) =>
|
||||
new Promise((resolve) => {
|
||||
resolveSend = () =>
|
||||
resolve({
|
||||
response_id: 'resp-1',
|
||||
conversation_id: payload.conversation_id,
|
||||
text: 'Started',
|
||||
state: 'success',
|
||||
created_at: new Date().toISOString(),
|
||||
actions: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(AssistantChatPanel);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(assistantApi.getAssistantHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByText('New'));
|
||||
|
||||
const input = screen.getByPlaceholderText('Type command');
|
||||
await fireEvent.input(input, { target: { value: 'first prompt' } });
|
||||
await fireEvent.click(screen.getByText('Send'));
|
||||
|
||||
expect(screen.getByText('first prompt')).toBeTruthy();
|
||||
expect(assistantApi.sendAssistantMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(assistantApi.getAssistantHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
resolveSend();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Started')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
// [/DEF:assistant_first_message_tests:Function]
|
||||
// [/DEF:frontend.src.lib.components.assistant.__tests__.assistant_first_message_integration:Module]
|
||||
@@ -79,7 +79,7 @@
|
||||
"environment_label": "Environment:",
|
||||
"all_environments": "All Environments",
|
||||
"validation_details": "Validation Details",
|
||||
"table_dashboard": "Dashboard",
|
||||
"table_dashboard": "Slug",
|
||||
"table_environment": "Environment",
|
||||
"table_status": "Status",
|
||||
"table_last_check": "Last Check",
|
||||
@@ -87,6 +87,9 @@
|
||||
"no_records": "No validation records found.",
|
||||
"no_records_for_environment": "No validation records found for this environment.",
|
||||
"view_report": "View Report",
|
||||
"delete_confirm": "Delete report for dashboard \"{slug}\"?",
|
||||
"delete_success": "Report for dashboard \"{slug}\" deleted.",
|
||||
"delete_failed": "Failed to delete report: {error}",
|
||||
"load_failed": "Failed to load health summary",
|
||||
"status_pass": "Pass",
|
||||
"status_warn": "Warn",
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"environment_label": "Окружение:",
|
||||
"all_environments": "Все окружения",
|
||||
"validation_details": "Детали валидации",
|
||||
"table_dashboard": "Дашборд",
|
||||
"table_dashboard": "Slug",
|
||||
"table_environment": "Окружение",
|
||||
"table_status": "Статус",
|
||||
"table_last_check": "Последняя проверка",
|
||||
@@ -87,6 +87,9 @@
|
||||
"no_records": "Записи валидации не найдены.",
|
||||
"no_records_for_environment": "Для этого окружения записи валидации не найдены.",
|
||||
"view_report": "Открыть отчет",
|
||||
"delete_confirm": "Удалить отчет для дашборда \"{slug}\"?",
|
||||
"delete_success": "Отчет для дашборда \"{slug}\" удалён.",
|
||||
"delete_failed": "Не удалось удалить отчет: {error}",
|
||||
"load_failed": "Не удалось загрузить сводку здоровья",
|
||||
"status_pass": "Успех",
|
||||
"status_warn": "Предупреждение",
|
||||
|
||||
Reference in New Issue
Block a user