mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
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:
20
desktop/src/features/messages/ui/ComposerUploadError.tsx
Normal file
20
desktop/src/features/messages/ui/ComposerUploadError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" ? (
|
||||
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Upload failed: {media.uploadState.message}
|
||||
<button
|
||||
className="ml-2 underline"
|
||||
onClick={() => media.setUploadState({ status: "idle" })}
|
||||
type="button"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<ComposerUploadError
|
||||
uploadState={media.uploadState}
|
||||
onDismiss={() => media.setUploadState({ status: "idle" })}
|
||||
/>
|
||||
{composerLinkPreviews}
|
||||
<output
|
||||
aria-live="polite"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user