diff --git a/packages/app/src/attachments/composer-workspace-attachments.tsx b/packages/app/src/attachments/composer-workspace-attachments.tsx index bc4a7fa7c..2bfb854ab 100644 --- a/packages/app/src/attachments/composer-workspace-attachments.tsx +++ b/packages/app/src/attachments/composer-workspace-attachments.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { CircleDot } from "lucide-react-native"; +import { MessageSquareCode } from "lucide-react-native"; import type { ComposerAttachment, UserComposerAttachment, @@ -237,7 +237,7 @@ function WorkspaceAttachmentPill({ > - + {label} @@ -279,5 +279,5 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, })) as unknown as Record; -const ThemedCircleDot = withUnistyles(CircleDot); +const ThemedMessageSquareCode = withUnistyles(MessageSquareCode); const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); diff --git a/packages/app/src/components/composer.test.tsx b/packages/app/src/components/composer.test.tsx index 0aef81cf6..c86341ae9 100644 --- a/packages/app/src/components/composer.test.tsx +++ b/packages/app/src/components/composer.test.tsx @@ -43,6 +43,7 @@ const { fontSize: { xs: 11, sm: 13, base: 15, lg: 18 }, fontWeight: { normal: "400", medium: "500" }, lineHeight: { diff: 18 }, + opacity: { 50: 0.5 }, shadow: { md: {} }, colors: { surface0: "#000", @@ -226,6 +227,7 @@ vi.mock("lucide-react-native", () => { AudioLines: createIcon("AudioLines"), CircleDot: createIcon("CircleDot"), GitPullRequest: createIcon("GitPullRequest"), + MessageSquareCode: createIcon("MessageSquareCode"), X: createIcon("X"), Mic: createIcon("Mic"), MicOff: createIcon("MicOff"), diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index a97c56e78..47fbed7d9 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -853,9 +853,7 @@ function ComposerCancelButton({ ) : ( ); - const shortcutNode = agentInterruptKeys ? ( - - ) : null; + const shortcutNode = agentInterruptKeys ? : null; return ( - ) : null; + const shortcutNode = voiceToggleKeys ? : null; const renderTriggerContent = useCallback( ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => { if (isVoiceSwitching) { @@ -1874,10 +1870,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, - tooltipShortcut: { - backgroundColor: theme.colors.surface3, - borderColor: theme.colors.borderAccent, - }, buttonDisabled: { opacity: 0.5, }, diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 7d4f18f6c..9e63f70a2 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -138,7 +138,6 @@ const styles = StyleSheet.create((theme) => ({ tooltipTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, }, tooltipText: { color: theme.colors.foreground, diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 5cb91eaa7..2b9522b5b 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -231,35 +231,59 @@ const EMPTY_COMMENTS: readonly ReviewDraftComment[] = []; function noopStartComment(): void {} -const LONG_PRESS_COMMENT_DELAY_MS = 350; +const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null; function LongPressableLine({ reviewTarget, reviewActions, + onHoverChange, + hoverTargetKey, + onHoverTargetChange, style, children, }: { reviewTarget: ReviewableDiffTarget | null | undefined; reviewActions: InlineReviewActions | undefined; + onHoverChange?: (hovered: boolean) => void; + hoverTargetKey?: string | null; + onHoverTargetChange?: (key: string | null) => void; style: StyleProp; children: ReactNode; }) { const onStartComment = reviewActions?.onStartComment; - const handleLongPress = useCallback(() => { + const handlePress = useCallback(() => { if (reviewTarget && onStartComment) { onStartComment(reviewTarget); } }, [reviewTarget, onStartComment]); + const handleHoverIn = useCallback(() => { + onHoverChange?.(true); + if (hoverTargetKey) { + onHoverTargetChange?.(hoverTargetKey); + } + }, [hoverTargetKey, onHoverChange, onHoverTargetChange]); + const handleHoverOut = useCallback(() => { + onHoverChange?.(false); + if (hoverTargetKey) { + onHoverTargetChange?.(null); + } + }, [hoverTargetKey, onHoverChange, onHoverTargetChange]); + const hoverStyle = useMemo(() => [style, DIFF_LINE_HOVER_STYLE], [style]); + + if (isWeb && (onHoverChange || onHoverTargetChange)) { + return ( + + {children} + + ); + } + if (!isNative || !reviewTarget || !onStartComment) { return {children}; } return ( - + {children} ); @@ -279,6 +303,7 @@ function DiffGutterCell({ gutterWidth, reviewTarget, reviewActions, + isLineHovered, style, }: { lineNumber: number | null; @@ -286,6 +311,7 @@ function DiffGutterCell({ gutterWidth: number; reviewTarget?: ReviewableDiffTarget | null; reviewActions?: InlineReviewActions; + isLineHovered?: boolean; style?: StyleProp; }) { const containerStyle = useMemo( @@ -315,6 +341,7 @@ function DiffGutterCell({ reviewTarget={reviewTarget} comments={comments} isEditorOpen={isEditorOpen} + isLineHovered={isLineHovered} onStartComment={onStartComment} style={containerStyle} > @@ -330,11 +357,17 @@ function DiffTextLine({ wrapLines, reviewTarget, reviewActions, + onHoverChange, + hoverTargetKey, + onHoverTargetChange, }: { line: DiffLine; wrapLines: boolean; reviewTarget?: ReviewableDiffTarget | null; reviewActions?: InlineReviewActions; + onHoverChange?: (hovered: boolean) => void; + hoverTargetKey?: string | null; + onHoverTargetChange?: (key: string | null) => void; }) { const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; @@ -358,6 +391,9 @@ function DiffTextLine({ {line.type !== "header" && visibleTokens ? ( @@ -373,10 +409,16 @@ function SplitTextLine({ line, wrapLines, reviewActions, + onHoverChange, + hoverTargetKey, + onHoverTargetChange, }: { line: SplitDiffDisplayLine | null; wrapLines: boolean; reviewActions?: InlineReviewActions; + onHoverChange?: (hovered: boolean) => void; + hoverTargetKey?: string | null; + onHoverTargetChange?: (key: string | null) => void; }) { const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; @@ -400,6 +442,9 @@ function SplitTextLine({ {visibleTokens ? ( @@ -426,6 +471,7 @@ function DiffLineView({ reviewTarget?: ReviewableDiffTarget | null; reviewActions?: InlineReviewActions; }) { + const [isLineHovered, setIsLineHovered] = useState(false); const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const containerStyle = useMemo( @@ -448,6 +494,7 @@ function DiffLineView({ {line.type !== "header" && visibleTokens ? ( @@ -478,6 +526,7 @@ function SplitDiffLine({ wrapLines: boolean; reviewActions?: InlineReviewActions; }) { + const [isLineHovered, setIsLineHovered] = useState(false); const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const containerStyle = useMemo( @@ -500,6 +549,7 @@ function SplitDiffLine({ {visibleTokens ? ( @@ -635,6 +686,7 @@ function SplitDiffColumn({ showDivider?: boolean; }) { const [scrollWidth, setScrollWidth] = useState(0); + const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState(null); const wrapCellStyle = useMemo( () => [styles.splitCell, showDivider && styles.splitCellWithDivider], @@ -701,6 +753,7 @@ function SplitDiffColumn({ ); } const line = side === "left" ? row.left : row.right; + const reviewTargetKey = line?.reviewTarget?.key ?? null; const reviewRowState = getSplitInlineReviewThreadState({ left: row.left?.reviewTarget, right: row.right?.reviewTarget, @@ -714,6 +767,9 @@ function SplitDiffColumn({ gutterWidth={gutterWidth} reviewTarget={line?.reviewTarget} reviewActions={reviewActions} + isLineHovered={ + reviewTargetKey !== null && hoveredReviewTargetKey === reviewTargetKey + } /> - + (null); const { theme } = useUnistyles(); const handleLayout = useCallback( @@ -975,6 +1039,9 @@ function DiffFileBody({ gutterWidth={gutterWidth} reviewTarget={reviewTarget} reviewActions={reviewActions} + isLineHovered={ + reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key + } /> ({ }, gutterColumn: { backgroundColor: theme.colors.surface1, + zIndex: 4, + elevation: 4, + overflow: "visible", }, gutterCell: { borderRightWidth: theme.borderWidth[1], borderRightColor: theme.colors.border, justifyContent: "flex-start", + zIndex: 4, + elevation: 4, + overflow: "visible", }, inlineReviewRow: { flexDirection: "row", @@ -3050,6 +3125,7 @@ const styles = StyleSheet.create((theme) => ({ diffLineContainer: { flexDirection: "row", alignItems: "stretch", + overflow: "visible", }, lineNumberGutter: { borderRightWidth: theme.borderWidth[1], @@ -3057,8 +3133,12 @@ const styles = StyleSheet.create((theme) => ({ marginRight: theme.spacing[2], alignSelf: "stretch", justifyContent: "flex-start", + zIndex: 4, + elevation: 4, + overflow: "visible", }, lineNumberText: { + width: "100%", textAlign: "right", paddingRight: theme.spacing[2], fontSize: theme.fontSize.xs, diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx index 790b6e43e..39bea613a 100644 --- a/packages/app/src/components/headers/header-toggle-button.tsx +++ b/packages/app/src/components/headers/header-toggle-button.tsx @@ -88,8 +88,5 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, - shortcut: { - backgroundColor: theme.colors.surface3, - borderColor: theme.colors.borderAccent, - }, + shortcut: {}, })); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 618cf7424..154bae910 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -278,7 +278,7 @@ function VoiceTooltipBody({ return ( {voiceTooltipText} - {shortcut ? : null} + {shortcut ? : null} ); } @@ -293,7 +293,7 @@ function SendTooltipBody({ return ( {label} - {sendKeys ? : null} + {sendKeys ? : null} ); } @@ -1925,10 +1925,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, - tooltipShortcut: { - backgroundColor: theme.colors.surface3, - borderColor: theme.colors.borderAccent, - }, buttonDisabled: { opacity: 0.5, }, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index c6b24168e..a013a4673 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -2724,10 +2724,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, - projectActionTooltipShortcut: { - backgroundColor: theme.colors.surface3, - borderColor: theme.colors.borderAccent, - }, + projectActionTooltipShortcut: {}, workspaceRow: { minHeight: 36, marginBottom: theme.spacing[1], diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 1b721cfa4..02247d601 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -126,7 +126,7 @@ interface SplitPaneDropData { interface SplitNodeViewProps extends Omit { node: SplitNode; uiTabs: WorkspaceTab[]; - focusedPaneId: string; + focusedPaneId: string | null; activeDragTabId: string | null; showDropZones: boolean; dropPreview: SplitDropZoneHover | null; @@ -370,7 +370,7 @@ export function SplitContainer({ if (!focusModeEnabled) { return layout.root; } - const focusedPane = panesById.get(layout.focusedPaneId); + const focusedPane = layout.focusedPaneId ? panesById.get(layout.focusedPaneId) : null; if (!focusedPane) { return layout.root; } diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 91c870b19..b705ffbce 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -1,10 +1,12 @@ import { + default as React, useCallback, useMemo, useState, type ComponentType, type PropsWithChildren, type ReactElement, + type ReactNode, } from "react"; import { ActivityIndicator, Pressable, Text, View } from "react-native"; import type { @@ -17,7 +19,7 @@ import type { import { StyleSheet, useUnistyles } from "react-native-unistyles"; type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive"; -type ButtonSize = "sm" | "md" | "lg"; +type ButtonSize = "xs" | "sm" | "md" | "lg"; type LeftIcon = | ReactElement @@ -25,7 +27,7 @@ type LeftIcon = | ((color: string) => ReactElement) | null; -const ICON_SIZE: Record = { sm: 14, md: 16, lg: 20 }; +const ICON_SIZE: Record = { xs: 12, sm: 14, md: 16, lg: 20 }; const styles = StyleSheet.create((theme) => ({ base: { @@ -41,6 +43,12 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[4], }, + xs: { + minHeight: 28, + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + }, sm: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], @@ -82,6 +90,9 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, + textXs: { + fontSize: theme.fontSize.xs, + }, textDefault: { color: theme.colors.palette.white, }, @@ -101,6 +112,7 @@ export function Button({ variant = "secondary", size = "md", leftIcon, + trailing, style, textStyle, disabled, @@ -112,6 +124,7 @@ export function Button({ variant?: ButtonVariant; size?: ButtonSize; leftIcon?: LeftIcon; + trailing?: ReactNode; style?: StyleProp; textStyle?: StyleProp; loading?: boolean; @@ -135,7 +148,9 @@ export function Button({ } let sizeStyle: ViewStyle; - if (size === "sm") { + if (size === "xs") { + sizeStyle = styles.xs; + } else if (size === "sm") { sizeStyle = styles.sm; } else if (size === "lg") { sizeStyle = styles.lg; @@ -162,13 +177,14 @@ export function Button({ const resolvedTextStyle = useMemo( () => [ styles.text, + size === "xs" ? styles.textXs : null, variant === "default" ? styles.textDefault : null, variant === "destructive" ? styles.textDestructive : null, variant === "ghost" ? styles.textGhost : null, textStyle, isGhostHovered ? styles.textGhostHovered : null, ], - [variant, textStyle, isGhostHovered], + [size, variant, textStyle, isGhostHovered], ); const accessibilityState = useMemo( @@ -235,6 +251,7 @@ export function Button({ > {renderIcon()} {children != null ? {children} : null} + {trailing} ); } diff --git a/packages/app/src/components/ui/shortcut.tsx b/packages/app/src/components/ui/shortcut.tsx index 0e49935f8..0f5be29fe 100644 --- a/packages/app/src/components/ui/shortcut.tsx +++ b/packages/app/src/components/ui/shortcut.tsx @@ -1,9 +1,26 @@ -import { useMemo, type ReactElement } from "react"; +import React, { useMemo, type ReactElement } from "react"; import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; +function colorWithOpacity(color: string, opacity: number): string { + const hex = color.trim(); + if (/^#[0-9a-fA-F]{3}$/.test(hex)) { + const r = Number.parseInt(hex[1] + hex[1], 16); + const g = Number.parseInt(hex[2] + hex[2], 16); + const b = Number.parseInt(hex[3] + hex[3], 16); + return `rgba(${r}, ${g}, ${b}, ${opacity})`; + } + if (/^#[0-9a-fA-F]{6}$/.test(hex)) { + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${opacity})`; + } + return `rgba(255, 255, 255, ${opacity})`; +} + export function Shortcut({ keys, chord, @@ -53,9 +70,8 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[1], paddingVertical: 2, borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface2, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.borderAccent, + backgroundColor: colorWithOpacity(theme.colors.foreground, 0.05), + borderWidth: 0, }, sequence: { flexDirection: "row", @@ -66,6 +82,6 @@ const styles = StyleSheet.create((theme) => ({ text: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, + color: colorWithOpacity(theme.colors.foreground, 0.6), }, })); diff --git a/packages/app/src/review/surface.test.tsx b/packages/app/src/review/surface.test.tsx index 27623a2d8..7c3f05c29 100644 --- a/packages/app/src/review/surface.test.tsx +++ b/packages/app/src/review/surface.test.tsx @@ -24,9 +24,10 @@ const { theme, pressablePropsByLabel } = vi.hoisted(() => { theme: { spacing: { 1: 4, 2: 8, 3: 12 }, borderWidth: { 1: 1 }, - borderRadius: { base: 4 }, + borderRadius: { base: 4, md: 6, lg: 8, xl: 12, full: 999 }, + opacity: { 50: 0.5 }, fontSize: { xs: 11, sm: 13 }, - fontWeight: { medium: "500" }, + fontWeight: { normal: "400", medium: "500" }, lineHeight: { diff: 18 }, colors: { accent: "#0a84ff", @@ -38,6 +39,7 @@ const { theme, pressablePropsByLabel } = vi.hoisted(() => { surface1: "#111", surface2: "#222", surface3: "#333", + palette: { white: "#fff" }, }, }, pressablePropsByLabel: new Map>(), @@ -91,6 +93,8 @@ vi.mock("react-native-unistyles", () => ({ })); vi.mock("@/constants/platform", () => ({ + getIsElectron: () => false, + getIsElectronMac: () => false, isNative: false, isWeb: true, })); @@ -101,8 +105,8 @@ vi.mock("lucide-react-native", () => { return { Check: createIcon("Check"), CircleDot: createIcon("CircleDot"), - MessageCircle: createIcon("MessageCircle"), Pencil: createIcon("Pencil"), + Plus: createIcon("Plus"), Trash2: createIcon("Trash2"), X: createIcon("X"), }; @@ -293,7 +297,7 @@ describe("git diff inline review helpers", () => { expect(pressablePropsByLabel.get("Add review comment")?.hitSlop).toBe(SMALL_ACTION_HIT_SLOP); }); - it("shows the line number by default and replaces it with the icon when comments exist or the editor is open", () => { + it("keeps the line number visible and only floats the plus for line hover", () => { const reviewTarget = target(); const { container, queryByText, rerender } = render( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='MessageCircle']")).toBeNull(); + expect(container.querySelector("[data-icon='Plus']")).toBeNull(); rerender( { , ); - expect(queryByText("2")).toBeNull(); - expect(container.querySelector("[data-icon='MessageCircle']")).toBeTruthy(); + expect(queryByText("2")).toBeTruthy(); + expect(container.querySelector("[data-icon='Plus']")).toBeNull(); rerender( { , ); - expect(queryByText("2")).toBeNull(); - expect(container.querySelector("[data-icon='MessageCircle']")).toBeTruthy(); + expect(queryByText("2")).toBeTruthy(); + expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + + rerender( + + 2 + , + ); + + expect(queryByText("2")).toBeTruthy(); + expect(container.querySelector("[data-icon='Plus']")).toBeTruthy(); }); }); describe("InlineReviewEditor", () => { + const originalMatchMedia = window.matchMedia; + afterEach(() => { + window.matchMedia = originalMatchMedia; cleanup(); vi.clearAllMocks(); }); @@ -364,6 +386,50 @@ describe("InlineReviewEditor", () => { fireEvent.click(getByTestId("editor-cancel")); expect(onCancel).toHaveBeenCalledTimes(1); }); + + it("handles Escape cancel and Mod+Enter save from the focused textarea", () => { + const onCancel = vi.fn(); + const onSave = vi.fn(); + const { getByTestId } = render( + , + ); + const input = getByTestId("editor-input"); + + fireEvent.focus(input); + fireEvent.keyDown(input, { key: "Escape" }); + expect(onCancel).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(input, { key: "Enter", metaKey: true }); + expect(onSave).toHaveBeenCalledWith("ready"); + }); + + it("shows shared shortcut hints while focused on a fine-pointer screen", () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + const { getByTestId, getByText, queryByText } = render( + , + ); + const input = getByTestId("editor-input"); + + expect(getByText("Esc")).toBeTruthy(); + expect(getByText(/(?:⌘⏎|Ctrl\+⏎)/)).toBeTruthy(); + + fireEvent.blur(input); + expect(queryByText("Esc")).toBeNull(); + }); }); describe("InlineReviewThread", () => { diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index 674407de7..d07133c0a 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; -import { MessageCircle, Pencil, Trash2 } from "lucide-react-native"; +import { Pencil, Plus, Trash2 } from "lucide-react-native"; import { Pressable, type PressableStateCallbackType, @@ -12,11 +12,19 @@ import { type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { Shortcut } from "@/components/ui/shortcut"; import { isWeb } from "@/constants/platform"; +import type { ShortcutKey } from "@/utils/format-shortcut"; +import { useWorkspaceFocusRestoration } from "@/workspace/focus"; import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store"; import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; type PressableState = PressableStateCallbackType & { hovered?: boolean }; +type WebTextInputRef = TextInput & { + getNativeElement?: () => unknown; + getNativeRef?: () => unknown; +}; function iconButtonStyle({ hovered, pressed }: PressableState): StyleProp { return [styles.iconButton, (hovered || pressed) && styles.iconButtonHovered]; @@ -26,14 +34,48 @@ function iconButtonDestructiveStyle({ hovered, pressed }: PressableState): Style return [styles.iconButton, (hovered || pressed) && styles.iconButtonDestructiveHovered]; } -function ghostButtonStyle({ hovered, pressed }: PressableState): StyleProp { - return [styles.ghostButton, (hovered || pressed) && styles.ghostButtonHovered]; +function getWebTextInputElement(input: TextInput | null): HTMLElement | null { + if (!isWeb || typeof HTMLElement === "undefined" || !input) { + return null; + } + const webInput = input as WebTextInputRef; + const element = webInput.getNativeElement?.() ?? webInput.getNativeRef?.() ?? input; + return element instanceof HTMLElement ? element : null; +} + +function getCanShowReviewKeyboardHints(): boolean { + if (!isWeb || typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + return window.matchMedia("(hover: hover) and (pointer: fine)").matches; +} + +function useCanShowReviewKeyboardHints(): boolean { + const [canShowHints, setCanShowHints] = useState(getCanShowReviewKeyboardHints); + + useEffect(() => { + if (!isWeb || typeof window === "undefined" || typeof window.matchMedia !== "function") { + return; + } + + const mediaQuery = window.matchMedia("(hover: hover) and (pointer: fine)"); + const handleChange = () => setCanShowHints(mediaQuery.matches); + handleChange(); + mediaQuery.addEventListener?.("change", handleChange); + return () => { + mediaQuery.removeEventListener?.("change", handleChange); + }; + }, []); + + return canShowHints; } export const INLINE_REVIEW_COMMENT_HEIGHT = 72; export const INLINE_REVIEW_EDITOR_HEIGHT = 132; const INLINE_REVIEW_GAP = 6; export const SMALL_ACTION_HIT_SLOP = 8; +const REVIEW_CANCEL_SHORTCUT_KEYS: ShortcutKey[] = ["Esc"]; +const REVIEW_SAVE_SHORTCUT_KEYS: ShortcutKey[] = ["mod", "Enter"]; export interface InlineReviewEditorState { target: ReviewableDiffTarget; @@ -228,7 +270,7 @@ export function InlineReviewGutterCell({ children, reviewTarget, comments, - isEditorOpen, + isLineHovered = false, onStartComment, style, }: { @@ -236,26 +278,49 @@ export function InlineReviewGutterCell({ reviewTarget: ReviewableDiffTarget | null | undefined; comments: readonly ReviewDraftComment[]; isEditorOpen: boolean; + isLineHovered?: boolean; onStartComment: (target: ReviewableDiffTarget) => void; style?: StyleProp; }) { const { theme } = useUnistyles(); const canComment = Boolean(reviewTarget); const hasComments = comments.length > 0; + const [isGutterHovered, setIsGutterHovered] = useState(false); + const [isPressed, setIsPressed] = useState(false); + const [isDismissedAfterPress, setIsDismissedAfterPress] = useState(false); + const isInteractionActive = isGutterHovered || isLineHovered || isPressed; + const showAction = canComment && isInteractionActive && !isDismissedAfterPress; const handlePress = useCallback(() => { if (reviewTarget) { + setIsDismissedAfterPress(true); onStartComment(reviewTarget); } }, [reviewTarget, onStartComment]); - const pressableStyle = useCallback( - ({ hovered, pressed }: PressableState): StyleProp => [ - style, - canComment && (hovered || pressed) && styles.gutterHovered, - ], - [style, canComment], - ); + const handleHoverIn = useCallback(() => { + setIsGutterHovered(true); + }, []); + + const handleHoverOut = useCallback(() => { + setIsGutterHovered(false); + }, []); + + const handlePressIn = useCallback(() => { + setIsPressed(true); + }, []); + + const handlePressOut = useCallback(() => { + setIsPressed(false); + }, []); + + useEffect(() => { + if (!isInteractionActive) { + setIsDismissedAfterPress(false); + } + }, [isInteractionActive]); + + const pressableStyle = useCallback((): StyleProp => style, [style]); const labelStyle = useMemo>( () => [styles.gutterLabel, hasComments && styles.gutterLabelActive], @@ -269,26 +334,22 @@ export function InlineReviewGutterCell({ hitSlop={canComment ? SMALL_ACTION_HIT_SLOP : undefined} disabled={!canComment} onPress={handlePress} + onHoverIn={handleHoverIn} + onHoverOut={handleHoverOut} + onPressIn={handlePressIn} + onPressOut={handlePressOut} style={pressableStyle} > - {({ hovered, pressed }) => { - const showAction = canComment && (hovered || pressed || hasComments || isEditorOpen); - return ( - - - {showAction ? ( - - ) : ( - children - )} + + + {children} + {showAction ? ( + + - - ); - }} + ) : null} + + ); } @@ -438,33 +499,75 @@ export function InlineReviewEditor({ }) { const { theme } = useUnistyles(); const inputRef = useRef(null); + const focus = useWorkspaceFocusRestoration(); + const canShowKeyboardHints = useCanShowReviewKeyboardHints(); const [body, setBody] = useState(initialBody); const [isFocused, setIsFocused] = useState(false); const trimmedBody = body.trim(); const canSave = trimmedBody.length > 0; + const showKeyboardHints = isFocused && canShowKeyboardHints; useEffect(() => { inputRef.current?.focus(); }, []); - const handleFocus = useCallback(() => setIsFocused(true), []); - const handleBlur = useCallback(() => setIsFocused(false), []); + const handleFocus = useCallback(() => { + focus.unfocus(); + setIsFocused(true); + }, [focus]); + const handleBlur = useCallback(() => { + setIsFocused(false); + focus.restore(); + }, [focus]); const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]); + const cancelShortcut = useMemo( + () => (showKeyboardHints ? : null), + [showKeyboardHints], + ); + const saveShortcut = useMemo( + () => (showKeyboardHints ? : null), + [showKeyboardHints], + ); + + useEffect(() => { + const element = getWebTextInputElement(inputRef.current); + if (!element) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onCancel(); + return; + } + + if (event.key !== "Enter" || event.shiftKey) { + return; + } + if (!event.metaKey && !event.ctrlKey) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (!canSave) { + return; + } + handleSave(); + }; + + element.addEventListener("keydown", handleKeyDown); + return () => { + element.removeEventListener("keydown", handleKeyDown); + }; + }, [canSave, handleSave, onCancel]); const inputStyle = useMemo>( () => [styles.editorInput, isFocused && styles.editorInputFocused], [isFocused], ); - const saveButtonStyle = useCallback( - ({ hovered, pressed }: PressableState): StyleProp => [ - styles.saveButton, - !canSave && styles.saveButtonDisabled, - canSave && (hovered || pressed) && styles.saveButtonHovered, - ], - [canSave], - ); - return ( - - Cancel - - + ); } const styles = StyleSheet.create((theme) => ({ - gutterHovered: { - backgroundColor: theme.colors.surface2, - }, gutterInner: { minHeight: theme.lineHeight.diff, alignItems: "center", justifyContent: "center", + overflow: "visible", }, gutterLabel: { width: "100%", @@ -522,10 +625,25 @@ const styles = StyleSheet.create((theme) => ({ height: theme.lineHeight.diff, alignItems: "center", justifyContent: "center", + position: "relative", + overflow: "visible", }, gutterLabelActive: { backgroundColor: theme.colors.surface2, }, + gutterActionIcon: { + position: "absolute", + right: -10, + top: Math.floor((theme.lineHeight.diff - 22) / 2), + width: 22, + height: 22, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.accent, + zIndex: 10, + elevation: 10, + }, threadContainer: { flex: 1, minWidth: 0, @@ -616,38 +734,4 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, - ghostButton: { - height: 28, - alignItems: "center", - justifyContent: "center", - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - }, - ghostButtonHovered: { - backgroundColor: theme.colors.surface3, - }, - ghostButtonText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, - }, - saveButton: { - height: 28, - alignItems: "center", - justifyContent: "center", - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.accent, - }, - saveButtonHovered: { - opacity: 0.9, - }, - saveButtonDisabled: { - opacity: 0.4, - }, - saveButtonText: { - color: theme.colors.accentForeground, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.semibold, - }, })); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 67b13c2b4..c174f16bf 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -1013,10 +1013,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, - newTabTooltipShortcut: { - backgroundColor: theme.colors.surface3, - borderColor: theme.colors.borderAccent, - }, + newTabTooltipShortcut: {}, tooltipAgentRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 7fb713692..69c386693 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -118,6 +118,7 @@ import { type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; +import { WorkspaceFocusProvider } from "@/workspace/focus"; import { shouldSeedEmptyWorkspaceDraft } from "@/screens/workspace/workspace-empty-draft-seed"; import { buildBulkCloseConfirmationMessage, @@ -2897,127 +2898,129 @@ function WorkspaceScreenContent({ ]); return ( - - {documentTitleEffectTab ? ( - - {(presentation) => ( - - )} - - ) : null} - - - {showScreenHeader && ( - - - - - } - right={headerRight} - /> - )} - - {isMobile ? ( - - ) : null} - - {shouldRenderDesktopPaneFallback ? ( - - ) : null} - - - {isMobile ? ( - - {content} - - ) : ( - {desktopContent} - )} - - - - {showExplorerSidebar && workspaceDirectory ? ( - + + {documentTitleEffectTab ? ( + + > + {(presentation) => ( + + )} + ) : null} + + + {showScreenHeader && ( + + + + + } + right={headerRight} + /> + )} + + {isMobile ? ( + + ) : null} + + {shouldRenderDesktopPaneFallback ? ( + + ) : null} + + + {isMobile ? ( + + {content} + + ) : ( + {desktopContent} + )} + + + + {showExplorerSidebar && workspaceDirectory ? ( + + ) : null} + - + ); } @@ -3154,10 +3157,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, - newTabTooltipShortcut: { - backgroundColor: theme.colors.surface3, - borderColor: theme.colors.borderAccent, - }, + newTabTooltipShortcut: {}, explorerTooltipRow: { flexDirection: "row", alignItems: "center", @@ -3167,10 +3167,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, - explorerTooltipShortcut: { - backgroundColor: theme.colors.surface3, - borderColor: theme.colors.borderAccent, - }, + explorerTooltipShortcut: {}, mobileTabsRow: { backgroundColor: theme.colors.surface0, borderBottomWidth: theme.borderWidth[1], diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 01218e988..ee7df82cb 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -23,7 +23,7 @@ export type SplitNode = { kind: "pane"; pane: SplitPane } | { kind: "group"; gro export interface WorkspaceLayout { root: SplitNode; - focusedPaneId: string; + focusedPaneId: string | null; } interface SplitPaneInternal extends SplitPane { @@ -398,9 +398,9 @@ function asInternalNode(node: SplitNode): SplitNodeInternal { function asInternalLayout(layout: WorkspaceLayout): { root: SplitNodeInternal; - focusedPaneId: string; + focusedPaneId: string | null; } { - return layout as { root: SplitNodeInternal; focusedPaneId: string }; + return layout as { root: SplitNodeInternal; focusedPaneId: string | null }; } function findPanePathById( @@ -899,11 +899,14 @@ export function normalizeLayout(layout: unknown): WorkspaceLayout { const rawLayout = layout as WorkspaceLayout; const root = normalizeNode(rawLayout.root) ?? asInternalNode(createDefaultLayout().root); - const focusedPaneId = trimNonEmpty(rawLayout.focusedPaneId); + const focusedPaneId = + rawLayout.focusedPaneId === null ? null : trimNonEmpty(rawLayout.focusedPaneId); const resolvedFocusedPaneId = - (focusedPaneId && findPaneById(root, focusedPaneId)?.id) ?? - collectAllPanes(root)[0]?.id ?? - DEFAULT_PANE_ID; + focusedPaneId === null + ? null + : ((focusedPaneId && findPaneById(root, focusedPaneId)?.id) ?? + collectAllPanes(root)[0]?.id ?? + DEFAULT_PANE_ID); return { root, @@ -911,7 +914,10 @@ export function normalizeLayout(layout: unknown): WorkspaceLayout { }; } -export function findPaneById(root: SplitNode, paneId: string): SplitPane | null { +export function findPaneById(root: SplitNode, paneId: string | null | undefined): SplitPane | null { + if (!paneId) { + return null; + } const internalRoot = asInternalNode(root); if (internalRoot.kind === "pane") { return internalRoot.pane.id === paneId ? internalRoot.pane : null; @@ -1074,10 +1080,12 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout const fallbackPaneId = findNearestSiblingPaneId(internalLayout.root, pane.id); const nextRoot = removeTabFromTree(internalLayout.root, input.tabId) as SplitNodeInternal; const nextFocusedPaneId = - findPaneById(nextRoot, internalLayout.focusedPaneId)?.id ?? - (fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ?? - collectAllPanes(nextRoot)[0]?.id ?? - DEFAULT_PANE_ID; + internalLayout.focusedPaneId === null + ? null + : (findPaneById(nextRoot, internalLayout.focusedPaneId)?.id ?? + (fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ?? + collectAllPanes(nextRoot)[0]?.id ?? + DEFAULT_PANE_ID); return { root: nextRoot, @@ -1204,7 +1212,7 @@ export function reorderFocusedPaneTabsInLayout( input: ReorderFocusedPaneTabsInLayoutInput, ): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); - if (!findPaneById(layout.root, layout.focusedPaneId)) { + if (!layout.focusedPaneId || !findPaneById(layout.root, layout.focusedPaneId)) { return null; } diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 5bd649cd0..700d52cde 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -226,6 +226,7 @@ describe("workspace-layout-store actions", () => { splitSizesByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, + focusRestorationByWorkspace: {}, }); vi.restoreAllMocks(); }); @@ -313,6 +314,66 @@ describe("workspace-layout-store actions", () => { expect(pane.focusedTabId).toBe(secondTabId); }); + it("unfocuses and restores the previous focused pane", () => { + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + const token = store.unfocusPane(workspaceKey); + expect(token).toBeTruthy(); + expect( + useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId, + ).toBeNull(); + + store.restorePaneFocus(workspaceKey, token!); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( + "main", + ); + }); + + it("does not restore stale focus after another pane is focused", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + ); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" }); + store.splitPane(workspaceKey, { + tabId: firstTabId!, + targetPaneId: "main", + position: "right", + }); + store.focusPane(workspaceKey, "main"); + + const token = store.unfocusPane(workspaceKey); + store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + store.restorePaneFocus(workspaceKey, token!); + + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( + "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + ); + }); + + it("waits for nested focus restorations before restoring", () => { + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" }); + const outerToken = store.unfocusPane(workspaceKey); + const innerToken = store.unfocusPane(workspaceKey); + + store.restorePaneFocus(workspaceKey, outerToken!); + expect( + useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId, + ).toBeNull(); + + store.restorePaneFocus(workspaceKey, innerToken!); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe( + "main", + ); + }); + it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => { const workspaceKey = createWorkspaceKey(); const store = useWorkspaceLayoutStore.getState(); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index d422cd070..c9367e16e 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -68,6 +68,7 @@ interface WorkspaceLayoutStore { splitSizesByWorkspace: Record>; pinnedAgentIdsByWorkspace: Record>; hiddenAgentIdsByWorkspace: Record>; + focusRestorationByWorkspace: Record; openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; openTabInBackground: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; closeTab: (workspaceKey: string, tabId: string) => void; @@ -94,6 +95,8 @@ interface WorkspaceLayoutStore { ) => string | null; moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void; focusPane: (workspaceKey: string, paneId: string) => void; + unfocusPane: (workspaceKey: string) => string | null; + restorePaneFocus: (workspaceKey: string, token: string) => void; resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void; reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void; pinAgent: (workspaceKey: string, agentId: string) => void; @@ -103,6 +106,11 @@ interface WorkspaceLayoutStore { purgeWorkspace: (workspaceKey: string) => void; } +interface WorkspaceFocusRestorationState { + restorePaneId: string | null; + tokens: string[]; +} + const MAX_TREE_DEPTH = 4; function trimNonEmpty(value: string | null | undefined): string | null { @@ -162,6 +170,26 @@ function getWorkspaceLayout( return normalizeLayout(state[workspaceKey] ?? createDefaultLayout()); } +function createFocusRestorationToken(): string { + const randomValue = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `workspace-focus-${randomValue}`; +} + +function withoutFocusRestoration( + state: WorkspaceLayoutStore, + workspaceKey: string, +): Pick | null { + if (!(workspaceKey in state.focusRestorationByWorkspace)) { + return null; + } + const { [workspaceKey]: _removed, ...focusRestorationByWorkspace } = + state.focusRestorationByWorkspace; + return { focusRestorationByWorkspace }; +} + export const useWorkspaceLayoutStore = create()( persist( (set, get) => ({ @@ -169,6 +197,7 @@ export const useWorkspaceLayoutStore = create()( splitSizesByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, + focusRestorationByWorkspace: {}, openTabFocused: (workspaceKey, target) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedTarget = normalizeWorkspaceTabTarget(target); @@ -183,6 +212,7 @@ export const useWorkspaceLayoutStore = create()( }); set((state) => ({ + ...withoutFocusRestoration(state, normalizedWorkspaceKey), hiddenAgentIdsByWorkspace: normalizedTarget.kind !== "agent" ? state.hiddenAgentIdsByWorkspace @@ -246,6 +276,7 @@ export const useWorkspaceLayoutStore = create()( } return { + ...withoutFocusRestoration(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -270,6 +301,7 @@ export const useWorkspaceLayoutStore = create()( } return { + ...withoutFocusRestoration(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -295,6 +327,9 @@ export const useWorkspaceLayoutStore = create()( } set((state) => ({ + ...(result.layout.focusedPaneId !== null + ? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {}) + : {}), hiddenAgentIdsByWorkspace: normalizedTarget.kind !== "agent" ? state.hiddenAgentIdsByWorkspace @@ -329,6 +364,9 @@ export const useWorkspaceLayoutStore = create()( } set((state) => ({ + ...(result.layout.focusedPaneId !== null + ? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {}) + : {}), hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( state.hiddenAgentIdsByWorkspace, normalizedWorkspaceKey, @@ -429,6 +467,7 @@ export const useWorkspaceLayoutStore = create()( } set((state) => ({ + ...withoutFocusRestoration(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: result.layout, @@ -462,6 +501,7 @@ export const useWorkspaceLayoutStore = create()( } set((state) => ({ + ...withoutFocusRestoration(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: result.layout, @@ -489,6 +529,7 @@ export const useWorkspaceLayoutStore = create()( } return { + ...withoutFocusRestoration(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -513,6 +554,7 @@ export const useWorkspaceLayoutStore = create()( } return { + ...withoutFocusRestoration(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -520,6 +562,90 @@ export const useWorkspaceLayoutStore = create()( }; }); }, + unfocusPane: (workspaceKey) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) { + return null; + } + + const token = createFocusRestorationToken(); + set((state) => { + const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); + const currentRestoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey]; + const restorePaneId = currentRestoration?.restorePaneId ?? layout.focusedPaneId; + + return { + focusRestorationByWorkspace: { + ...state.focusRestorationByWorkspace, + [normalizedWorkspaceKey]: { + restorePaneId, + tokens: [...(currentRestoration?.tokens ?? []), token], + }, + }, + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: + layout.focusedPaneId === null ? layout : { ...layout, focusedPaneId: null }, + }, + }; + }); + return token; + }, + restorePaneFocus: (workspaceKey, token) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedToken = trimNonEmpty(token); + if (!normalizedWorkspaceKey || !normalizedToken) { + return; + } + + set((state) => { + const restoration = state.focusRestorationByWorkspace[normalizedWorkspaceKey]; + if (!restoration?.tokens.includes(normalizedToken)) { + return state; + } + + const nextTokens = restoration.tokens.filter((entry) => entry !== normalizedToken); + const { [normalizedWorkspaceKey]: _removed, ...remainingRestorations } = + state.focusRestorationByWorkspace; + const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); + + if (layout.focusedPaneId !== null) { + return { + focusRestorationByWorkspace: remainingRestorations, + }; + } + + if (nextTokens.length > 0) { + return { + focusRestorationByWorkspace: { + ...remainingRestorations, + [normalizedWorkspaceKey]: { + restorePaneId: restoration.restorePaneId, + tokens: nextTokens, + }, + }, + }; + } + + const restorePaneId = findPaneById(layout.root, restoration.restorePaneId)?.id ?? null; + if (!restorePaneId) { + return { + focusRestorationByWorkspace: remainingRestorations, + }; + } + + return { + focusRestorationByWorkspace: remainingRestorations, + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: { + ...layout, + focusedPaneId: restorePaneId, + }, + }, + }; + }); + }, resizeSplit: (workspaceKey, groupId, sizes) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedGroupId = trimNonEmpty(groupId); @@ -555,6 +681,7 @@ export const useWorkspaceLayoutStore = create()( } return { + ...withoutFocusRestoration(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -682,7 +809,8 @@ export const useWorkspaceLayoutStore = create()( normalizedWorkspaceKey in state.layoutByWorkspace || normalizedWorkspaceKey in state.splitSizesByWorkspace || normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace || - normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace; + normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace || + normalizedWorkspaceKey in state.focusRestorationByWorkspace; if (!hasAny) { return state; } @@ -694,11 +822,14 @@ export const useWorkspaceLayoutStore = create()( state.pinnedAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } = state.hiddenAgentIdsByWorkspace; + const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } = + state.focusRestorationByWorkspace; return { layoutByWorkspace, splitSizesByWorkspace, pinnedAgentIdsByWorkspace, hiddenAgentIdsByWorkspace, + focusRestorationByWorkspace, }; }); }, diff --git a/packages/app/src/utils/format-shortcut.ts b/packages/app/src/utils/format-shortcut.ts index b6a6e95e4..9006a7e27 100644 --- a/packages/app/src/utils/format-shortcut.ts +++ b/packages/app/src/utils/format-shortcut.ts @@ -4,7 +4,7 @@ export type ShortcutOs = "mac" | "non-mac"; const KEY_DISPLAY: Record = { Backspace: "⌫", - Enter: "↩", + Enter: "⏎", Esc: "Esc", Space: "␣", Left: "←", diff --git a/packages/app/src/workspace/focus.tsx b/packages/app/src/workspace/focus.tsx new file mode 100644 index 000000000..e7a6218ca --- /dev/null +++ b/packages/app/src/workspace/focus.tsx @@ -0,0 +1,75 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + type ReactNode, +} from "react"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; + +interface WorkspaceFocusContextValue { + workspaceKey: string | null; + unfocusPane: (workspaceKey: string) => string | null; + restorePaneFocus: (workspaceKey: string, token: string) => void; +} + +export interface WorkspaceFocusRestoration { + unfocus: () => void; + restore: () => void; +} + +const WorkspaceFocusContext = createContext(null); + +const noopFocusRestoration: WorkspaceFocusRestoration = { + unfocus: () => {}, + restore: () => {}, +}; + +export function WorkspaceFocusProvider({ + workspaceKey, + children, +}: { + workspaceKey: string | null; + children: ReactNode; +}) { + const unfocusPane = useWorkspaceLayoutStore((state) => state.unfocusPane); + const restorePaneFocus = useWorkspaceLayoutStore((state) => state.restorePaneFocus); + const value = useMemo( + () => ({ + workspaceKey, + unfocusPane, + restorePaneFocus, + }), + [restorePaneFocus, unfocusPane, workspaceKey], + ); + + return {children}; +} + +export function useWorkspaceFocusRestoration(): WorkspaceFocusRestoration { + const context = useContext(WorkspaceFocusContext); + const tokenRef = useRef(null); + + const restore = useCallback(() => { + const token = tokenRef.current; + if (!context?.workspaceKey || !token) { + tokenRef.current = null; + return; + } + tokenRef.current = null; + context.restorePaneFocus(context.workspaceKey, token); + }, [context]); + + const unfocus = useCallback(() => { + if (!context?.workspaceKey || tokenRef.current) { + return; + } + tokenRef.current = context.unfocusPane(context.workspaceKey); + }, [context]); + + useEffect(() => restore, [restore]); + + return context ? { unfocus, restore } : noopFocusRestoration; +}