From 7717e17f929bfd78ceb0596ebbcd068ff8935c2d Mon Sep 17 00:00:00 2001 From: NikitaMGrimm <65914808+NikitaMGrimm@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:02:44 +0200 Subject: [PATCH 1/5] fix(composer): don't send attached threads to a machine that can't read them --- .../features/threads/NewTaskDraftScreen.tsx | 17 ++++++++++ .../threads/new-task-flow-provider.tsx | 11 +++++++ apps/web/src/components/ChatView.tsx | 25 ++++++++++++++ apps/web/src/components/ThreadContextChip.tsx | 11 ++++++- .../composerContextPresentation.tsx | 13 +++++++- .../src/composerContextReferences.test.ts | 33 +++++++++++++++++++ .../shared/src/composerContextReferences.ts | 23 +++++++++++++ 7 files changed, 131 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 181c257cbc17..b65a7e3fc33f 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -67,6 +67,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; +import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { useComposerCommandMenu } from "./use-composer-command-menu"; @@ -1290,6 +1291,22 @@ export function NewTaskDraftScreen(props: { Alert.alert("Too much context", contextBlockReason); return; } + const strandedThread = threadContextsOutsideEnvironment({ + text: draft.text, + records: draft.context?.records ?? [], + environmentId: selectedProject.environmentId, + })[0]; + if (strandedThread) { + const owner = + flow.environments.find( + (environment) => environment.environmentId === strandedThread.environmentId, + )?.environmentLabel ?? "another machine"; + Alert.alert( + "Attached thread is on another machine", + `This machine's agent can't read "${strandedThread.title}". Remove it, or switch back to ${owner} to send.`, + ); + return; + } const editingPendingTask = flow.editingPendingTask; diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 9a523c6bb0b0..7da2957ff825 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -24,6 +24,7 @@ import { ThreadId, } from "@t3tools/contracts"; import { sanitizeNewRefName } from "@t3tools/shared/git"; +import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import * as Arr from "effect/Array"; @@ -1108,6 +1109,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (text.length === 0 || !draftModelSelection) { return null; } + // A thread attached on another machine is unreadable here; the submit path explains why. + if ( + threadContextsOutsideEnvironment({ + text: draft.text, + records: draft.context?.records ?? [], + environmentId: selectedProject.environmentId, + }).length > 0 + ) { + return null; + } // A saved choice from before the project went no-project must not // survive: those threads always run locally in their own folder. const workspaceSelection = canChooseWorkspace ? draft.workspaceSelection : undefined; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dbee50a28ac7..5fbb7879baca 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -39,6 +39,7 @@ import { import { feedbackBannerItem } from "./chat/ComposerFeedback"; import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences"; import * as Schema from "effect/Schema"; import { questionAttachmentDraftId, @@ -1805,6 +1806,9 @@ export default function ChatView(props: ChatViewProps) { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; }); + const draftThreadContexts = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.threadContexts ?? null, + ); // Anything beyond the prompt text: attachments, terminal or element contexts, annotations. const composerHasNonPromptContent = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); @@ -4351,6 +4355,10 @@ export default function ChatView(props: ChatViewProps) { return ( environment?.connection.phase === "connected" && (loadBalancingSettings.loadBalancingWeights[candidate.environmentId] ?? 50) > 0 && + // Only a machine that owns every attached thread can read them. + (draftThreadContexts ?? []).every( + (record) => record.environmentId === candidate.environmentId, + ) && environment.serverConfig?.providers.some( (provider) => (activeProviderInstanceId === null || @@ -4370,6 +4378,7 @@ export default function ChatView(props: ChatViewProps) { needsLoadBalancing, logicalProjectEnvironments, environmentById, + draftThreadContexts, loadBalancingSettings.loadBalancingWeights, activeProviderInstanceId, selectedProvider, @@ -8900,6 +8909,22 @@ export default function ChatView(props: ChatViewProps) { previewAnnotationContextReference(directAnnotation.annotation), ]) : promptRef.current; + const strandedThread = threadContextsOutsideEnvironment({ + text: promptForSend, + records: composerThreadContexts, + environmentId, + })[0]; + if (strandedThread) { + const owner = environmentById.get(strandedThread.environmentId)?.label ?? "another machine"; + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Attached thread is on another machine", + description: `This machine's agent can't read "${strandedThread.title}". Remove it, or switch back to ${owner} to send.`, + }), + ); + return; + } if (editingQueuedRun !== null) { // Edit mode repurposes the composer: sending saves the queued message // in place instead of dispatching a new turn. diff --git a/apps/web/src/components/ThreadContextChip.tsx b/apps/web/src/components/ThreadContextChip.tsx index 17f1c20d8568..6bfda1e3453c 100644 --- a/apps/web/src/components/ThreadContextChip.tsx +++ b/apps/web/src/components/ThreadContextChip.tsx @@ -9,10 +9,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; /** * Inline chip for an attached thread, in the composer and in sent messages. Prefers the * live title so a renamed thread never shows a stale label, and opens the thread on click. + * `unreadable` marks a composer chip whose thread lives on another machine than the draft. */ export function ThreadContextChip(props: { record: Pick; copyMarkdown?: string; + unreadable?: boolean; }) { const { environmentId, threadId } = props.record; const shell = useThreadShell({ environmentId, threadId }); @@ -23,6 +25,7 @@ export function ThreadContextChip(props: { render={ } aria-label={`Thread, ${title}`} data-markdown-copy={props.copyMarkdown} @@ -33,7 +36,13 @@ export function ThreadContextChip(props: { } /> - {shell ? "Open thread" : "Thread no longer available"} + + {props.unreadable + ? "On another machine, so this machine's agent can't read it. Remove it or switch back to send." + : shell + ? "Open thread" + : "Thread no longer available"} + ); } diff --git a/apps/web/src/components/composerContextPresentation.tsx b/apps/web/src/components/composerContextPresentation.tsx index e8e493be73c6..a9562da73b9a 100644 --- a/apps/web/src/components/composerContextPresentation.tsx +++ b/apps/web/src/components/composerContextPresentation.tsx @@ -329,6 +329,17 @@ function ComposerPreviewAnnotationDetails({ ); } +/** Sending is blocked while an attached thread lives on another machine than the draft. */ +function ComposerThreadContextChip(props: { record: ThreadContextRecord }) { + const { environmentId } = use(ComposerContextActionsContext); + return ( + + ); +} + function UnresolvedContextChip(props: { label: string }) { return ( entry.kind === "thread", render: (entry, context) => entry.kind === "thread" ? ( - + ) : ( ), diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index bd078c8f1d44..f9ec57655391 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -10,6 +10,7 @@ import { projectComposerContextForProvider, replaceComposerContextReferences, sanitizeComposerContextLabel, + threadContextsOutsideEnvironment, } from "./composerContextReferences.ts"; const ctx = (value: string) => value as ComposerContextId; @@ -286,3 +287,35 @@ describe("provider projection", () => { expect(projected).not.toContain("boom"); }); }); + +describe("threadContextsOutsideEnvironment", () => { + const thread = (id: string, environmentId: string): ComposerContextRecord => ({ + version: 1, + kind: "thread", + contextId: ctx(`thread_${id}`), + label: id, + environmentId: environmentId as never, + threadId: id as never, + title: id, + }); + const text = "[a](t3-context://v1/thread/thread_a) [b](t3-context://v1/thread/thread_b)"; + + it("returns referenced threads owned by another environment", () => { + const stranded = threadContextsOutsideEnvironment({ + text, + records: [thread("a", "env-a"), thread("b", "env-b")], + environmentId: "env-b" as never, + }); + expect(stranded.map((record) => record.threadId)).toEqual(["a"]); + }); + + it("ignores records whose chip was removed from the text", () => { + expect( + threadContextsOutsideEnvironment({ + text: "no references left", + records: [thread("a", "env-a")], + environmentId: "env-b" as never, + }), + ).toEqual([]); + }); +}); diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index f7877fe937a6..79cb88216fcc 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -4,7 +4,9 @@ import { type ComposerContextKind, type ComposerContextRecord, type ElementContextDetails, + type EnvironmentId, type KnownComposerContextRecord, + type ThreadContextRecord, } from "@t3tools/contracts"; /** @@ -297,6 +299,27 @@ export function projectComposerContextForProvider(input: { return `${body}\n\n<${CONTEXT_ENVELOPE_TAG} version="1">\n${entries.join("\n")}\n`; } +/** + * Attached threads an agent on `environmentId` cannot read: `t3_thread_read` only sees its own + * server's threads, so a draft moved to another machine strands these references. + */ +export function threadContextsOutsideEnvironment(input: { + text: string; + records: ReadonlyArray; + environmentId: EnvironmentId; +}): ThreadContextRecord[] { + const referenced = new Set( + collectComposerContextReferences(input.text).map((occurrence) => occurrence.contextId), + ); + return input.records.filter( + (record): record is ThreadContextRecord => + record.kind === "thread" && + "threadId" in record && + referenced.has(record.contextId) && + record.environmentId !== input.environmentId, + ); +} + /** Preserve context bindings when uploads become thread-owned attachments. */ export function remapComposerContextAttachments( context: import("@t3tools/contracts").OrchestrationMessageContext | undefined, From 7bc59990a6f0eaf14a8e9c39efd40fd126d06a14 Mon Sep 17 00:00:00 2001 From: NikitaMGrimm <65914808+NikitaMGrimm@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:08:14 +0200 Subject: [PATCH 2/5] fix(mobile): drop pasted threads owned by another machine Also explain attached threads when Auto routing finds no machine that can read them. --- apps/mobile/src/components/ComposerEditor.tsx | 1 + .../features/threads/NewTaskDraftScreen.tsx | 2 +- .../src/lib/composerContextClipboard.test.ts | 30 ++++++++++++++++++- .../src/lib/composerContextClipboard.ts | 8 +++++ apps/web/src/components/ChatView.tsx | 6 ++-- 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 1eff2e899500..054de4a3f2eb 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -101,6 +101,7 @@ export function ComposerEditor({ retained.attachments.length, controller.signal, retained.context?.records.length ?? 0, + environmentId, ); if (!result) { insertComposerDraftText(draftKey, clipboard.text, insertion); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index b65a7e3fc33f..161dadbda978 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1300,7 +1300,7 @@ export function NewTaskDraftScreen(props: { const owner = flow.environments.find( (environment) => environment.environmentId === strandedThread.environmentId, - )?.environmentLabel ?? "another machine"; + )?.environmentLabel ?? "its machine"; Alert.alert( "Attached thread is on another machine", `This machine's agent can't read "${strandedThread.title}". Remove it, or switch back to ${owner} to send.`, diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index f3a3a3e83b08..ed6c46cd11eb 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { ComposerContextId, EnvironmentId } from "@t3tools/contracts"; +import { ComposerContextId, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; const mocks = vi.hoisted(() => ({ @@ -142,6 +142,34 @@ describe("mobile context clipboard imports", () => { expect(mocks.download).not.toHaveBeenCalled(); }); + it("drops threads owned by another machine than the destination", async () => { + const thread = (id: string, environmentId: string) => ({ + version: 1 as const, + kind: "thread" as const, + contextId: ComposerContextId.make(id), + label: id, + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(id), + title: id, + }); + const result = await importComposerContextClipboard( + { + text: "[local](t3-context://v1/thread/local) [foreign](t3-context://v1/thread/foreign)", + fragment: encodeComposerContextFragment({ + version: 1, + source: { environmentId: EnvironmentId.make("source") }, + records: [thread("local", "destination"), thread("foreign", "source")], + })!, + html: "", + }, + 0, + new AbortController().signal, + 0, + EnvironmentId.make("destination"), + ); + expect(result?.context.records.map((record) => record.label)).toEqual(["local"]); + }); + it("refuses an overflowing context paste before copying files", async () => { await expect( importComposerContextClipboard(clipboard, 0, new AbortController().signal, 200), diff --git a/apps/mobile/src/lib/composerContextClipboard.ts b/apps/mobile/src/lib/composerContextClipboard.ts index bbdd862170d1..83fe4e3bbc8f 100644 --- a/apps/mobile/src/lib/composerContextClipboard.ts +++ b/apps/mobile/src/lib/composerContextClipboard.ts @@ -66,6 +66,7 @@ export async function importComposerContextClipboard( existingCount: number, signal: AbortSignal, existingContextCount = 0, + destinationEnvironmentId?: EnvironmentId, ) { const fragment = decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html); @@ -81,6 +82,13 @@ export async function importComposerContextClipboard( for (const record of imported.context.records) { checkAborted(signal); if (!("attachmentId" in record)) { + // The agent can only read threads on its own server; a pasted foreign one is dropped. + if ( + record.kind === "thread" && + "threadId" in record && + record.environmentId !== destinationEnvironmentId + ) + continue; records.push(record); continue; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5fbb7879baca..7f5ac46d66e1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8799,7 +8799,9 @@ export default function ChatView(props: ChatViewProps) { : "Choose a machine to continue", description: loadBalancing.pending ? "Resource checks are still running. You can choose a machine in the composer." - : "No eligible machine has available resources. Choose a machine in the composer to override.", + : draftThreadContexts?.length + ? "No eligible machine can read every attached thread. Choose a machine in the composer or remove the threads." + : "No eligible machine has available resources. Choose a machine in the composer to override.", }); return; } @@ -8915,7 +8917,7 @@ export default function ChatView(props: ChatViewProps) { environmentId, })[0]; if (strandedThread) { - const owner = environmentById.get(strandedThread.environmentId)?.label ?? "another machine"; + const owner = environmentById.get(strandedThread.environmentId)?.label ?? "its machine"; toastManager.add( stackedThreadToast({ type: "warning", From d94333e29b47bb5cbb51a3e9cbae1e5eb69f0b43 Mon Sep 17 00:00:00 2001 From: NikitaMGrimm <65914808+NikitaMGrimm@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:04:59 +0200 Subject: [PATCH 3/5] fix(web): blame attached threads only when no machine owns them --- apps/web/src/components/ChatView.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7f5ac46d66e1..0b9ef3090a1c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8799,8 +8799,13 @@ export default function ChatView(props: ChatViewProps) { : "Choose a machine to continue", description: loadBalancing.pending ? "Resource checks are still running. You can choose a machine in the composer." - : draftThreadContexts?.length - ? "No eligible machine can read every attached thread. Choose a machine in the composer or remove the threads." + : draftThreadContexts?.length && + !logicalProjectEnvironments.some((candidate) => + draftThreadContexts.every( + (record) => record.environmentId === candidate.environmentId, + ), + ) + ? "No machine can read every attached thread. Choose a machine in the composer or remove the threads." : "No eligible machine has available resources. Choose a machine in the composer to override.", }); return; From 77711fb57e94bac03a440c76fb92e03a3f837b08 Mon Sep 17 00:00:00 2001 From: NikitaMGrimm <65914808+NikitaMGrimm@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:18:26 +0200 Subject: [PATCH 4/5] fix(mobile): explain dropped foreign threads and don't count them against the paste cap --- apps/mobile/src/components/ComposerEditor.tsx | 5 ++++ .../src/lib/composerContextClipboard.test.ts | 11 ++++++-- .../src/lib/composerContextClipboard.ts | 25 ++++++++++++------- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 054de4a3f2eb..b61820765a78 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -119,6 +119,11 @@ export function ComposerEditor({ "Some attachments could not be copied", "Reconnect to the source environment and copy them again. References without their files are marked unavailable.", ); + else if (result.skippedThreads.length > 0) + Alert.alert( + "Threads from another machine weren't pasted", + `This machine's agent can't read ${result.skippedThreads.map((title) => `"${title}"`).join(", ")}, so the references are marked unavailable.`, + ); } catch (error) { if (!controller.signal.aborted) Alert.alert( diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index ed6c46cd11eb..b684982a4788 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -1,5 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { ComposerContextId, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + COMPOSER_CONTEXT_MAX_RECORDS, + ComposerContextId, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; const mocks = vi.hoisted(() => ({ @@ -164,10 +169,12 @@ describe("mobile context clipboard imports", () => { }, 0, new AbortController().signal, - 0, + // Only one slot is free: the dropped thread must not count against it. + COMPOSER_CONTEXT_MAX_RECORDS - 1, EnvironmentId.make("destination"), ); expect(result?.context.records.map((record) => record.label)).toEqual(["local"]); + expect(result?.skippedThreads).toEqual(["foreign"]); }); it("refuses an overflowing context paste before copying files", async () => { diff --git a/apps/mobile/src/lib/composerContextClipboard.ts b/apps/mobile/src/lib/composerContextClipboard.ts index 83fe4e3bbc8f..ddeae1632e85 100644 --- a/apps/mobile/src/lib/composerContextClipboard.ts +++ b/apps/mobile/src/lib/composerContextClipboard.ts @@ -72,9 +72,22 @@ export async function importComposerContextClipboard( decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html); if (!fragment) return null; const selected = referencedComposerContext(input.text, { version: 1, records: fragment.records }); - if (existingContextCount + (selected?.records.length ?? 0) > COMPOSER_CONTEXT_MAX_RECORDS) + // The agent can only read threads on its own server; a pasted foreign one is dropped and its + // reference stays visibly unavailable, like an attachment that failed to copy. + const skippedThreads: string[] = []; + const retained = (selected?.records ?? []).filter((record) => { + if ( + record.kind !== "thread" || + !("threadId" in record) || + record.environmentId === destinationEnvironmentId + ) + return true; + skippedThreads.push(record.title); + return false; + }); + if (existingContextCount + retained.length > COMPOSER_CONTEXT_MAX_RECORDS) throw new Error("Remove some context items from the draft before pasting more."); - const imported = reidentifyComposerContext(input.text, selected?.records ?? [], uuidv4); + const imported = reidentifyComposerContext(input.text, retained, uuidv4); const attachments: DraftComposerAttachment[] = []; const records: ComposerContextRecord[] = []; const failures: string[] = []; @@ -82,13 +95,6 @@ export async function importComposerContextClipboard( for (const record of imported.context.records) { checkAborted(signal); if (!("attachmentId" in record)) { - // The agent can only read threads on its own server; a pasted foreign one is dropped. - if ( - record.kind === "thread" && - "threadId" in record && - record.environmentId !== destinationEnvironmentId - ) - continue; records.push(record); continue; } @@ -108,6 +114,7 @@ export async function importComposerContextClipboard( context: { version: 1 as const, records }, attachments, failures, + skippedThreads, }; } catch (error) { await Promise.all( From bc283f4e99c4ac5a232b5436d85c25956e5c0626 Mon Sep 17 00:00:00 2001 From: NikitaMGrimm <65914808+NikitaMGrimm@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:27:38 +0200 Subject: [PATCH 5/5] fix(mobile): show attachment and thread paste warnings together --- apps/mobile/src/components/ComposerEditor.tsx | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index b61820765a78..b0aa58c086d6 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -114,15 +114,22 @@ export function ComposerEditor({ ); return; } - if (result.failures.length > 0) + const warnings = [ + ...(result.failures.length > 0 + ? [ + "Some attachments could not be copied. Reconnect to the source environment and copy them again.", + ] + : []), + ...(result.skippedThreads.length > 0 + ? [ + `Threads from another machine weren't pasted: this machine's agent can't read ${result.skippedThreads.map((title) => `"${title}"`).join(", ")}.`, + ] + : []), + ]; + if (warnings.length > 0) Alert.alert( - "Some attachments could not be copied", - "Reconnect to the source environment and copy them again. References without their files are marked unavailable.", - ); - else if (result.skippedThreads.length > 0) - Alert.alert( - "Threads from another machine weren't pasted", - `This machine's agent can't read ${result.skippedThreads.map((title) => `"${title}"`).join(", ")}, so the references are marked unavailable.`, + "Some context could not be pasted", + [...warnings, "Those references are marked unavailable."].join("\n\n"), ); } catch (error) { if (!controller.signal.aborted)