diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 3d06dfd9f344a..f68c5396d8414 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -97,6 +97,7 @@ export function ComposerEditor({ retained.attachments.length, controller.signal, retained.context?.records.length ?? 0, + environmentId, ); if (!result) { insertComposerDraftText(draftKey, clipboard.text, insertion); @@ -109,10 +110,19 @@ export function ComposerEditor({ ); return; } + 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 attachments could not be copied", - "Reconnect to the source environment and copy them again. References without their files are marked unavailable.", + "Some context is unavailable", + [...warnings, "Those references are marked unavailable."].join("\n\n"), ); }); setComposerContextImporting(draftKey, false); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index d1c18a4ebbcde..1d4dcc750c50b 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -71,6 +71,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/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"; @@ -1248,6 +1249,22 @@ export function NewTaskDraftScreen(props: { Dialog.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 ?? "its machine"; + Dialog.alert( + "Attached thread unavailable", + `"${strandedThread.title}" is on another machine. Switch back to ${owner} or remove it from the draft.`, + ); + 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 27754c86dadc4..065cc73ce178b 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"; @@ -961,6 +962,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) { return null; } + if ( + threadContextsOutsideEnvironment({ + text: draft.text, + records: draft.context?.records ?? [], + environmentId: selectedProject.environmentId, + }).length > 0 + ) { + return null; + } const workspaceSelection = canChooseWorkspace ? draft.workspaceSelection : undefined; const mode = workspaceSelection?.mode ?? workspaceMode; // When the selection is the stand-in built from the queued snapshot, diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index f3a3a3e83b084..d37e0a9ae6fd8 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -1,6 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { ComposerContextId, EnvironmentId } from "@t3tools/contracts"; +import { + COMPOSER_CONTEXT_MAX_RECORDS, + ComposerContextId, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; +import { + collectComposerContextReferences, + formatComposerContextReference, +} from "@t3tools/shared/composerContextReferences"; const mocks = vi.hoisted(() => ({ execute: vi.fn(), @@ -44,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, @@ -76,6 +86,42 @@ const clipboard = { html: "", }; +const localThread = { + version: 1 as const, + kind: "thread" as const, + contextId: ComposerContextId.make("thread_local-thread"), + label: "Local discussion", + environmentId: EnvironmentId.make("destination"), + threadId: ThreadId.make("local-thread"), + title: "Local discussion", +}; +const foreignThread = { + ...localThread, + contextId: ComposerContextId.make("thread_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("thread_uncopied-thread"), + threadId: ThreadId.make("uncopied-thread"), + title: "Not in the copied selection", + }, + ], + })!, + html: "", +}; + beforeEach(() => { vi.clearAllMocks(); mocks.sequence = 0; @@ -142,6 +188,114 @@ describe("mobile context clipboard imports", () => { expect(mocks.download).not.toHaveBeenCalled(); }); + 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.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( + mixedClipboard, + 0, + new AbortController().signal, + COMPOSER_CONTEXT_MAX_RECORDS - 2, + EnvironmentId.make("destination"), + ), + ).rejects.toThrow("Remove some context"); + expect(mocks.download).not.toHaveBeenCalled(); + }); + + 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( + mixedClipboard, + 0, + new AbortController().signal, + 0, + EnvironmentId.make("destination"), + ); + 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("preserves both machines' thread references when pasting without a destination", async () => { + const result = await importComposerContextClipboard( + mixedClipboard, + 0, + new AbortController().signal, + ); + 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 () => { 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 5a5d3168cf09a..5231b4b94fb94 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"; @@ -65,19 +66,30 @@ export async function importComposerContextClipboard( existingCount: number, signal: AbortSignal, existingContextCount = 0, + destinationEnvironmentId?: EnvironmentId, ) { const fragment = 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) - throw new Error("Remove some context items from the draft before pasting more."); const imported = reidentifyComposerContext(input.text, selected?.records ?? [], uuidv4); + const skippedThreads = + destinationEnvironmentId === undefined + ? [] + : threadContextsOutsideEnvironment({ + text: imported.text, + records: imported.context.records, + environmentId: destinationEnvironmentId, + }); + const skippedIds = new Set(skippedThreads.map((record) => 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 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); @@ -99,6 +111,7 @@ export async function importComposerContextClipboard( context: { version: 1 as const, records }, attachments, failures, + skippedThreads: skippedThreads.map((record) => record.title), }; } catch (error) { await Promise.all(