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 && (