From 6456f697c2629877548ac179fc4e2d638b25c416 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Tue, 7 Apr 2026 15:32:24 +0200 Subject: [PATCH 1/3] wip --- .../app/src/components/agent-input-area.tsx | 79 ++- packages/app/src/components/git-diff-pane.tsx | 631 ++++++++++++++---- packages/app/src/components/message-input.tsx | 26 +- packages/app/src/panels/agent-panel.tsx | 19 +- .../workspace/workspace-draft-agent-tab.tsx | 10 + .../src/utils/active-chat-composer.test.ts | 124 ++++ .../app/src/utils/active-chat-composer.ts | 171 +++++ .../src/utils/chat-reference-token.test.ts | 145 ++++ .../app/src/utils/chat-reference-token.ts | 141 ++++ packages/app/src/utils/diff-layout.test.ts | 12 + packages/app/src/utils/diff-layout.ts | 48 +- 11 files changed, 1277 insertions(+), 129 deletions(-) create mode 100644 packages/app/src/utils/active-chat-composer.test.ts create mode 100644 packages/app/src/utils/active-chat-composer.ts create mode 100644 packages/app/src/utils/chat-reference-token.test.ts create mode 100644 packages/app/src/utils/chat-reference-token.ts diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 1e41fd620..513f61c20 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -47,6 +47,11 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { submitAgentInput } from "@/components/agent-input-submit"; +import { + buildComposerInsertResult, + markActiveChatComposer, + registerActiveChatComposer, +} from "@/utils/active-chat-composer"; type QueuedMessage = { id: string; @@ -76,6 +81,8 @@ interface AgentInputAreaProps { onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void; /** Callback to expose a focus function to parent components (desktop only). */ onFocusInput?: (focus: () => void) => void; + /** Called when external insertion should activate the owning workspace tab. */ + onActivateTab?: () => void; /** Optional draft context for listing commands before an agent exists. */ commandDraftConfig?: DraftCommandConfig; /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ @@ -106,6 +113,7 @@ export function AgentInputArea({ autoFocus = false, onAddImages, onFocusInput, + onActivateTab, commandDraftConfig, onMessageSent, onComposerHeightChange, @@ -165,9 +173,14 @@ export function AgentInputArea({ const [sendError, setSendError] = useState(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); const messageInputRef = useRef(null); + const composerSelectionRef = useRef({ start: 0, end: 0 }); + const hasKnownComposerSelectionRef = useRef(false); + const isMessageInputFocusedRef = useRef(false); + const userInputRef = useRef(userInput); const keyboardHandlerIdRef = useRef( `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, ); + const composerId = `${serverId}:${agentId}`; const autocomplete = useAgentAutocomplete({ userInput, @@ -192,6 +205,10 @@ export function AgentInputArea({ setCursorIndex((current) => Math.min(current, userInput.length)); }, [userInput.length]); + useEffect(() => { + userInputRef.current = userInput; + }, [userInput]); + const { pickImages } = useImageAttachmentPicker(); const agentIdRef = useRef(agentId); const sendAgentMessageRef = useRef< @@ -212,7 +229,10 @@ export function AgentInputArea({ }, [addImages, onAddImages]); const focusInput = useCallback(() => { - if (Platform.OS !== "web") return; + if (Platform.OS !== "web") { + messageInputRef.current?.focus(); + return; + } focusWithRetries({ focus: () => messageInputRef.current?.focus(), isFocused: () => { @@ -226,6 +246,59 @@ export function AgentInputArea({ onFocusInput?.(focusInput); }, [focusInput, onFocusInput]); + const insertComposerText = useCallback( + (text: string): boolean => { + const token = text.trim(); + if (!token) { + return false; + } + + const result = buildComposerInsertResult({ + value: userInputRef.current, + token, + selection: composerSelectionRef.current, + hasKnownSelection: hasKnownComposerSelectionRef.current, + }); + if (result.text === userInputRef.current) { + return false; + } + + composerSelectionRef.current = result.selection; + hasKnownComposerSelectionRef.current = true; + userInputRef.current = result.text; + setCursorIndex(result.selection.start); + setUserInput(result.text); + + const applySelection = () => { + messageInputRef.current?.setSelection(result.selection); + }; + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(applySelection); + } else { + setTimeout(applySelection, 0); + } + return true; + }, + [], + ); + + useEffect(() => { + return registerActiveChatComposer({ + id: composerId, + handle: { + insertText: insertComposerText, + activateTab: onActivateTab, + }, + }); + }, [composerId, insertComposerText, onActivateTab]); + + useEffect(() => { + if (!isInputActive) { + return; + } + markActiveChatComposer(composerId); + }, [composerId, isInputActive]); + const submitMessage = useCallback( async (text: string, images?: ImageAttachment[]) => { onMessageSent?.(); @@ -723,11 +796,15 @@ export function AgentInputArea({ onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined} onKeyPress={handleCommandKeyPress} onSelectionChange={(selection) => { + composerSelectionRef.current = selection; + hasKnownComposerSelectionRef.current = true; setCursorIndex(selection.start); }} onFocusChange={(focused) => { + isMessageInputFocusedRef.current = focused; setIsMessageInputFocused(focused); if (focused) { + markActiveChatComposer(composerId); onAttentionInputFocus?.(); } }} diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 3d2348a67..93f6c525c 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -32,6 +32,7 @@ import { GitMerge, ListChevronsDownUp, ListChevronsUpDown, + Paperclip, Pilcrow, RefreshCcw, Upload, @@ -56,7 +57,11 @@ import { import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; -import { buildSplitDiffRows, type SplitDiffDisplayLine } from "@/utils/diff-layout"; +import { + buildSplitDiffRows, + type SplitDiffDisplayLine, + type SplitDiffRow, +} from "@/utils/diff-layout"; import { DropdownMenu, DropdownMenuContent, @@ -72,6 +77,16 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; +import { useToast } from "@/contexts/toast-context"; +import { appendTextTokenToComposer, insertIntoActiveChatComposer } from "@/utils/active-chat-composer"; +import { + buildFileChatReference, + buildHunkLineChatReference, +} from "@/utils/chat-reference-token"; +import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; +import { useDraftStore } from "@/stores/draft-store"; +import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { usePanelStore } from "@/stores/panel-store"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; @@ -132,59 +147,218 @@ interface DiffFileSectionProps { file: ParsedDiffFile; isExpanded: boolean; onToggle: (path: string) => void; + onAddFileReference?: (file: ParsedDiffFile) => void; onHeaderHeightChange?: (path: string, height: number) => void; testID?: string; } +interface ChatReferenceButtonProps { + accessibilityLabel: string; + tooltipLabel: string; + onPress: () => void; + testID?: string; +} + +type HunkChatActionMode = "hover" | "first-line"; + +function ChatReferenceButton({ + accessibilityLabel, + tooltipLabel, + onPress, + testID, +}: ChatReferenceButtonProps) { + const { theme } = useUnistyles(); + const iconSize = Platform.OS === "web" ? 14 : 16; + + return ( + + + [ + styles.chatReferenceButton, + (hovered || pressed) && styles.chatReferenceButtonHovered, + ]} + > + + + + + {tooltipLabel} + + + ); +} + +function DiffHunkHeaderRow({ + content, + gutterWidth, + testID, +}: { + content: string; + gutterWidth?: number; + testID?: string; +}) { + return ( + + {typeof gutterWidth === "number" ? ( + + ) : null} + + {content} + + + ); +} + +function LineNumberGutterSlot({ + gutterWidth, + lineNumber, + visible, + revealOnHover = false, + lineType, + onAddReference, + testID, +}: { + gutterWidth: number; + lineNumber: number | null; + visible: boolean; + revealOnHover?: boolean; + lineType: DiffLine["type"]; + onAddReference?: () => void; + testID?: string; +}) { + const { theme } = useUnistyles(); + const [isHovered, setIsHovered] = useState(false); + const iconSize = Platform.OS === "web" ? 14 : 16; + const showAction = Boolean(onAddReference) && (visible || (revealOnHover && isHovered)); + + const trigger = ( + setIsHovered(true) : undefined} + onHoverOut={revealOnHover ? () => setIsHovered(false) : undefined} + onPress={showAction ? onAddReference : undefined} + disabled={!showAction} + style={({ pressed }) => [ + styles.lineNumberGutter, + { width: gutterWidth }, + showAction && pressed && styles.chatReferenceButtonHovered, + ]} + > + {showAction ? ( + + + + ) : ( + + {lineNumber != null ? String(lineNumber) : ""} + + )} + + ); + + if (!showAction) { + return trigger; + } + + return ( + + {trigger} + + Add hunk to chat + + + ); +} + function DiffLineView({ line, lineNumber, gutterWidth, wrapLines, + hunkActionMode, + isFirstVisibleLineInHunk, + onAddHunkReference, + testID, }: { line: DiffLine; lineNumber: number | null; gutterWidth: number; wrapLines: boolean; + hunkActionMode: HunkChatActionMode; + isFirstVisibleLineInHunk: boolean; + onAddHunkReference?: () => void; + testID?: string; }) { + if (line.type === "header") { + return ( + + ); + } + return ( - - - - {lineNumber != null ? String(lineNumber) : ""} - - - {line.tokens && line.type !== "header" ? ( - - ) : ( - - {line.content || " "} - - )} - + {({ hovered, pressed }) => { + const showHunkAction = + Boolean(onAddHunkReference) && + (hunkActionMode === "first-line" + ? isFirstVisibleLineInHunk + : hovered || pressed); + + return ( + <> + + {line.tokens ? ( + + ) : ( + + {line.content || " "} + + )} + + ); + }} + ); } @@ -192,42 +366,39 @@ function SplitDiffCell({ line, gutterWidth, wrapLines, + hunkActionMode, + showFirstLineAction, + onAddHunkReference, showDivider = false, + testID, }: { line: SplitDiffDisplayLine | null; gutterWidth: number; wrapLines: boolean; + hunkActionMode?: HunkChatActionMode; + showFirstLineAction?: boolean; + onAddHunkReference?: () => void; showDivider?: boolean; + testID?: string; }) { - return ( - - - - {line?.lineNumber != null ? String(line.lineNumber) : ""} - - + const cellContent = (showHunkAction: boolean) => ( + <> + {line?.tokens ? ( ) : ( )} + + ); + + if (!line) { + return ( + + {cellContent(false)} + + ); + } + + return ( + + {({ hovered, pressed }) => + cellContent( + Boolean(onAddHunkReference) && + (hunkActionMode === "first-line" ? Boolean(showFirstLineAction) : hovered || pressed), + ) + } + + ); +} + +function SplitDiffRowView({ + row, + gutterWidth, + wrapLines, + hunkActionMode, + onAddHunkReference, + testID, +}: { + row: Extract; + gutterWidth: number; + wrapLines: boolean; + hunkActionMode: HunkChatActionMode; + onAddHunkReference?: () => void; + testID?: string; +}) { + return ( + + + ); } @@ -245,6 +490,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ file, isExpanded, onToggle, + onAddFileReference, onHeaderHeightChange, testID, }: DiffFileSectionProps) { @@ -266,60 +512,70 @@ const DiffFileHeader = memo(function DiffFileHeader({ }} testID={testID} > - [styles.fileHeader, pressed && styles.fileHeaderPressed]} - // Android: prevent parent pan/scroll gestures from canceling the tap release. - cancelable={false} - onPressIn={(event) => { - pressHandledRef.current = false; - pressInRef.current = { - ts: Date.now(), - pageX: event.nativeEvent.pageX, - pageY: event.nativeEvent.pageY, - }; - }} - onPressOut={(event) => { - if ( - Platform.OS !== "web" && - !pressHandledRef.current && - layoutYRef.current === 0 && - pressInRef.current - ) { - const durationMs = Date.now() - pressInRef.current.ts; - const dx = event.nativeEvent.pageX - pressInRef.current.pageX; - const dy = event.nativeEvent.pageY - pressInRef.current.pageY; - const distance = Math.hypot(dx, dy); - // Sticky headers on Android can emit pressIn/pressOut without onPress. - // Treat short, low-movement interactions as taps. - if (durationMs <= 500 && distance <= 12) { - toggleExpanded(); + + [styles.fileHeader, pressed && styles.fileHeaderPressed]} + // Android: prevent parent pan/scroll gestures from canceling the tap release. + cancelable={false} + onPressIn={(event) => { + pressHandledRef.current = false; + pressInRef.current = { + ts: Date.now(), + pageX: event.nativeEvent.pageX, + pageY: event.nativeEvent.pageY, + }; + }} + onPressOut={(event) => { + if ( + Platform.OS !== "web" && + !pressHandledRef.current && + layoutYRef.current === 0 && + pressInRef.current + ) { + const durationMs = Date.now() - pressInRef.current.ts; + const dx = event.nativeEvent.pageX - pressInRef.current.pageX; + const dy = event.nativeEvent.pageY - pressInRef.current.pageY; + const distance = Math.hypot(dx, dy); + // Sticky headers on Android can emit pressIn/pressOut without onPress. + // Treat short, low-movement interactions as taps. + if (durationMs <= 500 && distance <= 12) { + toggleExpanded(); + } } - } - }} - onPress={toggleExpanded} - > - - {file.path.split("/").pop()} - - {file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""} - - {file.isNew && ( - - New - - )} - {file.isDeleted && ( - - Deleted - - )} - - - +{file.additions} - -{file.deletions} - - + }} + onPress={toggleExpanded} + > + + {file.path.split("/").pop()} + + {file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""} + + {file.isNew && ( + + New + + )} + {file.isDeleted && ( + + Deleted + + )} + + + +{file.additions} + -{file.deletions} + + + {onAddFileReference ? ( + onAddFileReference(file)} + testID={testID ? `${testID}-add-to-chat` : undefined} + /> + ) : null} + ); }); @@ -328,12 +584,16 @@ function DiffFileBody({ file, layout, wrapLines, + hunkActionMode, + onAddHunkReference, onBodyHeightChange, testID, }: { file: ParsedDiffFile; layout: "unified" | "split"; wrapLines: boolean; + hunkActionMode: HunkChatActionMode; + onAddHunkReference?: (reference: string) => void; onBodyHeightChange?: (path: string, height: number) => void; testID?: string; }) { @@ -372,28 +632,37 @@ function DiffFileBody({ if (row.kind === "header") { return ( - {row.content} + ); } return ( - - - - + onAddHunkReference(row.chatReference) + : undefined + } + testID={testID ? `${testID}-hunk-${rowIndex}` : undefined} + /> ); }) : file.hunks.map((hunk, hunkIndex) => { let oldLineNo = hunk.oldStart; let newLineNo = hunk.newStart; + let hasVisibleLine = false; return hunk.lines.map((line, lineIndex) => { let lineNumber: number | null = null; + let isFirstVisibleLineInHunk = false; if (line.type === "remove") { lineNumber = oldLineNo; oldLineNo++; @@ -405,6 +674,10 @@ function DiffFileBody({ oldLineNo++; newLineNo++; } + if (line.type !== "header") { + isFirstVisibleLineInHunk = !hasVisibleLine; + hasVisibleLine = true; + } return ( + onAddHunkReference( + buildHunkLineChatReference({ + path: file.path, + hunk, + lineIndex, + }), + ) + : undefined + } + testID={testID ? `${testID}-hunk-${hunkIndex}-line-${lineIndex}` : undefined} /> ); }); @@ -467,7 +755,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const canUseSplitLayout = Platform.OS === "web" && !isMobile; + const hunkActionMode: HunkChatActionMode = + Platform.OS === "web" && !isMobile ? "hover" : "first-line"; const router = useRouter(); + const toast = useToast(); + const closeToAgent = usePanelStore((state) => state.closeToAgent); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [actionError, setActionError] = useState(null); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); @@ -492,6 +784,62 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace }); }, [changesPreferences.hideWhitespace, updateChangesPreferences]); + const handleInsertChatReference = useCallback( + (reference: string) => { + if (insertIntoActiveChatComposer(reference)) { + if (isMobile) { + closeToAgent(); + } + return; + } + + const resolvedWorkspaceId = workspaceId?.trim() || cwd.trim(); + if (!resolvedWorkspaceId) { + toast.error("Open a chat first"); + return; + } + + const draftId = generateDraftId(); + const draftKey = buildDraftStoreKey({ + serverId, + agentId: draftId, + draftId, + }); + useDraftStore.getState().saveDraftInput({ + draftKey, + draft: { + text: appendTextTokenToComposer({ value: "", token: reference }), + images: [], + }, + }); + + const route = prepareWorkspaceTab({ + serverId, + workspaceId: resolvedWorkspaceId, + target: { kind: "draft", draftId }, + }); + if (isMobile) { + closeToAgent(); + } + router.navigate(route as any); + }, + [closeToAgent, cwd, isMobile, router, serverId, toast, workspaceId], + ); + + const handleAddFileReference = useCallback( + (file: ParsedDiffFile) => { + handleInsertChatReference(buildFileChatReference(file.path)); + }, + [handleInsertChatReference], + ); + + const handleAddHunkReference = useCallback( + (reference: string) => { + handleInsertChatReference(reference); + }, + [handleInsertChatReference], + ); + const { status, isLoading: isStatusLoading, @@ -835,6 +1183,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi file={item.file} isExpanded={item.isExpanded} onToggle={handleToggleExpanded} + onAddFileReference={handleAddFileReference} onHeaderHeightChange={handleHeaderHeightChange} testID={`diff-file-${item.fileIndex}`} /> @@ -845,6 +1194,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi file={item.file} layout={effectiveLayout} wrapLines={wrapLines} + hunkActionMode={hunkActionMode} + onAddHunkReference={handleAddHunkReference} onBodyHeightChange={handleBodyHeightChange} testID={`diff-file-${item.fileIndex}-body`} /> @@ -852,9 +1203,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi }, [ effectiveLayout, + handleAddFileReference, + handleAddHunkReference, handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, + hunkActionMode, wrapLines, ], ); @@ -1517,17 +1871,24 @@ const styles = StyleSheet.create((theme) => ({ borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, - fileHeader: { + fileHeaderRow: { flexDirection: "row", alignItems: "center", - justifyContent: "space-between", + gap: theme.spacing[1], paddingLeft: theme.spacing[3], paddingRight: theme.spacing[2], paddingVertical: theme.spacing[2], - gap: theme.spacing[1], zIndex: 2, elevation: 2, }, + fileHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[1], + flex: 1, + minWidth: 0, + }, fileHeaderPressed: { opacity: 0.7, }, @@ -1642,6 +2003,13 @@ const styles = StyleSheet.create((theme) => ({ alignSelf: "stretch", justifyContent: "flex-start", }, + lineNumberGutterActionContent: { + height: theme.lineHeight.diff, + alignSelf: "stretch", + alignItems: "flex-end", + justifyContent: "center", + paddingRight: theme.spacing[2], + }, lineNumberText: { textAlign: "right", paddingRight: theme.spacing[2], @@ -1684,12 +2052,35 @@ const styles = StyleSheet.create((theme) => ({ headerLineText: { color: theme.colors.foregroundMuted, }, + hunkHeaderText: { + flexShrink: 1, + paddingRight: theme.spacing[2], + }, contextLineContainer: { backgroundColor: theme.colors.surface1, }, contextLineText: { color: theme.colors.foregroundMuted, }, + chatReferenceButton: { + alignItems: "center", + justifyContent: "center", + width: { + xs: 28, + sm: 28, + md: 24, + }, + height: { + xs: 28, + sm: 28, + md: 24, + }, + borderRadius: theme.borderRadius.base, + flexShrink: 0, + }, + chatReferenceButtonHovered: { + backgroundColor: theme.colors.surface3, + }, emptySplitCellText: { color: "transparent", }, diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index c94bd8fbd..272f11e7d 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -100,6 +100,7 @@ export interface MessageInputProps { export interface MessageInputRef { focus: () => void; blur: () => void; + setSelection: (selection: { start: number; end: number }) => void; runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean; /** * Web-only: return the underlying DOM element for focus assertions/retries. @@ -231,6 +232,7 @@ export const MessageInput = forwardRef(funct null, ); const isInputFocusedRef = useRef(false); + const webTextareaRef = useRef(null); useImperativeHandle(ref, () => ({ focus: () => { @@ -239,6 +241,28 @@ export const MessageInput = forwardRef(funct blur: () => { textInputRef.current?.blur?.(); }, + setSelection: ({ start, end }) => { + const clampedStart = Math.max(0, Math.trunc(start)); + const clampedEnd = Math.max(clampedStart, Math.trunc(end)); + + if (IS_WEB) { + const textarea = webTextareaRef.current as HTMLTextAreaElement | null; + textarea?.focus?.(); + if (textarea && typeof textarea.selectionStart === "number") { + textarea.selectionStart = clampedStart; + textarea.selectionEnd = clampedEnd; + } + return; + } + + const input = textInputRef.current as + | (TextInput & { + setNativeProps?: (props: { selection: { start: number; end: number } }) => void; + }) + | null; + input?.focus?.(); + input?.setNativeProps?.({ selection: { start: clampedStart, end: clampedEnd } }); + }, runKeyboardAction: (action) => { if (action === "focus") { textInputRef.current?.focus(); @@ -591,8 +615,6 @@ export const MessageInput = forwardRef(funct return null; }, []); - const webTextareaRef = useRef(null); - useLayoutEffect(() => { if (IS_WEB) { webTextareaRef.current = getWebTextArea() as HTMLElement | null; diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index debf2ff29..684f7bb66 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -40,6 +40,7 @@ import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useSessionStore, type Agent } from "@/stores/session-store"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; @@ -111,20 +112,30 @@ function useAgentPanelDescriptor( } function AgentPanel() { - const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext(); + const { serverId, workspaceId, tabId, target, isPaneFocused, openFileInWorkspace } = + usePaneContext(); invariant(target.kind === "agent", "AgentPanel requires agent target"); + const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); function openWorkspaceFile(input: { filePath: string }) { openFileInWorkspace(input.filePath); } const handleOpenWorkspaceFile = useStableEvent(openWorkspaceFile); + const handleActivateTab = useCallback(() => { + const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + if (!workspaceKey) { + return; + } + focusWorkspaceTab(workspaceKey, tabId); + }, [focusWorkspaceTab, serverId, tabId, workspaceId]); return ( ); @@ -155,11 +166,13 @@ function AgentPanelContent({ serverId, agentId, isPaneFocused, + onActivateTab, onOpenWorkspaceFile, }: { serverId: string; agentId: string; isPaneFocused: boolean; + onActivateTab?: () => void; onOpenWorkspaceFile?: (input: { filePath: string }) => void; }) { const resolvedAgentId = agentId.trim() || undefined; @@ -199,6 +212,7 @@ function AgentPanelContent({ serverId={resolvedServerId} agentId={resolvedAgentId} isPaneFocused={isPaneFocused} + onActivateTab={onActivateTab} client={runtimeClient} isConnected={runtimeIsConnected} connectionStatus={connectionStatus} @@ -211,6 +225,7 @@ function AgentPanelBody({ serverId, agentId, isPaneFocused, + onActivateTab, client, isConnected, connectionStatus, @@ -219,6 +234,7 @@ function AgentPanelBody({ serverId: string; agentId?: string; isPaneFocused: boolean; + onActivateTab?: () => void; client: NonNullable>; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; @@ -760,6 +776,7 @@ function AgentPanelBody({ agentId={agentId} serverId={serverId} isInputActive={isPaneFocused} + onActivateTab={onActivateTab} value={agentInputDraft.text} onChangeText={agentInputDraft.setText} images={agentInputDraft.images} diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index c681c62eb..893ca95ef 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -11,6 +11,7 @@ import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import type { Agent } from "@/stores/session-store"; import { encodeImages } from "@/utils/encode-images"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; @@ -51,6 +52,7 @@ export function WorkspaceDraftAgentTab({ }: WorkspaceDraftAgentTabProps) { const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); + const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); const draftInput = useAgentInputDraft( buildDraftStoreKey({ @@ -304,6 +306,13 @@ export function WorkspaceDraftAgentTab({ }, [setDraftFeatureValue], ); + const handleActivateTab = useCallback(() => { + const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + if (!workspaceKey) { + return; + } + focusWorkspaceTab(workspaceKey, tabId); + }, [focusWorkspaceTab, serverId, tabId, workspaceId]); return ( @@ -341,6 +350,7 @@ export function WorkspaceDraftAgentTab({ agentId={tabId} serverId={serverId} isInputActive={isPaneFocused} + onActivateTab={handleActivateTab} onSubmitMessage={handleCreateFromInput} isSubmitLoading={isSubmitting} blurOnSubmit={true} diff --git a/packages/app/src/utils/active-chat-composer.test.ts b/packages/app/src/utils/active-chat-composer.test.ts new file mode 100644 index 000000000..5a31a40bf --- /dev/null +++ b/packages/app/src/utils/active-chat-composer.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + __activeChatComposerTestUtils, + appendTextTokenToComposer, + buildComposerInsertResult, + insertIntoActiveChatComposer, + insertTextAtSelection, + markActiveChatComposer, + registerActiveChatComposer, +} from "./active-chat-composer"; + +afterEach(() => { + __activeChatComposerTestUtils.reset(); +}); + +describe("insertTextAtSelection", () => { + it("replaces the selected text and collapses the caret after the inserted token", () => { + const result = insertTextAtSelection({ + value: "review old text now", + insertedText: "src/example.ts:12-18", + selection: { start: 7, end: 15 }, + }); + + expect(result).toEqual({ + text: "review src/example.ts:12-18 now", + selection: { start: 27, end: 27 }, + }); + }); + + it("inserts at a collapsed caret", () => { + const result = insertTextAtSelection({ + value: "review this", + insertedText: "src/example.ts:9", + selection: { start: 7, end: 7 }, + }); + + expect(result).toEqual({ + text: "review src/example.ts:9 this", + selection: { start: 24, end: 24 }, + }); + }); + + it("adds a trailing space when inserting at the end", () => { + const result = insertTextAtSelection({ + value: "review", + insertedText: "src/example.ts:9", + selection: { start: 6, end: 6 }, + }); + + expect(result).toEqual({ + text: "reviewsrc/example.ts:9 ", + selection: { start: 23, end: 23 }, + }); + }); +}); + +describe("appendTextTokenToComposer", () => { + it("appends a token with minimal whitespace normalization", () => { + expect( + appendTextTokenToComposer({ + value: "review", + token: "src/example.ts:12-18", + }), + ).toBe("review src/example.ts:12-18 "); + }); + + it("does not add extra blank lines when the composer already ends with a newline", () => { + expect( + appendTextTokenToComposer({ + value: "review this\n", + token: "src/example.ts", + }), + ).toBe("review this\nsrc/example.ts "); + }); +}); + +describe("buildComposerInsertResult", () => { + it("uses the stored selection when one is known", () => { + const result = buildComposerInsertResult({ + value: "review old text now", + token: "src/example.ts:12-18", + selection: { start: 7, end: 15 }, + hasKnownSelection: true, + }); + + expect(result).toEqual({ + text: "review src/example.ts:12-18 now", + selection: { start: 27, end: 27 }, + }); + }); + + it("falls back to append when no prior selection is known", () => { + const result = buildComposerInsertResult({ + value: "review this", + token: "src/example.ts", + selection: { start: 0, end: 0 }, + hasKnownSelection: false, + }); + + expect(result).toEqual({ + text: "review this src/example.ts ", + selection: { start: 27, end: 27 }, + }); + }); +}); + +describe("active chat composer registry", () => { + it("inserts into the last active composer", () => { + const insertText = vi.fn(() => true); + const activateTab = vi.fn(); + const dispose = registerActiveChatComposer({ + id: "server:agent", + handle: { insertText, activateTab }, + }); + + markActiveChatComposer("server:agent"); + + expect(insertIntoActiveChatComposer("src/example.ts")).toBe(true); + expect(insertText).toHaveBeenCalledWith("src/example.ts"); + expect(activateTab).toHaveBeenCalledTimes(1); + + dispose(); + }); +}); diff --git a/packages/app/src/utils/active-chat-composer.ts b/packages/app/src/utils/active-chat-composer.ts new file mode 100644 index 000000000..ebeff195e --- /dev/null +++ b/packages/app/src/utils/active-chat-composer.ts @@ -0,0 +1,171 @@ +export interface ComposerSelection { + start: number; + end: number; +} + +export interface InsertTextAtSelectionResult { + text: string; + selection: ComposerSelection; +} + +interface ActiveChatComposerHandle { + insertText: (text: string) => boolean; + activateTab?: () => void; +} + +const activeChatComposerHandles = new Map(); +let activeChatComposerId: string | null = null; + +function clampSelectionIndex(value: number, max: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.min(max, Math.trunc(value))); +} + +function withTrailingInsertionSpace(input: { + value: string; + insertedText: string; + selectionEnd: number; +}): string { + const insertedText = input.insertedText ?? ""; + if (!insertedText || /\s$/.test(insertedText)) { + return insertedText; + } + + const nextCharacter = input.value.slice(input.selectionEnd, input.selectionEnd + 1); + if (!nextCharacter || !/\s/.test(nextCharacter)) { + return `${insertedText} `; + } + + return insertedText; +} + +export function insertTextAtSelection(input: { + value: string; + insertedText: string; + selection: ComposerSelection; +}): InsertTextAtSelectionResult { + const value = input.value ?? ""; + const insertedText = input.insertedText ?? ""; + const start = clampSelectionIndex(input.selection.start, value.length); + const end = clampSelectionIndex(input.selection.end, value.length); + const selectionStart = Math.min(start, end); + const selectionEnd = Math.max(start, end); + const normalizedInsertedText = withTrailingInsertionSpace({ + value, + insertedText: input.insertedText ?? "", + selectionEnd, + }); + const text = + value.slice(0, selectionStart) + + normalizedInsertedText + + value.slice(selectionEnd, value.length); + const cursor = selectionStart + normalizedInsertedText.length; + + return { + text, + selection: { start: cursor, end: cursor }, + }; +} + +export function buildComposerInsertResult(input: { + value: string; + token: string; + selection: ComposerSelection; + hasKnownSelection: boolean; +}): InsertTextAtSelectionResult { + const token = input.token.trim(); + if (!token) { + const clampedStart = clampSelectionIndex(input.selection.start, input.value.length); + const clampedEnd = clampSelectionIndex(input.selection.end, input.value.length); + return { + text: input.value, + selection: { + start: Math.min(clampedStart, clampedEnd), + end: Math.max(clampedStart, clampedEnd), + }, + }; + } + + if (input.hasKnownSelection) { + return insertTextAtSelection({ + value: input.value, + insertedText: token, + selection: input.selection, + }); + } + + const text = appendTextTokenToComposer({ + value: input.value, + token, + }); + return { + text, + selection: { + start: text.length, + end: text.length, + }, + }; +} + +export function appendTextTokenToComposer(input: { value: string; token: string }): string { + const value = input.value ?? ""; + const token = input.token.trim(); + if (!token) { + return value; + } + + const leadingWhitespace = value.length > 0 && !/\s$/.test(value) ? " " : ""; + const trailingWhitespace = /\s$/.test(token) ? "" : " "; + return `${value}${leadingWhitespace}${token}${trailingWhitespace}`; +} + +export function registerActiveChatComposer(input: { + id: string; + handle: ActiveChatComposerHandle; +}): () => void { + activeChatComposerHandles.set(input.id, input.handle); + + return () => { + const current = activeChatComposerHandles.get(input.id); + if (current === input.handle) { + activeChatComposerHandles.delete(input.id); + } + if (activeChatComposerId === input.id) { + activeChatComposerId = null; + } + }; +} + +export function markActiveChatComposer(id: string): void { + if (!activeChatComposerHandles.has(id)) { + return; + } + activeChatComposerId = id; +} + +export function insertIntoActiveChatComposer(text: string): boolean { + if (!activeChatComposerId) { + return false; + } + + const handle = activeChatComposerHandles.get(activeChatComposerId); + if (!handle) { + activeChatComposerId = null; + return false; + } + + const inserted = handle.insertText(text); + if (inserted) { + handle.activateTab?.(); + } + return inserted; +} + +export const __activeChatComposerTestUtils = { + reset() { + activeChatComposerHandles.clear(); + activeChatComposerId = null; + }, +}; diff --git a/packages/app/src/utils/chat-reference-token.test.ts b/packages/app/src/utils/chat-reference-token.test.ts new file mode 100644 index 000000000..3ccf9c0ac --- /dev/null +++ b/packages/app/src/utils/chat-reference-token.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + buildDiffRangeChatReference, + buildFileChatReference, + buildHunkChatReference, + buildHunkLineChatReference, +} from "./chat-reference-token"; + +describe("buildFileChatReference", () => { + it("returns the file path unchanged", () => { + expect(buildFileChatReference("src/example.ts")).toBe("src/example.ts"); + }); +}); + +describe("buildHunkChatReference", () => { + it("formats a single-line hunk as path:line", () => { + expect( + buildHunkChatReference({ + path: "src/example.ts", + hunk: { + oldStart: 12, + oldCount: 1, + newStart: 12, + newCount: 1, + lines: [], + }, + }), + ).toBe("src/example.ts:12"); + }); + + it("formats a multi-line hunk as path:start-end", () => { + expect( + buildHunkChatReference({ + path: "src/example.ts", + hunk: { + oldStart: 12, + oldCount: 4, + newStart: 12, + newCount: 7, + lines: [], + }, + }), + ).toBe("src/example.ts:12-18"); + }); + + it("uses the new-side range for add-only hunks", () => { + expect( + buildHunkChatReference({ + path: "src/example.ts", + hunk: { + oldStart: 0, + oldCount: 0, + newStart: 42, + newCount: 3, + lines: [], + }, + }), + ).toBe("src/example.ts:42-44"); + }); + + it("falls back to the old-side range for delete-only hunks", () => { + expect( + buildHunkChatReference({ + path: "src/example.ts", + hunk: { + oldStart: 18, + oldCount: 2, + newStart: 18, + newCount: 0, + lines: [], + }, + }), + ).toBe("src/example.ts:18-19"); + }); +}); + +describe("buildDiffRangeChatReference", () => { + it("prefers the new-side range when it exists", () => { + expect( + buildDiffRangeChatReference({ + path: "src/example.ts", + oldStart: 83, + oldCount: 2, + newStart: 102, + newCount: 1, + }), + ).toBe("src/example.ts:102"); + }); +}); + +describe("buildHunkLineChatReference", () => { + it("uses the local add/remove block instead of the whole hunk", () => { + const hunk = { + oldStart: 67, + oldCount: 39, + newStart: 70, + newCount: 36, + lines: [ + { type: "header" as const, content: "@@ -67,39 +70,36 @@" }, + { type: "context" as const, content: "unchanged" }, + { type: "remove" as const, content: "old one" }, + { type: "remove" as const, content: "old two" }, + { type: "add" as const, content: "new one" }, + { type: "context" as const, content: "after" }, + ], + }; + + expect( + buildHunkLineChatReference({ + path: "src/example.ts", + hunk, + lineIndex: 2, + }), + ).toBe("src/example.ts:71"); + + expect( + buildHunkLineChatReference({ + path: "src/example.ts", + hunk, + lineIndex: 3, + }), + ).toBe("src/example.ts:71"); + }); + + it("uses the hovered context line for context rows", () => { + const hunk = { + oldStart: 10, + oldCount: 2, + newStart: 10, + newCount: 2, + lines: [ + { type: "header" as const, content: "@@ -10,2 +10,2 @@" }, + { type: "context" as const, content: "same line" }, + ], + }; + + expect( + buildHunkLineChatReference({ + path: "src/example.ts", + hunk, + lineIndex: 1, + }), + ).toBe("src/example.ts:10"); + }); +}); diff --git a/packages/app/src/utils/chat-reference-token.ts b/packages/app/src/utils/chat-reference-token.ts new file mode 100644 index 000000000..758018b04 --- /dev/null +++ b/packages/app/src/utils/chat-reference-token.ts @@ -0,0 +1,141 @@ +import type { DiffHunk } from "@/hooks/use-checkout-diff-query"; + +function formatLineRange(input: { path: string; start: number; count: number }): string { + const count = Math.max(1, input.count); + if (count <= 1) { + return `${input.path}:${input.start}`; + } + const end = input.start + count - 1; + return `${input.path}:${input.start}-${end}`; +} + +export function buildFileChatReference(path: string): string { + return path; +} + +export function buildDiffRangeChatReference(input: { + path: string; + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; +}): string { + if (input.newCount > 0) { + return formatLineRange({ + path: input.path, + start: input.newStart, + count: input.newCount, + }); + } + + return formatLineRange({ + path: input.path, + start: input.oldStart, + count: input.oldCount, + }); +} + +export function buildHunkChatReference(input: { path: string; hunk: DiffHunk }): string { + const { hunk, path } = input; + return buildDiffRangeChatReference({ + path, + oldStart: hunk.oldStart, + oldCount: hunk.oldCount, + newStart: hunk.newStart, + newCount: hunk.newCount, + }); +} + +function isChangeLineType(type: DiffHunk["lines"][number]["type"]): boolean { + return type === "add" || type === "remove"; +} + +export function buildHunkLineChatReference(input: { + path: string; + hunk: DiffHunk; + lineIndex: number; +}): string { + const { hunk, lineIndex, path } = input; + const line = hunk.lines[lineIndex]; + if (!line || line.type === "header") { + return buildHunkChatReference({ path, hunk }); + } + + let oldLineNo = hunk.oldStart; + let newLineNo = hunk.newStart; + const positions = hunk.lines.map((currentLine) => { + const oldLineNumber = + currentLine.type === "remove" || currentLine.type === "context" ? oldLineNo : null; + const newLineNumber = + currentLine.type === "add" || currentLine.type === "context" ? newLineNo : null; + + if (currentLine.type === "remove") { + oldLineNo += 1; + } else if (currentLine.type === "add") { + newLineNo += 1; + } else if (currentLine.type === "context") { + oldLineNo += 1; + newLineNo += 1; + } + + return { + type: currentLine.type, + oldLineNumber, + newLineNumber, + }; + }); + + const currentPosition = positions[lineIndex]; + if (!currentPosition) { + return buildHunkChatReference({ path, hunk }); + } + + if (currentPosition.type === "context") { + return buildDiffRangeChatReference({ + path, + oldStart: currentPosition.oldLineNumber ?? currentPosition.newLineNumber ?? hunk.oldStart, + oldCount: currentPosition.oldLineNumber != null ? 1 : 0, + newStart: currentPosition.newLineNumber ?? currentPosition.oldLineNumber ?? hunk.newStart, + newCount: currentPosition.newLineNumber != null ? 1 : 0, + }); + } + + let startIndex = lineIndex; + while (startIndex > 1 && isChangeLineType(hunk.lines[startIndex - 1]?.type)) { + startIndex -= 1; + } + + let endIndex = lineIndex; + while (endIndex + 1 < hunk.lines.length && isChangeLineType(hunk.lines[endIndex + 1]?.type)) { + endIndex += 1; + } + + let oldStart: number | null = null; + let oldCount = 0; + let newStart: number | null = null; + let newCount = 0; + + for (let index = startIndex; index <= endIndex; index += 1) { + const position = positions[index]; + if (!position) { + continue; + } + if (position.type === "remove" && position.oldLineNumber != null) { + oldStart ??= position.oldLineNumber; + oldCount += 1; + } else if (position.type === "add" && position.newLineNumber != null) { + newStart ??= position.newLineNumber; + newCount += 1; + } + } + + const fallbackStart = newStart ?? oldStart ?? hunk.newStart ?? hunk.oldStart; + + return buildDiffRangeChatReference({ + path, + oldStart: oldStart ?? fallbackStart, + oldCount, + newStart: newStart ?? fallbackStart, + newCount, + }); +} diff --git a/packages/app/src/utils/diff-layout.test.ts b/packages/app/src/utils/diff-layout.test.ts index c5e756d81..6a8df3584 100644 --- a/packages/app/src/utils/diff-layout.test.ts +++ b/packages/app/src/utils/diff-layout.test.ts @@ -37,11 +37,17 @@ describe("buildSplitDiffRows", () => { expect(rows).toHaveLength(3); expect(rows[1]).toMatchObject({ kind: "pair", + hunkIndex: 0, + isFirstVisibleLineInHunk: true, + chatReference: "example.ts:10-11", left: { type: "remove", content: "before one", lineNumber: 10 }, right: { type: "add", content: "after one", lineNumber: 10 }, }); expect(rows[2]).toMatchObject({ kind: "pair", + hunkIndex: 0, + isFirstVisibleLineInHunk: false, + chatReference: "example.ts:10-11", left: { type: "remove", content: "before two", lineNumber: 11 }, right: { type: "add", content: "after two", lineNumber: 11 }, }); @@ -59,6 +65,9 @@ describe("buildSplitDiffRows", () => { expect(rows[2]).toMatchObject({ kind: "pair", + hunkIndex: 0, + isFirstVisibleLineInHunk: false, + chatReference: "example.ts:10-11", left: null, right: { type: "add", content: "after two", lineNumber: 11 }, }); @@ -74,6 +83,9 @@ describe("buildSplitDiffRows", () => { expect(rows[1]).toMatchObject({ kind: "pair", + hunkIndex: 0, + isFirstVisibleLineInHunk: true, + chatReference: "example.ts:10", left: { type: "context", content: "same line", lineNumber: 10 }, right: { type: "context", content: "same line", lineNumber: 10 }, }); diff --git a/packages/app/src/utils/diff-layout.ts b/packages/app/src/utils/diff-layout.ts index b5746eab3..421f5b51b 100644 --- a/packages/app/src/utils/diff-layout.ts +++ b/packages/app/src/utils/diff-layout.ts @@ -1,4 +1,5 @@ import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query"; +import { buildDiffRangeChatReference } from "./chat-reference-token"; export interface SplitDiffDisplayLine { type: DiffLine["type"]; @@ -11,9 +12,13 @@ export type SplitDiffRow = | { kind: "header"; content: string; + hunkIndex: number; } | { kind: "pair"; + hunkIndex: number; + isFirstVisibleLineInHunk: boolean; + chatReference: string; left: SplitDiffDisplayLine | null; right: SplitDiffDisplayLine | null; }; @@ -64,24 +69,51 @@ function toDisplayLine(input: { export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { const rows: SplitDiffRow[] = []; - for (const hunk of file.hunks) { + for (const [hunkIndex, hunk] of file.hunks.entries()) { let oldLineNo = hunk.oldStart; let newLineNo = hunk.newStart; + let hasVisibleLine = false; rows.push({ kind: "header", content: hunk.lines[0]?.type === "header" ? hunk.lines[0].content : "@@", + hunkIndex, }); let pendingRemovals: Array<{ line: DiffLine; oldLineNumber: number }> = []; let pendingAdditions: Array<{ line: DiffLine; newLineNumber: number }> = []; + const pushPairRow = (input: { + chatReference: string; + left: SplitDiffDisplayLine | null; + right: SplitDiffDisplayLine | null; + }) => { + rows.push({ + kind: "pair", + hunkIndex, + isFirstVisibleLineInHunk: !hasVisibleLine, + chatReference: input.chatReference, + left: input.left, + right: input.right, + }); + hasVisibleLine = true; + }; + const flushPendingRows = () => { const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length); + const fallbackStart = + pendingAdditions[0]?.newLineNumber ?? pendingRemovals[0]?.oldLineNumber ?? hunk.newStart; + const chatReference = buildDiffRangeChatReference({ + path: file.path, + oldStart: pendingRemovals[0]?.oldLineNumber ?? fallbackStart, + oldCount: pendingRemovals.length, + newStart: pendingAdditions[0]?.newLineNumber ?? fallbackStart, + newCount: pendingAdditions.length, + }); for (let index = 0; index < pairCount; index += 1) { const removal = pendingRemovals[index] ?? null; const addition = pendingAdditions[index] ?? null; - rows.push({ - kind: "pair", + pushPairRow({ + chatReference, left: removal ? toDisplayLine({ line: removal.line, @@ -120,8 +152,14 @@ export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { flushPendingRows(); if (line.type === "context") { - rows.push({ - kind: "pair", + pushPairRow({ + chatReference: buildDiffRangeChatReference({ + path: file.path, + oldStart: oldLineNo, + oldCount: 1, + newStart: newLineNo, + newCount: 1, + }), left: toDisplayLine({ line, oldLineNumber: oldLineNo, From 3530869604370ad07e0c96ffaf3fdfb0002fda0e Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Tue, 7 Apr 2026 17:12:48 +0200 Subject: [PATCH 2/3] more work --- packages/app/src/components/diff-scroll.tsx | 5 +- .../app/src/components/diff-scroll.web.tsx | 12 +- packages/app/src/components/git-diff-pane.tsx | 279 +++++++++++++----- .../src/utils/chat-reference-token.test.ts | 58 ++++ .../app/src/utils/chat-reference-token.ts | 27 ++ packages/app/src/utils/diff-layout.test.ts | 78 ++++- packages/app/src/utils/diff-layout.ts | 50 +++- 7 files changed, 409 insertions(+), 100 deletions(-) diff --git a/packages/app/src/components/diff-scroll.tsx b/packages/app/src/components/diff-scroll.tsx index ab5feb77b..0553b4753 100644 --- a/packages/app/src/components/diff-scroll.tsx +++ b/packages/app/src/components/diff-scroll.tsx @@ -14,6 +14,7 @@ interface DiffScrollProps { children: React.ReactNode; scrollViewWidth: number; onScrollViewWidthChange: (width: number) => void; + onScroll?: (event: NativeSyntheticEvent) => void; style?: StyleProp; contentContainerStyle?: StyleProp; } @@ -22,6 +23,7 @@ export function DiffScroll({ children, scrollViewWidth, onScrollViewWidthChange, + onScroll, style, contentContainerStyle, }: DiffScrollProps) { @@ -57,8 +59,9 @@ export function DiffScroll({ if (horizontalScroll) { horizontalScroll.registerScrollOffset(scrollId, offsetX); } + onScroll?.(event); }, - [horizontalScroll, scrollId], + [horizontalScroll, onScroll, scrollId], ); return ( diff --git a/packages/app/src/components/diff-scroll.web.tsx b/packages/app/src/components/diff-scroll.web.tsx index 65c8dce2d..826f99ea2 100644 --- a/packages/app/src/components/diff-scroll.web.tsx +++ b/packages/app/src/components/diff-scroll.web.tsx @@ -1,9 +1,17 @@ -import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; +import { + ScrollView, + type LayoutChangeEvent, + type NativeSyntheticEvent, + type NativeScrollEvent, + type StyleProp, + type ViewStyle, +} from "react-native"; interface DiffScrollProps { children: React.ReactNode; scrollViewWidth: number; onScrollViewWidthChange: (width: number) => void; + onScroll?: (event: NativeSyntheticEvent) => void; style?: StyleProp; contentContainerStyle?: StyleProp; } @@ -11,6 +19,7 @@ interface DiffScrollProps { export function DiffScroll({ children, onScrollViewWidthChange, + onScroll, style, contentContainerStyle, }: DiffScrollProps) { @@ -21,6 +30,7 @@ export function DiffScroll({ showsHorizontalScrollIndicator style={style} contentContainerStyle={contentContainerStyle} + onScroll={onScroll} onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)} > {children} diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 944f035d3..1df24b593 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -15,6 +15,7 @@ import { Pressable, FlatList, Platform, + type GestureResponderEvent, type LayoutChangeEvent, type NativeSyntheticEvent, type NativeScrollEvent, @@ -149,6 +150,7 @@ interface DiffFileSectionProps { isExpanded: boolean; onToggle: (path: string) => void; onAddFileReference?: (file: ParsedDiffFile) => void; + onClearArmedLine?: () => void; onHeaderHeightChange?: (path: string, height: number) => void; testID?: string; } @@ -160,7 +162,7 @@ interface ChatReferenceButtonProps { testID?: string; } -type HunkChatActionMode = "hover" | "first-line"; +type HunkChatActionMode = "hover" | "tap-reveal"; function ChatReferenceButton({ accessibilityLabel, @@ -218,68 +220,77 @@ function DiffHunkHeaderRow({ function LineNumberGutterSlot({ gutterWidth, lineNumber, - visible, - revealOnHover = false, + showAction, lineType, onAddReference, + onPressLineNumber, + onHoverLine, + onLeaveLine, testID, }: { gutterWidth: number; lineNumber: number | null; - visible: boolean; - revealOnHover?: boolean; + showAction: boolean; lineType: DiffLine["type"]; onAddReference?: () => void; + onPressLineNumber?: () => void; + onHoverLine?: () => void; + onLeaveLine?: () => void; testID?: string; }) { const { theme } = useUnistyles(); - const [isHovered, setIsHovered] = useState(false); const iconSize = Platform.OS === "web" ? 14 : 16; - const showAction = Boolean(onAddReference) && (visible || (revealOnHover && isHovered)); + const isInteractive = Boolean(onAddReference) && showAction; + const handlePress = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + onAddReference?.(); + }, + [onAddReference], + ); - const trigger = ( - setIsHovered(true) : undefined} - onHoverOut={revealOnHover ? () => setIsHovered(false) : undefined} - onPress={showAction ? onAddReference : undefined} - disabled={!showAction} - style={({ pressed }) => [ - styles.lineNumberGutter, - { width: gutterWidth }, - showAction && pressed && styles.chatReferenceButtonHovered, - ]} + return ( + - {showAction ? ( - - - - ) : ( - + [ + styles.lineNumberGutter, + { width: gutterWidth }, + isInteractive && pressed && styles.chatReferenceButtonHovered, ]} > - {lineNumber != null ? String(lineNumber) : ""} - - )} - - ); - - if (!showAction) { - return trigger; - } - - return ( - - {trigger} - - Add hunk to chat - + {isInteractive ? ( + + + + ) : ( + + {lineNumber != null ? String(lineNumber) : ""} + + )} + + + {isInteractive ? ( + + Add hunk to chat + + ) : null} ); } @@ -290,7 +301,12 @@ function DiffLineView({ gutterWidth, wrapLines, hunkActionMode, - isFirstVisibleLineInHunk, + lineKey, + hoveredLineKey, + onHoverLine, + onLeaveLine, + armedLineKey, + onArmLine, onAddHunkReference, testID, }: { @@ -299,7 +315,12 @@ function DiffLineView({ gutterWidth: number; wrapLines: boolean; hunkActionMode: HunkChatActionMode; - isFirstVisibleLineInHunk: boolean; + lineKey: string; + hoveredLineKey: string | null; + onHoverLine?: (lineKey: string) => void; + onLeaveLine?: () => void; + armedLineKey: string | null; + onArmLine?: (lineKey: string) => void; onAddHunkReference?: () => void; testID?: string; }) { @@ -315,6 +336,8 @@ function DiffLineView({ return ( onHoverLine?.(lineKey) : undefined} + onHoverOut={Platform.OS === "web" ? onLeaveLine : undefined} style={[ styles.diffLineContainer, line.type === "add" && styles.addLineContainer, @@ -326,19 +349,29 @@ function DiffLineView({ {({ hovered, pressed }) => { const showHunkAction = Boolean(onAddHunkReference) && - (hunkActionMode === "first-line" - ? isFirstVisibleLineInHunk - : hovered || pressed); + (hunkActionMode === "tap-reveal" + ? armedLineKey === lineKey + : hoveredLineKey === lineKey || hovered || pressed); return ( <> { + if (armedLineKey !== lineKey) { + onArmLine?.(lineKey); + } + } + : undefined + } + onHoverLine={hunkActionMode === "hover" ? () => onHoverLine?.(lineKey) : undefined} + onLeaveLine={hunkActionMode === "hover" ? onLeaveLine : undefined} testID={testID ? `${testID}-add-to-chat` : undefined} /> {line.tokens ? ( @@ -368,6 +401,10 @@ function SplitDiffCell({ gutterWidth, wrapLines, hunkActionMode, + lineKey, + hoveredLineKey, + onHoverLine, + onLeaveLine, showFirstLineAction, onAddHunkReference, showDivider = false, @@ -377,6 +414,10 @@ function SplitDiffCell({ gutterWidth: number; wrapLines: boolean; hunkActionMode?: HunkChatActionMode; + lineKey?: string; + hoveredLineKey: string | null; + onHoverLine?: (lineKey: string) => void; + onLeaveLine?: () => void; showFirstLineAction?: boolean; onAddHunkReference?: () => void; showDivider?: boolean; @@ -387,10 +428,13 @@ function SplitDiffCell({ onHoverLine?.(lineKey) : undefined + } + onLeaveLine={hunkActionMode === "hover" ? onLeaveLine : undefined} testID={testID ? `${testID}-add-to-chat` : undefined} /> {line?.tokens ? ( @@ -421,7 +465,7 @@ function SplitDiffCell({ styles.emptySplitCell, ]} > - {cellContent(false)} + {cellContent(false)} ); } @@ -435,12 +479,22 @@ function SplitDiffCell({ line?.type === "remove" && styles.removeLineContainer, line?.type === "context" && styles.contextLineContainer, ]} + onHoverIn={ + Platform.OS === "web" && lineKey ? () => onHoverLine?.(lineKey) : undefined + } + onHoverOut={Platform.OS === "web" ? onLeaveLine : undefined} testID={testID} > {({ hovered, pressed }) => - cellContent( - Boolean(onAddHunkReference) && - (hunkActionMode === "first-line" ? Boolean(showFirstLineAction) : hovered || pressed), + ( + + {cellContent( + Boolean(onAddHunkReference) && + (hunkActionMode === "tap-reveal" + ? Boolean(showFirstLineAction) + : hoveredLineKey === lineKey || hovered || pressed), + )} + ) } @@ -452,6 +506,9 @@ function SplitDiffRowView({ gutterWidth, wrapLines, hunkActionMode, + hoveredLineKey, + onHoverLine, + onLeaveLine, onAddHunkReference, testID, }: { @@ -459,6 +516,9 @@ function SplitDiffRowView({ gutterWidth: number; wrapLines: boolean; hunkActionMode: HunkChatActionMode; + hoveredLineKey: string | null; + onHoverLine?: (lineKey: string) => void; + onLeaveLine?: () => void; onAddHunkReference?: () => void; testID?: string; }) { @@ -469,7 +529,11 @@ function SplitDiffRowView({ gutterWidth={gutterWidth} wrapLines={wrapLines} hunkActionMode={hunkActionMode} - showFirstLineAction={row.isFirstVisibleLineInHunk && row.left !== null} + lineKey={row.left ? `${testID ?? "split-row"}:left` : undefined} + hoveredLineKey={hoveredLineKey} + onHoverLine={onHoverLine} + onLeaveLine={onLeaveLine} + showFirstLineAction={row.isFirstChangedLineInHunk && row.left !== null} onAddHunkReference={onAddHunkReference} testID={testID ? `${testID}-left` : undefined} /> @@ -478,7 +542,13 @@ function SplitDiffRowView({ gutterWidth={gutterWidth} wrapLines={wrapLines} hunkActionMode={hunkActionMode} - showFirstLineAction={row.isFirstVisibleLineInHunk && row.left === null && row.right !== null} + lineKey={row.right ? `${testID ?? "split-row"}:right` : undefined} + hoveredLineKey={hoveredLineKey} + onHoverLine={onHoverLine} + onLeaveLine={onLeaveLine} + showFirstLineAction={ + row.isFirstChangedLineInHunk && row.left === null && row.right !== null + } onAddHunkReference={onAddHunkReference} showDivider testID={testID ? `${testID}-right` : undefined} @@ -492,6 +562,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ isExpanded, onToggle, onAddFileReference, + onClearArmedLine, onHeaderHeightChange, testID, }: DiffFileSectionProps) { @@ -501,8 +572,9 @@ const DiffFileHeader = memo(function DiffFileHeader({ const toggleExpanded = useCallback(() => { pressHandledRef.current = true; + onClearArmedLine?.(); onToggle(file.path); - }, [file.path, onToggle]); + }, [file.path, onClearArmedLine, onToggle]); return ( onAddFileReference(file)} + onPress={() => { + onClearArmedLine?.(); + onAddFileReference(file); + }} testID={testID ? `${testID}-add-to-chat` : undefined} /> ) : null} @@ -586,6 +661,12 @@ function DiffFileBody({ layout, wrapLines, hunkActionMode, + hoveredLineKey, + onHoverLine, + onLeaveLine, + armedLineKey, + onArmLine, + onClearArmedLine, onAddHunkReference, onBodyHeightChange, testID, @@ -594,6 +675,12 @@ function DiffFileBody({ layout: "unified" | "split"; wrapLines: boolean; hunkActionMode: HunkChatActionMode; + hoveredLineKey: string | null; + onHoverLine?: (lineKey: string) => void; + onLeaveLine?: () => void; + armedLineKey: string | null; + onArmLine?: (lineKey: string) => void; + onClearArmedLine?: () => void; onAddHunkReference?: (reference: string) => void; onBodyHeightChange?: (path: string, height: number) => void; testID?: string; @@ -648,6 +735,9 @@ function DiffFileBody({ gutterWidth={gutterWidth} wrapLines={wrapLines} hunkActionMode={hunkActionMode} + hoveredLineKey={hoveredLineKey} + onHoverLine={onHoverLine} + onLeaveLine={onLeaveLine} onAddHunkReference={ onAddHunkReference ? () => onAddHunkReference(row.chatReference) @@ -660,10 +750,8 @@ function DiffFileBody({ : file.hunks.map((hunk, hunkIndex) => { let oldLineNo = hunk.oldStart; let newLineNo = hunk.newStart; - let hasVisibleLine = false; return hunk.lines.map((line, lineIndex) => { let lineNumber: number | null = null; - let isFirstVisibleLineInHunk = false; if (line.type === "remove") { lineNumber = oldLineNo; oldLineNo++; @@ -675,10 +763,7 @@ function DiffFileBody({ oldLineNo++; newLineNo++; } - if (line.type !== "header") { - isFirstVisibleLineInHunk = !hasVisibleLine; - hasVisibleLine = true; - } + const currentLineKey = `${file.path}:${hunkIndex}:${lineIndex}`; return ( - onAddHunkReference( - buildHunkLineChatReference({ - path: file.path, - hunk, - lineIndex, - }), - ) + { + onAddHunkReference( + buildHunkLineChatReference({ + path: file.path, + hunk, + lineIndex, + }), + ); + onClearArmedLine?.(); + } : undefined } testID={testID ? `${testID}-hunk-${hunkIndex}-line-${lineIndex}` : undefined} @@ -728,6 +821,7 @@ function DiffFileBody({ @@ -756,10 +850,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const canUseSplitLayout = Platform.OS === "web" && !isMobile; const hunkActionMode: HunkChatActionMode = - Platform.OS === "web" && !isMobile ? "hover" : "first-line"; + Platform.OS === "web" && !isMobile ? "hover" : "tap-reveal"; const router = useRouter(); const toast = useToast(); const closeToAgent = usePanelStore((state) => state.closeToAgent); + const [hoveredLineKey, setHoveredLineKey] = useState(null); + const [armedLineKey, setArmedLineKey] = useState(null); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [actionError, setActionError] = useState(null); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); @@ -996,10 +1092,16 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const handleDiffListScroll = useCallback( (event: NativeSyntheticEvent) => { + if (hoveredLineKey !== null) { + setHoveredLineKey(null); + } + if (armedLineKey !== null) { + setArmedLineKey(null); + } diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; scrollbar.onScroll(event); }, - [scrollbar.onScroll], + [armedLineKey, hoveredLineKey, scrollbar.onScroll], ); const handleDiffListLayout = useCallback( @@ -1205,6 +1307,10 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi isExpanded={item.isExpanded} onToggle={handleToggleExpanded} onAddFileReference={handleAddFileReference} + onClearArmedLine={() => { + setHoveredLineKey(null); + setArmedLineKey(null); + }} onHeaderHeightChange={handleHeaderHeightChange} testID={`diff-file-${item.fileIndex}`} /> @@ -1216,6 +1322,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi layout={effectiveLayout} wrapLines={wrapLines} hunkActionMode={hunkActionMode} + hoveredLineKey={hoveredLineKey} + onHoverLine={setHoveredLineKey} + onLeaveLine={() => setHoveredLineKey(null)} + armedLineKey={armedLineKey} + onArmLine={setArmedLineKey} + onClearArmedLine={() => { + setHoveredLineKey(null); + setArmedLineKey(null); + }} onAddHunkReference={handleAddHunkReference} onBodyHeightChange={handleBodyHeightChange} testID={`diff-file-${item.fileIndex}-body`} @@ -1223,6 +1338,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi ); }, [ + armedLineKey, effectiveLayout, handleAddFileReference, handleAddHunkReference, @@ -1230,6 +1346,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi handleHeaderHeightChange, handleToggleExpanded, hunkActionMode, + hoveredLineKey, wrapLines, ], ); @@ -1336,7 +1453,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi renderItem={renderFlatItem} keyExtractor={flatKeyExtractor} stickyHeaderIndices={stickyHeaderIndices} - extraData={{ expandedPathsArray, effectiveLayout, wrapLines }} + extraData={{ armedLineKey, expandedPathsArray, effectiveLayout, hoveredLineKey, wrapLines }} style={styles.scrollView} contentContainerStyle={styles.contentContainer} testID="git-diff-scroll" diff --git a/packages/app/src/utils/chat-reference-token.test.ts b/packages/app/src/utils/chat-reference-token.test.ts index 3ccf9c0ac..2404d90ef 100644 --- a/packages/app/src/utils/chat-reference-token.test.ts +++ b/packages/app/src/utils/chat-reference-token.test.ts @@ -142,4 +142,62 @@ describe("buildHunkLineChatReference", () => { }), ).toBe("src/example.ts:10"); }); + + it("uses surrounding new-side context for delete-only blocks", () => { + const hunk = { + oldStart: 237, + oldCount: 8, + newStart: 239, + newCount: 2, + lines: [ + { type: "header" as const, content: "@@ -237,8 +239,2 @@" }, + { type: "context" as const, content: "before" }, + { type: "remove" as const, content: "deleted one" }, + { type: "remove" as const, content: "deleted two" }, + { type: "remove" as const, content: "deleted three" }, + { type: "remove" as const, content: "deleted four" }, + { type: "remove" as const, content: "deleted five" }, + { type: "remove" as const, content: "deleted six" }, + { type: "context" as const, content: "after" }, + ], + }; + + expect( + buildHunkLineChatReference({ + path: "src/example.ts", + hunk, + lineIndex: 2, + }), + ).toBe("src/example.ts:239-240"); + + expect( + buildHunkLineChatReference({ + path: "src/example.ts", + hunk, + lineIndex: 7, + }), + ).toBe("src/example.ts:239-240"); + }); + + it("falls back to the deleted old-side range when no surrounding new context exists", () => { + const hunk = { + oldStart: 18, + oldCount: 2, + newStart: 18, + newCount: 0, + lines: [ + { type: "header" as const, content: "@@ -18,2 +18,0 @@" }, + { type: "remove" as const, content: "deleted one" }, + { type: "remove" as const, content: "deleted two" }, + ], + }; + + expect( + buildHunkLineChatReference({ + path: "src/example.ts", + hunk, + lineIndex: 1, + }), + ).toBe("src/example.ts:18-19"); + }); }); diff --git a/packages/app/src/utils/chat-reference-token.ts b/packages/app/src/utils/chat-reference-token.ts index 758018b04..09a10609b 100644 --- a/packages/app/src/utils/chat-reference-token.ts +++ b/packages/app/src/utils/chat-reference-token.ts @@ -129,6 +129,33 @@ export function buildHunkLineChatReference(input: { } } + if (newCount === 0 && oldStart != null && oldCount > 0) { + const previousPosition = positions[startIndex - 1]; + const nextPosition = positions[endIndex + 1]; + const surroundingNewStart = + previousPosition?.type === "context" + ? previousPosition.newLineNumber + : nextPosition?.type === "context" + ? nextPosition.newLineNumber + : null; + const surroundingNewEnd = + nextPosition?.type === "context" + ? nextPosition.newLineNumber + : previousPosition?.type === "context" + ? previousPosition.newLineNumber + : null; + + if (surroundingNewStart != null && surroundingNewEnd != null) { + return buildDiffRangeChatReference({ + path, + oldStart, + oldCount, + newStart: surroundingNewStart, + newCount: surroundingNewEnd - surroundingNewStart + 1, + }); + } + } + const fallbackStart = newStart ?? oldStart ?? hunk.newStart ?? hunk.oldStart; return buildDiffRangeChatReference({ diff --git a/packages/app/src/utils/diff-layout.test.ts b/packages/app/src/utils/diff-layout.test.ts index 6a8df3584..2af2fb5f9 100644 --- a/packages/app/src/utils/diff-layout.test.ts +++ b/packages/app/src/utils/diff-layout.test.ts @@ -38,7 +38,7 @@ describe("buildSplitDiffRows", () => { expect(rows[1]).toMatchObject({ kind: "pair", hunkIndex: 0, - isFirstVisibleLineInHunk: true, + isFirstChangedLineInHunk: true, chatReference: "example.ts:10-11", left: { type: "remove", content: "before one", lineNumber: 10 }, right: { type: "add", content: "after one", lineNumber: 10 }, @@ -46,7 +46,7 @@ describe("buildSplitDiffRows", () => { expect(rows[2]).toMatchObject({ kind: "pair", hunkIndex: 0, - isFirstVisibleLineInHunk: false, + isFirstChangedLineInHunk: false, chatReference: "example.ts:10-11", left: { type: "remove", content: "before two", lineNumber: 11 }, right: { type: "add", content: "after two", lineNumber: 11 }, @@ -66,7 +66,7 @@ describe("buildSplitDiffRows", () => { expect(rows[2]).toMatchObject({ kind: "pair", hunkIndex: 0, - isFirstVisibleLineInHunk: false, + isFirstChangedLineInHunk: false, chatReference: "example.ts:10-11", left: null, right: { type: "add", content: "after two", lineNumber: 11 }, @@ -84,10 +84,80 @@ describe("buildSplitDiffRows", () => { expect(rows[1]).toMatchObject({ kind: "pair", hunkIndex: 0, - isFirstVisibleLineInHunk: true, + isFirstChangedLineInHunk: false, chatReference: "example.ts:10", left: { type: "context", content: "same line", lineNumber: 10 }, right: { type: "context", content: "same line", lineNumber: 10 }, }); }); + + it("marks the first changed row instead of leading context", () => { + const rows = buildSplitDiffRows( + makeFile([ + { type: "header", content: "@@ -10,3 +10,3 @@" }, + { type: "context", content: "same line" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ]), + ); + + expect(rows[1]).toMatchObject({ + kind: "pair", + isFirstChangedLineInHunk: false, + left: { type: "context", content: "same line", lineNumber: 10 }, + right: { type: "context", content: "same line", lineNumber: 10 }, + }); + expect(rows[2]).toMatchObject({ + kind: "pair", + isFirstChangedLineInHunk: true, + chatReference: "example.ts:11", + left: { type: "remove", content: "before", lineNumber: 11 }, + right: { type: "add", content: "after", lineNumber: 11 }, + }); + }); + + it("uses surrounding new-side context for delete-only split rows", () => { + const rows = buildSplitDiffRows( + { + path: "example.ts", + isNew: false, + isDeleted: false, + additions: 0, + deletions: 6, + status: "ok", + hunks: [ + { + oldStart: 237, + oldCount: 8, + newStart: 239, + newCount: 2, + lines: [ + { type: "header", content: "@@ -237,8 +239,2 @@" }, + { type: "context", content: "before" }, + { type: "remove", content: "deleted one" }, + { type: "remove", content: "deleted two" }, + { type: "remove", content: "deleted three" }, + { type: "remove", content: "deleted four" }, + { type: "remove", content: "deleted five" }, + { type: "remove", content: "deleted six" }, + { type: "context", content: "after" }, + ], + }, + ], + }, + ); + + expect(rows[2]).toMatchObject({ + kind: "pair", + chatReference: "example.ts:239-240", + left: { type: "remove", content: "deleted one", lineNumber: 238 }, + right: null, + }); + expect(rows[7]).toMatchObject({ + kind: "pair", + chatReference: "example.ts:239-240", + left: { type: "remove", content: "deleted six", lineNumber: 243 }, + right: null, + }); + }); }); diff --git a/packages/app/src/utils/diff-layout.ts b/packages/app/src/utils/diff-layout.ts index 421f5b51b..f12e138aa 100644 --- a/packages/app/src/utils/diff-layout.ts +++ b/packages/app/src/utils/diff-layout.ts @@ -17,7 +17,7 @@ export type SplitDiffRow = | { kind: "pair"; hunkIndex: number; - isFirstVisibleLineInHunk: boolean; + isFirstChangedLineInHunk: boolean; chatReference: string; left: SplitDiffDisplayLine | null; right: SplitDiffDisplayLine | null; @@ -72,7 +72,8 @@ export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { for (const [hunkIndex, hunk] of file.hunks.entries()) { let oldLineNo = hunk.oldStart; let newLineNo = hunk.newStart; - let hasVisibleLine = false; + let hasChangedLine = false; + let previousContextNewLineNumber: number | null = null; rows.push({ kind: "header", content: hunk.lines[0]?.type === "header" ? hunk.lines[0].content : "@@", @@ -84,36 +85,57 @@ export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { const pushPairRow = (input: { chatReference: string; + hasChanges: boolean; left: SplitDiffDisplayLine | null; right: SplitDiffDisplayLine | null; }) => { rows.push({ kind: "pair", hunkIndex, - isFirstVisibleLineInHunk: !hasVisibleLine, + isFirstChangedLineInHunk: input.hasChanges && !hasChangedLine, chatReference: input.chatReference, left: input.left, right: input.right, }); - hasVisibleLine = true; + if (input.hasChanges) { + hasChangedLine = true; + } }; - const flushPendingRows = () => { + const flushPendingRows = (nextContextNewLineNumber?: number | null) => { const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length); const fallbackStart = pendingAdditions[0]?.newLineNumber ?? pendingRemovals[0]?.oldLineNumber ?? hunk.newStart; - const chatReference = buildDiffRangeChatReference({ - path: file.path, - oldStart: pendingRemovals[0]?.oldLineNumber ?? fallbackStart, - oldCount: pendingRemovals.length, - newStart: pendingAdditions[0]?.newLineNumber ?? fallbackStart, - newCount: pendingAdditions.length, - }); + const oldStart = pendingRemovals[0]?.oldLineNumber ?? fallbackStart; + const oldCount = pendingRemovals.length; + const newStart = pendingAdditions[0]?.newLineNumber ?? fallbackStart; + const newCount = pendingAdditions.length; + const surroundingNewStart = + previousContextNewLineNumber ?? nextContextNewLineNumber ?? null; + const surroundingNewEnd = + nextContextNewLineNumber ?? previousContextNewLineNumber ?? null; + const chatReference = + newCount === 0 && oldCount > 0 && surroundingNewStart != null && surroundingNewEnd != null + ? buildDiffRangeChatReference({ + path: file.path, + oldStart, + oldCount, + newStart: surroundingNewStart, + newCount: surroundingNewEnd - surroundingNewStart + 1, + }) + : buildDiffRangeChatReference({ + path: file.path, + oldStart, + oldCount, + newStart, + newCount, + }); for (let index = 0; index < pairCount; index += 1) { const removal = pendingRemovals[index] ?? null; const addition = pendingAdditions[index] ?? null; pushPairRow({ chatReference, + hasChanges: true, left: removal ? toDisplayLine({ line: removal.line, @@ -149,7 +171,7 @@ export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { continue; } - flushPendingRows(); + flushPendingRows(newLineNo); if (line.type === "context") { pushPairRow({ @@ -160,6 +182,7 @@ export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { newStart: newLineNo, newCount: 1, }), + hasChanges: false, left: toDisplayLine({ line, oldLineNumber: oldLineNo, @@ -175,6 +198,7 @@ export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { }); oldLineNo += 1; newLineNo += 1; + previousContextNewLineNumber = newLineNo - 1; } } From b202afc28f29e15fb7a440be96bd3541e8cee7f3 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Tue, 7 Apr 2026 20:12:47 +0200 Subject: [PATCH 3/3] refactor --- .../app/src/components/agent-input-area.tsx | 2 +- .../app/src/components/git-diff-file-body.tsx | 834 ++++++++++++++++++ packages/app/src/components/git-diff-pane.tsx | 812 +---------------- .../src/utils/active-chat-composer.test.ts | 95 -- .../app/src/utils/active-chat-composer.ts | 115 --- .../app/src/utils/chat-reference-token.ts | 122 +-- .../src/utils/composer-text-insert.test.ts | 97 ++ .../app/src/utils/composer-text-insert.ts | 113 +++ packages/app/src/utils/diff-chat-reference.ts | 152 ++++ packages/app/src/utils/diff-layout.ts | 145 +-- 10 files changed, 1312 insertions(+), 1175 deletions(-) create mode 100644 packages/app/src/components/git-diff-file-body.tsx create mode 100644 packages/app/src/utils/composer-text-insert.test.ts create mode 100644 packages/app/src/utils/composer-text-insert.ts create mode 100644 packages/app/src/utils/diff-chat-reference.ts diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 21b2abc19..0156a4de5 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -49,10 +49,10 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { submitAgentInput } from "@/components/agent-input-submit"; import { - buildComposerInsertResult, markActiveChatComposer, registerActiveChatComposer, } from "@/utils/active-chat-composer"; +import { buildComposerInsertResult } from "@/utils/composer-text-insert"; type QueuedMessage = { id: string; diff --git a/packages/app/src/components/git-diff-file-body.tsx b/packages/app/src/components/git-diff-file-body.tsx new file mode 100644 index 000000000..3b4be463c --- /dev/null +++ b/packages/app/src/components/git-diff-file-body.tsx @@ -0,0 +1,834 @@ +import { memo, useCallback, useMemo, useState } from "react"; +import { + View, + Text, + Pressable, + Platform, + type GestureResponderEvent, + type LayoutChangeEvent, + type TextStyle, +} from "react-native"; +import { Paperclip } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { + darkHighlightColors, + lightHighlightColors, + type HighlightStyle as HighlightStyleKey, +} from "@getpaseo/highlight"; +import { + type ParsedDiffFile, + type DiffLine, + type HighlightToken, +} from "@/hooks/use-checkout-diff-query"; +import { Fonts } from "@/constants/theme"; +import { buildSplitDiffRows, type SplitDiffDisplayLine, type SplitDiffRow } from "@/utils/diff-layout"; +import { buildHunkLineChatReference } from "@/utils/chat-reference-token"; +import { lineNumberGutterWidth } from "@/components/code-insets"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { DiffScroll } from "./diff-scroll"; + +export type HunkChatActionMode = "hover" | "tap-reveal"; + +interface ChatReferenceButtonProps { + accessibilityLabel: string; + tooltipLabel: string; + onPress: () => void; + testID?: string; +} + +export interface GitDiffFileBodyProps { + file: ParsedDiffFile; + layout: "unified" | "split"; + wrapLines: boolean; + hunkActionMode: HunkChatActionMode; + onClearArmedLine?: () => void; + onAddHunkReference?: (reference: string) => void; + onBodyHeightChange?: (path: string, height: number) => void; + testID?: string; +} + +type HighlightStyle = NonNullable; + +type WrappedWebTextStyle = TextStyle & { + whiteSpace?: "pre" | "pre-wrap"; + overflowWrap?: "normal" | "anywhere"; +}; + +function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined { + if (Platform.OS !== "web") { + return undefined; + } + return wrapLines + ? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" } + : { whiteSpace: "pre", overflowWrap: "normal" }; +} + +function HighlightedText({ + tokens, + wrapLines = false, +}: { + tokens: HighlightToken[]; + wrapLines?: boolean; +}) { + const { theme } = useUnistyles(); + const isDark = theme.colorScheme === "dark"; + const lineHeight = theme.lineHeight.diff; + + const getTokenColor = (style: HighlightStyle | null): string => { + const baseColor = isDark ? "#c9d1d9" : "#24292f"; + if (!style) return baseColor; + const colors = isDark ? darkHighlightColors : lightHighlightColors; + return colors[style as HighlightStyleKey] ?? baseColor; + }; + + return ( + + {tokens.map((token, index) => ( + + {token.text} + + ))} + + ); +} + +export function ChatReferenceButton({ + accessibilityLabel, + tooltipLabel, + onPress, + testID, +}: ChatReferenceButtonProps) { + const { theme } = useUnistyles(); + const iconSize = Platform.OS === "web" ? 14 : 16; + + return ( + + + [ + styles.chatReferenceButton, + (hovered || pressed) && styles.chatReferenceButtonHovered, + ]} + > + + + + + {tooltipLabel} + + + ); +} + +function DiffHunkHeaderRow({ + content, + gutterWidth, + testID, +}: { + content: string; + gutterWidth?: number; + testID?: string; +}) { + return ( + + {typeof gutterWidth === "number" ? ( + + ) : null} + + {content} + + + ); +} + +function LineNumberGutterSlot({ + gutterWidth, + lineNumber, + showAction, + lineType, + onAddReference, + onPressLineNumber, + testID, +}: { + gutterWidth: number; + lineNumber: number | null; + showAction: boolean; + lineType: DiffLine["type"]; + onAddReference?: () => void; + onPressLineNumber?: () => void; + testID?: string; +}) { + const { theme } = useUnistyles(); + const iconSize = Platform.OS === "web" ? 14 : 16; + const canRevealAction = Boolean(onAddReference) || Boolean(onPressLineNumber); + const handlePress = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + if (onPressLineNumber && !showAction) { + onPressLineNumber?.(); + return; + } + onAddReference?.(); + }, + [onAddReference, onPressLineNumber, showAction], + ); + const renderText = () => ( + + {lineNumber != null ? String(lineNumber) : ""} + + ); + const renderAction = () => ( + + + + ); + + if (!canRevealAction) { + return {renderText()}; + } + + return ( + + + + {({ hovered, pressed }) => + showAction || (Platform.OS === "web" && (hovered || pressed)) + ? renderAction() + : renderText() + } + + + + Add hunk to chat + + + ); +} + +function DiffLineView({ + line, + lineNumber, + gutterWidth, + wrapLines, + hunkActionMode, + lineKey, + armedLineKey, + onArmLine, + onAddHunkReference, + testID, +}: { + line: DiffLine; + lineNumber: number | null; + gutterWidth: number; + wrapLines: boolean; + hunkActionMode: HunkChatActionMode; + lineKey: string; + armedLineKey: string | null; + onArmLine?: (lineKey: string) => void; + onAddHunkReference?: () => void; + testID?: string; +}) { + if (line.type === "header") { + return ( + + ); + } + + return ( + { + if (armedLineKey !== lineKey) { + onArmLine?.(lineKey); + } + } + : undefined + } + style={[ + styles.diffLineContainer, + line.type === "add" && styles.addLineContainer, + line.type === "remove" && styles.removeLineContainer, + line.type === "context" && styles.contextLineContainer, + ]} + testID={testID} + > + {({ hovered, pressed }) => { + const showHunkAction = + Boolean(onAddHunkReference) && + (hunkActionMode === "tap-reveal" + ? armedLineKey === lineKey + : hovered || pressed); + + return ( + <> + { + if (armedLineKey !== lineKey) { + onArmLine?.(lineKey); + } + } + : undefined + } + testID={testID ? `${testID}-add-to-chat` : undefined} + /> + {line.tokens ? ( + + ) : ( + + {line.content || " "} + + )} + + ); + }} + + ); +} + +function SplitDiffCell({ + line, + gutterWidth, + wrapLines, + hunkActionMode, + isArmed, + onArmLine, + showFirstLineAction, + onAddHunkReference, + showDivider = false, + testID, +}: { + line: SplitDiffDisplayLine | null; + gutterWidth: number; + wrapLines: boolean; + hunkActionMode?: HunkChatActionMode; + isArmed?: boolean; + onArmLine?: () => void; + showFirstLineAction?: boolean; + onAddHunkReference?: () => void; + showDivider?: boolean; + testID?: string; +}) { + const cellContent = (showHunkAction: boolean) => ( + <> + + {line?.tokens ? ( + + ) : ( + + {line?.content ?? ""} + + )} + + ); + + if (!line) { + return ( + + {cellContent(false)} + + ); + } + + return ( + + {({ hovered, pressed }) => ( + + {cellContent( + Boolean(onAddHunkReference) && + (hunkActionMode === "tap-reveal" + ? Boolean(showFirstLineAction) && Boolean(isArmed) + : hovered || pressed), + )} + + )} + + ); +} + +function SplitDiffRowView({ + row, + gutterWidth, + wrapLines, + hunkActionMode, + armedLineKey, + onArmLine, + onAddHunkReference, + testID, +}: { + row: Extract; + gutterWidth: number; + wrapLines: boolean; + hunkActionMode: HunkChatActionMode; + armedLineKey: string | null; + onArmLine?: (lineKey: string) => void; + onAddHunkReference?: () => void; + testID?: string; +}) { + const leftLineKey = row.left ? `${testID ?? "split-row"}:left` : null; + const rightLineKey = row.right ? `${testID ?? "split-row"}:right` : null; + + return ( + + { + onArmLine(leftLineKey); + } + : undefined + } + showFirstLineAction={row.isFirstChangedLineInHunk && row.left !== null} + onAddHunkReference={onAddHunkReference} + testID={testID ? `${testID}-left` : undefined} + /> + { + onArmLine(rightLineKey); + } + : undefined + } + showFirstLineAction={ + row.isFirstChangedLineInHunk && row.left === null && row.right !== null + } + onAddHunkReference={onAddHunkReference} + showDivider + testID={testID ? `${testID}-right` : undefined} + /> + + ); +} + +interface UnifiedDiffRenderRow { + hunkIndex: number; + lineIndex: number; + line: DiffLine; + lineNumber: number | null; + lineKey: string; + reference: string | null; +} + +const GitDiffFileBody = memo(function GitDiffFileBody({ + file, + layout, + wrapLines, + hunkActionMode, + onClearArmedLine, + onAddHunkReference, + onBodyHeightChange, + testID, +}: GitDiffFileBodyProps) { + const [armedLineKey, setArmedLineKey] = useState(null); + const [scrollViewWidth, setScrollViewWidth] = useState(0); + const [bodyWidth, setBodyWidth] = useState(0); + const gutterWidth = useMemo(() => { + let maxLineNo = 0; + for (const hunk of file.hunks) { + maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount); + } + return lineNumberGutterWidth(maxLineNo); + }, [file]); + const splitRows = useMemo(() => buildSplitDiffRows(file), [file]); + const unifiedRows = useMemo(() => { + const rows: UnifiedDiffRenderRow[] = []; + + for (const [hunkIndex, hunk] of file.hunks.entries()) { + let oldLineNo = hunk.oldStart; + let newLineNo = hunk.newStart; + + for (const [lineIndex, line] of hunk.lines.entries()) { + let lineNumber: number | null = null; + if (line.type === "remove") { + lineNumber = oldLineNo; + oldLineNo += 1; + } else if (line.type === "add") { + lineNumber = newLineNo; + newLineNo += 1; + } else if (line.type === "context") { + lineNumber = newLineNo; + oldLineNo += 1; + newLineNo += 1; + } + + rows.push({ + hunkIndex, + lineIndex, + line, + lineNumber, + lineKey: `${file.path}:${hunkIndex}:${lineIndex}`, + reference: + line.type === "header" + ? null + : buildHunkLineChatReference({ + path: file.path, + hunk, + lineIndex, + }), + }); + } + } + + return rows; + }, [file]); + const handleArmLine = useCallback((lineKey: string) => { + setArmedLineKey((current) => (current === lineKey ? current : lineKey)); + }, []); + const handleClearArmedLine = useCallback(() => { + setArmedLineKey(null); + onClearArmedLine?.(); + }, [onClearArmedLine]); + + return ( + { + setBodyWidth(event.nativeEvent.layout.width); + onBodyHeightChange?.(file.path, event.nativeEvent.layout.height); + }} + testID={testID} + > + {(() => { + if (file.status === "too_large" || file.status === "binary") { + return ( + + + {file.status === "binary" ? "Binary file" : "Diff too large to display"} + + + ); + } + + const linesContent = + layout === "split" + ? splitRows.map((row, rowIndex) => { + if (row.kind === "header") { + return ( + + + + ); + } + + return ( + onAddHunkReference(row.chatReference) : undefined + } + testID={testID ? `${testID}-hunk-${rowIndex}` : undefined} + /> + ); + }) + : unifiedRows.map((row) => { + const reference = row.reference; + return ( + { + onAddHunkReference(reference); + handleClearArmedLine(); + } + : undefined + } + testID={testID ? `${testID}-hunk-${row.hunkIndex}-line-${row.lineIndex}` : undefined} + /> + ); + }); + + const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth; + const contentContainer = ( + 0 && + (layout === "split" + ? { width: availableWidth, minWidth: availableWidth, maxWidth: availableWidth } + : { minWidth: availableWidth }), + ]} + > + {linesContent} + + ); + + if (wrapLines) { + return {contentContainer}; + } + return ( + + {contentContainer} + + ); + })()} + + ); +}); + +export { GitDiffFileBody }; + +const styles = StyleSheet.create((theme) => ({ + fileSectionBodyContainer: { + overflow: "hidden", + backgroundColor: theme.colors.surface2, + }, + fileSectionBorder: { + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + diffContent: { + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + backgroundColor: theme.colors.surface1, + }, + diffContentInner: { + flexDirection: "column", + }, + linesContainer: { + backgroundColor: theme.colors.surface1, + }, + splitLinesContainer: { + backgroundColor: theme.colors.surface1, + minWidth: 760, + }, + splitRow: { + flexDirection: "row", + alignItems: "stretch", + }, + splitHeaderRow: { + backgroundColor: theme.colors.surface2, + paddingHorizontal: theme.spacing[3], + }, + splitCell: { + flex: 1, + flexBasis: 0, + backgroundColor: theme.colors.surface2, + }, + splitCellRow: { + flexDirection: "row", + alignItems: "stretch", + }, + emptySplitCell: { + backgroundColor: theme.colors.surfaceDiffEmpty, + }, + splitCellWithDivider: { + borderLeftWidth: theme.borderWidth[1], + borderLeftColor: theme.colors.border, + }, + diffLineContainer: { + flexDirection: "row", + alignItems: "stretch", + }, + lineNumberGutter: { + borderRightWidth: theme.borderWidth[1], + borderRightColor: theme.colors.border, + marginRight: theme.spacing[2], + alignSelf: "stretch", + justifyContent: "flex-start", + }, + lineNumberGutterActionContent: { + height: theme.lineHeight.diff, + alignSelf: "stretch", + alignItems: "flex-end", + justifyContent: "center", + paddingRight: theme.spacing[2], + }, + lineNumberText: { + textAlign: "right", + paddingRight: theme.spacing[2], + fontSize: theme.fontSize.xs, + lineHeight: theme.lineHeight.diff, + fontFamily: Fonts.mono, + color: theme.colors.foregroundMuted, + userSelect: "none", + }, + addLineNumberText: { + color: theme.colors.palette.green[400], + }, + removeLineNumberText: { + color: theme.colors.palette.red[500], + }, + diffLineText: { + flex: 1, + paddingRight: theme.spacing[3], + fontSize: theme.fontSize.xs, + lineHeight: theme.lineHeight.diff, + fontFamily: Fonts.mono, + color: theme.colors.foreground, + userSelect: "text", + }, + addLineContainer: { + backgroundColor: "rgba(46, 160, 67, 0.15)", + }, + addLineText: { + color: theme.colors.foreground, + }, + removeLineContainer: { + backgroundColor: "rgba(248, 81, 73, 0.1)", + }, + removeLineText: { + color: theme.colors.foreground, + }, + headerLineContainer: { + backgroundColor: theme.colors.surface2, + }, + headerLineText: { + color: theme.colors.foregroundMuted, + }, + hunkHeaderText: { + flexShrink: 1, + paddingRight: theme.spacing[2], + }, + contextLineContainer: { + backgroundColor: theme.colors.surface1, + }, + contextLineText: { + color: theme.colors.foregroundMuted, + }, + chatReferenceButton: { + alignItems: "center", + justifyContent: "center", + width: { + xs: 28, + sm: 28, + md: 24, + }, + height: { + xs: 28, + sm: 28, + md: 24, + }, + borderRadius: theme.borderRadius.base, + flexShrink: 0, + }, + chatReferenceButtonHovered: { + backgroundColor: theme.colors.surface3, + }, + emptySplitCellText: { + color: "transparent", + }, + statusMessageContainer: { + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + backgroundColor: theme.colors.surface1, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[4], + }, + statusMessageText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + fontStyle: "italic", + }, + tooltipText: { + fontSize: theme.fontSize.xs, + 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 1df24b593..97e030e50 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -15,11 +15,9 @@ import { Pressable, FlatList, Platform, - type GestureResponderEvent, type LayoutChangeEvent, type NativeSyntheticEvent, type NativeScrollEvent, - TextStyle, } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; @@ -33,7 +31,6 @@ import { GitMerge, ListChevronsDownUp, ListChevronsUpDown, - Paperclip, Pilcrow, RefreshCcw, Upload, @@ -43,26 +40,12 @@ import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store" import { useCheckoutDiffQuery, type ParsedDiffFile, - type DiffLine, - type HighlightToken, } from "@/hooks/use-checkout-diff-query"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; -import { DiffScroll } from "./diff-scroll"; -import { - darkHighlightColors, - lightHighlightColors, - type HighlightStyle as HighlightStyleKey, -} from "@getpaseo/highlight"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; -import { Fonts } from "@/constants/theme"; import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; -import { - buildSplitDiffRows, - type SplitDiffDisplayLine, - type SplitDiffRow, -} from "@/utils/diff-layout"; import { DropdownMenu, DropdownMenuContent, @@ -73,17 +56,19 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { GitHubIcon } from "@/components/icons/github-icon"; import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; -import { lineNumberGutterWidth } from "@/components/code-insets"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; -import { useToast } from "@/contexts/toast-context"; -import { appendTextTokenToComposer, insertIntoActiveChatComposer } from "@/utils/active-chat-composer"; import { - buildFileChatReference, - buildHunkLineChatReference, -} from "@/utils/chat-reference-token"; + ChatReferenceButton, + GitDiffFileBody, + type HunkChatActionMode, +} from "@/components/git-diff-file-body"; +import { useToast } from "@/contexts/toast-context"; +import { insertIntoActiveChatComposer } from "@/utils/active-chat-composer"; +import { buildFileChatReference } from "@/utils/chat-reference-token"; +import { appendTextTokenToComposer } from "@/utils/composer-text-insert"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { useDraftStore } from "@/stores/draft-store"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; @@ -96,55 +81,6 @@ function openURLInNewTab(url: string): void { void openExternalUrl(url); } -type HighlightStyle = NonNullable; - -interface HighlightedTextProps { - tokens: HighlightToken[]; - wrapLines?: boolean; -} - -type WrappedWebTextStyle = TextStyle & { - whiteSpace?: "pre" | "pre-wrap"; - overflowWrap?: "normal" | "anywhere"; -}; - -function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined { - if (Platform.OS !== "web") { - return undefined; - } - return wrapLines - ? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" } - : { whiteSpace: "pre", overflowWrap: "normal" }; -} - -function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) { - const { theme } = useUnistyles(); - const isDark = theme.colorScheme === "dark"; - const lineHeight = theme.lineHeight.diff; - - const getTokenColor = (style: HighlightStyle | null): string => { - const baseColor = isDark ? "#c9d1d9" : "#24292f"; - if (!style) return baseColor; - const colors = isDark ? darkHighlightColors : lightHighlightColors; - return colors[style as HighlightStyleKey] ?? baseColor; - }; - - return ( - - {tokens.map((token, index) => ( - - {token.text} - - ))} - - ); -} - interface DiffFileSectionProps { file: ParsedDiffFile; isExpanded: boolean; @@ -155,408 +91,6 @@ interface DiffFileSectionProps { testID?: string; } -interface ChatReferenceButtonProps { - accessibilityLabel: string; - tooltipLabel: string; - onPress: () => void; - testID?: string; -} - -type HunkChatActionMode = "hover" | "tap-reveal"; - -function ChatReferenceButton({ - accessibilityLabel, - tooltipLabel, - onPress, - testID, -}: ChatReferenceButtonProps) { - const { theme } = useUnistyles(); - const iconSize = Platform.OS === "web" ? 14 : 16; - - return ( - - - [ - styles.chatReferenceButton, - (hovered || pressed) && styles.chatReferenceButtonHovered, - ]} - > - - - - - {tooltipLabel} - - - ); -} - -function DiffHunkHeaderRow({ - content, - gutterWidth, - testID, -}: { - content: string; - gutterWidth?: number; - testID?: string; -}) { - return ( - - {typeof gutterWidth === "number" ? ( - - ) : null} - - {content} - - - ); -} - -function LineNumberGutterSlot({ - gutterWidth, - lineNumber, - showAction, - lineType, - onAddReference, - onPressLineNumber, - onHoverLine, - onLeaveLine, - testID, -}: { - gutterWidth: number; - lineNumber: number | null; - showAction: boolean; - lineType: DiffLine["type"]; - onAddReference?: () => void; - onPressLineNumber?: () => void; - onHoverLine?: () => void; - onLeaveLine?: () => void; - testID?: string; -}) { - const { theme } = useUnistyles(); - const iconSize = Platform.OS === "web" ? 14 : 16; - const isInteractive = Boolean(onAddReference) && showAction; - const handlePress = useCallback( - (event: GestureResponderEvent) => { - event.stopPropagation(); - onAddReference?.(); - }, - [onAddReference], - ); - - return ( - - - [ - styles.lineNumberGutter, - { width: gutterWidth }, - isInteractive && pressed && styles.chatReferenceButtonHovered, - ]} - > - {isInteractive ? ( - - - - ) : ( - - {lineNumber != null ? String(lineNumber) : ""} - - )} - - - {isInteractive ? ( - - Add hunk to chat - - ) : null} - - ); -} - -function DiffLineView({ - line, - lineNumber, - gutterWidth, - wrapLines, - hunkActionMode, - lineKey, - hoveredLineKey, - onHoverLine, - onLeaveLine, - armedLineKey, - onArmLine, - onAddHunkReference, - testID, -}: { - line: DiffLine; - lineNumber: number | null; - gutterWidth: number; - wrapLines: boolean; - hunkActionMode: HunkChatActionMode; - lineKey: string; - hoveredLineKey: string | null; - onHoverLine?: (lineKey: string) => void; - onLeaveLine?: () => void; - armedLineKey: string | null; - onArmLine?: (lineKey: string) => void; - onAddHunkReference?: () => void; - testID?: string; -}) { - if (line.type === "header") { - return ( - - ); - } - - return ( - onHoverLine?.(lineKey) : undefined} - onHoverOut={Platform.OS === "web" ? onLeaveLine : undefined} - style={[ - styles.diffLineContainer, - line.type === "add" && styles.addLineContainer, - line.type === "remove" && styles.removeLineContainer, - line.type === "context" && styles.contextLineContainer, - ]} - testID={testID} - > - {({ hovered, pressed }) => { - const showHunkAction = - Boolean(onAddHunkReference) && - (hunkActionMode === "tap-reveal" - ? armedLineKey === lineKey - : hoveredLineKey === lineKey || hovered || pressed); - - return ( - <> - { - if (armedLineKey !== lineKey) { - onArmLine?.(lineKey); - } - } - : undefined - } - onHoverLine={hunkActionMode === "hover" ? () => onHoverLine?.(lineKey) : undefined} - onLeaveLine={hunkActionMode === "hover" ? onLeaveLine : undefined} - testID={testID ? `${testID}-add-to-chat` : undefined} - /> - {line.tokens ? ( - - ) : ( - - {line.content || " "} - - )} - - ); - }} - - ); -} - -function SplitDiffCell({ - line, - gutterWidth, - wrapLines, - hunkActionMode, - lineKey, - hoveredLineKey, - onHoverLine, - onLeaveLine, - showFirstLineAction, - onAddHunkReference, - showDivider = false, - testID, -}: { - line: SplitDiffDisplayLine | null; - gutterWidth: number; - wrapLines: boolean; - hunkActionMode?: HunkChatActionMode; - lineKey?: string; - hoveredLineKey: string | null; - onHoverLine?: (lineKey: string) => void; - onLeaveLine?: () => void; - showFirstLineAction?: boolean; - onAddHunkReference?: () => void; - showDivider?: boolean; - testID?: string; -}) { - const cellContent = (showHunkAction: boolean) => ( - <> - onHoverLine?.(lineKey) : undefined - } - onLeaveLine={hunkActionMode === "hover" ? onLeaveLine : undefined} - testID={testID ? `${testID}-add-to-chat` : undefined} - /> - {line?.tokens ? ( - - ) : ( - - {line?.content ?? ""} - - )} - - ); - - if (!line) { - return ( - - {cellContent(false)} - - ); - } - - return ( - onHoverLine?.(lineKey) : undefined - } - onHoverOut={Platform.OS === "web" ? onLeaveLine : undefined} - testID={testID} - > - {({ hovered, pressed }) => - ( - - {cellContent( - Boolean(onAddHunkReference) && - (hunkActionMode === "tap-reveal" - ? Boolean(showFirstLineAction) - : hoveredLineKey === lineKey || hovered || pressed), - )} - - ) - } - - ); -} - -function SplitDiffRowView({ - row, - gutterWidth, - wrapLines, - hunkActionMode, - hoveredLineKey, - onHoverLine, - onLeaveLine, - onAddHunkReference, - testID, -}: { - row: Extract; - gutterWidth: number; - wrapLines: boolean; - hunkActionMode: HunkChatActionMode; - hoveredLineKey: string | null; - onHoverLine?: (lineKey: string) => void; - onLeaveLine?: () => void; - onAddHunkReference?: () => void; - testID?: string; -}) { - return ( - - - - - ); -} - const DiffFileHeader = memo(function DiffFileHeader({ file, isExpanded, @@ -656,183 +190,6 @@ const DiffFileHeader = memo(function DiffFileHeader({ ); }); -function DiffFileBody({ - file, - layout, - wrapLines, - hunkActionMode, - hoveredLineKey, - onHoverLine, - onLeaveLine, - armedLineKey, - onArmLine, - onClearArmedLine, - onAddHunkReference, - onBodyHeightChange, - testID, -}: { - file: ParsedDiffFile; - layout: "unified" | "split"; - wrapLines: boolean; - hunkActionMode: HunkChatActionMode; - hoveredLineKey: string | null; - onHoverLine?: (lineKey: string) => void; - onLeaveLine?: () => void; - armedLineKey: string | null; - onArmLine?: (lineKey: string) => void; - onClearArmedLine?: () => void; - onAddHunkReference?: (reference: string) => void; - onBodyHeightChange?: (path: string, height: number) => void; - testID?: string; -}) { - const [scrollViewWidth, setScrollViewWidth] = useState(0); - const [bodyWidth, setBodyWidth] = useState(0); - - return ( - { - setBodyWidth(event.nativeEvent.layout.width); - onBodyHeightChange?.(file.path, event.nativeEvent.layout.height); - }} - testID={testID} - > - {(() => { - if (file.status === "too_large" || file.status === "binary") { - return ( - - - {file.status === "binary" ? "Binary file" : "Diff too large to display"} - - - ); - } - - let maxLineNo = 0; - for (const hunk of file.hunks) { - maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount); - } - const gutterWidth = lineNumberGutterWidth(maxLineNo); - - const linesContent = - layout === "split" - ? buildSplitDiffRows(file).map((row, rowIndex) => { - if (row.kind === "header") { - return ( - - - - ); - } - - return ( - onAddHunkReference(row.chatReference) - : undefined - } - testID={testID ? `${testID}-hunk-${rowIndex}` : undefined} - /> - ); - }) - : file.hunks.map((hunk, hunkIndex) => { - let oldLineNo = hunk.oldStart; - let newLineNo = hunk.newStart; - return hunk.lines.map((line, lineIndex) => { - let lineNumber: number | null = null; - if (line.type === "remove") { - lineNumber = oldLineNo; - oldLineNo++; - } else if (line.type === "add") { - lineNumber = newLineNo; - newLineNo++; - } else if (line.type === "context") { - lineNumber = newLineNo; - oldLineNo++; - newLineNo++; - } - const currentLineKey = `${file.path}:${hunkIndex}:${lineIndex}`; - return ( - - { - onAddHunkReference( - buildHunkLineChatReference({ - path: file.path, - hunk, - lineIndex, - }), - ); - onClearArmedLine?.(); - } - : undefined - } - testID={testID ? `${testID}-hunk-${hunkIndex}-line-${lineIndex}` : undefined} - /> - ); - }); - }); - - const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth; - const contentContainer = ( - 0 && - (layout === "split" - ? { width: availableWidth, minWidth: availableWidth, maxWidth: availableWidth } - : { minWidth: availableWidth }), - ]} - > - {linesContent} - - ); - - if (wrapLines) { - return {contentContainer}; - } - return ( - - {contentContainer} - - ); - })()} - - ); -} - interface GitDiffPaneProps { serverId: string; workspaceId?: string | null; @@ -854,8 +211,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const router = useRouter(); const toast = useToast(); const closeToAgent = usePanelStore((state) => state.closeToAgent); - const [hoveredLineKey, setHoveredLineKey] = useState(null); - const [armedLineKey, setArmedLineKey] = useState(null); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [actionError, setActionError] = useState(null); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); @@ -1092,16 +447,10 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const handleDiffListScroll = useCallback( (event: NativeSyntheticEvent) => { - if (hoveredLineKey !== null) { - setHoveredLineKey(null); - } - if (armedLineKey !== null) { - setArmedLineKey(null); - } diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; scrollbar.onScroll(event); }, - [armedLineKey, hoveredLineKey, scrollbar.onScroll], + [scrollbar.onScroll], ); const handleDiffListLayout = useCallback( @@ -1307,30 +656,17 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi isExpanded={item.isExpanded} onToggle={handleToggleExpanded} onAddFileReference={handleAddFileReference} - onClearArmedLine={() => { - setHoveredLineKey(null); - setArmedLineKey(null); - }} onHeaderHeightChange={handleHeaderHeightChange} testID={`diff-file-${item.fileIndex}`} /> ); } return ( - setHoveredLineKey(null)} - armedLineKey={armedLineKey} - onArmLine={setArmedLineKey} - onClearArmedLine={() => { - setHoveredLineKey(null); - setArmedLineKey(null); - }} onAddHunkReference={handleAddHunkReference} onBodyHeightChange={handleBodyHeightChange} testID={`diff-file-${item.fileIndex}-body`} @@ -1338,7 +674,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi ); }, [ - armedLineKey, effectiveLayout, handleAddFileReference, handleAddHunkReference, @@ -1346,7 +681,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi handleHeaderHeightChange, handleToggleExpanded, hunkActionMode, - hoveredLineKey, wrapLines, ], ); @@ -1453,7 +787,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi renderItem={renderFlatItem} keyExtractor={flatKeyExtractor} stickyHeaderIndices={stickyHeaderIndices} - extraData={{ armedLineKey, expandedPathsArray, effectiveLayout, hoveredLineKey, wrapLines }} + extraData={{ expandedPathsArray, effectiveLayout, wrapLines }} style={styles.scrollView} contentContainerStyle={styles.contentContainer} testID="git-diff-scroll" @@ -2122,130 +1456,6 @@ const styles = StyleSheet.create((theme) => ({ splitColumnScroll: { flex: 1, }, - splitHeaderRow: { - backgroundColor: theme.colors.surface2, - paddingHorizontal: theme.spacing[3], - }, - splitCell: { - flex: 1, - flexBasis: 0, - backgroundColor: theme.colors.surface2, - }, - splitCellRow: { - flexDirection: "row", - alignItems: "stretch", - }, - emptySplitCell: { - backgroundColor: theme.colors.surfaceDiffEmpty, - }, - splitCellWithDivider: { - borderLeftWidth: theme.borderWidth[1], - borderLeftColor: theme.colors.border, - }, - diffLineContainer: { - flexDirection: "row", - alignItems: "stretch", - }, - lineNumberGutter: { - borderRightWidth: theme.borderWidth[1], - borderRightColor: theme.colors.border, - marginRight: theme.spacing[2], - alignSelf: "stretch", - justifyContent: "flex-start", - }, - lineNumberGutterActionContent: { - height: theme.lineHeight.diff, - alignSelf: "stretch", - alignItems: "flex-end", - justifyContent: "center", - paddingRight: theme.spacing[2], - }, - lineNumberText: { - textAlign: "right", - paddingRight: theme.spacing[2], - fontSize: theme.fontSize.xs, - lineHeight: theme.lineHeight.diff, - fontFamily: Fonts.mono, - color: theme.colors.foregroundMuted, - userSelect: "none", - }, - addLineNumberText: { - color: theme.colors.palette.green[400], - }, - removeLineNumberText: { - color: theme.colors.palette.red[500], - }, - diffLineText: { - flex: 1, - paddingRight: theme.spacing[3], - fontSize: theme.fontSize.xs, - lineHeight: theme.lineHeight.diff, - fontFamily: Fonts.mono, - color: theme.colors.foreground, - userSelect: "text", - }, - addLineContainer: { - backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green - }, - addLineText: { - color: theme.colors.foreground, - }, - removeLineContainer: { - backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red - }, - removeLineText: { - color: theme.colors.foreground, - }, - headerLineContainer: { - backgroundColor: theme.colors.surface2, - }, - headerLineText: { - color: theme.colors.foregroundMuted, - }, - hunkHeaderText: { - flexShrink: 1, - paddingRight: theme.spacing[2], - }, - contextLineContainer: { - backgroundColor: theme.colors.surface1, - }, - contextLineText: { - color: theme.colors.foregroundMuted, - }, - chatReferenceButton: { - alignItems: "center", - justifyContent: "center", - width: { - xs: 28, - sm: 28, - md: 24, - }, - height: { - xs: 28, - sm: 28, - md: 24, - }, - borderRadius: theme.borderRadius.base, - flexShrink: 0, - }, - chatReferenceButtonHovered: { - backgroundColor: theme.colors.surface3, - }, - emptySplitCellText: { - color: "transparent", - }, - statusMessageContainer: { - borderTopWidth: theme.borderWidth[1], - borderTopColor: theme.colors.border, - backgroundColor: theme.colors.surface1, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[4], - }, - statusMessageText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - fontStyle: "italic", - }, tooltipText: { fontSize: theme.fontSize.xs, color: theme.colors.foreground, diff --git a/packages/app/src/utils/active-chat-composer.test.ts b/packages/app/src/utils/active-chat-composer.test.ts index 5a31a40bf..e00436035 100644 --- a/packages/app/src/utils/active-chat-composer.test.ts +++ b/packages/app/src/utils/active-chat-composer.test.ts @@ -1,109 +1,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { __activeChatComposerTestUtils, - appendTextTokenToComposer, - buildComposerInsertResult, insertIntoActiveChatComposer, - insertTextAtSelection, markActiveChatComposer, registerActiveChatComposer, } from "./active-chat-composer"; - afterEach(() => { __activeChatComposerTestUtils.reset(); }); -describe("insertTextAtSelection", () => { - it("replaces the selected text and collapses the caret after the inserted token", () => { - const result = insertTextAtSelection({ - value: "review old text now", - insertedText: "src/example.ts:12-18", - selection: { start: 7, end: 15 }, - }); - - expect(result).toEqual({ - text: "review src/example.ts:12-18 now", - selection: { start: 27, end: 27 }, - }); - }); - - it("inserts at a collapsed caret", () => { - const result = insertTextAtSelection({ - value: "review this", - insertedText: "src/example.ts:9", - selection: { start: 7, end: 7 }, - }); - - expect(result).toEqual({ - text: "review src/example.ts:9 this", - selection: { start: 24, end: 24 }, - }); - }); - - it("adds a trailing space when inserting at the end", () => { - const result = insertTextAtSelection({ - value: "review", - insertedText: "src/example.ts:9", - selection: { start: 6, end: 6 }, - }); - - expect(result).toEqual({ - text: "reviewsrc/example.ts:9 ", - selection: { start: 23, end: 23 }, - }); - }); -}); - -describe("appendTextTokenToComposer", () => { - it("appends a token with minimal whitespace normalization", () => { - expect( - appendTextTokenToComposer({ - value: "review", - token: "src/example.ts:12-18", - }), - ).toBe("review src/example.ts:12-18 "); - }); - - it("does not add extra blank lines when the composer already ends with a newline", () => { - expect( - appendTextTokenToComposer({ - value: "review this\n", - token: "src/example.ts", - }), - ).toBe("review this\nsrc/example.ts "); - }); -}); - -describe("buildComposerInsertResult", () => { - it("uses the stored selection when one is known", () => { - const result = buildComposerInsertResult({ - value: "review old text now", - token: "src/example.ts:12-18", - selection: { start: 7, end: 15 }, - hasKnownSelection: true, - }); - - expect(result).toEqual({ - text: "review src/example.ts:12-18 now", - selection: { start: 27, end: 27 }, - }); - }); - - it("falls back to append when no prior selection is known", () => { - const result = buildComposerInsertResult({ - value: "review this", - token: "src/example.ts", - selection: { start: 0, end: 0 }, - hasKnownSelection: false, - }); - - expect(result).toEqual({ - text: "review this src/example.ts ", - selection: { start: 27, end: 27 }, - }); - }); -}); - describe("active chat composer registry", () => { it("inserts into the last active composer", () => { const insertText = vi.fn(() => true); diff --git a/packages/app/src/utils/active-chat-composer.ts b/packages/app/src/utils/active-chat-composer.ts index ebeff195e..cd87c38f5 100644 --- a/packages/app/src/utils/active-chat-composer.ts +++ b/packages/app/src/utils/active-chat-composer.ts @@ -1,13 +1,3 @@ -export interface ComposerSelection { - start: number; - end: number; -} - -export interface InsertTextAtSelectionResult { - text: string; - selection: ComposerSelection; -} - interface ActiveChatComposerHandle { insertText: (text: string) => boolean; activateTab?: () => void; @@ -16,111 +6,6 @@ interface ActiveChatComposerHandle { const activeChatComposerHandles = new Map(); let activeChatComposerId: string | null = null; -function clampSelectionIndex(value: number, max: number): number { - if (!Number.isFinite(value)) { - return 0; - } - return Math.max(0, Math.min(max, Math.trunc(value))); -} - -function withTrailingInsertionSpace(input: { - value: string; - insertedText: string; - selectionEnd: number; -}): string { - const insertedText = input.insertedText ?? ""; - if (!insertedText || /\s$/.test(insertedText)) { - return insertedText; - } - - const nextCharacter = input.value.slice(input.selectionEnd, input.selectionEnd + 1); - if (!nextCharacter || !/\s/.test(nextCharacter)) { - return `${insertedText} `; - } - - return insertedText; -} - -export function insertTextAtSelection(input: { - value: string; - insertedText: string; - selection: ComposerSelection; -}): InsertTextAtSelectionResult { - const value = input.value ?? ""; - const insertedText = input.insertedText ?? ""; - const start = clampSelectionIndex(input.selection.start, value.length); - const end = clampSelectionIndex(input.selection.end, value.length); - const selectionStart = Math.min(start, end); - const selectionEnd = Math.max(start, end); - const normalizedInsertedText = withTrailingInsertionSpace({ - value, - insertedText: input.insertedText ?? "", - selectionEnd, - }); - const text = - value.slice(0, selectionStart) + - normalizedInsertedText + - value.slice(selectionEnd, value.length); - const cursor = selectionStart + normalizedInsertedText.length; - - return { - text, - selection: { start: cursor, end: cursor }, - }; -} - -export function buildComposerInsertResult(input: { - value: string; - token: string; - selection: ComposerSelection; - hasKnownSelection: boolean; -}): InsertTextAtSelectionResult { - const token = input.token.trim(); - if (!token) { - const clampedStart = clampSelectionIndex(input.selection.start, input.value.length); - const clampedEnd = clampSelectionIndex(input.selection.end, input.value.length); - return { - text: input.value, - selection: { - start: Math.min(clampedStart, clampedEnd), - end: Math.max(clampedStart, clampedEnd), - }, - }; - } - - if (input.hasKnownSelection) { - return insertTextAtSelection({ - value: input.value, - insertedText: token, - selection: input.selection, - }); - } - - const text = appendTextTokenToComposer({ - value: input.value, - token, - }); - return { - text, - selection: { - start: text.length, - end: text.length, - }, - }; -} - -export function appendTextTokenToComposer(input: { value: string; token: string }): string { - const value = input.value ?? ""; - const token = input.token.trim(); - if (!token) { - return value; - } - - const leadingWhitespace = value.length > 0 && !/\s$/.test(value) ? " " : ""; - const trailingWhitespace = /\s$/.test(token) ? "" : " "; - return `${value}${leadingWhitespace}${token}${trailingWhitespace}`; -} - export function registerActiveChatComposer(input: { id: string; handle: ActiveChatComposerHandle; diff --git a/packages/app/src/utils/chat-reference-token.ts b/packages/app/src/utils/chat-reference-token.ts index 09a10609b..798c5727b 100644 --- a/packages/app/src/utils/chat-reference-token.ts +++ b/packages/app/src/utils/chat-reference-token.ts @@ -1,4 +1,10 @@ import type { DiffHunk } from "@/hooks/use-checkout-diff-query"; +import { + buildChangeBlockReferenceRange, + buildContextReferenceRange, + buildHunkLinePositions, + findContiguousChangeBlock, +} from "./diff-chat-reference"; function formatLineRange(input: { path: string; start: number; count: number }): string { const count = Math.max(1, input.count); @@ -46,10 +52,6 @@ export function buildHunkChatReference(input: { path: string; hunk: DiffHunk }): }); } -function isChangeLineType(type: DiffHunk["lines"][number]["type"]): boolean { - return type === "add" || type === "remove"; -} - export function buildHunkLineChatReference(input: { path: string; hunk: DiffHunk; @@ -61,108 +63,38 @@ export function buildHunkLineChatReference(input: { return buildHunkChatReference({ path, hunk }); } - let oldLineNo = hunk.oldStart; - let newLineNo = hunk.newStart; - const positions = hunk.lines.map((currentLine) => { - const oldLineNumber = - currentLine.type === "remove" || currentLine.type === "context" ? oldLineNo : null; - const newLineNumber = - currentLine.type === "add" || currentLine.type === "context" ? newLineNo : null; - - if (currentLine.type === "remove") { - oldLineNo += 1; - } else if (currentLine.type === "add") { - newLineNo += 1; - } else if (currentLine.type === "context") { - oldLineNo += 1; - newLineNo += 1; - } - - return { - type: currentLine.type, - oldLineNumber, - newLineNumber, - }; + const positions = buildHunkLinePositions(hunk); + const contextRange = buildContextReferenceRange({ + hunk, + positions, + lineIndex, }); - - const currentPosition = positions[lineIndex]; - if (!currentPosition) { - return buildHunkChatReference({ path, hunk }); - } - - if (currentPosition.type === "context") { + if (contextRange) { return buildDiffRangeChatReference({ path, - oldStart: currentPosition.oldLineNumber ?? currentPosition.newLineNumber ?? hunk.oldStart, - oldCount: currentPosition.oldLineNumber != null ? 1 : 0, - newStart: currentPosition.newLineNumber ?? currentPosition.oldLineNumber ?? hunk.newStart, - newCount: currentPosition.newLineNumber != null ? 1 : 0, + ...contextRange, }); } - let startIndex = lineIndex; - while (startIndex > 1 && isChangeLineType(hunk.lines[startIndex - 1]?.type)) { - startIndex -= 1; - } - - let endIndex = lineIndex; - while (endIndex + 1 < hunk.lines.length && isChangeLineType(hunk.lines[endIndex + 1]?.type)) { - endIndex += 1; - } - - let oldStart: number | null = null; - let oldCount = 0; - let newStart: number | null = null; - let newCount = 0; - - for (let index = startIndex; index <= endIndex; index += 1) { - const position = positions[index]; - if (!position) { - continue; - } - if (position.type === "remove" && position.oldLineNumber != null) { - oldStart ??= position.oldLineNumber; - oldCount += 1; - } else if (position.type === "add" && position.newLineNumber != null) { - newStart ??= position.newLineNumber; - newCount += 1; - } + const changeBlock = findContiguousChangeBlock({ + hunk, + lineIndex, + }); + if (!changeBlock) { + return buildHunkChatReference({ path, hunk }); } - if (newCount === 0 && oldStart != null && oldCount > 0) { - const previousPosition = positions[startIndex - 1]; - const nextPosition = positions[endIndex + 1]; - const surroundingNewStart = - previousPosition?.type === "context" - ? previousPosition.newLineNumber - : nextPosition?.type === "context" - ? nextPosition.newLineNumber - : null; - const surroundingNewEnd = - nextPosition?.type === "context" - ? nextPosition.newLineNumber - : previousPosition?.type === "context" - ? previousPosition.newLineNumber - : null; - - if (surroundingNewStart != null && surroundingNewEnd != null) { - return buildDiffRangeChatReference({ - path, - oldStart, - oldCount, - newStart: surroundingNewStart, - newCount: surroundingNewEnd - surroundingNewStart + 1, - }); - } + const range = buildChangeBlockReferenceRange({ + hunk, + positions, + ...changeBlock, + }); + if (!range) { + return buildHunkChatReference({ path, hunk }); } - const fallbackStart = newStart ?? oldStart ?? hunk.newStart ?? hunk.oldStart; - return buildDiffRangeChatReference({ path, - oldStart: oldStart ?? fallbackStart, - oldCount, - newStart: newStart ?? fallbackStart, - newCount, + ...range, }); } diff --git a/packages/app/src/utils/composer-text-insert.test.ts b/packages/app/src/utils/composer-text-insert.test.ts new file mode 100644 index 000000000..06d9bdc2e --- /dev/null +++ b/packages/app/src/utils/composer-text-insert.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + appendTextTokenToComposer, + buildComposerInsertResult, + insertTextAtSelection, +} from "./composer-text-insert"; + +describe("insertTextAtSelection", () => { + it("replaces the selected text and collapses the caret after the inserted token", () => { + const result = insertTextAtSelection({ + value: "review old text now", + insertedText: "src/example.ts:12-18", + selection: { start: 7, end: 15 }, + }); + + expect(result).toEqual({ + text: "review src/example.ts:12-18 now", + selection: { start: 27, end: 27 }, + }); + }); + + it("inserts at a collapsed caret", () => { + const result = insertTextAtSelection({ + value: "review this", + insertedText: "src/example.ts:9", + selection: { start: 7, end: 7 }, + }); + + expect(result).toEqual({ + text: "review src/example.ts:9 this", + selection: { start: 24, end: 24 }, + }); + }); + + it("adds a trailing space when inserting at the end", () => { + const result = insertTextAtSelection({ + value: "review", + insertedText: "src/example.ts:9", + selection: { start: 6, end: 6 }, + }); + + expect(result).toEqual({ + text: "reviewsrc/example.ts:9 ", + selection: { start: 23, end: 23 }, + }); + }); +}); + +describe("appendTextTokenToComposer", () => { + it("appends a token with minimal whitespace normalization", () => { + expect( + appendTextTokenToComposer({ + value: "review", + token: "src/example.ts:12-18", + }), + ).toBe("review src/example.ts:12-18 "); + }); + + it("does not add extra blank lines when the composer already ends with a newline", () => { + expect( + appendTextTokenToComposer({ + value: "review this\n", + token: "src/example.ts", + }), + ).toBe("review this\nsrc/example.ts "); + }); +}); + +describe("buildComposerInsertResult", () => { + it("uses the stored selection when one is known", () => { + const result = buildComposerInsertResult({ + value: "review old text now", + token: "src/example.ts:12-18", + selection: { start: 7, end: 15 }, + hasKnownSelection: true, + }); + + expect(result).toEqual({ + text: "review src/example.ts:12-18 now", + selection: { start: 27, end: 27 }, + }); + }); + + it("falls back to append when no prior selection is known", () => { + const result = buildComposerInsertResult({ + value: "review this", + token: "src/example.ts", + selection: { start: 0, end: 0 }, + hasKnownSelection: false, + }); + + expect(result).toEqual({ + text: "review this src/example.ts ", + selection: { start: 27, end: 27 }, + }); + }); +}); diff --git a/packages/app/src/utils/composer-text-insert.ts b/packages/app/src/utils/composer-text-insert.ts new file mode 100644 index 000000000..c6b3feb39 --- /dev/null +++ b/packages/app/src/utils/composer-text-insert.ts @@ -0,0 +1,113 @@ +export interface ComposerSelection { + start: number; + end: number; +} + +export interface InsertTextAtSelectionResult { + text: string; + selection: ComposerSelection; +} + +function clampSelectionIndex(value: number, max: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.min(max, Math.trunc(value))); +} + +function withTrailingInsertionSpace(input: { + value: string; + insertedText: string; + selectionEnd: number; +}): string { + const insertedText = input.insertedText ?? ""; + if (!insertedText || /\s$/.test(insertedText)) { + return insertedText; + } + + const nextCharacter = input.value.slice(input.selectionEnd, input.selectionEnd + 1); + if (!nextCharacter || !/\s/.test(nextCharacter)) { + return `${insertedText} `; + } + + return insertedText; +} + +export function insertTextAtSelection(input: { + value: string; + insertedText: string; + selection: ComposerSelection; +}): InsertTextAtSelectionResult { + const value = input.value ?? ""; + const start = clampSelectionIndex(input.selection.start, value.length); + const end = clampSelectionIndex(input.selection.end, value.length); + const selectionStart = Math.min(start, end); + const selectionEnd = Math.max(start, end); + const normalizedInsertedText = withTrailingInsertionSpace({ + value, + insertedText: input.insertedText ?? "", + selectionEnd, + }); + const text = + value.slice(0, selectionStart) + + normalizedInsertedText + + value.slice(selectionEnd, value.length); + const cursor = selectionStart + normalizedInsertedText.length; + + return { + text, + selection: { start: cursor, end: cursor }, + }; +} + +export function buildComposerInsertResult(input: { + value: string; + token: string; + selection: ComposerSelection; + hasKnownSelection: boolean; +}): InsertTextAtSelectionResult { + const token = input.token.trim(); + if (!token) { + const clampedStart = clampSelectionIndex(input.selection.start, input.value.length); + const clampedEnd = clampSelectionIndex(input.selection.end, input.value.length); + return { + text: input.value, + selection: { + start: Math.min(clampedStart, clampedEnd), + end: Math.max(clampedStart, clampedEnd), + }, + }; + } + + if (input.hasKnownSelection) { + return insertTextAtSelection({ + value: input.value, + insertedText: token, + selection: input.selection, + }); + } + + const text = appendTextTokenToComposer({ + value: input.value, + token, + }); + return { + text, + selection: { + start: text.length, + end: text.length, + }, + }; +} + +export function appendTextTokenToComposer(input: { value: string; token: string }): string { + const value = input.value ?? ""; + const token = input.token.trim(); + if (!token) { + return value; + } + + const leadingWhitespace = value.length > 0 && !/\s$/.test(value) ? " " : ""; + const trailingWhitespace = /\s$/.test(token) ? "" : " "; + return `${value}${leadingWhitespace}${token}${trailingWhitespace}`; +} diff --git a/packages/app/src/utils/diff-chat-reference.ts b/packages/app/src/utils/diff-chat-reference.ts new file mode 100644 index 000000000..6831974b4 --- /dev/null +++ b/packages/app/src/utils/diff-chat-reference.ts @@ -0,0 +1,152 @@ +import type { DiffHunk } from "@/hooks/use-checkout-diff-query"; + +export interface DiffReferenceRange { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; +} + +export interface DiffHunkLinePosition { + type: DiffHunk["lines"][number]["type"]; + oldLineNumber: number | null; + newLineNumber: number | null; +} + +function isChangeLineType(type: DiffHunk["lines"][number]["type"]): boolean { + return type === "add" || type === "remove"; +} + +export function buildHunkLinePositions(hunk: DiffHunk): DiffHunkLinePosition[] { + let oldLineNo = hunk.oldStart; + let newLineNo = hunk.newStart; + + return hunk.lines.map((line) => { + const oldLineNumber = line.type === "remove" || line.type === "context" ? oldLineNo : null; + const newLineNumber = line.type === "add" || line.type === "context" ? newLineNo : null; + + if (line.type === "remove") { + oldLineNo += 1; + } else if (line.type === "add") { + newLineNo += 1; + } else if (line.type === "context") { + oldLineNo += 1; + newLineNo += 1; + } + + return { + type: line.type, + oldLineNumber, + newLineNumber, + }; + }); +} + +export function buildContextReferenceRange(input: { + hunk: DiffHunk; + positions: DiffHunkLinePosition[]; + lineIndex: number; +}): DiffReferenceRange | null { + const position = input.positions[input.lineIndex]; + if (!position || position.type !== "context") { + return null; + } + + return { + oldStart: position.oldLineNumber ?? position.newLineNumber ?? input.hunk.oldStart, + oldCount: position.oldLineNumber != null ? 1 : 0, + newStart: position.newLineNumber ?? position.oldLineNumber ?? input.hunk.newStart, + newCount: position.newLineNumber != null ? 1 : 0, + }; +} + +export function findContiguousChangeBlock(input: { + hunk: DiffHunk; + lineIndex: number; +}): { startIndex: number; endIndex: number } | null { + const line = input.hunk.lines[input.lineIndex]; + if (!line || !isChangeLineType(line.type)) { + return null; + } + + let startIndex = input.lineIndex; + while (startIndex > 1 && isChangeLineType(input.hunk.lines[startIndex - 1]?.type)) { + startIndex -= 1; + } + + let endIndex = input.lineIndex; + while ( + endIndex + 1 < input.hunk.lines.length && + isChangeLineType(input.hunk.lines[endIndex + 1]?.type) + ) { + endIndex += 1; + } + + return { startIndex, endIndex }; +} + +export function buildChangeBlockReferenceRange(input: { + hunk: DiffHunk; + positions: DiffHunkLinePosition[]; + startIndex: number; + endIndex: number; +}): DiffReferenceRange | null { + let oldStart: number | null = null; + let oldCount = 0; + let newStart: number | null = null; + let newCount = 0; + + for (let index = input.startIndex; index <= input.endIndex; index += 1) { + const position = input.positions[index]; + if (!position) { + continue; + } + + if (position.type === "remove" && position.oldLineNumber != null) { + oldStart ??= position.oldLineNumber; + oldCount += 1; + } else if (position.type === "add" && position.newLineNumber != null) { + newStart ??= position.newLineNumber; + newCount += 1; + } + } + + if (newCount === 0 && oldStart != null && oldCount > 0) { + const previousPosition = input.positions[input.startIndex - 1]; + const nextPosition = input.positions[input.endIndex + 1]; + const surroundingNewStart = + previousPosition?.type === "context" + ? previousPosition.newLineNumber + : nextPosition?.type === "context" + ? nextPosition.newLineNumber + : null; + const surroundingNewEnd = + nextPosition?.type === "context" + ? nextPosition.newLineNumber + : previousPosition?.type === "context" + ? previousPosition.newLineNumber + : null; + + if (surroundingNewStart != null && surroundingNewEnd != null) { + return { + oldStart, + oldCount, + newStart: surroundingNewStart, + newCount: surroundingNewEnd - surroundingNewStart + 1, + }; + } + } + + if (oldStart == null && newStart == null) { + return null; + } + + const fallbackStart = newStart ?? oldStart ?? input.hunk.newStart ?? input.hunk.oldStart; + + return { + oldStart: oldStart ?? fallbackStart, + oldCount, + newStart: newStart ?? fallbackStart, + newCount, + }; +} diff --git a/packages/app/src/utils/diff-layout.ts b/packages/app/src/utils/diff-layout.ts index f12e138aa..8287933e8 100644 --- a/packages/app/src/utils/diff-layout.ts +++ b/packages/app/src/utils/diff-layout.ts @@ -1,5 +1,11 @@ import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query"; import { buildDiffRangeChatReference } from "./chat-reference-token"; +import { + buildChangeBlockReferenceRange, + buildContextReferenceRange, + buildHunkLinePositions, + type DiffHunkLinePosition, +} from "./diff-chat-reference"; export interface SplitDiffDisplayLine { type: DiffLine["type"]; @@ -25,11 +31,10 @@ export type SplitDiffRow = function toDisplayLine(input: { line: DiffLine; - oldLineNumber: number | null; - newLineNumber: number | null; + position: DiffHunkLinePosition; side: "left" | "right"; }): SplitDiffDisplayLine | null { - const { line, oldLineNumber, newLineNumber, side } = input; + const { line, position, side } = input; if (line.type === "header") { return null; } @@ -42,7 +47,7 @@ function toDisplayLine(input: { type: "remove", content: line.content, tokens: line.tokens, - lineNumber: oldLineNumber, + lineNumber: position.oldLineNumber, }; } @@ -54,7 +59,7 @@ function toDisplayLine(input: { type: "add", content: line.content, tokens: line.tokens, - lineNumber: newLineNumber, + lineNumber: position.newLineNumber, }; } @@ -62,7 +67,7 @@ function toDisplayLine(input: { type: "context", content: line.content, tokens: line.tokens, - lineNumber: side === "left" ? oldLineNumber : newLineNumber, + lineNumber: side === "left" ? position.oldLineNumber : position.newLineNumber, }; } @@ -70,18 +75,16 @@ export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { const rows: SplitDiffRow[] = []; for (const [hunkIndex, hunk] of file.hunks.entries()) { - let oldLineNo = hunk.oldStart; - let newLineNo = hunk.newStart; + const positions = buildHunkLinePositions(hunk); let hasChangedLine = false; - let previousContextNewLineNumber: number | null = null; rows.push({ kind: "header", content: hunk.lines[0]?.type === "header" ? hunk.lines[0].content : "@@", hunkIndex, }); - let pendingRemovals: Array<{ line: DiffLine; oldLineNumber: number }> = []; - let pendingAdditions: Array<{ line: DiffLine; newLineNumber: number }> = []; + let pendingRemovalIndices: number[] = []; + let pendingAdditionIndices: number[] = []; const pushPairRow = (input: { chatReference: string; @@ -102,103 +105,109 @@ export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { } }; - const flushPendingRows = (nextContextNewLineNumber?: number | null) => { - const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length); - const fallbackStart = - pendingAdditions[0]?.newLineNumber ?? pendingRemovals[0]?.oldLineNumber ?? hunk.newStart; - const oldStart = pendingRemovals[0]?.oldLineNumber ?? fallbackStart; - const oldCount = pendingRemovals.length; - const newStart = pendingAdditions[0]?.newLineNumber ?? fallbackStart; - const newCount = pendingAdditions.length; - const surroundingNewStart = - previousContextNewLineNumber ?? nextContextNewLineNumber ?? null; - const surroundingNewEnd = - nextContextNewLineNumber ?? previousContextNewLineNumber ?? null; - const chatReference = - newCount === 0 && oldCount > 0 && surroundingNewStart != null && surroundingNewEnd != null - ? buildDiffRangeChatReference({ - path: file.path, - oldStart, - oldCount, - newStart: surroundingNewStart, - newCount: surroundingNewEnd - surroundingNewStart + 1, - }) - : buildDiffRangeChatReference({ - path: file.path, - oldStart, - oldCount, - newStart, - newCount, - }); + const flushPendingRows = () => { + const pairCount = Math.max(pendingRemovalIndices.length, pendingAdditionIndices.length); + if (pairCount === 0) { + return; + } + + const range = buildChangeBlockReferenceRange({ + hunk, + positions, + startIndex: + Math.min( + pendingRemovalIndices[0] ?? Number.POSITIVE_INFINITY, + pendingAdditionIndices[0] ?? Number.POSITIVE_INFINITY, + ) || 0, + endIndex: + Math.max( + pendingRemovalIndices[pendingRemovalIndices.length - 1] ?? Number.NEGATIVE_INFINITY, + pendingAdditionIndices[pendingAdditionIndices.length - 1] ?? Number.NEGATIVE_INFINITY, + ) || 0, + }); + const chatReference = buildDiffRangeChatReference({ + path: file.path, + ...(range ?? { + oldStart: hunk.oldStart, + oldCount: hunk.oldCount, + newStart: hunk.newStart, + newCount: hunk.newCount, + }), + }); + for (let index = 0; index < pairCount; index += 1) { - const removal = pendingRemovals[index] ?? null; - const addition = pendingAdditions[index] ?? null; + const removalIndex = pendingRemovalIndices[index]; + const additionIndex = pendingAdditionIndices[index]; + const removalLine = removalIndex != null ? hunk.lines[removalIndex] : null; + const additionLine = additionIndex != null ? hunk.lines[additionIndex] : null; pushPairRow({ chatReference, hasChanges: true, - left: removal + left: removalLine && removalIndex != null ? toDisplayLine({ - line: removal.line, - oldLineNumber: removal.oldLineNumber, - newLineNumber: null, + line: removalLine, + position: positions[removalIndex], side: "left", }) : null, - right: addition + right: additionLine && additionIndex != null ? toDisplayLine({ - line: addition.line, - oldLineNumber: null, - newLineNumber: addition.newLineNumber, + line: additionLine, + position: positions[additionIndex], side: "right", }) : null, }); } - pendingRemovals = []; - pendingAdditions = []; + pendingRemovalIndices = []; + pendingAdditionIndices = []; }; - for (const line of hunk.lines.slice(1)) { + for (const [lineIndex, line] of hunk.lines.entries()) { + if (lineIndex === 0) { + continue; + } + if (line.type === "remove") { - pendingRemovals.push({ line, oldLineNumber: oldLineNo }); - oldLineNo += 1; + pendingRemovalIndices.push(lineIndex); continue; } if (line.type === "add") { - pendingAdditions.push({ line, newLineNumber: newLineNo }); - newLineNo += 1; + pendingAdditionIndices.push(lineIndex); continue; } - flushPendingRows(newLineNo); + flushPendingRows(); if (line.type === "context") { + const range = buildContextReferenceRange({ + hunk, + positions, + lineIndex, + }); + const position = positions[lineIndex]; + if (!range || !position) { + continue; + } + pushPairRow({ chatReference: buildDiffRangeChatReference({ path: file.path, - oldStart: oldLineNo, - oldCount: 1, - newStart: newLineNo, - newCount: 1, + ...range, }), hasChanges: false, left: toDisplayLine({ line, - oldLineNumber: oldLineNo, - newLineNumber: newLineNo, + position, side: "left", }), right: toDisplayLine({ line, - oldLineNumber: oldLineNo, - newLineNumber: newLineNo, + position, side: "right", }), }); - oldLineNo += 1; - newLineNo += 1; - previousContextNewLineNumber = newLineNo - 1; } }