feat(rename): implement rename suggestion handling

This commit is contained in:
Rodrigo Verdiani 2026-06-12 13:38:32 -03:00
parent 979f804623
commit 72c3078912
9 changed files with 965 additions and 68 deletions

View File

@ -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;
};

View File

@ -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<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* Reject the suggested rename without applying it.
* @summary Reject a rename suggestion
*/
export const rejectRename = (
renameId: string,
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal
) => {
return customInstance<void>(
{
url: `/rename-suggestions/${encodeURIComponent(String(renameId))}/reject`,
method: "POST",
signal,
},
options
);
};
export const getRejectRenameMutationOptions = <TError = unknown, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof rejectRename>>,
TError,
{ renameId: string },
TContext
>;
request?: SecondParameter<typeof customInstance>;
}): UseMutationOptions<
Awaited<ReturnType<typeof rejectRename>>,
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<ReturnType<typeof rejectRename>>,
{ renameId: string }
> = (props) => {
const { renameId } = props ?? {};
return rejectRename(renameId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type RejectRenameMutationResult = NonNullable<Awaited<ReturnType<typeof rejectRename>>>;
export type RejectRenameMutationError = unknown;
/**
* @summary Reject a rename suggestion
*/
export const useRejectRename = <TError = unknown, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof rejectRename>>,
TError,
{ renameId: string },
TContext
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): UseMutationResult<
Awaited<ReturnType<typeof rejectRename>>,
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<typeof customInstance>,
signal?: AbortSignal
) => {
return customInstance<RenameSuggestionResponse>(
{
url: `/rename-suggestions/${encodeURIComponent(String(renameId))}/approve`,
method: "POST",
signal,
},
options
);
};
export const getApproveRenameMutationOptions = <TError = unknown, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof approveRename>>,
TError,
{ renameId: string },
TContext
>;
request?: SecondParameter<typeof customInstance>;
}): UseMutationOptions<
Awaited<ReturnType<typeof approveRename>>,
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<ReturnType<typeof approveRename>>,
{ renameId: string }
> = (props) => {
const { renameId } = props ?? {};
return approveRename(renameId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type ApproveRenameMutationResult = NonNullable<Awaited<ReturnType<typeof approveRename>>>;
export type ApproveRenameMutationError = unknown;
/**
* @summary Approve a rename suggestion
*/
export const useApproveRename = <TError = unknown, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof approveRename>>,
TError,
{ renameId: string },
TContext
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): UseMutationResult<
Awaited<ReturnType<typeof approveRename>>,
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<typeof customInstance>,
signal?: AbortSignal
) => {
return customInstance<FunctionSummaryResponse>(
{
url: `/functions/${encodeURIComponent(String(functionId))}/rename`,
method: "POST",
headers: { "Content-Type": "application/json" },
data: renameRequest,
signal,
},
options
);
};
export const getRenameFunctionMutationOptions = <TError = unknown, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof renameFunction>>,
TError,
{ functionId: string; data: RenameRequest },
TContext
>;
request?: SecondParameter<typeof customInstance>;
}): UseMutationOptions<
Awaited<ReturnType<typeof renameFunction>>,
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<ReturnType<typeof renameFunction>>,
{ functionId: string; data: RenameRequest }
> = (props) => {
const { functionId, data } = props ?? {};
return renameFunction(functionId, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type RenameFunctionMutationResult = NonNullable<Awaited<ReturnType<typeof renameFunction>>>;
export type RenameFunctionMutationBody = RenameRequest;
export type RenameFunctionMutationError = unknown;
/**
* @summary Rename a function directly
*/
export const useRenameFunction = <TError = unknown, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof renameFunction>>,
TError,
{ functionId: string; data: RenameRequest },
TContext
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): UseMutationResult<
Awaited<ReturnType<typeof renameFunction>>,
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<TData = Awaited<ReturnType<typeof getCallees>>, 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<typeof customInstance>,
signal?: AbortSignal
) => {
return customInstance<RenameSuggestionResponse[]>(
{
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<ReturnType<typeof listRenameSuggestions>>,
TError = unknown,
>(
binaryId: string,
params?: ListRenameSuggestionsParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listRenameSuggestions>>, TError, TData>
>;
request?: SecondParameter<typeof customInstance>;
}
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRenameSuggestionsQueryKey(binaryId, params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRenameSuggestions>>> = ({ signal }) =>
listRenameSuggestions(binaryId, params, requestOptions, signal);
return {
queryKey,
queryFn,
enabled: binaryId !== null && binaryId !== undefined,
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof listRenameSuggestions>>, TError, TData> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
};
export type ListRenameSuggestionsQueryResult = NonNullable<
Awaited<ReturnType<typeof listRenameSuggestions>>
>;
export type ListRenameSuggestionsQueryError = unknown;
export function useListRenameSuggestions<
TData = Awaited<ReturnType<typeof listRenameSuggestions>>,
TError = unknown,
>(
binaryId: string,
params: undefined | ListRenameSuggestionsParams,
options: {
query: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listRenameSuggestions>>, TError, TData>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listRenameSuggestions>>,
TError,
Awaited<ReturnType<typeof listRenameSuggestions>>
>,
"initialData"
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
export function useListRenameSuggestions<
TData = Awaited<ReturnType<typeof listRenameSuggestions>>,
TError = unknown,
>(
binaryId: string,
params?: ListRenameSuggestionsParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listRenameSuggestions>>, TError, TData>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listRenameSuggestions>>,
TError,
Awaited<ReturnType<typeof listRenameSuggestions>>
>,
"initialData"
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
export function useListRenameSuggestions<
TData = Awaited<ReturnType<typeof listRenameSuggestions>>,
TError = unknown,
>(
binaryId: string,
params?: ListRenameSuggestionsParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listRenameSuggestions>>, TError, TData>
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
/**
* @summary List rename suggestions for a binary
*/
export function useListRenameSuggestions<
TData = Awaited<ReturnType<typeof listRenameSuggestions>>,
TError = unknown,
>(
binaryId: string,
params?: ListRenameSuggestionsParams,
options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof listRenameSuggestions>>, TError, TData>
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListRenameSuggestionsQueryOptions(binaryId, params, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
return { ...query, queryKey: queryOptions.queryKey };
}

View File

@ -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<typeof TooltipPrimitive.Content>,
@ -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 };

View File

@ -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<typeof ReactMarkdown>["compon
}
return (
<div
className="max-w-full rounded-lg"
style={{ background: "oklch(0.1 0.01 260)" }}
>
<div className="max-w-full rounded-lg" style={{ background: "oklch(0.1 0.01 260)" }}>
<SyntaxHighlighter
language={match[1]}
style={codeBlockTheme}
@ -89,7 +89,7 @@ function normalizeMarkdown(content: string) {
return content.replace(/\r\n/g, "\n").replace(/([^\n])\n([^\n])/g, "$1 \n$2");
}
export function AIChatPanel() {
export function AIChatPanel({ onRenameApplied }: { onRenameApplied?: () => 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 ? (
<div className="flex flex-1 flex-col items-center justify-center p-4">
<Bot className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="text-foreground mb-2 font-medium">AI Analysis Assistant</h3>
@ -300,12 +328,28 @@ export function AIChatPanel() {
</div>
) : (
<>
{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 (
<RenameSuggestionCard
key={s.id}
suggestionId={s.id}
functionId={s.functionId}
oldName={s.oldName}
newName={s.newName}
onRenameApplied={onRenameApplied}
/>
);
}
const message = item.data;
const isUser = message.role === "USER";
return (
<div
key={message.id ?? `streaming-${idx}`}
key={message.id ?? `msg-${idx}`}
className={cn("mb-4 flex gap-3", isUser && "flex-row-reverse")}
>
<div
@ -354,6 +398,16 @@ export function AIChatPanel() {
})}
</>
)}
{previousRenameSuggestions.map((s) => (
<RenameSuggestionCard
key={s.suggestionId}
suggestionId={s.suggestionId}
functionId={s.functionId}
oldName={s.oldName}
newName={s.newName}
onRenameApplied={onRenameApplied}
/>
))}
{isStreaming && (
<>
<div className="mb-4 flex gap-3">
@ -388,6 +442,16 @@ export function AIChatPanel() {
<span>{toolStatus}</span>
</div>
)}
{renameSuggestions.map((s) => (
<RenameSuggestionCard
key={s.suggestionId}
suggestionId={s.suggestionId}
functionId={s.functionId}
oldName={s.oldName}
newName={s.newName}
onRenameApplied={onRenameApplied}
/>
))}
</>
)}
<div ref={messagesEndRef} />

View File

@ -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<string, string>;
onRenameApplied?: () => void;
}
const FLAG_COLORS: Record<string, string> = {
@ -59,7 +64,9 @@ export function CodeViewer({
isLoading,
onSelectFunction,
functionNameToId,
onRenameApplied,
}: CodeViewerProps) {
const queryClient = useQueryClient();
const assemblyRef = useRef<HTMLTableElement>(null);
const pseudoRef = useRef<HTMLTableElement>(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({
)}
</TabsList>
{func?.resolvedName && (
{isEditingName && func ? (
<div className="flex items-center gap-1">
<Input
value={editName}
onChange={(e) => 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}
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
if (editName.trim() && editName.trim() !== (func.resolvedName || func.name)) {
renameMutation.mutate({
functionId: func.id ?? "",
data: { name: editName.trim() },
});
} else {
setIsEditingName(false);
}
}}
disabled={renameMutation.isPending}
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setIsEditingName(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
) : func?.resolvedName ? (
<div className="flex items-center gap-1.5">
<div className="flex flex-col items-start leading-tight">
<span className="max-w-80 truncate font-mono text-xs font-semibold">
<span
className={cn(
"max-w-80 truncate font-mono text-xs font-semibold",
func.renamed && "text-primary"
)}
>
{func.resolvedName}
</span>
{func.name && func.resolvedName !== func.name && (
{func.renamed && func.originalName && (
<span className="text-muted-foreground max-w-80 truncate font-mono text-[10px] line-through">
{func.originalName}
</span>
)}
{!func.renamed && func.name && func.resolvedName !== func.name && (
<span className="text-muted-foreground max-w-80 truncate font-mono text-[10px]">
{func.name}
</span>
)}
</div>
)}
{!func?.resolvedName && func?.name && (
<span className="font-mono text-xs">{func.name}</span>
)}
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-6 w-6"
onClick={() => {
setEditName(func.resolvedName || func.name || "");
setIsEditingName(true);
}}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
) : func?.name ? (
<div className="flex items-center gap-1.5">
<span className={cn("font-mono text-xs", func.renamed && "text-primary")}>
{func.name}
</span>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-6 w-6"
onClick={() => {
setEditName(func.name || "");
setIsEditingName(true);
}}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
@ -246,6 +356,36 @@ export function CodeViewer({
</div>
)}
{func?.renameHistory && func.renameHistory.length > 0 && (
<div className="border-border shrink-0 border-b px-4 py-1">
<details>
<summary className="text-muted-foreground cursor-pointer text-xs select-none">
Rename History ({func.renameHistory.length})
</summary>
<div className="mt-1 flex max-h-40 flex-col gap-1 overflow-y-auto">
{func.renameHistory.map((entry, i) => (
<div
key={i}
className="even:bg-muted/30 flex items-center gap-2 px-1 py-0.5 font-mono text-xs"
>
<span className="text-muted-foreground line-through">{entry.oldName}</span>
<span className="text-muted-foreground"></span>
<span>{entry.newName}</span>
<span className="text-muted-foreground ml-auto shrink-0 text-[10px]">
by {entry.source === "AI" ? "AI" : "you"}
</span>
{entry.createdAt && (
<span className="text-muted-foreground shrink-0 text-[10px]">
{formatDistanceToNow(entry.createdAt, { addSuffix: true })}
</span>
)}
</div>
))}
</div>
</details>
</div>
)}
<TabsContent value="assembly" className="mt-0 flex-1 overflow-hidden">
{hasData ? (
<ScrollArea className="h-full w-full">

View File

@ -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({
</div>
</div>
<ScrollArea className="min-h-0 flex-1 min-w-0">
<div className="p-2 overflow-x-hidden">
<ScrollArea className="min-h-0 min-w-0 flex-1">
<div className="overflow-x-hidden p-2">
{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({
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 overflow-hidden">
<span className="block truncate font-mono text-xs font-semibold">
<span
className={cn(
"block truncate font-mono text-xs font-semibold",
func.renamed && "text-primary"
)}
>
{func.resolvedName}
</span>
<span className="text-muted-foreground block truncate font-mono text-[10px]">
<span
className={cn(
"text-muted-foreground block truncate font-mono text-[10px]",
func.renamed && "line-through"
)}
>
{func.name}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-none overflow-visible font-mono text-xs">
<TooltipContent
side="top"
className="max-w-none overflow-visible font-mono text-xs"
>
<p>{func.resolvedName}</p>
<p className="text-muted-foreground">{func.name}</p>
{func.renamed && func.originalName && (
<p className="text-muted-foreground mt-0.5">
Originally: {func.originalName}
</p>
)}
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{func.name}</span>
<span
className={cn(
"min-w-0 flex-1 truncate font-mono text-xs",
func.renamed && "text-primary"
)}
>
{func.name}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-none overflow-visible font-mono text-xs">
<TooltipContent
side="top"
className="max-w-none overflow-visible font-mono text-xs"
>
<p>{func.name}</p>
{func.renamed && func.originalName && (
<p className="text-muted-foreground mt-0.5">
Originally: {func.originalName}
</p>
)}
</TooltipContent>
</Tooltip>
)}
{func.renamed && (
<Tooltip>
<TooltipTrigger asChild>
<Pencil className="text-primary h-3 w-3 shrink-0" />
</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-none overflow-visible font-mono text-xs"
>
<p>Renamed</p>
{func.originalName && (
<p className="text-muted-foreground">Originally: {func.originalName}</p>
)}
</TooltipContent>
</Tooltip>
)}
@ -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}

View File

@ -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<SuggestionStatus>("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 (
<div
className={cn(
"border-l-primary/60 bg-muted/60 my-3 rounded-lg border-l-[3px] px-3 py-2.5 text-sm",
isApproved && "bg-muted/40 border-l-(--success)",
isRejected && "border-l-muted-foreground/40 bg-muted/30"
)}
>
<div className="mb-2 flex items-center gap-2">
<Sparkles className="text-primary h-4 w-4 shrink-0" />
<span className="text-foreground text-xs font-medium">AI suggests renaming:</span>
</div>
<div className="mb-3 font-mono text-sm">
<span className="text-muted-foreground line-through">{oldName}</span>
<span className="text-muted-foreground mx-1.5"></span>
<span
className={cn(
"font-semibold",
isApproved && "text-(--success)",
isRejected && "text-muted-foreground"
)}
>
{newName}
</span>
</div>
{isApproved && (
<p className="mb-2 flex items-center gap-1 text-xs text-(--success)">
<Check className="h-3.5 w-3.5" />
Approved
</p>
)}
{isRejected && (
<p className="text-muted-foreground mb-2 flex items-center gap-1 text-xs">
<X className="h-3.5 w-3.5" />
Rejected
</p>
)}
{isPending && (
<div className="flex gap-2">
<Button
variant="default"
size="sm"
className="h-7 text-xs"
onClick={handleApprove}
disabled={approveMutation.isPending}
>
{approveMutation.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
Approve
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={handleReject}
disabled={rejectMutation.isPending}
>
{rejectMutation.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
Reject
</Button>
</div>
)}
</div>
);
}

View File

@ -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<string | null>(null);
const [toolStatus, setToolStatus] = useState<string | null>(null);
const [isToolRunning, setIsToolRunning] = useState(false);
const [renameSuggestions, setRenameSuggestions] = useState<RenameSuggestionEvent[]>([]);
const [previousRenameSuggestions, setPreviousRenameSuggestions] = useState<
RenameSuggestionEvent[]
>([]);
const [pendingUserMessage, setPendingUserMessage] = useState<MessageResponse | null>(null);
const [optimisticAssistantMessage, setOptimisticAssistantMessage] =
useState<MessageResponse | null>(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,

View File

@ -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}
/>
</div>
</ResizablePanel>
@ -321,7 +332,7 @@ const Binary = () => {
<ResizablePanel id="chat-panel" defaultSize="30%" minSize="10%" maxSize="40%">
<div className="border-border bg-card h-full overflow-hidden border-l">
<AIChatPanel />
<AIChatPanel onRenameApplied={handleRenameApplied} />
</div>
</ResizablePanel>
</ResizablePanelGroup>