Files
zeekayandHanzo Dev 668e2d9d55 The sidebar chat holds its draft again
efc979d4 took draft and onDraft out of the props sidebar.ts passes to
Chat, but Chat.tsx still handed props.draft to the Composer. The Composer
is controlled and calls value.trim() on every render, so every signed-in
sidebar threw "Cannot read properties of undefined (reading 'trim')" on
its first paint and drew nothing. e2e/turn.spec.ts failed at 8214f212 and
passed on 1.9.56.

Chat now keeps the draft in state, as @hanzo/ui/chat's own Chat does. On
send it empties the box and hands the trimmed text to onSend, which is the
call sidebar.ts already makes: onSend(text), and retry sends the old
question without touching what is being typed. Rows key on the id
sidebar.ts mints, which is what that id was for.

tests/sidebar-chat.test.ts mounts Chat with the sidebar's props. Against
the old Chat.tsx both cases fail with the same TypeError.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 08:39:18 -07:00

94 lines
3.0 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { GuiProvider } from '@hanzo/gui';
import guiConfig from '@hanzo/ui/gui-config';
import { Chat, type ChatProps } from '../src/Chat.js';
/**
* The sidebar's chat, mounted with the props `sidebar.ts` hands it.
*
* The draft lives in Chat, not in the sidebar. When the sidebar stopped
* passing one and Chat still read it from props, the controlled Composer got
* `undefined`, threw on `value.trim()` during the first render, and every
* signed-in sidebar was blank. Nothing type-checks sidebar.ts, so this mounts
* the real component the way the sidebar does.
*/
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/** What sidebar.ts's paint() passes, less the callbacks a test replaces. */
const sidebar = (over: Partial<ChatProps> = {}): ChatProps => ({
turns: [],
onSend: () => {},
onStop: () => {},
onRetry: () => {},
busy: false,
models: [{ id: 'auto', label: 'Auto', family: 'Router', description: 'Routes to the best model', modality: 'chat' }],
model: 'auto',
onModel: () => {},
rag: true,
onRag: () => {},
tab: true,
onTab: () => {},
status: 'Ready',
signedIn: true,
onSignIn: () => {},
onSignUp: () => {},
...over,
});
let root: Root | undefined;
function mount(props: ChatProps): HTMLElement {
const host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
act(() => {
root!.render(
React.createElement(GuiProvider, { config: guiConfig, defaultTheme: 'dark' }, React.createElement(Chat, props)),
);
});
return host;
}
/** Type the way a person does: React reads a field's value off its input event. */
function type(field: HTMLTextAreaElement, text: string) {
const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!;
act(() => {
set.call(field, text);
field.dispatchEvent(new Event('input', { bubbles: true }));
});
}
afterEach(() => {
act(() => root?.unmount());
root = undefined;
document.body.replaceChildren();
});
describe('the sidebar chat', () => {
it('draws the composer for a signed-in user', () => {
const host = mount(sidebar());
expect(host.querySelector('[data-slot="composer"]')).not.toBeNull();
expect(host.querySelector<HTMLTextAreaElement>('[data-slot="composer-field"]')!.value).toBe('');
});
it('hands the trimmed draft to onSend and empties the box', () => {
const onSend = vi.fn();
const host = mount(sidebar({ onSend }));
const field = host.querySelector<HTMLTextAreaElement>('[data-slot="composer-field"]')!;
type(field, ' Say pong. ');
expect(field.value).toBe(' Say pong. ');
act(() => {
field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
});
expect(onSend).toHaveBeenCalledTimes(1);
expect(onSend).toHaveBeenCalledWith('Say pong.');
expect(field.value).toBe('');
});
});