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 // Smart routing: lead + alive → stdin direct, else → inbox
if (isLeadRecipient && isAlive) { 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 { try {
await provisioning.sendMessageToTeam(tn, payload.text!, validatedAttachments); 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( if (stdinSent) {
tn, // Persistence is best-effort — stdin already delivered the message
leadName, let result: SendMessageResult;
payload.text!, try {
payload.summary 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) => ({ const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({
id: a.id, id: a.id,
@ -825,17 +849,6 @@ async function handleSendMessage(
}); });
return result; 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 { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -7,6 +8,7 @@ import { atomicWriteAsync } from './atomicWrite';
import type { InboxMessage } from '@shared/types'; import type { InboxMessage } from '@shared/types';
const MAX_MESSAGES = 200; const MAX_MESSAGES = 200;
const logger = createLogger('TeamSentMessagesStore');
export class TeamSentMessagesStore { export class TeamSentMessagesStore {
private getFilePath(teamName: string): string { private getFilePath(teamName: string): string {
@ -23,7 +25,9 @@ export class TeamSentMessagesStore {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return []; 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; let parsed: unknown;
@ -48,6 +52,7 @@ export class TeamSentMessagesStore {
) { ) {
continue; continue;
} }
// Bug #5: preserve optional fields (attachments, color)
messages.push({ messages.push({
from: row.from, from: row.from,
to: typeof row.to === 'string' ? row.to : undefined, to: typeof row.to === 'string' ? row.to : undefined,
@ -56,6 +61,8 @@ export class TeamSentMessagesStore {
read: typeof row.read === 'boolean' ? row.read : true, read: typeof row.read === 'boolean' ? row.read : true,
summary: typeof row.summary === 'string' ? row.summary : undefined, summary: typeof row.summary === 'string' ? row.summary : undefined,
messageId: typeof row.messageId === 'string' ? row.messageId : 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', source: 'user_sent',
}); });
} }
@ -64,12 +71,17 @@ export class TeamSentMessagesStore {
} }
async appendMessage(teamName: string, message: InboxMessage): Promise<void> { async appendMessage(teamName: string, message: InboxMessage): Promise<void> {
const existing = await this.readMessages(teamName); // Bug #6: wrap in try/catch to prevent crash on IO errors
existing.push(message); try {
const existing = await this.readMessages(teamName);
existing.push(message);
// Trim to MAX_MESSAGES (keep newest) // Trim to MAX_MESSAGES (keep newest)
const trimmed = existing.length > MAX_MESSAGES ? existing.slice(-MAX_MESSAGES) : existing; 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, projects,
activeProjectId, activeProjectId,
setActiveProject, setActiveProject,
clearActiveProject,
fetchRepositoryGroups, fetchRepositoryGroups,
fetchProjects, fetchProjects,
toggleSidebar, toggleSidebar,
@ -169,6 +170,7 @@ export const SidebarHeader = (): React.JSX.Element => {
projects: s.projects, projects: s.projects,
activeProjectId: s.activeProjectId, activeProjectId: s.activeProjectId,
setActiveProject: s.setActiveProject, setActiveProject: s.setActiveProject,
clearActiveProject: s.clearActiveProject,
fetchRepositoryGroups: s.fetchRepositoryGroups, fetchRepositoryGroups: s.fetchRepositoryGroups,
fetchProjects: s.fetchProjects, fetchProjects: s.fetchProjects,
toggleSidebar: s.toggleSidebar, toggleSidebar: s.toggleSidebar,
@ -279,6 +281,8 @@ export const SidebarHeader = (): React.JSX.Element => {
: 'Nothing found' : 'Nothing found'
} }
className="text-sm font-medium" className="text-sm font-medium"
resetLabel="Reset selection"
onReset={clearActiveProject}
renderOption={(option, isSelected) => { renderOption={(option, isSelected) => {
const sessionCount = (option.meta?.sessionCount as number) ?? 0; const sessionCount = (option.meta?.sessionCount as number) ?? 0;
const path = option.meta?.path as string | undefined; const path = option.meta?.path as string | undefined;

View file

@ -24,6 +24,7 @@ import {
Play, Play,
Plus, Plus,
Search, Search,
Square,
Trash2, Trash2,
UserPlus, UserPlus,
X, X,
@ -107,6 +108,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const [launchDialogOpen, setLaunchDialogOpen] = useState(false); const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
const [sendDialogOpen, setSendDialogOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [stoppingTeam, setStoppingTeam] = useState(false);
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined); const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
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 => { const handleDeleteTeam = useCallback((): void => {
setDeleteConfirmOpen(true); 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> <h2 className="text-base font-semibold text-[var(--color-text)]">{data.config.name}</h2>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -603,7 +633,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
)} )}
</div> </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"> <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> <span className="text-xs text-amber-200">Team is offline</span>
<Button <Button

View file

@ -8,8 +8,17 @@ export const DropZoneOverlay = ({ active }: DropZoneOverlayProps): React.JSX.Ele
if (!active) return null; if (!active) return null;
return ( 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
<div className="flex flex-col items-center gap-1.5 text-blue-400"> 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} /> <ImagePlus size={24} />
<span className="text-xs font-medium">Drop images here</span> <span className="text-xs font-medium">Drop images here</span>
</div> </div>

View file

@ -14,6 +14,7 @@ import {
Eye, Eye,
LayoutGrid, LayoutGrid,
PlayCircle, PlayCircle,
Plus,
ShieldCheck, ShieldCheck,
} from 'lucide-react'; } from 'lucide-react';
@ -280,7 +281,22 @@ export const KanbanBoard = ({
const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => { const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => {
if (columnTasks.length === 0) { 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)]"> <div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
No tasks No tasks
</div> </div>
@ -398,12 +414,6 @@ export const KanbanBoard = ({
{COLUMNS.map((column) => { {COLUMNS.map((column) => {
const columnTasks = groupedOrdered.get(column.id) ?? []; const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[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 ( return (
<KanbanColumn <KanbanColumn
key={column.id} key={column.id}
@ -412,7 +422,6 @@ export const KanbanBoard = ({
icon={accent.icon} icon={accent.icon}
headerBg={accent.headerBg} headerBg={accent.headerBg}
bodyBg={accent.bodyBg} bodyBg={accent.bodyBg}
onAddTask={addHandler}
> >
{renderCards(column.id, columnTasks)} {renderCards(column.id, columnTasks)}
</KanbanColumn> </KanbanColumn>
@ -424,12 +433,6 @@ export const KanbanBoard = ({
{COLUMNS.map((column) => { {COLUMNS.map((column) => {
const columnTasks = groupedOrdered.get(column.id) ?? []; const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[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 ( return (
<div key={column.id} className="w-64 shrink-0"> <div key={column.id} className="w-64 shrink-0">
<KanbanColumn <KanbanColumn
@ -438,7 +441,6 @@ export const KanbanBoard = ({
icon={accent.icon} icon={accent.icon}
headerBg={accent.headerBg} headerBg={accent.headerBg}
bodyBg={accent.bodyBg} bodyBg={accent.bodyBg}
onAddTask={addHandler}
> >
{renderCards(column.id, columnTasks)} {renderCards(column.id, columnTasks)}
</KanbanColumn> </KanbanColumn>

View file

@ -1,6 +1,5 @@
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { Plus } from 'lucide-react';
interface KanbanColumnProps { interface KanbanColumnProps {
title: string; title: string;
@ -8,7 +7,6 @@ interface KanbanColumnProps {
icon?: React.ReactNode; icon?: React.ReactNode;
headerBg?: string; headerBg?: string;
bodyBg?: string; bodyBg?: string;
onAddTask?: () => void;
children: React.ReactNode; children: React.ReactNode;
} }
@ -18,7 +16,6 @@ export const KanbanColumn = ({
icon, icon,
headerBg, headerBg,
bodyBg, bodyBg,
onAddTask,
children, children,
}: KanbanColumnProps): React.JSX.Element => { }: KanbanColumnProps): React.JSX.Element => {
return ( return (
@ -37,21 +34,9 @@ export const KanbanColumn = ({
{icon} {icon}
{title} {title}
</h4> </h4>
<div className="flex items-center gap-1.5"> <Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
{onAddTask ? ( {count}
<button </Badge>
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>
</header> </header>
<div className="flex max-h-[480px] flex-col gap-2 overflow-auto p-2">{children}</div> <div className="flex max-h-[480px] flex-col gap-2 overflow-auto p-2">{children}</div>
</section> </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 { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; 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 isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const canAttach = isLeadRecipient && isTeamAlive && canAddMore; const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
// Track whether we initiated a send — clear draft only on confirmed success
const pendingSendRef = useRef(false);
const handleSend = useCallback(() => { const handleSend = useCallback(() => {
if (!canSend) return; if (!canSend) return;
const autoSummary = trimmed.length > 60 ? trimmed.slice(0, 57) + '...' : trimmed; const autoSummary = trimmed.length > 60 ? trimmed.slice(0, 57) + '...' : trimmed;
pendingSendRef.current = true;
onSend(recipient, trimmed, autoSummary, attachments.length > 0 ? attachments : undefined); onSend(recipient, trimmed, autoSummary, attachments.length > 0 ? attachments : undefined);
draft.clearDraft(); }, [canSend, recipient, trimmed, onSend, attachments]);
clearAttachments();
}, [canSend, recipient, trimmed, onSend, draft, attachments, clearAttachments]); // 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( const handleKeyDownCapture = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {

View file

@ -2,7 +2,7 @@ import * as React from 'react';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { Command as CommandPrimitive } from 'cmdk'; 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'; import { Popover, PopoverContent, PopoverTrigger } from './popover';
@ -24,6 +24,10 @@ interface ComboboxProps {
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
renderOption?: (option: ComboboxOption, isSelected: boolean, query: string) => React.ReactNode; 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 = ({ export const Combobox = ({
@ -36,6 +40,8 @@ export const Combobox = ({
disabled = false, disabled = false,
className, className,
renderOption, renderOption,
resetLabel,
onReset,
}: ComboboxProps): React.JSX.Element => { }: ComboboxProps): React.JSX.Element => {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState(''); 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)]"> <CommandPrimitive.Empty className="py-4 pr-2 text-center text-xs text-[var(--color-text-muted)]">
{emptyMessage} {emptyMessage}
</CommandPrimitive.Empty> </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 {options
.filter((opt) => { .filter((opt) => {
if (!search.trim()) return true; if (!search.trim()) return true;

View file

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