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": "Предупреждение",
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
@TIER: STANDARD
|
||||
@PURPOSE: Main page for the Dashboard Health Center.
|
||||
@LAYER: UI/Page
|
||||
@RELATION: DEPENDS_ON -> frontend.src.lib.components.health.HealthMatrix
|
||||
@RELATION: CALLS -> frontend.src.lib.api.api_module
|
||||
@UX_STATE: Idle -> Displays the Health Center with HealthMatrix.
|
||||
@UX_STATE: Loading -> Displays a skeleton or spinner.
|
||||
@UX_STATE: Error -> Displays an error message.
|
||||
@UX_REATIVITY: State: $state, Derived: $derived.
|
||||
@UX_REACTIVITY: State: $state, Derived: $derived.
|
||||
-->
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import HealthMatrix from '$lib/components/health/HealthMatrix.svelte';
|
||||
import { getHealthSummary, getEnvironments } from '$lib/api.js';
|
||||
import { getHealthSummary, getEnvironments, requestApi } from '$lib/api.js';
|
||||
import { t } from '$lib/i18n';
|
||||
import { addToast } from '$lib/toasts.js';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
let healthData = $state({
|
||||
@@ -26,7 +29,13 @@
|
||||
let selectedEnvId = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state(null);
|
||||
let deletingReportIds = $state(new Set());
|
||||
|
||||
// [DEF:loadData:Function]
|
||||
// @PURPOSE: Load health summary rows and environment options for the current filter.
|
||||
// @PRE: Page is mounted or environment selection changed.
|
||||
// @POST: `healthData` and `environments` reflect latest backend response.
|
||||
// @SIDE_EFFECT: Calls backend API endpoints for health summary and environments.
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
@@ -46,15 +55,49 @@
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:loadData:Function]
|
||||
|
||||
onMount(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
// [DEF:handleEnvChange:Function]
|
||||
// @PURPOSE: Apply environment filter and trigger health summary reload.
|
||||
// @PRE: DOM change event carries target value.
|
||||
// @POST: selectedEnvId is updated and new data load starts.
|
||||
function handleEnvChange(e) {
|
||||
selectedEnvId = e.target.value;
|
||||
loadData();
|
||||
}
|
||||
// [/DEF:handleEnvChange:Function]
|
||||
|
||||
// [DEF:handleDeleteReport:Function]
|
||||
// @PURPOSE: Delete one health report row with confirmation and optimistic button lock.
|
||||
// @PRE: item contains `record_id` from health summary payload.
|
||||
// @POST: Row is removed from backend and page data is reloaded on success.
|
||||
// @SIDE_EFFECT: Calls DELETE health API and emits toast notifications.
|
||||
async function handleDeleteReport(item) {
|
||||
if (!item?.record_id || deletingReportIds.has(item.record_id)) return;
|
||||
if (!confirm($t.health?.delete_confirm.replace('{slug}', item.dashboard_slug || item.dashboard_id))) return;
|
||||
|
||||
deletingReportIds.add(item.record_id);
|
||||
deletingReportIds = new Set(deletingReportIds);
|
||||
|
||||
try {
|
||||
await requestApi(`/health/summary/${item.record_id}`, 'DELETE');
|
||||
addToast($t.health?.delete_success.replace('{slug}', item.dashboard_slug || item.dashboard_id), 'success');
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
addToast(
|
||||
$t.health?.delete_failed.replace('{error}', e?.message || $t.common?.error),
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
deletingReportIds.delete(item.record_id);
|
||||
deletingReportIds = new Set(deletingReportIds);
|
||||
}
|
||||
}
|
||||
// [/DEF:handleDeleteReport:Function]
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-6">
|
||||
@@ -136,8 +179,13 @@
|
||||
{:else}
|
||||
{#each healthData.items as item}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{item.dashboard_title || item.dashboard_id}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<a
|
||||
href={`/dashboards/${encodeURIComponent(String(item.dashboard_slug || item.dashboard_id))}?env_id=${encodeURIComponent(item.environment_id)}`}
|
||||
class="text-indigo-600 hover:text-indigo-900 hover:underline"
|
||||
>
|
||||
{item.dashboard_slug || item.dashboard_id}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span class="px-2 py-1 bg-gray-100 rounded text-xs font-mono">{item.environment_id}</span>
|
||||
@@ -161,6 +209,14 @@
|
||||
{#if item.task_id}
|
||||
<a href="/reports/llm/{item.task_id}" class="text-indigo-600 hover:text-indigo-900">{$t.health?.view_report}</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-3 text-red-600 hover:text-red-900"
|
||||
onclick={() => handleDeleteReport(item)}
|
||||
disabled={deletingReportIds.has(item.record_id)}
|
||||
>
|
||||
{deletingReportIds.has(item.record_id) ? '...' : $t.common?.delete}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// [DEF:frontend.src.routes.dashboards.health.__tests__.health_page_integration:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: health-page, integration-test, slug-link, delete-flow
|
||||
// @PURPOSE: Lock dashboard health page contract for slug navigation and report deletion.
|
||||
// @LAYER: UI Tests
|
||||
// @RELATION: VERIFIES -> frontend/src/routes/dashboards/health/+page.svelte
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const PAGE_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/routes/dashboards/health/+page.svelte',
|
||||
);
|
||||
|
||||
describe('Dashboard health page contract', () => {
|
||||
it('renders slug-first dashboard link bound to environment route context', () => {
|
||||
const source = fs.readFileSync(PAGE_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain("{$t.health?.table_dashboard}");
|
||||
expect(source).toContain("item.dashboard_slug || item.dashboard_id");
|
||||
expect(source).toContain("href={`/dashboards/${encodeURIComponent(String(item.dashboard_slug || item.dashboard_id))}?env_id=${encodeURIComponent(item.environment_id)}`}");
|
||||
});
|
||||
|
||||
it('keeps explicit delete report flow with confirm and DELETE request', () => {
|
||||
const source = fs.readFileSync(PAGE_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('async function handleDeleteReport(item)');
|
||||
expect(source).toContain("requestApi(`/health/summary/${item.record_id}`, 'DELETE')");
|
||||
expect(source).toContain("$t.health?.delete_confirm.replace('{slug}', item.dashboard_slug || item.dashboard_id)");
|
||||
expect(source).toContain("$t.common?.delete");
|
||||
});
|
||||
});
|
||||
// [/DEF:frontend.src.routes.dashboards.health.__tests__.health_page_integration:Module]
|
||||
Reference in New Issue
Block a user