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 { 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"
|
||||||
|
|||||||
@@ -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.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user