diff --git a/changelog.d/20260611_000000_chat_first_layout_and_storage.md b/changelog.d/20260611_000000_chat_first_layout_and_storage.md new file mode 100644 index 0000000..9b32ce1 --- /dev/null +++ b/changelog.d/20260611_000000_chat_first_layout_and_storage.md @@ -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/`. diff --git a/docs/case-studies/issue-15/README.md b/docs/case-studies/issue-15/README.md new file mode 100644 index 0000000..5236395 --- /dev/null +++ b/docs/case-studies/issue-15/README.md @@ -0,0 +1,337 @@ +# Case Study — Issue #15: "Selecting chat and the model does nothing except from downloading model" + +> Deep-dive analysis of the chat-unreachable failure reported in +> [issue #15](https://github.com/link-assistant/model-in-browser/issues/15), +> its root cause, the chat-first redesign that fixes it, and the regression +> tests that now guard against it. + +- **Issue:** [#15 — "Selecting chat and the model does nothing except from downloading model"](https://github.com/link-assistant/model-in-browser/issues/15) +- **Reported by:** @konard (Konstantin Diachenko) — 2026-06-10 +- **Deployment affected:** the web app (`web/`), served locally and at `https://link-assistant.github.io/model-in-browser/` +- **Symptom:** picking a chat engine + model only downloads the model; there is **no chat surface** to actually send a message. For the `reachat` engine the model selector additionally collapses to a narrow column. +- **Fix PR:** [#16](https://github.com/link-assistant/model-in-browser/pull/16) + +--- + +## 1. Symptom (what the user saw) + +The deployed app rendered a **full-page grid of model cards**. After choosing a +model, a status line at the bottom reported e.g. *"SmolLM2 135M Instruct ready"* — +but the catalogue grid filled the entire viewport, pushing any chat input and +message area off-screen. There was nowhere to type a message or read a reply. + +| Reported state #1 | Reported state #2 | +|---|---| +| ![issue screenshot 1](./screenshots/issue-15-img1.png) | ![issue screenshot 2](./screenshots/issue-15-img2.png) | + +In the user's words: + +> So at the moment there is no way to actually chat with a model for some reason… + +and separately: + +> And for reachat for some reason width of model selector becomes narrow. + +--- + +## 2. Timeline / sequence of events + +| # | Event | +|---|-------| +| 1 | The app was built **catalogue-first**: `App.tsx` rendered the device-aware `ModelSelector` as a full-width grid at the top of a single-column layout. | +| 2 | The chat surface (`ChatContainer` + the active provider) was rendered *below* the catalogue in the same single column. | +| 3 | The model catalogue lists ~18 models, each a sizeable card. On a normal viewport the grid is tall enough to consume the whole screen. | +| 4 | **The bug (R1):** with the catalogue occupying the viewport, the chat composer and message list scrolled out of view. A user who picked a model saw the download complete but had no visible way to chat — "selecting the model does nothing except downloading." | +| 5 | **The reachat bug (R7):** because chat and catalogue shared one column, the `reachat` engine's Tailwind flex layout stretched horizontally and squeezed the neighbouring model selector into a narrow strip. | +| 6 | **Why it was never caught:** the e2e suite asserted the catalogue rendered and a model loaded, but never asserted that a composer was *visible at the same time* as the catalogue, nor that switching engines preserved the selector's width. | + +--- + +## 3. Requirements from the issue + +The issue is a bug report *and* a feature specification. Each explicit +requirement is tracked here. + +| ID | Requirement | Status | +|----|-------------|--------| +| **R1** | Fix the core bug — there must be a way to actually chat with a model, not just download it. | ✅ Three-panel, chat-first layout — the composer is always visible beside the catalogue (`web/src/App.tsx`, `web/src/index.css`) | +| **R2** | By default, use the UI/UX of chat from [formal-ai](https://github.com/link-assistant/formal-ai). | ✅ `FormalAiProvider` is the default engine — avatars, per-message copy, markdown, auto-growing composer (`web/src/components/chat-providers/FormalAiProvider.tsx`) | +| **R3** | All chat engines from [react-chat-ui](https://github.com/link-assistant/react-chat-ui) should be selectable. | ✅ 6 engines wired into the selector: formal-ai, chatscope, deep-chat, react-chat-elements, assistant-ui, reachat (`web/src/types/chat.ts`, `web/src/components/chat-providers/`) | +| **R4** | All message/input features as in formal-ai; conversations panel; data stored in the browser; export/import. | ✅ Conversations sidebar; `localStorage` persistence; `.lino` export/import (`web/src/storage/conversations.ts`, `web/src/App.tsx`) | +| **R5** | Use Links Notation for all data storage, as in the formal-ai web UI. | ✅ Conversations serialized to a `model_in_browser_bundle` Links Notation document (`web/src/storage/links-notation.ts`, `conversations.ts`) | +| **R6** | An e2e test guaranteeing we can send a message and get a response. | ✅ `web/e2e/inference.spec.ts` (real model send→streamed reply) + `web/e2e/chat-ux.spec.ts` (shell/storage/engines) | +| **R7** | Fix the narrow model selector for reachat. | ✅ Selector lives in its own sidebar column; defensive CSS on the reachat container (`web/src/index.css`) | +| **R8** | Compile data to `./docs/case-studies/issue-15`; deep case study with timeline, all requirements, solution plans, library/online research. | ✅ This document + screenshots | +| **R9** | Plan and execute everything in a single PR until every requirement is fully addressed. | ✅ All work lands in PR #16 | + +--- + +## 4. Root cause analysis + +The bug was **layout**, not inference. The engine already loaded models and +generated text (issues #5, #7, #13 fixed earlier); the problem was that the user +could never *reach* the chat surface. + +The pre-fix `App.tsx` rendered a single vertical column: + +``` +┌──────────────────────────────────────────────┐ +│ Header │ +│ ┌──────────────────────────────────────────┐ │ +│ │ ModelSelector — FULL-WIDTH GRID │ │ ← ~18 cards, +│ │ [card][card][card][card][card][card] … │ │ fills the viewport +│ │ [card][card][card][card][card][card] … │ │ +│ └──────────────────────────────────────────┘ │ +│ … chat surface lives down here, off-screen … │ ← never visible +└──────────────────────────────────────────────┘ +``` + +Two consequences followed directly: + +1. **R1 — chat unreachable.** The catalogue grid is taller than the viewport, so + the chat composer below it is scrolled out of view. Selecting a model only + surfaced a status string; there was no visible input. +2. **R7 — narrow selector.** Because the catalogue and the active chat engine + shared one column, the `reachat` provider's horizontal Tailwind flex layout + stole width from its sibling, collapsing the model selector. + +Both are solved by the *same* structural change: split the screen into a fixed +sidebar (catalogue + conversations + data controls) and an always-present chat +column. + +--- + +## 5. The fix + +### 5.1 Chat-first three-panel layout (R1, R2, R7) + +`App.tsx` now renders an `.app-shell` flex row: + +``` +┌──────────────┬───────────────────────────────────────┐ +│ SIDEBAR │ CHAT (always visible) │ +│ 320px │ │ +│ ┌────────┐ │ status / progress bar │ +│ │ brand │ │ ┌──────────────────────────────────┐ │ +│ │ + New │ │ │ message list (active conversa- │ │ +│ │ chat │ │ │ tion) — greeting, user, AI … │ │ +│ ├────────┤ │ │ │ │ +│ │ convos │ │ └──────────────────────────────────┘ │ +│ ├────────┤ │ ┌──────────────────────────────────┐ │ +│ │ engine │ │ │ composer (Enter to send) │ │ +│ │ picker │ │ └──────────────────────────────────┘ │ +│ ├────────┤ │ "No data sent to servers" │ +│ │ model │ │ │ +│ │ catalog│ │ │ +│ ├────────┤ │ │ +│ │ export │ │ │ +│ │ import │ │ │ +│ └────────┘ │ │ +└──────────────┴───────────────────────────────────────┘ +``` + +- The model catalogue is confined to a scrollable `flex: 0 0 320px` sidebar + (`.sidebar` in `index.css`), its grid forced to a single column + (`.sidebar .model-list { grid-template-columns: 1fr }`). +- The chat column (`.chat-main`, `flex: 1 1 auto; min-width: 0`) always renders + the status bar, the active provider, and the composer — so a model selection + can never push chat off-screen again. +- **R7:** the selector now lives in its own column, structurally immune to the + chat engine's width. Defensive CSS (`.reachat-container { width: 100%; + min-width: 0 }` and `.chat-main { min-width: 0 }`) keeps any flex child from + overflowing. + +### 5.2 Formal-AI default chat UX (R2) + +`FormalAiProvider` is the dependency-free default engine, mirroring the formal-ai +web app: per-message avatars, a copy button, markdown + code rendering, a +"Thinking…" typing indicator, and an auto-growing composer (Enter to send, +Shift+Enter for a newline). The app is wrapped in +``. + +### 5.3 All engines selectable (R3) + +Six providers are registered in `web/src/types/chat.ts` and surfaced through the +sidebar `ChatProviderSelector`: **formal-ai** (default), **chatscope** +(`@chatscope/chat-ui-kit-react`), **deep-chat** (`deep-chat-react`), +**react-chat-elements**, **assistant-ui** (`@assistant-ui/react`), and +**reachat**. This covers every engine that ships as a live, installable demo in +[react-chat-ui](https://github.com/link-assistant/react-chat-ui). + +### 5.4 Links Notation storage + conversations + export/import (R4, R5) + +Conversations are persisted to `localStorage` (key `mib_conversations_lino`) as a +Links Notation document, exactly as the formal-ai web UI stores its memory: + +```lino +model_in_browser_bundle + version "1" + exported_at "2026-06-10T00:00:00.000Z" + conversation "conv_…" + title "Imported greeting" + createdAt "…" + updatedAt "…" + message "m1" + role "user" + content "Imported greeting" + sentAt "…" + message "m2" + role "assistant" + content "Hi there" + sentAt "…" +``` + +- `web/src/storage/links-notation.ts` — a small, indentation-based (2 spaces / + level) Links Notation reader/writer. +- `web/src/storage/conversations.ts` — `serializeBundle` / `parseBundle` + (forgiving), `saveConversations` / `loadConversations`, and `deriveTitle`. +- The sidebar **Export** button downloads `model-in-browser-chats.lino`; **Import** + reads a `.lino` bundle and restores the conversation list — mirroring + formal-ai's `formal-ai-memory.lino` / `formal_ai_bundle` flow. + +--- + +## 6. Codebase-wide audit + +| Surface | Pre-fix | Post-fix | +|---|---|---| +| Layout (`App.tsx`) | Single column, catalogue-first | Three-panel shell; chat always visible | +| Catalogue width (`ModelSelector`) | Full-width grid | Single-column, confined to 320px sidebar | +| `reachat` selector width (R7) | Squeezed by shared column | Isolated column + defensive CSS | +| Chat storage | In-memory only (lost on reload) | Links Notation in `localStorage`; export/import | +| Engines | Selectable but chat unreachable | All 6 selectable *and* reachable | +| e2e coverage | Asserted model load only | Asserts composer visibility, engine widths, storage round-trips, and real send→reply | + +**Conclusion:** the chat-unreachable defect was rooted entirely in the +single-column layout. Splitting the shell fixes both R1 and R7 at the structural +level; the storage and engine work fulfils R2–R5. No other surface rendered chat, +so the fix is complete codebase-wide. + +--- + +## 7. Should this be reported upstream? (R5/R7) + +**No.** The root cause is in *this repository's* layout — the catalogue-first +single column and the shared chat/catalogue column. The wrapped libraries +(formal-ai's UX, react-chat-ui's engines, reachat) behaved correctly in +isolation; the narrow-selector symptom was a consequence of *our* layout placing +a horizontally-greedy flex engine beside the selector, not a bug in reachat. The +remedy is purely local CSS/structure. There is no upstream bug to file. + +--- + +## 8. Verification + +### Automated regression tests + +1. **`web/e2e/chat-ux.spec.ts`** (6 tests, fast — no model download): + - *keeps the chat surface reachable beside the model catalog* — asserts the + sidebar, model selector, own-chat panel, and composer are **all visible at + once** (the direct R1 regression guard). + - *all chat engines are selectable and never narrow the model selector + (R3, R7)* — iterates all six engines, asserting the chat column renders and + the model selector keeps `boundingBox().width > 250` for every engine + (including reachat). + - *creates, switches, and deletes conversations*. + - *persists conversations across reloads (Links Notation storage, R4/R5)* — + checks `localStorage['mib_conversations_lino']` contains the + `model_in_browser_bundle` root and survives a reload. + - *exports conversations as a .lino bundle (R4)*. + - *imports conversations from a .lino bundle (R4)*. + +2. **`web/e2e/inference.spec.ts`** (R6) — the send→response guarantee. Pins + SmolLM2 135M Instruct (q8), auto-loads it, sends real messages, and asserts a + streamed reply renders (`message-assistant` count grows; "Thinking…" appears + then clears; no console errors). Observed: + + ``` + Running 6 tests using 2 workers + 6 passed (55.6s) + ``` + +### Manual Playwright verification (R1, R7) + +The fixed build was driven manually. The chat composer is visible beside the +catalogue, and switching to reachat keeps the model selector at full sidebar +width. + +| After — default (formal-ai) chat-first layout | After — reachat keeps the selector full-width | +|---|---| +| ![default layout](./screenshots/after-fix-default-layout.png) | ![reachat width](./screenshots/after-fix-reachat-width.png) | + +--- + +## 9. Existing components / libraries & online research (R8) + +**Default chat UX — formal-ai:** + +- [link-assistant/formal-ai](https://github.com/link-assistant/formal-ai) — the + reference UX. Its README documents the storage model this fix mirrors: *"Every + interface produces the same self-contained Links Notation document by + default,"* with an **Export memory** button that writes + `formal-ai-memory.lino` as a complete `formal_ai_bundle`, and an **Import + memory** counterpart. We adopt the same pattern under + `model_in_browser_bundle` / `model-in-browser-chats.lino`. + +**Selectable engines — react-chat-ui:** + +- [link-assistant/react-chat-ui](https://github.com/link-assistant/react-chat-ui) + — catalogue of chat-UI engines. It ships **18 catalogue profiles**, of which + **3 are live, installable demos** (ChatScope, React Chat Elements, Deep Chat); + the remaining 15 are source-only blocks for packages that aren't installed or + require hosted credentials. This fix wires every live engine plus + assistant-ui and reachat into the selector. + +**Wrapped chat libraries:** + +- [@chatscope/chat-ui-kit-react](https://github.com/chatscope/chat-ui-kit-react) + — classic chat components (ChatScope). +- [deep-chat / deep-chat-react](https://github.com/OvidijusParsiunas/deep-chat) + — configurable AI chat web component; wrapped with a streaming bridge in + `DeepChatProvider`. +- [react-chat-elements](https://github.com/Detaysoft/react-chat-elements) — + lightweight chat components. +- [@assistant-ui/react](https://github.com/assistant-ui/assistant-ui) — modern + AI chat interface with streaming. +- [reachat](https://github.com/reaviz/reachat) — Tailwind-styled, LLM-focused + chat; the source of the R7 width symptom (its horizontal flex layout), fixed + here at the layout level. + +**Storage format — Links Notation:** + +- Links Notation (`.lino`) — an indentation-based, human-readable symbolic + format used by formal-ai for all chat/memory storage. Implemented locally in + `web/src/storage/links-notation.ts` so the app has zero new runtime + dependencies for persistence. + +**Testing tooling:** + +- [Playwright](https://playwright.dev/) — both the fast chat-UX shell spec and + the model-loading inference spec. + +--- + +## 10. Artifacts in this folder + +``` +docs/case-studies/issue-15/ +├── README.md ← this analysis +└── screenshots/ + ├── issue-15-img1.png ← reported state: full-page catalogue, no chat + ├── issue-15-img2.png ← reported state (second screenshot) + ├── after-fix-default-layout.png ← chat-first three-panel layout after the fix + └── after-fix-reachat-width.png ← reachat keeps the model selector full-width +``` + +**Related source / test files:** + +- Layout & wiring: [`web/src/App.tsx`](../../../web/src/App.tsx), + [`web/src/index.css`](../../../web/src/index.css) +- Default chat UX: [`web/src/components/chat-providers/FormalAiProvider.tsx`](../../../web/src/components/chat-providers/FormalAiProvider.tsx) +- Engine registry: [`web/src/types/chat.ts`](../../../web/src/types/chat.ts), + [`web/src/components/chat-providers/`](../../../web/src/components/chat-providers/) +- Links Notation storage: [`web/src/storage/links-notation.ts`](../../../web/src/storage/links-notation.ts), + [`web/src/storage/conversations.ts`](../../../web/src/storage/conversations.ts) +- Tests: [`web/e2e/chat-ux.spec.ts`](../../../web/e2e/chat-ux.spec.ts), + [`web/e2e/inference.spec.ts`](../../../web/e2e/inference.spec.ts), + [`web/src/storage/storage.test.ts`](../../../web/src/storage/storage.test.ts) diff --git a/docs/case-studies/issue-15/screenshots/after-fix-default-layout.png b/docs/case-studies/issue-15/screenshots/after-fix-default-layout.png new file mode 100644 index 0000000..db4d6e9 Binary files /dev/null and b/docs/case-studies/issue-15/screenshots/after-fix-default-layout.png differ diff --git a/docs/case-studies/issue-15/screenshots/after-fix-reachat-width.png b/docs/case-studies/issue-15/screenshots/after-fix-reachat-width.png new file mode 100644 index 0000000..a004f61 Binary files /dev/null and b/docs/case-studies/issue-15/screenshots/after-fix-reachat-width.png differ diff --git a/docs/case-studies/issue-15/screenshots/issue-15-img1.png b/docs/case-studies/issue-15/screenshots/issue-15-img1.png new file mode 100644 index 0000000..02b56bb Binary files /dev/null and b/docs/case-studies/issue-15/screenshots/issue-15-img1.png differ diff --git a/docs/case-studies/issue-15/screenshots/issue-15-img2.png b/docs/case-studies/issue-15/screenshots/issue-15-img2.png new file mode 100644 index 0000000..489288d Binary files /dev/null and b/docs/case-studies/issue-15/screenshots/issue-15-img2.png differ diff --git a/web/e2e/chat-ux.spec.ts b/web/e2e/chat-ux.spec.ts new file mode 100644 index 0000000..e4a21e3 --- /dev/null +++ b/web/e2e/chat-ux.spec.ts @@ -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(); + }); +}); diff --git a/web/e2e/inference.spec.ts b/web/e2e/inference.spec.ts index ca49313..f58dbe7 100644 --- a/web/e2e/inference.spec.ts +++ b/web/e2e/inference.spec.ts @@ -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 }) => { @@ -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'); @@ -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'); @@ -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 }); }); @@ -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'); @@ -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 }); }); }); diff --git a/web/package-lock.json b/web/package-lock.json index d98dfea..b37ded0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,6 +14,7 @@ "@chatscope/chat-ui-kit-styles": "^1.4.0", "@huggingface/transformers": "^3.8.1", "@types/react-syntax-highlighter": "^15.5.13", + "deep-chat-react": "^2.4.2", "reachat": "^2.0.2", "react": "^18.3.1", "react-chat-elements": "^12.0.18", @@ -1934,6 +1935,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lit/react": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.8.tgz", + "integrity": "sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==", + "license": "BSD-3-Clause", + "peerDependencies": { + "@types/react": "17 || 18 || 19" + } + }, "node_modules/@marko19907/string-to-color": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@marko19907/string-to-color/-/string-to-color-1.0.1.tgz", @@ -1943,6 +1953,12 @@ "esm-seedrandom": "^3.0.5-esm.2" } }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "license": "MIT" + }, "node_modules/@playwright/test": { "version": "1.57.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", @@ -3689,6 +3705,15 @@ "node": "^18 || >=20" } }, + "node_modules/autolinker": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-3.16.2.tgz", + "integrity": "sha512-JiYl7j2Z19F9NdTmirENSUUIIL/9MytEWtmzhfmsKPCp9E+G35Y0UNCMoM9tFigxT59qSc8Ml2dlZXOCVTYwuA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -4090,6 +4115,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-chat": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/deep-chat/-/deep-chat-2.4.2.tgz", + "integrity": "sha512-wQNd6Z6HDoH7OMeoQKpmxDm9XdWrRjMkBJ2yl5tsYND9Dz2UCv/BdGVM8vK2V0mhO1u+OniHYPWDGvjlNxjCeQ==", + "license": "MIT", + "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", + "remarkable": "^2.0.1", + "speech-to-element": "^1.0.4" + } + }, + "node_modules/deep-chat-react": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/deep-chat-react/-/deep-chat-react-2.4.2.tgz", + "integrity": "sha512-ST+xgauNp9pwpe2pai3/BhMGQgX/xSxfPAzdVlpkB5rXHPnyICgGvr33nA5rhtDerwwRWrozp9T2Dxr62cZh1Q==", + "license": "MIT", + "dependencies": { + "@lit/react": "^1.0.8", + "deep-chat": "2.4.2" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -7560,6 +7609,37 @@ "remark-rehype": "^11.1.0" } }, + "node_modules/remarkable": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/remarkable/-/remarkable-2.0.1.tgz", + "integrity": "sha512-YJyMcOH5lrR+kZdmB0aJJ4+93bEojRZ1HGDn9Eagu6ibg7aVZhc3OWbbShRid+Q5eAfsEqWxpe+g5W5nYNfNiA==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "autolinker": "^3.11.0" + }, + "bin": { + "remarkable": "bin/remarkable.js" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/remarkable/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/remarkable/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -7790,6 +7870,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/speech-to-element": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/speech-to-element/-/speech-to-element-1.0.4.tgz", + "integrity": "sha512-0wDab5u0vbch7taU48N4ccqNBqHMX7EtrOeZdfwfrZ3e4Cw4OTyZZziLjCouTjWaT/oe4SmpK26/28SjNLxFLQ==", + "license": "MIT" + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", diff --git a/web/package.json b/web/package.json index 5a2eb75..2b1ffae 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,7 @@ "@chatscope/chat-ui-kit-styles": "^1.4.0", "@huggingface/transformers": "^3.8.1", "@types/react-syntax-highlighter": "^15.5.13", + "deep-chat-react": "^2.4.2", "reachat": "^2.0.2", "react": "^18.3.1", "react-chat-elements": "^12.0.18", diff --git a/web/src/App.tsx b/web/src/App.tsx index fbf8d0d..d70254c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { ChatProviderProvider } from './context/ChatProviderContext'; import { ChatContainer } from './components/ChatContainer'; import { ChatProviderSelector } from './components/ChatProviderSelector'; @@ -29,6 +29,14 @@ import { pickRecommended, type EvaluatedModel, } from './models/registry'; +import { + type Conversation, + deriveTitle, + serializeBundle, + parseBundle, + saveConversations, + loadConversations, +} from './storage/conversations'; // Auto-download cap: on first visit we only auto-load the recommended model // when its download stays under this size, so a large model never starts a @@ -37,6 +45,10 @@ import { // this cap. const AUTO_LOAD_MAX_BYTES = 700 * 1024 * 1024; +/** The assistant greeting that seeds every fresh conversation. */ +const GREETING = + "Hello! I'm a small language model running entirely in your browser. Pick a model in the sidebar that fits your device — the recommended one downloads automatically. You can start chatting once it's ready!"; + /** * Absolute base URL the ONNX Runtime Web wasm binaries are served from. * @@ -99,22 +111,47 @@ interface ProgressInfo { progress: number; } -// Generate unique message IDs -let messageIdCounter = 0; -function generateMessageId(): string { - return `msg-${Date.now()}-${++messageIdCounter}`; +// Generate unique ids for messages and conversations. +let idCounter = 0; +function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${++idCounter}`; +} + +/** Build a fresh conversation seeded with the assistant greeting. */ +function createConversation(): Conversation { + const now = new Date(); + return { + id: generateId('conv'), + title: 'New conversation', + createdAt: now, + updatedAt: now, + messages: [ + { + id: generateId('msg'), + content: GREETING, + sender: 'assistant', + timestamp: now, + }, + ], + }; } function App() { - const [messages, setMessages] = useState([ - { - id: generateMessageId(), - content: - "Hello! I'm a small language model running entirely in your browser. Pick a model below that fits your device — the recommended one downloads automatically. You can start chatting once it's ready!", - sender: 'assistant', - timestamp: new Date(), - }, - ]); + // All chat data lives as a list of conversations, persisted to localStorage as + // a Links Notation (.lino) bundle. On first load we restore any saved + // conversations, otherwise we start a fresh one with the greeting. + const initialConversations = useMemo(() => { + const loaded = loadConversations(); + return loaded.length > 0 ? loaded : [createConversation()]; + }, []); + const [conversations, setConversations] = + useState(initialConversations); + const [activeId, setActiveId] = useState(initialConversations[0].id); + const activeIdRef = useRef(activeId); + useEffect(() => { + activeIdRef.current = activeId; + }, [activeId]); + const [status, setStatus] = useState('idle'); const [statusText, setStatusText] = useState('Detecting device...'); const [isTyping, setIsTyping] = useState(false); @@ -139,6 +176,46 @@ function App() { const evaluatedRef = useRef([]); const catalogRef = useRef(MODEL_CATALOG); const overrideRef = useRef(readUrlOverride()); + const fileInputRef = useRef(null); + + // The active conversation and its messages drive the chat surface. + const activeConversation = + conversations.find((c) => c.id === activeId) ?? conversations[0]; + const messages = activeConversation?.messages ?? []; + + // Apply an update to the active conversation's messages, refreshing its + // derived title and timestamp. All chat mutations flow through here so the + // persisted bundle always reflects the latest state. + const updateActiveMessages = useCallback( + (updater: (msgs: ChatMessage[]) => ChatMessage[]) => { + setConversations((prev) => + prev.map((c) => { + if (c.id !== activeIdRef.current) return c; + const next = updater(c.messages); + return { + ...c, + messages: next, + updatedAt: new Date(), + title: deriveTitle(next), + }; + }) + ); + }, + [] + ); + + // Persist every change to the conversation bundle. + useEffect(() => { + saveConversations(conversations); + }, [conversations]); + + // Keep a valid active conversation selected (e.g. after a delete). + useEffect(() => { + if (conversations.length === 0) return; + if (!conversations.some((c) => c.id === activeId)) { + setActiveId(conversations[0].id); + } + }, [conversations, activeId]); // The quantization to use for an entry on this device: the per-model choice // from the evaluated catalog, or a freshly computed best dtype. @@ -272,7 +349,7 @@ function App() { case 'token': currentResponseRef.current += payload as string; - setMessages((prev) => { + updateActiveMessages((prev) => { const updated = [...prev]; const lastIdx = updated.length - 1; if ( @@ -378,13 +455,13 @@ function App() { if (!entry) return; const userMessage: ChatMessage = { - id: generateMessageId(), + id: generateId('msg'), content: text, sender: 'user', timestamp: new Date(), }; - const assistantMessageId = generateMessageId(); + const assistantMessageId = generateId('msg'); const aiPlaceholder: ChatMessage = { id: assistantMessageId, content: '', @@ -392,7 +469,7 @@ function App() { timestamp: new Date(), }; - setMessages((prev) => [...prev, userMessage, aiPlaceholder]); + updateActiveMessages((prev) => [...prev, userMessage, aiPlaceholder]); setIsTyping(true); currentResponseRef.current = ''; currentResponseIdRef.current = assistantMessageId; @@ -408,7 +485,69 @@ function App() { workerRef.current.postMessage({ type: 'generate', payload: generatePayload }); }, - [status, isTyping] + [status, isTyping, updateActiveMessages] + ); + + // ---- Conversation management ------------------------------------------------ + + const handleNewConversation = useCallback(() => { + const conv = createConversation(); + setConversations((prev) => [conv, ...prev]); + setActiveId(conv.id); + }, []); + + const handleSelectConversation = useCallback((id: string) => { + setActiveId(id); + }, []); + + const handleDeleteConversation = useCallback((id: string) => { + setConversations((prev) => { + const next = prev.filter((c) => c.id !== id); + return next.length > 0 ? next : [createConversation()]; + }); + }, []); + + // ---- Export / import (.lino) ----------------------------------------------- + + const handleExport = useCallback(() => { + const text = serializeBundle(conversations); + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'model-in-browser-chats.lino'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }, [conversations]); + + const handleImportClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleImportFile = useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + try { + const text = await file.text(); + const imported = parseBundle(text); + if (imported.length === 0) { + setStatusText('Import failed: no conversations found in that file'); + return; + } + setConversations(imported); + setActiveId(imported[0].id); + setStatusText( + `Imported ${imported.length} conversation${imported.length === 1 ? '' : 's'}` + ); + } catch { + setStatusText('Import failed: could not read that file'); + } + }, + [] ); const getStatusIndicatorClass = () => { @@ -428,59 +567,142 @@ function App() { const loadedName = loadedEntryRef.current?.name ?? 'a model'; return ( - -
-
-

Models in Browser

-

- Small AI language models running entirely on your device — WebGPU - accelerated when available, WebAssembly otherwise -

- -
- - - -
-
- {statusText} - {status === 'error' && ( - +
    + {conversations.map((c) => ( +
  • + + +
  • + ))} +
+
+ +
+ +
+ +
+ +
+ +
+
+ + +
+ +

+ All chat data is stored in your browser as Links Notation (.lino). +

+
+ + +
+
+
+ {statusText} + {status === 'error' && ( + + )} +
+ + {progress && ( +
+
+
)} -
- {progress && ( -
-
+
- )} - -
- -
- -

- Running {loadedName} | No data sent to servers | All processing happens - locally -

+ +

+ Running {loadedName} | No data sent to servers | All processing + happens locally +

+
); diff --git a/web/src/components/ChatContainer.tsx b/web/src/components/ChatContainer.tsx index 75cce51..44e80b1 100644 --- a/web/src/components/ChatContainer.tsx +++ b/web/src/components/ChatContainer.tsx @@ -1,6 +1,8 @@ import { useChatProvider } from '../context/ChatProviderContext'; import { + FormalAiProvider, ChatscopeProvider, + DeepChatProvider, AssistantUIProvider, ReachatProvider, ReactChatElementsProvider, @@ -11,8 +13,12 @@ export function ChatContainer(props: ChatProviderProps) { const { provider } = useChatProvider(); switch (provider) { + case 'formal-ai': + return ; case 'chatscope': return ; + case 'deep-chat': + return ; case 'assistant-ui': return ; case 'reachat': @@ -20,6 +26,6 @@ export function ChatContainer(props: ChatProviderProps) { case 'react-chat-elements': return ; default: - return ; + return ; } } diff --git a/web/src/components/chat-providers/DeepChatProvider.tsx b/web/src/components/chat-providers/DeepChatProvider.tsx new file mode 100644 index 0000000..d657a32 --- /dev/null +++ b/web/src/components/chat-providers/DeepChatProvider.tsx @@ -0,0 +1,145 @@ +/** + * Deep Chat provider — wraps the `deep-chat-react` engine (one of the live chat + * engines showcased in https://github.com/link-assistant/react-chat-ui). + * + * Deep Chat owns its own message rendering and composer, so we bridge it to our + * worker-driven streaming model: + * + * - `history` seeds Deep Chat with the messages already in the active + * conversation (keyed on the first message id so switching conversations + * remounts it with fresh history). + * - A custom `connect.handler` intercepts each user submission, forwards the + * text to `onSendMessage`, and then streams the assistant's reply back into + * Deep Chat via the handler `signals` as our worker fills in the placeholder + * message. This keeps Deep Chat's bubbles in sync with the streamed tokens. + */ + +import { useCallback, useEffect, useRef, type FC } from 'react'; +import { DeepChat as DeepChatReact } from 'deep-chat-react'; +import type { ChatProviderProps } from '../../types/chat'; + +// The `deep-chat-react` wrapper only strongly types its DOM events, so we cast +// it to a permissive component to pass Deep Chat's element properties (connect, +// history, messageStyles, …) without fighting the wrapper's narrow prop type. +const DeepChat = DeepChatReact as unknown as FC>; + +interface DeepChatSignals { + onResponse: (response: { text?: string; error?: string }) => Promise | void; + onOpen?: () => void; + onClose?: () => void; +} +interface DeepChatBody { + messages?: Array<{ role?: string; text?: string }>; +} + +export function DeepChatProvider({ + messages, + isTyping, + isDisabled, + onSendMessage, +}: ChatProviderProps) { + // Refs let the (stable) handler and the streaming effect coordinate without + // re-creating Deep Chat's connect object on every render. + const signalsRef = useRef(null); + const sentLenRef = useRef(0); + const startedRef = useRef(false); + const isDisabledRef = useRef(isDisabled); + const isTypingRef = useRef(isTyping); + + useEffect(() => { + isDisabledRef.current = isDisabled; + }, [isDisabled]); + useEffect(() => { + isTypingRef.current = isTyping; + }, [isTyping]); + + // Stream the worker's tokens into Deep Chat. Each time the active assistant + // message grows we forward the delta; when generation finishes we close. + useEffect(() => { + const signals = signalsRef.current; + if (!signals) return; + if (isTyping) startedRef.current = true; + + let lastAssistant = ''; + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (messages[i].sender === 'assistant') { + lastAssistant = messages[i].content; + break; + } + } + if (lastAssistant.length > sentLenRef.current) { + const delta = lastAssistant.slice(sentLenRef.current); + sentLenRef.current = lastAssistant.length; + void signals.onResponse({ text: delta }); + } + + if (startedRef.current && !isTyping) { + signals.onClose?.(); + signalsRef.current = null; + sentLenRef.current = 0; + startedRef.current = false; + } + }, [messages, isTyping]); + + const handler = useCallback( + (body: DeepChatBody, signals: DeepChatSignals) => { + const list = body?.messages ?? []; + const last = list[list.length - 1]; + const text = (typeof last?.text === 'string' ? last.text : '').trim(); + if (!text) { + signals.onClose?.(); + return; + } + if (isDisabledRef.current || isTypingRef.current) { + void signals.onResponse({ + text: 'The model is still loading — please wait a moment and try again.', + }); + signals.onClose?.(); + return; + } + signalsRef.current = signals; + sentLenRef.current = 0; + startedRef.current = false; + onSendMessage(text); + }, + [onSendMessage] + ); + + // Seed Deep Chat with the conversation so far. Remount (via key) when the + // conversation changes so its internal state is replaced, not appended to. + const history = messages.map((m) => ({ + role: m.sender === 'user' ? 'user' : 'ai', + text: m.content, + })); + const mountKey = messages[0]?.id ?? 'empty'; + + return ( +
+ +
+ ); +} diff --git a/web/src/components/chat-providers/FormalAiProvider.tsx b/web/src/components/chat-providers/FormalAiProvider.tsx new file mode 100644 index 0000000..c775c8f --- /dev/null +++ b/web/src/components/chat-providers/FormalAiProvider.tsx @@ -0,0 +1,167 @@ +/** + * Formal-AI–style chat surface — the default, built-in provider. + * + * This is a small, dependency-free chat UI that mirrors the look and feel of the + * Formal-AI web app (https://github.com/link-assistant/formal-ai): a scrolling + * message list with avatars, author + timestamp meta rows, per-message "copy" + * buttons, markdown rendering with syntax-highlighted code, a "Thinking…" pending + * indicator, and an auto-growing composer that sends on Enter (Shift+Enter inserts + * a newline). It renders purely from the external `messages` array so it works + * with the streaming worker the same way every other provider does. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { MarkdownRenderer } from '../MarkdownRenderer'; +import type { ChatProviderProps, ChatMessage } from '../../types/chat'; + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + const onCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } catch { + /* clipboard may be unavailable (e.g. insecure context) — ignore */ + } + }, [text]); + return ( + + ); +} + +function MessageBubble({ message }: { message: ChatMessage }) { + const isUser = message.sender === 'user'; + return ( +
+ +
+
+ + {isUser ? 'You' : 'Assistant'} + + + {message.content.trim().length > 0 && ( + + )} +
+ +
+
+ ); +} + +export function FormalAiProvider({ + messages, + isTyping, + isDisabled, + onSendMessage, +}: ChatProviderProps) { + const [value, setValue] = useState(''); + const textareaRef = useRef(null); + const listEndRef = useRef(null); + + // Auto-grow the textarea up to a max height as the user types. + const resize = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, 200)}px`; + }, []); + + useEffect(() => { + resize(); + }, [value, resize]); + + // Keep the latest message in view as the conversation grows / streams. + useEffect(() => { + listEndRef.current?.scrollIntoView({ block: 'end' }); + }, [messages, isTyping]); + + const send = useCallback(() => { + const text = value.trim(); + if (!text || isDisabled || isTyping) return; + onSendMessage(text); + setValue(''); + }, [value, isDisabled, isTyping, onSendMessage]); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + }, + [send] + ); + + return ( +
+
+ {messages.map((msg) => ( + + ))} + {isTyping && ( +
+ + + + Thinking... +
+ )} +
+
+ +
{ + e.preventDefault(); + send(); + }} + > +