Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
42 changes: 21 additions & 21 deletions client/webui/frontend/src/lib/components/chat/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,28 @@ import { useNavigate } from "react-router-dom";

import { Trash2, Check, X, Pencil, MessageCircle, FolderInput, MoreHorizontal, PanelsTopLeft, Loader2 } from "lucide-react";

import { useChatContext, useConfigContext } from "@/lib/hooks";
import { api } from "@/lib/api";
import { getErrorMessage } from "@/lib/utils/api";
import { formatTimestamp } from "@/lib/utils/format";
import { Button } from "@/lib/components/ui/button";
import { Badge } from "@/lib/components/ui/badge";
import { Spinner } from "@/lib/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/lib/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/lib/components/ui/tooltip";
import { MoveSessionDialog } from "@/lib/components/chat/MoveSessionDialog";
import { SessionSearch } from "@/lib/components/chat/SessionSearch";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/lib/components/ui/dropdown-menu";
import { useChatContext, useConfigContext } from "@/lib/hooks";
import type { Project, Session } from "@/lib/types";
import { formatTimestamp, getErrorMessage } from "@/lib/utils";
import { MoveSessionDialog, ProjectBadge, SessionSearch } from "@/lib/components/chat";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Spinner,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/lib/components/ui";

interface PaginatedSessionsResponse {
data: Session[];
Expand Down Expand Up @@ -342,16 +351,7 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
</div>
<span className="text-muted-foreground truncate text-xs">{formatSessionDate(session.updatedTime)}</span>
</div>
{session.projectName && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="bg-primary/10 border-primary/30 text-primary max-w-[120px] flex-shrink-0 justify-start px-2 py-0.5 text-xs font-semibold shadow-sm">
<span className="block truncate">{session.projectName}</span>
</Badge>
</TooltipTrigger>
<TooltipContent>{session.projectName}</TooltipContent>
</Tooltip>
)}
{session.projectName && <ProjectBadge text={session.projectName} />}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This badge was used in 4 places so extracted it to a component.

</div>
</button>
)}
Expand Down
16 changes: 6 additions & 10 deletions client/webui/frontend/src/lib/components/chat/SessionSearch.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useState, useCallback, useEffect } from "react";
import { Search, X } from "lucide-react";
import { Input } from "@/lib/components/ui/input";
import { Button } from "@/lib/components/ui/button";
import { Badge } from "@/lib/components/ui/badge";
import { useDebounce } from "@/lib/hooks/useDebounce";
import type { Session } from "@/lib/types";

import { api } from "@/lib/api";
import { ProjectBadge } from "@/lib/components/chat";
import { Button, Input } from "@/lib/components/ui";
import { useDebounce } from "@/lib/hooks";
import type { Session } from "@/lib/types";

interface SessionSearchProps {
onSessionSelect: (sessionId: string) => void;
Expand Down Expand Up @@ -98,11 +98,7 @@ export const SessionSearch = ({ onSessionSelect, projectId }: SessionSearchProps
<button key={session.id} onClick={() => handleSessionClick(session.id)} className="hover:bg-accent hover:text-accent-foreground w-full rounded-sm px-3 py-2 text-left text-sm">
<div className="mb-1 flex items-center justify-between gap-2">
<div className="flex-1 truncate font-medium">{session.name || "Untitled Session"}</div>
{session.projectName && (
<Badge variant="outline" className="bg-primary/10 border-primary/30 text-primary flex-shrink-0 px-2 py-0.5 text-xs font-semibold shadow-sm">
{session.projectName}
</Badge>
)}
{session.projectName && <ProjectBadge text={session.projectName} />}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Use extracted badge.

</div>
<div className="text-muted-foreground text-xs">{new Date(session.updatedTime).toLocaleDateString()}</div>
</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, { useState, useEffect } from "react";
import { Download, ChevronDown, Trash, Info, ChevronUp, CircleAlert } from "lucide-react";
import { Download, ChevronDown, Trash, Info, ChevronUp, CircleAlert, Pencil } from "lucide-react";

import { Button, Spinner, Badge } from "@/lib/components/ui";
import { FileIcon } from "../file/FileIcon";
import { Button, Spinner } from "@/lib/components/ui";
import { FileIcon, ProjectBadge } from "../file";
import { cn } from "@/lib/utils";

const ErrorState: React.FC<{ message: string }> = ({ message }) => (
Expand All @@ -26,6 +26,7 @@ export interface ArtifactBarProps {
onDelete?: () => void;
onInfo?: () => void;
onExpand?: () => void;
onEdit?: () => void;
};
// For creation progress
bytesTransferred?: number;
Expand Down Expand Up @@ -211,11 +212,7 @@ export const ArtifactBar: React.FC<ArtifactBarProps> = ({
{hasDescription ? displayDescription : filename.length > 50 ? `${filename.substring(0, 47)}...` : filename}
</div>
{/* Project badge */}
{source === "project" && (
<Badge variant="outline" className="bg-primary/10 border-primary/30 text-primary px-2 py-0.5 text-xs font-semibold shadow-sm">
Project
</Badge>
)}
{source === "project" && <ProjectBadge />}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Use extracted badge

</div>

{/* Secondary line: Filename (if description shown) or status */}
Expand Down Expand Up @@ -298,6 +295,24 @@ export const ArtifactBar: React.FC<ArtifactBarProps> = ({
</Button>
)}

{status === "completed" && actions?.onEdit && !isDeleted && (
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Add edit option to artifact bar:

image

<Button
variant="ghost"
size="icon"
onClick={e => {
e.stopPropagation();
try {
actions.onEdit?.();
} catch (error) {
console.error("Edit failed:", error);
}
}}
tooltip="Edit Description"
>
<Pencil className="h-4 w-4" />
</Button>
)}

{status === "completed" && actions?.onDelete && !isDeleted && (
<Button
variant="ghost"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Badge, Tooltip, TooltipContent, TooltipTrigger } from "@/lib";

export const ProjectBadge = ({ text = "Project" }: { text?: string }) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="max-w-[120px]">
<span className="block truncate">{text}</span>
</Badge>
</TooltipTrigger>
<TooltipContent>{text}</TooltipContent>
</Tooltip>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from "./FileBadge";
export * from "./FileIcon";
export * from "./FileMessage";
export * from "./fileUtils";
export * from "./ProjectBadge";
3 changes: 3 additions & 0 deletions client/webui/frontend/src/lib/components/chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export { AudioRecorder } from "./AudioRecorder";
export { ChatInputArea } from "./ChatInputArea";
export { ChatMessage } from "./ChatMessage";
export { ChatSessionDeleteDialog } from "./ChatSessionDeleteDialog";
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Add missing exports.

export { ChatSessionDialog } from "./ChatSessionDialog";
export { ChatSessions } from "./ChatSessions";
export { ChatSidePanel } from "./ChatSidePanel";
export { LoadingMessageRow } from "./LoadingMessageRow";
Expand All @@ -9,4 +11,5 @@ export { MoveSessionDialog } from "./MoveSessionDialog";
export { VariableDialog } from "./VariableDialog";
export { SessionSearch } from "./SessionSearch";
export { MessageHoverButtons } from "./MessageHoverButtons";
export * from "./file";
export * from "./selection";
173 changes: 173 additions & 0 deletions client/webui/frontend/src/lib/components/common/FileUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { useState, useRef, type DragEvent, type ChangeEvent } from "react";
import { X } from "lucide-react";

import { Button } from "@/lib/components";
import { MessageBanner } from "@/lib/components/common";

/**
* Removes a file at the specified index from a FileList.
* @param prevFiles the FileList
* @param indexToRemove the index of the file to remove
* @returns new FileList with the file removed, or null if no files remain
*/
const removeAtIndex = (prevFiles: FileList | null, indexToRemove: number): FileList | null => {
if (!prevFiles) return null;
const filesArray = Array.from(prevFiles);
filesArray.splice(indexToRemove, 1);
if (filesArray.length === 0) {
return null;
}
const dataTransfer = new DataTransfer();
filesArray.forEach(file => dataTransfer.items.add(file));
return dataTransfer.files;
};

export interface FileUploadProps {
name: string;
accept: string;
multiple?: boolean;
disabled?: boolean;
testid?: string;
value?: FileList | null;
onChange: (file: FileList | null) => void;
onValidate?: (files: FileList) => { valid: boolean; error?: string };
}

function FileUpload({ name, accept, multiple = false, disabled = false, testid = "", value = null, onChange, onValidate }: FileUploadProps) {
Copy link
Collaborator Author

@lgh-solace lgh-solace Jan 6, 2026

Choose a reason for hiding this comment

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

Developed in enterprise, porting back to community for sharing with a couple of tweaks minor fixes and adding optional file validation.

const [uploadedFiles, setUploadedFiles] = useState<FileList | null>(value);
const [isDragging, setIsDragging] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

const setSelectedFiles = (files: FileList | null) => {
if (files && files.length > 0) {
// Validate files if validation function is provided
if (onValidate) {
const validation = onValidate(files);
if (!validation.valid) {
setValidationError(validation.error || "File validation failed.");
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
}

setValidationError(null);
setUploadedFiles(files);
onChange(files);
} else {
setValidationError(null);
setUploadedFiles(null);
onChange(null);
fileInputRef.current!.value = "";
}
};

const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};

const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};

const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (disabled) {
e.currentTarget.style.cursor = "not-allowed";
} else {
e.currentTarget.style.cursor = "default";
}
};

const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);

if (!disabled) {
let files = e.dataTransfer.files;

// If multiple is false and more than one file is dropped, only take the first file
if (!multiple && files.length > 1) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(files[0]);
files = dataTransfer.files;
}

setSelectedFiles(files);
}
};

const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
setSelectedFiles(files);
};

const handleDropZoneClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
fileInputRef.current?.click();
};

const handleClearValidationError = () => {
setValidationError(null);
};

const handleRemoveFile = (index: number) => {
const newFiles = removeAtIndex(uploadedFiles, index);
setUploadedFiles(newFiles);
onChange(newFiles);
};

return (
<div>
{validationError && (
<div className="mb-3">
<MessageBanner variant="error" message={validationError} dismissible onDismiss={handleClearValidationError} />
</div>
)}
<input ref={fileInputRef} name={name} type="file" multiple={multiple} disabled={disabled} onChange={handleFileChange} className="hidden" accept={accept} data-testid={testid} />
{uploadedFiles ? (
Array.from(uploadedFiles).map((file, index) => (
<div key={file.name} className="var(--tw-border-style) flex h-[48px] flex-row items-center rounded-sm border-1 pr-2 pl-4 text-[var(--color-secondary-text-wMain)]">
<div className="flex-1 font-semibold">{file.name}</div>
<Button variant="ghost" size="sm" onClick={() => handleRemoveFile(index)} aria-label={`Remove file ${file.name}`}>
<X />
</Button>
</div>
))
) : (
<div
className={`flex h-[140px] flex-col justify-center rounded-sm border-1 border-dashed transition-colors ${isDragging ? "border-[var(--color-brand-wMain)] hover:border-solid" : "border-[var(--color-secondary-w40)]"}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
role="dropzone"
>
{isDragging && !disabled ? (
<div className="pointer-events-none text-center text-[var(--color-primary-text-wMain)]">Drop file here</div>
) : (
<div className="pointer-events-none text-center text-[var(--color-secondary-text-wMain)]">
<div>Drag and drop file here</div>
<div className="mt-2 mb-2 flex flex-row items-center justify-center">
<div className="mr-1 h-[1px] w-[125px] bg-[var(--color-secondary-w40)]"></div>
<div>OR</div>
<div className="ml-1 h-[1px] w-[125px] bg-[var(--color-secondary-w40)]"></div>
</div>
<div>
<Button className="pointer-events-auto" variant="ghost" disabled={disabled} onClick={handleDropZoneClick}>
Upload File
</Button>
</div>
</div>
)}
</div>
)}
</div>
);
}

export { FileUpload };
1 change: 1 addition & 0 deletions client/webui/frontend/src/lib/components/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { ConfirmationDialog } from "./ConfirmationDialog";
export { EmptyState } from "./EmptyState";
export { ErrorDialog } from "./ErrorDialog";
export { FileUpload } from "./FileUpload";
export { Footer } from "./Footer";
export { GridCard } from "./GridCard";
export { LoadingBlocker } from "./LoadingBlocker";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const NavigationButton: React.FC<NavigationItemProps> = ({ item, isActive
<Icon className={cn("mb-1 h-6 w-6", isActive && "text-(--color-brand-wMain)")} />
<span className="text-center text-[13px] leading-tight">{label}</span>
{badge && (
<Badge variant="outline" className="mt-1 border-gray-400 bg-(--color-secondary-w80) px-1 py-0.5 text-[8px] leading-tight text-(--color-secondary-text-w10) uppercase">
<Badge variant="outline" className="mt-1 h-4 bg-(--color-secondary-w80) pt-1 text-[8px] leading-none text-(--color-secondary-text-w10) uppercase">
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Tweaking the Experimental label to use palette colours and be better centered in the badge.

image

{badge}
</Badge>
)}
Expand Down
Loading
Loading