fix: enhance message sending reliability and error handling

- Improved the message sending process in `handleSendMessage` by separating try blocks for stdin delivery and persistence, preventing fallback to inbox on stdin success.
- Added logging for persistence failures after stdin delivery to ensure better tracking of issues.
- Updated `TeamSentMessagesStore` to handle IO errors gracefully, preserving optional fields and preventing crashes.
- Enhanced `MessageComposer` to clear draft only after successful message send, improving user experience.
This commit is contained in:
iliya 2026-02-24 16:01:44 +02:00
parent 683146c45d
commit 47d266b55b
10 changed files with 171 additions and 65 deletions

View file

@ -788,15 +788,39 @@ async function handleSendMessage(
// Smart routing: lead + alive → stdin direct, else → inbox
if (isLeadRecipient && isAlive) {
// Separate try blocks: stdin delivery vs persistence
// If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate)
let stdinSent = false;
try {
await provisioning.sendMessageToTeam(tn, payload.text!, validatedAttachments);
stdinSent = true;
} catch (stdinError: unknown) {
// Stdin failed (process died between check and write)
// If attachments were requested, fail rather than silently dropping them
if (validatedAttachments?.length) {
throw new Error(
'Failed to deliver message with attachments: team process became unavailable'
);
}
const errMsg = stdinError instanceof Error ? stdinError.message : 'unknown error';
logger.warn(`stdin fallback for ${tn}: ${errMsg}`);
// Fallback to inbox path below
}
const result = await getTeamDataService().sendDirectToLead(
tn,
leadName,
payload.text!,
payload.summary
);
if (stdinSent) {
// Persistence is best-effort — stdin already delivered the message
let result: SendMessageResult;
try {
result = await getTeamDataService().sendDirectToLead(
tn,
leadName,
payload.text!,
payload.summary
);
} catch (persistError) {
logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`);
result = { deliveredToInbox: false, messageId: `stdin-${Date.now()}` };
}
const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({
id: a.id,
@ -825,17 +849,6 @@ async function handleSendMessage(
});
return result;
} catch (stdinError: unknown) {
// Stdin failed (process died between check and write)
// If attachments were requested, fail rather than silently dropping them
if (validatedAttachments?.length) {
throw new Error(
'Failed to deliver message with attachments: team process became unavailable'
);
}
const errMsg = stdinError instanceof Error ? stdinError.message : 'unknown error';
logger.warn(`stdin fallback for ${tn}: ${errMsg}`);
// Fallback to inbox path for text-only messages
}
}

View file

@ -1,4 +1,5 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
@ -7,6 +8,7 @@ import { atomicWriteAsync } from './atomicWrite';
import type { InboxMessage } from '@shared/types';
const MAX_MESSAGES = 200;
const logger = createLogger('TeamSentMessagesStore');
export class TeamSentMessagesStore {
private getFilePath(teamName: string): string {
@ -23,7 +25,9 @@ export class TeamSentMessagesStore {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
// Bug #4: graceful degradation instead of crashing
logger.error(`Failed to read sent messages for ${teamName}: ${String(error)}`);
return [];
}
let parsed: unknown;
@ -48,6 +52,7 @@ export class TeamSentMessagesStore {
) {
continue;
}
// Bug #5: preserve optional fields (attachments, color)
messages.push({
from: row.from,
to: typeof row.to === 'string' ? row.to : undefined,
@ -56,6 +61,8 @@ export class TeamSentMessagesStore {
read: typeof row.read === 'boolean' ? row.read : true,
summary: typeof row.summary === 'string' ? row.summary : undefined,
messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
color: typeof row.color === 'string' ? row.color : undefined,
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
source: 'user_sent',
});
}
@ -64,12 +71,17 @@ export class TeamSentMessagesStore {
}
async appendMessage(teamName: string, message: InboxMessage): Promise<void> {
const existing = await this.readMessages(teamName);
existing.push(message);
// Bug #6: wrap in try/catch to prevent crash on IO errors
try {
const existing = await this.readMessages(teamName);
existing.push(message);
// Trim to MAX_MESSAGES (keep newest)
const trimmed = existing.length > MAX_MESSAGES ? existing.slice(-MAX_MESSAGES) : existing;
// Trim to MAX_MESSAGES (keep newest)
const trimmed = existing.length > MAX_MESSAGES ? existing.slice(-MAX_MESSAGES) : existing;
await atomicWriteAsync(this.getFilePath(teamName), JSON.stringify(trimmed, null, 2));
await atomicWriteAsync(this.getFilePath(teamName), JSON.stringify(trimmed, null, 2));
} catch (error) {
logger.error(`Failed to append sent message for ${teamName}: ${String(error)}`);
}
}
}

View file

@ -155,6 +155,7 @@ export const SidebarHeader = (): React.JSX.Element => {
projects,
activeProjectId,
setActiveProject,
clearActiveProject,
fetchRepositoryGroups,
fetchProjects,
toggleSidebar,
@ -169,6 +170,7 @@ export const SidebarHeader = (): React.JSX.Element => {
projects: s.projects,
activeProjectId: s.activeProjectId,
setActiveProject: s.setActiveProject,
clearActiveProject: s.clearActiveProject,
fetchRepositoryGroups: s.fetchRepositoryGroups,
fetchProjects: s.fetchProjects,
toggleSidebar: s.toggleSidebar,
@ -279,6 +281,8 @@ export const SidebarHeader = (): React.JSX.Element => {
: 'Nothing found'
}
className="text-sm font-medium"
resetLabel="Reset selection"
onReset={clearActiveProject}
renderOption={(option, isSelected) => {
const sessionCount = (option.meta?.sessionCount as number) ?? 0;
const path = option.meta?.path as string | undefined;

View file

@ -24,6 +24,7 @@ import {
Play,
Plus,
Search,
Square,
Trash2,
UserPlus,
X,
@ -107,6 +108,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [stoppingTeam, setStoppingTeam] = useState(false);
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
undefined
@ -427,6 +429,17 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
});
};
const handleStopTeam = useCallback(async (): Promise<void> => {
setStoppingTeam(true);
try {
await api.teams.stop(teamName);
} catch (err) {
console.error('Failed to stop team:', err);
} finally {
setStoppingTeam(false);
}
}, [teamName]);
const handleDeleteTeam = useCallback((): void => {
setDeleteConfirmOpen(true);
}, []);
@ -552,6 +565,23 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<h2 className="text-base font-semibold text-[var(--color-text)]">{data.config.name}</h2>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{data.isAlive && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
disabled={stoppingTeam}
onClick={() => void handleStopTeam()}
>
<Square size={12} className={stoppingTeam ? 'animate-pulse' : ''} />
Stop
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Stop team</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -603,7 +633,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
)}
</div>
{!data.isAlive ? (
{!data.isAlive && !isTeamProvisioning ? (
<div className="mb-3 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2">
<span className="text-xs text-amber-200">Team is offline</span>
<Button

View file

@ -8,8 +8,17 @@ export const DropZoneOverlay = ({ active }: DropZoneOverlayProps): React.JSX.Ele
if (!active) return null;
return (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border-2 border-dashed border-blue-400/60 bg-blue-500/10 backdrop-blur-[1px]">
<div className="flex flex-col items-center gap-1.5 text-blue-400">
<div
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border-2 border-dashed backdrop-blur-[1px]"
style={{
borderColor: 'var(--color-accent, #6366f1)',
backgroundColor: 'color-mix(in srgb, var(--color-accent, #6366f1) 10%, transparent)',
}}
>
<div
className="flex flex-col items-center gap-1.5"
style={{ color: 'var(--color-accent, #6366f1)' }}
>
<ImagePlus size={24} />
<span className="text-xs font-medium">Drop images here</span>
</div>

View file

@ -14,6 +14,7 @@ import {
Eye,
LayoutGrid,
PlayCircle,
Plus,
ShieldCheck,
} from 'lucide-react';
@ -280,7 +281,22 @@ export const KanbanBoard = ({
const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => {
if (columnTasks.length === 0) {
return (
const addHandler =
onAddTask && columnId === 'todo'
? () => onAddTask(false)
: onAddTask && columnId === 'in_progress'
? () => onAddTask(true)
: undefined;
return addHandler ? (
<button
type="button"
onClick={addHandler}
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
>
<Plus size={13} />
Add task
</button>
) : (
<div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
No tasks
</div>
@ -398,12 +414,6 @@ export const KanbanBoard = ({
{COLUMNS.map((column) => {
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
const addHandler =
onAddTask && column.id === 'todo'
? () => onAddTask(false)
: onAddTask && column.id === 'in_progress'
? () => onAddTask(true)
: undefined;
return (
<KanbanColumn
key={column.id}
@ -412,7 +422,6 @@ export const KanbanBoard = ({
icon={accent.icon}
headerBg={accent.headerBg}
bodyBg={accent.bodyBg}
onAddTask={addHandler}
>
{renderCards(column.id, columnTasks)}
</KanbanColumn>
@ -424,12 +433,6 @@ export const KanbanBoard = ({
{COLUMNS.map((column) => {
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
const addHandler =
onAddTask && column.id === 'todo'
? () => onAddTask(false)
: onAddTask && column.id === 'in_progress'
? () => onAddTask(true)
: undefined;
return (
<div key={column.id} className="w-64 shrink-0">
<KanbanColumn
@ -438,7 +441,6 @@ export const KanbanBoard = ({
icon={accent.icon}
headerBg={accent.headerBg}
bodyBg={accent.bodyBg}
onAddTask={addHandler}
>
{renderCards(column.id, columnTasks)}
</KanbanColumn>

View file

@ -1,6 +1,5 @@
import { Badge } from '@renderer/components/ui/badge';
import { cn } from '@renderer/lib/utils';
import { Plus } from 'lucide-react';
interface KanbanColumnProps {
title: string;
@ -8,7 +7,6 @@ interface KanbanColumnProps {
icon?: React.ReactNode;
headerBg?: string;
bodyBg?: string;
onAddTask?: () => void;
children: React.ReactNode;
}
@ -18,7 +16,6 @@ export const KanbanColumn = ({
icon,
headerBg,
bodyBg,
onAddTask,
children,
}: KanbanColumnProps): React.JSX.Element => {
return (
@ -37,21 +34,9 @@ export const KanbanColumn = ({
{icon}
{title}
</h4>
<div className="flex items-center gap-1.5">
{onAddTask ? (
<button
type="button"
onClick={onAddTask}
className="inline-flex size-5 items-center justify-center rounded text-[var(--color-text-muted)] transition-colors hover:bg-white/10 hover:text-[var(--color-text)]"
aria-label={`Add task to ${title}`}
>
<Plus size={13} />
</button>
) : null}
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
{count}
</Badge>
</div>
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
{count}
</Badge>
</header>
<div className="flex max-h-[480px] flex-col gap-2 overflow-auto p-2">{children}</div>
</section>

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
@ -81,13 +81,26 @@ export const MessageComposer = ({
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
// Track whether we initiated a send — clear draft only on confirmed success
const pendingSendRef = useRef(false);
const handleSend = useCallback(() => {
if (!canSend) return;
const autoSummary = trimmed.length > 60 ? trimmed.slice(0, 57) + '...' : trimmed;
pendingSendRef.current = true;
onSend(recipient, trimmed, autoSummary, attachments.length > 0 ? attachments : undefined);
draft.clearDraft();
clearAttachments();
}, [canSend, recipient, trimmed, onSend, draft, attachments, clearAttachments]);
}, [canSend, recipient, trimmed, onSend, attachments]);
// Clear draft only after send completes successfully (sending: true → false, no error)
useEffect(() => {
if (!sending && pendingSendRef.current) {
pendingSendRef.current = false;
if (!sendError) {
draft.clearDraft();
clearAttachments();
}
}
}, [sending, sendError, draft, clearAttachments]);
const handleKeyDownCapture = useCallback(
(e: React.KeyboardEvent) => {

View file

@ -2,7 +2,7 @@ import * as React from 'react';
import { cn } from '@renderer/lib/utils';
import { Command as CommandPrimitive } from 'cmdk';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
@ -24,6 +24,10 @@ interface ComboboxProps {
disabled?: boolean;
className?: string;
renderOption?: (option: ComboboxOption, isSelected: boolean, query: string) => React.ReactNode;
/** Label for the reset item shown at the top of the dropdown. */
resetLabel?: string;
/** Called when the user clicks the reset item. */
onReset?: () => void;
}
export const Combobox = ({
@ -36,6 +40,8 @@ export const Combobox = ({
disabled = false,
className,
renderOption,
resetLabel,
onReset,
}: ComboboxProps): React.JSX.Element => {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
@ -91,6 +97,23 @@ export const Combobox = ({
<CommandPrimitive.Empty className="py-4 pr-2 text-center text-xs text-[var(--color-text-muted)]">
{emptyMessage}
</CommandPrimitive.Empty>
{onReset && value && !search.trim() ? (
<CommandPrimitive.Item
value="__reset__"
onSelect={() => {
onReset();
setOpen(false);
setSearch('');
}}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-0 pr-2 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
style={{ paddingLeft: 0 }}
>
<X className="mr-2 size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="text-[var(--color-text-muted)]">
{resetLabel ?? 'Reset selection'}
</span>
</CommandPrimitive.Item>
) : null}
{options
.filter((opt) => {
if (!search.trim()) return true;

View file

@ -22,7 +22,11 @@ import {
syncFocusedPaneState,
updatePane,
} from '../utils/paneHelpers';
import { getFullResetState, getWorktreeNavigationState } from '../utils/stateResetHelpers';
import {
getFullResetState,
getSessionResetState,
getWorktreeNavigationState,
} from '../utils/stateResetHelpers';
import type { AppState, SearchNavigationContext } from '../types';
import type { PaneLayout } from '@renderer/types/panes';
@ -55,6 +59,7 @@ export interface TabSlice {
// Project context actions
setActiveProject: (projectId: string) => void;
clearActiveProject: () => void;
// Per-tab UI state actions
setTabContextPanelVisible: (tabId: string, visible: boolean) => void;
@ -678,6 +683,16 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
get().selectProject(projectId);
},
clearActiveProject: () => {
set({
activeProjectId: null,
selectedProjectId: null,
selectedRepositoryId: null,
selectedWorktreeId: null,
...getSessionResetState(),
});
},
// Navigate to a session (from search or other sources)
navigateToSession: (
projectId: string,