Files
zeekay 10b3d55703 extension: chat is @hanzo/ui/chat on both surfaces, and nothing else
The sidebar drew chat with hand-written markup in sidebar.html and ~200 lines of
DOM in sidebar.ts — its own composer, its own five-regex markdown, its own
scroll-to-bottom, its own typing dots. The new tab drew it with a second
composer, a second model menu and a second citation chip. Both are now the
shared shell: Thread, Message, Composer, Sources, Code, Step and Failure from
@hanzo/ui/chat, and ModelSelector from @hanzo/ui/models.

sidebar.html declares one mount node and no chat control at all, so there is
nothing for a second implementation to render over — the stronger form of the
invariant sidebar-composer.test.ts was written to hold after chat-widget.tsx
detached every cached handle. sidebar.ts keeps the whole wire (auth, retrieval,
budgets, dedupe, the stream, persistence) and passes values and callbacks.

Deleted: answer/Composer.tsx, answer/Models.tsx, answer/Source.tsx, the sidebar's
private markdown, and ~700 lines of CSS for controls the shell now draws. The
two markdown renderers become one shared/markdown.ts, which also splits fenced
code out so it renders through the shell's Code with its copy control. Failure
carries a retry, and retrying clears the dedupe key — a retry is by definition
the request that just ran, so without that the control does nothing at all.

The rail stays local. The shell's Sidebar has no collapsed presentation and its
SidebarIconButton takes only a label, so it cannot carry the aria-expanded that
answer-rail.test.ts asserts; a rail that cannot announce its own state is worse
than one that is not the shell's.

Three shell shapes had to be worked around, all found by driving the built
extension rather than a jsdom render: ModelSelector's trigger is width:100% and
pushes the send control off a narrow row; Thread's gutters are coupled to its
reading-measure cap, so opting out of the cap removes them; the Composer toolbar
row does not wrap for its children.

The esbuild config that makes gui resolve for the web is now stated once instead
of per-entry — the sidebar built without it and died on Flow syntax inside
react-native, an error naming neither the alias nor either surface. The live-IAM
e2e reads its password from the environment rather than from a line in the repo.
2026-08-21 12:03:51 -07:00

78 lines
3.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
/**
* The sidebar composer has ONE implementation, and it is `@hanzo/ui/chat`.
*
* A second React implementation (chat-widget.tsx) used to mount over hand-written
* markup in sidebar.html by clearing `#chat-composer` and `#chat-login-prompt`
* and re-rendering. Because sidebar.ts cached `#chat-input` / `#send-btn` /
* `#model-select` in initializeUI() and bound its listeners to those nodes, the
* re-render left every handle pointing at a detached node: the user typed into
* the live field and the sidebar read the empty detached one, so Send fired zero
* times and the model list reverted to five hardcoded ids over the catalog.
*
* WHAT THESE ASSERTIONS NOW SAY, AND WHY THEY CHANGED. They used to require the
* markup to declare exactly one of each control — the best available guarantee
* while the composer WAS markup. It no longer is: chat is the shared shell,
* mounted at a single node, and sidebar.html declares no chat control at all.
* So the same invariant is asserted at its stronger end — there is nothing for
* a second implementation to render over, because there is no first one in the
* markup — and the old ids would now pass by being absent, which asserts
* nothing. Everything the shell owns (the field, Enter, send/stop) is asserted
* in @hanzo/ui; what stays here is that this page keeps no copy of it.
*/
const SRC = join(__dirname, '..', 'src');
function sources(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) sources(path, out);
else if (/\.tsx?$/.test(name)) out.push(path);
}
return out;
}
describe('the sidebar composer is singular', () => {
const html = readFileSync(join(SRC, 'sidebar.html'), 'utf8');
const { document } = new JSDOM(html).window;
it('declares one mount node for chat and nothing else inside the tab', () => {
expect(document.querySelectorAll('#chat')).toHaveLength(1);
const tab = document.getElementById('tab-chat')!;
expect([...tab.children].map((el) => el.id)).toEqual(['chat']);
expect(document.getElementById('chat')!.children).toHaveLength(0);
});
it('writes no composer of its own — no field, no send control, no picker', () => {
expect(document.querySelectorAll('textarea')).toHaveLength(0);
for (const id of ['chat-composer', 'chat-input', 'send-btn', 'model-select', 'chat-login-prompt']) {
expect(document.querySelectorAll(`#${id}`), id).toHaveLength(0);
}
});
it('gives every element a unique id, so a cached handle stays the live one', () => {
const ids = [...document.querySelectorAll('[id]')].map((el) => el.id);
expect(ids.filter((id, i) => ids.indexOf(id) !== i)).toEqual([]);
});
it('is mounted by sidebar.ts alone — no second module can render over it', () => {
const owners = sources(SRC)
.filter((path) => /getElementById\('chat'\)/.test(readFileSync(path, 'utf8')))
.map((path) => path.slice(SRC.length + 1));
expect(owners).toEqual(['sidebar.ts']);
});
it('offers no model the catalog did not supply — Auto is written in one place', () => {
// Neither picker is markup: the chat one is ModelSelector, the Settings one
// is filled by setModels(). A hand-written <option> here would be a model
// offered without the account having been asked for it.
expect(document.querySelectorAll('select option')).toHaveLength(0);
const ts = readFileSync(join(SRC, 'sidebar.ts'), 'utf8');
expect(ts.match(/id: 'auto'/g)).toHaveLength(1);
});
});