refactor: replace any types with proper TypeScript types across gateway client, hooks, and components

- Add GatewayMessage and JsonPayload interfaces to gateway.ts
- Type WebSocket message parsing with GatewayMessage instead of any
- Use ReturnType<typeof setTimeout> for timer refs
- Type chat event payload destructuring explicitly
- Add ChatPayloadMessage interface for extractText()
- Replace any casts in loadSessions/loadHistory with typed assertions
- Add str() helper in ToolCall.tsx for safe unknown→string extraction
- Use Extract<MessageBlock, ...> type guards instead of `as any` casts
- Type CodeBlock children prop properly
This commit is contained in:
Nicolas Varrot
2026-02-11 21:17:44 +00:00
parent d724a8ca0b
commit 693229c14e
6 changed files with 91 additions and 57 deletions

View File

@@ -148,7 +148,7 @@ function renderTextBlocks(blocks: MessageBlock[]) {
return getTextBlocks(blocks).map((block, i) => (
<div key={`text-${i}`} className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeHighlight]} components={markdownComponents}>
{autoFormatText((block as any).text)}
{autoFormatText((block as Extract<MessageBlock, { type: 'text' }>).text)}
</ReactMarkdown>
</div>
));
@@ -238,7 +238,7 @@ function CopyButton({ text }: { text: string }) {
/** Extract plain text from message blocks for clipboard copy */
function getPlainText(message: ChatMessageType): string {
if (message.blocks.length > 0) {
return getTextBlocks(message.blocks).map(b => (b as any).text).join('\n\n');
return getTextBlocks(message.blocks).map(b => (b as Extract<MessageBlock, { type: 'text' }>).text).join('\n\n');
}
return message.content;
}

View File

@@ -10,7 +10,7 @@ export function CodeBlock(props: HTMLAttributes<HTMLPreElement>) {
const handleCopy = useCallback(() => {
// Extract text from the nested <code> element
const code = (props.children as any)?.props?.children;
const code = (props.children as React.ReactElement<{ children?: string }> | undefined)?.props?.children;
if (typeof code === 'string') {
navigator.clipboard.writeText(code).then(() => {
setCopied(true);

View File

@@ -122,33 +122,40 @@ export function HighlightedPre({ text, className }: { text: string; className: s
return <pre className={className}>{text}</pre>;
}
function getContextHint(name: string, input: any): string | null {
function str(v: unknown): string | null {
return typeof v === 'string' ? v : null;
}
function getContextHint(name: string, input: Record<string, unknown> | undefined): string | null {
if (!input || typeof input !== 'object') return null;
switch (name) {
case 'exec':
return input.command ? truncate(input.command, 60) : null;
return str(input.command) ? truncate(str(input.command)!, 60) : null;
case 'Read': case 'read':
case 'Write': case 'write':
case 'Edit': case 'edit':
return input.file_path || input.path || null;
return str(input.file_path) || str(input.path) || null;
case 'web_search':
return input.query ? truncate(input.query, 50) : null;
return str(input.query) ? truncate(str(input.query)!, 50) : null;
case 'web_fetch':
return input.url ? truncate(input.url, 60) : null;
return str(input.url) ? truncate(str(input.url)!, 60) : null;
case 'browser':
return input.action || null;
case 'message':
return input.action ? `${input.action}${input.target ? ' → ' + input.target : ''}` : null;
return str(input.action) || null;
case 'message': {
const action = str(input.action);
const target = str(input.target);
return action ? `${action}${target ? ' → ' + target : ''}` : null;
}
case 'memory_search':
return input.query ? truncate(input.query, 50) : null;
return str(input.query) ? truncate(str(input.query)!, 50) : null;
case 'memory_get':
return input.path || null;
return str(input.path) || null;
case 'cron':
return input.action || null;
return str(input.action) || null;
case 'sessions_spawn':
return input.task ? truncate(input.task, 50) : null;
return str(input.task) ? truncate(str(input.task)!, 50) : null;
case 'image':
return input.prompt ? truncate(input.prompt, 50) : null;
return str(input.prompt) ? truncate(str(input.prompt)!, 50) : null;
default:
return null;
}
@@ -184,7 +191,7 @@ function extractImageFromResult(result: string): { src: string; remaining: strin
return null;
}
export function ToolCall({ name, input, result }: { name: string; input?: any; result?: string }) {
export function ToolCall({ name, input, result }: { name: string; input?: Record<string, unknown>; result?: string }) {
const t = useT();
const [open, setOpen] = useState(false);
const c = getColor(name);

View File

@@ -1,17 +1,21 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { GatewayClient } from '../lib/gateway';
import { GatewayClient, type JsonPayload } from '../lib/gateway';
import { genIdempotencyKey } from '../lib/utils';
import { getStoredCredentials, storeCredentials, clearCredentials } from '../components/LoginScreen';
import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
function extractText(message: any): string {
interface ChatPayloadMessage {
content?: string | Array<{ type: string; text?: string }>;
}
function extractText(message: ChatPayloadMessage | undefined): string {
if (!message) return '';
const content = message.content;
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter((b: any) => b.type === 'text' && typeof b.text === 'string')
.map((b: any) => b.text)
.filter((b) => b.type === 'text' && typeof b.text === 'string')
.map((b) => b.text as string)
.join('\n');
}
return '';
@@ -72,7 +76,11 @@ export function useGateway() {
}
if (event !== 'chat') return;
const { state, runId, message, errorMessage, sessionKey: evtSession } = payload;
const state = payload.state as string | undefined;
const runId = payload.runId as string;
const message = payload.message as ChatPayloadMessage | undefined;
const errorMessage = payload.errorMessage as string | undefined;
const evtSession = payload.sessionKey as string | undefined;
if (evtSession) {
if (state === 'delta') {
@@ -167,12 +175,12 @@ export function useGateway() {
}
}, [setupClient]);
const handleAgentEvent = useCallback((payload: any) => {
const handleAgentEvent = useCallback((payload: JsonPayload) => {
if (payload?.stream !== 'tool') return;
const data = payload.data ?? {};
const phase = data.phase;
const toolCallId = data.toolCallId;
const name = data.name || 'tool';
const data = (payload.data ?? {}) as Record<string, unknown>;
const phase = data.phase as string | undefined;
const toolCallId = data.toolCallId as string | undefined;
const name = (data.name as string) || 'tool';
if (!toolCallId) return;
setMessages(prev => {
@@ -185,11 +193,12 @@ export function useGateway() {
updated.blocks.push({
type: 'tool_use' as const,
name,
input: data.args,
input: (data.args as Record<string, unknown>) ?? {},
id: toolCallId,
});
} else if (phase === 'result') {
const result = typeof data.result === 'string' ? data.result : JSON.stringify(data.result, null, 2);
const rawResult = data.result;
const result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult, null, 2);
updated.blocks.push({
type: 'tool_result' as const,
content: result?.slice(0, 500) || '',
@@ -205,15 +214,16 @@ export function useGateway() {
const loadSessions = useCallback(async () => {
try {
const res = await clientRef.current?.send('sessions.list', {});
if (res?.sessions) {
setSessions(res.sessions.map((s: any) => ({
key: s.key || s.sessionKey,
label: s.label || s.key || s.sessionKey,
messageCount: s.messageCount,
totalTokens: s.totalTokens,
contextTokens: s.contextTokens,
inputTokens: s.inputTokens,
outputTokens: s.outputTokens,
const sessionList = res?.sessions as Array<Record<string, unknown>> | undefined;
if (sessionList) {
setSessions(sessionList.map((s) => ({
key: (s.key || s.sessionKey) as string,
label: (s.label || s.key || s.sessionKey) as string,
messageCount: s.messageCount as number | undefined,
totalTokens: s.totalTokens as number | undefined,
contextTokens: s.contextTokens as number | undefined,
inputTokens: s.inputTokens as number | undefined,
outputTokens: s.outputTokens as number | undefined,
})));
}
} catch {}
@@ -222,9 +232,10 @@ export function useGateway() {
const loadHistory = useCallback(async (sessionKey: string) => {
try {
const res = await clientRef.current?.send('chat.history', { sessionKey, limit: 100 });
if (res?.messages) {
const rawMsgs: any[] = res.messages;
const msgs: ChatMessage[] = rawMsgs.map((m: any, i: number) => {
const rawMsgs = res?.messages as Array<Record<string, unknown>> | undefined;
if (rawMsgs) {
/* eslint-disable @typescript-eslint/no-explicit-any -- raw gateway history messages have dynamic shape */
const msgs: ChatMessage[] = rawMsgs.map((m: Record<string, any>, i: number) => {
const blocks: MessageBlock[] = [];
if (m.content) {
if (Array.isArray(m.content)) {
@@ -269,19 +280,20 @@ export function useGateway() {
return {
id: m.id || `hist-${i}`,
role,
content: blocks.filter(b => b.type === 'text').map(b => (b as any).text).join(''),
content: blocks.filter((b): b is Extract<MessageBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join(''),
timestamp: m.timestamp || Date.now(),
blocks,
};
});
const merged: ChatMessage[] = [];
for (const msg of msgs) {
if ((msg as any).isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') {
const isToolResult = 'isToolResult' in msg && (msg as ChatMessage & { isToolResult?: boolean }).isToolResult;
if (isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') {
merged[merged.length - 1] = {
...merged[merged.length - 1],
blocks: [...merged[merged.length - 1].blocks, ...msg.blocks],
};
} else if ((msg as any).isToolResult) {
} else if (isToolResult) {
// skip orphan
} else {
merged.push(msg);

View File

@@ -1,14 +1,29 @@
import { genId } from './utils';
export type GatewayEventHandler = (event: string, payload: any) => void;
export type GatewayResponseHandler = (id: string, ok: boolean, payload: any) => void;
/** JSON-safe payload type used for gateway messages. */
export type JsonPayload = Record<string, unknown>;
export type GatewayEventHandler = (event: string, payload: JsonPayload) => void;
export type GatewayResponseHandler = (id: string, ok: boolean, payload: JsonPayload) => void;
/** Shape of an incoming WebSocket message from the gateway. */
interface GatewayMessage {
type: 'event' | 'res';
// event fields
event?: string;
payload?: JsonPayload;
// response fields
id?: string;
ok?: boolean;
error?: string;
}
export class GatewayClient {
private ws: WebSocket | null = null;
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
private pendingRequests = new Map<string, { resolve: (v: JsonPayload) => void; reject: (e: unknown) => void }>();
private eventHandlers: GatewayEventHandler[] = [];
private _onStatus: (s: 'disconnected' | 'connecting' | 'connected') => void = () => {};
private reconnectTimer: any = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private connected = false;
private autoReconnect = true;
@@ -44,22 +59,22 @@ export class GatewayClient {
this.ws.onopen = () => { console.log('[GW] WS open'); };
this.ws.onmessage = (ev) => {
let msg: any;
try { msg = JSON.parse(ev.data); } catch { console.log('[GW] parse error', ev.data); return; }
let msg: GatewayMessage;
try { msg = JSON.parse(ev.data as string) as GatewayMessage; } catch { console.log('[GW] parse error', ev.data); return; }
console.log('[GW] msg:', msg.type, msg.event || msg.id || '', msg.ok);
if (msg.type === 'event') {
if (msg.event === 'connect.challenge') {
this.handleChallenge();
} else {
for (const h of this.eventHandlers) h(msg.event, msg.payload);
for (const h of this.eventHandlers) h(msg.event ?? '', msg.payload ?? {});
}
} else if (msg.type === 'res') {
} else if (msg.type === 'res' && msg.id) {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
this.pendingRequests.delete(msg.id);
if (msg.ok) pending.resolve(msg.payload);
else pending.reject(msg.payload || msg.error);
if (msg.ok) pending.resolve(msg.payload ?? {});
else pending.reject(msg.payload ?? msg.error ?? 'unknown error');
}
}
};
@@ -118,7 +133,7 @@ export class GatewayClient {
this._onStatus('disconnected');
}
request(id: string, method: string, params: any): Promise<any> {
request(id: string, method: string, params: JsonPayload): Promise<JsonPayload> {
return new Promise((resolve, reject) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return reject(new Error('not connected'));
@@ -134,7 +149,7 @@ export class GatewayClient {
});
}
async send(method: string, params: any): Promise<any> {
async send(method: string, params: JsonPayload): Promise<JsonPayload> {
const id = genId('req');
return this.request(id, method, params);
}

View File

@@ -11,7 +11,7 @@ export interface ChatMessage {
export type MessageBlock =
| { type: 'text'; text: string }
| { type: 'thinking'; text: string }
| { type: 'tool_use'; name: string; input: any; id?: string }
| { type: 'tool_use'; name: string; input: Record<string, unknown>; id?: string }
| { type: 'tool_result'; content: string; toolUseId?: string; name?: string }
| { type: 'image'; mediaType: string; data?: string; url?: string };