Skip to content
Open
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 @@ -739,7 +739,20 @@ internal class SelectionAwareEditText(
pasteContextListener?.invoke(payload)
return true
}
return pasteImagesOrInterceptedText()
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
val clip = clipboard?.primaryClip
val plainText = clip?.plainText()
return if (
plainText != null &&
(plainText.contains("app.t3.codes/") || plainText.contains("://threads/"))
) {
if (!pasteInterceptedText(clip)) {
pasteContextListener?.invoke(payload + ("text" to plainText))
}
true
} else {
pasteImagesOrInterceptedText()
}
}

private fun pasteImagesOrInterceptedText(): Boolean {
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 @@ -201,7 +201,10 @@ private final class ComposerTextView: UITextView {
}
let pasteboard = UIPasteboard.general
let context = T3ComposerClipboard.read()
if !context["fragment", default: ""].isEmpty || context["html", default: ""].contains("data-t3-context-fragment=") {
let text = context["text", default: ""]
let hasThreadLink = text.contains("app.t3.codes/") || text.contains("://threads/")
if !context["fragment", default: ""].isEmpty || context["html", default: ""].contains("data-t3-context-fragment=") ||
(!pasteboard.hasImages && !bypassTextPasteInterception && hasThreadLink && !shouldInterceptTextPaste(text)) {
onPasteContext?(context)
return
}
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 @@ -331,3 +331,46 @@ describe("mobile context clipboard imports", () => {
).toEqual(result!.context.records.map((record) => record.contextId));
});
});

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([]);
});
28 changes: 24 additions & 4 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 @@ -70,11 +71,30 @@ export async function importComposerContextClipboard(
/** The machines the destination links to, whose threads its agent can read too. */
linkedEnvironments: ReadonlyMap<EnvironmentId, string> = new Map(),
) {
const fragment =
decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html);
const normalized = normalizeThreadClipboard(
input.text,
decodeComposerContextFragment(input.fragment) ?? decodeComposerContextClipboardHtml(input.html),
);
const fragment = normalized.fragment;
if (!fragment) return null;
const selected = referencedComposerContext(input.text, { version: 1, records: fragment.records });
const imported = reidentifyComposerContext(input.text, selected?.records ?? [], uuidv4);
const outside =
destinationEnvironmentId === undefined
? []
: threadContextsOutsideEnvironment({
text: normalized.text,
records: fragment.records,
environmentId: destinationEnvironmentId,
linkedEnvironments,
});
const outsideIds = new Set(outside.map((record) => record.contextId));
const accepted = new Map(
fragment.records
.filter((record) => !outsideIds.has(record.contextId))
.map((record) => [record.contextId, record.contextId]),
);
const text = normalizeThreadClipboard(input.text, fragment, accepted).text;
const selected = referencedComposerContext(text, { version: 1, records: fragment.records });
const imported = reidentifyComposerContext(text, selected?.records ?? [], uuidv4);
const skippedThreads =
destinationEnvironmentId === undefined
? []
Expand Down
15 changes: 1 addition & 14 deletions apps/mobile/src/lib/copyThreadReference.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,6 @@
import type { ThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference";
import * as Haptics from "expo-haptics";

import { writeComposerContextClipboard } from "./composerContextClipboard";
import { tryCopyTextWithHaptic } from "./copyTextWithHaptic";

/** Resolves false, after logging why, when the clipboard write fails. */
export async function copyThreadReference(target: ThreadReferenceCopyTarget): Promise<boolean> {
if (!target.fragment)
return tryCopyTextWithHaptic(target.value, { target: target.clipboardTarget });
try {
await writeComposerContextClipboard(target.value, target.fragment);
} catch (error) {
console.error(`Failed to copy ${target.clipboardTarget} to the clipboard.`, error);
return false;
}
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
return true;
return tryCopyTextWithHaptic(target.value, { target: target.clipboardTarget });
}
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 @@ -2530,7 +2530,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
78 changes: 78 additions & 0 deletions apps/web/src/components/composerInlineTokenPaste.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import {
COMPOSER_CONTEXT_CLIPBOARD_MIME,
encodeComposerContextFragment,
} from "@t3tools/shared/composerContextClipboard";
import {
collectComposerContextReferences,
formatComposerContextReference,
threadContextRecord,
} from "@t3tools/shared/composerContextReferences";
import { threadDeepLink } from "@t3tools/shared/threadDeepLinks";
import {
importPastedComposerText,
readPastedComposerContext,
isComposerContextPaste,
} from "./composerInlineTokenPaste";
const ref = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") };
const record = threadContextRecord(ref, "Fix login");
function clipboard(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 = clipboard(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 = clipboard(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 = clipboard(threadDeepLink(ref));
expect(importPastedComposerText(data)).toBe(threadDeepLink(ref));
expect(importPastedComposerText(data, () => new Map())).toBe(threadDeepLink(ref));
});
it("preserves legacy context reference paste", () => {
const data = clipboard(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(clipboard("log line\n".repeat(10000) + threadDeepLink(ref)))).toBe(
false,
);
expect(isComposerContextPaste(clipboard(threadDeepLink(ref)))).toBe(true);
});
Loading
Loading