diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 847f23488..0156a4de5 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -48,6 +48,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 { + markActiveChatComposer, + registerActiveChatComposer, +} from "@/utils/active-chat-composer"; +import { buildComposerInsertResult } from "@/utils/composer-text-insert"; type QueuedMessage = { id: string; @@ -77,6 +82,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). */ @@ -107,6 +114,7 @@ export function AgentInputArea({ autoFocus = false, onAddImages, onFocusInput, + onActivateTab, commandDraftConfig, onMessageSent, onComposerHeightChange, @@ -168,9 +176,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, @@ -195,6 +208,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< @@ -215,7 +232,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: () => { @@ -229,6 +249,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?.(); @@ -746,11 +819,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/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-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 ea9e86320..97e030e50 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -18,7 +18,6 @@ import { 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"; @@ -41,22 +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, @@ -67,11 +56,22 @@ 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 { + 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"; import { usePanelStore } from "@/stores/panel-store"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; @@ -81,328 +81,22 @@ 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; onToggle: (path: string) => void; + onAddFileReference?: (file: ParsedDiffFile) => void; + onClearArmedLine?: () => void; onHeaderHeightChange?: (path: string, height: number) => void; testID?: string; } -function lineTypeBackground(type: DiffLine["type"] | undefined | null) { - if (!type) return styles.emptySplitCell; - if (type === "add") return styles.addLineContainer; - if (type === "remove") return styles.removeLineContainer; - if (type === "header") return styles.headerLineContainer; - return styles.contextLineContainer; -} - -function DiffGutterCell({ - lineNumber, - type, - gutterWidth, -}: { - lineNumber: number | null; - type: DiffLine["type"] | undefined | null; - gutterWidth: number; -}) { - return ( - - - {lineNumber != null ? String(lineNumber) : ""} - - - ); -} - -function DiffTextLine({ - line, - wrapLines, -}: { - line: DiffLine; - wrapLines: boolean; -}) { - return ( - - {line.tokens && line.type !== "header" ? ( - - ) : ( - - {line.content || " "} - - )} - - ); -} - -function SplitTextLine({ - line, - wrapLines, -}: { - line: SplitDiffDisplayLine | null; - wrapLines: boolean; -}) { - return ( - - {line?.tokens ? ( - - ) : ( - - {line?.content ?? ""} - - )} - - ); -} - -function DiffLineView({ - line, - lineNumber, - gutterWidth, - wrapLines, -}: { - line: DiffLine; - lineNumber: number | null; - gutterWidth: number; - wrapLines: boolean; -}) { - return ( - - - - {lineNumber != null ? String(lineNumber) : ""} - - - {line.tokens && line.type !== "header" ? ( - - ) : ( - - {line.content || " "} - - )} - - ); -} - -function SplitDiffLine({ - line, - gutterWidth, - wrapLines, -}: { - line: SplitDiffDisplayLine | null; - gutterWidth: number; - wrapLines: boolean; -}) { - return ( - - - - {line?.lineNumber != null ? String(line.lineNumber) : ""} - - - {line?.tokens ? ( - - ) : ( - - {line?.content ?? ""} - - )} - - ); -} - -function SplitDiffColumn({ - rows, - side, - gutterWidth, - wrapLines, - showDivider = false, -}: { - rows: SplitDiffRow[]; - side: "left" | "right"; - gutterWidth: number; - wrapLines: boolean; - showDivider?: boolean; -}) { - const [scrollWidth, setScrollWidth] = useState(0); - - if (wrapLines) { - return ( - - - {rows.map((row, i) => { - if (row.kind === "header") { - return ( - - {row.content} - - ); - } - return ( - - ); - })} - - - ); - } - - return ( - - - {rows.map((row, i) => { - if (row.kind === "header") { - return ; - } - const line = side === "left" ? row.left : row.right; - return ; - })} - - - 0 && { minWidth: scrollWidth }]}> - {rows.map((row, i) => { - if (row.kind === "header") { - return ( - - {row.content} - - ); - } - return ; - })} - - - - ); -} - const DiffFileHeader = memo(function DiffFileHeader({ file, isExpanded, onToggle, + onAddFileReference, + onClearArmedLine, onHeaderHeightChange, testID, }: DiffFileSectionProps) { @@ -412,8 +106,9 @@ const DiffFileHeader = memo(function DiffFileHeader({ const toggleExpanded = useCallback(() => { pressHandledRef.current = true; + onClearArmedLine?.(); onToggle(file.path); - }, [file.path, onToggle]); + }, [file.path, onClearArmedLine, onToggle]); return ( - [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} - - - - ); -}); - -function DiffFileBody({ - file, - layout, - wrapLines, - onBodyHeightChange, - testID, -}: { - file: ParsedDiffFile; - layout: "unified" | "split"; - wrapLines: boolean; - 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); - - if (layout === "split") { - const rows = buildSplitDiffRows(file); - return ( - - - - - ); - } - - const computedLines: { line: DiffLine; lineNumber: number | null; key: string }[] = []; - 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++; - } else if (line.type === "add") { - lineNumber = newLineNo; - newLineNo++; - } else if (line.type === "context") { - lineNumber = newLineNo; - oldLineNo++; - newLineNo++; - } - computedLines.push({ line, lineNumber, key: `${hunkIndex}-${lineIndex}` }); - } - } - - if (wrapLines) { - return ( - - - {computedLines.map(({ line, lineNumber, key }) => ( - - ))} + }} + onPress={toggleExpanded} + > + + {file.path.split("/").pop()} + + {file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""} + + {file.isNew && ( + + New - - ); - } - - const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth; - return ( - - - {computedLines.map(({ line, lineNumber, key }) => ( - - ))} - - - 0 && { minWidth: availableWidth }]}> - {computedLines.map(({ line, key }) => ( - - ))} + )} + {file.isDeleted && ( + + Deleted - + )} - ); - })()} + + +{file.additions} + -{file.deletions} + + + {onAddFileReference ? ( + { + onClearArmedLine?.(); + onAddFileReference(file); + }} + testID={testID ? `${testID}-add-to-chat` : undefined} + /> + ) : null} + ); -} +}); interface GitDiffPaneProps { serverId: string; @@ -610,7 +206,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" : "tap-reveal"; 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); @@ -635,6 +235,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, @@ -999,16 +655,19 @@ 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}`} /> ); } return ( - @@ -1016,9 +675,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi }, [ effectiveLayout, + handleAddFileReference, + handleAddHunkReference, handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, + hunkActionMode, wrapLines, ], ); @@ -1675,17 +1337,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, }, @@ -1763,6 +1432,10 @@ const styles = StyleSheet.create((theme) => ({ linesContainer: { backgroundColor: theme.colors.surface1, }, + splitLinesContainer: { + backgroundColor: theme.colors.surface1, + minWidth: 760, + }, gutterColumn: { backgroundColor: theme.colors.surface1, }, @@ -1783,100 +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", - }, - 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, - }, - contextLineContainer: { - backgroundColor: theme.colors.surface1, - }, - contextLineText: { - color: theme.colors.foregroundMuted, - }, - 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/message-input.tsx b/packages/app/src/components/message-input.tsx index 8e18b3706..17bca81a4 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -102,6 +102,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. @@ -234,6 +235,7 @@ export const MessageInput = forwardRef(funct null, ); const isInputFocusedRef = useRef(false); + const webTextareaRef = useRef(null); useImperativeHandle(ref, () => ({ focus: () => { @@ -242,6 +244,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(); @@ -594,8 +618,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..e00436035 --- /dev/null +++ b/packages/app/src/utils/active-chat-composer.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + __activeChatComposerTestUtils, + insertIntoActiveChatComposer, + markActiveChatComposer, + registerActiveChatComposer, +} from "./active-chat-composer"; +afterEach(() => { + __activeChatComposerTestUtils.reset(); +}); + +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..cd87c38f5 --- /dev/null +++ b/packages/app/src/utils/active-chat-composer.ts @@ -0,0 +1,56 @@ +interface ActiveChatComposerHandle { + insertText: (text: string) => boolean; + activateTab?: () => void; +} + +const activeChatComposerHandles = new Map(); +let activeChatComposerId: string | null = null; + +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..2404d90ef --- /dev/null +++ b/packages/app/src/utils/chat-reference-token.test.ts @@ -0,0 +1,203 @@ +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"); + }); + + 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 new file mode 100644 index 000000000..798c5727b --- /dev/null +++ b/packages/app/src/utils/chat-reference-token.ts @@ -0,0 +1,100 @@ +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); + 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, + }); +} + +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 }); + } + + const positions = buildHunkLinePositions(hunk); + const contextRange = buildContextReferenceRange({ + hunk, + positions, + lineIndex, + }); + if (contextRange) { + return buildDiffRangeChatReference({ + path, + ...contextRange, + }); + } + + const changeBlock = findContiguousChangeBlock({ + hunk, + lineIndex, + }); + if (!changeBlock) { + return buildHunkChatReference({ path, hunk }); + } + + const range = buildChangeBlockReferenceRange({ + hunk, + positions, + ...changeBlock, + }); + if (!range) { + return buildHunkChatReference({ path, hunk }); + } + + return buildDiffRangeChatReference({ + path, + ...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.test.ts b/packages/app/src/utils/diff-layout.test.ts index c5e756d81..2af2fb5f9 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, + isFirstChangedLineInHunk: 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, + isFirstChangedLineInHunk: 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, + isFirstChangedLineInHunk: false, + chatReference: "example.ts:10-11", left: null, right: { type: "add", content: "after two", lineNumber: 11 }, }); @@ -74,8 +83,81 @@ describe("buildSplitDiffRows", () => { expect(rows[1]).toMatchObject({ kind: "pair", + hunkIndex: 0, + 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 b5746eab3..8287933e8 100644 --- a/packages/app/src/utils/diff-layout.ts +++ b/packages/app/src/utils/diff-layout.ts @@ -1,4 +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"]; @@ -11,20 +18,23 @@ export type SplitDiffRow = | { kind: "header"; content: string; + hunkIndex: number; } | { kind: "pair"; + hunkIndex: number; + isFirstChangedLineInHunk: boolean; + chatReference: string; left: SplitDiffDisplayLine | null; right: SplitDiffDisplayLine | null; }; 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; } @@ -37,7 +47,7 @@ function toDisplayLine(input: { type: "remove", content: line.content, tokens: line.tokens, - lineNumber: oldLineNumber, + lineNumber: position.oldLineNumber, }; } @@ -49,7 +59,7 @@ function toDisplayLine(input: { type: "add", content: line.content, tokens: line.tokens, - lineNumber: newLineNumber, + lineNumber: position.newLineNumber, }; } @@ -57,86 +67,147 @@ function toDisplayLine(input: { type: "context", content: line.content, tokens: line.tokens, - lineNumber: side === "left" ? oldLineNumber : newLineNumber, + lineNumber: side === "left" ? position.oldLineNumber : position.newLineNumber, }; } export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { const rows: SplitDiffRow[] = []; - for (const hunk of file.hunks) { - let oldLineNo = hunk.oldStart; - let newLineNo = hunk.newStart; + for (const [hunkIndex, hunk] of file.hunks.entries()) { + const positions = buildHunkLinePositions(hunk); + let hasChangedLine = 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 }> = []; + let pendingRemovalIndices: number[] = []; + let pendingAdditionIndices: number[] = []; + + const pushPairRow = (input: { + chatReference: string; + hasChanges: boolean; + left: SplitDiffDisplayLine | null; + right: SplitDiffDisplayLine | null; + }) => { + rows.push({ + kind: "pair", + hunkIndex, + isFirstChangedLineInHunk: input.hasChanges && !hasChangedLine, + chatReference: input.chatReference, + left: input.left, + right: input.right, + }); + if (input.hasChanges) { + hasChangedLine = true; + } + }; const flushPendingRows = () => { - const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length); + 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; - rows.push({ - kind: "pair", - left: removal + 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: 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(); if (line.type === "context") { - rows.push({ - kind: "pair", + const range = buildContextReferenceRange({ + hunk, + positions, + lineIndex, + }); + const position = positions[lineIndex]; + if (!range || !position) { + continue; + } + + pushPairRow({ + chatReference: buildDiffRangeChatReference({ + path: file.path, + ...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; } }