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
|
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue