diff --git a/desktop/src/features/messages/ui/ComposerUploadError.tsx b/desktop/src/features/messages/ui/ComposerUploadError.tsx new file mode 100644 index 0000000000..96b3b36128 --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerUploadError.tsx @@ -0,0 +1,20 @@ +import type { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; + +/** Display the upload failure without losing the composer's retry controls. */ +export function ComposerUploadError({ + uploadState, + onDismiss, +}: { + uploadState: ReturnType["uploadState"]; + onDismiss: () => void; +}) { + if (uploadState.status !== "error") return null; + return ( +
+ Upload failed: {uploadState.message} + +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index ed43796906..bf6bf1abbb 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -52,6 +52,7 @@ import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments"; import { focusMentionOptionsTrigger } from "./MentionAutocomplete"; import { MessageComposerAutocompletes } from "./MessageComposerAutocompletes"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; +import { ComposerUploadError } from "./ComposerUploadError"; import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useComposerVoiceNote } from "./useComposerVoiceNote"; @@ -897,18 +898,10 @@ function MessageComposerImpl({ onOptionsRevealComplete={completeMentionOptionsReveal} onToggleAlwaysAddressAgent={toggleAlwaysAddressAgent} /> - {media.uploadState.status === "error" ? ( -
- Upload failed: {media.uploadState.message} - -
- ) : null} + media.setUploadState({ status: "idle" })} + /> {composerLinkPreviews} => { + const cached = availableRuntimesQuery.data ?? []; + if (cached.length > 0 || !availableRuntimesQuery.isLoading) { + return cached; + } + const refetched = await availableRuntimesQuery.refetch(); + return (refetched.data ?? []).filter( + (runtime): runtime is AcpRuntime => + runtime.availability === "available" && + runtime.command !== null && + runtime.binaryPath !== null, + ); + }, [ + availableRuntimesQuery.data, + availableRuntimesQuery.isLoading, + availableRuntimesQuery.refetch, + ]); + return getAvailableRuntimes; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs index 9d73f7b7b5..6621b43cd2 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs @@ -216,3 +216,35 @@ test("automatic addressed prefix is programmatic optimistic empty, not new autho assert.equal(s.store.get("thread:a").content, TEXT); assert.deepEqual(s.store.get("thread:a").mentionRefs, s.refs); }); + +for (const extractor of [ + "getDraftMentionRefs", + "extractMentionPubkeys", + "extractMentionPersonas", +]) { + test(`synchronous ${extractor} failure is visible and releases the send latch`, async () => { + const s = await setup({ lifecycle: true }); + s.dismiss(); + s.options.mentions[extractor] = () => { + throw new Error("Choose an exact recipient"); + }; + s.rerender(); + const input = { + capturedChannelId: "general", + pendingImeta: [], + trimmed: TEXT, + recoveryDraftKey: "thread:a", + sentDraftKey: "thread:a", + }; + for (let attempt = 0; attempt < 2; attempt++) { + await s.act(async () => + s.result.current.sendMessageWithMentionFlow(input), + ); + assert.equal(s.result.current.isPreparingMentionSend, false); + assert.equal(s.options.contentRef.current, TEXT); + assert.equal(s.events("error").length, attempt + 1); + assert.equal(s.events("error").at(-1)[1], "Choose an exact recipient"); + assert.equal(s.events("SEND").length, 0); + } + }); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs index 9702463f18..305afd4f98 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs @@ -159,6 +159,10 @@ export async function setup({ lifecycle = false } = {}) { AgentMentionAuthorizationError: class extends Error {}, }, }; + stubs["./useMentionAvailableRuntimes"] = load( + "useMentionAvailableRuntimes", + stubs, + ); stubs["./useNonMemberInvite"] = load("useNonMemberInvite", stubs); stubs["./useActivePreparedLinkPreviews"] = load( "useActivePreparedLinkPreviews", diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index addc6a22eb..4585a2360a 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -4,7 +4,6 @@ import { toast } from "sonner"; import { type CreateChannelManagedAgentInput, useAttachManagedAgentToChannelMutation, - useAvailableAcpRuntimes, useCreateChannelManagedAgentMutation, useManagedAgentsQuery, usePersonasQuery, @@ -15,6 +14,7 @@ import { applyReusableAgentAccessPolicy } from "@/features/agents/channelAgents" import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; +import { useMentionAvailableRuntimes } from "./useMentionAvailableRuntimes"; import { useNonMemberInvite } from "./useNonMemberInvite"; import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgentMentionError"; import { @@ -27,7 +27,7 @@ import { } from "@/features/messages/lib/imetaMediaMarkdown"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { invokeTauri } from "@/shared/api/tauri"; -import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; +import type { ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { @@ -111,7 +111,6 @@ export function useMentionSendFlow({ useCreateChannelManagedAgentMutation(channelId); const provisionPersonaAgentMutation = useProvisionChannelManagedAgentMutation(channelId); - const availableRuntimesQuery = useAvailableAcpRuntimes(); const managedAgentsQuery = useManagedAgentsQuery(); const personasQuery = usePersonasQuery(); const startAgentMutation = useStartManagedAgentMutation(); @@ -127,25 +126,7 @@ export function useMentionSendFlow({ const getPersonas = React.useCallback(async () => { return personasQuery.data ?? (await personasQuery.refetch()).data ?? []; }, [personasQuery.data, personasQuery.refetch]); - const getAvailableRuntimes = React.useCallback(async (): Promise< - AcpRuntime[] - > => { - const cached = availableRuntimesQuery.data ?? []; - if (cached.length > 0 || !availableRuntimesQuery.isLoading) { - return cached; - } - const refetched = await availableRuntimesQuery.refetch(); - return (refetched.data ?? []).filter( - (runtime): runtime is AcpRuntime => - runtime.availability === "available" && - runtime.command !== null && - runtime.binaryPath !== null, - ); - }, [ - availableRuntimesQuery.data, - availableRuntimesQuery.isLoading, - availableRuntimesQuery.refetch, - ]); + const getAvailableRuntimes = useMentionAvailableRuntimes(); const ensureManagedAgentMentionsReady = React.useCallback( async ( mentionPubkeys: string[], @@ -669,7 +650,11 @@ export function useMentionSendFlow({ await finishSend(uploaded, signal); } catch (error) { restoreComposerAfterFailure(); - toast.error(error instanceof AgentMentionAuthorizationError ? error.message : formatMessageSendError(error)); + toast.error( + error instanceof AgentMentionAuthorizationError + ? error.message + : formatMessageSendError(error), + ); } finally { settleUpload(); } @@ -697,7 +682,11 @@ export function useMentionSendFlow({ await finishSend([]); } catch (error) { restoreComposerAfterFailure(); - toast.error(error instanceof AgentMentionAuthorizationError ? error.message : formatMessageSendError(error)); + toast.error( + error instanceof AgentMentionAuthorizationError + ? error.message + : formatMessageSendError(error), + ); } } } catch (error) { @@ -762,20 +751,22 @@ export function useMentionSendFlow({ } isMentionSendPendingRef.current = true; setIsMentionSendPending(true); - // Capture exact selections before any async preparation can navigate the - // reused editor to another draft (possibly with identical display text). - claimDraftSend(effectiveDraftKey); - const composerRevision = getComposerRevision(); - const savedMentionRefs = mentions.getDraftMentionRefs(trimmed).slice(); - const selectedMentionPubkeys = mentions.extractMentionPubkeys(trimmed); - const selectedPersonas = mentions.extractMentionPersonas(trimmed); - const isSendCancelled = () => - preparedLinkPreviews?.signal.aborted === true; let sendPromoted = false; - if (preparedLinkPreviews) { - activePreparedLinkPreviews.add(preparedLinkPreviews); - } try { + // Capture exact selections before any async preparation can navigate the + // reused editor to another draft (possibly with identical display text). + // Ambiguous-name extraction can throw; it owns the same visible failure + // and pending-state cleanup as the asynchronous preparation below. + claimDraftSend(effectiveDraftKey); + const composerRevision = getComposerRevision(); + const savedMentionRefs = mentions.getDraftMentionRefs(trimmed).slice(); + const selectedMentionPubkeys = mentions.extractMentionPubkeys(trimmed); + const selectedPersonas = mentions.extractMentionPersonas(trimmed); + const isSendCancelled = () => + preparedLinkPreviews?.signal.aborted === true; + if (preparedLinkPreviews) { + activePreparedLinkPreviews.add(preparedLinkPreviews); + } if (isSendCancelled()) return; const dmThreadAgentMentionErrorMessage = dmThreadAgentMentionError({ trimmed, diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 178f0c4b20..176321eea1 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -14,7 +14,6 @@ import { type ParsedMessageLink, } from "@/features/messages/lib/messageLink"; import { renderAudioMessageAttachment } from "@/features/messages/ui/AudioMessageAttachment"; -import { invokeTauri } from "@/shared/api/tauri"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { cn } from "@/shared/lib/cn"; import { parseEntityLink } from "@/shared/lib/entityLink";