feat(web): add typed SPA chat
Bring the chat experience into the Vite/React-Router SPA on the exact typed Socket.IO /chat contract from @mosaicstack/types, replacing the /chat placeholder behind AuthGuard. Surfaces message:ack (with an accessible status), agent:start, streamed agent:text/agent:thinking, tool start/end status, agent:end with usage, session:info (thinking controls + routing decision), commands:manifest, command:result, command:approval (with a one-time approved-run affordance), system:reload (refreshing the rendered manifest), and error, and emits message/abort/set:thinking/command:execute/ command:approve with exact payloads. The gateway does not guarantee message:ack is the first event for a new conversation (session:info, and error on auth/session-creation failure, can both arrive first) — conversation-scoped events now adopt the conversation from whichever scoped event names it first while a send is pending, then filter everything else against that established conversation. A typed error stops streaming instead of leaving Stop stuck active; agent:end no longer appends an empty assistant turn when there is no text or thinking; and a second message can no longer be sent while a turn is streaming. Command approval is now integrity-checked end to end: only one command:approve request may be outstanding at a time (a concurrent request is ignored rather than overwriting the pending command/args), a stale or mismatched command:approval response cannot replace active approval state, and running an approved command clears its approval state immediately (via a ref, before React re-renders) so a double-click cannot replay command:execute. The `/chat` socket is now typed at a single boundary: apps/web/src/lib/ socket.ts narrows socket.io-client's untyped `io()` return value to `ChatSocket` (Socket<ServerToClientEvents, ClientToServerEvents>) once, at creation, via the one assertion the library's types force; every consumer (use-chat-connection.ts) then gets fully checked `on`/`emit` calls with no further casts. The shared contract types live in the new apps/web/src/lib/chat-contract.ts (replacing the old spa/chat/types.ts shim), which re-exports them via type-only imports resolved directly against packages/types/src (apps/web has no @mosaicstack/types package dependency, so this stays source-only and is erased at compile time — no package manifest or lockfile is touched). The two recorded-event test suites now drive a shared, typed fake socket (spa/chat/test-support/fake-chat-socket.ts) instead of an untyped `(event: string, payload: unknown)` harness, so a wrong event name or malformed payload fails to compile.
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket';
|
||||
|
||||
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
||||
getSocketMock: vi.fn(),
|
||||
destroySocketMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/socket', () => ({
|
||||
getSocket: getSocketMock,
|
||||
destroySocket: destroySocketMock,
|
||||
}));
|
||||
|
||||
import { ChatPage } from './chat';
|
||||
|
||||
function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
||||
const proto =
|
||||
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||||
setter?.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function selectValue(el: HTMLSelectElement, value: string): void {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
||||
setter?.call(el, value);
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) throw new Error(`Button with text "${text}" not found`);
|
||||
return button;
|
||||
}
|
||||
|
||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||
let root: Root | null;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
fake = createFakeChatSocket();
|
||||
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
||||
destroySocketMock.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<ChatPage />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe('ChatPage', () => {
|
||||
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
});
|
||||
await act(async () => {
|
||||
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'pondering…' });
|
||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
|
||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo!' });
|
||||
fake.serverEmit('agent:tool:start', {
|
||||
conversationId: 'c1',
|
||||
toolCallId: 't1',
|
||||
toolName: 'web_search',
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('pondering…');
|
||||
expect(container.textContent).toContain('Hello!');
|
||||
expect(container.textContent).toContain('web_search');
|
||||
expect(container.textContent).toMatch(/running/i);
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('agent:tool:end', {
|
||||
conversationId: 'c1',
|
||||
toolCallId: 't1',
|
||||
toolName: 'web_search',
|
||||
isError: false,
|
||||
});
|
||||
fake.serverEmit('agent:end', {
|
||||
conversationId: 'c1',
|
||||
usage: {
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'medium',
|
||||
tokens: { input: 12, output: 34, cacheRead: 0, cacheWrite: 0, total: 46 },
|
||||
cost: 0.02,
|
||||
context: { percent: 3, window: 200000 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toMatch(/success/i);
|
||||
expect(container.textContent).toContain('Hello!');
|
||||
expect(container.textContent).toMatch(/46/);
|
||||
});
|
||||
|
||||
it('renders the commands manifest and session info, and lets the user pick a thinking level', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('commands:manifest', {
|
||||
manifest: {
|
||||
commands: [
|
||||
{
|
||||
name: 'model',
|
||||
aliases: ['m'],
|
||||
description: 'Change the active model',
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
fake.serverEmit('session:info', {
|
||||
conversationId: 'c1',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'medium',
|
||||
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||
routingDecision: {
|
||||
model: 'claude',
|
||||
provider: 'anthropic',
|
||||
ruleName: 'default',
|
||||
reason: 'default routing',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('model');
|
||||
expect(container.textContent).toContain('Change the active model');
|
||||
expect(container.textContent).toContain('anthropic');
|
||||
expect(container.textContent).toContain('default routing');
|
||||
|
||||
const select = container.querySelector(
|
||||
'select[aria-label="Thinking level"]',
|
||||
) as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
expect([...select.options].map((o) => o.value)).toEqual(['low', 'medium', 'high']);
|
||||
|
||||
await act(async () => {
|
||||
selectValue(select, 'high');
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'set:thinking',
|
||||
payload: { conversationId: 'c1', level: 'high' },
|
||||
});
|
||||
});
|
||||
|
||||
it('executes and approves commands with exact payloads and surfaces the approval affordance', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
const commandInput = container.querySelector(
|
||||
'input[aria-label="Command name"]',
|
||||
) as HTMLInputElement;
|
||||
const argsInput = container.querySelector(
|
||||
'input[aria-label="Command arguments"]',
|
||||
) as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(commandInput, 'model');
|
||||
setValue(argsInput, 'gpt-5');
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Run command').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:execute',
|
||||
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
setValue(commandInput, 'deploy');
|
||||
setValue(argsInput, 'prod');
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Request approval').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:approve',
|
||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('command:approval', {
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toMatch(/approved/i);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Run approved command').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:execute',
|
||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows visible alert surfaces for a server error and a failed command result', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
|
||||
fake.serverEmit('command:result', {
|
||||
conversationId: 'c1',
|
||||
command: 'model',
|
||||
success: false,
|
||||
message: 'unknown model id',
|
||||
});
|
||||
});
|
||||
|
||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||
const alertText = alerts.map((node) => node.textContent).join(' ');
|
||||
expect(alertText).toContain('The model is unavailable');
|
||||
expect(alertText).toContain('unknown model id');
|
||||
});
|
||||
|
||||
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
const providerInput = container.querySelector(
|
||||
'input[aria-label="Provider"]',
|
||||
) as HTMLInputElement;
|
||||
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
|
||||
|
||||
const stopButtonBefore = container.querySelector(
|
||||
'button[aria-label="Stop"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(stopButtonBefore.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'hello there');
|
||||
setValue(providerInput, 'anthropic');
|
||||
setValue(modelInput, 'claude');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: {
|
||||
conversationId: undefined,
|
||||
content: 'hello there',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
},
|
||||
});
|
||||
expect(container.textContent).toContain('hello there');
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
});
|
||||
|
||||
const stopButtonDuring = container.querySelector(
|
||||
'button[aria-label="Stop"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(stopButtonDuring.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
stopButtonDuring.click();
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
||||
});
|
||||
|
||||
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'hello');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.serverEmit('session:info', {
|
||||
conversationId: 'c1',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'medium',
|
||||
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('anthropic');
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('anthropic');
|
||||
});
|
||||
|
||||
it('surfaces a pre-ack error as an alert without leaving the Stop control stuck active', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'hello');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.serverEmit('error', {
|
||||
conversationId: 'c1',
|
||||
error: 'Failed to start agent session. Please try again.',
|
||||
});
|
||||
});
|
||||
|
||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||
expect(alerts.some((node) => node.textContent?.includes('Failed to start agent session'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const stopButton = container.querySelector('button[aria-label="Stop"]') as HTMLButtonElement;
|
||||
expect(stopButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows an accessible status once the message is acknowledged', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
const statuses = [...container.querySelectorAll('[role="status"]')];
|
||||
expect(statuses.some((node) => node.textContent?.includes('m1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('renders finalized thinking text in the transcript after agent:end, not only while streaming', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'reasoning about it' });
|
||||
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Done.' });
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('reasoning about it');
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('reasoning about it');
|
||||
expect(container.textContent).toContain('Done.');
|
||||
});
|
||||
|
||||
it('ignores a concurrent approval request and only executes the approved command once', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
const commandInput = container.querySelector(
|
||||
'input[aria-label="Command name"]',
|
||||
) as HTMLInputElement;
|
||||
const argsInput = container.querySelector(
|
||||
'input[aria-label="Command arguments"]',
|
||||
) as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(commandInput, 'deploy');
|
||||
setValue(argsInput, 'prod');
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Request approval').click();
|
||||
});
|
||||
await act(async () => {
|
||||
setValue(argsInput, 'staging');
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Request approval').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:approve',
|
||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('command:approval', {
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Run approved command').click();
|
||||
findButton(container, 'Run approved command').click();
|
||||
});
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'command:execute',
|
||||
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('disables sending a second message while a turn is streaming', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'first');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
});
|
||||
|
||||
const sendButton = findButton(container, 'Send');
|
||||
expect(sendButton.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'second');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
||||
expect(fake.listeners.size).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
|
||||
for (const [, handlers] of fake.listeners) {
|
||||
expect(handlers.size).toBe(0);
|
||||
}
|
||||
expect(destroySocketMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { CommandsPanel } from '@/spa/chat/commands-panel';
|
||||
import { Composer } from '@/spa/chat/composer';
|
||||
import { MessageTranscript } from '@/spa/chat/message-transcript';
|
||||
import { SessionPanel } from '@/spa/chat/session-panel';
|
||||
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
||||
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
||||
|
||||
export function ChatPage(): ReactElement {
|
||||
const { state, actions } = useChatConnection();
|
||||
const hasConversation = state.conversationId !== null;
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] min-h-0 flex-col overflow-hidden md:h-screen">
|
||||
<header className="border-b px-4 py-3">
|
||||
<h1 className="text-lg font-semibold">Chat</h1>
|
||||
</header>
|
||||
|
||||
{state.systemReload ? (
|
||||
<div role="status" className="border-b px-4 py-2 text-sm">
|
||||
{state.systemReload.message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.error ? (
|
||||
<div role="alert" className="border-b px-4 py-2 text-sm">
|
||||
{state.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.ack ? (
|
||||
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
|
||||
Message accepted · conversation {state.ack.conversationId} · id {state.ack.messageId}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SessionPanel sessionInfo={state.sessionInfo} onSetThinking={actions.setThinking} />
|
||||
|
||||
<MessageTranscript messages={state.messages} streaming={state.streaming} text={state.text} />
|
||||
|
||||
{state.thinking ? (
|
||||
<section aria-label="Thinking" className="px-4 pb-2 text-xs italic opacity-80">
|
||||
{state.thinking}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<ToolCallList tools={state.tools} />
|
||||
|
||||
{state.usage ? (
|
||||
<div aria-label="Usage" className="px-4 pb-2 text-xs opacity-80">
|
||||
{state.usage.tokens.total} tokens · ${state.usage.cost.toFixed(4)} ·{' '}
|
||||
{state.usage.provider}/{state.usage.modelId}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CommandsPanel
|
||||
manifest={state.manifest}
|
||||
results={state.commandResults}
|
||||
approval={state.approval}
|
||||
pendingApproval={state.pendingApproval}
|
||||
hasConversation={hasConversation}
|
||||
onExecute={actions.executeCommand}
|
||||
onApprove={actions.approveCommand}
|
||||
onRunApproved={actions.runApprovedCommand}
|
||||
/>
|
||||
|
||||
<Composer
|
||||
onSend={actions.sendMessage}
|
||||
onStop={actions.abort}
|
||||
streaming={state.streaming}
|
||||
hasConversation={hasConversation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user