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 @@...@@ -4,6 +4,9 @@
4 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"]4 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"]
5 },5 },
6 "android": {6 "android": {
7 "modules": ["expo.modules.t3nativecontrols.T3NativeControlsModule"]7 "modules": [
8 "expo.modules.t3nativecontrols.T3NativeControlsModule",
9 "expo.modules.t3nativecontrols.T3KeyboardCommandsModule"
10 ]
8 }11 }
9}12}
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+8
...@@ -29,6 +29,13 @@ public final class T3KeyboardCommandsView: ExpoView {...@@ -29,6 +29,13 @@ public final class T3KeyboardCommandsView: ExpoView {
29 enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"),29 enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"),
30 enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"),30 enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"),
31 enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"),31 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 ),
32 enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),39 enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),
33 ].compactMap { $0 }40 ].compactMap { $0 }
34 }41 }
...@@ -106,6 +113,7 @@ public final class T3KeyboardCommandsView: ExpoView {...@@ -106,6 +113,7 @@ public final class T3KeyboardCommandsView: ExpoView {
106 @objc private func openFiles() { emit("files") }113 @objc private func openFiles() { emit("files") }
107 @objc private func openTerminal() { emit("terminal") }114 @objc private func openTerminal() { emit("terminal") }
108 @objc private func openReview() { emit("review") }115 @objc private func openReview() { emit("review") }
116 @objc private func copyThreadReference() { emit("copyThreadReference") }
109 @objc private func handleToggleSidebar() { emit("toggleSidebar") }117 @objc private func handleToggleSidebar() { emit("toggleSidebar") }
110118
111 private func emit(_ command: String) {119 private func emit(_ command: String) {
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+124-6
...@@ -1,7 +1,22 @@...@@ -1,7 +1,22 @@
1import { StackActions, useNavigation } from "@react-navigation/native";1import { 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";
4import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";14import { 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";
5import {20import {
6 dispatchHardwareKeyboardCommand,21 dispatchHardwareKeyboardCommand,
7 getHardwareKeyboardCommandRegistrationVersion,22 getHardwareKeyboardCommandRegistrationVersion,
...@@ -11,11 +26,86 @@ import {...@@ -11,11 +26,86 @@ import {
11 type HardwareKeyboardCommand,26 type HardwareKeyboardCommand,
12} from "./hardwareKeyboardCommands";27} 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
14export function HardwareKeyboardCommandProvider({36export function HardwareKeyboardCommandProvider({
15 children,37 children,
16 pathname,38 pathname,
17}: PropsWithChildren<{ readonly pathname: string }>) {39}: PropsWithChildren<{ readonly pathname: string }>) {
18 const navigation = useNavigation();40 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 );
19 const registrationVersion = useSyncExternalStore(109 const registrationVersion = useSyncExternalStore(
20 subscribeToHardwareKeyboardCommandRegistrations,110 subscribeToHardwareKeyboardCommandRegistrations,
21 getHardwareKeyboardCommandRegistrationVersion,111 getHardwareKeyboardCommandRegistrationVersion,
...@@ -25,10 +115,11 @@ export function HardwareKeyboardCommandProvider({...@@ -25,10 +115,11 @@ export function HardwareKeyboardCommandProvider({
25 const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());115 const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());
26 commands.add("newTask");116 commands.add("newTask");
27 if (pathname !== "/" || navigation.canGoBack()) commands.add("back");117 if (pathname !== "/" || navigation.canGoBack()) commands.add("back");
28 if (parseActiveThreadPath(pathname)) {118 if (activeThreadRef !== null) {
29 commands.add("files");119 commands.add("files");
30 commands.add("terminal");120 commands.add("terminal");
31 commands.add("review");121 commands.add("review");
122 if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference");
32 }123 }
33 return [...commands];124 return [...commands];
34 }, [pathname, registrationVersion, navigation]);125 }, [pathname, registrationVersion, navigation]);
...@@ -37,6 +128,30 @@ export function HardwareKeyboardCommandProvider({...@@ -37,6 +128,30 @@ export function HardwareKeyboardCommandProvider({
37 (command: HardwareKeyboardCommand) => {128 (command: HardwareKeyboardCommand) => {
38 if (dispatchHardwareKeyboardCommand(command)) return;129 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
40 if (command === "newTask") {155 if (command === "newTask") {
41 navigation.navigate("NewTaskSheet", { screen: "NewTask" });156 navigation.navigate("NewTaskSheet", { screen: "NewTask" });
42 return;157 return;
...@@ -62,12 +177,15 @@ export function HardwareKeyboardCommandProvider({...@@ -62,12 +177,15 @@ export function HardwareKeyboardCommandProvider({
62 navigation.navigate("ThreadReview", thread);177 navigation.navigate("ThreadReview", thread);
63 }178 }
64 },179 },
65 [pathname, navigation],180 [copyTarget, navigation, pathname, showCopyFeedback],
66 );181 );
67182
68 return (183 return (
69 <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}>184 <>
70 {children}185 <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}>
71 </T3KeyboardCommands>186 {children}
187 </T3KeyboardCommands>
188 <GitActionProgressOverlay progress={copyFeedback} onDismiss={dismissCopyFeedback} />
189 </>
72 );190 );
73}191}
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts+1
...@@ -8,6 +8,7 @@ export type HardwareKeyboardCommand =...@@ -8,6 +8,7 @@ export type HardwareKeyboardCommand =
8 | "files"8 | "files"
9 | "terminal"9 | "terminal"
10 | "review"10 | "review"
11 | "copyThreadReference"
11 | "toggleSidebar";12 | "toggleSidebar";
1213
13type CommandHandler = () => boolean | void;14type CommandHandler = () => boolean | void;
apps/mobile/src/lib/copyTextWithHaptic.test.ts+14
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
22 CopyTextClipboardWriteError,22 CopyTextClipboardWriteError,
23 CopyTextHapticFeedbackError,23 CopyTextHapticFeedbackError,
24 copyTextWithHaptic,24 copyTextWithHaptic,
25 tryCopyTextWithHaptic,
25} from "./copyTextWithHaptic";26} from "./copyTextWithHaptic";
2627
27describe("copyTextWithHaptic", () => {28describe("copyTextWithHaptic", () => {
...@@ -54,6 +55,19 @@ describe("copyTextWithHaptic", () => {...@@ -54,6 +55,19 @@ describe("copyTextWithHaptic", () => {
54 expect(mocks.impactAsync).not.toHaveBeenCalled();55 expect(mocks.impactAsync).not.toHaveBeenCalled();
55 });56 });
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
57 it("reports structured failures without including clipboard contents", async () => {71 it("reports structured failures without including clipboard contents", async () => {
58 const clipboardCause = new Error("native clipboard failure");72 const clipboardCause = new Error("native clipboard failure");
59 const hapticCause = new Error("native haptic failure");73 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...@@ -27,19 +27,22 @@ export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass<CopyTex
27 }27 }
28}28}
2929
30export function copyTextWithHaptic(30interface CopyTextWithHapticOptions {
31 readonly target?: string;
32 readonly feedback?: "light-impact" | "selection";
33}
34
35export async function tryCopyTextWithHaptic(
31 value: string,36 value: string,
32 options: {37 options: CopyTextWithHapticOptions = {},
33 readonly target?: string;38): Promise<boolean> {
34 readonly feedback?: "light-impact" | "selection";
35 } = {},
36): void {
37 const target = options.target ?? "text";39 const target = options.target ?? "text";
38 const feedback = options.feedback ?? "light-impact";40 const feedback = options.feedback ?? "light-impact";
3941
40 void (async () => {42 const clipboardWrite = (async () => {
41 try {43 try {
42 await Clipboard.setStringAsync(value);44 await Clipboard.setStringAsync(value);
45 return true;
43 } catch (cause) {46 } catch (cause) {
44 console.error(47 console.error(
45 new CopyTextClipboardWriteError({48 new CopyTextClipboardWriteError({
...@@ -47,6 +50,7 @@ export function copyTextWithHaptic(...@@ -47,6 +50,7 @@ export function copyTextWithHaptic(
47 cause,50 cause,
48 }),51 }),
49 );52 );
53 return false;
50 }54 }
51 })();55 })();
5256
...@@ -67,4 +71,10 @@ export function copyTextWithHaptic(...@@ -67,4 +71,10 @@ export function copyTextWithHaptic(
67 );71 );
68 }72 }
69 })();73 })();
74
75 return await clipboardWrite;
76}
77
78export function copyTextWithHaptic(value: string, options: CopyTextWithHapticOptions = {}): void {
79 void tryCopyTextWithHaptic(value, options);
70}80}
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(...@@ -54,6 +54,7 @@ function threadDetailToShell(
54 interactionMode: thread.interactionMode,54 interactionMode: thread.interactionMode,
55 branch: thread.branch,55 branch: thread.branch,
56 worktreePath: thread.worktreePath,56 worktreePath: thread.worktreePath,
57 linkedPullRequest: thread.linkedPullRequest ?? null,
57 latestTurn: thread.latestTurn,58 latestTurn: thread.latestTurn,
58 createdAt: thread.createdAt,59 createdAt: thread.createdAt,
59 updatedAt: thread.updatedAt,60 updatedAt: thread.updatedAt,
apps/server/src/keybindings.test.ts+1
...@@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
195195
196 assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");196 assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");
197 assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");197 assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
198 assert.equal(defaultsByCommand.get("thread.copyReference"), "mod+shift+c");
198 assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s");199 assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s");
199 assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p");200 assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p");
200 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");201 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
apps/web/src/components/ChatView.tsx+44
...@@ -50,6 +50,7 @@ import {...@@ -50,6 +50,7 @@ import {
50} from "@t3tools/shared/model";50} from "@t3tools/shared/model";
51import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";51import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
52import { truncate } from "@t3tools/shared/String";52import { truncate } from "@t3tools/shared/String";
53import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
53import {54import {
54 getTerminalLabel,55 getTerminalLabel,
55 nextTerminalId,56 nextTerminalId,
...@@ -4593,6 +4594,41 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4593,6 +4594,41 @@ function ChatViewContent(props: ChatViewProps) {
4593 linkedPullRequest: linkedThreadPullRequest,4594 linkedPullRequest: linkedThreadPullRequest,
4594 linkedPullRequestStatus,4595 linkedPullRequestStatus,
4595 });4596 });
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]);
4596 // The right panel offers the thread's own change request, so it can only offer it once the4632 // The right panel offers the thread's own change request, so it can only offer it once the
4597 // branch has one; until then the picker says so rather than opening an empty panel.4633 // branch has one; until then the picker says so rather than opening an empty panel.
4598 const addPullRequestSurface = useCallback(() => {4634 const addPullRequestSurface = useCallback(() => {
...@@ -5257,6 +5293,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5257,6 +5293,13 @@ function ChatViewContent(props: ChatViewProps) {
5257 });5293 });
5258 if (!command) return;5294 if (!command) return;
52595295
5296 if (command === "thread.copyReference") {
5297 event.preventDefault();
5298 event.stopPropagation();
5299 if (!event.repeat) copyActiveThreadReference();
5300 return;
5301 }
5302
5260 if (command === "thread.settle") {5303 if (command === "thread.settle") {
5261 event.preventDefault();5304 event.preventDefault();
5262 event.stopPropagation();5305 event.stopPropagation();
...@@ -5426,6 +5469,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5426,6 +5469,7 @@ function ChatViewContent(props: ChatViewProps) {
5426 supportsPinning,5469 supportsPinning,
5427 supportsSettlement,5470 supportsSettlement,
5428 confirmAndUnpinThread,5471 confirmAndUnpinThread,
5472 copyActiveThreadReference,
5429 toggleRightPanel,5473 toggleRightPanel,
5430 toggleRightPanelMaximized,5474 toggleRightPanelMaximized,
5431 toggleTerminalVisibility,5475 toggleTerminalVisibility,
apps/web/src/components/CommandPalette.tsx+90-2
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1"use client";1"use client";
22
3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";3import {
4 scopedThreadKey,
5 scopeProjectRef,
6 scopeThreadRef,
7} from "@t3tools/client-runtime/environment";
4import {8import {
5 canCreateProjectInEnvironment,9 canCreateProjectInEnvironment,
6 getCloneDestinationBrowsePath,10 getCloneDestinationBrowsePath,
...@@ -11,6 +15,7 @@ import {...@@ -11,6 +15,7 @@ import {
11} from "@t3tools/client-runtime/operations/projects";15} from "@t3tools/client-runtime/operations/projects";
12import { connectionStatusText } from "@t3tools/client-runtime/connection";16import { connectionStatusText } from "@t3tools/client-runtime/connection";
13import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";17import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
18import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
14import {19import {
15 canPreloadBrowsePath,20 canPreloadBrowsePath,
16 createBrowseNavigationCoordinator,21 createBrowseNavigationCoordinator,
...@@ -65,6 +70,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -65,6 +70,7 @@ import { useAtomValue } from "@effect/atom-react";
65import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";70import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
66import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";71import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
67import { useHandleNewThread } from "../hooks/useHandleNewThread";72import { useHandleNewThread } from "../hooks/useHandleNewThread";
73import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
68import { useClientSettings } from "../hooks/useSettings";74import { useClientSettings } from "../hooks/useSettings";
69import { useTheme } from "../hooks/useTheme";75import { useTheme } from "../hooks/useTheme";
70import { readLocalApi } from "../localApi";76import { readLocalApi } from "../localApi";
...@@ -73,11 +79,13 @@ import { filesystemEnvironment } from "../state/filesystem";...@@ -73,11 +79,13 @@ import { filesystemEnvironment } from "../state/filesystem";
73import { projectEnvironment } from "../state/projects";79import { projectEnvironment } from "../state/projects";
74import { useEnvironmentQuery } from "../state/query";80import { useEnvironmentQuery } from "../state/query";
75import { sourceControlEnvironment } from "../state/sourceControl";81import { sourceControlEnvironment } from "../state/sourceControl";
82import { vcsEnvironment } from "../state/vcs";
76import { useAtomCommand } from "../state/use-atom-command";83import { useAtomCommand } from "../state/use-atom-command";
77import { useAtomQueryRunner } from "../state/use-atom-query-runner";84import { useAtomQueryRunner } from "../state/use-atom-query-runner";
78import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";85import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
79import { useProjects, useThreadShells } from "../state/entities";86import { useProject, useProjects, useThreadShells } from "../state/entities";
80import { useThreadSearch } from "../state/queries";87import { useThreadSearch } from "../state/queries";
88import * as ThreadPr from "./ThreadStatusIndicators";
81import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";89import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
82import {90import {
83 appendBrowsePathSegment,91 appendBrowsePathSegment,
...@@ -592,6 +600,65 @@ function OpenCommandPaletteDialog(props: {...@@ -592,6 +600,65 @@ function OpenCommandPaletteDialog(props: {
592 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =600 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
593 useHandleNewThread();601 useHandleNewThread();
594 const projects = useProjects();602 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]);
595 const projectOrder = useUiStateStore((store) => store.projectOrder);662 const projectOrder = useUiStateStore((store) => store.projectOrder);
596 const threads = useThreadShells();663 const threads = useThreadShells();
597 const keybindings = useAtomValue(primaryServerKeybindingsAtom);664 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
...@@ -1529,6 +1596,20 @@ function OpenCommandPaletteDialog(props: {...@@ -1529,6 +1596,20 @@ function OpenCommandPaletteDialog(props: {
1529 });1596 });
1530 }1597 }
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
1532 actionItems.push({1613 actionItems.push({
1533 kind: "action",1614 kind: "action",
1534 value: "action:open-file-picker",1615 value: "action:open-file-picker",
...@@ -2203,6 +2284,13 @@ function OpenCommandPaletteDialog(props: {...@@ -2203,6 +2284,13 @@ function OpenCommandPaletteDialog(props: {
2203 return;2284 return;
2204 }2285 }
2205 }2286 }
2287 if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) {
2288 event.preventDefault();
2289 event.stopPropagation();
2290 setOpen(false);
2291 void copyActiveThreadReference();
2292 return;
2293 }
22062294
2207 if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {2295 if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {
2208 event.preventDefault();2296 event.preventDefault();
apps/web/src/keybindings.test.ts+31
...@@ -142,6 +142,11 @@ const DEFAULT_BINDINGS = compile([...@@ -142,6 +142,11 @@ const DEFAULT_BINDINGS = compile([
142 { shortcut: modShortcut("o"), command: "editor.openFavorite" },142 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
143 { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" },143 { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" },
144 { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" },144 { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" },
145 {
146 shortcut: modShortcut("c", { shiftKey: true }),
147 command: "thread.copyReference",
148 whenAst: whenNot(whenIdentifier("terminalFocus")),
149 },
145 {150 {
146 shortcut: modShortcut("s", { shiftKey: true }),151 shortcut: modShortcut("s", { shiftKey: true }),
147 command: "thread.settle",152 command: "thread.settle",
...@@ -213,6 +218,32 @@ describe("settle thread shortcut", () => {...@@ -213,6 +218,32 @@ describe("settle thread shortcut", () => {
213 });218 });
214});219});
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
216describe("split/new/close terminal shortcuts", () => {247describe("split/new/close terminal shortcuts", () => {
217 it("requires terminalFocus for default split/new/close bindings", () => {248 it("requires terminalFocus for default split/new/close bindings", () => {
218 assert.isFalse(249 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...@@ -52,6 +52,10 @@ successful pick; its hover glow and badge preview the element and color family t
52`rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut,52`rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut,
53so add one in **Settings** → **Keybindings** if you want to use it.53so 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
55`thread.settle` settles the active thread or restores it when it is already settled. Its default59`thread.settle` settles the active thread or restores it when it is already settled. Its default
56shortcut is `mod+shift+s`, and it does not run while the terminal has focus.60shortcut 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", () =>...@@ -114,6 +114,13 @@ it.effect("parses keybinding rules", () =>
114 when: "!terminalFocus",114 when: "!terminalFocus",
115 });115 });
116 assert.strictEqual(parsedThreadSettle.command, "thread.settle");116 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");
117 }),124 }),
118);125);
119126
packages/contracts/src/keybindings.ts+1
...@@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand =...@@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand =
37export const THREAD_KEYBINDING_COMMANDS = [37export const THREAD_KEYBINDING_COMMANDS = [
38 "thread.previous",38 "thread.previous",
39 "thread.next",39 "thread.next",
40 "thread.copyReference",
40 "thread.settle",41 "thread.settle",
41 "thread.pin",42 "thread.pin",
42 ...THREAD_JUMP_KEYBINDING_COMMANDS,43 ...THREAD_JUMP_KEYBINDING_COMMANDS,
packages/shared/package.json+4
...@@ -167,6 +167,10 @@...@@ -167,6 +167,10 @@
167 "types": "./src/keybindings.ts",167 "types": "./src/keybindings.ts",
168 "import": "./src/keybindings.ts"168 "import": "./src/keybindings.ts"
169 },169 },
170 "./threadReference": {
171 "types": "./src/threadReference.ts",
172 "import": "./src/threadReference.ts"
173 },
170 "./composerTrigger": {174 "./composerTrigger": {
171 "types": "./src/composerTrigger.ts",175 "types": "./src/composerTrigger.ts",
172 "import": "./src/composerTrigger.ts"176 "import": "./src/composerTrigger.ts"
packages/shared/src/keybindings.ts+1
...@@ -46,6 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -46,6 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
46 { key: "mod+o", command: "editor.openFavorite" },46 { key: "mod+o", command: "editor.openFavorite" },
47 { key: "mod+shift+[", command: "thread.previous" },47 { key: "mod+shift+[", command: "thread.previous" },
48 { key: "mod+shift+]", command: "thread.next" },48 { key: "mod+shift+]", command: "thread.next" },
49 { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" },
49 { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },50 { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },
50 { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" },51 { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" },
51 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({52 ...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}