Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import org.json.JSONArray
internal object T3ComposerClipboard {
// Pasted thread links become chips in JS; packages/shared/src/threadLinks.ts parses them.
fun hasThreadLink(text: String): Boolean =
text.contains("t3-thread://v2/") || text.contains("https://app.t3.codes/threads/")
text.contains("t3-thread://v2/") || text.contains("https://app.t3.codes/") ||
text.contains("://threads/")

fun write(context: Context, text: String, fragment: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,35 @@ class ComposerPasteTest {
assertNull(intercepted)
}

@Test
fun regularPasteSendsPlainThreadUrlsToTheContextImporter() {
val urls = listOf(
"https://app.t3.codes/environment/thread",
"t3code://threads/environment/thread"
)
for (url in urls) {
clipboard.setPrimaryClip(ClipData.newPlainText("test", url))
editor.setText("before")
editor.setSelection(6)
var payload: Map<String, String>? = null
editor.pasteContextListener = { payload = it }
assertTrue(editor.onTextContextMenuItem(android.R.id.paste))
assertEquals(url, payload?.get("text"))
assertEquals("before", editor.text.toString())
}
}

@Test
fun incidentalThreadUrlsDoNotBypassLargeTextFolding() {
val text = "log line\n".repeat(5000) + "https://app.t3.codes/environment/thread"
clipboard.setPrimaryClip(ClipData.newPlainText("test", text))
editor.pasteContextListener = { error("Large logs must use the text folding path") }
var intercepted: String? = null
editor.pasteTextListener = { value, _, _ -> intercepted = value }
assertTrue(editor.onTextContextMenuItem(android.R.id.paste))
assertEquals(text, intercepted)
}

private fun dropEditor(): SelectionAwareEditText {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
return SelectionAwareEditText(activity).apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ enum T3ComposerClipboard {

// Pasted thread links become chips in JS; packages/shared/src/threadLinks.ts parses them.
static func hasThreadLink(_ text: String) -> Bool {
text.contains("t3-thread://v2/") || text.contains("https://app.t3.codes/threads/")
text.contains("t3-thread://v2/") || text.contains("https://app.t3.codes/") ||
text.contains("://threads/")
}

static func write(text: String, fragment: String) {
Expand Down
43 changes: 43 additions & 0 deletions apps/mobile/src/lib/composerContextClipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,46 @@ describe("mobile context clipboard imports", () => {
).toBeNull();
});
});

describe("public thread URL paste", () => {
it("imports a plain link without rich clipboard metadata", async () => {
const result = await importComposerContextClipboard(
{ text: "https://app.t3.codes/destination/local-thread", fragment: "", html: "" },
0,
new AbortController().signal,
0,
EnvironmentId.make("destination"),
);
expect(result?.context.records).toMatchObject([
{ kind: "thread", environmentId: "destination", threadId: "local-thread" },
]);
expect(collectComposerContextReferences(result!.text)).toHaveLength(1);
});
it("imports an app link from a machine the destination links to", async () => {
const result = await importComposerContextClipboard(
{ text: "t3code://threads/source/foreign-thread", fragment: "", html: "" },
0,
new AbortController().signal,
0,
EnvironmentId.make("destination"),
new Map([[EnvironmentId.make("source"), "Source"]]),
);
expect(result?.context.records).toMatchObject([
{ kind: "thread", environmentId: "source", threadId: "foreign-thread" },
]);
expect(result?.skippedThreads).toEqual([]);
});
});

it("preserves a public URL when the destination cannot read its environment", async () => {
const text = "See https://app.t3.codes/unknown/thread";
const result = await importComposerContextClipboard(
{ text, fragment: "", html: "" },
0,
new AbortController().signal,
0,
EnvironmentId.make("destination"),
);
expect(result?.text).toBe(text);
expect(result?.context.records).toEqual([]);
});
22 changes: 19 additions & 3 deletions apps/mobile/src/lib/composerContextClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "@t3tools/contracts";
import {
decodeComposerContextClipboardHtml,
normalizeThreadClipboard,
decodeComposerContextFragment,
encodeComposerContextFragment,
} from "@t3tools/shared/composerContextClipboard";
Expand Down Expand Up @@ -75,11 +76,26 @@ export async function importComposerContextClipboard(
/** The machines the destination links to, whose threads its agent can read too. */
linkedEnvironments: ReadonlyMap<EnvironmentId, string> = new Map(),
) {
const copied =
decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html);
const normalized = normalizeThreadClipboard(
input.text,
decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html),
);
const copied = normalized.fragment;
const accepted = new Map(
(copied?.records ?? [])
.filter(
(record) =>
record.kind !== "thread" ||
"payload" in record ||
destinationEnvironmentId === undefined ||
record.environmentId === destinationEnvironmentId ||
linkedEnvironments.has(record.environmentId),
)
.map((record) => [record.contextId, record.contextId]),
);
// A thread link becomes a chip only where this agent can read its machine; others stay links.
const threads = threadLinkContextReferences(
input.text,
normalizeThreadClipboard(input.text, copied, accepted).text,
copied?.records ?? [],
(ref) => appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref))?.title,
(record) =>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { isLocalEnvironmentDisabled } from "../../localEnvironment";
import { usePrimaryEnvironmentId } from "../../state/environments";
import { useRightPanelStore } from "~/rightPanelStore";
import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste";
import { importPastedComposerText, isComposerContextPaste } from "../composerInlineTokenPaste";
import {
questionAttachmentDraftId,
trackOpenQuestionAttachmentDraft,
Expand Down Expand Up @@ -2573,7 +2573,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
return;
}

if ((readPastedComposerContext(event.clipboardData)?.records.length ?? 0) > 0) return;
if (isComposerContextPaste(event.clipboardData)) return;
if (!foldPastedText(plainText, bypassAutoAttachment)) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { threadContextRecord } from "@t3tools/shared/composerContextReferences";
import { useEffect } from "react";
import { type ComposerContextRecord, ComposerContextId, EnvironmentId } from "@t3tools/contracts";
import {
type ComposerContextRecord,
ComposerContextId,
EnvironmentId,
ThreadId,
} from "@t3tools/contracts";
import { expect, it, vi } from "vite-plus/test";

import { act, render, stubGlobal } from "../../../test/render";
Expand Down Expand Up @@ -186,3 +192,72 @@ it.each([
: [],
);
});

it.each(["same", "renamed", "other-environment"] as const)(
"preserves thread identity across repeated pastes: %s",
async (variant) => {
const otherEnvironment = variant === "other-environment";
const target = DraftId.make(`thread-collision-${variant}`);
const store = useComposerDraftStore.getState();
using _draft = { [Symbol.dispose]: () => store.clearComposerContent(target) };
const environmentId = EnvironmentId.make("thread-source");
const otherEnvironmentId = EnvironmentId.make("other-thread-source");
const first = threadContextRecord(
{ environmentId, threadId: ThreadId.make("same-id") },
"First",
);
const pasted = otherEnvironment
? threadContextRecord(
{ environmentId: otherEnvironmentId, threadId: first.threadId },
"Second",
)
: variant === "renamed"
? threadContextRecord({ environmentId, threadId: first.threadId }, "Renamed")
: first;
store.addThreadContexts(target, [first], { appendReference: false });
let importer!: Importer;
await using _screen = await render(
<Harness
input={{
environmentId,
linkedEnvironments: new Map([[otherEnvironmentId, "Other machine"]]),
activeThread: undefined,
activeThreadId: null,
composerDraftTarget: target,
attachmentDraftTarget: target,
attachmentTargetKey: target,
attachmentTargetKeyRef: { current: target },
questionAttachmentTarget: null,
pendingUserInputs: [],
composerContextRecords: new Map([[first.contextId, { kind: "thread", record: first }]]),
composerImages: [],
composerFilesRef: { current: [] },
pendingThreadImportsRef: { current: new Map() },
promptRef: { current: "" },
addComposerFilesToDraft: () => [],
}}
ready={(value) => {
importer = value;
}}
/>,
);
let importedId: string | undefined;
await act(() => {
importedId = importer
.importContextRecords([pasted], pasted.environmentId)
.get(pasted.contextId);
});
const records = store.getComposerDraft(target)!.threadContexts;
expect(records.find((record) => record.contextId === importedId)).toMatchObject({
environmentId: pasted.environmentId,
threadId: pasted.threadId,
});
expect(records).toHaveLength(otherEnvironment ? 2 : 1);
expect(records[0]).toEqual(first);
await act(() => {
const repeated = importer.importContextRecords([pasted], pasted.environmentId);
expect(repeated.get(pasted.contextId)).toBe(importedId);
});
expect(store.getComposerDraft(target)!.threadContexts).toEqual(records);
},
);
14 changes: 12 additions & 2 deletions apps/web/src/components/chat/useComposerContextImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ export function useComposerContextImport(input: {
if (record.kind === "preview-annotation" && record.screenshotContextId) {
skippedDependentAttachmentIds.add(record.screenshotContextId);
}
if (record.kind === "thread") rewritten.set(record.contextId, record.contextId);
continue;
}
const conflicts = existing !== undefined;
Expand Down Expand Up @@ -398,10 +399,19 @@ export function useComposerContextImport(input: {
!linkedEnvironments.has(record.environmentId)
)
break;
addComposerDraftThreadContexts(threadDraftTarget, [record], {
const attached = getComposerDraft(threadDraftTarget)?.threadContexts.find(
(thread) =>
thread.environmentId === record.environmentId && thread.threadId === record.threadId,
);
const imported =
attached ??
(conflicts
? { ...record, contextId: toKindScopedComposerContextId("thread", randomUUID()) }
: record);
addComposerDraftThreadContexts(threadDraftTarget, [imported], {
appendReference: false,
});
rewritten.set(record.contextId, record.contextId);
rewritten.set(record.contextId, imported.contextId);
break;
}
case "environment": {
Expand Down
82 changes: 80 additions & 2 deletions apps/web/src/components/composerInlineTokenPaste.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { threadDeepLink } from "@t3tools/shared/threadDeepLinks";
import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference";
import { type ComposerContextClipboardFragment, EnvironmentId, ThreadId } from "@t3tools/contracts";
import {
COMPOSER_CONTEXT_CLIPBOARD_MIME,
encodeComposerContextFragment,
} from "@t3tools/shared/composerContextClipboard";
import { threadContextRecord } from "@t3tools/shared/composerContextReferences";
import {
collectComposerContextReferences,
formatComposerContextReference,
threadContextRecord,
} from "@t3tools/shared/composerContextReferences";
import { describe, expect, it } from "vite-plus/test";

import { importPastedComposerText } from "./composerInlineTokenPaste";
import {
importPastedComposerText,
readPastedComposerContext,
isComposerContextPaste,
} from "./composerInlineTokenPaste";

const thread = {
environmentId: EnvironmentId.make("env-1"),
Expand Down Expand Up @@ -57,3 +66,72 @@ describe("importPastedComposerText", () => {
);
});
});

const ref = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") };
const record = threadContextRecord(ref, "Fix login");
function legacyClipboard(text: string, rich = false) {
return {
getData: (type: string) =>
type === "text/plain"
? text
: rich && type === COMPOSER_CONTEXT_CLIPBOARD_MIME
? encodeComposerContextFragment({
version: 1,
source: { environmentId: ref.environmentId },
records: [record],
})!
: "",
};
}
describe("pasted thread deep links", () => {
it("imports a copied thread card while the plain clipboard stays a public URL", () => {
const data = legacyClipboard(threadDeepLink(ref), true);
const fragment = readPastedComposerContext(data)!;
expect(fragment.records).toEqual([record]);
const result = importPastedComposerText(
data,
(f) => new Map(f.records.map((r) => [r.contextId, r.contextId])),
);
expect(result).toBe(formatComposerContextReference(record));
expect(data.getData("text/plain")).toBe("https://app.t3.codes/env/thread");
});
it("imports public URLs after clipboard metadata has been stripped", () => {
const data = legacyClipboard(threadDeepLink(ref));
const result = importPastedComposerText(
data,
(f) => new Map(f.records.map((r) => [r.contextId, "imported-thread"])),
);
expect(collectComposerContextReferences(result)).toMatchObject([
{ contextId: "imported-thread", kind: "thread" },
]);
});
it("keeps raw URLs when there is no importer or the machine cannot read the thread", () => {
const data = legacyClipboard(threadDeepLink(ref));
expect(importPastedComposerText(data)).toBe(threadDeepLink(ref));
expect(importPastedComposerText(data, () => new Map())).toBe(threadDeepLink(ref));
});
it("preserves legacy context reference paste", () => {
const data = legacyClipboard(formatComposerContextReference(record), true);
expect(readPastedComposerContext(data)?.records).toEqual([record]);
expect(
importPastedComposerText(
data,
(f) => new Map(f.records.map((r) => [r.contextId, r.contextId])),
),
).toBe(formatComposerContextReference(record));
});
});

it("allows large logs with an incidental thread URL to fold", () => {
expect(
isComposerContextPaste(legacyClipboard("log line\n".repeat(10000) + threadDeepLink(ref))),
).toBe(false);
expect(isComposerContextPaste(legacyClipboard(threadDeepLink(ref)))).toBe(true);
expect(isComposerContextPaste(legacyClipboard("<t3-thread://v2/env/thread>"))).toBe(true);
expect(isComposerContextPaste(legacyClipboard("[Work](t3-thread://v2/env/thread)"))).toBe(true);
expect(
isComposerContextPaste(
legacyClipboard("log line\n".repeat(10000) + "<t3-thread://v2/env/thread>"),
),
).toBe(false);
});
Loading
Loading