From 21c9584a4bda7d7c075c3d44ee9ecc58b00fc015 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 01/11] 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 ba60d57d33fe1..5e3a41388ce63 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"; @@ -1298,6 +1299,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 2c37898dd8f90..ac13d5899319b 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 { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import * as Arr from "effect/Array"; @@ -1129,6 +1130,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if ((text.length === 0 && draft.attachments.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 260db2e47b4f3..814b74ecf9787 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); @@ -4359,6 +4363,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 || @@ -4385,6 +4393,7 @@ export default function ChatView(props: ChatViewProps) { needsLoadBalancing, logicalProjectEnvironments, environmentById, + draftThreadContexts, loadBalancingSettings.loadBalancingWeights, activeProviderInstanceId, selectedProvider, @@ -8960,6 +8969,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 17f1c20d85684..6bfda1e3453c5 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 1d620ed05d4ad..e8ea149182dc3 100644 --- a/apps/web/src/components/composerContextPresentation.tsx +++ b/apps/web/src/components/composerContextPresentation.tsx @@ -330,6 +330,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 8b59a6c261b9e..fb5603e3f436f 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -11,6 +11,7 @@ import { projectComposerContextForProvider, replaceComposerContextReferences, sanitizeComposerContextLabel, + threadContextsOutsideEnvironment, } from "./composerContextReferences.ts"; const ctx = (value: string) => value as ComposerContextId; @@ -298,3 +299,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 ae58648990809..e360423b10b99 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"; /** @@ -311,6 +313,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 babc68237c70d0243e951f21eb35fe4314d22387 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 02/11] 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 1eff2e8995008..054de4a3f2eb0 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 5e3a41388ce63..2c3efb4fe2d46 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1308,7 +1308,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 f3a3a3e83b084..ed6c46cd11eb3 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 bbdd862170d11..83fe4e3bbc8f2 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 814b74ecf9787..ba93821941779 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8859,7 +8859,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; } @@ -8975,7 +8977,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 4894986ada5f300d641c2e325b2667c67401d060 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 03/11] 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 ba93821941779..f347f07587880 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8859,8 +8859,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 ab2c9e2db6095b3ec01d3e8ecdb1ef1f9ee8887c 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 04/11] 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 054de4a3f2eb0..b61820765a782 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 ed6c46cd11eb3..b684982a47889 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 83fe4e3bbc8f2..ddeae1632e854 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 e15c511cf204fb1b02138e10bb9d2b2a559260b0 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 05/11] 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 b61820765a782..b0aa58c086d67 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) From 7584e351fa1257bd19c23cfcabd310e9cbb8bb38 Mon Sep 17 00:00:00 2001 From: NikitaMGrimm <65914808+NikitaMGrimm@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:16:19 +0200 Subject: [PATCH 06/11] fix(mobile): keep pasted threads when the destination machine is unknown --- .../src/lib/composerContextClipboard.test.ts | 37 ++++++++++++++----- .../src/lib/composerContextClipboard.ts | 1 + 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index b684982a47889..9f9d6648f73de 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -147,16 +147,17 @@ describe("mobile context clipboard imports", () => { expect(mocks.download).not.toHaveBeenCalled(); }); + 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, + }); + 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)", @@ -177,6 +178,24 @@ describe("mobile context clipboard imports", () => { expect(result?.skippedThreads).toEqual(["foreign"]); }); + it("keeps threads when the destination machine is unknown", async () => { + const result = await importComposerContextClipboard( + { + text: "[foreign](t3-context://v1/thread/foreign)", + fragment: encodeComposerContextFragment({ + version: 1, + source: { environmentId: EnvironmentId.make("source") }, + records: [thread("foreign", "source")], + })!, + html: "", + }, + 0, + new AbortController().signal, + ); + expect(result?.context.records.map((record) => record.label)).toEqual(["foreign"]); + expect(result?.skippedThreads).toEqual([]); + }); + 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 ddeae1632e854..96b184660c1be 100644 --- a/apps/mobile/src/lib/composerContextClipboard.ts +++ b/apps/mobile/src/lib/composerContextClipboard.ts @@ -79,6 +79,7 @@ export async function importComposerContextClipboard( if ( record.kind !== "thread" || !("threadId" in record) || + destinationEnvironmentId === undefined || record.environmentId === destinationEnvironmentId ) return true; From aa958fac25fda8f6cff43215c8633457c835da2c Mon Sep 17 00:00:00 2001 From: NikitaMGrimm <65914808+NikitaMGrimm@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:23:30 +0200 Subject: [PATCH 07/11] refactor(web): leave the web half to its own pull request --- apps/web/src/components/ChatView.tsx | 34 +------------------ apps/web/src/components/ThreadContextChip.tsx | 11 +----- .../composerContextPresentation.tsx | 13 +------ 3 files changed, 3 insertions(+), 55 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f347f07587880..260db2e47b4f3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -39,7 +39,6 @@ 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, @@ -1806,9 +1805,6 @@ 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); @@ -4363,10 +4359,6 @@ 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 || @@ -4393,7 +4385,6 @@ export default function ChatView(props: ChatViewProps) { needsLoadBalancing, logicalProjectEnvironments, environmentById, - draftThreadContexts, loadBalancingSettings.loadBalancingWeights, activeProviderInstanceId, selectedProvider, @@ -8859,14 +8850,7 @@ 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 && - !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.", + : "No eligible machine has available resources. Choose a machine in the composer to override.", }); return; } @@ -8976,22 +8960,6 @@ 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 ?? "its 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 6bfda1e3453c5..17f1c20d85684 100644 --- a/apps/web/src/components/ThreadContextChip.tsx +++ b/apps/web/src/components/ThreadContextChip.tsx @@ -9,12 +9,10 @@ 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 }); @@ -25,7 +23,6 @@ export function ThreadContextChip(props: { render={ } aria-label={`Thread, ${title}`} data-markdown-copy={props.copyMarkdown} @@ -36,13 +33,7 @@ export function ThreadContextChip(props: { } /> - - {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"} - + {shell ? "Open thread" : "Thread no longer available"} ); } diff --git a/apps/web/src/components/composerContextPresentation.tsx b/apps/web/src/components/composerContextPresentation.tsx index e8ea149182dc3..1d620ed05d4ad 100644 --- a/apps/web/src/components/composerContextPresentation.tsx +++ b/apps/web/src/components/composerContextPresentation.tsx @@ -330,17 +330,6 @@ 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" ? ( - + ) : ( ), From beda35de70953589b5d943dd4ad6078b938c8d6d Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Mon, 12 Oct 2026 00:55:35 +0200 Subject: [PATCH 08/11] fix(mobile): clarify unavailable thread warnings --- apps/mobile/src/components/ComposerEditor.tsx | 4 ++-- apps/mobile/src/features/threads/NewTaskDraftScreen.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index b0aa58c086d67..541ae86242619 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -122,13 +122,13 @@ export function ComposerEditor({ : []), ...(result.skippedThreads.length > 0 ? [ - `Threads from another machine weren't pasted: this machine's agent can't read ${result.skippedThreads.map((title) => `"${title}"`).join(", ")}.`, + `${result.skippedThreads.map((title) => `"${title}"`).join(", ")} ${result.skippedThreads.length === 1 ? "is" : "are"} on another machine.`, ] : []), ]; if (warnings.length > 0) Alert.alert( - "Some context could not be pasted", + "Some context is unavailable", [...warnings, "Those references are marked unavailable."].join("\n\n"), ); } catch (error) { diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 2c3efb4fe2d46..a51c9ff714e85 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1310,8 +1310,8 @@ export function NewTaskDraftScreen(props: { (environment) => environment.environmentId === strandedThread.environmentId, )?.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.`, + "Attached thread unavailable", + `"${strandedThread.title}" is on another machine. Switch back to ${owner} or remove it from the draft.`, ); return; } From c28e806c45866501cafd12a030fa88cba9482cba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:08:24 +0000 Subject: [PATCH 09/11] test(mobile): cover mixed context paste across machines --- .../src/lib/composerContextClipboard.test.ts | 161 ++++++++++++++---- .../src/composerContextReferences.test.ts | 33 ---- 2 files changed, 127 insertions(+), 67 deletions(-) diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index 9f9d6648f73de..a0025fc4b25e6 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -6,6 +6,10 @@ import { ThreadId, } from "@t3tools/contracts"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; +import { + collectComposerContextReferences, + formatComposerContextReference, +} from "@t3tools/shared/composerContextReferences"; const mocks = vi.hoisted(() => ({ execute: vi.fn(), @@ -81,6 +85,41 @@ const clipboard = { html: "", }; +const localThread = { + version: 1 as const, + kind: "thread" as const, + contextId: ComposerContextId.make("local-thread"), + label: "Local discussion", + environmentId: EnvironmentId.make("destination"), + threadId: ThreadId.make("local-thread"), + title: "Local discussion", +}; +const foreignThread = { + ...localThread, + contextId: ComposerContextId.make("foreign-thread"), + label: "Remote discussion", + environmentId: EnvironmentId.make("source"), + threadId: ThreadId.make("foreign-thread"), + title: "Remote discussion", +}; +const mixedRecords = [foreignThread, localThread, terminal, image]; +const mixedClipboard = { + text: `Compare these findings: ${mixedRecords.map(formatComposerContextReference).join(" ")} before changing anything.`, + fragment: encodeComposerContextFragment({ + version: 1, + source: { environmentId: EnvironmentId.make("source") }, + records: [ + ...mixedRecords, + { + ...foreignThread, + contextId: ComposerContextId.make("uncopied-thread"), + title: "Not in the copied selection", + }, + ], + })!, + html: "", +}; + beforeEach(() => { vi.clearAllMocks(); mocks.sequence = 0; @@ -147,53 +186,107 @@ describe("mobile context clipboard imports", () => { expect(mocks.download).not.toHaveBeenCalled(); }); - 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, + it("pastes local threads, text context and files at the cap after excluding foreign threads", async () => { + const result = await importComposerContextClipboard( + mixedClipboard, + 0, + new AbortController().signal, + COMPOSER_CONTEXT_MAX_RECORDS - 3, + EnvironmentId.make("destination"), + ); + expect(result).not.toBeNull(); + expect(result!.context.records).toEqual([ + { ...localThread, contextId: expect.any(String) }, + { ...terminal, contextId: expect.any(String) }, + { ...image, contextId: expect.any(String), attachmentId: result!.attachments[0]!.id }, + ]); + expect(result!.attachments).toEqual([ + expect.objectContaining({ name: image.name, fileUri: "file:///owned.png" }), + ]); + expect(result!.failures).toEqual([]); + expect(result!.skippedThreads).toEqual([foreignThread.title]); + const references = collectComposerContextReferences(result!.text); + expect(references.map((reference) => reference.label)).toEqual( + mixedRecords.map((record) => record.label), + ); + expect(result!.context.records.map((record) => record.contextId)).toEqual( + references.slice(1).map((reference) => reference.contextId), + ); + expect( + references + .slice(1) + .every( + (reference) => !mixedRecords.some((record) => record.contextId === reference.contextId), + ), + ).toBe(true); + expect( + result!.context.records.some((record) => record.contextId === references[0]!.contextId), + ).toBe(false); + expect(result!.text).toMatch(/^Compare these findings: .* before changing anything\.$/); + + mocks.download.mockClear(); + await expect( + importComposerContextClipboard( + mixedClipboard, + 0, + new AbortController().signal, + COMPOSER_CONTEXT_MAX_RECORDS - 2, + EnvironmentId.make("destination"), + ), + ).rejects.toThrow("Remove some context"); + expect(mocks.download).not.toHaveBeenCalled(); }); - it("drops threads owned by another machine than the destination", async () => { + it("reports foreign threads and failed files together while keeping the rest of a paste", async () => { + mocks.download.mockRejectedValueOnce(new Error("source disconnected")); 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: "", - }, + mixedClipboard, 0, new AbortController().signal, - // Only one slot is free: the dropped thread must not count against it. - COMPOSER_CONTEXT_MAX_RECORDS - 1, + 0, EnvironmentId.make("destination"), ); - expect(result?.context.records.map((record) => record.label)).toEqual(["local"]); - expect(result?.skippedThreads).toEqual(["foreign"]); + expect(result).not.toBeNull(); + expect(result!.context.records).toEqual([ + { ...localThread, contextId: expect.any(String) }, + { ...terminal, contextId: expect.any(String) }, + ]); + expect(result!.attachments).toEqual([]); + expect(result!.failures).toEqual([image.name]); + expect(result!.skippedThreads).toEqual([foreignThread.title]); + const references = collectComposerContextReferences(result!.text); + expect(references.map((reference) => reference.label)).toEqual( + mixedRecords.map((record) => record.label), + ); + expect( + references + .filter( + (reference) => + !result!.context.records.some((record) => record.contextId === reference.contextId), + ) + .map((reference) => reference.label), + ).toEqual([foreignThread.label, image.label]); + expect(result!.text).toMatch(/^Compare these findings: .* before changing anything\.$/); }); - it("keeps threads when the destination machine is unknown", async () => { + it("preserves both machines' thread references when pasting without a destination", async () => { const result = await importComposerContextClipboard( - { - text: "[foreign](t3-context://v1/thread/foreign)", - fragment: encodeComposerContextFragment({ - version: 1, - source: { environmentId: EnvironmentId.make("source") }, - records: [thread("foreign", "source")], - })!, - html: "", - }, + mixedClipboard, 0, new AbortController().signal, ); - expect(result?.context.records.map((record) => record.label)).toEqual(["foreign"]); - expect(result?.skippedThreads).toEqual([]); + expect(result).not.toBeNull(); + expect(result!.context.records).toEqual([ + { ...foreignThread, contextId: expect.any(String) }, + { ...localThread, contextId: expect.any(String) }, + { ...terminal, contextId: expect.any(String) }, + { ...image, contextId: expect.any(String), attachmentId: result!.attachments[0]!.id }, + ]); + expect(result!.attachments).toHaveLength(1); + expect(result!.failures).toEqual([]); + expect( + collectComposerContextReferences(result!.text).map((reference) => reference.contextId), + ).toEqual(result!.context.records.map((record) => record.contextId)); }); it("refuses an overflowing context paste before copying files", async () => { diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index dc4a53e8b5724..b1a41b2eac3f4 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -11,7 +11,6 @@ import { projectComposerContextForProvider, replaceComposerContextReferences, sanitizeComposerContextLabel, - threadContextsOutsideEnvironment, toComposerContextId, toKindScopedComposerContextId, } from "./composerContextReferences.ts"; @@ -302,38 +301,6 @@ describe("provider projection", () => { }); }); -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([]); - }); -}); - describe("toComposerContextId", () => { it("keeps same-kind raw IDs distinct when one contains the namespace prefix", () => { expect(toKindScopedComposerContextId("image", "x")).toBe("image_x"); From b326cfb6aa92a6b3a64dd3fdc15dc06374835339 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:13:05 +0000 Subject: [PATCH 10/11] refactor(mobile): share attached thread validation when pasting --- apps/mobile/src/components/ComposerEditor.tsx | 21 +++++++-------- .../threads/new-task-flow-provider.tsx | 1 - .../src/lib/composerContextClipboard.ts | 27 +++++++++---------- .../shared/src/composerContextReferences.ts | 5 +--- 4 files changed, 22 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index b12680a0f0cc9..4334118bc5096 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -108,18 +108,15 @@ export function ComposerEditor({ ); return; } - 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 - ? [ - `${result.skippedThreads.map((title) => `"${title}"`).join(", ")} ${result.skippedThreads.length === 1 ? "is" : "are"} on another machine.`, - ] - : []), - ]; + const warnings: string[] = []; + if (result.failures.length > 0) + warnings.push( + "Some attachments could not be copied. Reconnect to the source environment and copy them again.", + ); + if (result.skippedThreads.length > 0) + warnings.push( + `${result.skippedThreads.map((title) => `"${title}"`).join(", ")} ${result.skippedThreads.length === 1 ? "is" : "are"} on another machine.`, + ); if (warnings.length > 0) Dialog.alert( "Some context is unavailable", 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 5fb35e1aa9fb6..d31bfb87973fc 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -958,7 +958,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) { return null; } - // A thread attached on another machine is unreadable here; the submit path explains why. if ( threadContextsOutsideEnvironment({ text: draft.text, diff --git a/apps/mobile/src/lib/composerContextClipboard.ts b/apps/mobile/src/lib/composerContextClipboard.ts index cfcb2a63105f7..c1e66f521a0d6 100644 --- a/apps/mobile/src/lib/composerContextClipboard.ts +++ b/apps/mobile/src/lib/composerContextClipboard.ts @@ -13,6 +13,7 @@ import { decodeComposerContextFragment, encodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; +import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences"; import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import * as Option from "effect/Option"; @@ -71,20 +72,16 @@ export async function importComposerContextClipboard( decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html); if (!fragment) return null; const selected = referencedComposerContext(input.text, { version: 1, records: fragment.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) || - destinationEnvironmentId === undefined || - record.environmentId === destinationEnvironmentId - ) - return true; - skippedThreads.push(record.title); - return false; - }); + const skippedThreads = + destinationEnvironmentId === undefined + ? [] + : threadContextsOutsideEnvironment({ + text: input.text, + records: selected?.records ?? [], + environmentId: destinationEnvironmentId, + }); + const skippedIds = new Set(skippedThreads.map((record) => record.contextId)); + const retained = (selected?.records ?? []).filter((record) => !skippedIds.has(record.contextId)); 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, retained, uuidv4); @@ -114,7 +111,7 @@ export async function importComposerContextClipboard( context: { version: 1 as const, records }, attachments, failures, - skippedThreads, + skippedThreads: skippedThreads.map((record) => record.title), }; } catch (error) { await Promise.all( diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index f6113417b6081..7d8eb53cf881d 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -322,10 +322,7 @@ 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. - */ +// Thread history is only readable from the environment that owns it. export function threadContextsOutsideEnvironment(input: { text: string; records: ReadonlyArray; From 1ecea4f87290930f89a8e906504c9524158fb30f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:28:06 +0000 Subject: [PATCH 11/11] fix(mobile): keep skipped pasted thread references unavailable --- .../src/lib/composerContextClipboard.test.ts | 23 ++++++++++++------- .../src/lib/composerContextClipboard.ts | 10 ++++---- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index a0025fc4b25e6..d37e0a9ae6fd8 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -53,6 +53,7 @@ vi.mock("./composerImages", () => ({ vi.mock("./uuid", () => ({ uuidv4: () => `import-${++mocks.sequence}` })); import { importComposerContextClipboard } from "./composerContextClipboard"; +import { createComposerContextHistory } from "./composerContext"; const image = { version: 1 as const, @@ -88,7 +89,7 @@ const clipboard = { const localThread = { version: 1 as const, kind: "thread" as const, - contextId: ComposerContextId.make("local-thread"), + contextId: ComposerContextId.make("thread_local-thread"), label: "Local discussion", environmentId: EnvironmentId.make("destination"), threadId: ThreadId.make("local-thread"), @@ -96,7 +97,7 @@ const localThread = { }; const foreignThread = { ...localThread, - contextId: ComposerContextId.make("foreign-thread"), + contextId: ComposerContextId.make("thread_foreign-thread"), label: "Remote discussion", environmentId: EnvironmentId.make("source"), threadId: ThreadId.make("foreign-thread"), @@ -112,7 +113,8 @@ const mixedClipboard = { ...mixedRecords, { ...foreignThread, - contextId: ComposerContextId.make("uncopied-thread"), + contextId: ComposerContextId.make("thread_uncopied-thread"), + threadId: ThreadId.make("uncopied-thread"), title: "Not in the copied selection", }, ], @@ -213,17 +215,22 @@ describe("mobile context clipboard imports", () => { references.slice(1).map((reference) => reference.contextId), ); expect( - references - .slice(1) - .every( - (reference) => !mixedRecords.some((record) => record.contextId === reference.contextId), - ), + references.every( + (reference) => !mixedRecords.some((record) => record.contextId === reference.contextId), + ), ).toBe(true); expect( result!.context.records.some((record) => record.contextId === references[0]!.contextId), ).toBe(false); expect(result!.text).toMatch(/^Compare these findings: .* before changing anything\.$/); + const restoreHistory = createComposerContextHistory(); + restoreHistory(formatComposerContextReference(foreignThread), { + version: 1, + records: [foreignThread], + }); + expect(restoreHistory(`${result!.text} Next step`, result!.context)).toEqual(result!.context); + mocks.download.mockClear(); await expect( importComposerContextClipboard( diff --git a/apps/mobile/src/lib/composerContextClipboard.ts b/apps/mobile/src/lib/composerContextClipboard.ts index c1e66f521a0d6..5231b4b94fb94 100644 --- a/apps/mobile/src/lib/composerContextClipboard.ts +++ b/apps/mobile/src/lib/composerContextClipboard.ts @@ -72,24 +72,24 @@ export async function importComposerContextClipboard( decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html); if (!fragment) return null; const selected = referencedComposerContext(input.text, { version: 1, records: fragment.records }); + const imported = reidentifyComposerContext(input.text, selected?.records ?? [], uuidv4); const skippedThreads = destinationEnvironmentId === undefined ? [] : threadContextsOutsideEnvironment({ - text: input.text, - records: selected?.records ?? [], + text: imported.text, + records: imported.context.records, environmentId: destinationEnvironmentId, }); const skippedIds = new Set(skippedThreads.map((record) => record.contextId)); - const retained = (selected?.records ?? []).filter((record) => !skippedIds.has(record.contextId)); + const retained = imported.context.records.filter((record) => !skippedIds.has(record.contextId)); 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, retained, uuidv4); const attachments: DraftComposerAttachment[] = []; const records: ComposerContextRecord[] = []; const failures: string[] = []; try { - for (const record of imported.context.records) { + for (const record of retained) { checkAborted(signal); if (!("attachmentId" in record)) { records.push(record);