From 50ad0f8186dbc157fbb6150275dbe6391ac5bc85 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 22 Oct 2025 14:27:25 +0200 Subject: [PATCH 1/3] chore: replace chat.error with onError --- packages/blink/src/local/chat-manager.ts | 64 +++++++----------------- packages/blink/src/local/types.ts | 1 - packages/blink/src/react/use-chat.ts | 15 +++++- packages/blink/src/react/use-dev-mode.ts | 10 ++-- packages/blink/src/tui/dev.tsx | 8 +-- 5 files changed, 36 insertions(+), 62 deletions(-) diff --git a/packages/blink/src/local/chat-manager.ts b/packages/blink/src/local/chat-manager.ts index 81033b5..71e53f1 100644 --- a/packages/blink/src/local/chat-manager.ts +++ b/packages/blink/src/local/chat-manager.ts @@ -25,7 +25,6 @@ export interface ChatState { readonly messages: StoredMessage[]; readonly status: ChatStatus; readonly streamingMessage?: StoredMessage; - readonly error?: string; readonly loading: boolean; readonly queuedMessages: StoredMessage[]; } @@ -43,6 +42,10 @@ export interface ChatManagerOptions { * Return true to include the message, false to exclude it. */ readonly filterMessages?: (message: StoredMessage) => boolean; + /** + * Optional callback invoked when an error occurs during chat operations. + */ + readonly onError?: (error: string) => void; } type StateListener = (state: ChatState) => void; @@ -57,6 +60,7 @@ export class ChatManager { private chatStore: Store; private serializeMessage?: (message: UIMessage) => StoredMessage | undefined; private filterMessages?: (message: StoredMessage) => boolean; + private onError?: (error: string) => void; private chat: StoredChat; private loading = false; @@ -82,6 +86,7 @@ export class ChatManager { this.chatStore = createDiskStore(options.chatsDirectory, "id"); this.serializeMessage = options.serializeMessage; this.filterMessages = options.filterMessages; + this.onError = options.onError; // Start disk watcher this.watcher = createDiskStoreWatcher(options.chatsDirectory, { @@ -122,21 +127,14 @@ export class ChatManager { const diskValue = event.value; - let newStatus = event.value?.error ? "error" : "idle"; - if (event.locked) { - newStatus = "streaming"; - } + let newStatus: ChatStatus = event.locked ? "streaming" : "idle"; const shouldEmit = this.chat.updated_at !== diskValue?.updated_at || this.status !== newStatus; - // Clear persisted errors - they're stale from disk - this.chat = { - ...diskValue, - error: undefined, - }; + this.chat = diskValue; this.streamingMessage = undefined; - this.status = newStatus as ChatStatus; + this.status = newStatus; if (shouldEmit) { this.notifyListeners(); @@ -154,14 +152,11 @@ export class ChatManager { if (!chat) { return; } - // Clear any persisted errors on load - they're stale - this.chat = { - ...chat, - error: undefined, - }; + this.chat = chat; }) .catch((err) => { - this.chat.error = err instanceof Error ? err.message : String(err); + const errorMessage = err instanceof Error ? err.message : String(err); + this.onError?.(errorMessage); }) .finally(() => { this.loading = false; @@ -190,7 +185,6 @@ export class ChatManager { updated_at: this.chat?.updated_at, status: this.status, streamingMessage: this.streamingMessage, - error: this.chat?.error, loading: this.loading, queuedMessages: this.queue, }; @@ -298,22 +292,6 @@ export class ChatManager { * Send a message to the agent */ async sendMessages(messages: StoredMessage[]): Promise { - // Clear any previous errors when sending a new message (persist to disk) - if (this.chat.error) { - const locked = await this.chatStore.lock(this.chatId); - try { - const current = await locked.get(); - this.chat = { - ...current, - error: undefined, - updated_at: new Date().toISOString(), - }; - await locked.set(this.chat); - } finally { - await locked.release(); - } - } - this.status = "idle"; this.notifyListeners(); @@ -331,8 +309,6 @@ export class ChatManager { } async start(): Promise { - // Clear error when explicitly starting - this.chat.error = undefined; this.status = "idle"; this.notifyListeners(); // Do not await this - it will block the server. @@ -347,19 +323,16 @@ export class ChatManager { private async processQueueOrRun(): Promise { if (!this.agent) { - // Set error state instead of throwing - this.chat.error = + const errorMessage = "The agent is not available. Please wait for the build to succeed."; - this.status = "error"; + this.onError?.(errorMessage); this.queue = []; // Clear the queue - this.notifyListeners(); return; } if (this.isProcessingQueue) { return; } this.isProcessingQueue = true; - this.chat.error = undefined; let locked: LockedStoreEntry | undefined; try { @@ -501,15 +474,12 @@ export class ChatManager { } } } catch (err: any) { - this.chat.error = err instanceof Error ? err.message : String(err); + const errorMessage = err instanceof Error ? err.message : String(err); + this.onError?.(errorMessage); } finally { this.isProcessingQueue = false; this.streamingMessage = undefined; - if (this.chat.error) { - this.status = "error"; - } else { - this.status = "idle"; - } + this.status = "idle"; if (locked) { this.chat.updated_at = new Date().toISOString(); diff --git a/packages/blink/src/local/types.ts b/packages/blink/src/local/types.ts index 5a05823..b3bc6d0 100644 --- a/packages/blink/src/local/types.ts +++ b/packages/blink/src/local/types.ts @@ -7,7 +7,6 @@ export interface StoredChat { created_at: string; updated_at: string; messages: StoredMessage[]; - error?: string; } export type StoredMessageMetadata = { diff --git a/packages/blink/src/react/use-chat.ts b/packages/blink/src/react/use-chat.ts index c9f9933..369d422 100644 --- a/packages/blink/src/react/use-chat.ts +++ b/packages/blink/src/react/use-chat.ts @@ -21,6 +21,10 @@ export interface UseChatOptions { * Return true to include the message, false to exclude it. */ readonly filterMessages?: (message: StoredMessage) => boolean; + /** + * Optional callback invoked when an error occurs during chat operations. + */ + readonly onError?: (error: string) => void; } export interface UseChat extends ChatState { @@ -34,8 +38,14 @@ export interface UseChat extends ChatState { } export default function useChat(options: UseChatOptions): UseChat { - const { chatId, agent, chatsDirectory, serializeMessage, filterMessages } = - options; + const { + chatId, + agent, + chatsDirectory, + serializeMessage, + filterMessages, + onError, + } = options; // Use a ref to store the manager so it persists across renders const managerRef = useRef(null); @@ -60,6 +70,7 @@ export default function useChat(options: UseChatOptions): UseChat { chatsDirectory, serializeMessage, filterMessages, + onError, }); const unsubscribe = manager.subscribe((newState) => { setState(newState); diff --git a/packages/blink/src/react/use-dev-mode.ts b/packages/blink/src/react/use-dev-mode.ts index 8dfbc18..6f88e7e 100644 --- a/packages/blink/src/react/use-dev-mode.ts +++ b/packages/blink/src/react/use-dev-mode.ts @@ -1,4 +1,5 @@ import type { UIMessage } from "ai"; +import chalk from "chalk"; import { isToolOrDynamicToolUIPart } from "ai"; import { isToolApprovalOutput } from "../agent/tools"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -310,6 +311,9 @@ export default function useDevMode(options: UseDevModeOptions): UseDevMode { } return true; }, + onError: (error) => { + options.onError?.(chalk.red(`⚙ Chat error: ${error}`)); + }, }); // Track agent logs @@ -441,16 +445,12 @@ export default function useDevMode(options: UseDevModeOptions): UseDevMode { if (editAgentError && mode === "edit") { errorMap.set("editAgent", `Edit agent error: ${editAgentError.message}`); } - // Don't show chat error if we're building - user already sees "Compiling..." - if (chat.error && buildStatus !== "building") { - errorMap.set("chat", `Chat error: ${chat.error}`); - } if (optionsError) { errorMap.set("options", `Options error: ${optionsError.message}`); } return errorMap; - }, [agentError, editAgentError, chat.error, optionsError, mode, buildStatus]); + }, [agentError, editAgentError, optionsError, mode]); // Track previous errors to detect changes const prevErrorsRef = useRef>(new Map()); diff --git a/packages/blink/src/tui/dev.tsx b/packages/blink/src/tui/dev.tsx index 084307e..35ae16f 100644 --- a/packages/blink/src/tui/dev.tsx +++ b/packages/blink/src/tui/dev.tsx @@ -84,7 +84,7 @@ const Root = ({ directory }: { directory: string }) => { ); }, onError: (error) => { - console.log(chalk.red(`⚙ ${error}`)); + console.log(error); }, onModeChange: (mode) => { switch (mode) { @@ -240,12 +240,6 @@ const Root = ({ directory }: { directory: string }) => { return ( <> - {dev.chat.error ? ( - - {dev.chat.error} - - ) : null} - {dev.mode === "edit" && dev.editModeMissingApiKey ? ( ) : null} From d05f4e118b9efa0097574ccb1d828a84feb6f9e4 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 22 Oct 2025 14:41:12 +0200 Subject: [PATCH 2/3] feat: less scary TUI errors --- packages/blink/src/react/use-dev-mode.ts | 2 +- packages/blink/src/tui/dev.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/blink/src/react/use-dev-mode.ts b/packages/blink/src/react/use-dev-mode.ts index 6f88e7e..08a0e4c 100644 --- a/packages/blink/src/react/use-dev-mode.ts +++ b/packages/blink/src/react/use-dev-mode.ts @@ -312,7 +312,7 @@ export default function useDevMode(options: UseDevModeOptions): UseDevMode { return true; }, onError: (error) => { - options.onError?.(chalk.red(`⚙ Chat error: ${error}`)); + options.onError?.(`${chalk.red("⚙ [Chat Error]")} ${chalk.gray(error)}`); }, }); diff --git a/packages/blink/src/tui/dev.tsx b/packages/blink/src/tui/dev.tsx index 35ae16f..0030301 100644 --- a/packages/blink/src/tui/dev.tsx +++ b/packages/blink/src/tui/dev.tsx @@ -73,7 +73,9 @@ const Root = ({ directory }: { directory: string }) => { }, onAgentLog: (log) => { const logColor = log.level === "error" ? "red" : "white"; - console.log(chalk[logColor](`@ ${log.message}`)); + const logPrefix = + log.level === "error" ? "⚙ [Agent Error]" : "⚙ [Agent Log]"; + console.log(`${chalk[logColor](logPrefix)} ${chalk.gray(log.message)}`); }, onDevhookRequest: (request) => { console.log( From a000f317ad0b8f333e8bccfaa33767f4e2e20381 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 22 Oct 2025 14:55:47 +0200 Subject: [PATCH 3/3] fixes --- packages/blink/src/cli/run.ts | 6 +- packages/blink/src/local/chat-manager.test.ts | 145 ++++-------------- packages/blink/src/react/use-chat.test.tsx | 2 - 3 files changed, 33 insertions(+), 120 deletions(-) diff --git a/packages/blink/src/cli/run.ts b/packages/blink/src/cli/run.ts index d917b59..cc0e8ba 100644 --- a/packages/blink/src/cli/run.ts +++ b/packages/blink/src/cli/run.ts @@ -67,6 +67,9 @@ export default async function run( const manager = new ChatManager({ chatId: opts?.chat, chatsDirectory: chatsDir, + onError: (error) => { + console.error("Error:", error); + }, }); manager.setAgent(agent.client); @@ -95,9 +98,6 @@ export default async function run( // Print final state const finalState = manager.getState(); - if (finalState.error) { - console.error("Error:", finalState.error); - } console.log("Final state:", finalState.messages.pop()); } finally { manager.dispose(); diff --git a/packages/blink/src/local/chat-manager.test.ts b/packages/blink/src/local/chat-manager.test.ts index 56c4089..744d59e 100644 --- a/packages/blink/src/local/chat-manager.test.ts +++ b/packages/blink/src/local/chat-manager.test.ts @@ -197,7 +197,6 @@ test("initializes with empty state for non-existent chat", async () => { expect(state.messages).toEqual([]); expect(state.status).toBe("idle"); expect(state.streamingMessage).toBeUndefined(); - expect(state.error).toBeUndefined(); expect(state.queuedMessages).toEqual([]); manager.dispose(); @@ -873,126 +872,42 @@ test("watcher onChange does not cause status to flicker during lock release", as manager.dispose(); }); -test("error clearing: errors clear when sending new message", async () => { - const chatsDir = await mkdtemp(join(tmpdir(), "chat-test-")); - - try { - // Create a manager with an agent that will fail - const failingAgent: any = { - chat: async () => { - throw new Error("Test error"); - }, - }; - - const manager = new ChatManager({ - chatId: crypto.randomUUID(), - chatsDirectory: chatsDir, - }); - - // Track state changes before sending message - let errorSeen = false; - let errorCleared = false; - const unsubscribe = manager.subscribe((state) => { - if (state.error) { - errorSeen = true; - } - if (errorSeen && !state.error) { - errorCleared = true; - } - }); - - manager.setAgent(failingAgent); - - // Send a message that will fail - const message: StoredMessage = { - id: crypto.randomUUID(), - created_at: new Date().toISOString(), - role: "user", - parts: [{ type: "text", text: "Hello" }], - mode: "run", - metadata: undefined, - }; - - await manager.sendMessages([message]); - - // Wait for error state - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Should have seen an error - expect(errorSeen).toBe(true); - let state = manager.getState(); - expect(state.status).toBe("error"); - - // Now set a working agent - const workingAgent = createMockAgent("Success!"); - manager.setAgent(workingAgent); - - // Send another message - const message2: StoredMessage = { - id: crypto.randomUUID(), - created_at: new Date().toISOString(), - role: "user", - parts: [{ type: "text", text: "Try again" }], - mode: "run", - metadata: undefined, - }; - - await manager.sendMessages([message2]); - - // Wait for completion - await new Promise((resolve) => setTimeout(resolve, 300)); +test("onError callback is called when no agent is available", async () => { + const chatId = crypto.randomUUID(); - // Error should have been cleared at some point during the lifecycle - // This is the key behavior - errors should clear when sending new messages - expect(errorCleared).toBe(true); + // Track errors via onError callback + const errors: string[] = []; + const onError = mock((error: string) => { + errors.push(error); + }); - // Final state should have no error (watcher may still show error status briefly) - state = manager.getState(); - expect(state.error).toBeUndefined(); + const manager = new ChatManager({ + chatId, + chatsDirectory: tempDir, + onError, + }); - unsubscribe(); - manager.dispose(); - } finally { - await rm(chatsDir, { recursive: true, force: true }); - } -}); + // Don't set an agent, so it should fail when we try to send a message -test("error clearing: persisted errors don't load from disk", async () => { - const chatsDir = await mkdtemp(join(tmpdir(), "chat-test-")); - - try { - const chatId = crypto.randomUUID(); - - // Manually create a chat with an error in the store - const store = createDiskStore(chatsDir, "id"); - const locked = await store.lock(chatId); - try { - await locked.set({ - id: chatId, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - messages: [], - error: "Old persisted error", - }); - } finally { - await locked.release(); - } + // Send a message without an agent + const message: StoredMessage = { + id: crypto.randomUUID(), + created_at: new Date().toISOString(), + role: "user", + parts: [{ type: "text", text: "Hello" }], + mode: "run", + metadata: undefined, + }; - // Create a new manager - it should clear the persisted error - const manager = new ChatManager({ - chatId, - chatsDirectory: chatsDir, - }); + await manager.sendMessages([message]); - // Wait for initial load - await new Promise((resolve) => setTimeout(resolve, 100)); + // Wait a bit for the error to be processed + await new Promise((resolve) => setTimeout(resolve, 100)); - const state = manager.getState(); - expect(state.error).toBeUndefined(); // Error should be cleared - expect(state.loading).toBe(false); + // Verify onError was called with the "no agent" error message + expect(onError).toHaveBeenCalled(); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]).toContain("agent is not available"); - manager.dispose(); - } finally { - await rm(chatsDir, { recursive: true, force: true }); - } + manager.dispose(); }); diff --git a/packages/blink/src/react/use-chat.test.tsx b/packages/blink/src/react/use-chat.test.tsx index cb8ce55..86bc307 100644 --- a/packages/blink/src/react/use-chat.test.tsx +++ b/packages/blink/src/react/use-chat.test.tsx @@ -23,7 +23,6 @@ const Harness: React.FC = ({ options, onUpdate }) => { result.status, result.messages.length, result.streamingMessage, - result.error, result.queuedMessages.length, ]); return null; @@ -106,7 +105,6 @@ test("initializes with empty state for non-existent chat", async () => { expect(r.messages).toEqual([]); expect(r.status).toBe("idle"); expect(r.streamingMessage).toBeUndefined(); - expect(r.error).toBeUndefined(); app.unmount(); });