Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
21c9584
fix(composer): don't send attached threads to a machine that can't re…
NikitaMGrimm Oct 9, 2026
babc682
fix(mobile): drop pasted threads owned by another machine
NikitaMGrimm Oct 9, 2026
4894986
fix(web): blame attached threads only when no machine owns them
NikitaMGrimm Oct 9, 2026
ab2c9e2
fix(mobile): explain dropped foreign threads and don't count them aga…
NikitaMGrimm Oct 9, 2026
e15c511
fix(mobile): show attachment and thread paste warnings together
NikitaMGrimm Oct 9, 2026
7584e35
fix(mobile): keep pasted threads when the destination machine is unknown
NikitaMGrimm Oct 11, 2026
aa958fa
refactor(web): leave the web half to its own pull request
NikitaMGrimm Oct 11, 2026
beda35d
fix(mobile): clarify unavailable thread warnings
NikitaMGrimm Oct 11, 2026
b241e2a
Merge remote-tracking branch 'origin/main' into resubmit/reference-ma…
NikitaMGrimm Oct 15, 2026
0af3440
Merge remote-tracking branch 'origin/main' into resubmit/reference-ma…
NikitaMGrimm Oct 15, 2026
6a1784c
Merge remote-tracking branch 'origin/main' into resubmit/reference-ma…
NikitaMGrimm Oct 16, 2026
c19def9
Merge remote-tracking branch 'origin/main' into pr18322
NikitaMGrimm Oct 18, 2026
633d504
Merge remote-tracking branch 'origin/main' into resubmit/reference-ma…
juliusmarminge Oct 19, 2026
c28e806
test(mobile): cover mixed context paste across machines
juliusmarminge Oct 19, 2026
b326cfb
refactor(mobile): share attached thread validation when pasting
juliusmarminge Oct 19, 2026
1ecea4f
fix(mobile): keep skipped pasted thread references unavailable
juliusmarminge Oct 19, 2026
2d4b2d7
Merge remote-tracking branch 'origin/main' into L70-18322
juliusmarminge Oct 20, 2026
703f36b
Merge remote-tracking branch 'origin/main' into L70-18322
juliusmarminge Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/mobile/src/components/ComposerEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
156 changes: 155 additions & 1 deletion apps/mobile/src/lib/composerContextClipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
19 changes: 16 additions & 3 deletions apps/mobile/src/lib/composerContextClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand Down
Loading