feat: enhance error handling and notifications for API errors

- Implemented a new mechanism to detect and notify users of API errors within team communications, improving error visibility.
- Updated the ActivityItem and ThoughtBodyContent components to visually indicate API errors with distinct styling.
- Enhanced the TaskDetailDialog and KanbanTaskCard components to improve user experience by providing clearer feedback on task dependencies and statuses.
- Refactored the teams IPC module to include checks for API errors, ensuring timely notifications are sent to users.
This commit is contained in:
iliya 2026-03-19 12:47:00 +02:00
parent 8d97c53cb4
commit 08f22121c6
8 changed files with 134 additions and 34 deletions

View file

@ -38,21 +38,34 @@ A new approach to task management with AI agent teams.
<details>
<summary><strong>More features</strong></summary>
<br />
- **Task creation with attachments** — Simply send a message to the team lead with any attached images (planed all files). The lead will automatically create a fully described task and attach your files directly to the task for complete context.
- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
- **Smart task-to-log/changes matching** — automatically links Claude session logs/changes to specific tasks
- **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size.
- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place
- **Zero-setup onboarding** — built-in Claude Code installation and authentication
- **Built-in code editor** — edit project files with Git support without leaving the app
- **Branch strategy** — choose via prompt: single branch or git worktree per agent
- **Team member stats** — global performance statistics per member
- **Attach code context** — reference files or snippets in messages, like in Cursor. You can also mention tasks using `#task-id`, or refer to another team with `@team-name` in your messages.
- **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur
- **MCP integration** — supports the built-in `mcp-server` (see [mcp-server folder](./mcp-server)) for integrating external tools and extensible agent plugins out of the box
- **Post-compact context recovery** — when Claude compresses its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost
- **Task context is preserved** — thanks to task descriptions, comments, and attachments, all essential information about each task remains available for ongoing work and future reference
</details>
## Installation

View file

@ -70,6 +70,7 @@ import {
} from '@shared/utils/cliArgsParser';
import { createLogger } from '@shared/utils/logger';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import crypto from 'crypto';
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
import * as fs from 'fs';
@ -155,6 +156,13 @@ const logger = createLogger('IPC:teams');
const seenRateLimitKeys = new Set<string>();
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
/**
* In-memory set of API error message keys already processed.
* Independent of NotificationManager storage survives notification deletion/pruning.
*/
const seenApiErrorKeys = new Set<string>();
const SEEN_API_ERROR_KEYS_MAX = 500;
/**
* Check messages for rate limit indicators and fire notifications for new ones.
* Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion)
@ -198,6 +206,53 @@ function checkRateLimitMessages(
}
}
/**
* Check messages for API errors (e.g. "API Error: 429 ...") and fire OS notifications.
* Mirrors the rate-limit approach: in-memory dedup + NotificationManager dedupeKey.
* Skips rate-limit messages (they have their own notification path).
*/
function checkApiErrorMessages(
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
teamName: string,
teamDisplayName: string,
projectPath?: string
): void {
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!isApiErrorMessage(msg.text)) continue;
// Don't double-notify if it's also a rate limit message
if (isRateLimitMessage(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `api-error:${teamName}:${rawKey}`;
if (seenApiErrorKeys.has(dedupeKey)) continue;
seenApiErrorKeys.add(dedupeKey);
if (seenApiErrorKeys.size > SEEN_API_ERROR_KEYS_MAX) {
const first = seenApiErrorKeys.values().next().value;
if (first) seenApiErrorKeys.delete(first);
}
// Extract status code for summary
const statusMatch = msg.text.match(/^API Error:\s*(\d{3})/);
const statusCode = statusMatch?.[1] ?? '???';
void NotificationManager.getInstance()
.addTeamNotification({
teamEventType: 'rate_limit', // reuse rate_limit type — closest fit
teamName,
teamDisplayName,
from: msg.from,
summary: `API Error ${statusCode}: ${msg.from}`,
body: msg.text.slice(0, 400),
dedupeKey,
projectPath,
})
.catch(() => undefined);
}
}
let teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
@ -443,6 +498,7 @@ async function handleGetData(
const live = provisioning.getLiveLeadProcessMessages(tn);
if (live.length === 0) {
checkRateLimitMessages(data.messages, tn, displayName, projectPath);
checkApiErrorMessages(data.messages, tn, displayName, projectPath);
return { success: true, data: { ...data, isAlive } };
}
@ -503,6 +559,7 @@ async function handleGetData(
merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
checkRateLimitMessages(merged, tn, displayName, projectPath);
checkApiErrorMessages(merged, tn, displayName, projectPath);
return { success: true, data: { ...data, isAlive, messages: merged } };
}

View file

@ -775,7 +775,10 @@ export const ActivityItem = memo(
replyTaskRefs={message.taskRefs}
/>
) : displayText ? (
<div className="group/message-body relative">
<div
className={`group/message-body relative${isApiError ? '[&_code]:!text-red-400 [&_p]:!text-red-400' : ''}`}
style={isApiError ? { color: '#f87171' } : undefined}
>
<div className="absolute right-1 top-1 z-10 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/message-body:opacity-100">
{onReply ? (
<Tooltip>

View file

@ -17,6 +17,7 @@ import {
areThoughtMessagesEquivalentForRender,
} from '@renderer/utils/messageRenderEquality';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react';
@ -560,6 +561,9 @@ const LeadThoughtsGroupRowComponent = ({
return null;
}, [thoughts]);
// Detect if any thought in this group is an API error
const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]);
const [expanded, setExpanded] = useState(false);
const [needsTruncation, setNeedsTruncation] = useState(false);
const isManaged = collapseMode === 'managed';
@ -719,8 +723,8 @@ const LeadThoughtsGroupRowComponent = ({
className="group rounded-md [overflow:clip]"
style={{
backgroundColor: zebraShade ? CARD_BG_ZEBRA : CARD_BG,
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
border: hasApiError ? '1px solid rgba(248, 113, 113, 0.3)' : CARD_BORDER_STYLE,
borderLeft: `3px solid ${hasApiError ? '#f87171' : colors.border}`,
}}
>
{/* Header */}
@ -732,6 +736,7 @@ const LeadThoughtsGroupRowComponent = ({
'flex select-none items-center gap-2 px-3 py-1.5',
canToggleBodyVisibility ? 'cursor-pointer' : '',
].join(' ')}
style={hasApiError ? { backgroundColor: 'rgba(248, 113, 113, 0.08)' } : undefined}
onClick={handleBodyToggle}
onKeyDown={
canToggleBodyVisibility
@ -879,10 +884,13 @@ const LeadThoughtsGroupRowComponent = ({
) : null}
</article>
{isBodyVisible && !expanded && needsTruncation ? (
<div className="pointer-events-none flex justify-center" style={{ marginTop: -15 }}>
<div
className="pointer-events-none relative z-10 flex justify-center"
style={{ marginTop: -15 }}
>
<button
type="button"
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setExpanded(true);

View file

@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
import { CopyButton } from '@renderer/components/common/CopyButton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import {
areStringArraysEqual,
@ -74,6 +75,8 @@ export const ThoughtBodyContent = memo(
[onReply, thought]
);
const isApiError = useMemo(() => isApiErrorMessage(thought.text), [thought.text]);
return (
<>
{showDivider && (
@ -85,8 +88,8 @@ export const ThoughtBodyContent = memo(
)}
<div className="group/thought relative flex text-[11px]">
<div
className="min-w-0 flex-1 [&>span>div>div>div]:py-2"
style={{ color: CARD_TEXT_LIGHT }}
className={`min-w-0 flex-1 [&>span>div>div>div]:py-2${isApiError ? '[&_code]:!text-red-400 [&_p]:!text-red-400' : ''}`}
style={{ color: isApiError ? '#f87171' : CARD_TEXT_LIGHT }}
>
<span onClickCapture={onTaskIdClick ? handleTaskLinkClick : undefined}>
<MarkdownViewer

View file

@ -823,7 +823,7 @@ export const TaskDetailDialog = ({
<div className="space-y-1">
{blockedByIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-0.5 text-xs text-yellow-300">
<span className="inline-flex items-center gap-0.5 text-xs text-yellow-700 dark:text-yellow-300">
<ArrowLeftFromLine size={12} />
Blocked by
</span>
@ -840,8 +840,8 @@ export const TaskDetailDialog = ({
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
? 'bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/25 dark:text-emerald-400'
: 'bg-yellow-500/15 text-yellow-700 hover:bg-yellow-500/25 dark:text-yellow-300'
} cursor-pointer`}
onClick={() => handleDependencyClick(id)}
>
@ -859,7 +859,7 @@ export const TaskDetailDialog = ({
{blocksIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-0.5 text-xs text-blue-400">
<span className="inline-flex items-center gap-0.5 text-xs text-blue-600 dark:text-blue-400">
<ArrowRightFromLine size={12} />
Blocks
</span>
@ -876,8 +876,8 @@ export const TaskDetailDialog = ({
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
? 'bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/25 dark:text-emerald-400'
: 'bg-blue-500/15 text-blue-600 hover:bg-blue-500/25 dark:text-blue-400'
} cursor-pointer`}
onClick={() => handleDependencyClick(id)}
>

View file

@ -65,27 +65,30 @@ const DependencyBadge = ({
}: DependencyBadgeProps): React.JSX.Element => {
const depTask = taskMap.get(taskId);
const isCompleted = depTask?.status === 'completed';
const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(taskId)}`;
return (
<button
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
} ${onScrollToTask ? 'cursor-pointer' : ''}`}
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(taskId)}`
}
onClick={(e) => {
e.stopPropagation();
onScrollToTask?.(taskId);
}}
>
{depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(taskId)}`}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/25 dark:text-emerald-400'
: 'bg-yellow-500/15 text-yellow-700 hover:bg-yellow-500/25 dark:text-yellow-300'
} ${onScrollToTask ? 'cursor-pointer' : ''}`}
onClick={(e) => {
e.stopPropagation();
onScrollToTask?.(taskId);
}}
>
{depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(taskId)}`}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
};
@ -349,7 +352,7 @@ export const KanbanTaskCard = ({
{hasBlockedBy ? (
<div className="mb-2 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-300">
<span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-700 dark:text-yellow-300">
<ArrowLeftFromLine size={10} />
Blocked by
</span>

View file

@ -0,0 +1,13 @@
/**
* Detects API error messages from Claude CLI output.
* Pattern: "API Error: <status_code>" at the beginning of the text.
*/
const API_ERROR_RE = /^API Error:\s*\d{3}/;
/**
* Returns true if the message text starts with "API Error: <status_code>".
*/
export function isApiErrorMessage(text: string): boolean {
return API_ERROR_RE.test(text);
}