fix(desktop): retain drafts after synchronous mention extraction failure

Replay the approved first slice from 857ed203 and e0cc5522 (aggregate 58db416d). Keep extraction within failure cleanup; use the existing runtime lookup and upload error rendering through size-gate helper extractions. Remove the inherited unused markdown import for strict builds.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-01 13:03:16 -04:00
parent 802ba6170e
commit db566eb57d
7 changed files with 116 additions and 49 deletions

View File

@@ -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<typeof useMediaUpload>["uploadState"];
onDismiss: () => void;
}) {
if (uploadState.status !== "error") return null;
return (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Upload failed: {uploadState.message}
<button className="ml-2 underline" onClick={onDismiss} type="button">
Dismiss
</button>
</div>
);
}

View File

@@ -52,6 +52,7 @@ import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments";
import { focusMentionOptionsTrigger } from "./MentionAutocomplete"; import { focusMentionOptionsTrigger } from "./MentionAutocomplete";
import { MessageComposerAutocompletes } from "./MessageComposerAutocompletes"; import { MessageComposerAutocompletes } from "./MessageComposerAutocompletes";
import { ComposerDockToolbar } from "./ComposerDockToolbar"; import { ComposerDockToolbar } from "./ComposerDockToolbar";
import { ComposerUploadError } from "./ComposerUploadError";
import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill";
import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
import { useComposerVoiceNote } from "./useComposerVoiceNote"; import { useComposerVoiceNote } from "./useComposerVoiceNote";
@@ -897,18 +898,10 @@ function MessageComposerImpl({
onOptionsRevealComplete={completeMentionOptionsReveal} onOptionsRevealComplete={completeMentionOptionsReveal}
onToggleAlwaysAddressAgent={toggleAlwaysAddressAgent} onToggleAlwaysAddressAgent={toggleAlwaysAddressAgent}
/> />
{media.uploadState.status === "error" ? ( <ComposerUploadError
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"> uploadState={media.uploadState}
Upload failed: {media.uploadState.message} onDismiss={() => media.setUploadState({ status: "idle" })}
<button />
className="ml-2 underline"
onClick={() => media.setUploadState({ status: "idle" })}
type="button"
>
Dismiss
</button>
</div>
) : null}
{composerLinkPreviews} {composerLinkPreviews}
<output <output
aria-live="polite" aria-live="polite"

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import { useAvailableAcpRuntimes } from "@/features/agents/hooks";
import type { AcpRuntime } from "@/shared/api/types";
/** Resolve available runtimes for persona mentions without refetching warm data. */
export function useMentionAvailableRuntimes() {
const availableRuntimesQuery = useAvailableAcpRuntimes();
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,
]);
return getAvailableRuntimes;
}

View File

@@ -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.equal(s.store.get("thread:a").content, TEXT);
assert.deepEqual(s.store.get("thread:a").mentionRefs, s.refs); 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);
}
});
}

View File

@@ -159,6 +159,10 @@ export async function setup({ lifecycle = false } = {}) {
AgentMentionAuthorizationError: class extends Error {}, AgentMentionAuthorizationError: class extends Error {},
}, },
}; };
stubs["./useMentionAvailableRuntimes"] = load(
"useMentionAvailableRuntimes",
stubs,
);
stubs["./useNonMemberInvite"] = load("useNonMemberInvite", stubs); stubs["./useNonMemberInvite"] = load("useNonMemberInvite", stubs);
stubs["./useActivePreparedLinkPreviews"] = load( stubs["./useActivePreparedLinkPreviews"] = load(
"useActivePreparedLinkPreviews", "useActivePreparedLinkPreviews",

View File

@@ -4,7 +4,6 @@ import { toast } from "sonner";
import { import {
type CreateChannelManagedAgentInput, type CreateChannelManagedAgentInput,
useAttachManagedAgentToChannelMutation, useAttachManagedAgentToChannelMutation,
useAvailableAcpRuntimes,
useCreateChannelManagedAgentMutation, useCreateChannelManagedAgentMutation,
useManagedAgentsQuery, useManagedAgentsQuery,
usePersonasQuery, usePersonasQuery,
@@ -15,6 +14,7 @@ import { applyReusableAgentAccessPolicy } from "@/features/agents/channelAgents"
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { useAddChannelMembersMutation } from "@/features/channels/hooks";
import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers";
import { useMentionAvailableRuntimes } from "./useMentionAvailableRuntimes";
import { useNonMemberInvite } from "./useNonMemberInvite"; import { useNonMemberInvite } from "./useNonMemberInvite";
import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgentMentionError"; import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgentMentionError";
import { import {
@@ -27,7 +27,7 @@ import {
} from "@/features/messages/lib/imetaMediaMarkdown"; } from "@/features/messages/lib/imetaMediaMarkdown";
import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews";
import { invokeTauri } from "@/shared/api/tauri"; 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 { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags";
import { import {
@@ -111,7 +111,6 @@ export function useMentionSendFlow({
useCreateChannelManagedAgentMutation(channelId); useCreateChannelManagedAgentMutation(channelId);
const provisionPersonaAgentMutation = const provisionPersonaAgentMutation =
useProvisionChannelManagedAgentMutation(channelId); useProvisionChannelManagedAgentMutation(channelId);
const availableRuntimesQuery = useAvailableAcpRuntimes();
const managedAgentsQuery = useManagedAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery();
const personasQuery = usePersonasQuery(); const personasQuery = usePersonasQuery();
const startAgentMutation = useStartManagedAgentMutation(); const startAgentMutation = useStartManagedAgentMutation();
@@ -127,25 +126,7 @@ export function useMentionSendFlow({
const getPersonas = React.useCallback(async () => { const getPersonas = React.useCallback(async () => {
return personasQuery.data ?? (await personasQuery.refetch()).data ?? []; return personasQuery.data ?? (await personasQuery.refetch()).data ?? [];
}, [personasQuery.data, personasQuery.refetch]); }, [personasQuery.data, personasQuery.refetch]);
const getAvailableRuntimes = React.useCallback(async (): Promise< const getAvailableRuntimes = useMentionAvailableRuntimes();
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 ensureManagedAgentMentionsReady = React.useCallback( const ensureManagedAgentMentionsReady = React.useCallback(
async ( async (
mentionPubkeys: string[], mentionPubkeys: string[],
@@ -669,7 +650,11 @@ export function useMentionSendFlow({
await finishSend(uploaded, signal); await finishSend(uploaded, signal);
} catch (error) { } catch (error) {
restoreComposerAfterFailure(); restoreComposerAfterFailure();
toast.error(error instanceof AgentMentionAuthorizationError ? error.message : formatMessageSendError(error)); toast.error(
error instanceof AgentMentionAuthorizationError
? error.message
: formatMessageSendError(error),
);
} finally { } finally {
settleUpload(); settleUpload();
} }
@@ -697,7 +682,11 @@ export function useMentionSendFlow({
await finishSend([]); await finishSend([]);
} catch (error) { } catch (error) {
restoreComposerAfterFailure(); restoreComposerAfterFailure();
toast.error(error instanceof AgentMentionAuthorizationError ? error.message : formatMessageSendError(error)); toast.error(
error instanceof AgentMentionAuthorizationError
? error.message
: formatMessageSendError(error),
);
} }
} }
} catch (error) { } catch (error) {
@@ -762,20 +751,22 @@ export function useMentionSendFlow({
} }
isMentionSendPendingRef.current = true; isMentionSendPendingRef.current = true;
setIsMentionSendPending(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; let sendPromoted = false;
if (preparedLinkPreviews) {
activePreparedLinkPreviews.add(preparedLinkPreviews);
}
try { 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; if (isSendCancelled()) return;
const dmThreadAgentMentionErrorMessage = dmThreadAgentMentionError({ const dmThreadAgentMentionErrorMessage = dmThreadAgentMentionError({
trimmed, trimmed,

View File

@@ -14,7 +14,6 @@ import {
type ParsedMessageLink, type ParsedMessageLink,
} from "@/features/messages/lib/messageLink"; } from "@/features/messages/lib/messageLink";
import { renderAudioMessageAttachment } from "@/features/messages/ui/AudioMessageAttachment"; import { renderAudioMessageAttachment } from "@/features/messages/ui/AudioMessageAttachment";
import { invokeTauri } from "@/shared/api/tauri";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
import { parseEntityLink } from "@/shared/lib/entityLink"; import { parseEntityLink } from "@/shared/lib/entityLink";