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

77 lines
3.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { GuiProvider } from '@hanzo/gui';
import guiConfig from '@hanzo/ui/gui-config';
import { Toolbar, MODES, SOURCES, type Mode } from '../src/answer/Toolbar.js';
/**
* The answer surface offers FOUR modes: Chat, Search, News, Research.
*
* There was a fifth, Deep. It was research with the dials up — same system
* prompt, same models, same plan gate, only wider bounds (6/16/6 at 10c vs
* 4/12/4 at 5c) — so the tab offered a choice that was really an intensity and
* hid a 2x price behind an adjective. Cloud collapsed the two into one research
* mode (hanzoai/cloud apps/answer/mode.go); this is the client half of that.
*
* Rendered, not read: the tabs and the source pills are what a user actually
* gets, so the assertions run against the markup they emit. They are what is
* left local now that `@hanzo/ui/chat` owns the composer — the field, the Enter
* rule and the send control are the shell's and are asserted there.
*
* Selected by `data-mode` / `data-source` / `data-on`, not by class name: the
* controls are `@hanzo/ui` buttons whose classes belong to the design system and
* change with it, while these attributes are this file's own contract.
*/
const props = (mode: Mode) => ({
mode,
setMode: () => {},
sources: new Set<string>(),
toggleSource: () => {},
});
// The toolbar renders REAL @hanzo/ui, so it mounts the way every consumer must:
// under GuiProvider — outside it, a gui component throws on first paint.
const draw = (mode: Mode) =>
renderToStaticMarkup(
React.createElement(
GuiProvider,
{ config: guiConfig, defaultTheme: 'dark' },
React.createElement(Toolbar, props(mode)),
),
);
/** The value of `attr` on every element carrying it, in document order. */
const attrs = (html: string, attr: string) =>
[...html.matchAll(new RegExp(`${attr}="([^"]+)"`, 'g'))].map((m) => m[1]);
/** The value of `attr` on the elements that are also marked `data-on`. */
const on = (html: string, attr: string) =>
[...html.matchAll(new RegExp(`<[^>]*${attr}="([^"]+)"[^>]*data-on="true"`, 'g'))].map((m) => m[1]);
describe('answer modes', () => {
it('names four, in escalating effort, with no Deep', () => {
expect(MODES.map((m) => m.id)).toEqual(['chat', 'search', 'news', 'research']);
expect(attrs(draw('search'), 'data-mode')).toEqual(['chat', 'search', 'news', 'research']);
});
it('marks exactly the selected tab, for every mode it offers', () => {
for (const m of MODES) {
expect(on(draw(m.id), 'data-mode'), m.id).toEqual([m.id]);
}
});
it('offers the six sources to ground a web answer, and none to Chat', () => {
expect(attrs(draw('search'), 'data-source')).toEqual(SOURCES);
// Chat goes straight to the model, so there is nothing to source from —
// the row itself stays (dropping it would slide the toolbar left).
expect(attrs(draw('chat'), 'data-source')).toEqual([]);
expect(draw('chat')).toContain('data-slot="sources"');
});
it('leaves no path back to the retired mode', () => {
expect(JSON.stringify(MODES)).not.toMatch(/deep/i);
});
});