diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af7cdc8a94a2..265e218bc20a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -112,6 +112,7 @@ import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; +import { readThreadLink } from "../threadLink"; import { buildThreadRouteParams, resolveActiveThreadRouteRef, @@ -1772,6 +1773,25 @@ export default function Sidebar() { ); }, }); + const { copyToClipboard: copyLinkToClipboard } = useCopyToClipboard<{ link: string }>({ + target: "link", + onCopy: ({ link }) => { + toastManager.add({ + type: "success", + title: "Link copied", + description: link, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + }); const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ onCopy: ({ threadId }) => { toastManager.add({ @@ -3065,6 +3085,7 @@ export default function Sidebar() { const isPinned = thread.pinnedAt != null; // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const threadLink = readThreadLink(threadRef); const clicked = await settlePromise(() => api.contextMenu.show( buildThreadActionMenuItems({ @@ -3083,6 +3104,7 @@ export default function Sidebar() { titleRegeneration: supportsTitleRegeneration, }, snoozePresets, + canCopyLink: threadLink !== null, }), position, ), @@ -3158,6 +3180,11 @@ export default function Sidebar() { case "mark-unread": markThreadUnread(threadKey, thread.latestTurn?.completedAt); return; + case "copy-link": + if (threadLink) { + copyLinkToClipboard(threadLink, { link: threadLink }); + } + return; case "copy-path": if (!threadWorkspacePath) { toastManager.add( @@ -3250,6 +3277,7 @@ export default function Sidebar() { confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, + copyLinkToClipboard, copyPathToClipboard, copyThreadIdToClipboard, deleteThread, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 96931bc8f875..b64d2aa20377 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -11,6 +11,7 @@ const baseState: ThreadActionMenuState = { isRegeneratingTitle: false, isRunning: false, supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true }, + canCopyLink: true, snoozePresets: [ { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" }, ], @@ -44,6 +45,11 @@ describe("buildThreadActionMenuItems", () => { expect(allIds(baseState)).not.toContain("copy-branch"); }); + it("offers the thread link only when this client can build one", () => { + expect(allIds(baseState)).toContain("copy-link"); + expect(allIds({ ...baseState, canCopyLink: false })).not.toContain("copy-link"); + }); + it("flips lifecycle labels with thread state", () => { expect(ids({ ...baseState, isPinned: true, isSettled: true, isSnoozed: true })).toEqual( expect.arrayContaining(["unpin", "unsettle", "unsnooze"]), diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index df983ee86773..bdc162d78eab 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -19,6 +19,7 @@ export type ThreadActionMenuId = | "regenerate-title" | "mark-unread" | "copy" + | "copy-link" | "copy-path" | "copy-branch" | "copy-thread-id" @@ -41,6 +42,8 @@ export interface ThreadActionMenuState { readonly titleRegeneration: boolean; }; readonly snoozePresets: ReadonlyArray; + /** False when this client cannot express the thread's address as a URL. */ + readonly canCopyLink: boolean; } /** @@ -112,6 +115,7 @@ export function buildThreadActionMenuItems( icon: "copy", separatorBefore: true, children: [ + ...(state.canCopyLink ? [{ id: "copy-link" as const, label: "Link", icon: "link" }] : []), { id: "copy-path", label: "Path", icon: "folder" }, ...(state.branch ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index f2c7f42a0617..09c6b06480db 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -51,6 +51,10 @@ const ICON_PATHS: Record failureToast("Failed to copy branch", error), }); + const { copyToClipboard: copyLinkToClipboard } = useCopyToClipboard<{ link: string }>({ + target: "link", + onCopy: ({ link }) => { + toastManager.add({ type: "success", title: "Link copied", description: link }); + }, + onError: (error) => failureToast("Failed to copy link", error), + }); const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ onCopy: ({ threadId }) => { toastManager.add({ type: "success", title: "Thread ID copied", description: threadId }); @@ -124,6 +132,7 @@ export function useThreadActionMenu(input: { }; const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const threadLink = readThreadLink(threadRef); const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, isPinned: thread.pinnedAt != null, @@ -144,6 +153,7 @@ export function useThreadActionMenu(input: { isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, supports, snoozePresets, + canCopyLink: threadLink !== null, }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; @@ -233,6 +243,11 @@ export function useThreadActionMenu(input: { case "mark-unread": markThreadUnread(scopedThreadKey(threadRef), thread.latestTurn?.completedAt); return; + case "copy-link": + if (threadLink) { + copyLinkToClipboard(threadLink, { link: threadLink }); + } + return; case "copy-path": { const workspacePath = thread.worktreePath ?? projectCwd; if (!workspacePath) { @@ -316,6 +331,7 @@ export function useThreadActionMenu(input: { confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, + copyLinkToClipboard, copyPathToClipboard, copyThreadIdToClipboard, deleteThread, diff --git a/apps/web/src/threadLink.test.ts b/apps/web/src/threadLink.test.ts new file mode 100644 index 000000000000..421c11cbe6eb --- /dev/null +++ b/apps/web/src/threadLink.test.ts @@ -0,0 +1,50 @@ +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadLink } from "./threadLink"; + +const ref = { + environmentId: "env_local" as EnvironmentId, + threadId: "thr_123" as ThreadId, +} as ScopedThreadRef; + +describe("resolveThreadLink", () => { + it("uses the browser origin the client is served from", () => { + expect( + resolveThreadLink({ + clientOrigin: "https://app.t3.codes", + environmentHttpBaseUrl: "http://192.168.1.4:3773", + ref, + }), + ).toBe("https://app.t3.codes/env_local/thr_123"); + }); + + it("falls back to the environment server when the origin is not a web origin", () => { + expect( + resolveThreadLink({ + clientOrigin: "t3code://app", + environmentHttpBaseUrl: "http://127.0.0.1:3773/", + ref, + }), + ).toBe("http://127.0.0.1:3773/env_local/thr_123"); + }); + + it("returns null when neither the client nor the environment has a web origin", () => { + expect( + resolveThreadLink({ clientOrigin: "t3code://app", environmentHttpBaseUrl: null, ref }), + ).toBeNull(); + expect( + resolveThreadLink({ clientOrigin: null, environmentHttpBaseUrl: "not a url", ref }), + ).toBeNull(); + }); + + it("drops any path the environment base URL carries", () => { + expect( + resolveThreadLink({ + clientOrigin: null, + environmentHttpBaseUrl: "https://tunnel.example.com/base/", + ref, + }), + ).toBe("https://tunnel.example.com/env_local/thr_123"); + }); +}); diff --git a/apps/web/src/threadLink.ts b/apps/web/src/threadLink.ts new file mode 100644 index 000000000000..3dab361e5aca --- /dev/null +++ b/apps/web/src/threadLink.ts @@ -0,0 +1,47 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { readPreparedConnection } from "~/state/session"; +import { buildThreadRoutePath } from "./threadRoutes"; + +function browserOrigin(value: string | null): string | null { + if (value === null) { + return null; + } + return value.startsWith("http://") || value.startsWith("https://") ? value : null; +} + +/** + * The address a browser can open to land on a thread. + * + * In a browser the answer is the address bar: the app is served from the + * origin the user is already on. The desktop app has no address bar and its + * renderer origin (`t3code://app`) means nothing outside the app, so the link + * falls back to the environment's own HTTP base URL — the T3 server that owns + * the thread and serves the web client for it. Environments reachable only + * through a target we cannot express as an HTTP origin get no link. + */ +export function resolveThreadLink(input: { + /** `window.location.origin`, or null off a browser origin. */ + readonly clientOrigin: string | null; + readonly environmentHttpBaseUrl: string | null; + readonly ref: ScopedThreadRef; +}): string | null { + const base = browserOrigin(input.clientOrigin) ?? browserOrigin(input.environmentHttpBaseUrl); + if (base === null) { + return null; + } + try { + return new URL(buildThreadRoutePath(input.ref), base).toString(); + } catch { + return null; + } +} + +/** `resolveThreadLink` against this client's origin and live connection. */ +export function readThreadLink(ref: ScopedThreadRef): string | null { + return resolveThreadLink({ + clientOrigin: typeof window === "undefined" ? null : window.location.origin, + environmentHttpBaseUrl: readPreparedConnection(ref.environmentId)?.httpBaseUrl ?? null, + ref, + }); +} diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index fd5bc39d836a..45a88d6dbd7a 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -101,3 +101,8 @@ export function resolveActiveThreadRouteRef( } return draftThread.promotedTo; } + +/** The pathname the web client renders a thread at. */ +export function buildThreadRoutePath(ref: ScopedThreadRef): string { + return `/${encodeURIComponent(ref.environmentId)}/${encodeURIComponent(ref.threadId)}`; +} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 70b3cccc962a..6de65f528b08 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -12,6 +12,18 @@ If reordering is unavailable for one environment, update the T3 Code server runn environment. Older servers can still pin and unpin threads, but do not understand synced ordering; their pinned threads keep the default newest-first order below the ones you have arranged. +## Linking to a thread + +Open a thread's context menu and choose **Copy → Link** to put its address on the clipboard. In a +browser the link uses the address you are already on; in the desktop app, which has no address bar, +it uses the address this client reaches the environment at — a `localhost` address for a server on +this machine, or its tunnel address when you connect remotely. + +The link opens the thread on any device that can reach that address and is paired with the +environment, so it is worth checking that the address is one the other device can reach before you +send it. It is not a public share link: someone without access to the environment cannot read the +thread through it. + ## Environment artwork Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in