diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index a0da816153..9805a96fdb 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -6,6 +6,7 @@ import { getRightSidebarLayoutKey, getTerminalTitlesKey, } from "@/common/constants/storage"; +import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar"; import { readPersistedState, @@ -244,6 +245,10 @@ interface RightSidebarTabsetNodeProps { reviewStats: ReviewStats | null; onReviewStatsChange: (stats: ReviewStats | null) => void; statsTabEnabled: boolean; + /** Whether immersive review should use touch/mobile UX affordances. */ + isTouchReviewImmersive: boolean; + /** Update touch/mobile immersive affordance mode from child controls/events. */ + onTouchReviewImmersiveChange: (isTouch: boolean) => void; /** Whether any sidebar tab is currently being dragged */ isDraggingTab: boolean; /** Data about the currently dragged tab (if any) */ @@ -616,6 +621,8 @@ const RightSidebarTabsetNode: React.FC = (props) => onReviewNote={props.onReviewNote} focusTrigger={props.focusTrigger} isCreating={props.isCreating} + isTouchImmersive={props.isTouchReviewImmersive} + onTouchImmersiveChange={props.onTouchReviewImmersiveChange} onStatsChange={props.onReviewStatsChange} onOpenFile={props.onOpenFile} /> @@ -659,6 +666,8 @@ const RightSidebarComponent: React.FC = ({ { listener: true } ); + const [isTouchReviewImmersive, setIsTouchReviewImmersive] = React.useState(false); + // Stats tab feature flag const { statsTabState } = useFeatureFlags(); const statsTabEnabled = Boolean(statsTabState?.enabled); @@ -802,6 +811,30 @@ const RightSidebarComponent: React.FC = ({ _setFocusTrigger((prev) => prev + 1); }, [setLayout]); + React.useEffect(() => { + const handleOpenTouchReviewImmersive = (event: Event) => { + const detail = (event as CustomEvent<{ workspaceId: string }>).detail; + if (detail?.workspaceId !== workspaceId) { + return; + } + + setIsTouchReviewImmersive(true); + setCollapsed(false); + selectOrOpenReviewTab(); + setIsReviewImmersive(true); + }; + + window.addEventListener( + CUSTOM_EVENTS.OPEN_TOUCH_REVIEW_IMMERSIVE, + handleOpenTouchReviewImmersive + ); + return () => + window.removeEventListener( + CUSTOM_EVENTS.OPEN_TOUCH_REVIEW_IMMERSIVE, + handleOpenTouchReviewImmersive + ); + }, [selectOrOpenReviewTab, setCollapsed, setIsReviewImmersive, workspaceId]); + // Keyboard shortcuts for tab switching by position (Cmd/Ctrl+1-9) // Auto-expands sidebar if collapsed React.useEffect(() => { @@ -863,12 +896,18 @@ const RightSidebarComponent: React.FC = ({ e.preventDefault(); setCollapsed(false); selectOrOpenReviewTab(); - setIsReviewImmersive((prev) => !prev); + setIsReviewImmersive((prev) => { + const next = !prev; + if (next) { + setIsTouchReviewImmersive(false); + } + return next; + }); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [isReviewImmersive, selectOrOpenReviewTab, setCollapsed, setIsReviewImmersive]); + }, [selectOrOpenReviewTab, setCollapsed, setIsReviewImmersive]); const baseId = `right-sidebar-${workspaceId}`; @@ -1280,6 +1319,8 @@ const RightSidebarComponent: React.FC = ({ reviewStats={reviewStats} statsTabEnabled={statsTabEnabled} onReviewStatsChange={setReviewStats} + isTouchReviewImmersive={isTouchReviewImmersive} + onTouchReviewImmersiveChange={setIsTouchReviewImmersive} isDraggingTab={isDraggingTab} activeDragData={activeDragData} setLayout={setLayout} diff --git a/src/browser/components/RightSidebar/CodeReview/ImmersiveReviewView.tsx b/src/browser/components/RightSidebar/CodeReview/ImmersiveReviewView.tsx index 74c3d3d856..7ddbd594ce 100644 --- a/src/browser/components/RightSidebar/CodeReview/ImmersiveReviewView.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ImmersiveReviewView.tsx @@ -51,6 +51,8 @@ interface ImmersiveReviewViewProps { onToggleRead: (hunkId: string) => void; selectedHunkId: string | null; onSelectHunk: (hunkId: string | null) => void; + /** Whether immersive review should use touch/mobile UX affordances. */ + isTouchImmersive?: boolean; onExit: () => void; onReviewNote?: (data: ReviewNoteData) => void; reviewActions?: ReviewActionCallbacks; @@ -364,7 +366,9 @@ export const ImmersiveReviewView: React.FC = (props) = onToggleRead, onExit, onReviewNote, + isTouchImmersive, } = props; + const isTouchExperience = isTouchImmersive === true; // Flatten file tree into ordered file list const fileList = useMemo(() => flattenFileTreeLeaves(fileTree), [fileTree]); @@ -889,8 +893,12 @@ export const ImmersiveReviewView: React.FC = (props) = const selectedLineSummary = getCurrentLineSelection(); const openComposer = useCallback( - (prefill: string) => { - const selection = getCurrentLineSelection(); + (prefill: string, selectionOverride?: SelectedLineRange) => { + const selection = selectionOverride ?? + getCurrentLineSelection() ?? { + startIndex: activeLineIndexRef.current ?? 0, + endIndex: activeLineIndexRef.current ?? 0, + }; pendingComposerHunkSwitchRef.current = null; // Resolve which hunk to attach the composer to. If the cursor has moved @@ -1025,17 +1033,30 @@ export const ImmersiveReviewView: React.FC = (props) = } else { setSelectedLineRange(null); } + + if (isTouchExperience && !shiftKey && resolvedHunk) { + // Mobile row tap should only open a composer for lines backed by a diff hunk. + openComposer("", { startIndex: lineIndex, endIndex: lineIndex }); + } }, - [overlayData, currentFileHunks, onSelectHunk] + [overlayData, currentFileHunks, isTouchExperience, onSelectHunk, openComposer] ); - // Auto-focus container on mount + // Auto-focus only for keyboard-first immersive mode. useEffect(() => { + if (isTouchExperience) { + return; + } + containerRef.current?.focus(); - }, []); + }, [isTouchExperience]); // --- Keyboard handler --- useEffect(() => { + if (isTouchExperience) { + return; + } + const handleKeyDown = (e: KeyboardEvent) => { // Tab: toggle between diff and notes panels. if (matchesKeybind(e, KEYBINDS.REVIEW_FOCUS_NOTES)) { @@ -1224,6 +1245,7 @@ export const ImmersiveReviewView: React.FC = (props) = openComposer, selectedHunkId, onToggleRead, + isTouchExperience, ]); const previousContentRef = useRef(overlayData.content); @@ -1379,7 +1401,7 @@ export const ImmersiveReviewView: React.FC = (props) = return (
@@ -1507,113 +1529,119 @@ export const ImmersiveReviewView: React.FC = (props) = )}
- + ); + })} + + )} + + + )} {/* Boundary toast */} @@ -1625,19 +1653,23 @@ export const ImmersiveReviewView: React.FC = (props) = )} - {/* Shortcut bar */} -
- - - - - - - - - - -
+ {!isTouchExperience && ( + <> + {/* Shortcut bar */} +
+ + + + + + + + + + +
+ + )} ); }; diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index 9bc0dc9d09..f7779035ca 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -86,6 +86,10 @@ interface ReviewPanelProps { isCreating?: boolean; /** Callback to report stats changes (for tab badge) */ onStatsChange?: (stats: ReviewPanelStats) => void; + /** Whether immersive review should use touch/mobile UX affordances. */ + isTouchImmersive?: boolean; + /** Allow parent to switch touch/mobile immersive affordances before entering immersive UI. */ + onTouchImmersiveChange?: (isTouch: boolean) => void; /** Callback to open a file in a new tab */ onOpenFile?: (relativePath: string) => void; } @@ -260,6 +264,8 @@ export const ReviewPanel: React.FC = ({ focusTrigger, isCreating = false, onStatsChange, + isTouchImmersive = false, + onTouchImmersiveChange, onOpenFile, }) => { const originFetchRef = useRef(null); @@ -378,8 +384,15 @@ export const ReviewPanel: React.FC = ({ ); const toggleImmersive = useCallback(() => { - setIsImmersive((prev) => !prev); - }, [setIsImmersive]); + setIsImmersive((prev) => { + const next = !prev; + if (next) { + // The in-panel immersive button defaults to keyboard-first navigation. + onTouchImmersiveChange?.(false); + } + return next; + }); + }, [onTouchImmersiveChange, setIsImmersive]); const reviewsByFilePath = useMemo(() => { const grouped = new Map(); @@ -1436,6 +1449,7 @@ export const ReviewPanel: React.FC = ({ selectedHunkId={selectedHunkId} onSelectHunk={setSelectedHunkId} onExit={toggleImmersive} + isTouchImmersive={isTouchImmersive} onReviewNote={onReviewNote} reviewActions={reviewActions} reviewsByFilePath={reviewsByFilePath} diff --git a/src/browser/components/WorkspaceActionsMenuContent.tsx b/src/browser/components/WorkspaceActionsMenuContent.tsx index e423fbb86c..edd83a29b0 100644 --- a/src/browser/components/WorkspaceActionsMenuContent.tsx +++ b/src/browser/components/WorkspaceActionsMenuContent.tsx @@ -1,6 +1,6 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { ArchiveIcon } from "./icons/ArchiveIcon"; -import { GitBranch, Link2, Pencil, Server } from "lucide-react"; +import { GitBranch, Link2, Maximize2, Pencil, Server } from "lucide-react"; import React from "react"; interface WorkspaceActionButtonProps { @@ -38,6 +38,8 @@ interface WorkspaceActionsMenuContentProps { onEditTitle?: (() => void) | null; /** Workspace-level settings action currently surfaced from the workspace menu bar. */ onConfigureMcp?: (() => void) | null; + /** Mobile workspace-header action: open immersive review in full-screen touch mode. */ + onOpenTouchFullscreenReview?: (() => void) | null; onForkChat?: ((anchorEl: HTMLElement) => void) | null; onShareTranscript?: (() => void) | null; onArchiveChat?: ((anchorEl: HTMLElement) => void) | null; @@ -82,6 +84,17 @@ export const WorkspaceActionsMenuContent: React.FC )} + {props.onOpenTouchFullscreenReview && !props.isMuxHelpChat && ( + } + onClick={(e) => { + e.stopPropagation(); + props.onCloseMenu(); + props.onOpenTouchFullscreenReview?.(); + }} + /> + )} {props.onForkChat && !props.isMuxHelpChat && ( = ({ } }, [workspaceId, openTerminalPopout, runtimeConfig, onOpenTerminal]); + const isTouchMobileScreen = + typeof window !== "undefined" && + window.matchMedia("(max-width: 768px) and (pointer: coarse)").matches; + + const handleOpenTouchFullscreenReview = useCallback(() => { + window.dispatchEvent( + createCustomEvent(CUSTOM_EVENTS.OPEN_TOUCH_REVIEW_IMMERSIVE, { + workspaceId, + }) + ); + }, [workspaceId]); + const handleOpenInEditor = useCallback(async () => { setEditorError(null); const result = await openInEditor(workspaceId, namedWorkspacePath, runtimeConfig); @@ -516,6 +528,9 @@ export const WorkspaceMenuBar: React.FC = ({ {/* Keep MCP configuration in the more actions menu to keep the workspace menu bar lean. */} setMcpModalOpen(true)} + onOpenTouchFullscreenReview={ + isTouchMobileScreen ? handleOpenTouchFullscreenReview : null + } onForkChat={(anchorEl) => { void handleForkChat(anchorEl); }} diff --git a/src/browser/stories/App.phoneViewports.stories.tsx b/src/browser/stories/App.phoneViewports.stories.tsx index d143db1ca4..764437f5ed 100644 --- a/src/browser/stories/App.phoneViewports.stories.tsx +++ b/src/browser/stories/App.phoneViewports.stories.tsx @@ -5,10 +5,14 @@ * Chromatic is configured to snapshot both light and dark themes. */ +import { within, waitFor } from "@storybook/test"; +import type { ComponentType } from "react"; + +import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; + import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; import { createAssistantMessage, createUserMessage, STABLE_TIMESTAMP } from "./mockFactory"; import { setupSimpleChatStory } from "./storyHelpers"; -import type { ComponentType } from "react"; import { blurActiveElement, waitForChatInputAutofocusDone, @@ -71,6 +75,23 @@ const MESSAGES = [ ), ] as const; +const TOUCH_REVIEW_IMMERSIVE_WORKSPACE_ID = "ws-iphone-17-pro-max-touch-review"; +const TOUCH_REVIEW_IMMERSIVE_DIFF = `diff --git a/src/mobile/review.tsx b/src/mobile/review.tsx +index 1111111..2222222 100644 +--- a/src/mobile/review.tsx ++++ b/src/mobile/review.tsx +@@ -10,6 +10,10 @@ export function ReviewPanel() { + return ( +
++

Touch review

+

Review hunk interactions on mobile.

++

Tap any changed line to add a note immediately.

+
+ ); + } +`; +const TOUCH_REVIEW_IMMERSIVE_NUMSTAT = "2\t0\tsrc/mobile/review.tsx"; + export default { ...appMeta, title: "App/PhoneViewports", @@ -142,3 +163,64 @@ export const IPhone17ProMax: AppStory = { await stabilizePhoneViewportStory(canvasElement); }, }; + +export const IPhone17ProMaxTouchReviewImmersive: AppStory = { + render: () => ( + + setupSimpleChatStory({ + workspaceId: TOUCH_REVIEW_IMMERSIVE_WORKSPACE_ID, + workspaceName: "mobile-review", + projectName: "mux", + messages: [...MESSAGES], + gitDiff: { + diffOutput: TOUCH_REVIEW_IMMERSIVE_DIFF, + numstatOutput: TOUCH_REVIEW_IMMERSIVE_NUMSTAT, + }, + }) + } + /> + ), + decorators: [IPhone17ProMaxDecorator], + parameters: { + ...appMeta.parameters, + chromatic: { + ...(appMeta.parameters?.chromatic ?? {}), + cropToViewport: true, + modes: { + dark: { theme: "dark", viewport: IPHONE_17_PRO_MAX, hasTouch: true }, + light: { theme: "light", viewport: IPHONE_17_PRO_MAX, hasTouch: true }, + }, + }, + }, + play: async ({ canvasElement }) => { + await stabilizePhoneViewportStory(canvasElement); + + window.dispatchEvent( + createCustomEvent(CUSTOM_EVENTS.OPEN_TOUCH_REVIEW_IMMERSIVE, { + workspaceId: TOUCH_REVIEW_IMMERSIVE_WORKSPACE_ID, + }) + ); + + const canvas = within(canvasElement); + await waitFor( + () => { + canvas.getByTestId("immersive-review-view"); + }, + { timeout: 10_000 } + ); + + await waitFor( + () => { + const immersiveView = canvas.getByTestId("immersive-review-view"); + within(immersiveView).getByText(/Tap any changed line to add a note immediately\./i); + if (canvas.queryByRole("heading", { name: "Notes" })) { + throw new Error("Touch immersive mode should hide the desktop notes sidebar."); + } + }, + { timeout: 10_000 } + ); + + blurActiveElement(); + }, +}; diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index 6bd15f6ab7..1516ad1a6a 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -87,6 +87,12 @@ export const CUSTOM_EVENTS = { */ ANALYTICS_REBUILD_TOAST: "mux:analyticsRebuildToast", + /** + * Event to open immersive code review in touch/mobile mode for a workspace. + * Detail: { workspaceId: string } + */ + OPEN_TOUCH_REVIEW_IMMERSIVE: "mux:openTouchReviewImmersive", + /** * Event to open the debug LLM request modal * No detail @@ -140,6 +146,9 @@ export interface CustomEventPayloads { message: string; title?: string; }; + [CUSTOM_EVENTS.OPEN_TOUCH_REVIEW_IMMERSIVE]: { + workspaceId: string; + }; [CUSTOM_EVENTS.OPEN_DEBUG_LLM_REQUEST]: never; // No payload }