Skip to content
Open
Original file line number Diff line number Diff line change
@@ -1,29 +1,53 @@
import React from "react";

import { RefreshCcw, Trash } from "lucide-react";
import { Eye, EyeOff, RefreshCcw, Trash } from "lucide-react";

import { Menu, Popover, PopoverContent, PopoverTrigger, type MenuAction } from "@/lib/components";
import { useChatContext } from "@/lib/hooks";

interface ArtifactMorePopoverProps {
children: React.ReactNode;
hideDeleteAll?: boolean;
showWorkingArtifacts?: boolean;
onToggleWorkingArtifacts?: () => void;
workingArtifactCount?: number;
}

export const ArtifactMorePopover: React.FC<ArtifactMorePopoverProps> = ({ children, hideDeleteAll = false }) => {
export const ArtifactMorePopover: React.FC<ArtifactMorePopoverProps> = ({
children,
hideDeleteAll = false,
showWorkingArtifacts = false,
onToggleWorkingArtifacts,
workingArtifactCount = 0,
}) => {
const { artifactsRefetch, setIsBatchDeleteModalOpen } = useChatContext();

const menuActions: MenuAction[] = [
{
id: "refreshAll",
label: "Refresh",
onClick: () => {
artifactsRefetch();
},
icon: <RefreshCcw />,
const menuActions: MenuAction[] = [];
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added hidden concept within artifact file list:

Image

and

Image


// Add working artifacts toggle if callback is provided
if (onToggleWorkingArtifacts) {
const countLabel = workingArtifactCount > 0 ? ` (${workingArtifactCount})` : "";
menuActions.push({
id: "toggleWorking",
label: showWorkingArtifacts
? `Hide Working Files${countLabel}`
: `Show Working Files${countLabel}`,
onClick: onToggleWorkingArtifacts,
icon: showWorkingArtifacts ? <EyeOff /> : <Eye />,
iconPosition: "left",
});
}

menuActions.push({
id: "refreshAll",
label: "Refresh",
onClick: () => {
artifactsRefetch();
},
];
icon: <RefreshCcw />,
iconPosition: "left",
divider: onToggleWorkingArtifacts ? true : false,
});

if (!hideDeleteAll) {
menuActions.push({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useMemo, useState } from "react";

import { ArrowDown, ArrowLeft, Ellipsis, FileText, Loader2 } from "lucide-react";
import { ArrowDown, ArrowLeft, Ellipsis, EyeOff, FileText, Loader2 } from "lucide-react";

import { Button } from "@/lib/components";
import { useChatContext, useDownload } from "@/lib/hooks";
Expand All @@ -23,7 +23,7 @@ const sortFunctions: Record<SortOptionType, (a1: ArtifactInfo, a2: ArtifactInfo)
};

export const ArtifactPanel: React.FC = () => {
const { artifacts, artifactsLoading, previewArtifact, setPreviewArtifact, artifactsRefetch, openDeleteModal } = useChatContext();
const { artifacts, artifactsLoading, previewArtifact, setPreviewArtifact, artifactsRefetch, openDeleteModal, showWorkingArtifacts, toggleShowWorkingArtifacts, workingArtifactCount } = useChatContext();
const { onDownload } = useDownload();

const [sortOption, setSortOption] = useState<SortOptionType>(SortOption.DateDesc);
Expand Down Expand Up @@ -51,50 +51,80 @@ export const ArtifactPanel: React.FC = () => {
);
}

// Show header when there are visible artifacts OR when there are working artifacts (so menu is accessible)
const hasArtifactsOrWorking = sortedArtifacts.length > 0 || workingArtifactCount > 0;
if (!hasArtifactsOrWorking) return null;

return (
sortedArtifacts.length > 0 && (
<div className="flex items-center justify-end border-b p-2">
<div className="flex items-center justify-end border-b p-2">
{sortedArtifacts.length > 0 && (
<SortPopover key="sort-popover" currentSortOption={sortOption} onSortChange={setSortOption}>
<Button variant="ghost" title="Sort By">
<ArrowDown className="h-5 w-5" />
<div>Sort By</div>
</Button>
</SortPopover>
<ArtifactMorePopover key="more-popover" hideDeleteAll={!hasDeletableArtifacts}>
<Button variant="ghost" tooltip="More">
<Ellipsis className="h-5 w-5" />
</Button>
</ArtifactMorePopover>
</div>
)
)}
<ArtifactMorePopover
key="more-popover"
hideDeleteAll={!hasDeletableArtifacts}
showWorkingArtifacts={showWorkingArtifacts}
onToggleWorkingArtifacts={toggleShowWorkingArtifacts}
workingArtifactCount={workingArtifactCount}
>
<Button variant="ghost" tooltip="More">
<Ellipsis className="h-5 w-5" />
</Button>
</ArtifactMorePopover>
</div>
);
}, [previewArtifact, sortedArtifacts.length, sortOption, setPreviewArtifact, hasDeletableArtifacts]);
}, [previewArtifact, sortedArtifacts.length, sortOption, setPreviewArtifact, hasDeletableArtifacts, showWorkingArtifacts, toggleShowWorkingArtifacts, workingArtifactCount]);

return (
<div className="flex h-full flex-col">
{header}
<div className="flex min-h-0 flex-1">
{!previewArtifact && (
<div className="flex-1 overflow-y-auto">
{sortedArtifacts.map(artifact => (
<ArtifactCard key={artifact.filename} artifact={artifact} />
))}
{sortedArtifacts.length === 0 && (
<div className="flex h-full items-center justify-center p-4">
<div className="text-muted-foreground text-center">
{artifactsLoading && <Loader2 className="size-6 animate-spin" />}
{!artifactsLoading && (
<>
<FileText className="mx-auto mb-4 h-12 w-12" />
<div className="text-lg font-medium">Files</div>
<div className="mt-2 text-sm">No files available</div>
<Button className="mt-4" variant="default" onClick={artifactsRefetch} data-testid="refreshFiles" title="Refresh Files">
Refresh
</Button>
</>
)}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto">
{sortedArtifacts.map(artifact => (
<ArtifactCard key={artifact.filename} artifact={artifact} />
))}
{sortedArtifacts.length === 0 && (
<div className="flex h-full items-center justify-center p-4">
<div className="text-muted-foreground text-center">
{artifactsLoading && <Loader2 className="size-6 animate-spin" />}
{!artifactsLoading && (
<>
<FileText className="mx-auto mb-4 h-12 w-12" />
<div className="text-lg font-medium">Files</div>
{!showWorkingArtifacts && workingArtifactCount > 0 ? (
<div className="mt-2 text-sm">
{workingArtifactCount} working {workingArtifactCount === 1 ? "file is" : "files are"} hidden
</div>
) : (
<>
<div className="mt-2 text-sm">No files available</div>
<Button className="mt-4" variant="default" onClick={artifactsRefetch} data-testid="refreshFiles" title="Refresh Files">
Refresh
</Button>
</>
)}
</>
)}
</div>
</div>
</div>
)}
</div>
{/* Hidden working files indicator */}
{!showWorkingArtifacts && workingArtifactCount > 0 && sortedArtifacts.length > 0 && (
<button
onClick={toggleShowWorkingArtifacts}
className="text-muted-foreground hover:text-foreground hover:bg-muted/50 flex items-center justify-center gap-1.5 border-t px-3 py-2 text-xs transition-colors"
>
<EyeOff className="h-3 w-3" />
<span>{workingArtifactCount} working {workingArtifactCount === 1 ? "file" : "files"} hidden</span>
</button>
)}
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ type ArtifactMessageProps = (
};

export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
const { artifacts, setPreviewArtifact, openSidePanelTab, sessionId, openDeleteModal, markArtifactAsDisplayed, downloadAndResolveArtifact, navigateArtifactVersion, ragData } = useChatContext();
const { artifacts, allArtifacts, setPreviewArtifact, openSidePanelTab, sessionId, openDeleteModal, markArtifactAsDisplayed, downloadAndResolveArtifact, navigateArtifactVersion, ragData } = useChatContext();
const { activeProject } = useProjectContext();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -70,10 +70,14 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
const fileName = fileAttachment?.name || props.name;
const fileMimeType = fileAttachment?.mime_type;

// Detect if artifact has been deleted: completed but not in artifacts list
// Check if artifact exists in allArtifacts (exists but may be hidden due to working tag)
// Fall back to artifacts array if allArtifacts is not available (e.g., in Storybook)
const artifactInAll = useMemo(() => (allArtifacts ?? artifacts).find(art => art.filename === props.name), [allArtifacts, artifacts, props.name]);

// Detect if artifact has been deleted: completed but not in allArtifacts list
const isDeleted = useMemo(() => {
return props.status === "completed" && !artifact;
}, [props.status, artifact]);
return props.status === "completed" && !artifactInAll;
}, [props.status, artifactInAll]);

// Determine if this should auto-expand based on context
const shouldAutoExpand = useMemo(() => {
Expand Down Expand Up @@ -107,32 +111,36 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
});

const handlePreviewClick = useCallback(async () => {
if (artifact) {
// Use artifact if available, otherwise use artifactInAll for hidden artifacts
const artifactToPreview = artifact || artifactInAll;
if (artifactToPreview) {
openSidePanelTab("files");
setPreviewArtifact(artifact);
setPreviewArtifact(artifactToPreview);

// If this artifact has a specific version from the chat message, navigate to it
if (version !== undefined) {
// Wait a bit for the preview to open, then navigate to the specific version
setTimeout(async () => {
await navigateArtifactVersion(artifact.filename, version);
await navigateArtifactVersion(artifactToPreview.filename, version);
}, 100);
}
}
}, [artifact, openSidePanelTab, setPreviewArtifact, version, navigateArtifactVersion]);
}, [artifact, artifactInAll, openSidePanelTab, setPreviewArtifact, version, navigateArtifactVersion]);

const handleDownloadClick = useCallback(() => {
// Build the file to download from available sources
let fileToDownload: FileAttachment | null = null;

// Try to use artifact from global state (has URI) or fileAttachment prop (might have content)
if (artifact) {
// For hidden artifacts, use artifactInAll as fallback
const artifactSource = artifact || artifactInAll;
if (artifactSource) {
fileToDownload = {
name: artifact.filename,
mime_type: artifact.mime_type,
uri: artifact.uri,
size: artifact.size,
last_modified: artifact.last_modified,
name: artifactSource.filename,
mime_type: artifactSource.mime_type,
uri: artifactSource.uri,
size: artifactSource.size,
last_modified: artifactSource.last_modified,
};
// If artifact doesn't have URI, try to use content from fileAttachment
if (!fileToDownload.uri && fileAttachment?.content) {
Expand All @@ -147,7 +155,7 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
} else {
console.error(`No file to download for artifact: ${props.name}`);
}
}, [artifact, fileAttachment, sessionId, activeProject?.id, props.name]);
}, [artifact, artifactInAll, fileAttachment, sessionId, activeProject?.id, props.name]);

const handleDeleteClick = useCallback(() => {
if (artifact) {
Expand Down Expand Up @@ -340,10 +348,8 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
}
}, [props.status, context, handleDownloadClick, artifact, handleDeleteClick, handleInfoClick, handlePreviewClick, isProjectArtifact]);

// Get description from global artifacts instead of message parts
const artifactFromGlobal = useMemo(() => artifacts.find(art => art.filename === props.name), [artifacts, props.name]);

const description = artifactFromGlobal?.description;
// Get description from allArtifacts (unfiltered) so hidden artifacts still show their description
const description = artifactInAll?.description;

// For rendering content, we need the actual content
const contentToRender = fetchedContent || fileAttachment?.content;
Expand Down Expand Up @@ -432,11 +438,13 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
const shouldShowContent = shouldRender && isExpanded;

// Prepare info content for expansion
// Use artifactInAll to get info even for hidden artifacts
const infoContent = useMemo(() => {
if (!isInfoExpanded || !artifact) return null;
const artifactData = artifactInAll || artifact;
if (!isInfoExpanded || !artifactData) return null;

return <FileDetails description={artifact.description ?? undefined} size={artifact.size} lastModified={artifact.last_modified} mimeType={artifact.mime_type} />;
}, [isInfoExpanded, artifact]);
return <FileDetails description={artifactData.description ?? undefined} size={artifactData.size} lastModified={artifactData.last_modified} mimeType={artifactData.mime_type} />;
}, [isInfoExpanded, artifact, artifactInAll]);

// Determine what content to show in expanded area - can show both info and content
const finalExpandedContent = useMemo(() => {
Expand Down Expand Up @@ -482,7 +490,7 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
context={context}
isDeleted={isDeleted}
version={version}
source={artifact?.source}
source={artifactInAll?.source}
/>
);
};
8 changes: 8 additions & 0 deletions client/webui/frontend/src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Frontend constants matching backend system tag definitions.
* System tags are prefixed with "__" to distinguish from user tags.
*/

// System artifact tags (must match backend constants in common/constants.py)
export const ARTIFACT_TAG_USER_UPLOADED = "__user_uploaded";
export const ARTIFACT_TAG_WORKING = "__working";
4 changes: 4 additions & 0 deletions client/webui/frontend/src/lib/contexts/ChatContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ export interface ChatState {
agentNameDisplayNameMap: Record<string, string>;
// Chat Side Panel State
artifacts: ArtifactInfo[];
allArtifacts: ArtifactInfo[];
artifactsLoading: boolean;
artifactsRefetch: () => Promise<void>;
setArtifacts: React.Dispatch<React.SetStateAction<ArtifactInfo[]>>;
showWorkingArtifacts: boolean;
toggleShowWorkingArtifacts: () => void;
workingArtifactCount: number;
taskIdInSidePanel: string | null;
// RAG State
ragData: RAGSearchResult[];
Expand Down
Loading
Loading