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