diff --git a/electron/src/main/agents/manager.ts b/electron/src/main/agents/manager.ts index 0b7b9c15..d0ffa81c 100644 --- a/electron/src/main/agents/manager.ts +++ b/electron/src/main/agents/manager.ts @@ -1640,5 +1640,7 @@ function makeEmptyChain( subagentRecord: null, startTime: new Date().toISOString(), endTime: null, + errorDetail: null, + errorTitle: null, }; } diff --git a/electron/src/main/ipc/chat.ts b/electron/src/main/ipc/chat.ts index f964eb70..9d10c2d1 100644 --- a/electron/src/main/ipc/chat.ts +++ b/electron/src/main/ipc/chat.ts @@ -10,7 +10,7 @@ import { getForegroundLiveRegistry } from '../tools/process/foreground-live'; import { SEND_INPUT_MAX_TEXT_LENGTH } from '../tools/process/send-input'; import { getSessionManager } from '../session/singleton'; import { IPC_CHANNELS, type ChatSessionSnapshot } from '../../shared/types/ipc'; -import { ChainStatus } from '../../shared/types/chain'; +import { ChainStatus, lastChainError } from '../../shared/types/chain'; import { flattenSessionMessages } from '../../shared/types/session'; import { clearAllChatHistory } from './chat-history'; import { chatCancelSchema, chatQueueNextSchema, chatSendSchema, chatSnapshotSchema, chatStopSchema } from './payload-schemas'; @@ -137,6 +137,7 @@ export function registerChatIPC(): void { sessionId, messages: liveAgent && live ? [...liveAgent.messages] : flattenSessionMessages(session), live, + lastChainError: live ? null : lastChainError(session.chains), }; }, ); diff --git a/electron/src/main/ipc/chat/persist.ts b/electron/src/main/ipc/chat/persist.ts index 5eecab8b..3c88b9dc 100644 --- a/electron/src/main/ipc/chat/persist.ts +++ b/electron/src/main/ipc/chat/persist.ts @@ -191,6 +191,8 @@ export function persistTurnConversation( agent: Agent, selection?: ModelSelection | null, webContents?: WebContents, + errorDetail?: string | null, + errorTitle?: string | null, ): void { setChatHistory(sessionId, fullHistory); try { @@ -203,6 +205,8 @@ export function persistTurnConversation( agentName: agent.name, agentType: agent.type, agentTier: agent.tier, + errorDetail, + errorTitle, }, sessionId); const update = updated ? buildSessionUpdatedEvent(updated, null) : null; if (update && webContents) { diff --git a/electron/src/main/ipc/chat/send.ts b/electron/src/main/ipc/chat/send.ts index 46cea9b4..a952e507 100644 --- a/electron/src/main/ipc/chat/send.ts +++ b/electron/src/main/ipc/chat/send.ts @@ -549,6 +549,7 @@ export async function startChatTurn( persistTurnConversation( sessionId, fullHistory, turnMessagesFromAgent(activeAgent), ChainStatus.FAILED, agent, activeAgent.selection, webContents, + detail, title, ); activeAgent.messages = fullHistory; sendTurnEvent(webContents, activeAgent, IPC_CHANNELS.CHAT_ERROR, { diff --git a/electron/src/main/ipc/session.ts b/electron/src/main/ipc/session.ts index 3bee8a12..fb156786 100644 --- a/electron/src/main/ipc/session.ts +++ b/electron/src/main/ipc/session.ts @@ -7,6 +7,7 @@ import { BrowserWindow, dialog, ipcMain } from 'electron'; import { IPC_CHANNELS } from '../../shared/types/ipc'; import { flattenSessionMessages, sessionForRenderer } from '../../shared/types/session'; +import { lastChainError } from '../../shared/types/chain'; import type { ModelSelection } from '../../shared/types/provider'; import { getSessionManager, @@ -358,6 +359,7 @@ export function registerSessionIPC(): void { messages, live, workspace, + lastChainError: session && !live ? lastChainError(session.chains) : null, }; }); diff --git a/electron/src/main/session/manager.ts b/electron/src/main/session/manager.ts index 2792e9f4..1fa18594 100644 --- a/electron/src/main/session/manager.ts +++ b/electron/src/main/session/manager.ts @@ -639,6 +639,8 @@ export class SessionManager { subagentRecord: null, startTime: now, endTime: null, + errorDetail: null, + errorTitle: null, }; chains = [...chains, chain]; @@ -711,6 +713,8 @@ export class SessionManager { finishActiveChain( status: ChainStatus = ChainStatus.COMPLETED, sessionId?: string, + errorDetail?: string | null, + errorTitle?: string | null, ): Session | null { const targetId = sessionId ?? this.selectedSessionId(); const session = targetId ? this.ensureSession(targetId) : null; @@ -729,6 +733,8 @@ export class SessionManager { ...existing, status: terminal, endTime: now, + errorDetail: errorDetail ?? null, + errorTitle: errorTitle ?? null, }; const chains = session.chains.map((c) => c.id === chain.id ? chain : c, @@ -769,6 +775,8 @@ export class SessionManager { agentName?: string; agentType?: string; agentTier?: string; + errorDetail?: string | null; + errorTitle?: string | null; }, sessionId?: string): Session | null { const targetId = sessionId ?? this.selectedSessionId(); let session = targetId ? this.ensureSession(targetId) : null; @@ -827,7 +835,12 @@ export class SessionManager { if (status === ChainStatus.ACTIVE) { return session; } - return this.finishActiveChain(status, targetId ?? undefined); + return this.finishActiveChain( + status, + targetId ?? undefined, + params.errorDetail, + params.errorTitle, + ); } /** diff --git a/electron/src/main/session/schema.ts b/electron/src/main/session/schema.ts index fa7f016b..f19294c4 100644 --- a/electron/src/main/session/schema.ts +++ b/electron/src/main/session/schema.ts @@ -39,7 +39,9 @@ CREATE TABLE IF NOT EXISTS chains ( subagent_record_json TEXT, messages_json TEXT NOT NULL DEFAULT '[]', start_time TEXT, - end_time TEXT + end_time TEXT, + error_detail TEXT, + error_title TEXT ); CREATE TABLE IF NOT EXISTS subagent_chains ( @@ -72,4 +74,14 @@ export function applySessionSchemaMigrations(db: SqliteDatabase): void { } } } + + if (tables.has('chains')) { + const chainColumns = db.prepare('PRAGMA table_info(chains)').all() as Array<{ name: string }>; + const existing = new Set(chainColumns.map((c) => c.name)); + for (const col of ['error_detail', 'error_title']) { + if (!existing.has(col)) { + db.prepare(`ALTER TABLE chains ADD COLUMN ${col} TEXT`).run(); + } + } + } } diff --git a/electron/src/main/session/storage.ts b/electron/src/main/session/storage.ts index 4b1ba5b0..ccf6dd1b 100644 --- a/electron/src/main/session/storage.ts +++ b/electron/src/main/session/storage.ts @@ -220,6 +220,8 @@ interface ChainRow { messages_json: string; start_time: string | null; end_time: string | null; + error_detail: string | null; + error_title: string | null; } interface SubagentChainRow { @@ -293,6 +295,8 @@ function chainFromRow(row: ChainRow): Chain { subagentRecord, startTime: row.start_time, endTime: row.end_time, + errorDetail: row.error_detail ?? null, + errorTitle: row.error_title ?? null, }; } @@ -351,8 +355,8 @@ function sessionFromRow(row: SessionRow, chains: Chain[], subagentChains: Subage } const INSERT_CHAIN_SQL = ` - INSERT INTO chains (id, session_id, ordinal, status, selection_json, model_label, agent_name, agent_type, agent_tier, subagent_record_json, messages_json, start_time, end_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO chains (id, session_id, ordinal, status, selection_json, model_label, agent_name, agent_type, agent_tier, subagent_record_json, messages_json, start_time, end_time, error_detail, error_title) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; const INSERT_SUBAGENT_CHAIN_SQL = ` @@ -385,6 +389,8 @@ function insertChainRow( serializeMessages(chain.messages), chain.startTime, chain.endTime, + chain.errorDetail, + chain.errorTitle, ); } @@ -395,7 +401,8 @@ function updateChainRow(db: SqliteDatabase, chain: Chain): number { SET status = ?, selection_json = ?, model_label = ?, agent_name = ?, agent_type = ?, agent_tier = ?, subagent_record_json = ?, messages_json = ?, - start_time = ?, end_time = ? + start_time = ?, end_time = ?, + error_detail = ?, error_title = ? WHERE id = ? AND session_id = ?`, ) .run( @@ -411,6 +418,8 @@ function updateChainRow(db: SqliteDatabase, chain: Chain): number { serializeMessages(chain.messages), chain.startTime, chain.endTime, + chain.errorDetail, + chain.errorTitle, chain.id, chain.sessionId, ).changes; diff --git a/electron/src/renderer/components/ChainFooter.tsx b/electron/src/renderer/components/ChainFooter.tsx index 9fb06d06..059fc77c 100644 --- a/electron/src/renderer/components/ChainFooter.tsx +++ b/electron/src/renderer/components/ChainFooter.tsx @@ -13,6 +13,8 @@ interface ChainFooterProps { elapsedSeconds?: number; interrupted?: boolean; failed?: boolean; + /** Error detail from a FAILED chain, shown as a tooltip on the badge. */ + errorDetail?: string | null; } export function ChainFooter({ @@ -22,6 +24,7 @@ export function ChainFooter({ elapsedSeconds, interrupted, failed, + errorDetail, }: ChainFooterProps) { const showSub = hasUsage(subUsage); const showUsage = hasUsage(usage); @@ -35,7 +38,7 @@ export function ChainFooter({ )} {failed && !interrupted && ( - + Failed diff --git a/electron/src/renderer/components/ChatStream.tsx b/electron/src/renderer/components/ChatStream.tsx index e4fd5d5a..a087ea91 100644 --- a/electron/src/renderer/components/ChatStream.tsx +++ b/electron/src/renderer/components/ChatStream.tsx @@ -337,6 +337,12 @@ export function ChatStream({ return (
+ {/* History + live tail + active footer render as ONE keyed sequence. + History nodes remain referentially stable across live-only frames, + while the small tail/footer path updates independently. Keeping the + final nodes flat lets React retain shared segment/footer keys across + the live→committed swap instead of replaying entrance animation. */} + {streamNodes} {error && (
)} - - {/* History + live tail + active footer render as ONE keyed sequence. - History nodes remain referentially stable across live-only frames, - while the small tail/footer path updates independently. Keeping the - final nodes flat lets React retain shared segment/footer keys across - the live→committed swap instead of replaying entrance animation. */} - {streamNodes}
{isUserScrolledUp ? (