Improve review comment UI

This commit is contained in:
Mohamed Boudra
2026-05-02 18:04:18 +07:00
parent 49e3011a28
commit 34b95e7f01
20 changed files with 801 additions and 286 deletions
@@ -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({
>
<View style={styles.pillBody}>
<View style={styles.pillIcon}>
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
</View>
<Text style={styles.pillText} numberOfLines={1}>
{label}
@@ -279,5 +279,5 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
})) as unknown as Record<string, object>;
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -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"),
+2 -10
View File
@@ -853,9 +853,7 @@ function ComposerCancelButton({
) : (
<Square size={buttonIconSize} color="white" fill="white" />
);
const shortcutNode = agentInterruptKeys ? (
<Shortcut chord={agentInterruptKeys} style={styles.tooltipShortcut} />
) : null;
const shortcutNode = agentInterruptKeys ? <Shortcut chord={agentInterruptKeys} /> : null;
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
@@ -941,9 +939,7 @@ function ComposerVoiceModeButton({
realtimeVoiceButtonStyle,
voiceToggleKeys,
}: ComposerVoiceModeButtonProps) {
const shortcutNode = voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null;
const shortcutNode = voiceToggleKeys ? <Shortcut chord={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,
},
@@ -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,
+88 -8
View File
@@ -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<ViewStyle>;
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 (
<Pressable onHoverIn={handleHoverIn} onHoverOut={handleHoverOut} style={hoverStyle}>
{children}
</Pressable>
);
}
if (!isNative || !reviewTarget || !onStartComment) {
return <View style={style}>{children}</View>;
}
return (
<Pressable
onLongPress={handleLongPress}
delayLongPress={LONG_PRESS_COMMENT_DELAY_MS}
style={style}
>
<Pressable onPress={handlePress} style={style}>
{children}
</Pressable>
);
@@ -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<ViewStyle>;
}) {
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({
<LongPressableLine
reviewTarget={reviewTarget}
reviewActions={reviewActions}
onHoverChange={onHoverChange}
hoverTargetKey={hoverTargetKey}
onHoverTargetChange={onHoverTargetChange}
style={containerStyle}
>
{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({
<LongPressableLine
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
onHoverChange={onHoverChange}
hoverTargetKey={hoverTargetKey}
onHoverTargetChange={onHoverTargetChange}
style={containerStyle}
>
{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({
<LongPressableLine
reviewTarget={reviewTarget}
reviewActions={reviewActions}
onHoverChange={setIsLineHovered}
style={containerStyle}
>
<DiffGutterCell
@@ -456,6 +503,7 @@ function DiffLineView({
gutterWidth={gutterWidth}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
isLineHovered={isLineHovered}
style={styles.lineNumberGutter}
/>
{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({
<LongPressableLine
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
onHoverChange={setIsLineHovered}
style={containerStyle}
>
<DiffGutterCell
@@ -508,6 +558,7 @@ function SplitDiffLine({
gutterWidth={gutterWidth}
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
isLineHovered={isLineHovered}
style={styles.lineNumberGutter}
/>
{visibleTokens ? (
@@ -635,6 +686,7 @@ function SplitDiffColumn({
showDivider?: boolean;
}) {
const [scrollWidth, setScrollWidth] = useState(0);
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(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
}
/>
<InlineReviewGutterSpacer
reviewTarget={line?.reviewTarget}
@@ -741,6 +797,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,
@@ -748,7 +805,13 @@ function SplitDiffColumn({
});
return (
<View key={key}>
<SplitTextLine line={line} wrapLines={false} reviewActions={reviewActions} />
<SplitTextLine
line={line}
wrapLines={false}
reviewActions={reviewActions}
hoverTargetKey={reviewTargetKey}
onHoverTargetChange={setHoveredReviewTargetKey}
/>
<InlineReviewThreadContent
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
@@ -872,6 +935,7 @@ function DiffFileBody({
}) {
const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [bodyWidth, setBodyWidth] = useState(0);
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(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
}
/>
<InlineReviewGutterSpacer
reviewTarget={reviewTarget}
@@ -998,6 +1065,8 @@ function DiffFileBody({
wrapLines={false}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
hoverTargetKey={reviewTarget?.key ?? null}
onHoverTargetChange={setHoveredReviewTargetKey}
/>
<InlineReviewThreadContent
reviewTarget={reviewTarget}
@@ -2998,11 +3067,17 @@ const styles = StyleSheet.create((theme) => ({
},
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,
@@ -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: {},
}));
@@ -278,7 +278,7 @@ function VoiceTooltipBody({
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{voiceTooltipText}</Text>
{shortcut ? <Shortcut chord={shortcut} style={styles.tooltipShortcut} /> : null}
{shortcut ? <Shortcut chord={shortcut} /> : null}
</View>
);
}
@@ -293,7 +293,7 @@ function SendTooltipBody({
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{label}</Text>
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
{sendKeys ? <Shortcut chord={sendKeys} /> : null}
</View>
);
}
@@ -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,
},
@@ -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],
@@ -126,7 +126,7 @@ interface SplitPaneDropData {
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
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;
}
+21 -4
View File
@@ -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<ButtonSize, number> = { sm: 14, md: 16, lg: 20 };
const ICON_SIZE: Record<ButtonSize, number> = { 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<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
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 ? <Text style={resolvedTextStyle}>{children}</Text> : null}
{trailing}
</Pressable>
);
}
+21 -5
View File
@@ -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),
},
}));
+75 -9
View File
@@ -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<string, Record<string, unknown>>(),
@@ -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(
<InlineReviewGutterCell
@@ -307,7 +311,7 @@ describe("git diff inline review helpers", () => {
);
expect(queryByText("2")).toBeTruthy();
expect(container.querySelector("[data-icon='MessageCircle']")).toBeNull();
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
rerender(
<InlineReviewGutterCell
@@ -320,8 +324,8 @@ describe("git diff inline review helpers", () => {
</InlineReviewGutterCell>,
);
expect(queryByText("2")).toBeNull();
expect(container.querySelector("[data-icon='MessageCircle']")).toBeTruthy();
expect(queryByText("2")).toBeTruthy();
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
rerender(
<InlineReviewGutterCell
@@ -334,13 +338,31 @@ describe("git diff inline review helpers", () => {
</InlineReviewGutterCell>,
);
expect(queryByText("2")).toBeNull();
expect(container.querySelector("[data-icon='MessageCircle']")).toBeTruthy();
expect(queryByText("2")).toBeTruthy();
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
rerender(
<InlineReviewGutterCell
reviewTarget={reviewTarget}
comments={COMMENT_LIST}
isEditorOpen={false}
isLineHovered
onStartComment={vi.fn()}
>
<span>2</span>
</InlineReviewGutterCell>,
);
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(
<InlineReviewEditor
initialBody="ready"
onCancel={onCancel}
onSave={onSave}
testID="editor"
/>,
);
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(
<InlineReviewEditor
initialBody="ready"
onCancel={vi.fn()}
onSave={vi.fn()}
testID="editor"
/>,
);
const input = getByTestId("editor-input");
expect(getByText("Esc")).toBeTruthy();
expect(getByText(/(?:⌘⏎|Ctrl\+⏎)/)).toBeTruthy();
fireEvent.blur(input);
expect(queryByText("Esc")).toBeNull();
});
});
describe("InlineReviewThread", () => {
+170 -86
View File
@@ -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<ViewStyle> {
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<ViewStyle> {
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<ViewStyle>;
}) {
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<ViewStyle> => [
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<ViewStyle> => style, [style]);
const labelStyle = useMemo<StyleProp<ViewStyle>>(
() => [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 (
<View style={styles.gutterInner}>
<View style={labelStyle}>
{showAction ? (
<MessageCircle
size={13}
strokeWidth={hasComments ? 2.25 : 1.75}
color={hasComments ? theme.colors.accent : theme.colors.foregroundMuted}
/>
) : (
children
)}
<View style={styles.gutterInner}>
<View style={labelStyle}>
{children}
{showAction ? (
<View style={styles.gutterActionIcon}>
<Plus size={16} strokeWidth={2.4} color={theme.colors.accentForeground} />
</View>
</View>
);
}}
) : null}
</View>
</View>
</Pressable>
);
}
@@ -438,33 +499,75 @@ export function InlineReviewEditor({
}) {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput | null>(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 ? <Shortcut keys={REVIEW_CANCEL_SHORTCUT_KEYS} /> : null),
[showKeyboardHints],
);
const saveShortcut = useMemo(
() => (showKeyboardHints ? <Shortcut keys={REVIEW_SAVE_SHORTCUT_KEYS} /> : 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<StyleProp<TextStyle>>(
() => [styles.editorInput, isFocused && styles.editorInputFocused],
[isFocused],
);
const saveButtonStyle = useCallback(
({ hovered, pressed }: PressableState): StyleProp<ViewStyle> => [
styles.saveButton,
!canSave && styles.saveButtonDisabled,
canSave && (hovered || pressed) && styles.saveButtonHovered,
],
[canSave],
);
return (
<View style={styles.editorBlock} testID={testID}>
<TextInput
@@ -481,40 +584,40 @@ export function InlineReviewEditor({
style={inputStyle}
/>
<View style={styles.editorActions}>
<Pressable
accessibilityRole="button"
<Button
accessibilityLabel="Cancel review comment"
testID={testID ? `${testID}-cancel` : undefined}
hitSlop={SMALL_ACTION_HIT_SLOP}
onPress={onCancel}
style={ghostButtonStyle}
variant="ghost"
size="xs"
trailing={cancelShortcut}
>
<Text style={styles.ghostButtonText}>Cancel</Text>
</Pressable>
<Pressable
accessibilityRole="button"
Cancel
</Button>
<Button
accessibilityLabel="Save review comment"
testID={testID ? `${testID}-save` : undefined}
hitSlop={SMALL_ACTION_HIT_SLOP}
disabled={!canSave}
onPress={handleSave}
style={saveButtonStyle}
variant="default"
size="xs"
trailing={saveShortcut}
>
<Text style={styles.saveButtonText}>Save</Text>
</Pressable>
Comment
</Button>
</View>
</View>
);
}
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,
},
}));
@@ -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",
@@ -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 (
<View style={containerStyle}>
{documentTitleEffectTab ? (
<WorkspaceTabPresentationResolver
tab={documentTitleEffectTab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{(presentation) => (
<WorkspaceDocumentTitleEffect
label={presentation.label}
titleState={presentation.titleState}
/>
)}
</WorkspaceTabPresentationResolver>
) : null}
<View style={styles.threePaneRow}>
<View style={styles.centerColumn}>
{showScreenHeader && (
<ScreenHeader
onRowLayout={onHeaderLayout}
left={
<>
<SidebarMenuToggle />
<WorkspaceHeaderTitleBar
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
currentBranchName={currentBranchName}
isGitCheckout={isGitCheckout}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
showWorkspaceSetup={showWorkspaceSetup}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
/>
</>
}
right={headerRight}
/>
)}
{isMobile ? (
<MobileWorkspaceTabSwitcher
tabs={tabs}
activeTabKey={activeTabKey}
activeTab={activeTabDescriptor}
tabSwitcherOptions={tabSwitcherOptions}
tabByKey={tabByKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
/>
) : null}
{shouldRenderDesktopPaneFallback ? (
<WorkspaceDesktopTabsRow
paneId={focusedPaneIdOrUndefined}
isFocused={isRouteFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
disableCreateTerminal={createTerminalMutation.isPending}
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
onReorderTabs={handleReorderTabsInFocusedPane}
onSplitRight={noop}
onSplitDown={noop}
showPaneSplitActions={false}
/>
) : null}
<View style={styles.centerContent}>
{isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
<View style={styles.content}>{content}</View>
</GestureDetector>
) : (
<View style={styles.content}>{desktopContent}</View>
)}
</View>
</View>
{showExplorerSidebar && workspaceDirectory ? (
<ExplorerSidebar
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
<View style={containerStyle}>
{documentTitleEffectTab ? (
<WorkspaceTabPresentationResolver
tab={documentTitleEffectTab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
>
{(presentation) => (
<WorkspaceDocumentTitleEffect
label={presentation.label}
titleState={presentation.titleState}
/>
)}
</WorkspaceTabPresentationResolver>
) : null}
<View style={styles.threePaneRow}>
<View style={styles.centerColumn}>
{showScreenHeader && (
<ScreenHeader
onRowLayout={onHeaderLayout}
left={
<>
<SidebarMenuToggle />
<WorkspaceHeaderTitleBar
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
currentBranchName={currentBranchName}
isGitCheckout={isGitCheckout}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
showWorkspaceSetup={showWorkspaceSetup}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
/>
</>
}
right={headerRight}
/>
)}
{isMobile ? (
<MobileWorkspaceTabSwitcher
tabs={tabs}
activeTabKey={activeTabKey}
activeTab={activeTabDescriptor}
tabSwitcherOptions={tabSwitcherOptions}
tabByKey={tabByKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
/>
) : null}
{shouldRenderDesktopPaneFallback ? (
<WorkspaceDesktopTabsRow
paneId={focusedPaneIdOrUndefined}
isFocused={isRouteFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
disableCreateTerminal={createTerminalMutation.isPending}
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
onReorderTabs={handleReorderTabsInFocusedPane}
onSplitRight={noop}
onSplitDown={noop}
showPaneSplitActions={false}
/>
) : null}
<View style={styles.centerContent}>
{isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
<View style={styles.content}>{content}</View>
</GestureDetector>
) : (
<View style={styles.content}>{desktopContent}</View>
)}
</View>
</View>
{showExplorerSidebar && workspaceDirectory ? (
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
) : null}
</View>
</View>
</View>
</WorkspaceFocusProvider>
);
}
@@ -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],
@@ -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;
}
@@ -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();
@@ -68,6 +68,7 @@ interface WorkspaceLayoutStore {
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
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<WorkspaceLayoutStore, "focusRestorationByWorkspace"> | null {
if (!(workspaceKey in state.focusRestorationByWorkspace)) {
return null;
}
const { [workspaceKey]: _removed, ...focusRestorationByWorkspace } =
state.focusRestorationByWorkspace;
return { focusRestorationByWorkspace };
}
export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
persist(
(set, get) => ({
@@ -169,6 +197,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
splitSizesByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
openTabFocused: (workspaceKey, target) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
@@ -183,6 +212,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
});
set((state) => ({
...withoutFocusRestoration(state, normalizedWorkspaceKey),
hiddenAgentIdsByWorkspace:
normalizedTarget.kind !== "agent"
? state.hiddenAgentIdsByWorkspace
@@ -246,6 +276,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
}
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -270,6 +301,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
}
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -295,6 +327,9 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
}
set((state) => ({
...(result.layout.focusedPaneId !== null
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
: {}),
hiddenAgentIdsByWorkspace:
normalizedTarget.kind !== "agent"
? state.hiddenAgentIdsByWorkspace
@@ -329,6 +364,9 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
}
set((state) => ({
...(result.layout.focusedPaneId !== null
? (withoutFocusRestoration(state, normalizedWorkspaceKey) ?? {})
: {}),
hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
state.hiddenAgentIdsByWorkspace,
normalizedWorkspaceKey,
@@ -429,6 +467,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
}
set((state) => ({
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: result.layout,
@@ -462,6 +501,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
}
set((state) => ({
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: result.layout,
@@ -489,6 +529,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
}
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -513,6 +554,7 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
}
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -520,6 +562,90 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
};
});
},
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<WorkspaceLayoutStore>()(
}
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -682,7 +809,8 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
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<WorkspaceLayoutStore>()(
state.pinnedAgentIdsByWorkspace;
const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } =
state.hiddenAgentIdsByWorkspace;
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
state.focusRestorationByWorkspace;
return {
layoutByWorkspace,
splitSizesByWorkspace,
pinnedAgentIdsByWorkspace,
hiddenAgentIdsByWorkspace,
focusRestorationByWorkspace,
};
});
},
+1 -1
View File
@@ -4,7 +4,7 @@ export type ShortcutOs = "mac" | "non-mac";
const KEY_DISPLAY: Record<string, string> = {
Backspace: "⌫",
Enter: "↩",
Enter: "⏎",
Esc: "Esc",
Space: "␣",
Left: "←",
+75
View File
@@ -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<WorkspaceFocusContextValue | null>(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<WorkspaceFocusContextValue>(
() => ({
workspaceKey,
unfocusPane,
restorePaneFocus,
}),
[restorePaneFocus, unfocusPane, workspaceKey],
);
return <WorkspaceFocusContext.Provider value={value}>{children}</WorkspaceFocusContext.Provider>;
}
export function useWorkspaceFocusRestoration(): WorkspaceFocusRestoration {
const context = useContext(WorkspaceFocusContext);
const tokenRef = useRef<string | null>(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;
}