fix(chat): fix mutation card rendering, reappearance after approve, and pseudocode tab switching

- MutationSuggestionCard: call onResolved immediately on approve/reject so
  resolvedIds is set even if component unmounts before dismiss timer fires.
  Invalidate mutations list query to refresh pendingSuggestions.
- AIChatPanel: use explicit null checks instead of falsy for oldValue/newValue
  so empty-string fields (e.g. decompiledCode) render correctly. Remove
  resolvedIds filter from streaming mutationSuggestions — the card manages
  its own lifecycle internally.
- CodeViewer: auto-switch to assembly tab when selecting a function that has
  no pseudocode (derived effectiveTab via useMemo).
This commit is contained in:
Rodrigo Verdiani 2026-07-04 18:01:30 -03:00
parent 914005e00e
commit e67a99671b
3 changed files with 45 additions and 18 deletions

View File

@ -3,6 +3,8 @@ import { formatDistanceToNow } from "date-fns";
import { Send, Bot, User, Loader2, Sparkles, Plus, Trash2 } from "lucide-react"; import { Send, Bot, User, Loader2, Sparkles, Plus, Trash2 } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useAIChat } from "@/features/binary/hooks/useAIChat"; import { useAIChat } from "@/features/binary/hooks/useAIChat";
@ -25,7 +27,7 @@ const quickPrompts = [
"Summarize the binary", "Summarize the binary",
]; ];
const remarkPlugins = [remarkGfm]; const remarkPlugins = [remarkGfm, remarkMath];
const codeBlockTheme = { const codeBlockTheme = {
...oneDark, ...oneDark,
@ -352,7 +354,13 @@ export function AIChatPanel({
{mergedMessages.map((item, idx) => { {mergedMessages.map((item, idx) => {
if (item.kind === "suggestion") { if (item.kind === "suggestion") {
const s = item.data; const s = item.data;
if (!s.id || !s.targetId || !s.oldValue || !s.newValue) return null; if (
s.id == null ||
s.targetId == null ||
s.oldValue == null ||
s.newValue == null
)
return null;
if (resolvedIds.has(s.id)) return null; if (resolvedIds.has(s.id)) return null;
return ( return (
<MutationSuggestionCard <MutationSuggestionCard
@ -403,6 +411,7 @@ export function AIChatPanel({
<div className={markdownContainerClass}> <div className={markdownContainerClass}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={remarkPlugins} remarkPlugins={remarkPlugins}
rehypePlugins={[rehypeKatex]}
components={markdownComponents} components={markdownComponents}
> >
{normalizeMarkdown(message.content ?? "")} {normalizeMarkdown(message.content ?? "")}
@ -477,21 +486,19 @@ export function AIChatPanel({
<span>{toolStatus}</span> <span>{toolStatus}</span>
</div> </div>
)} )}
{mutationSuggestions {mutationSuggestions.map((s) => (
.filter((s) => !resolvedIds.has(s.mutationId)) <MutationSuggestionCard
.map((s) => ( key={s.mutationId}
<MutationSuggestionCard mutationId={s.mutationId}
key={s.mutationId} targetType={s.targetType}
mutationId={s.mutationId} targetId={s.targetId}
targetType={s.targetType} fieldName={s.fieldName}
targetId={s.targetId} oldValue={s.oldValue}
fieldName={s.fieldName} newValue={s.newValue}
oldValue={s.oldValue} onRenameApplied={onRenameApplied}
newValue={s.newValue} onResolved={handleResolved}
onRenameApplied={onRenameApplied} />
onResolved={handleResolved} ))}
/>
))}
</> </>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />

View File

@ -153,8 +153,14 @@ export function CodeViewer({
const hasCfg = cfgBlocks.length > 0; const hasCfg = cfgBlocks.length > 0;
const hasPseudocode = !!func?.decompiledCode; const hasPseudocode = !!func?.decompiledCode;
const effectiveTab = useMemo(() => {
if (activeTab === "pseudocode" && !hasPseudocode) return "assembly";
if (activeTab === "cfg" && !hasCfg) return "assembly";
return activeTab;
}, [activeTab, hasPseudocode, hasCfg]);
return ( return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col"> <Tabs value={effectiveTab} onValueChange={setActiveTab} className="flex h-full flex-col">
<div className="border-border flex shrink-0 items-center justify-between gap-2 border-b px-4 py-2"> <div className="border-border flex shrink-0 items-center justify-between gap-2 border-b px-4 py-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<TabsList className="h-8"> <TabsList className="h-8">

View File

@ -158,6 +158,13 @@ export function MutationSuggestionCard({
if (invalidationKey) { if (invalidationKey) {
queryClient.invalidateQueries({ queryKey: [invalidationKey] }); queryClient.invalidateQueries({ queryKey: [invalidationKey] });
} }
queryClient.invalidateQueries({
predicate: (query) =>
typeof query.queryKey[0] === "string" &&
query.queryKey[0].startsWith("/binaries/") &&
query.queryKey[0].endsWith("/mutations"),
});
onResolved?.(mutationId);
onRenameApplied?.(); onRenameApplied?.();
dismissTimerRef.current = setTimeout(resolve, 1500); dismissTimerRef.current = setTimeout(resolve, 1500);
}, },
@ -168,6 +175,13 @@ export function MutationSuggestionCard({
mutation: { mutation: {
onSuccess: () => { onSuccess: () => {
setStatus("REJECTED"); setStatus("REJECTED");
queryClient.invalidateQueries({
predicate: (query) =>
typeof query.queryKey[0] === "string" &&
query.queryKey[0].startsWith("/binaries/") &&
query.queryKey[0].endsWith("/mutations"),
});
onResolved?.(mutationId);
onRenameApplied?.(); onRenameApplied?.();
dismissTimerRef.current = setTimeout(resolve, 1500); dismissTimerRef.current = setTimeout(resolve, 1500);
}, },