Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import React from "react";
import type { MouseEvent } from "react";

import type { ArtifactInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
import { ArtifactMessage } from "../file/ArtifactMessage";
import { useChatContext } from "@/lib/hooks";

interface ArtifactCardProps {
artifact: ArtifactInfo;
isPreview?: boolean;
readOnly?: boolean;
onDownloadOverride?: () => Promise<void>;
}

export const ArtifactCard: React.FC<ArtifactCardProps> = ({ artifact, isPreview }) => {
export const ArtifactCard = ({ artifact, isPreview, readOnly = false, onDownloadOverride }: ArtifactCardProps) => {
const { setPreviewArtifact } = useChatContext();

// Create a FileAttachment from the ArtifactInfo
Expand All @@ -20,16 +23,16 @@ export const ArtifactCard: React.FC<ArtifactCardProps> = ({ artifact, isPreview
uri: artifact.uri,
};

const handleClick = (e: React.MouseEvent) => {
const handleClick = (e: MouseEvent) => {
if (!isPreview) {
e.stopPropagation();
setPreviewArtifact(artifact);
}
};

return (
<div className={`${isPreview ? "" : "cursor-pointer transition-all duration-150 hover:bg-(--background-w20)"}`} onClick={handleClick}>
<ArtifactMessage status="completed" name={artifact.filename} fileAttachment={fileAttachment} context="list" />
<div className={cn(!isPreview && "cursor-pointer transition-all duration-150 hover:bg-(--accent-background)")} onClick={handleClick}>
<ArtifactMessage status="completed" name={artifact.filename} fileAttachment={fileAttachment} context="list" readOnly={readOnly} onDownloadOverride={onDownloadOverride} />
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { useMemo, useState } from "react";
import { useMemo, useState } from "react";

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

import { Button } from "@/lib/components";
import { useChatContext, useDownload } from "@/lib/hooks";
import { useChatContext } from "@/lib/hooks";
import { useDownload } from "@/lib/hooks";
import type { ArtifactInfo } from "@/lib/types";
import { formatBytes } from "@/lib/utils/format";

Expand All @@ -22,9 +23,20 @@ const sortFunctions: Record<SortOptionType, (a1: ArtifactInfo, a2: ArtifactInfo)
[SortOption.DateDesc]: (a1, a2) => (a1.last_modified < a2.last_modified ? 1 : -1),
};

export const ArtifactPanel: React.FC = () => {
const { artifacts, artifactsLoading, previewArtifact, setPreviewArtifact, artifactsRefetch, openDeleteModal } = useChatContext();
const { onDownload } = useDownload();
interface ArtifactPanelProps {
/** Read-only mode - hides delete buttons and edit functionality */
readOnly?: boolean;
/** Custom download handler - if not provided, uses default useDownload hook */
onDownloadOverride?: (artifact: ArtifactInfo) => Promise<void>;
}

export const ArtifactPanel = ({ readOnly = false, onDownloadOverride }: ArtifactPanelProps) => {
const { artifacts, artifactsLoading, artifactsRefetch, previewArtifact, setPreviewArtifact, openDeleteModal, isDeleteModalOpen, isBatchDeleteModalOpen } = useChatContext();

const { onDownload: defaultOnDownload } = useDownload();

// Use custom download handler if provided, otherwise use default
const onDownload = onDownloadOverride || defaultOnDownload;

const [sortOption, setSortOption] = useState<SortOptionType>(SortOption.DateDesc);
const [isPreviewInfoExpanded, setIsPreviewInfoExpanded] = useState(false);
Expand Down Expand Up @@ -60,15 +72,18 @@ export const ArtifactPanel: React.FC = () => {
<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>
{/* Hide "More" popover in readOnly mode */}
{!readOnly && (
<ArtifactMorePopover key="more-popover" hideDeleteAll={!hasDeletableArtifacts}>
<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, readOnly]);

return (
<div className="flex h-full flex-col">
Expand All @@ -77,20 +92,23 @@ export const ArtifactPanel: React.FC = () => {
{!previewArtifact && (
<div className="flex-1 overflow-y-auto">
{sortedArtifacts.map(artifact => (
<ArtifactCard key={artifact.filename} artifact={artifact} />
<ArtifactCard key={artifact.filename} artifact={artifact} readOnly={readOnly} onDownloadOverride={onDownloadOverride ? () => onDownloadOverride(artifact) : undefined} />
))}
{sortedArtifacts.length === 0 && (
<div className="flex h-full items-center justify-center p-4">
<div className="text-center text-(--secondary-text-wMain)">
<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>
{/* Hide Refresh button in readOnly mode */}
{!readOnly && (
<Button className="mt-4" variant="default" onClick={artifactsRefetch} data-testid="refreshFiles" title="Refresh Files">
Refresh
</Button>
)}
</>
)}
</div>
Expand All @@ -106,7 +124,7 @@ export const ArtifactPanel: React.FC = () => {
isPreview={true}
isExpanded={isPreviewInfoExpanded}
setIsExpanded={setIsPreviewInfoExpanded}
onDelete={previewArtifact.source === "project" ? undefined : () => openDeleteModal(previewArtifact)}
onDelete={readOnly || previewArtifact.source === "project" ? undefined : () => openDeleteModal(previewArtifact)}
onDownload={() => onDownload(previewArtifact)}
/>
</div>
Expand All @@ -115,17 +133,17 @@ export const ArtifactPanel: React.FC = () => {
<div className="space-y-2 text-sm">
{previewArtifact.description && (
<div>
<span className="text-(--secondary-text-wMain)">Description:</span>
<span className="text-secondary-foreground">Description:</span>
<div className="mt-1">{previewArtifact.description}</div>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-(--secondary-text-wMain)">Size:</span>
<span className="text-secondary-foreground">Size:</span>
<div>{formatBytes(previewArtifact.size)}</div>
</div>
<div>
<span className="text-(--secondary-text-wMain)">Type:</span>
<span className="text-secondary-foreground">Type:</span>
<div>{previewArtifact.mime_type || "Unknown"}</div>
</div>
</div>
Expand All @@ -138,8 +156,13 @@ export const ArtifactPanel: React.FC = () => {
</div>
)}
</div>
<ArtifactDeleteDialog />
<ArtifactDeleteAllDialog />
{/* Only render dialogs if they might be open - SharedChatProvider provides no-op handlers */}
{(isDeleteModalOpen || isBatchDeleteModalOpen) && (
<>
<ArtifactDeleteDialog />
<ArtifactDeleteAllDialog />
</>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";

import { api, getErrorFromResponse } from "@/lib/api";
import { Spinner } from "@/lib/components/ui/spinner";
Expand Down Expand Up @@ -36,9 +36,11 @@ type ArtifactMessageProps = (
uniqueKey?: string; // Optional unique key for expansion state (e.g., taskId-filename)
isStreaming?: boolean;
message?: MessageFE; // Optional message to get taskId for ragData lookup
readOnly?: boolean; // Hide delete button and other edit actions
onDownloadOverride?: () => Promise<void>; // Custom download handler
};

export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
export const ArtifactMessage = (props: ArtifactMessageProps) => {
const { artifacts, setPreviewArtifact, openSidePanelTab, sessionId, openDeleteModal, markArtifactAsDisplayed, downloadAndResolveArtifact, navigateArtifactVersion, ragData } = useChatContext();
const { activeProject } = useProjectContext();
const [isLoading, setIsLoading] = useState(false);
Expand All @@ -51,6 +53,7 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
const artifact = useMemo(() => artifacts.find(art => art.filename === props.name), [artifacts, props.name]);
const context = props.context || "chat";
const isStreaming = props.isStreaming;
const readOnly = props.readOnly || false;

// Check if this artifact is from a project (should not be deletable)
const isProjectArtifact = artifact?.source === "project";
Expand Down Expand Up @@ -128,6 +131,12 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
}, [artifact, openSidePanelTab, setPreviewArtifact, version, navigateArtifactVersion]);

const handleDownloadClick = useCallback(() => {
// If custom download handler is provided, use it
if (props.onDownloadOverride) {
props.onDownloadOverride();
return;
}

// Build the file to download from available sources
let fileToDownload: FileAttachment | null = null;

Expand All @@ -140,9 +149,14 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
size: artifact.size,
last_modified: artifact.last_modified,
};
// If artifact doesn't have URI, try to use content from fileAttachment
if (!fileToDownload.uri && fileAttachment?.content) {
fileToDownload.content = fileAttachment.content;
// If artifact doesn't have URI, try to use content from various sources
if (!fileToDownload.uri) {
// Priority: fetchedContent (from downloadAndResolveArtifact) > fileAttachment.content
if (fetchedContent) {
fileToDownload.content = fetchedContent;
} else if (fileAttachment?.content) {
fileToDownload.content = fileAttachment.content;
}
}
} else if (fileAttachment) {
fileToDownload = fileAttachment;
Expand All @@ -153,7 +167,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, fileAttachment, sessionId, activeProject?.id, props.name, fetchedContent, props.onDownloadOverride]);

const handleDeleteClick = useCallback(() => {
if (artifact) {
Expand Down Expand Up @@ -334,8 +348,8 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
return {
onInfo: handleInfoClick,
onDownload: props.status === "completed" ? handleDownloadClick : undefined,
// Hide delete button for artifacts with source="project" (they came from project files)
onDelete: artifact && props.status === "completed" && !isProjectArtifact ? handleDeleteClick : undefined,
// Hide delete button for artifacts with source="project" (they came from project files) or in readOnly mode
onDelete: artifact && props.status === "completed" && !isProjectArtifact && !readOnly ? handleDeleteClick : undefined,
};
} else {
// In chat context, show preview, download, and info actions
Expand All @@ -346,7 +360,7 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
onInfo: handleInfoClick,
};
}
}, [props.status, context, handleDownloadClick, artifact, handleDeleteClick, handleInfoClick, handlePreviewClick, isProjectArtifact]);
}, [props.status, context, handleDownloadClick, artifact, handleDeleteClick, handleInfoClick, handlePreviewClick, isProjectArtifact, readOnly]);

// Get description from global artifacts instead of message parts
const artifactFromGlobal = useMemo(() => artifacts.find(art => art.filename === props.name), [artifacts, props.name]);
Expand All @@ -358,11 +372,11 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
const renderType = getRenderType(fileName, fileMimeType);

// Prepare expanded content if we have content to render
let expandedContent: React.ReactNode = null;
let expandedContent: ReactNode = null;

if (isLoading) {
expandedContent = (
<div className="flex h-25 items-center justify-center bg-(--secondary-w10)">
<div className="bg-muted flex h-25 items-center justify-center">
<Spinner />
</div>
);
Expand Down
29 changes: 20 additions & 9 deletions client/webui/frontend/src/lib/components/chat/file/FileMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { useMemo, useCallback } from "react";
import { useMemo, useCallback, useContext, type MouseEvent } from "react";

import { Download, Eye } from "lucide-react";

import { Button } from "@/lib/components/ui";
import { useChatContext } from "@/lib/hooks";
import { ChatContext, type ChatContextValue } from "@/lib/contexts/ChatContext";
import type { ArtifactInfo } from "@/lib/types";

import { getFileIcon } from "./fileUtils";
Expand All @@ -14,18 +14,29 @@ interface FileMessageProps {
className?: string;
onDownload?: () => void;
isEmbedded?: boolean;
/**
* When true, renders in read-only mode without requiring ChatContext.
* Used for shared sessions where ChatProvider is not available.
*/
readOnly?: boolean;
}

export const FileMessage: React.FC<Readonly<FileMessageProps>> = ({ filename, mimeType, className, onDownload, isEmbedded = false }) => {
const { artifacts, setPreviewArtifact, openSidePanelTab } = useChatContext();
export const FileMessage = ({ filename, mimeType, className, onDownload, isEmbedded = false, readOnly = false }: Readonly<FileMessageProps>) => {
// Try to get ChatContext, but don't fail if not available (for shared sessions)
const chatContext = useContext(ChatContext) as ChatContextValue | undefined;
const hasContext = chatContext !== undefined && !readOnly;

const artifact: ArtifactInfo | undefined = useMemo(() => artifacts.find(artifact => artifact.filename === filename), [artifacts, filename]);
const artifacts = hasContext ? chatContext.artifacts : [];
const setPreviewArtifact = hasContext ? chatContext.setPreviewArtifact : undefined;
const openSidePanelTab = hasContext ? chatContext.openSidePanelTab : undefined;

const artifact: ArtifactInfo | undefined = useMemo(() => artifacts.find((a: ArtifactInfo) => a.filename === filename), [artifacts, filename]);
const FileIcon = useMemo(() => getFileIcon(artifact || { filename, mime_type: mimeType || "", size: 0, last_modified: "" }), [artifact, filename, mimeType]);

const handlePreviewClick = useCallback(
(e: React.MouseEvent) => {
(e: MouseEvent) => {
e.stopPropagation();
if (artifact) {
if (artifact && setPreviewArtifact && openSidePanelTab) {
openSidePanelTab("files");
setPreviewArtifact(artifact);
}
Expand All @@ -40,15 +51,15 @@ export const FileMessage: React.FC<Readonly<FileMessageProps>> = ({ filename, mi
}, [onDownload]);

return (
<div className={`flex h-11 max-w-xs flex-shrink items-center gap-2 rounded-lg bg-(--secondary-w20) px-2 py-1 ${className || ""}`}>
<div className={`flex h-11 max-w-xs flex-shrink items-center gap-2 rounded-lg bg-(--message-background) px-2 py-1 ${className || ""}`}>
{FileIcon}
<span className="min-w-0 flex-1 truncate text-sm leading-9" title={filename}>
<strong>
<code>{filename}</code>
</strong>
</span>

{artifact && !isEmbedded && (
{artifact && !isEmbedded && !readOnly && setPreviewArtifact && (
<Button variant="ghost" onClick={handlePreviewClick} tooltip="Preview">
<Eye className="h-4 w-4" />
</Button>
Expand Down
1 change: 1 addition & 0 deletions client/webui/frontend/src/lib/components/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { AgentMeshPage } from "./AgentMeshPage";
export { ArtifactsPage } from "./ArtifactsPage";
export { ChatPage } from "./ChatPage";
export { PromptsPage } from "./PromptsPage";
export { SharedChatViewPage } from "./SharedChatViewPage";
Loading
Loading