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
37 changes: 37 additions & 0 deletions changelog.d/20260611_000000_chat_first_layout_and_storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
bump: minor
---

### Fixed
- The chat surface is now reachable. Previously the model catalogue rendered as a
full-width grid that filled the viewport and pushed the chat composer
off-screen, so selecting a model only downloaded it with no way to actually
send a message. The app now uses a chat-first **three-panel layout** — a fixed
sidebar (brand, conversations, engine picker, model catalogue, export/import)
beside an always-visible chat column (issue #15).
- The model selector no longer collapses to a narrow strip when the **reachat**
engine is active. The selector lives in its own sidebar column, structurally
isolated from the chat engine's horizontal flex layout, with defensive
`min-width` / `width` CSS as a backstop (issue #15).

### Added
- A built-in **Formal-AI–style** default chat engine (`FormalAiProvider`):
avatars, per-message copy, markdown + code rendering, a "Thinking…" typing
indicator, and an auto-growing composer (Enter to send, Shift+Enter for a
newline) — mirroring the UX of
[formal-ai](https://github.com/link-assistant/formal-ai).
- A **conversations panel**: create, switch, and delete conversations from the
sidebar.
- **Links Notation (`.lino`) storage** for all chat data, mirroring the formal-ai
web UI. Conversations are persisted to `localStorage` as a
`model_in_browser_bundle` document, with sidebar **Export** (downloads
`model-in-browser-chats.lino`) and **Import** controls.
- All chat engines from
[react-chat-ui](https://github.com/link-assistant/react-chat-ui) are
selectable: Formal-AI (default), Chatscope, Deep Chat, React Chat Elements,
Assistant UI, and Reachat.
- E2E coverage guaranteeing the chat works: `e2e/chat-ux.spec.ts` (composer
reachable beside the catalogue, every engine selectable without narrowing the
selector, conversation management, and Links Notation export/import round-trips)
plus the existing `e2e/inference.spec.ts` send→streamed-reply guarantee.
- Deep case study at `docs/case-studies/issue-15/`.
337 changes: 337 additions & 0 deletions docs/case-studies/issue-15/README.md

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
179 changes: 179 additions & 0 deletions web/e2e/chat-ux.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { test, expect } from '@playwright/test';
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

/**
* E2E tests for the issue #15 chat UX: a persistent three-panel layout, multiple
* selectable chat engines, conversation management, and Links Notation (.lino)
* export/import.
*
* These tests deliberately do NOT load a model — they exercise the shell,
* storage, and engine wiring, which is fast and deterministic. The actual
* send→response guarantee (R6) lives in `inference.spec.ts`, which pins a small
* model and asserts a streamed reply.
*
* `?model=__none__` is an unknown id, so the auto-loader finds no entry and the
* app stays on the chat shell without kicking off a multi-hundred-MB download.
*/
const SHELL_URL = '/?model=__none__';

// The engines wired into the selector. formal-ai is the default; the rest must
// all be selectable (issue #15 R3). We assert the chat column keeps rendering
// and — crucially for R7 — that the model selector keeps its full sidebar width
// no matter which engine is active.
const ENGINES = [
'formal-ai',
'chatscope',
'deep-chat',
'react-chat-elements',
'assistant-ui',
'reachat',
];

test.describe('Chat UX shell (issue #15)', () => {
test('keeps the chat surface reachable beside the model catalog', async ({
page,
}) => {
await page.goto(SHELL_URL);

// The regression at the heart of issue #15: selecting a model used to push
// the chat off-screen. The sidebar (with the model catalog) and the chat
// composer must now be visible at the same time.
await expect(page.getByTestId('sidebar')).toBeVisible();
await expect(page.getByTestId('model-selector')).toBeVisible();
await expect(page.getByTestId('own-chat')).toBeVisible();
await expect(page.getByTestId('composer-input')).toBeVisible();

// The seeded greeting is shown in the default (formal-ai) provider.
await expect(
page.getByText(/Hello! I'm a small language model running entirely in your browser/)
).toBeVisible();
});

test('all chat engines are selectable and never narrow the model selector (R3, R7)', async ({
page,
}) => {
await page.goto(SHELL_URL);

const selector = page.getByTestId('model-selector');
const dropdown = page.locator('.provider-select');

for (const engine of ENGINES) {
await dropdown.selectOption(engine);

// The chat column keeps rendering for every engine (the container that
// holds the active provider is always present).
await expect(page.locator('.chat-main')).toBeVisible();

// R7: switching engines (notably reachat) must not shrink the sidebar
// model selector. It lives in its own column now, so its width stays at
// the full sidebar width regardless of the active engine.
const box = await selector.boundingBox();
expect(box, `model selector box for ${engine}`).not.toBeNull();
expect(box!.width, `model selector width for ${engine}`).toBeGreaterThan(
250
);
}
});

test('creates, switches, and deletes conversations', async ({ page }) => {
await page.goto(SHELL_URL);

const list = page.getByTestId('conversation-list');
await expect(list.locator('li')).toHaveCount(1);

// New chat adds a conversation and makes it active.
await page.getByTestId('new-conversation').click();
await expect(list.locator('li')).toHaveCount(2);

// Delete one — the count drops back.
await list.locator('li').first().getByLabel('Delete conversation').click();
await expect(list.locator('li')).toHaveCount(1);
});

test('persists conversations across reloads (Links Notation storage, R4/R5)', async ({
page,
}) => {
await page.goto(SHELL_URL);

await page.getByTestId('new-conversation').click();
await page.getByTestId('new-conversation').click();
await expect(page.getByTestId('conversation-list').locator('li')).toHaveCount(
3
);

// The bundle is stored in localStorage as a .lino document.
const stored = await page.evaluate(() =>
window.localStorage.getItem('mib_conversations_lino')
);
expect(stored).toContain('model_in_browser_bundle');
expect(stored).toContain('conversation');

// After a reload the conversations are restored from storage.
await page.reload();
await expect(page.getByTestId('conversation-list').locator('li')).toHaveCount(
3
);
});

test('exports conversations as a .lino bundle (R4)', async ({ page }) => {
await page.goto(SHELL_URL);

const downloadPromise = page.waitForEvent('download');
await page.getByTestId('export-button').click();
const download = await downloadPromise;

expect(download.suggestedFilename()).toMatch(/\.lino$/);

const path = await download.path();
const text = readFileSync(path, 'utf8');
expect(text).toContain('model_in_browser_bundle');
expect(text).toContain('conversation');
});

test('imports conversations from a .lino bundle (R4)', async ({ page }) => {
await page.goto(SHELL_URL);

// A hand-authored bundle with two conversations, one of which carries a
// recognizable title derived from its first user message.
const bundle = [
'model_in_browser_bundle',
' version "1"',
' exported_at "2026-06-10T00:00:00.000Z"',
' conversation "conv_imported_1"',
' title "Imported greeting"',
' createdAt "2026-06-10T00:00:00.000Z"',
' updatedAt "2026-06-10T00:00:00.000Z"',
' message "m1"',
' role "user"',
' content "Imported greeting"',
' sentAt "2026-06-10T00:00:00.000Z"',
' message "m2"',
' role "assistant"',
' content "Hi there from the import"',
' sentAt "2026-06-10T00:00:01.000Z"',
' conversation "conv_imported_2"',
' title "Second thread"',
' createdAt "2026-06-10T00:00:00.000Z"',
' updatedAt "2026-06-10T00:00:00.000Z"',
' message "m3"',
' role "user"',
' content "Second thread"',
' sentAt "2026-06-10T00:00:00.000Z"',
'',
].join('\n');

const dir = mkdtempSync(join(tmpdir(), 'mib-import-'));
const file = join(dir, 'chats.lino');
writeFileSync(file, bundle, 'utf8');

await page.getByTestId('import-input').setInputFiles(file);

const list = page.getByTestId('conversation-list');
await expect(list.locator('li')).toHaveCount(2);
await expect(page.getByText('Imported greeting').first()).toBeVisible();
// The imported active conversation's assistant message is rendered.
await expect(page.getByText('Hi there from the import')).toBeVisible();
});
});
12 changes: 6 additions & 6 deletions web/e2e/inference.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ test.describe('In-Browser Inference', () => {
});

// Message input should be enabled
await expect(page.locator('.cs-message-input__content-editor')).toBeEnabled();
await expect(page.getByTestId('composer-input')).toBeEnabled();
});

test('should generate text response without errors', async ({ page }) => {
Expand All @@ -100,7 +100,7 @@ test.describe('In-Browser Inference', () => {
});

// Send a message
const messageInput = page.locator('.cs-message-input__content-editor');
const messageInput = page.getByTestId('composer-input');
await messageInput.fill('Hello');
await messageInput.press('Enter');

Expand Down Expand Up @@ -137,7 +137,7 @@ test.describe('In-Browser Inference', () => {
});

// Send a message
const messageInput = page.locator('.cs-message-input__content-editor');
const messageInput = page.getByTestId('composer-input');
await messageInput.fill('Count from 1 to 5');
await messageInput.press('Enter');

Expand All @@ -148,7 +148,7 @@ test.describe('In-Browser Inference', () => {
});

// There should be multiple AI response regions (initial greeting + new response)
const aiMessages = page.locator('[class*="cs-message--incoming"]');
const aiMessages = page.getByTestId('message-assistant');
await expect(aiMessages).toHaveCount(2, { timeout: 5000 });
});

Expand All @@ -168,7 +168,7 @@ test.describe('In-Browser Inference', () => {
timeout: 5 * 60 * 1000,
});

const messageInput = page.locator('.cs-message-input__content-editor');
const messageInput = page.getByTestId('composer-input');

// Send FIRST message
await messageInput.fill('Say hello');
Expand Down Expand Up @@ -224,7 +224,7 @@ test.describe('In-Browser Inference', () => {
await expect(page.getByText(READY)).toBeVisible();

// There should be 4 AI response regions (initial greeting + 3 responses)
const aiMessages = page.locator('[class*="cs-message--incoming"]');
const aiMessages = page.getByTestId('message-assistant');
await expect(aiMessages).toHaveCount(4, { timeout: 5000 });
});
});
Expand Down
Loading
Loading