Finalize assistant and dashboard health updates

This commit is contained in:
2026-03-15 13:19:46 +03:00
parent a8563a8369
commit 6b66f2fb49
11 changed files with 7662 additions and 5275 deletions

View File

@@ -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;

View File

@@ -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');

View File

@@ -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]

View File

@@ -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",

View File

@@ -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": "Предупреждение",