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
5 changes: 5 additions & 0 deletions .github/workflows/pull-request-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ jobs:
HELPDESK_E2E_AUTH_MODE: development
run: tools/ci/run-ux-local.sh

- name: Run AI Assistant Playwright validation
env:
HELPDESK_AI_ASSISTANT_CONFIGURATION: Release
run: npm run test:ux:ai-assistant

- name: Upload Playwright reports and failure artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
7 changes: 6 additions & 1 deletion playwright.ai-assistant.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { defineConfig } from '@playwright/test';
import base from './playwright.config';

const newWebCommand = process.env.HELPDESK_AI_ASSISTANT_CONFIGURATION === 'Release'
? 'dotnet bin/Release/net10.0/HelpDesk.NewWeb.dll'
: 'dotnet run --no-build --no-launch-profile';

export default defineConfig(base, {
testMatch: '**/ai-assistant-chat.spec.ts',
testIgnore: [],
Expand All @@ -9,7 +13,8 @@ export default defineConfig(base, {
webServer: [
{ command: 'node tests/ux/ai-assistant-fixture-api.mjs', url: 'http://127.0.0.1:18299/fixture/health', reuseExistingServer: !process.env.CI },
{
command: 'dotnet run --project src/HelpDesk.NewWeb --no-build --no-launch-profile',
command: newWebCommand,
cwd: 'src/HelpDesk.NewWeb',
url: 'https://127.0.0.1:5158/health', ignoreHTTPSErrors: true, timeout: 120_000,
reuseExistingServer: !process.env.CI,
env: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
<button type="button" class="activity-heading" aria-expanded="@expanded.ToString().ToLowerInvariant()" @onclick="Toggle">
<MudIcon Icon="@(expanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)" />
<span>AiAssistant activity · @Turn.Summary</span>
@if (HasPendingApproval)
{
<span class="approval-heading-status">
<MudIcon Icon="@Icons.Material.Filled.WarningAmber" Color="Color.Warning" Size="Size.Small" />
<span>Approval required</span>
</span>
}
@if (!Turn.Completed && State == ChatState.Processing)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
Expand All @@ -26,14 +33,31 @@
}
@foreach (var item in Turn.Activity)
{
<div class="activity-interaction">
var isPendingApproval = IsPendingApproval(item);
<div class="@(isPendingApproval ? "activity-interaction approval-callout" : "activity-interaction")"
data-testid="@(isPendingApproval ? "ai-assistant-approval" : null)">
@if (isPendingApproval)
{
<div class="approval-status" role="status" aria-live="polite">
<MudIcon Icon="@Icons.Material.Filled.WarningAmber" Color="Color.Warning" />
<MudText Typo="Typo.subtitle2">Approval required</MudText>
</div>
}
<MudText Style="white-space: pre-wrap; overflow-wrap: anywhere">@item.Text</MudText>
@if (item.Type == "approval_request" && State == ChatState.AwaitingApproval && !Turn.Activity.Any(x => x.Type == "approval_response" && x.CallId == item.CallId))
@if (isPendingApproval)
{
@foreach (var option in JsonSerializer.Deserialize<List<ChatOption>>(item.OptionsJson ?? "[]") ?? [])
{
<MudButton Disabled="Busy" OnClick="@(() => Approval.InvokeAsync((item.CallId!, option.Key)))">@option.Label</MudButton>
}
<div class="approval-actions">
@foreach (var option in JsonSerializer.Deserialize<List<ChatOption>>(item.OptionsJson ?? "[]") ?? [])
{
<MudButton Class="approval-action"
Color="@ApprovalColor(option.Key)"
Variant="@ApprovalVariant(option.Key)"
StartIcon="@ApprovalIcon(option.Key)"
Disabled="Busy"
data-approval-key="@option.Key"
OnClick="@(() => Approval.InvokeAsync((item.CallId!, option.Key)))">@option.Label</MudButton>
}
</div>
}
</div>
}
Expand All @@ -53,6 +77,15 @@
completed = Turn.Completed; initialized = true;
}
private void Toggle() => expanded = !expanded;
private bool HasPendingApproval => Turn.Activity.Any(IsPendingApproval);
private bool IsPendingApproval(AiAssistantChatEvent item) => item.Type == "approval_request"
&& State == ChatState.AwaitingApproval
&& !Turn.Activity.Any(x => x.Type == "approval_response" && x.CallId == item.CallId);
private static Color ApprovalColor(string key) => key == "deny" ? Color.Error : Color.Primary;
private static Variant ApprovalVariant(string key) => key == "deny" ? Variant.Outlined : Variant.Filled;
private static string ApprovalIcon(string key) => key == "deny"
? Icons.Material.Filled.Block
: Icons.Material.Filled.CheckCircle;
private static string Icon(ChatActivityMetadata? metadata) => metadata?.Outcome == "failed" ? Icons.Material.Filled.ErrorOutline
: metadata?.ToolKind switch
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
.ai-assistant-activity { margin-inline-start: 1rem; border-inline-start: 2px solid var(--mud-palette-divider); padding: .5rem; min-width: 0; }
.activity-heading { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem; text-align: start; width: 100%; background: transparent; color: inherit; border: 0; cursor: pointer; padding: .5rem; }
.activity-heading:focus-visible { outline: 2px solid var(--mud-palette-primary); }
.approval-heading-status { display: inline-flex; align-items: center; gap: .25rem; color: var(--mud-palette-warning); font-size: .875rem; font-weight: 600; }
.activity-row { display: flex; align-items: center; gap: .5rem; padding: .5rem; flex-wrap: wrap; font-size: .875rem; }
.activity-name { flex: 1 1 12rem; min-width: 0; overflow-wrap: anywhere; }
.activity-interaction { padding: .5rem; }
@media (max-width: 600px) { .ai-assistant-activity { margin-inline-start: .25rem; } }
.approval-callout { display: grid; gap: .75rem; margin-block: .5rem; border: 1px solid color-mix(in srgb, var(--mud-palette-warning) 70%, var(--mud-palette-divider)); border-inline-start-width: 4px; border-radius: var(--mud-default-borderradius); background: color-mix(in srgb, var(--mud-palette-warning) 12%, var(--mud-palette-surface)); }
.approval-status { display: flex; align-items: center; gap: .5rem; color: var(--mud-palette-warning); }
.approval-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
.approval-actions ::deep .approval-action { min-height: 2.75rem; }
@media (max-width: 600px) {
.ai-assistant-activity { margin-inline-start: .25rem; }
.approval-actions { display: grid; grid-template-columns: minmax(0, 1fr); }
.approval-actions ::deep .approval-action { width: 100%; }
}
83 changes: 69 additions & 14 deletions tests/ux/ai-assistant-chat.spec.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
import { expect, test } from '@playwright/test';
import { assertNoHorizontalOverflow, selectTheme } from './auth';
import { assertNoHorizontalOverflow } from './auth';

// Requires the loopback fixture API and real feature NewWeb host. No live ticket mutations.
test.beforeEach(async ({ page, request }) => {
await request.get('http://127.0.0.1:18299/fixture/reset?mode=completed');
await page.goto('/auth/development', { waitUntil: 'networkidle' });
await page.goto('/auth/development', { waitUntil: 'domcontentloaded' });
});

async function openChat(page) {
await page.goto('/incidents/ux-chat', { waitUntil: 'networkidle' });
await page.getByRole('tab', { name: /AI Assistant/ }).click();
await expect(page.getByTestId('ai-assistant-composer')).toBeVisible();
async function openChat(page, theme: 'Light' | 'Dark' = 'Light') {
await page.addInitScript((selectedTheme) => {
localStorage.setItem('ticket-tabs-index:incidents:ux-chat', '4');
localStorage.setItem('helpdesk.theme.preference', selectedTheme.toLowerCase());
}, theme);
await page.goto('/incidents/ux-chat', { waitUntil: 'domcontentloaded' });
const composer = page.getByTestId('ai-assistant-composer');
await expect(page.getByRole('tab', { name: /AI Assistant/ })).toHaveAttribute('aria-selected', 'true');
await expect(composer).toBeVisible();
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.helpdeskTheme)).toBe(theme.toLowerCase());
}

for (const theme of ['Light', 'Dark'] as const) {
for (const mobile of [false, true]) {
test(`${theme} ${mobile ? 'mobile' : 'desktop'} completed, expand and composer boundary`, async ({ page }, testInfo) => {
await openChat(page);
await selectTheme(page, theme);
await openChat(page, theme);
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
const heading = page.getByTestId('ai-assistant-activity').getByRole('button');
await expect(heading).toHaveAttribute('aria-expanded', 'false');
Expand Down Expand Up @@ -58,7 +63,40 @@ for (const mode of ['empty', 'processing', 'approval', 'failed', 'archived']) {
await expect(page.getByRole('button', { name: 'Stop waiting' })).toBeVisible();
await expect(page.getByText('will not resend this request automatically', { exact: false })).toBeVisible();
}
if (mode === 'approval') await expect(page.getByRole('button', { name: 'Allow once' })).toBeVisible();
if (mode === 'approval') {
const activity = page.getByTestId('ai-assistant-activity');
const approval = page.getByTestId('ai-assistant-approval');
const allow = approval.getByRole('button', { name: 'Allow once' });
const deny = approval.getByRole('button', { name: 'Deny', exact: true });
await expect(activity.getByRole('button').first()).toContainText('Approval required');
await expect(approval.getByRole('status')).toHaveText('Approval required');
await expect(allow).toBeEnabled();
await expect(deny).toBeEnabled();
await expect(allow).toHaveClass(/mud-button-filled/);
await expect(deny).toHaveClass(/mud-button-outlined/);
await expect(allow).toHaveAttribute('data-approval-key', 'approve_once');
await expect(deny).toHaveAttribute('data-approval-key', 'deny');
const approvalStyles = await approval.evaluate(element => {
const callout = getComputedStyle(element);
const allow = element.querySelector('[data-approval-key="approve_once"]')!;
const deny = element.querySelector('[data-approval-key="deny"]')!;
const allowStyle = getComputedStyle(allow);
const denyStyle = getComputedStyle(deny);
return {
background: callout.backgroundColor,
borderLeftWidth: callout.borderLeftWidth,
allowBackground: allowStyle.backgroundColor,
denyBorderWidth: denyStyle.borderTopWidth,
denyColor: denyStyle.color
};
});
expect(approvalStyles.borderLeftWidth).toBe('4px');
expect(approvalStyles.background).not.toBe('rgba(0, 0, 0, 0)');
expect(approvalStyles.allowBackground).not.toBe('rgba(0, 0, 0, 0)');
expect(approvalStyles.denyBorderWidth).not.toBe('0px');
expect(approvalStyles.denyColor).not.toBe(approvalStyles.allowBackground);
await testInfo.attach('approval-presentation-styles', { body: JSON.stringify(approvalStyles), contentType: 'application/json' });
}
if (mode === 'archived') {
await expect(page.getByText('Archived transcript', { exact: false })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Message AiAssistant' })).toBeDisabled();
Expand All @@ -68,6 +106,22 @@ for (const mode of ['empty', 'processing', 'approval', 'failed', 'archived']) {
});
}

test('dark desktop approval callout remains visually distinct', async ({ page, request }, testInfo) => {
await request.get('http://127.0.0.1:18299/fixture/reset?mode=approval');
await openChat(page, 'Dark');
await page.setViewportSize({ width: 1440, height: 1000 });
const approval = page.getByTestId('ai-assistant-approval');
await expect(approval.getByRole('status')).toHaveText('Approval required');
const styles = await approval.evaluate(element => {
const style = getComputedStyle(element);
return { background: style.backgroundColor, borderLeftWidth: style.borderLeftWidth };
});
expect(styles.borderLeftWidth).toBe('4px');
expect(styles.background).not.toBe('rgba(0, 0, 0, 0)');
await assertNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('dark-desktop-approval.png'), fullPage: true });
});

test('stop waiting shows authoritative uncertain-delivery recovery controls', async ({ page, request }) => {
await request.get('http://127.0.0.1:18299/fixture/reset?mode=processing');
await openChat(page);
Expand Down Expand Up @@ -123,15 +177,16 @@ test('live draft replacement, automatic completion collapse and duplicate replay

test('mobile dark approval controls and bounded growing composer', async ({ page, request }, testInfo) => {
await request.get('http://127.0.0.1:18299/fixture/reset?mode=approval');
await openChat(page);
await selectTheme(page, 'Dark');
await openChat(page, 'Dark');
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole('button', { name: 'Allow once' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Deny', exact: true })).toBeVisible();
const approval = page.getByTestId('ai-assistant-approval');
await expect(approval.getByRole('status')).toHaveText('Approval required');
await expect(approval.getByRole('button', { name: 'Allow once' })).toBeVisible();
await expect(approval.getByRole('button', { name: 'Deny', exact: true })).toBeVisible();
await assertNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('mobile-dark-approval.png'), fullPage: true });
await request.get('http://127.0.0.1:18299/fixture/reset?mode=empty');
await openChat(page);
await openChat(page, 'Dark');
const input = page.getByRole('textbox', { name: 'Message AiAssistant' });
await input.fill(Array.from({ length: 30 }, (_, i) => `Line ${i}`).join('\n'));
await expect.poll(() => input.evaluate(e => e.scrollHeight > e.clientHeight)).toBe(true);
Expand Down
7 changes: 7 additions & 0 deletions tests/ux/ai-assistant-fixture-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,20 @@ http.createServer(async (req, res) => {
const encode = value => Buffer.from(JSON.stringify(value)).toString('base64url');
return reply({ token: `${encode({ alg: 'none' })}.${encode({ sub: actor, auth_mode: 'development', preferred_username: 'UX Test Operator', roles: ['HelpdeskAdmin', 'Incident.User'], exp: Math.floor(Date.now()/1000)+3600 })}.fixture` });
}
if (url.pathname === '/api/v1/auth/me') return reply({
isAuthenticated: true, name: 'UX Test Operator', email: 'UX Test Operator', userId: actor,
primaryOrganizationId: null, primaryOrganizationName: null, customerId: null,
isHelpdeskAdmin: true, roleBundles: ['HelpdeskAdmin', 'Incident.User'], permissions: ['HelpdeskAdmin', 'Incident.User'],
allowedOrganizationIds: [], managedOrganizationIds: [], scopedPermissionGrants: []
});
if (url.pathname === '/api/v1/branding') return reply({ applicationName: 'RatelDesk', faviconUrl: '/favicon.ico' });
if (url.pathname.endsWith('/chat/stream')) {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
for (const item of events.filter(x => x.Sequence > Number(url.searchParams.get('cursor') ?? 0))) res.write(`id: ${item.Sequence}\nevent: chat\ndata: ${JSON.stringify(item)}\n\n`);
res.write(`event: state\ndata: ${state}\n\n`);
streams.add(res); req.on('close', () => streams.delete(res)); return;
}
if (url.pathname.endsWith('/chat-capabilities')) return reply({ enabled: true, reason: null });
if (url.pathname.endsWith('/chat/history')) return reply([{ conversationId: conversation, state, lastActivityUtc: new Date().toISOString(), createdByUserId: actor }]);
if (url.pathname.endsWith('/chat/stop-waiting')) {
state = 3; event('delivery_unknown', 'Operator stopped waiting locally.');
Expand Down
6 changes: 4 additions & 2 deletions tests/ux/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ export async function clearThemeOverride(page: Page): Promise<void> {
}

export async function assertNoHorizontalOverflow(page: Page): Promise<void> {
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow, 'page should not require horizontal scrolling').toBeLessThanOrEqual(2);
await expect.poll(
() => page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth),
{ message: 'page should not require horizontal scrolling' }
).toBeLessThanOrEqual(2);
}

export async function selectTheme(page: Page, theme: 'System' | 'Light' | 'Dark'): Promise<void> {
Expand Down
Loading