authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-01 12:48:56-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 12:48:56-04:00
log2d156a83b96ebf2e4a9c6017251baad357ae6ab1
tree1b5f613c9facbd79feaa3c699fd7d8f808f6d44d
parentb5b6abb11e61ad2c9946b8d1346653c6c43d2261
signature Signed by PGP key B5690EEEBB952194

feat(shortcuts): copy active thread reference (#8994)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

20 files changed, 500 insertions(+), 16 deletions(-)

apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt created+46
......@@ -0,0 +1,46 @@
1package expo.modules.t3nativecontrols
2
3import android.content.Context
4import android.view.KeyEvent
5import expo.modules.kotlin.AppContext
6import expo.modules.kotlin.modules.Module
7import expo.modules.kotlin.modules.ModuleDefinition
8import expo.modules.kotlin.viewevent.EventDispatcher
9import expo.modules.kotlin.views.ExpoView
10
11class T3KeyboardCommandsModule : Module() {
12 override fun definition() = ModuleDefinition {
13 Name("T3KeyboardCommands")
14
15 View(T3KeyboardCommandsView::class) {
16 Prop("enabledCommands") { view: T3KeyboardCommandsView, commands: List<String> ->
17 view.enabledCommands = commands.toSet()
18 }
19 Events("onCommand")
20 }
21 }
22}
23
24class T3KeyboardCommandsView(
25 context: Context,
26 appContext: AppContext
27) : ExpoView(context, appContext) {
28 private val onCommand by EventDispatcher()
29 var enabledCommands = emptySet<String>()
30
31 override fun dispatchKeyEvent(event: KeyEvent): Boolean {
32 val copiesThreadReference =
33 event.action == KeyEvent.ACTION_DOWN &&
34 event.repeatCount == 0 &&
35 event.keyCode == KeyEvent.KEYCODE_C &&
36 event.isCtrlPressed &&
37 event.isShiftPressed &&
38 !event.isAltPressed &&
39 enabledCommands.contains("copyThreadReference")
40 if (copiesThreadReference) {
41 onCommand(mapOf("command" to "copyThreadReference"))
42 return true
43 }
44 return super.dispatchKeyEvent(event)
45 }
46}
apps/mobile/modules/t3-native-controls/expo-module.config.json+4-1
......@@ -4,6 +4,9 @@
44 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"]
55 },
66 "android": {
7 "modules": ["expo.modules.t3nativecontrols.T3NativeControlsModule"]
7 "modules": [
8 "expo.modules.t3nativecontrols.T3NativeControlsModule",
9 "expo.modules.t3nativecontrols.T3KeyboardCommandsModule"
10 ]
811 }
912}
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+8
......@@ -29,6 +29,13 @@ public final class T3KeyboardCommandsView: ExpoView {
2929 enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"),
3030 enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"),
3131 enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"),
32 enabledCommand(
33 "copyThreadReference",
34 input: "c",
35 modifiers: [.command, .shift],
36 action: #selector(copyThreadReference),
37 title: "Copy PR Link or Thread ID"
38 ),
3239 enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),
3340 ].compactMap { $0 }
3441 }
......@@ -106,6 +113,7 @@ public final class T3KeyboardCommandsView: ExpoView {
106113 @objc private func openFiles() { emit("files") }
107114 @objc private func openTerminal() { emit("terminal") }
108115 @objc private func openReview() { emit("review") }
116 @objc private func copyThreadReference() { emit("copyThreadReference") }
109117 @objc private func handleToggleSidebar() { emit("toggleSidebar") }
110118
111119 private func emit(_ command: String) {
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+124-6
......@@ -1,7 +1,22 @@
11import { StackActions, useNavigation } from "@react-navigation/native";
2import { useCallback, useMemo, useSyncExternalStore, type PropsWithChildren } from "react";
2import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
3import {
4 useCallback,
5 useEffect,
6 useMemo,
7 useRef,
8 useState,
9 useSyncExternalStore,
10 type PropsWithChildren,
11} from "react";
312
13import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
414import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
15import { useProject, useThreadShell } from "../../state/entities";
16import { useEnvironmentQuery } from "../../state/query";
17import type { GitActionProgress } from "../../state/use-vcs-action-state";
18import { vcsEnvironment } from "../../state/vcs";
19import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";
520import {
621 dispatchHardwareKeyboardCommand,
722 getHardwareKeyboardCommandRegistrationVersion,
......@@ -11,11 +26,86 @@ import {
1126 type HardwareKeyboardCommand,
1227} from "./hardwareKeyboardCommands";
1328
29const EMPTY_COPY_FEEDBACK: GitActionProgress = {
30 phase: "idle",
31 label: null,
32 description: null,
33};
34const COPY_FEEDBACK_DISMISS_MS = 3_000;
35
1436export function HardwareKeyboardCommandProvider({
1537 children,
1638 pathname,
1739}: PropsWithChildren<{ readonly pathname: string }>) {
1840 const navigation = useNavigation();
41 const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]);
42 const activeThread = useThreadShell(activeThreadRef);
43 const activeProjectRef = useMemo(
44 () =>
45 activeThread === null
46 ? null
47 : {
48 environmentId: activeThread.environmentId,
49 projectId: activeThread.projectId,
50 },
51 [activeThread],
52 );
53 const activeProject = useProject(activeProjectRef);
54 const activeThreadCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null;
55 const gitStatus = useEnvironmentQuery(
56 activeThread !== null &&
57 activeThread.linkedPullRequest == null &&
58 activeThread.branch !== null &&
59 activeThreadCwd !== null
60 ? vcsEnvironment.status({
61 environmentId: activeThread.environmentId,
62 input: { cwd: activeThreadCwd },
63 })
64 : null,
65 ).data;
66 const detectedPullRequestUrl =
67 activeThread?.branch != null && gitStatus?.refName === activeThread.branch
68 ? (gitStatus.pr?.url ?? null)
69 : null;
70 const copyTarget = useMemo(
71 () =>
72 activeThreadRef === null
73 ? null
74 : resolveThreadReferenceCopyTarget({
75 threadId: activeThread?.id ?? activeThreadRef.threadId,
76 linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? null,
77 detectedPullRequestUrl,
78 }),
79 [activeThread, activeThreadRef, detectedPullRequestUrl],
80 );
81 const [copyFeedback, setCopyFeedback] = useState<GitActionProgress>(EMPTY_COPY_FEEDBACK);
82 const copyRequestIdRef = useRef(0);
83 const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
84 const dismissCopyFeedback = useCallback(() => {
85 if (copyFeedbackTimerRef.current !== null) {
86 clearTimeout(copyFeedbackTimerRef.current);
87 copyFeedbackTimerRef.current = null;
88 }
89 setCopyFeedback(EMPTY_COPY_FEEDBACK);
90 }, []);
91 const showCopyFeedback = useCallback((feedback: GitActionProgress) => {
92 if (copyFeedbackTimerRef.current !== null) {
93 clearTimeout(copyFeedbackTimerRef.current);
94 }
95 setCopyFeedback(feedback);
96 copyFeedbackTimerRef.current = setTimeout(() => {
97 copyFeedbackTimerRef.current = null;
98 setCopyFeedback(EMPTY_COPY_FEEDBACK);
99 }, COPY_FEEDBACK_DISMISS_MS);
100 }, []);
101 useEffect(
102 () => () => {
103 if (copyFeedbackTimerRef.current !== null) {
104 clearTimeout(copyFeedbackTimerRef.current);
105 }
106 },
107 [],
108 );
19109 const registrationVersion = useSyncExternalStore(
20110 subscribeToHardwareKeyboardCommandRegistrations,
21111 getHardwareKeyboardCommandRegistrationVersion,
......@@ -25,10 +115,11 @@ export function HardwareKeyboardCommandProvider({
25115 const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());
26116 commands.add("newTask");
27117 if (pathname !== "/" || navigation.canGoBack()) commands.add("back");
28 if (parseActiveThreadPath(pathname)) {
118 if (activeThreadRef !== null) {
29119 commands.add("files");
30120 commands.add("terminal");
31121 commands.add("review");
122 if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference");
32123 }
33124 return [...commands];
34125 }, [pathname, registrationVersion, navigation]);
......@@ -37,6 +128,30 @@ export function HardwareKeyboardCommandProvider({
37128 (command: HardwareKeyboardCommand) => {
38129 if (dispatchHardwareKeyboardCommand(command)) return;
39130
131 if (command === "copyThreadReference") {
132 if (copyTarget === null) return;
133 const requestId = ++copyRequestIdRef.current;
134 void tryCopyTextWithHaptic(copyTarget.value, {
135 target: copyTarget.clipboardTarget,
136 }).then((didCopy) => {
137 if (requestId !== copyRequestIdRef.current) return;
138 showCopyFeedback(
139 didCopy
140 ? {
141 phase: "success",
142 label: copyTarget.successTitle,
143 description: copyTarget.value,
144 }
145 : {
146 phase: "error",
147 label: copyTarget.failureTitle,
148 description: "Try again.",
149 },
150 );
151 });
152 return;
153 }
154
40155 if (command === "newTask") {
41156 navigation.navigate("NewTaskSheet", { screen: "NewTask" });
42157 return;
......@@ -62,12 +177,15 @@ export function HardwareKeyboardCommandProvider({
62177 navigation.navigate("ThreadReview", thread);
63178 }
64179 },
65 [pathname, navigation],
180 [copyTarget, navigation, pathname, showCopyFeedback],
66181 );
67182
68183 return (
69 <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}>
70 {children}
71 </T3KeyboardCommands>
184 <>
185 <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}>
186 {children}
187 </T3KeyboardCommands>
188 <GitActionProgressOverlay progress={copyFeedback} onDismiss={dismissCopyFeedback} />
189 </>
72190 );
73191}
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts+1
......@@ -8,6 +8,7 @@ export type HardwareKeyboardCommand =
88 | "files"
99 | "terminal"
1010 | "review"
11 | "copyThreadReference"
1112 | "toggleSidebar";
1213
1314type CommandHandler = () => boolean | void;
apps/mobile/src/lib/copyTextWithHaptic.test.ts+14
......@@ -22,6 +22,7 @@ import {
2222 CopyTextClipboardWriteError,
2323 CopyTextHapticFeedbackError,
2424 copyTextWithHaptic,
25 tryCopyTextWithHaptic,
2526} from "./copyTextWithHaptic";
2627
2728describe("copyTextWithHaptic", () => {
......@@ -54,6 +55,19 @@ describe("copyTextWithHaptic", () => {
5455 expect(mocks.impactAsync).not.toHaveBeenCalled();
5556 });
5657
58 it("reports whether the clipboard write succeeded", async () => {
59 mocks.setStringAsync.mockResolvedValueOnce(undefined);
60
61 await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(true);
62 });
63
64 it("returns false when the clipboard write fails", async () => {
65 vi.spyOn(console, "error").mockImplementation(() => undefined);
66 mocks.setStringAsync.mockRejectedValueOnce(new Error("native clipboard failure"));
67
68 await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(false);
69 });
70
5771 it("reports structured failures without including clipboard contents", async () => {
5872 const clipboardCause = new Error("native clipboard failure");
5973 const hapticCause = new Error("native haptic failure");
apps/mobile/src/lib/copyTextWithHaptic.ts+17-7
......@@ -27,19 +27,22 @@ export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass<CopyTex
2727 }
2828}
2929
30export function copyTextWithHaptic(
30interface CopyTextWithHapticOptions {
31 readonly target?: string;
32 readonly feedback?: "light-impact" | "selection";
33}
34
35export async function tryCopyTextWithHaptic(
3136 value: string,
32 options: {
33 readonly target?: string;
34 readonly feedback?: "light-impact" | "selection";
35 } = {},
36): void {
37 options: CopyTextWithHapticOptions = {},
38): Promise<boolean> {
3739 const target = options.target ?? "text";
3840 const feedback = options.feedback ?? "light-impact";
3941
40 void (async () => {
42 const clipboardWrite = (async () => {
4143 try {
4244 await Clipboard.setStringAsync(value);
45 return true;
4346 } catch (cause) {
4447 console.error(
4548 new CopyTextClipboardWriteError({
......@@ -47,6 +50,7 @@ export function copyTextWithHaptic(
4750 cause,
4851 }),
4952 );
53 return false;
5054 }
5155 })();
5256
......@@ -67,4 +71,10 @@ export function copyTextWithHaptic(
6771 );
6872 }
6973 })();
74
75 return await clipboardWrite;
76}
77
78export function copyTextWithHaptic(value: string, options: CopyTextWithHapticOptions = {}): void {
79 void tryCopyTextWithHaptic(value, options);
7080}
apps/mobile/src/native/T3KeyboardCommands.android.tsx created+31
......@@ -0,0 +1,31 @@
1import { requireNativeView } from "expo";
2import type { PropsWithChildren } from "react";
3import type { NativeSyntheticEvent, ViewProps } from "react-native";
4
5import type { HardwareKeyboardCommand } from "../features/keyboard/hardwareKeyboardCommands";
6
7interface NativeKeyboardCommandsProps extends ViewProps, PropsWithChildren {
8 readonly enabledCommands: ReadonlyArray<HardwareKeyboardCommand>;
9 readonly onCommand: (
10 event: NativeSyntheticEvent<{ readonly command: HardwareKeyboardCommand }>,
11 ) => void;
12}
13
14const NativeKeyboardCommands = requireNativeView<NativeKeyboardCommandsProps>("T3KeyboardCommands");
15
16export function T3KeyboardCommands(
17 props: PropsWithChildren<{
18 readonly enabledCommands: ReadonlyArray<HardwareKeyboardCommand>;
19 readonly onCommand: (command: HardwareKeyboardCommand) => void;
20 }>,
21) {
22 return (
23 <NativeKeyboardCommands
24 onCommand={(event) => props.onCommand(event.nativeEvent.command)}
25 enabledCommands={props.enabledCommands}
26 style={{ flex: 1 }}
27 >
28 {props.children}
29 </NativeKeyboardCommands>
30 );
31}
apps/mobile/src/state/use-thread-selection.ts+1
......@@ -54,6 +54,7 @@ function threadDetailToShell(
5454 interactionMode: thread.interactionMode,
5555 branch: thread.branch,
5656 worktreePath: thread.worktreePath,
57 linkedPullRequest: thread.linkedPullRequest ?? null,
5758 latestTurn: thread.latestTurn,
5859 createdAt: thread.createdAt,
5960 updatedAt: thread.updatedAt,
apps/server/src/keybindings.test.ts+1
......@@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
195195
196196 assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");
197197 assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
198 assert.equal(defaultsByCommand.get("thread.copyReference"), "mod+shift+c");
198199 assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s");
199200 assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p");
200201 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
apps/web/src/components/ChatView.tsx+44
......@@ -50,6 +50,7 @@ import {
5050} from "@t3tools/shared/model";
5151import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
5252import { truncate } from "@t3tools/shared/String";
53import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
5354import {
5455 getTerminalLabel,
5556 nextTerminalId,
......@@ -4593,6 +4594,41 @@ function ChatViewContent(props: ChatViewProps) {
45934594 linkedPullRequest: linkedThreadPullRequest,
45944595 linkedPullRequestStatus,
45954596 });
4597 const activeThreadReferenceCopyTarget = useMemo(
4598 () =>
4599 activeThreadId === null || !isServerThread
4600 ? null
4601 : resolveThreadReferenceCopyTarget({
4602 threadId: activeThreadId,
4603 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,
4604 detectedPullRequestUrl: activeThreadPr?.url ?? null,
4605 }),
4606 [activeThreadId, activeThreadPr?.url, isServerThread, linkedThreadPullRequest?.url],
4607 );
4608 const copyActiveThreadReference = useCallback(() => {
4609 const target = activeThreadReferenceCopyTarget;
4610 if (target === null) return;
4611 void writeTextToClipboard(target.value, target.clipboardTarget).then(
4612 (didCopy) => {
4613 if (!didCopy) return;
4614 toastManager.add({
4615 type: "success",
4616 title: target.successTitle,
4617 description: target.value,
4618 });
4619 },
4620 (error) => {
4621 console.error(error);
4622 toastManager.add(
4623 stackedThreadToast({
4624 type: "error",
4625 title: target.failureTitle,
4626 description: error instanceof Error ? error.message : "An error occurred.",
4627 }),
4628 );
4629 },
4630 );
4631 }, [activeThreadReferenceCopyTarget]);
45964632 // The right panel offers the thread's own change request, so it can only offer it once the
45974633 // branch has one; until then the picker says so rather than opening an empty panel.
45984634 const addPullRequestSurface = useCallback(() => {
......@@ -5257,6 +5293,13 @@ function ChatViewContent(props: ChatViewProps) {
52575293 });
52585294 if (!command) return;
52595295
5296 if (command === "thread.copyReference") {
5297 event.preventDefault();
5298 event.stopPropagation();
5299 if (!event.repeat) copyActiveThreadReference();
5300 return;
5301 }
5302
52605303 if (command === "thread.settle") {
52615304 event.preventDefault();
52625305 event.stopPropagation();
......@@ -5426,6 +5469,7 @@ function ChatViewContent(props: ChatViewProps) {
54265469 supportsPinning,
54275470 supportsSettlement,
54285471 confirmAndUnpinThread,
5472 copyActiveThreadReference,
54295473 toggleRightPanel,
54305474 toggleRightPanelMaximized,
54315475 toggleTerminalVisibility,
apps/web/src/components/CommandPalette.tsx+90-2
......@@ -1,6 +1,10 @@
11"use client";
22
3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
3import {
4 scopedThreadKey,
5 scopeProjectRef,
6 scopeThreadRef,
7} from "@t3tools/client-runtime/environment";
48import {
59 canCreateProjectInEnvironment,
610 getCloneDestinationBrowsePath,
......@@ -11,6 +15,7 @@ import {
1115} from "@t3tools/client-runtime/operations/projects";
1216import { connectionStatusText } from "@t3tools/client-runtime/connection";
1317import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
18import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
1419import {
1520 canPreloadBrowsePath,
1621 createBrowseNavigationCoordinator,
......@@ -65,6 +70,7 @@ import { useAtomValue } from "@effect/atom-react";
6570import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
6671import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
6772import { useHandleNewThread } from "../hooks/useHandleNewThread";
73import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
6874import { useClientSettings } from "../hooks/useSettings";
6975import { useTheme } from "../hooks/useTheme";
7076import { readLocalApi } from "../localApi";
......@@ -73,11 +79,13 @@ import { filesystemEnvironment } from "../state/filesystem";
7379import { projectEnvironment } from "../state/projects";
7480import { useEnvironmentQuery } from "../state/query";
7581import { sourceControlEnvironment } from "../state/sourceControl";
82import { vcsEnvironment } from "../state/vcs";
7683import { useAtomCommand } from "../state/use-atom-command";
7784import { useAtomQueryRunner } from "../state/use-atom-query-runner";
7885import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
79import { useProjects, useThreadShells } from "../state/entities";
86import { useProject, useProjects, useThreadShells } from "../state/entities";
8087import { useThreadSearch } from "../state/queries";
88import * as ThreadPr from "./ThreadStatusIndicators";
8189import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
8290import {
8391 appendBrowsePathSegment,
......@@ -592,6 +600,65 @@ function OpenCommandPaletteDialog(props: {
592600 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
593601 useHandleNewThread();
594602 const projects = useProjects();
603 const changeRequestSnapshotByKey = useAtomValue(ThreadPr.threadChangeRequestSnapshotsAtom);
604 const activeThreadProject = useProject(
605 activeThread === null
606 ? null
607 : scopeProjectRef(activeThread.environmentId, activeThread.projectId),
608 );
609 const activeThreadCwd = activeThread?.worktreePath ?? activeThreadProject?.workspaceRoot ?? null;
610 const activeThreadGitStatus = useEnvironmentQuery(
611 activeThread != null &&
612 activeThread.linkedPullRequest == null &&
613 activeThread.branch !== null &&
614 activeThreadCwd !== null
615 ? vcsEnvironment.status({
616 environmentId: activeThread.environmentId,
617 input: { cwd: activeThreadCwd },
618 })
619 : null,
620 ).data;
621 const detectedPullRequestUrl =
622 activeThread == null || activeThread.linkedPullRequest != null
623 ? null
624 : (ThreadPr.resolveDisplayedThreadPr({
625 threadBranch: activeThread.branch,
626 gitStatus: activeThreadGitStatus ?? null,
627 snapshot: changeRequestSnapshotByKey.get(
628 scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)),
629 ),
630 retainTerminalOnBranchMismatch: activeThread.worktreePath === null,
631 })?.url ?? null);
632 const activeThreadReferenceCopyTarget =
633 activeThread == null
634 ? null
635 : resolveThreadReferenceCopyTarget({
636 threadId: activeThread.id,
637 linkedPullRequestUrl: activeThread.linkedPullRequest?.url ?? null,
638 detectedPullRequestUrl,
639 });
640 const copyActiveThreadReference = useCallback(async () => {
641 const target = activeThreadReferenceCopyTarget;
642 if (target === null) return;
643 try {
644 const didCopy = await writeTextToClipboard(target.value, target.clipboardTarget);
645 if (!didCopy) return;
646 toastManager.add({
647 type: "success",
648 title: target.successTitle,
649 description: target.value,
650 });
651 } catch (error) {
652 console.error(error);
653 toastManager.add(
654 stackedThreadToast({
655 type: "error",
656 title: target.failureTitle,
657 description: error instanceof Error ? error.message : "An error occurred.",
658 }),
659 );
660 }
661 }, [activeThreadReferenceCopyTarget]);
595662 const projectOrder = useUiStateStore((store) => store.projectOrder);
596663 const threads = useThreadShells();
597664 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
......@@ -1529,6 +1596,20 @@ function OpenCommandPaletteDialog(props: {
15291596 });
15301597 }
15311598
1599 if (activeThreadReferenceCopyTarget !== null) {
1600 actionItems.push({
1601 kind: "action",
1602 value: "action:copy-thread-reference",
1603 searchTerms: ["copy", "pull request", "pr link", "thread id", "reference"],
1604 title:
1605 activeThreadReferenceCopyTarget.kind === "pull-request" ? "Copy PR link" : "Copy thread ID",
1606 description: activeThreadReferenceCopyTarget.value,
1607 icon: <LinkIcon className={ITEM_ICON_CLASS} />,
1608 shortcutCommand: "thread.copyReference",
1609 run: copyActiveThreadReference,
1610 });
1611 }
1612
15321613 actionItems.push({
15331614 kind: "action",
15341615 value: "action:open-file-picker",
......@@ -2203,6 +2284,13 @@ function OpenCommandPaletteDialog(props: {
22032284 return;
22042285 }
22052286 }
2287 if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) {
2288 event.preventDefault();
2289 event.stopPropagation();
2290 setOpen(false);
2291 void copyActiveThreadReference();
2292 return;
2293 }
22062294
22072295 if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {
22082296 event.preventDefault();
apps/web/src/keybindings.test.ts+31
......@@ -142,6 +142,11 @@ const DEFAULT_BINDINGS = compile([
142142 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
143143 { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" },
144144 { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" },
145 {
146 shortcut: modShortcut("c", { shiftKey: true }),
147 command: "thread.copyReference",
148 whenAst: whenNot(whenIdentifier("terminalFocus")),
149 },
145150 {
146151 shortcut: modShortcut("s", { shiftKey: true }),
147152 command: "thread.settle",
......@@ -213,6 +218,32 @@ describe("settle thread shortcut", () => {
213218 });
214219});
215220
221describe("copy thread reference shortcut", () => {
222 it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => {
223 assert.equal(
224 resolveShortcutCommand(event({ key: "c", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
225 platform: "MacIntel",
226 }),
227 "thread.copyReference",
228 );
229 assert.equal(
230 resolveShortcutCommand(event({ key: "c", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
231 platform: "Linux",
232 }),
233 "thread.copyReference",
234 );
235 });
236
237 it("leaves terminal copy untouched", () => {
238 assert.isNull(
239 resolveShortcutCommand(event({ key: "c", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
240 platform: "Linux",
241 context: { terminalFocus: true },
242 }),
243 );
244 });
245});
246
216247describe("split/new/close terminal shortcuts", () => {
217248 it("requires terminalFocus for default split/new/close bindings", () => {
218249 assert.isFalse(
docs/user/keybindings.md+4
......@@ -52,6 +52,10 @@ successful pick; its hover glow and badge preview the element and color family t
5252`rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut,
5353so add one in **Settings** → **Keybindings** if you want to use it.
5454
55`thread.copyReference` copies the active thread's pull request link, or its thread ID when no pull
56request is available. Its default shortcut is `mod+shift+c`, and it does not replace terminal copy
57while the terminal has focus.
58
5559`thread.settle` settles the active thread or restores it when it is already settled. Its default
5660shortcut is `mod+shift+s`, and it does not run while the terminal has focus.
5761
packages/contracts/src/keybindings.test.ts+7
......@@ -114,6 +114,13 @@ it.effect("parses keybinding rules", () =>
114114 when: "!terminalFocus",
115115 });
116116 assert.strictEqual(parsedThreadSettle.command, "thread.settle");
117
118 const parsedThreadCopyReference = yield* decode(KeybindingRule, {
119 key: "mod+shift+c",
120 command: "thread.copyReference",
121 when: "!terminalFocus",
122 });
123 assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference");
117124 }),
118125);
119126
packages/contracts/src/keybindings.ts+1
......@@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand =
3737export const THREAD_KEYBINDING_COMMANDS = [
3838 "thread.previous",
3939 "thread.next",
40 "thread.copyReference",
4041 "thread.settle",
4142 "thread.pin",
4243 ...THREAD_JUMP_KEYBINDING_COMMANDS,
packages/shared/package.json+4
......@@ -167,6 +167,10 @@
167167 "types": "./src/keybindings.ts",
168168 "import": "./src/keybindings.ts"
169169 },
170 "./threadReference": {
171 "types": "./src/threadReference.ts",
172 "import": "./src/threadReference.ts"
173 },
170174 "./composerTrigger": {
171175 "types": "./src/composerTrigger.ts",
172176 "import": "./src/composerTrigger.ts"
packages/shared/src/keybindings.ts+1
......@@ -46,6 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
4646 { key: "mod+o", command: "editor.openFavorite" },
4747 { key: "mod+shift+[", command: "thread.previous" },
4848 { key: "mod+shift+]", command: "thread.next" },
49 { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" },
4950 { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },
5051 { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" },
5152 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
packages/shared/src/threadReference.test.ts created+41
......@@ -0,0 +1,41 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveThreadReferenceCopyTarget } from "./threadReference.ts";
4
5describe("resolveThreadReferenceCopyTarget", () => {
6 it("prefers a durable linked pull request", () => {
7 expect(
8 resolveThreadReferenceCopyTarget({
9 threadId: "thread-1",
10 linkedPullRequestUrl: "https://github.com/t3/pr/12",
11 detectedPullRequestUrl: "https://github.com/t3/pr/13",
12 }),
13 ).toMatchObject({
14 kind: "pull-request",
15 value: "https://github.com/t3/pr/12",
16 successTitle: "PR link copied",
17 });
18 });
19
20 it("uses a pull request detected from the active branch", () => {
21 expect(
22 resolveThreadReferenceCopyTarget({
23 threadId: "thread-1",
24 detectedPullRequestUrl: "https://github.com/t3/pr/13",
25 }),
26 ).toMatchObject({
27 kind: "pull-request",
28 value: "https://github.com/t3/pr/13",
29 });
30 });
31
32 it("falls back to the thread ID", () => {
33 expect(resolveThreadReferenceCopyTarget({ threadId: "thread-1" })).toEqual({
34 kind: "thread",
35 value: "thread-1",
36 clipboardTarget: "thread ID",
37 successTitle: "Thread ID copied",
38 failureTitle: "Failed to copy thread ID",
39 });
40 });
41});
packages/shared/src/threadReference.ts created+30
......@@ -0,0 +1,30 @@
1export interface ThreadReferenceCopyTarget {
2 readonly kind: "pull-request" | "thread";
3 readonly value: string;
4 readonly clipboardTarget: string;
5 readonly successTitle: string;
6 readonly failureTitle: string;
7}
8
9export function resolveThreadReferenceCopyTarget(input: {
10 readonly threadId: string;
11 readonly linkedPullRequestUrl?: string | null;
12 readonly detectedPullRequestUrl?: string | null;
13}): ThreadReferenceCopyTarget {
14 const pullRequestUrl = input.linkedPullRequestUrl ?? input.detectedPullRequestUrl;
15 return pullRequestUrl
16 ? {
17 kind: "pull-request",
18 value: pullRequestUrl,
19 clipboardTarget: "pull request link",
20 successTitle: "PR link copied",
21 failureTitle: "Failed to copy PR link",
22 }
23 : {
24 kind: "thread",
25 value: input.threadId,
26 clipboardTarget: "thread ID",
27 successTitle: "Thread ID copied",
28 failureTitle: "Failed to copy thread ID",
29 };
30}