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:
parent
683146c45d
commit
47d266b55b
10 changed files with 171 additions and 65 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue