From 47d266b55bb648ee00e1bdf27cab2cd11604dc33 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 24 Feb 2026 16:01:44 +0200 Subject: [PATCH] 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. --- src/main/ipc/teams.ts | 47 ++++++++++++------- .../services/team/TeamSentMessagesStore.ts | 24 +++++++--- .../components/layout/SidebarHeader.tsx | 4 ++ .../components/team/TeamDetailView.tsx | 32 ++++++++++++- .../team/attachments/DropZoneOverlay.tsx | 13 ++++- .../components/team/kanban/KanbanBoard.tsx | 32 +++++++------ .../components/team/kanban/KanbanColumn.tsx | 21 ++------- .../team/messages/MessageComposer.tsx | 21 +++++++-- src/renderer/components/ui/combobox.tsx | 25 +++++++++- src/renderer/store/slices/tabSlice.ts | 17 ++++++- 10 files changed, 171 insertions(+), 65 deletions(-) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 53109998..b8b8d754 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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 } } diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index 893c6f93..6b1e6eb2 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -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 { - 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)}`); + } } } diff --git a/src/renderer/components/layout/SidebarHeader.tsx b/src/renderer/components/layout/SidebarHeader.tsx index 457c99ef..c7f9f443 100644 --- a/src/renderer/components/layout/SidebarHeader.tsx +++ b/src/renderer/components/layout/SidebarHeader.tsx @@ -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; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 04885796..b57908cb 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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(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 => { + 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

{data.config.name}

+ {data.isAlive && ( + + + + + Stop team + + )} + ) : (
No tasks
@@ -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 ( {renderCards(column.id, columnTasks)} @@ -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 (
{renderCards(column.id, columnTasks)} diff --git a/src/renderer/components/team/kanban/KanbanColumn.tsx b/src/renderer/components/team/kanban/KanbanColumn.tsx index 0b6b7fe9..61a9bf03 100644 --- a/src/renderer/components/team/kanban/KanbanColumn.tsx +++ b/src/renderer/components/team/kanban/KanbanColumn.tsx @@ -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} -
- {onAddTask ? ( - - ) : null} - - {count} - -
+ + {count} +
{children}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 76750ed1..50e4e52e 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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) => { diff --git a/src/renderer/components/ui/combobox.tsx b/src/renderer/components/ui/combobox.tsx index 991ca4a9..b7abea58 100644 --- a/src/renderer/components/ui/combobox.tsx +++ b/src/renderer/components/ui/combobox.tsx @@ -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 = ({ {emptyMessage} + {onReset && value && !search.trim() ? ( + { + 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 }} + > + + + {resetLabel ?? 'Reset selection'} + + + ) : null} {options .filter((opt) => { if (!search.trim()) return true; diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index 09e423dd..a6e4091a 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -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 = (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,