diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index efa54e2ec374a..f9e80878113ee 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -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 { diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt index 441154f3e1b97..9f8555100651b 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt @@ -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? = 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 { diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index f54f75caf5f50..8aca4ee96bbe7 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -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 } diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index afabd7afb550c..4f0db7c9e9e02 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -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([]); +}); diff --git a/apps/mobile/src/lib/composerContextClipboard.ts b/apps/mobile/src/lib/composerContextClipboard.ts index e7c7201840569..5f37779e82bc7 100644 --- a/apps/mobile/src/lib/composerContextClipboard.ts +++ b/apps/mobile/src/lib/composerContextClipboard.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import { decodeComposerContextClipboardHtml, + normalizeThreadClipboard, decodeComposerContextFragment, encodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; @@ -70,11 +71,30 @@ export async function importComposerContextClipboard( /** The machines the destination links to, whose threads its agent can read too. */ linkedEnvironments: ReadonlyMap = 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 ? [] diff --git a/apps/mobile/src/lib/copyThreadReference.ts b/apps/mobile/src/lib/copyThreadReference.ts index f3921fb2605a7..35ba714b9721f 100644 --- a/apps/mobile/src/lib/copyThreadReference.ts +++ b/apps/mobile/src/lib/copyThreadReference.ts @@ -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 { - 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 }); } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 82b6b9617c1b0..36cffcad094c6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -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, @@ -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; } diff --git a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx index a050b0f51edf0..4e342c074c94f 100644 --- a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx @@ -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"; @@ -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( + [], + }} + 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); + }, +); diff --git a/apps/web/src/components/chat/useComposerContextImport.ts b/apps/web/src/components/chat/useComposerContextImport.ts index 74e97a5076d7d..4b31a72e2b479 100644 --- a/apps/web/src/components/chat/useComposerContextImport.ts +++ b/apps/web/src/components/chat/useComposerContextImport.ts @@ -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; @@ -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": { diff --git a/apps/web/src/components/composerInlineTokenPaste.test.ts b/apps/web/src/components/composerInlineTokenPaste.test.ts new file mode 100644 index 0000000000000..16ea3df49872a --- /dev/null +++ b/apps/web/src/components/composerInlineTokenPaste.test.ts @@ -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); +}); diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index 32722ad6a9571..c3bf3f489a2af 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -1,6 +1,8 @@ +import { parseThreadDeepLink } from "@t3tools/shared/threadDeepLinks"; import { ComposerContextId } from "@t3tools/contracts"; import type { ComposerContextClipboardFragment } from "@t3tools/contracts"; import { + normalizeThreadClipboard, COMPOSER_CONTEXT_CLIPBOARD_MIME, decodeComposerContextFragment, decodeComposerContextClipboardHtml, @@ -13,10 +15,14 @@ import { export function readPastedComposerContext( clipboardData: Pick, ): ComposerContextClipboardFragment | null { - const pastedText = clipboardData.getData("text/plain"); - const decodedFragment = + const rawText = clipboardData.getData("text/plain"); + const decoded = decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ?? decodeComposerContextClipboardHtml(clipboardData.getData("text/html")); + const { text: pastedText, fragment: decodedFragment } = normalizeThreadClipboard( + rawText, + decoded, + ); if (decodedFragment === null) return null; const pastedIds = new Set( collectComposerContextReferences(pastedText).map((occurrence) => occurrence.contextId), @@ -43,10 +49,13 @@ export function importPastedComposerText( fragment: ComposerContextClipboardFragment, ) => ReadonlyMap, ): string { - const pastedText = clipboardData.getData("text/plain"); + const rawText = clipboardData.getData("text/plain"); const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; const rewrittenIds = fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null; + const pastedText = rewrittenIds + ? normalizeThreadClipboard(rawText, fragment, rewrittenIds).text + : rawText; const text = rewrittenIds && rewrittenIds.size > 0 ? replaceComposerContextReferences(pastedText, (occurrence) => { @@ -62,3 +71,13 @@ export function importPastedComposerText( : pastedText; return text; } + +export function isComposerContextPaste(clipboardData: Pick): boolean { + if ( + decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) || + decodeComposerContextClipboardHtml(clipboardData.getData("text/html")) + ) + return true; + const text = clipboardData.getData("text/plain").trim(); + return text.length > 0 && text.split(/\s+/).every((value) => parseThreadDeepLink(value) !== null); +} diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx index 1f901a5eae156..bd140e8f319c5 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx @@ -5,17 +5,21 @@ import { AsyncResult, type Atom } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; import { act, render, stubGlobal } from "../../../test/render"; +import { toastManager } from "../ui/toast"; const state = vi.hoisted(() => ({ projects: [] as EnvironmentProject[], routeParams: {} as Record, preview: vi.fn(), + openExternal: vi.fn(async (_url: string) => true), openLink: vi.fn(async () => undefined), navigate: vi.fn(async (): Promise => undefined), openScratchProject: vi.fn(async () => null), confirm: vi.fn(async (_message: string) => true), connectPairing: vi.fn(async (_input: { pairingUrl: string }) => ({ _tag: "Success" })), localEnvironmentDisabled: false, + catalogReady: true, + environmentIds: ["local"] as string[], })); // One connected primary environment that can read pull requests. Its project list and @@ -31,6 +35,10 @@ vi.mock(import("@tanstack/react-router"), async (original) => ({ vi.mock(import("../../state/environments"), async (original) => ({ ...(await original()), usePrimaryEnvironmentId: () => "local" as EnvironmentId, + useEnvironments: (() => ({ + isReady: state.catalogReady, + environments: state.environmentIds.map((environmentId) => ({ environmentId })), + })) as never, usePrimaryEnvironment: (() => state.localEnvironmentDisabled ? null @@ -147,6 +155,8 @@ function setup() { vi.clearAllMocks(); state.confirm.mockResolvedValue(true); state.localEnvironmentDisabled = false; + state.catalogReady = true; + state.environmentIds = ["local"]; state.projects = []; state.routeParams = {}; state.preview @@ -164,6 +174,7 @@ async function mount(...urls: string[]) { stack.use( stubGlobal("desktopBridge", { getClientSettings: async () => null, + openExternal: state.openExternal, webLinks: { onOpen: (listener: (url: string) => void) => { deliver = listener; @@ -175,14 +186,21 @@ async function mount(...urls: string[]) { }, }), ); - stack.use( + const screen = stack.use( await render( , ), ); - return stack.move(); + return Object.assign(stack.move(), { + rerender: () => + screen.rerender( + + + , + ), + }); } it("holds a launch PR link until project discovery settles, then opens the PR view", async () => { @@ -347,3 +365,59 @@ it("holds a launch PR link with Local environment disabled until discovery settl expect(state.navigate).toHaveBeenCalledExactlyOnceWith(pullRequestView); expect(state.openScratchProject).not.toHaveBeenCalled(); }); + +it.each([false, true])( + "keeps a saved remote startup thread link queued until its catalog loads (local disabled: %s)", + async (localEnvironmentDisabled) => { + setup(); + state.localEnvironmentDisabled = localEnvironmentDisabled; + state.catalogReady = false; + state.environmentIds = []; + await using coordinator = await mount("t3code://threads/remote/thread"); + expect(state.navigate).not.toHaveBeenCalled(); + expect(state.openScratchProject).not.toHaveBeenCalled(); + expect(state.openLink).not.toHaveBeenCalled(); + + const navigated = new Promise((resolve) => { + state.navigate.mockImplementationOnce(async () => resolve()); + }); + state.environmentIds = ["remote"]; + state.catalogReady = true; + await coordinator.rerender(); + await navigated; + expect(state.navigate).toHaveBeenCalledExactlyOnceWith({ href: "/remote/thread" }); + expect(state.openScratchProject).not.toHaveBeenCalled(); + expect(state.openLink).not.toHaveBeenCalled(); + }, +); + +it.each([ + { url: "https://app.t3.codes/unknown/thread", opened: true }, + { url: "t3code://threads/unknown/thread", opened: true }, + { url: "https://app.t3.codes/unknown/thread", opened: false }, +])( + "opens an uncatalogued thread externally and reports failure: $url (opened: $opened)", + async ({ url, opened }) => { + setup(); + state.openExternal.mockResolvedValueOnce(opened); + using notification = vi.spyOn(toastManager, "add"); + await using _coordinator = await mount(url); + await vi.waitFor(() => + expect(state.openExternal).toHaveBeenCalledWith("https://app.t3.codes/unknown/thread"), + ); + expect(state.openScratchProject).not.toHaveBeenCalled(); + expect(state.preview).not.toHaveBeenCalled(); + expect(state.navigate).not.toHaveBeenCalled(); + if (!opened) { + await vi.waitFor(() => + expect(notification).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + title: "Could not open the link", + description: "Unable to open link.", + }), + ), + ); + } + }, +); diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx index b138a8232bdb6..eb23ef8e2b73d 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx @@ -5,7 +5,9 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { parseThreadDeepLink, threadDeepLink } from "@t3tools/shared/threadDeepLinks"; import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; +import { readLocalApi } from "../../localApi"; import { readPairingAppLink, resolveRemotePairingTarget } from "@t3tools/shared/remote"; import { useRouter } from "@tanstack/react-router"; import * as Effect from "effect/Effect"; @@ -28,7 +30,11 @@ import { appAtomRegistry } from "../../rpc/atomRegistry"; import { resolveThreadRouteTarget } from "../../threadRoutes"; import { assetEnvironment } from "../../state/assets"; import { readProject, readThreadShell } from "../../state/entities"; -import { useEnvironmentHttpBaseUrl, usePrimaryEnvironment } from "../../state/environments"; +import { + useEnvironments, + useEnvironmentHttpBaseUrl, + usePrimaryEnvironment, +} from "../../state/environments"; import { previewEnvironment } from "../../state/preview"; import { allReachableShellsLiveAtom } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -81,6 +87,7 @@ function threadShowingBrowser( export function DesktopWebLinkCoordinator() { const router = useRouter(); const primaryEnvironment = usePrimaryEnvironment(); + const { environments, isReady: environmentsReady } = useEnvironments(); const openPullRequestsInApp = useClientSettings((settings) => settings.pullRequestLinksOpenInApp); const openChangeRequest = useOpenChangeRequestLink(); const { scratchEnvironmentId, openScratchProject } = useScratchProject(); @@ -98,9 +105,11 @@ export function DesktopWebLinkCoordinator() { // A sync IPC read; the setting only changes across a relaunch. const [localEnvironmentDisabled] = useState(isLocalEnvironmentDisabled); // With Local environment disabled there is no primary to wait for, so links run against the - // saved remote environments instead of waiting forever. + // saved remote environments instead of waiting forever. The catalog must finish loading + // before the bridge drains startup links, so saved remotes are not mistaken for unknown ones. const ready = webLinks !== undefined && + environmentsReady && (localEnvironmentDisabled || (primaryEnvironment?.connection.phase === "connected" && primaryEnvironment.serverConfig !== null)); @@ -207,10 +216,23 @@ export function DesktopWebLinkCoordinator() { return; } const appPath = promptLinkAppPath(url); - if (appPath !== null) { + const bareThread = parseThreadDeepLink(url); + if ( + appPath !== null && + (bareThread === null || + environments.some((e) => e.environmentId === bareThread.environmentId)) + ) { await router.navigate({ href: appPath }); return; } + if (bareThread !== null) { + try { + await readLocalApi()?.shell.openExternal(threadDeepLink(bareThread)); + } catch (error) { + reportFailure(url, error instanceof Error ? error.message : url); + } + return; + } const routeParams = router.state.matches.at(-1)?.params ?? {}; const routeTarget = resolveThreadRouteTarget(routeParams); const activeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : undefined; diff --git a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx index 24facbea1911f..8780284971ee6 100644 --- a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx +++ b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx @@ -19,6 +19,8 @@ import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; import { StandalonePage, StandalonePageHeader } from "../ui/standalone-page"; +import { hostedThreadLinkAction } from "./hostedThreadLink"; + const OPENER_STORAGE_KEY = "t3code:prompt-link-opener"; const Opener = Schema.NullOr(Schema.Literals(["app", "browser"])); @@ -69,6 +71,13 @@ export function useHostedPromptLinkLanding(enabled: boolean): ReactNode | null { ? promptLinkAppPath(new URL(href, window.location.origin).href, window.location.origin) : null; if (appPath === null || continuedHref === href) return null; + const threadAction = hostedThreadLinkAction( + appPath, + isReady, + environments.map((e) => e.environmentId), + ); + if (threadAction === "wait") return <>; + if (threadAction === "continue") return null; const canContinue = isReady && environments.length > 0; if (opener === "browser") { if (canContinue) return null; @@ -78,7 +87,7 @@ export function useHostedPromptLinkLanding(enabled: boolean): ReactNode | null { { if (remember) setOpener(choice); if (choice === "browser") setContinuedHref(href); @@ -123,8 +132,12 @@ function HostedPromptLinkLanding({ return ( {prompt !== null && prompt.hiddenCharacters > 0 ? ( diff --git a/apps/web/src/components/promptLinks/hostedThreadLink.test.ts b/apps/web/src/components/promptLinks/hostedThreadLink.test.ts new file mode 100644 index 0000000000000..8779e5679b508 --- /dev/null +++ b/apps/web/src/components/promptLinks/hostedThreadLink.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; +import { hostedThreadLinkAction } from "./hostedThreadLink"; +describe("bare hosted thread navigation", () => { + it("waits for environment discovery on refresh", () => { + expect(hostedThreadLinkAction("/env/thread", false, [])).toBe("wait"); + }); + it.each(["/env/first", "/env/second"])( + "keeps connected sidebar navigation and refresh in the browser: %s", + (path) => { + expect(hostedThreadLinkAction(path, true, ["env"])).toBe("continue"); + }, + ); + it("requires a manual choice for unknown environments even with a remembered app opener", () => { + expect(hostedThreadLinkAction("/unknown/thread", true, ["env"])).toBe("choose-manually"); + }); + it("keeps prompt links on the consent path", () => { + expect(hostedThreadLinkAction("/env/thread?prompt=go", true, ["env"])).toBe("choose"); + }); +}); diff --git a/apps/web/src/components/promptLinks/hostedThreadLink.ts b/apps/web/src/components/promptLinks/hostedThreadLink.ts new file mode 100644 index 0000000000000..7f1430d8bc50c --- /dev/null +++ b/apps/web/src/components/promptLinks/hostedThreadLink.ts @@ -0,0 +1,13 @@ +import { parseThreadPath } from "@t3tools/shared/threadDeepLinks"; +export function hostedThreadLinkAction( + appPath: string, + ready: boolean, + environmentIds: ReadonlyArray, +) { + const url = new URL(appPath, "https://app.invalid"); + const ref = parseThreadPath(url.pathname); + if (!ref || url.searchParams.has("prompt")) return "choose"; + if (!ready) return "wait"; + // Desktop may hand an unknown thread back here; never automatically send it back again. + return environmentIds.includes(ref.environmentId) ? "continue" : "choose-manually"; +} diff --git a/apps/web/src/lib/copyThreadReference.ts b/apps/web/src/lib/copyThreadReference.ts index bbfa3b73a2c9c..3efbd1c1bd103 100644 --- a/apps/web/src/lib/copyThreadReference.ts +++ b/apps/web/src/lib/copyThreadReference.ts @@ -1,20 +1,10 @@ import type { ThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; -import { - COMPOSER_CONTEXT_CLIPBOARD_MIME, - encodeComposerContextFragment, -} from "@t3tools/shared/composerContextClipboard"; - import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; export async function copyThreadReference(target: ThreadReferenceCopyTarget): Promise { - const fragment = target.fragment ? encodeComposerContextFragment(target.fragment) : null; try { - const didCopy = await writeTextToClipboard( - target.value, - target.clipboardTarget, - fragment ? { [COMPOSER_CONTEXT_CLIPBOARD_MIME]: fragment } : undefined, - ); + const didCopy = await writeTextToClipboard(target.value, target.clipboardTarget); if (!didCopy) return; toastManager.add({ type: "success", diff --git a/docs/user/composer.md b/docs/user/composer.md index 49f49a87e3690..f643097d25615 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -292,7 +292,9 @@ Another thread can be context too. Type `@` followed by part of its title to pic the same server, or from a machine it links to while the Linked environments beta is on; those show the machine's name. On web and desktop you can also drag a thread out of the sidebar and drop it on the composer; a multi-selection drops together. On any client, **Copy thread** puts a -thread on the clipboard that pastes into a composer as its chip. The chip shows the thread's +plain thread URL on the clipboard. Paste it into a composer to attach the thread as a chip, or +share it elsewhere to open the thread in a browser, desktop, or mobile app. Opening the link +requires access to the thread's environment. The chip shows the thread's current title and opens it when selected. Your prompt only carries a reference: the agent reads the thread's history on demand, so attaching a long thread costs nothing until the agent looks. Attaching a thread does not change it, and the agent cannot send messages to it unless you ask. diff --git a/packages/client-runtime/src/threadReference.test.ts b/packages/client-runtime/src/threadReference.test.ts index 9fa3245df5754..99a45a149c949 100644 --- a/packages/client-runtime/src/threadReference.test.ts +++ b/packages/client-runtime/src/threadReference.test.ts @@ -1,8 +1,5 @@ import { EnvironmentId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; -import { - decodeComposerContextFragment, - encodeComposerContextFragment, -} from "@t3tools/shared/composerContextClipboard"; +import { normalizeThreadClipboard } from "@t3tools/shared/composerContextClipboard"; import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { describe, expect, it } from "vite-plus/test"; @@ -82,29 +79,15 @@ describe("resolveThreadReferenceCopyTarget", () => { expect(target).toMatchObject({ kind: "thread", + value: "https://app.t3.codes/env-1/thread-1", description: "Fix the login flow", successTitle: "Thread copied", }); - const pasted = decodeComposerContextFragment(encodeComposerContextFragment(target!.fragment!)); - expect(pasted).toEqual({ - version: 1, - source: { environmentId: "env-1" }, - records: [ - expect.objectContaining({ - kind: "thread", - environmentId: "env-1", - threadId: "thread-1", - title: "Fix the login flow", - }), - ], - }); - // A paste keeps only the records its plain text references. - expect(collectComposerContextReferences(target!.value)).toEqual([ - expect.objectContaining({ - kind: "thread", - contextId: pasted!.records[0]!.contextId, - label: "Fix the login flow", - }), + expect(target).not.toHaveProperty("fragment"); + const pasted = normalizeThreadClipboard(target!.value, null); + expect(pasted.fragment?.records).toMatchObject([ + { kind: "thread", environmentId: "env-1", threadId: "thread-1" }, ]); + expect(collectComposerContextReferences(pasted.text)).toMatchObject([{ kind: "thread" }]); }); }); diff --git a/packages/client-runtime/src/threadReference.ts b/packages/client-runtime/src/threadReference.ts index 94f68202a62cf..3478ab13ba8fa 100644 --- a/packages/client-runtime/src/threadReference.ts +++ b/packages/client-runtime/src/threadReference.ts @@ -1,23 +1,16 @@ -import type { - ComposerContextClipboardFragment, - ScopedThreadRef, - ThreadPullRequestLink, -} from "@t3tools/contracts"; -import { - formatComposerContextReference, - threadContextRecord, -} from "@t3tools/shared/composerContextReferences"; +import type { ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; + +import { threadDeepLink } from "@t3tools/shared/threadDeepLinks"; import { resolveThreadCurrentPullRequestLink } from "./threadPullRequests.ts"; export interface ThreadReferenceCopyTarget { readonly kind: "pull-request" | "thread"; - /** The plain text: a PR URL, or a thread chip's reference link. */ + /** The plain text: a PR URL or a public thread deep link. */ readonly value: string; /** What a toast or palette row shows for the copy. */ readonly description: string; - /** T3's clipboard format, which pastes a thread back into a composer as a chip. */ - readonly fragment?: ComposerContextClipboardFragment; readonly clipboardTarget: string; readonly successTitle: string; readonly failureTitle: string; @@ -31,9 +24,8 @@ export function threadChipCopyTarget( const record = threadContextRecord(threadRef, title); return { kind: "thread", - value: formatComposerContextReference(record), + value: threadDeepLink(threadRef), description: record.title, - fragment: { version: 1, source: { environmentId: threadRef.environmentId }, records: [record] }, clipboardTarget: "thread", successTitle: "Thread copied", failureTitle: "Failed to copy thread", diff --git a/packages/shared/package.json b/packages/shared/package.json index f17f48f1c9e6c..e92901b75b62f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -518,6 +518,10 @@ "./cloneUrl": { "types": "./src/cloneUrl.ts", "import": "./src/cloneUrl.ts" + }, + "./threadDeepLinks": { + "types": "./src/threadDeepLinks.ts", + "import": "./src/threadDeepLinks.ts" } }, "scripts": { diff --git a/packages/shared/src/composerContextClipboard.ts b/packages/shared/src/composerContextClipboard.ts index 6036795a780cb..2fcccf98ae8db 100644 --- a/packages/shared/src/composerContextClipboard.ts +++ b/packages/shared/src/composerContextClipboard.ts @@ -5,8 +5,74 @@ import { import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import { + formatComposerContextReference, + threadContextRecord, + toKindScopedComposerContextId, +} from "./composerContextReferences.ts"; +import { parseThreadDeepLink } from "./threadDeepLinks.ts"; + export { COMPOSER_CONTEXT_CLIPBOARD_MIME }; +/** Normalize public URLs before filtering clipboard records, including plain-text-only pastes. */ +export function normalizeThreadClipboard( + text: string, + fragment: ComposerContextClipboardFragment | null, + acceptedIds?: ReadonlyMap, +) { + if (!text.includes("app.t3.codes/") && !text.includes("://threads/")) return { text, fragment }; + const records = [...(fragment?.records ?? [])]; + const byThread = new Map( + records.flatMap((record) => + record.kind === "thread" && !("payload" in record) + ? [[JSON.stringify([record.environmentId, record.threadId]), record] as const] + : [], + ), + ); + const ids = new Set(records.map((record) => record.contextId)); + let source = fragment?.source; + // Consume code and every Markdown link as a unit; never rewrite a URL inside another link. + const normalized = text.replace( + /(?`{3,}|~{3,})[\s\S]*?(?:\k|$)|(?`+)[^\n]*?\k|!?\[(?