@@ -486,14 +499,28 @@ export const ComputerSidebarBase: React.FC = ({
type="button"
onClick={handleClose}
className="w-7 h-7 relative rounded-md inline-flex items-center justify-center gap-2.5 cursor-pointer hover:bg-muted/50 transition-colors"
- aria-label="Minimize sidebar"
+ aria-label={
+ usesCloseAction ? "Close details" : "Minimize sidebar"
+ }
tabIndex={0}
onKeyDown={handleKeyDown}
>
-
+ {usesCloseAction ? (
+
+ ) : (
+
+ )}
- Minimize
+
+ {usesCloseAction ? "Close" : "Minimize"}
+
@@ -504,8 +531,17 @@ export const ComputerSidebarBase: React.FC
= ({
- HackerAI is using{" "}
- {toolName}
+ {isToolError ? (
+ <>
+ {toolName} needs
+ attention
+ >
+ ) : (
+ <>
+ HackerAI is using{" "}
+ {toolName}
+ >
+ )}
= ({
Notes
+ ) : isFinding ? (
+
+ Vulnerability report
+
+ ) : isToolError ? (
+
+ Error details
+
) : isSharedFiles ? (
Shared Files
@@ -558,49 +602,53 @@ export const ComputerSidebarBase: React.FC = ({
{/* Action buttons - far right */}
- {!isWebSearch && !isNotes && !isSharedFiles && (
-
- )}
+ {!isWebSearch &&
+ !isNotes &&
+ !isFinding &&
+ !isToolError &&
+ !isSharedFiles && (
+
+ )}
{/* Content */}
@@ -750,8 +798,7 @@ export const ComputerSidebarBase: React.FC
= ({
key={file.fileId || `file-${index}`}
part={{
fileId: file.fileId as
- | Id<"files">
- | undefined,
+ Id<"files"> | undefined,
s3Key: file.s3Key,
name: file.name,
filename: file.name,
@@ -766,6 +813,16 @@ export const ComputerSidebarBase: React.FC = ({
)}
+ {isFinding && (
+
diff --git a/app/components/MessagePartHandler.tsx b/app/components/MessagePartHandler.tsx
index 129eb03cf..46b117d3f 100644
--- a/app/components/MessagePartHandler.tsx
+++ b/app/components/MessagePartHandler.tsx
@@ -8,12 +8,16 @@ import { HttpRequestToolHandler } from "./tools/HttpRequestToolHandler";
import { WebToolHandler } from "./tools/WebToolHandler";
import { TodoToolHandler } from "./tools/TodoToolHandler";
import { NotesToolHandler } from "./tools/NotesToolHandler";
+import { FindingToolHandler } from "./tools/FindingToolHandler";
+import { ToolValidationErrorHandler } from "./tools/ToolErrorHandler";
+import { FindingCard } from "./findings/FindingCard";
import { ProxyToolHandler } from "./tools/ProxyToolHandler";
import { GetTerminalFilesHandler } from "./tools/GetTerminalFilesHandler";
import { SummarizationHandler } from "./tools/SummarizationHandler";
import type { ChatStatus } from "@/types";
import type { FileDetails } from "@/types/file";
import { ReasoningHandler } from "./ReasoningHandler";
+import { isToolInputValidationError } from "@/lib/chat/tool-error-display";
interface MessagePartHandlerProps {
message: UIMessage;
@@ -64,7 +68,7 @@ function deepEqual(a: any, b: any): boolean {
}
// Custom comparison for MessagePartHandler to minimize re-renders
-function arePropsEqual(
+export function areMessagePartHandlerPropsEqual(
prevProps: MessagePartHandlerProps,
nextProps: MessagePartHandlerProps,
): boolean {
@@ -84,6 +88,8 @@ function arePropsEqual(
)
return false;
+ if (prevProps.part?.type !== nextProps.part?.type) return false;
+
// Shared file details change for get_terminal_files during streaming
// Must be checked before the part reference check below, because the part
// reference may be stable while new file metadata arrives via the stream.
@@ -103,6 +109,10 @@ function arePropsEqual(
)
return false;
+ if (prevProps.part?.type === "data-shared-finding") {
+ return deepEqual(prevProps.part.data, nextProps.part.data);
+ }
+
// For tool parts, compare state and output which change during streaming
if (
prevProps.part?.type?.startsWith("tool-") ||
@@ -111,10 +121,12 @@ function arePropsEqual(
return (
prevProps.part.state === nextProps.part.state &&
prevProps.part.toolCallId === nextProps.part.toolCallId &&
+ prevProps.part.toolName === nextProps.part.toolName &&
prevProps.part.output === nextProps.part.output &&
+ prevProps.part.errorText === nextProps.part.errorText &&
deepEqual(prevProps.part.approval, nextProps.part.approval) &&
// Tool input is an object — reference check first (fast path), then
- // shallow comparison so new objects with identical content don't re-render.
+ // deep comparison so new objects with identical content don't re-render.
(prevProps.part.input === nextProps.part.input ||
deepEqual(prevProps.part.input, nextProps.part.input))
);
@@ -148,6 +160,26 @@ export const MessagePartHandler = memo(function MessagePartHandler({
terminalOutputByToolCallId,
sharedFileDetails,
}: MessagePartHandlerProps) {
+ const validationToolType =
+ typeof part.type === "string" && part.type.startsWith("tool-")
+ ? part.type
+ : part.type === "dynamic-tool" && typeof part.toolName === "string"
+ ? `tool-${part.toolName}`
+ : null;
+ if (
+ validationToolType &&
+ part.state === "output-error" &&
+ isToolInputValidationError(part.errorText)
+ ) {
+ return (
+
+ );
+ }
+
// Main switch for different part types
switch (part.type) {
case "text": {
@@ -257,6 +289,19 @@ export const MessagePartHandler = memo(function MessagePartHandler({
);
+ case "tool-create_vulnerability_report":
+ return
;
+
+ case "data-shared-finding":
+ return part.data ? (
+
+ ) : null;
+
case "tool-list_requests":
return (
import("./AllFilesDialog").then((module) => module.AllFilesDialog),
@@ -120,6 +123,11 @@ export const Messages = ({
() => messages.filter((msg) => !msg.metadata?.isAutoContinue),
[messages],
);
+ const sourceMessageId = useSourceMessageNavigation({
+ loadedMessageCount: messages.length,
+ paginationStatus,
+ loadMore,
+ });
// Memoize expensive calculations
const lastAssistantMessageIndex = useMemo(() => {
@@ -313,46 +321,59 @@ export const Messages = ({
)}
- {visibleMessages.map((message, index) => (
-
- ))}
+ {visibleMessages.map((message, index) => {
+ const isSourceMessage = sourceMessageId === message.id;
+
+ return (
+
+
+
+ );
+ })}
{/* Processing status - upload/loading dots always separate, summarization only when no content */}
{(showSummarizationSeparately ||
diff --git a/app/components/SidebarHeader.tsx b/app/components/SidebarHeader.tsx
index aa331bd34..7514e0797 100644
--- a/app/components/SidebarHeader.tsx
+++ b/app/components/SidebarHeader.tsx
@@ -1,15 +1,19 @@
"use client";
import { useState, useEffect, useMemo, FC } from "react";
+import { usePathname, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
PanelLeft,
Sidebar as SidebarIcon,
SquarePen,
Search,
+ ShieldAlert,
} from "lucide-react";
import { useSidebar } from "@/components/ui/sidebar";
import { HackerAISVG } from "@/components/icons/hackerai-svg";
+import { useGlobalState } from "../contexts/GlobalState";
+import { useIsMobile } from "@/hooks/use-mobile";
import { useChats } from "../hooks/useChats";
import { useStartNewChat } from "../hooks/useStartNewChat";
import { MessageSearchDialog } from "./MessageSearchDialog";
@@ -36,6 +40,10 @@ const SidebarHeaderContentImpl: FC
= ({
toggleSidebar,
}) => {
const startNewChat = useStartNewChat();
+ const isMobile = useIsMobile();
+ const router = useRouter();
+ const pathname = usePathname();
+ const { setChatSidebarOpen, closeSidebar } = useGlobalState();
// Search dialog state
const [isSearchOpen, setIsSearchOpen] = useState(false);
@@ -79,6 +87,12 @@ const SidebarHeaderContentImpl: FC = ({
setIsSearchOpen(true);
};
+ const handleFindingsOpen = () => {
+ closeSidebar();
+ if (isMobile) setChatSidebarOpen(false);
+ router.push("/findings");
+ };
+
const handleSearchClose = () => {
setIsSearchOpen(false);
};
@@ -140,6 +154,21 @@ const SidebarHeaderContentImpl: FC = ({
+
+