mirror of
https://github.com/hanzoai/extension.git
synced 2026-10-31 21:57:06 +00:00
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.
77 lines
3.2 KiB
TypeScript
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);
|
|
});
|
|
});
|