Skip to content
Draft
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
6 changes: 4 additions & 2 deletions core/http/react-ui/e2e/chat-history.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down Expand Up @@ -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)
Expand Down
172 changes: 172 additions & 0 deletions core/http/react-ui/e2e/group-chat.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
}
10 changes: 6 additions & 4 deletions core/http/react-ui/e2e/home-console.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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()
Expand All @@ -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 }])
Expand Down
8 changes: 5 additions & 3 deletions core/http/react-ui/e2e/model-page.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
9 changes: 4 additions & 5 deletions core/http/react-ui/e2e/models-cleanup.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
})
Expand Down
3 changes: 1 addition & 2 deletions core/http/react-ui/e2e/studio-work.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
3 changes: 2 additions & 1 deletion core/http/react-ui/e2e/traffic-models-host.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
7 changes: 7 additions & 0 deletions core/http/react-ui/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
2 changes: 2 additions & 0 deletions core/http/react-ui/src/components/chat/ChatHeader.jsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -94,6 +95,7 @@ export default function ChatHeader({
)}

<div className="cx-hdr">
<Link className="btn btn-secondary" to="/app/group-chat">{t('group.title', 'Group chat')}</Link>
{onFind && (
<button
type="button"
Expand Down
6 changes: 4 additions & 2 deletions core/http/react-ui/src/components/models/page/model-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -262,15 +262,17 @@ a.modelpage-chip { cursor: pointer; text-decoration: none; color: inherit; }
.modelpage-keys { display: none; }
}

.modelpage-table .dk-table-sub { max-width: none; white-space: normal; overflow-wrap: anywhere; }
.modelpage-table .dk-table-sub .modelpage-inline-badge:first-child { margin-left: 0; }
.modelpage-table .dk-table .dk-table-sub { max-width: none; white-space: normal; overflow-wrap: anywhere; }
.modelpage-table .dk-table .dk-table-sub .modelpage-inline-badge:first-child { margin-left: 0; }

/* A grid child is as wide as its content unless told otherwise; a table inside
one would push the whole page sideways instead of scrolling in its own box. */
.modelpage-variants > section, .modelpage-usage > section, .modelpage-columns > *, .modelpage-fit > * { min-width: 0; }
.modelpage-variants__size { display: none; }
@media (max-width: 640px) {
.modelpage-variants__size { display: inline; }
/* Leave room for Fit and Install while long build names wrap. */
.modelpage-table .dk-table .dk-table-sub { max-width: 14ch; }
.modelpage-table .dk-table-actions .dk-btn { padding-inline: var(--dk-space-2); }
}

Expand Down
Loading
Loading