From 72c30789127ac3ef23d1a506155854094d7123b1 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Fri, 12 Jun 2026 13:38:32 -0300 Subject: [PATCH] feat(rename): implement rename suggestion handling --- src/api/generated/api.schemas.ts | 62 ++- src/api/generated/function/function.ts | 399 ++++++++++++++++++- src/components/ui/tooltip.tsx | 20 +- src/features/binary/AIChatPanel.tsx | 82 +++- src/features/binary/CodeViewer.tsx | 172 +++++++- src/features/binary/FunctionTree.tsx | 79 +++- src/features/binary/RenameSuggestionCard.tsx | 126 ++++++ src/features/binary/hooks/useAIChat.ts | 78 +++- src/pages/Binary.tsx | 15 +- 9 files changed, 965 insertions(+), 68 deletions(-) create mode 100644 src/features/binary/RenameSuggestionCard.tsx diff --git a/src/api/generated/api.schemas.ts b/src/api/generated/api.schemas.ts index f718720..617418f 100644 --- a/src/api/generated/api.schemas.ts +++ b/src/api/generated/api.schemas.ts @@ -35,6 +35,20 @@ export interface ProjectResponse { binaryCount?: number; } +export interface RenameSuggestionResponse { + id?: string; + functionId?: string; + binaryId?: string; + projectId?: string; + oldName?: string; + newName?: string; + source?: string; + sessionId?: string; + status?: string; + createdAt?: string; + resolvedAt?: string; +} + export interface BinaryDependencyResponse { id: string; /** @minLength 1 */ @@ -103,6 +117,26 @@ export interface JobResponse { updatedAt: string; } +export interface RenameRequest { + /** + * @minLength 0 + * @maxLength 500 + */ + name: string; +} + +export interface FunctionSummaryResponse { + id?: string; + analysisId?: string; + name?: string; + resolvedName?: string; + address?: string; + size?: number; + flags?: string; + renamed?: boolean; + originalName?: string; +} + export interface SessionResponse { id?: string; binaryId?: string; @@ -149,8 +183,8 @@ export interface PageableObject { } export interface PageLibrarySymbolResponse { - totalElements?: number; totalPages?: number; + totalElements?: number; size?: number; content?: LibrarySymbolResponse[]; number?: number; @@ -197,6 +231,13 @@ export interface LabelResponse { type?: string; } +export interface RenameHistoryEntry { + oldName?: string; + newName?: string; + source?: string; + createdAt?: string; +} + export interface FunctionDetailResponse { id?: string; analysisId?: string; @@ -214,6 +255,9 @@ export interface FunctionDetailResponse { stackFrame?: FrameMemberResponse[]; comments?: CommentResponse[]; cfgBlocks?: CfgBlockResponse[]; + renamed?: boolean; + originalName?: string; + renameHistory?: RenameHistoryEntry[]; } export interface XrefResponse { @@ -312,19 +356,9 @@ export interface DataLabelResponse { dataXrefs?: DataXrefResponse[]; } -export interface FunctionSummaryResponse { - id?: string; - analysisId?: string; - name?: string; - resolvedName?: string; - address?: string; - size?: number; - flags?: string; -} - export interface PageFunctionSummaryResponse { - totalElements?: number; totalPages?: number; + totalElements?: number; size?: number; content?: FunctionSummaryResponse[]; number?: number; @@ -470,6 +504,10 @@ export const GetJobsType = { GENERATE_EMBEDDINGS: "GENERATE_EMBEDDINGS", } as const; +export type ListRenameSuggestionsParams = { + status?: string; +}; + export type ListFunctionsParams = { pageable: Pageable; }; diff --git a/src/api/generated/function/function.ts b/src/api/generated/function/function.ts index aaf359f..0b632fb 100644 --- a/src/api/generated/function/function.ts +++ b/src/api/generated/function/function.ts @@ -4,25 +4,272 @@ * OpenAPI definition * OpenAPI spec version: v0 */ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, + MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, UseQueryOptions, UseQueryResult, } from "@tanstack/react-query"; -import type { FunctionDetailResponse, XrefResponse } from "../api.schemas"; +import type { + FunctionDetailResponse, + FunctionSummaryResponse, + ListRenameSuggestionsParams, + RenameRequest, + RenameSuggestionResponse, + XrefResponse, +} from "../api.schemas"; import { customInstance } from "../../api"; type SecondParameter unknown> = Parameters[1]; +/** + * Reject the suggested rename without applying it. + * @summary Reject a rename suggestion + */ +export const rejectRename = ( + renameId: string, + options?: SecondParameter, + signal?: AbortSignal +) => { + return customInstance( + { + url: `/rename-suggestions/${encodeURIComponent(String(renameId))}/reject`, + method: "POST", + signal, + }, + options + ); +}; + +export const getRejectRenameMutationOptions = (options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { renameId: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { renameId: string }, + TContext +> => { + const mutationKey = ["rejectRename"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { renameId: string } + > = (props) => { + const { renameId } = props ?? {}; + + return rejectRename(renameId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type RejectRenameMutationResult = NonNullable>>; + +export type RejectRenameMutationError = unknown; + +/** + * @summary Reject a rename suggestion + */ +export const useRejectRename = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { renameId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { renameId: string }, + TContext +> => { + return useMutation(getRejectRenameMutationOptions(options), queryClient); +}; +/** + * Apply the suggested rename to the function. + * @summary Approve a rename suggestion + */ +export const approveRename = ( + renameId: string, + options?: SecondParameter, + signal?: AbortSignal +) => { + return customInstance( + { + url: `/rename-suggestions/${encodeURIComponent(String(renameId))}/approve`, + method: "POST", + signal, + }, + options + ); +}; + +export const getApproveRenameMutationOptions = (options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { renameId: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { renameId: string }, + TContext +> => { + const mutationKey = ["approveRename"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { renameId: string } + > = (props) => { + const { renameId } = props ?? {}; + + return approveRename(renameId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ApproveRenameMutationResult = NonNullable>>; + +export type ApproveRenameMutationError = unknown; + +/** + * @summary Approve a rename suggestion + */ +export const useApproveRename = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { renameId: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { renameId: string }, + TContext +> => { + return useMutation(getApproveRenameMutationOptions(options), queryClient); +}; +/** + * Manually rename a function without going through the approval flow. + * @summary Rename a function directly + */ +export const renameFunction = ( + functionId: string, + renameRequest: RenameRequest, + options?: SecondParameter, + signal?: AbortSignal +) => { + return customInstance( + { + url: `/functions/${encodeURIComponent(String(functionId))}/rename`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: renameRequest, + signal, + }, + options + ); +}; + +export const getRenameFunctionMutationOptions = (options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { functionId: string; data: RenameRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { functionId: string; data: RenameRequest }, + TContext +> => { + const mutationKey = ["renameFunction"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { functionId: string; data: RenameRequest } + > = (props) => { + const { functionId, data } = props ?? {}; + + return renameFunction(functionId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type RenameFunctionMutationResult = NonNullable>>; +export type RenameFunctionMutationBody = RenameRequest; +export type RenameFunctionMutationError = unknown; + +/** + * @summary Rename a function directly + */ +export const useRenameFunction = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { functionId: string; data: RenameRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseMutationResult< + Awaited>, + TError, + { functionId: string; data: RenameRequest }, + TContext +> => { + return useMutation(getRenameFunctionMutationOptions(options), queryClient); +}; /** * Retrieve full details of a function including assembly, decompiled code, and xref counts. * @summary Get function details @@ -352,3 +599,151 @@ export function useGetCallees>, TE return { ...query, queryKey: queryOptions.queryKey }; } + +/** + * Retrieve pending or resolved rename suggestions. + * @summary List rename suggestions for a binary + */ +export const listRenameSuggestions = ( + binaryId: string, + params?: ListRenameSuggestionsParams, + options?: SecondParameter, + signal?: AbortSignal +) => { + return customInstance( + { + url: `/binaries/${encodeURIComponent(String(binaryId))}/rename-suggestions`, + method: "GET", + params, + signal, + }, + options + ); +}; + +export const getListRenameSuggestionsQueryKey = ( + binaryId: string, + params?: ListRenameSuggestionsParams +) => { + return [`/binaries/${binaryId}/rename-suggestions`, ...(params ? [params] : [])] as const; +}; + +export const getListRenameSuggestionsQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + binaryId: string, + params?: ListRenameSuggestionsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + } +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListRenameSuggestionsQueryKey(binaryId, params); + + const queryFn: QueryFunction>> = ({ signal }) => + listRenameSuggestions(binaryId, params, requestOptions, signal); + + return { + queryKey, + queryFn, + enabled: binaryId !== null && binaryId !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type ListRenameSuggestionsQueryResult = NonNullable< + Awaited> +>; +export type ListRenameSuggestionsQueryError = unknown; + +export function useListRenameSuggestions< + TData = Awaited>, + TError = unknown, +>( + binaryId: string, + params: undefined | ListRenameSuggestionsParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useListRenameSuggestions< + TData = Awaited>, + TError = unknown, +>( + binaryId: string, + params?: ListRenameSuggestionsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useListRenameSuggestions< + TData = Awaited>, + TError = unknown, +>( + binaryId: string, + params?: ListRenameSuggestionsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List rename suggestions for a binary + */ + +export function useListRenameSuggestions< + TData = Awaited>, + TError = unknown, +>( + binaryId: string, + params?: ListRenameSuggestionsParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getListRenameSuggestionsQueryOptions(binaryId, params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 60d9678..52fe5b0 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -1,13 +1,13 @@ -import * as React from "react" -import * as TooltipPrimitive from "@radix-ui/react-tooltip" +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const TooltipProvider = TooltipPrimitive.Provider +const TooltipProvider = TooltipPrimitive.Provider; -const Tooltip = TooltipPrimitive.Root +const Tooltip = TooltipPrimitive.Root; -const TooltipTrigger = TooltipPrimitive.Trigger +const TooltipTrigger = TooltipPrimitive.Trigger; const TooltipContent = React.forwardRef< React.ElementRef, @@ -17,12 +17,12 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]", + "bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md", className )} {...props} /> -)) -TooltipContent.displayName = TooltipPrimitive.Content.displayName +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/src/features/binary/AIChatPanel.tsx b/src/features/binary/AIChatPanel.tsx index 089e61c..f55e19b 100644 --- a/src/features/binary/AIChatPanel.tsx +++ b/src/features/binary/AIChatPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useMemo } from "react"; import { formatDistanceToNow } from "date-fns"; import { Send, Bot, User, Loader2, Sparkles, Plus, Trash2 } from "lucide-react"; import ReactMarkdown from "react-markdown"; @@ -6,11 +6,14 @@ import remarkGfm from "remark-gfm"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { useAIChat } from "@/features/binary/hooks/useAIChat"; +import { RenameSuggestionCard } from "@/features/binary/RenameSuggestionCard"; +import { useListRenameSuggestions } from "@/api/generated/function/function.ts"; import { analyzingMessages } from "@/features/binary/analyzingMessages"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import type { ComponentPropsWithoutRef } from "react"; +import type { RenameSuggestionResponse } from "@/api/generated/api.schemas"; const MAX_INPUT_LENGTH = 4000; @@ -64,10 +67,7 @@ const markdownComponents: ComponentPropsWithoutRef["compon } return ( -
+
void }) { const { binary, sessions, @@ -101,6 +101,8 @@ export function AIChatPanel() { streamingContent, toolStatus, isToolRunning, + renameSuggestions, + previousRenameSuggestions, streamError, isCreatingSession, createSession, @@ -131,6 +133,32 @@ export function AIChatPanel() { const analyzingMessage = analyzingMessages[analyzingIdx]; + const { data: pendingSuggestions } = useListRenameSuggestions( + binary?.id ?? "", + { status: "PENDING" }, + { query: { enabled: !!binary?.id } } + ); + + const mergedMessages = useMemo(() => { + const items: Array< + | { kind: "message"; data: (typeof messages)[number] } + | { kind: "suggestion"; data: RenameSuggestionResponse } + > = messages.map((m) => ({ kind: "message" as const, data: m })); + + for (const s of pendingSuggestions ?? []) { + if (!s.createdAt) continue; + items.push({ kind: "suggestion" as const, data: s }); + } + + items.sort((a, b) => { + const aDate = a.kind === "message" ? a.data.createdAt : a.data.createdAt; + const bDate = b.kind === "message" ? b.data.createdAt : b.data.createdAt; + return (aDate ?? "").localeCompare(bDate ?? ""); + }); + + return items; + }, [messages, pendingSuggestions]); + // Track manual scroll to respect user reading position useEffect(() => { const el = scrollContainerRef.current; @@ -268,7 +296,7 @@ export function AIChatPanel() { ref={scrollContainerRef} className="flex min-h-0 flex-1 flex-col overflow-x-hidden overflow-y-auto" > - {messages.length === 0 && !isStreaming ? ( + {messages.length === 0 && !pendingSuggestions?.length && !isStreaming ? (

AI Analysis Assistant

@@ -300,12 +328,28 @@ export function AIChatPanel() {
) : ( <> - {messages.map((message, idx) => { + {mergedMessages.map((item, idx) => { + if (item.kind === "suggestion") { + const s = item.data; + if (!s.id || !s.functionId || !s.oldName || !s.newName) return null; + return ( + + ); + } + + const message = item.data; const isUser = message.role === "USER"; return (
)} + {previousRenameSuggestions.map((s) => ( + + ))} {isStreaming && ( <>
@@ -388,6 +442,16 @@ export function AIChatPanel() { {toolStatus}
)} + {renameSuggestions.map((s) => ( + + ))} )}
diff --git a/src/features/binary/CodeViewer.tsx b/src/features/binary/CodeViewer.tsx index 3653286..d238879 100644 --- a/src/features/binary/CodeViewer.tsx +++ b/src/features/binary/CodeViewer.tsx @@ -1,20 +1,25 @@ "use client"; import { useRef, useCallback, useMemo, useState } from "react"; -import { Copy, Code, ArrowUpRight, ArrowDownRight, Link } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { Copy, Code, ArrowUpRight, ArrowDownRight, Link, Pencil, Check, X } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; import type { FunctionDetailResponse, CommentResponse } from "@/api/generated/api.schemas"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { CfgGraph } from "@/features/binary/CfgGraph"; -import { useGetCallees } from "@/api/generated/function/function.ts"; +import { useGetCallees, useRenameFunction } from "@/api/generated/function/function.ts"; +import { cn } from "@/lib/utils"; interface CodeViewerProps { func: FunctionDetailResponse | undefined; isLoading?: boolean; onSelectFunction: (id: string) => void; functionNameToId: Record; + onRenameApplied?: () => void; } const FLAG_COLORS: Record = { @@ -59,7 +64,9 @@ export function CodeViewer({ isLoading, onSelectFunction, functionNameToId, + onRenameApplied, }: CodeViewerProps) { + const queryClient = useQueryClient(); const assemblyRef = useRef(null); const pseudoRef = useRef(null); @@ -69,6 +76,21 @@ export function CodeViewer({ end: string; } | null>(null); + const [isEditingName, setIsEditingName] = useState(false); + const [editName, setEditName] = useState(""); + + const renameMutation = useRenameFunction({ + mutation: { + onSuccess: () => { + setIsEditingName(false); + if (func?.id) { + queryClient.invalidateQueries({ queryKey: [`/functions/${func.id}`] }); + } + onRenameApplied?.(); + }, + }, + }); + const handleCfgHighlight = useCallback((start: string, end: string) => { setHighlightedRange((prev) => prev?.start === start && prev?.end === end ? null : { start, end } @@ -167,21 +189,109 @@ export function CodeViewer({ )} - {func?.resolvedName && ( -
- - {func.resolvedName} - - {func.name && func.resolvedName !== func.name && ( - - {func.name} - - )} + {isEditingName && func ? ( +
+ setEditName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (editName.trim() && editName.trim() !== (func.resolvedName || func.name)) { + renameMutation.mutate({ + functionId: func.id ?? "", + data: { name: editName.trim() }, + }); + } else { + setIsEditingName(false); + } + } else if (e.key === "Escape") { + setIsEditingName(false); + } + }} + className="h-7 w-60 font-mono text-xs" + autoFocus + maxLength={500} + /> + +
- )} - {!func?.resolvedName && func?.name && ( - {func.name} - )} + ) : func?.resolvedName ? ( +
+
+ + {func.resolvedName} + + {func.renamed && func.originalName && ( + + {func.originalName} + + )} + {!func.renamed && func.name && func.resolvedName !== func.name && ( + + {func.name} + + )} +
+ +
+ ) : func?.name ? ( +
+ + {func.name} + + +
+ ) : null}
@@ -246,6 +356,36 @@ export function CodeViewer({
)} + {func?.renameHistory && func.renameHistory.length > 0 && ( +
+
+ + Rename History ({func.renameHistory.length}) + +
+ {func.renameHistory.map((entry, i) => ( +
+ {entry.oldName} + + {entry.newName} + + by {entry.source === "AI" ? "AI" : "you"} + + {entry.createdAt && ( + + {formatDistanceToNow(entry.createdAt, { addSuffix: true })} + + )} +
+ ))} +
+
+
+ )} + {hasData ? ( diff --git a/src/features/binary/FunctionTree.tsx b/src/features/binary/FunctionTree.tsx index c43678b..2e135e8 100644 --- a/src/features/binary/FunctionTree.tsx +++ b/src/features/binary/FunctionTree.tsx @@ -1,16 +1,12 @@ "use client"; import { useState, useRef, useEffect } from "react"; -import { FunctionSquare, Search, Loader2 } from "lucide-react"; +import { FunctionSquare, Search, Loader2, Pencil } from "lucide-react"; import type { FunctionSummaryResponse, SegmentResponse } from "@/api/generated/api.schemas"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; interface FunctionTreeProps { functions: FunctionSummaryResponse[]; @@ -162,8 +158,8 @@ export function FunctionTree({
- -
+ +
{nameFiltered.map((func) => { const funcFlags = parseFlags(func.flags); const segName = func.address ? segmentByAddress[func.address.toLowerCase()] : undefined; @@ -172,7 +168,7 @@ export function FunctionTree({ key={func.id} onClick={() => onSelectFunction(func)} className={cn( - "grid min-w-0 w-full items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors", + "grid w-full min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors", "grid-cols-[auto_1fr]", selectedFunctionId === func.id ? "bg-primary/10 text-primary" @@ -185,26 +181,75 @@ export function FunctionTree({ - + {func.resolvedName} - + {func.name} - +

{func.resolvedName}

{func.name}

+ {func.renamed && func.originalName && ( +

+ Originally: {func.originalName} +

+ )}
) : ( - {func.name} + + {func.name} + - +

{func.name}

+ {func.renamed && func.originalName && ( +

+ Originally: {func.originalName} +

+ )} +
+
+ )} + {func.renamed && ( + + + + + +

Renamed

+ {func.originalName && ( +

Originally: {func.originalName}

+ )}
)} @@ -215,7 +260,9 @@ export function FunctionTree({ key={f} className={cn( "rounded px-1 font-mono text-[9px] leading-relaxed", - selectedFlags.has(f) ? "bg-primary/15" : "bg-muted text-muted-foreground" + selectedFlags.has(f) + ? "bg-primary/15" + : "bg-muted text-muted-foreground" )} > {FLAG_LABELS[f] ?? f} @@ -262,6 +309,6 @@ export function FunctionTree({ ? `${nameFiltered.length} of ${totalCount ?? functions.length} functions` : `${totalCount ?? functions.length} functions`}
-
+
); } diff --git a/src/features/binary/RenameSuggestionCard.tsx b/src/features/binary/RenameSuggestionCard.tsx new file mode 100644 index 0000000..e5c2237 --- /dev/null +++ b/src/features/binary/RenameSuggestionCard.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Check, X, Sparkles, Loader2 } from "lucide-react"; +import { useApproveRename, useRejectRename } from "@/api/generated/function/function.ts"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type SuggestionStatus = "PENDING" | "APPROVED" | "REJECTED"; + +interface RenameSuggestionCardProps { + suggestionId: string; + functionId: string; + oldName: string; + newName: string; + onRenameApplied?: () => void; +} + +export function RenameSuggestionCard({ + suggestionId, + functionId, + oldName, + newName, + onRenameApplied, +}: RenameSuggestionCardProps) { + const queryClient = useQueryClient(); + const [status, setStatus] = useState("PENDING"); + + const approveMutation = useApproveRename({ + mutation: { + onSuccess: () => { + setStatus("APPROVED"); + queryClient.invalidateQueries({ queryKey: [`/functions/${functionId}`] }); + onRenameApplied?.(); + }, + }, + }); + + const rejectMutation = useRejectRename({ + mutation: { + onSuccess: () => { + setStatus("REJECTED"); + }, + }, + }); + + const handleApprove = () => { + if (approveMutation.isPending || status !== "PENDING") return; + approveMutation.mutate({ renameId: suggestionId }); + }; + + const handleReject = () => { + if (rejectMutation.isPending || status !== "PENDING") return; + rejectMutation.mutate({ renameId: suggestionId }); + }; + + const isPending = status === "PENDING"; + const isApproved = status === "APPROVED"; + const isRejected = status === "REJECTED"; + + return ( +
+
+ + AI suggests renaming: +
+ +
+ {oldName} + + + {newName} + +
+ + {isApproved && ( +

+ + Approved +

+ )} + {isRejected && ( +

+ + Rejected +

+ )} + + {isPending && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/features/binary/hooks/useAIChat.ts b/src/features/binary/hooks/useAIChat.ts index cdbda46..bd90c6a 100644 --- a/src/features/binary/hooks/useAIChat.ts +++ b/src/features/binary/hooks/useAIChat.ts @@ -32,6 +32,21 @@ interface ThinkingEvent { message: string; } +interface RenameSuggestionEvent { + type: "rename_suggestion"; + suggestionId: string; + functionId: string; + oldName: string; + newName: string; +} + +interface FunctionRenamedEvent { + type: "function_renamed"; + functionId: string; + oldName: string; + newName: string; +} + interface DoneEvent { type: "done"; sessionId: string; @@ -47,6 +62,8 @@ type SSEEvent = | ToolCallStartEvent | ToolCallEndEvent | ThinkingEvent + | RenameSuggestionEvent + | FunctionRenamedEvent | DoneEvent | ErrorEvent; @@ -62,6 +79,10 @@ export function useAIChat() { const [streamError, setStreamError] = useState(null); const [toolStatus, setToolStatus] = useState(null); const [isToolRunning, setIsToolRunning] = useState(false); + const [renameSuggestions, setRenameSuggestions] = useState([]); + const [previousRenameSuggestions, setPreviousRenameSuggestions] = useState< + RenameSuggestionEvent[] + >([]); const [pendingUserMessage, setPendingUserMessage] = useState(null); const [optimisticAssistantMessage, setOptimisticAssistantMessage] = useState(null); @@ -104,6 +125,15 @@ export function useAIChat() { const createSession = useCallback( (model?: string) => { + setStreamingContent(""); + setIsStreaming(false); + setStreamError(null); + setToolStatus(null); + setIsToolRunning(false); + setRenameSuggestions([]); + setPreviousRenameSuggestions([]); + setPendingUserMessage(null); + setOptimisticAssistantMessage(null); createSessionMutation.mutate({ binaryId, params: { @@ -116,8 +146,16 @@ export function useAIChat() { ); const selectSession = useCallback((sessionId: string) => { - setActiveSessionId(sessionId); + setStreamingContent(""); + setIsStreaming(false); setStreamError(null); + setToolStatus(null); + setIsToolRunning(false); + setRenameSuggestions([]); + setPreviousRenameSuggestions([]); + setPendingUserMessage(null); + setOptimisticAssistantMessage(null); + setActiveSessionId(sessionId); }, []); const deleteSession = useCallback( @@ -135,6 +173,10 @@ export function useAIChat() { setStreamingContent(""); setToolStatus(null); setIsToolRunning(false); + setRenameSuggestions((current) => { + setPreviousRenameSuggestions((prev) => [...prev, ...current]); + return []; + }); setOptimisticAssistantMessage(null); setStreamError(null); setPendingUserMessage({ @@ -206,6 +248,15 @@ export function useAIChat() { setToolStatus(sseEvent.message); setIsToolRunning(true); break; + case "rename_suggestion": + setRenameSuggestions((prev) => [...prev, sseEvent]); + break; + case "function_renamed": + queryClient.invalidateQueries({ + queryKey: [`/functions/${sseEvent.functionId}`], + }); + queryClient.invalidateQueries({ queryKey: ["functions"] }); + break; case "done": setOptimisticAssistantMessage({ role: "ASSISTANT", @@ -216,12 +267,19 @@ export function useAIChat() { setStreamingContent(""); setToolStatus(null); setIsToolRunning(false); + setRenameSuggestions((current) => { + setPreviousRenameSuggestions((prev) => [...prev, ...current]); + return []; + }); queryClient.invalidateQueries({ queryKey: [`/chat/sessions/${activeSessionId}/messages`], }); queryClient.invalidateQueries({ queryKey: [`/binaries/${binaryId}/chat/sessions`], }); + queryClient.invalidateQueries({ + queryKey: [`/binaries/${binaryId}/rename-suggestions`], + }); break; case "error": setIsStreaming(false); @@ -263,6 +321,15 @@ export function useAIChat() { setToolStatus(sseEvent.message); setIsToolRunning(true); break; + case "rename_suggestion": + setRenameSuggestions((prev) => [...prev, sseEvent]); + break; + case "function_renamed": + queryClient.invalidateQueries({ + queryKey: [`/functions/${sseEvent.functionId}`], + }); + queryClient.invalidateQueries({ queryKey: ["functions"] }); + break; case "done": setOptimisticAssistantMessage({ role: "ASSISTANT", @@ -273,12 +340,19 @@ export function useAIChat() { setStreamingContent(""); setToolStatus(null); setIsToolRunning(false); + setRenameSuggestions((current) => { + setPreviousRenameSuggestions((prev) => [...prev, ...current]); + return []; + }); queryClient.invalidateQueries({ queryKey: [`/chat/sessions/${activeSessionId}/messages`], }); queryClient.invalidateQueries({ queryKey: [`/binaries/${binaryId}/chat/sessions`], }); + queryClient.invalidateQueries({ + queryKey: [`/binaries/${binaryId}/rename-suggestions`], + }); break; case "error": setIsStreaming(false); @@ -354,6 +428,8 @@ export function useAIChat() { streamingContent, toolStatus, isToolRunning, + renameSuggestions, + previousRenameSuggestions, streamError: userFriendlyError, isCreatingSession: createSessionMutation.isPending, createSession, diff --git a/src/pages/Binary.tsx b/src/pages/Binary.tsx index 07883d7..35bfa14 100644 --- a/src/pages/Binary.tsx +++ b/src/pages/Binary.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, ChevronDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/EmptyState"; @@ -81,6 +82,15 @@ const Binary = () => { analysisId, } = useBinaryPage(); + const queryClient = useQueryClient(); + + const handleRenameApplied = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ["functions", analysisId] }); + if (selectedFunctionId) { + queryClient.invalidateQueries({ queryKey: [`/functions/${selectedFunctionId}`] }); + } + }, [queryClient, analysisId, selectedFunctionId]); + const navigateToEntryPoint = () => { if (!analysis?.entryPoint) return; const match = analysis.entryPoint.match(/^(0x[0-9a-fA-F]+)/); @@ -313,6 +323,7 @@ const Binary = () => { isLoading={selectedFunctionLoading} onSelectFunction={setSelectedFunctionId} functionNameToId={functionNameToId} + onRenameApplied={handleRenameApplied} />
@@ -321,7 +332,7 @@ const Binary = () => {
- +