diff --git a/core/http/react-ui/e2e/chat-history.spec.js b/core/http/react-ui/e2e/chat-history.spec.js index 05f024ed5283..568cd177c086 100644 --- a/core/http/react-ui/e2e/chat-history.spec.js +++ b/core/http/react-ui/e2e/chat-history.spec.js @@ -18,13 +18,15 @@ async function openMenu(page) { } test.describe('Chat conversations menu', () => { + const now = new Date('2026-10-09T12:00:00Z').getTime() test.beforeEach(async ({ page }) => { + await page.clock.setFixedTime(now) await mockChat(page, { models: [ { id: 'qwen3-8b-instruct', capabilities: ['FLAG_CHAT'] }, { id: 'gemma-4-e4b-it-qat-q4_0', capabilities: ['FLAG_CHAT'] }, { id: 'qwen2.5-vl-7b-instruct', capabilities: ['FLAG_CHAT'] }, ], loaded: ['qwen3-8b-instruct'] }) - await openChat(page, sampleChats()) + await openChat(page, sampleChats(now)) await expect(page.getByTestId('chat-message').first()).toBeVisible() }) @@ -153,7 +155,7 @@ test.describe('Chat conversations menu', () => { }) test('the undo time runs out by itself after six seconds', async ({ page }) => { - await page.clock.install() + await page.clock.install({ time: now }) await page.reload() await page.getByTestId('chat-composer').waitFor() await openMenu(page) diff --git a/core/http/react-ui/e2e/group-chat.spec.js b/core/http/react-ui/e2e/group-chat.spec.js new file mode 100644 index 000000000000..57c3b76eac74 --- /dev/null +++ b/core/http/react-ui/e2e/group-chat.spec.js @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MIT +import { test, expect } from './coverage-fixtures.js' + +async function setup(page, models = ['alpha', 'beta']) { + await page.route('**/api/**', route => route.fulfill({ json: {} })) + await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: false } })) + await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: models.map(id => ({ id, capabilities: ['FLAG_CHAT'] })) } })) + await page.goto('/app/group-chat') +} +async function participants(page) { + await page.getByLabel('Model to add').selectOption('alpha') + await page.getByRole('button', { name: 'Add participant', exact: true }).click() + await page.getByLabel('Model to add').selectOption('beta') + await page.getByRole('button', { name: 'Add participant', exact: true }).click() +} +function sse(content = 'Reply', extra = '') { + return `data: ${JSON.stringify({ choices: [{ delta: { reasoning_content: 'private reasoning', content } }] })}\n\n${extra}data: [DONE]\n\n` +} + +test('manual turns share attributed history and lock setup', async ({ page }) => { + const requests = [] + await page.route('**/v1/chat/completions', async route => { + requests.push(route.request().postDataJSON()) + await route.fulfill({ contentType: 'text/event-stream', body: sse(`Reply ${requests.length}`) }) + }) + await setup(page) + await participants(page) + await page.getByLabel('Name for alpha').fill('Planner') + await page.getByLabel('Objective').fill('Design a garden') + await page.getByLabel('Moderator message').fill('Use native plants') + await page.getByRole('button', { name: 'Add message', exact: true }).click() + await page.getByRole('button', { name: 'Give Planner a turn', exact: true }).click() + await expect(page.getByText('Reply 1', { exact: true })).toBeVisible() + await expect(page.getByLabel('Objective')).toBeDisabled() + await page.getByRole('button', { name: 'Give beta a turn', exact: true }).click() + await expect(page.getByText('Reply 2', { exact: true })).toBeVisible() + expect(requests.map(r => r.model)).toEqual(['alpha', 'beta']) + expect(requests[0].messages[0].content).toContain('Planner') + expect(JSON.stringify(requests[1].messages)).toContain('Planner (alpha)') + expect(JSON.stringify(requests[1].messages)).toContain('Reply 1') + expect(JSON.stringify(requests[1].messages)).not.toContain('private reasoning') + expect(requests[1].messages.at(-1).role).toBe('user') + expect(requests[1].messages.filter(m => m.role === 'assistant')).toEqual([]) + await page.getByRole('button', { name: 'New conversation', exact: true }).click() + await expect(page.getByText('Reply 1', { exact: true })).toHaveCount(0) + await expect(page.getByLabel('Objective')).toBeEnabled() +}) + +test('bounded rounds run in order and reject invalid names', async ({ page }) => { + const requests = [] + await page.route('**/v1/chat/completions', route => { + requests.push(route.request().postDataJSON().model) + return route.fulfill({ contentType: 'text/event-stream', body: sse() }) + }) + await setup(page) + await participants(page) + await page.getByLabel('Name for beta').fill('alpha') + await expect(page.getByRole('button', { name: 'Run one round', exact: true })).toBeDisabled() + await page.getByLabel('Name for beta').fill(' ') + await expect(page.getByRole('button', { name: 'Run one round', exact: true })).toBeDisabled() + await page.getByLabel('Name for beta').fill('beta') + await page.getByLabel('Number of rounds').fill('11') + await expect(page.getByRole('button', { name: 'Run rounds', exact: true })).toBeDisabled() + await page.getByLabel('Number of rounds').fill('2') + await page.getByRole('button', { name: 'Run rounds', exact: true }).click() + await expect(page.getByRole('button', { name: 'Run rounds', exact: true })).toBeEnabled() + expect(requests).toEqual(['alpha', 'beta', 'alpha', 'beta']) + await page.getByRole('button', { name: 'Run one round', exact: true }).click() + await expect(page.getByRole('button', { name: 'Run one round', exact: true })).toBeEnabled() + expect(requests).toEqual(['alpha', 'beta', 'alpha', 'beta', 'alpha', 'beta']) +}) + +test('stop cancels the run and prevents overlapping turns', async ({ page }) => { + let count = 0 + let release + await page.route('**/v1/chat/completions', async route => { + count++ + await new Promise(resolve => { release = resolve }) + await route.fulfill({ contentType: 'text/event-stream', body: sse('late reply') }).catch(() => {}) + }) + await setup(page) + await participants(page) + await page.getByRole('button', { name: 'Run one round', exact: true }).click() + await expect(page.getByRole('button', { name: 'Give beta a turn', exact: true })).toBeDisabled() + await expect.poll(() => count).toBe(1) + await page.getByRole('button', { name: 'Stop', exact: true }).click() + release() + await expect(page.getByText('Incomplete', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: 'Run one round', exact: true })).toBeEnabled() + expect(count).toBe(1) + await expect(page.getByText('late reply', { exact: true })).toHaveCount(0) +}) + +for (const [name, response, message] of [ + ['HTTP failure', { status: 500, json: { error: { message: 'Backend failed' } } }, 'Backend failed'], + ['SSE failure', { contentType: 'text/event-stream', body: 'data: {"error":{"message":"Stream failed"}}\n\n' }, 'Stream failed'], + ['empty response', { contentType: 'text/event-stream', body: 'data: [DONE]\n\n' }, 'The model returned no text.'], +]) { + test(`${name} stops remaining turns`, async ({ page }) => { + let count = 0 + await page.route('**/v1/chat/completions', route => { count++; return route.fulfill(response) }) + await setup(page) + await participants(page) + await page.getByRole('button', { name: 'Run one round', exact: true }).click() + await expect(page.getByRole('alert')).toContainText(message) + expect(count).toBe(1) + }) +} + +test('empty model discovery and chat permission', async ({ page }) => { + await setup(page, []) + await expect(page.getByText('No installed chat models available.', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: 'Add participant', exact: true })).toBeDisabled() + await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: true, user: { role: 'user', permissions: {} } } })) + await page.reload() + await expect(page).toHaveURL(/\/app\/?$/) +}) + +async function stagedStream(page) { + await page.addInitScript(() => { + const original = window.fetch + window.groupRequests = [] + window.groupAborted = false + window.fetch = async (input, options) => { + if (!String(input).endsWith('/v1/chat/completions')) return original(input, options) + window.groupRequests.push(JSON.parse(options.body)) + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"unfinished text"}}]}\n\n')) + options.signal.addEventListener('abort', () => { + window.groupAborted = true + controller.error(new DOMException('Aborted', 'AbortError')) + }) + }, + }), { headers: { 'Content-Type': 'text/event-stream' } }) + } + }) +} + +test('partial abort is excluded from later prompts and leaving cancels a stream', async ({ page }) => { + await stagedStream(page) + await setup(page) + await participants(page) + await page.getByRole('button', { name: 'Run one round', exact: true }).click() + await expect(page.getByText('unfinished text', { exact: true })).toBeVisible() + await page.getByRole('button', { name: 'Stop', exact: true }).click() + await expect(page.getByText('Incomplete', { exact: true })).toBeVisible() + await page.getByRole('button', { name: 'Give beta a turn', exact: true }).click() + const requests = await page.evaluate(() => window.groupRequests) + expect(requests).toHaveLength(2) + expect(JSON.stringify(requests[1])).not.toContain('unfinished text') + await page.evaluate(() => { window.groupAborted = false }) + await page.getByRole('link', { name: 'Back to Chat', exact: true }).click() + await expect.poll(() => page.evaluate(() => window.groupAborted)).toBe(true) + await page.getByRole('link', { name: 'Group chat', exact: true }).click() + await expect(page.getByText('unfinished text', { exact: true })).toHaveCount(0) +}) + +for (const [name, body] of [ + ['truncated stream', 'data: {"choices":[{"delta":{"content":"partial"}}]}\n\n'], + ['malformed stream', 'data: not-json\n\n'], +]) { + test(`${name} fails without scheduling the next participant`, async ({ page }) => { + let count = 0 + await page.route('**/v1/chat/completions', route => { count++; return route.fulfill({ contentType: 'text/event-stream', body }) }) + await setup(page) + await participants(page) + await page.getByRole('button', { name: 'Run one round', exact: true }).click() + await expect(page.getByRole('alert')).toBeVisible() + expect(count).toBe(1) + }) +} diff --git a/core/http/react-ui/e2e/home-console.spec.js b/core/http/react-ui/e2e/home-console.spec.js index f50cb00bdee6..42a5c677d90d 100644 --- a/core/http/react-ui/e2e/home-console.spec.js +++ b/core/http/react-ui/e2e/home-console.spec.js @@ -317,9 +317,11 @@ test.describe('Home in a cluster', () => { }) test.describe('Home resume list', () => { + const now = new Date('2026-10-09T12:00:00Z').getTime() test.beforeEach(async ({ page }) => { + await page.clock.setFixedTime(now) await mockHome(page) - await seedChats(page) + await seedChats(page, sampleChats(now)) }) test('groups conversations by day, one card per day', async ({ page }) => { @@ -408,7 +410,7 @@ test.describe('Home resume list', () => { }) test('without undo the delete becomes final when the time ends', async ({ page }) => { - await page.clock.install() + await page.clock.install({ time: now }) await ready(page) const row = page.getByTestId('home-conversation').filter({ hasText: 'invoice' }) await row.hover() @@ -430,12 +432,12 @@ test.describe('Home resume list', () => { }) test('old conversations wait behind one button', async ({ page }) => { - const chats = sampleChats() + const chats = sampleChats(now) const old = Array.from({ length: 8 }, (_, i) => ({ ...chats[0], id: `old-${i}`, name: `Old chat ${i}`, - updatedAt: Date.now() - (10 + i) * 24 * 60 * 60 * 1000, + updatedAt: now - (10 + i) * 24 * 60 * 60 * 1000, })) await page.addInitScript(([key, data]) => localStorage.setItem(key, JSON.stringify(data)), [CHATS_KEY, { chats: [...chats, ...old], activeChatId: chats[0].id }]) diff --git a/core/http/react-ui/e2e/model-page.spec.js b/core/http/react-ui/e2e/model-page.spec.js index 9a07d8993040..5ee36ecb5723 100644 --- a/core/http/react-ui/e2e/model-page.spec.js +++ b/core/http/react-ui/e2e/model-page.spec.js @@ -199,15 +199,17 @@ test.describe('Model page - walking and going back', () => { const { state } = await setup(page) await expect(rows(page).first()).toBeVisible({ timeout: 15_000 }) await page.waitForTimeout(500) - const before = state.listingRequests.length + // Facet counts load in the background; they do not reload the list. + const listings = () => state.listingRequests.filter(search => new URLSearchParams(search).get('items') !== '1') + const before = listings().length await page.locator('[data-entity="qwen3-8b-instruct"] [data-row-open]').click() await expect(name(page)).toHaveText('qwen3-8b-instruct') await page.keyboard.press('Escape') await expect(rows(page).first()).toBeVisible() await page.waitForTimeout(500) // The page itself looks its own entry up once; the list asks for nothing. - expect(state.listingRequests.length - before).toBeLessThanOrEqual(1) - expect(state.listingRequests.slice(before).every(search => search.includes('term=qwen3-8b-instruct'))).toBe(true) + expect(listings().length - before).toBeLessThanOrEqual(1) + expect(listings().slice(before).every(search => search.includes('term=qwen3-8b-instruct'))).toBe(true) }) test('changing tabs keeps the walker, and Back still leaves for the list in one step', async ({ page }) => { diff --git a/core/http/react-ui/e2e/models-cleanup.spec.js b/core/http/react-ui/e2e/models-cleanup.spec.js index 08b1870fe706..11c4ee8cca03 100644 --- a/core/http/react-ui/e2e/models-cleanup.spec.js +++ b/core/http/react-ui/e2e/models-cleanup.spec.js @@ -92,11 +92,10 @@ test.describe('Cleanup sheet - what it knows and what it does not', () => { test('each tier is ranked by size and a model with no known size goes last', async ({ page }) => { await openSheet(page) - const names = await tier(page, 'call').locator('.ledger-cr__name').evaluateAll( - (els) => els.map((e) => e.firstChild.textContent.trim()), - ) - expect(names[0]).toBe('llama-3.3-70b-instruct-iq2') - expect(names[names.length - 1]).toBe('my-finetune-q4') + // Estimates arrive after the sheet becomes interactive and can reorder it. + const names = tier(page, 'call').locator('.ledger-cr__name') + await expect(names.first()).toContainText('llama-3.3-70b-instruct-iq2') + await expect(names.last()).toContainText('my-finetune-q4') await expect(tier(page, 'call').locator('li').last()).toContainText('size unknown') await expect(tier(page, 'call').locator('li').first()).toContainText(`${gb1(sizeOf('llama-3.3-70b-instruct-iq2'))} GB`) }) diff --git a/core/http/react-ui/e2e/studio-work.spec.js b/core/http/react-ui/e2e/studio-work.spec.js index a7623d0cfc99..4f1f31cc13db 100644 --- a/core/http/react-ui/e2e/studio-work.spec.js +++ b/core/http/react-ui/e2e/studio-work.spec.js @@ -135,9 +135,8 @@ test.describe('Your work', () => { test('the masonry uses fewer columns on a narrow screen', async ({ page }) => { await open(page) - const wide = await page.locator('.studio-masonry').getAttribute('data-columns') + await expect.poll(async () => Number(await page.locator('.studio-masonry').getAttribute('data-columns'))).toBeGreaterThan(1) await page.setViewportSize({ width: 390, height: 844 }) await expect.poll(() => page.locator('.studio-masonry').getAttribute('data-columns')).toBe('1') - expect(Number(wide)).toBeGreaterThan(1) }) }) diff --git a/core/http/react-ui/e2e/traffic-models-host.spec.js b/core/http/react-ui/e2e/traffic-models-host.spec.js index 3c707396071d..f67763286441 100644 --- a/core/http/react-ui/e2e/traffic-models-host.spec.js +++ b/core/http/react-ui/e2e/traffic-models-host.spec.js @@ -17,7 +17,8 @@ test.describe('Traffic models', () => { await expect(bge.locator('td.dk-num').last()).toHaveText('-') // The failed load of flux-dev is in the backend-operation buffer. await expect(page.locator('tr[data-entity="flux-dev"]')).toContainText('1') - await expect(page.getByText(/p50|p95|p99/)).toHaveCount(0) + // A row's concatenated text can contain llama-cpp followed by 99 requests. + await expect(page.getByText(/\bp(?:50|95|99)\b/i)).toHaveCount(0) }) test('a row opens in place with the split, the buffer and the links', async ({ page }) => { diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 980dd7492ba6..d06101fdb407 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -9692,3 +9692,10 @@ button.lane:hover { @media (prefers-reduced-motion: reduce) { .lai-icon--beat { animation: none; } } +/* Group conversation: shared controls wrap on narrow screens. */ +.group-chat { max-width: 960px; margin: 0 auto; padding: var(--spacing-lg); } +.group-chat .card { padding: var(--spacing-lg); } +.group-chat-controls { display: flex; flex-wrap: wrap; align-items: end; gap: var(--spacing-md); } +.group-chat-controls label { flex: 1 1 180px; } +.group-chat textarea { width: 100%; resize: vertical; } +.group-chat-content { white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/core/http/react-ui/src/components/chat/ChatHeader.jsx b/core/http/react-ui/src/components/chat/ChatHeader.jsx index 2099afa003ce..2a7d5539dc5d 100644 --- a/core/http/react-ui/src/components/chat/ChatHeader.jsx +++ b/core/http/react-ui/src/components/chat/ChatHeader.jsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { fillStyle } from '../home/memory' // eslint-disable-next-line no-unused-vars @@ -94,6 +95,7 @@ export default function ChatHeader({ )}
+ {t('group.title', 'Group chat')} {onFind && ( +
+ {participants.map((p, i) =>
+ + {p.model} + + +
)} + {!valid &&

{t('group.validation', 'Add 2–6 participants with unique, nonempty names.')}

} +