mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-16 23:35:46 +00:00
Compare commits
155 Commits
v2.2.1
...
projects-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec85100c29 | ||
|
|
46667f9f55 | ||
|
|
a51f193211 | ||
|
|
18ef4cc095 | ||
|
|
0e4a0578de | ||
|
|
d4d0106d8f | ||
|
|
5c7417fa97 | ||
|
|
185e57d55e | ||
|
|
e4777338b8 | ||
|
|
9e5fea5395 | ||
|
|
eb39ba9be4 | ||
|
|
34a48b1a15 | ||
|
|
e8141db66d | ||
|
|
7027b3e385 | ||
|
|
dd123bb1af | ||
|
|
013648e205 | ||
|
|
e313fd7431 | ||
|
|
fd2cef73ba | ||
|
|
1203ae174f | ||
|
|
11d784c0a7 | ||
|
|
73bb1c5445 | ||
|
|
4e7ee20406 | ||
|
|
c44d3c0332 | ||
|
|
d599935d4a | ||
|
|
c56575d6f9 | ||
|
|
44c30b45bd | ||
|
|
ca4908f2d1 | ||
|
|
e417da31c3 | ||
|
|
944bff8a45 | ||
|
|
885a484900 | ||
|
|
d21eaa92e4 | ||
|
|
423961878e | ||
|
|
49fdf2cc78 | ||
|
|
4f3d1466fb | ||
|
|
0d78fefaa4 | ||
|
|
192cfd6965 | ||
|
|
c0858484a4 | ||
|
|
873730cb02 | ||
|
|
acecca0de5 | ||
|
|
9aed84ec66 | ||
|
|
5e28d86a18 | ||
|
|
313e38cf3e | ||
|
|
f3dcb31b6c | ||
|
|
b22e16b604 | ||
|
|
43c62cfe5b | ||
|
|
196945378f | ||
|
|
851a14ce68 | ||
|
|
ac7d1e358f | ||
|
|
b1124fd042 | ||
|
|
92ef44972d | ||
|
|
e00d175b06 | ||
|
|
289cc2d83a | ||
|
|
c38078ef67 | ||
|
|
e08ec11b4f | ||
|
|
8fbf06fcdb | ||
|
|
f14e450f1e | ||
|
|
e388c26a60 | ||
|
|
8c2514ccbd | ||
|
|
0c95af31c7 | ||
|
|
656e1fe7cf | ||
|
|
6461638b65 | ||
|
|
157ae8e18e | ||
|
|
ef0e7984ca | ||
|
|
74455041be | ||
|
|
769c24272f | ||
|
|
d718dd485d | ||
|
|
7b533ef535 | ||
|
|
3410e5b59b | ||
|
|
b4e453f3d1 | ||
|
|
9d62d83c5c | ||
|
|
cf33d1ebb9 | ||
|
|
9604ddb089 | ||
|
|
73c7cb1aed | ||
|
|
2fb5ef3a9b | ||
|
|
cd8bb439bb | ||
|
|
8f2107f61e | ||
|
|
3b42f0556a | ||
|
|
59db35cf2d | ||
|
|
e6479410c4 | ||
|
|
a4c271b5ca | ||
|
|
70dad196ca | ||
|
|
ed6272dac6 | ||
|
|
4fa1050d4f | ||
|
|
dfdb94269b | ||
|
|
692766d1f7 | ||
|
|
0fa48521de | ||
|
|
1166697599 | ||
|
|
a831c54a85 | ||
|
|
1bbf7211ba | ||
|
|
438b762360 | ||
|
|
fb54e41337 | ||
|
|
5427a7d766 | ||
|
|
7d822f6ee9 | ||
|
|
ac98043bad | ||
|
|
f46a27cf22 | ||
|
|
18535d58d4 | ||
|
|
1707e41683 | ||
|
|
7e25322bce | ||
|
|
508b9076a7 | ||
|
|
4df416e482 | ||
|
|
80444f6bbc | ||
|
|
23f335f033 | ||
|
|
265eca2195 | ||
|
|
2587e5bfb2 | ||
|
|
936500ca8b | ||
|
|
bdc6ddea1d | ||
|
|
d1a739c6d4 | ||
|
|
ab28f67386 | ||
|
|
ecfada63bb | ||
|
|
17a1d3b234 | ||
|
|
8e0dd12ab3 | ||
|
|
a9eb256e6d | ||
|
|
17c5d1b740 | ||
|
|
e533e98f9b | ||
|
|
1e3cbc1856 | ||
|
|
779397d9b8 | ||
|
|
2ac0133b0b | ||
|
|
9b88e778e1 | ||
|
|
7199bb980a | ||
|
|
238518af72 | ||
|
|
c11b78cfd1 | ||
|
|
996cc7265c | ||
|
|
103cea9edf | ||
|
|
27a745413d | ||
|
|
d9a56b3bd5 | ||
|
|
66a779990a | ||
|
|
14da796a88 | ||
|
|
81f73ab388 | ||
|
|
10e153b420 | ||
|
|
da8f0ff589 | ||
|
|
8c76194cf6 | ||
|
|
1f9e5e3ac9 | ||
|
|
cf63c61b33 | ||
|
|
446440aec0 | ||
|
|
2b3b9b82c2 | ||
|
|
621b3e7819 | ||
|
|
690734029f | ||
|
|
897615da71 | ||
|
|
7086afaf6e | ||
|
|
f02cb76e1d | ||
|
|
8374fcef63 | ||
|
|
5b06d0355b | ||
|
|
3e27df819e | ||
|
|
9e8ab9e3dc | ||
|
|
6674cdd516 | ||
|
|
b251ea795e | ||
|
|
dcd3f009ee | ||
|
|
277065181f | ||
|
|
7b881dd9a4 | ||
|
|
56cd0e6725 | ||
|
|
a223dc7aea | ||
|
|
c8cc9ee590 | ||
|
|
e7290385bd | ||
|
|
8df45b5950 | ||
|
|
2b7d361c73 |
@@ -138,23 +138,34 @@ def _build_project_llm_docs(
|
||||
|
||||
project_file_id_set = set(project_file_ids)
|
||||
for f in in_memory_user_files:
|
||||
# Only include files that belong to the project (not ad-hoc uploads)
|
||||
if project_file_id_set and (f.file_id in project_file_id_set):
|
||||
try:
|
||||
text_content = f.content.decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
text_content = ""
|
||||
|
||||
# Build a short blurb from the file content for better UI display
|
||||
blurb = (
|
||||
(text_content[:200] + "...")
|
||||
if len(text_content) > 200
|
||||
else text_content
|
||||
)
|
||||
def _strip_nuls(s: str) -> str:
|
||||
return s.replace("\x00", "") if s else s
|
||||
|
||||
cleaned_filename = _strip_nuls(f.filename or str(f.file_id))
|
||||
|
||||
if f.file_type.is_text_file():
|
||||
try:
|
||||
text_content = f.content.decode("utf-8", errors="ignore")
|
||||
text_content = _strip_nuls(text_content)
|
||||
except Exception:
|
||||
text_content = ""
|
||||
|
||||
# Build a short blurb from the file content for better UI display
|
||||
blurb = (
|
||||
(text_content[:200] + "...")
|
||||
if len(text_content) > 200
|
||||
else text_content
|
||||
)
|
||||
else:
|
||||
# Non-text (e.g., images): do not decode bytes; keep empty content but allow citation
|
||||
text_content = ""
|
||||
blurb = f"[{f.file_type.value}] {cleaned_filename}"
|
||||
|
||||
# Provide basic metadata to improve SavedSearchDoc display
|
||||
file_metadata: dict[str, str | list[str]] = {
|
||||
"filename": f.filename or str(f.file_id),
|
||||
"filename": cleaned_filename,
|
||||
"file_type": f.file_type.value,
|
||||
}
|
||||
|
||||
@@ -163,7 +174,7 @@ def _build_project_llm_docs(
|
||||
document_id=str(f.file_id),
|
||||
content=text_content,
|
||||
blurb=blurb,
|
||||
semantic_identifier=f.filename or str(f.file_id),
|
||||
semantic_identifier=cleaned_filename,
|
||||
source_type=DocumentSource.USER_FILE,
|
||||
metadata=file_metadata,
|
||||
updated_at=None,
|
||||
|
||||
@@ -100,12 +100,14 @@ def parse_user_files(
|
||||
persona=persona,
|
||||
actual_user_input=actual_user_input,
|
||||
)
|
||||
uploaded_context_cap = int(available_tokens * 0.5)
|
||||
|
||||
logger.debug(
|
||||
f"Total file tokens: {total_tokens}, Available tokens: {available_tokens}"
|
||||
f"Total file tokens: {total_tokens}, Available tokens: {available_tokens},"
|
||||
f"Allowed uploaded context tokens: {uploaded_context_cap}"
|
||||
)
|
||||
|
||||
have_enough_tokens = total_tokens <= available_tokens
|
||||
have_enough_tokens = total_tokens <= uploaded_context_cap
|
||||
|
||||
# If we have enough tokens, we don't need search
|
||||
# we can just pass them into the prompt directly
|
||||
|
||||
@@ -93,18 +93,8 @@ import {
|
||||
} from "@/components/Dropdown";
|
||||
import { SourceChip } from "@/app/chat/components/input/ChatInputBar";
|
||||
import { FileCard } from "@/app/chat/components/projects/ProjectContextPanel";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import FilesList from "@/app/chat/components/files/FilesList";
|
||||
import {
|
||||
MultipleFilesIcon,
|
||||
OpenFolderIcon,
|
||||
} from "@/components/icons/CustomIcons";
|
||||
import CoreModal from "@/refresh-components/modals/CoreModal";
|
||||
import UserFilesModalContent from "@/components/modals/UserFilesModalContent";
|
||||
import { TagIcon, UserIcon, FileIcon, InfoIcon, BookIcon } from "lucide-react";
|
||||
import { LLMSelector } from "@/components/llm/LLMSelector";
|
||||
import useSWR from "swr";
|
||||
@@ -125,6 +115,10 @@ import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
|
||||
import FilePicker from "@/app/chat/components/files/FilePicker";
|
||||
import SvgTrash from "@/icons/trash";
|
||||
import SvgEditBig from "@/icons/edit-big";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import SvgPlusCircle from "@/icons/plus-circle";
|
||||
import Text from "@/refresh-components/Text";
|
||||
import SvgFiles from "@/icons/files";
|
||||
|
||||
function findSearchTool(tools: ToolSnapshot[]) {
|
||||
return tools.find((tool) => tool.in_code_tool_id === SEARCH_TOOL_ID);
|
||||
@@ -1106,9 +1100,9 @@ export function AssistantEditor({
|
||||
<div className="text-sm flex flex-col items-start">
|
||||
<SubLabel>Click below to add files</SubLabel>
|
||||
{values.user_file_ids.length > 0 && (
|
||||
<div className="flex gap-3 mb-2">
|
||||
<div className="flex gap-spacing-inline">
|
||||
{values.user_file_ids
|
||||
.slice(0, 3)
|
||||
.slice(0, 4)
|
||||
.map((userFileId: string) => {
|
||||
const rf = recentFiles.find(
|
||||
(f) => f.id === userFileId
|
||||
@@ -1119,7 +1113,7 @@ export function AssistantEditor({
|
||||
status: "completed" as const,
|
||||
};
|
||||
return (
|
||||
<div key={userFileId} className="w-52">
|
||||
<div key={userFileId} className="w-40">
|
||||
<FileCard
|
||||
file={fileData as ProjectFile}
|
||||
removeFile={() => {
|
||||
@@ -1135,30 +1129,33 @@ export function AssistantEditor({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{values.user_file_ids.length > 3 && (
|
||||
{values.user_file_ids.length > 4 && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl px-3 py-1 text-left bg-transparent hover:bg-accent-background-hovered hover:dark:bg-neutral-800/75 transition-colors"
|
||||
className="rounded-xl px-3 py-1 text-left transition-colors hover:bg-background-tint-02"
|
||||
onClick={() => setShowAllUserFiles(true)}
|
||||
>
|
||||
<div className="flex flex-col overflow-hidden h-12 p-1">
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<span className="text-onyx-medium text-sm truncate flex-1">
|
||||
<Text text04 secondaryAction>
|
||||
View All
|
||||
</span>
|
||||
<MultipleFilesIcon className="h-5 w-5 text-onyx-medium" />
|
||||
</Text>
|
||||
<SvgFiles className="h-5 w-5 stroke-text-02" />
|
||||
</div>
|
||||
<span className="text-onyx-muted text-sm">
|
||||
<Text text03 secondaryBody>
|
||||
{values.user_file_ids.length} files
|
||||
</span>
|
||||
</Text>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<FilePicker
|
||||
showTriggerLabel
|
||||
triggerLabel="Add User Files"
|
||||
trigger={
|
||||
<LineItem icon={SvgPlusCircle}>
|
||||
Add User Files
|
||||
</LineItem>
|
||||
}
|
||||
recentFiles={recentFiles}
|
||||
onFileClick={(file: ProjectFile) => {
|
||||
setPresentingDocument({
|
||||
@@ -1837,19 +1834,15 @@ export function AssistantEditor({
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
<Dialog
|
||||
open={showAllUserFiles}
|
||||
onOpenChange={setShowAllUserFiles}
|
||||
>
|
||||
<DialogContent className="w-full max-w-lg">
|
||||
<DialogHeader>
|
||||
<OpenFolderIcon size={32} />
|
||||
<DialogTitle>User Files</DialogTitle>
|
||||
<DialogDescription>
|
||||
All files selected for this assistant
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FilesList
|
||||
{showAllUserFiles && (
|
||||
<CoreModal
|
||||
className="w-full max-w-lg"
|
||||
onClickOutside={() => setShowAllUserFiles(false)}
|
||||
>
|
||||
<UserFilesModalContent
|
||||
title="User Files"
|
||||
description="All files selected for this assistant"
|
||||
icon={SvgFiles}
|
||||
recentFiles={values.user_file_ids.map(
|
||||
(userFileId: string) => {
|
||||
const rf = recentFiles.find((f) => f.id === userFileId);
|
||||
@@ -1871,9 +1864,10 @@ export function AssistantEditor({
|
||||
)
|
||||
);
|
||||
}}
|
||||
onClose={() => setShowAllUserFiles(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CoreModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -81,6 +81,7 @@ import ProjectChatSessionList from "@/app/chat/components/projects/ProjectChatSe
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Suggestions } from "@/sections/Suggestions";
|
||||
|
||||
const DEFAULT_CONTEXT_TOKENS = 120_000;
|
||||
interface ChatPageProps {
|
||||
documentSidebarInitialWidth?: number;
|
||||
firstMessage?: string;
|
||||
@@ -692,8 +693,9 @@ export function ChatPage({
|
||||
// Available context tokens source of truth:
|
||||
// - If a chat session exists, fetch from session API (dynamic per session/model)
|
||||
// - If no session, derive from the default/current persona's max document tokens
|
||||
const [availableContextTokens, setAvailableContextTokens] =
|
||||
useState<number>(128_000);
|
||||
const [availableContextTokens, setAvailableContextTokens] = useState<number>(
|
||||
DEFAULT_CONTEXT_TOKENS * 0.5
|
||||
);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function run() {
|
||||
@@ -702,18 +704,22 @@ export function ChatPage({
|
||||
const available = await getAvailableContextTokens(
|
||||
existingChatSessionId
|
||||
);
|
||||
if (!cancelled) setAvailableContextTokens(available ?? 0);
|
||||
const capped_context_tokens =
|
||||
(available ?? DEFAULT_CONTEXT_TOKENS) * 0.5;
|
||||
if (!cancelled) setAvailableContextTokens(capped_context_tokens);
|
||||
} else {
|
||||
const personaId = (selectedAssistant || liveAssistant)?.id;
|
||||
if (personaId !== undefined && personaId !== null) {
|
||||
const maxTokens = await getMaxSelectedDocumentTokens(personaId);
|
||||
if (!cancelled) setAvailableContextTokens(maxTokens ?? 128_000);
|
||||
const capped_context_tokens =
|
||||
(maxTokens ?? DEFAULT_CONTEXT_TOKENS) * 0.5;
|
||||
if (!cancelled) setAvailableContextTokens(capped_context_tokens);
|
||||
} else if (!cancelled) {
|
||||
setAvailableContextTokens(128_000);
|
||||
setAvailableContextTokens(DEFAULT_CONTEXT_TOKENS * 0.5);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setAvailableContextTokens(128_000);
|
||||
if (!cancelled) setAvailableContextTokens(DEFAULT_CONTEXT_TOKENS * 0.5);
|
||||
}
|
||||
}
|
||||
run();
|
||||
|
||||
@@ -2,32 +2,27 @@
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarTrigger,
|
||||
} from "@/components/ui/menubar";
|
||||
import { Files } from "@phosphor-icons/react";
|
||||
import { FileIcon, Loader2, Eye } from "lucide-react";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import FilesList from "./FilesList";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import CoreModal from "@/refresh-components/modals/CoreModal";
|
||||
import UserFilesModalContent from "@/components/modals/UserFilesModalContent";
|
||||
import { ProjectFile } from "../../projects/projectsService";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import SvgPaperclip from "@/icons/paperclip";
|
||||
import SvgFiles from "@/icons/files";
|
||||
import MoreHorizontal from "@/icons/more-horizontal";
|
||||
import SvgFileText from "@/icons/file-text";
|
||||
import SvgExternalLink from "@/icons/external-link";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import SvgPlusCircle from "@/icons/plus-circle";
|
||||
import Text from "@/refresh-components/Text";
|
||||
|
||||
// Small helper to render an icon + label row
|
||||
const Row = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="flex items-center gap-2">{children}</div>
|
||||
<div className="flex items-center gap-2 w-full">{children}</div>
|
||||
);
|
||||
|
||||
interface FilePickerContentsProps {
|
||||
@@ -38,6 +33,14 @@ interface FilePickerContentsProps {
|
||||
setShowRecentFiles: (show: boolean) => void;
|
||||
}
|
||||
|
||||
const getFileExtension = (fileName: string): string => {
|
||||
const idx = fileName.lastIndexOf(".");
|
||||
if (idx === -1) return "";
|
||||
const ext = fileName.slice(idx + 1).toLowerCase();
|
||||
if (ext === "txt") return "PLAINTEXT";
|
||||
return ext.toUpperCase();
|
||||
};
|
||||
|
||||
export function FilePickerContents({
|
||||
recentFiles,
|
||||
onPickRecent,
|
||||
@@ -49,55 +52,88 @@ export function FilePickerContents({
|
||||
<>
|
||||
{recentFiles.length > 0 && (
|
||||
<>
|
||||
<label className="text-sm font-light text-input-text p-2.5">
|
||||
<Text text02 secondaryBody className="mx-2 mt-2 mb-1">
|
||||
Recent Files
|
||||
</label>
|
||||
</Text>
|
||||
|
||||
{recentFiles.slice(0, 3).map((f) => (
|
||||
<MenubarItem
|
||||
<button
|
||||
type="button"
|
||||
key={f.id}
|
||||
onClick={() =>
|
||||
onPickRecent ? onPickRecent(f) : console.log("Picked recent", f)
|
||||
}
|
||||
className="m-1 rounded-lg hover:bg-background-chat-hover hover:text-neutral-900 dark:hover:text-neutral-50 text-input-text p-2 group"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onPickRecent && onPickRecent(f);
|
||||
}}
|
||||
className="w-full rounded-lg hover:bg-background-neutral-02 group"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center w-full m-1 mt-2 p-0.5 group">
|
||||
<Row>
|
||||
{String(f.status).toLowerCase() === "processing" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span className="truncate max-w-[160px]" title={f.name}>
|
||||
<div className="p-0.5">
|
||||
{String(f.status).toLowerCase() === "processing" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-text-02" />
|
||||
) : (
|
||||
<SvgFileText className="h-4 w-4 stroke-text-02" />
|
||||
)}
|
||||
</div>
|
||||
<Text
|
||||
text03
|
||||
mainUiBody
|
||||
nowrap
|
||||
className="truncate max-w-[160px]"
|
||||
>
|
||||
{f.name}
|
||||
</span>
|
||||
</Row>
|
||||
{onFileClick &&
|
||||
String(f.status).toLowerCase() !== "processing" && (
|
||||
<button
|
||||
title="View file"
|
||||
aria-label="View file"
|
||||
className="p-0 bg-transparent border-0 outline-none cursor-pointer opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150 ml-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onFileClick && onFileClick(f);
|
||||
}}
|
||||
</Text>
|
||||
|
||||
<div className="relative flex items-center ml-auto mr-2">
|
||||
<Text
|
||||
text02
|
||||
secondaryBody
|
||||
className="p-0.5 group-hover:opacity-0 transition-opacity duration-150"
|
||||
>
|
||||
<Eye className="h-4 w-4 text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-200" />
|
||||
</button>
|
||||
)}
|
||||
{getFileExtension(f.name)}
|
||||
</Text>
|
||||
|
||||
{onFileClick &&
|
||||
String(f.status).toLowerCase() !== "processing" && (
|
||||
<IconButton
|
||||
internal
|
||||
icon={SvgExternalLink}
|
||||
tooltip="View file"
|
||||
className="absolute flex items-center justify-center opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150 p-0 bg-transparent hover:bg-transparent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onFileClick(f);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
</MenubarItem>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{recentFiles.length > 3 && (
|
||||
<LineItem onClick={() => setShowRecentFiles(true)}>
|
||||
... All Recent Files
|
||||
</LineItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRecentFiles(true)}
|
||||
className="w-full rounded-lg hover:bg-background-neutral-02 hover:text-neutral-900 dark:hover:text-neutral-50"
|
||||
>
|
||||
<div className="flex items-center w-full m-1 p-1">
|
||||
<Row>
|
||||
<div className="p-0.5">
|
||||
<MoreHorizontal className="h-4 w-4 stroke-text-02" />
|
||||
</div>
|
||||
<Text text03 mainUiBody>
|
||||
All Recent Files
|
||||
</Text>
|
||||
</Row>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<MenubarSeparator />
|
||||
<div className="border-b" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -118,10 +154,7 @@ interface FilePickerProps {
|
||||
onFileClick?: (file: ProjectFile) => void;
|
||||
recentFiles: ProjectFile[];
|
||||
handleUploadChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
showTriggerLabel?: boolean;
|
||||
triggerLabel?: string;
|
||||
triggerLabelClassName?: string;
|
||||
triggerClassName?: string;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function FilePicker({
|
||||
@@ -130,11 +163,11 @@ export default function FilePicker({
|
||||
onFileClick,
|
||||
recentFiles,
|
||||
handleUploadChange,
|
||||
showTriggerLabel = false,
|
||||
triggerLabel = "Add Files",
|
||||
trigger,
|
||||
}: FilePickerProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [showRecentFiles, setShowRecentFiles] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const triggerUploadPicker = () => fileInputRef.current?.click();
|
||||
|
||||
@@ -148,56 +181,61 @@ export default function FilePicker({
|
||||
onChange={handleUploadChange}
|
||||
accept={"*/*"}
|
||||
/>
|
||||
<Menubar className="bg-transparent dark:bg-transparent p-0 border-0 h-8">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="relative cursor-pointer flex items-center group rounded-lg text-input-text px-0 h-8">
|
||||
{showTriggerLabel ? (
|
||||
<LineItem icon={SvgPlusCircle}>{triggerLabel}</LineItem>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={SvgPlusCircle}
|
||||
tooltip="Attach Files"
|
||||
tertiary
|
||||
/>
|
||||
)}
|
||||
</MenubarTrigger>
|
||||
<MenubarContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="min-w-[220px] text-input-text"
|
||||
>
|
||||
<FilePickerContents
|
||||
recentFiles={recentFiles}
|
||||
onPickRecent={onPickRecent}
|
||||
onFileClick={onFileClick}
|
||||
triggerUploadPicker={triggerUploadPicker}
|
||||
setShowRecentFiles={setShowRecentFiles}
|
||||
/>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
|
||||
<Dialog open={showRecentFiles} onOpenChange={setShowRecentFiles}>
|
||||
<DialogContent
|
||||
className="w-full max-w-lg px-6 py-3 sm:px-6 sm:py-4 focus:outline-none focus-visible:outline-none"
|
||||
tabIndex={-1}
|
||||
onOpenAutoFocus={(e) => {
|
||||
// Prevent auto-focus which can interfere with input
|
||||
e.preventDefault();
|
||||
}}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative cursor-pointer flex items-center group rounded-lg text-input-text px-0 h-8">
|
||||
{trigger}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[15.5rem] max-h-[300px] border-transparent"
|
||||
side="top"
|
||||
>
|
||||
<DialogHeader className="px-0 pt-0 pb-2">
|
||||
<Files size={32} />
|
||||
<DialogTitle>Recent Files</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FilesList
|
||||
<FilePickerContents
|
||||
recentFiles={recentFiles}
|
||||
onPickRecent={onPickRecent}
|
||||
onFileClick={onFileClick}
|
||||
handleUploadChange={handleUploadChange}
|
||||
onPickRecent={(file) => {
|
||||
onPickRecent && onPickRecent(file);
|
||||
setOpen(false);
|
||||
}}
|
||||
onFileClick={(file) => {
|
||||
onFileClick && onFileClick(file);
|
||||
setOpen(false);
|
||||
}}
|
||||
triggerUploadPicker={() => {
|
||||
triggerUploadPicker();
|
||||
setOpen(false);
|
||||
}}
|
||||
setShowRecentFiles={(show) => {
|
||||
setShowRecentFiles(show);
|
||||
// Close the small popover when opening the dialog
|
||||
if (show) setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{showRecentFiles && (
|
||||
<CoreModal
|
||||
className="w-[32rem] rounded-16 border flex flex-col bg-background-tint-00"
|
||||
onClickOutside={() => setShowRecentFiles(false)}
|
||||
>
|
||||
<UserFilesModalContent
|
||||
title="Recent Files"
|
||||
description="Upload files or pick from your recent files."
|
||||
icon={SvgFiles}
|
||||
recentFiles={recentFiles}
|
||||
onPickRecent={(file) => {
|
||||
onPickRecent && onPickRecent(file);
|
||||
setShowRecentFiles(false);
|
||||
}}
|
||||
handleUploadChange={handleUploadChange}
|
||||
onFileClick={onFileClick}
|
||||
onClose={() => setShowRecentFiles(false)}
|
||||
/>
|
||||
</CoreModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef, useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Search, Loader2, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProjectFile } from "../../projects/ProjectsContext";
|
||||
import { formatRelativeTime } from "../projects/project_utils";
|
||||
import {
|
||||
FileUploadIcon,
|
||||
ImageIcon as ImageFileIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import { DocumentIcon, OpenInNewIcon } from "@/components/icons/CustomIcons";
|
||||
|
||||
interface FilesListProps {
|
||||
className?: string;
|
||||
recentFiles: ProjectFile[];
|
||||
onPickRecent?: (file: ProjectFile) => void;
|
||||
handleUploadChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
showRemove?: boolean;
|
||||
onRemove?: (file: ProjectFile) => void;
|
||||
onFileClick?: (file: ProjectFile) => void;
|
||||
}
|
||||
|
||||
// Using the same visual pattern as FileCard: spinner when processing, otherwise a
|
||||
// DocumentIcon wrapped in a small accent background.
|
||||
|
||||
const getFileExtension = (fileName: string): string => {
|
||||
const idx = fileName.lastIndexOf(".");
|
||||
if (idx === -1) return "";
|
||||
const ext = fileName.slice(idx + 1).toLowerCase();
|
||||
if (ext === "txt") return "PLAINTEXT";
|
||||
return ext.toUpperCase();
|
||||
};
|
||||
|
||||
export default function FilesList({
|
||||
className,
|
||||
recentFiles,
|
||||
onPickRecent,
|
||||
handleUploadChange,
|
||||
showRemove,
|
||||
onRemove,
|
||||
onFileClick,
|
||||
}: FilesListProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [minHeight, setMinHeight] = useState<string>("320px");
|
||||
const [isScrollable, setIsScrollable] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerUploadPicker = () => fileInputRef.current?.click();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const s = search.trim().toLowerCase();
|
||||
if (!s) return recentFiles;
|
||||
return recentFiles.filter((f) => f.name.toLowerCase().includes(s));
|
||||
}, [recentFiles, search]);
|
||||
|
||||
// Track the container height before search starts
|
||||
useEffect(() => {
|
||||
if (!search && scrollContainerRef.current) {
|
||||
// Use requestAnimationFrame to ensure DOM has updated
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const containerHeight = scrollContainerRef.current.offsetHeight;
|
||||
|
||||
// Only update if the new height is larger than current minHeight
|
||||
const currentMin = parseInt(minHeight);
|
||||
if (containerHeight > currentMin) {
|
||||
setMinHeight(`${containerHeight}px`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [recentFiles.length]); // Only track when file count changes, not search
|
||||
|
||||
// Check if content is scrollable
|
||||
useEffect(() => {
|
||||
const checkScrollable = () => {
|
||||
if (scrollAreaRef.current) {
|
||||
const viewport = scrollAreaRef.current.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
);
|
||||
if (viewport) {
|
||||
const isContentScrollable =
|
||||
viewport.scrollHeight > viewport.clientHeight;
|
||||
setIsScrollable(isContentScrollable);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check initially and after content changes
|
||||
requestAnimationFrame(checkScrollable);
|
||||
|
||||
// Also check on resize
|
||||
window.addEventListener("resize", checkScrollable);
|
||||
return () => window.removeEventListener("resize", checkScrollable);
|
||||
}, [filtered.length, minHeight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2 focus:outline-none w-full", className)}
|
||||
tabIndex={-1}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent parent dialog from intercepting mouse events
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search files..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 pl-8 bg-transparent border-0 shadow-none focus:bg-transparent focus:ring-0 focus-visible:ring-0 focus:border focus:border-border-dark"
|
||||
removeFocusRing
|
||||
autoComplete="off"
|
||||
tabIndex={0}
|
||||
onFocus={(e) => {
|
||||
// Select all text when focused
|
||||
e.target.select();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Force focus on click
|
||||
e.stopPropagation();
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent dialog from interfering with input clicks
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
// Handle touch/pointer events
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{handleUploadChange && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleUploadChange}
|
||||
accept={"*/*"}
|
||||
/>
|
||||
<button
|
||||
onClick={triggerUploadPicker}
|
||||
className="flex flex-row gap-2 items-center justify-center p-2 rounded-md bg-background-dark/75 hover:dark:bg-neutral-800/75 hover:bg-accent-background-hovered transition-all duration-150"
|
||||
>
|
||||
<FileUploadIcon className="text-text-darker dark:text-text-lighter" />
|
||||
<p className="text-sm text-text-darker dark:text-text-lighter whitespace-nowrap">
|
||||
Add Files
|
||||
</p>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="transition-all duration-200 relative"
|
||||
style={{
|
||||
minHeight: minHeight,
|
||||
height: search ? minHeight : "auto",
|
||||
maxHeight: "588px", // ~10.5 items * 56px per item
|
||||
}}
|
||||
>
|
||||
<ScrollArea ref={scrollAreaRef} className="h-full pr-1">
|
||||
<div className="flex flex-col">
|
||||
{filtered.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 text-left rounded-md px-2 py-2 group border border-transparent",
|
||||
"hover:bg-background-chat-hover hover:text-neutral-900 dark:hover:text-neutral-50 hover:border-border-dark dark:hover:border-border-light"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (onPickRecent) {
|
||||
onPickRecent(f);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-transparent">
|
||||
{String((f as any).status).toLowerCase() ===
|
||||
"processing" ? (
|
||||
<Loader2 className="h-5 w-5 text-onyx-medium animate-spin" />
|
||||
) : (
|
||||
<div className="bg-accent-background p-2 rounded-lg shadow-sm">
|
||||
{(() => {
|
||||
const ext = getFileExtension(f.name).toLowerCase();
|
||||
const isImage = [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"svg",
|
||||
"bmp",
|
||||
].includes(ext);
|
||||
return isImage ? (
|
||||
<ImageFileIcon
|
||||
size={20}
|
||||
className="text-onyx-muted"
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon className="h-5 w-5 text-onyx-muted" />
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-normal">{f.name}</div>
|
||||
<div className="text-xs text-text-400 dark:text-neutral-400">
|
||||
{(() => {
|
||||
const s = String(f.status || "").toLowerCase();
|
||||
const typeLabel = getFileExtension(f.name);
|
||||
if (s === "processing") return "Processing...";
|
||||
if (s === "completed") return typeLabel;
|
||||
return f.status ? f.status : typeLabel;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-3">
|
||||
{f.last_accessed_at && (
|
||||
<div className="text-xs text-text-400 dark:text-neutral-400 whitespace-nowrap">
|
||||
{formatRelativeTime(f.last_accessed_at)}
|
||||
</div>
|
||||
)}
|
||||
{onFileClick &&
|
||||
String(f.status).toLowerCase() !== "processing" && (
|
||||
<button
|
||||
title="View file"
|
||||
aria-label="View file"
|
||||
className="p-0 bg-transparent border-0 outline-none cursor-pointer opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onFileClick && onFileClick(f);
|
||||
}}
|
||||
>
|
||||
<OpenInNewIcon
|
||||
size={16}
|
||||
className="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-200"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{showRemove &&
|
||||
String(f.status).toLowerCase() !== "processing" && (
|
||||
<button
|
||||
title="Remove from project"
|
||||
aria-label="Remove file from project"
|
||||
className="p-0 bg-transparent border-0 outline-none cursor-pointer opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove && onRemove(f);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-neutral-600 hover:text-red-600 dark:text-neutral-400 dark:hover:text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground px-2 py-4">
|
||||
No files found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/* Fade effect at bottom when scrollable */}
|
||||
{isScrollable && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background via-background/90 to-transparent pointer-events-none z-10" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
import { FiFileText } from "react-icons/fi";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ExpandTwoIcon } from "@/components/icons/icons";
|
||||
import SvgFileText from "@/icons/file-text";
|
||||
import { getFileExtension } from "../files_utils";
|
||||
import Truncated from "@/refresh-components/Truncated";
|
||||
import Text from "@/refresh-components/Text";
|
||||
|
||||
export function DocumentPreview({
|
||||
fileName,
|
||||
@@ -19,73 +14,32 @@ export function DocumentPreview({
|
||||
maxWidth?: string;
|
||||
alignBubble?: boolean;
|
||||
}) {
|
||||
const fileNameRef = useRef<HTMLDivElement>(null);
|
||||
const typeLabel = getFileExtension(fileName);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${alignBubble && "min-w-52 max-w-48"}
|
||||
flex
|
||||
items-center
|
||||
bg-accent-background/50
|
||||
border
|
||||
border-border
|
||||
rounded-lg
|
||||
box-border
|
||||
py-4
|
||||
h-12
|
||||
hover:shadow-sm
|
||||
transition-all
|
||||
px-2
|
||||
`}
|
||||
className={`relative group flex items-center gap-3 border border-border rounded-xl bg-background-tint-00 px-3 py-1 shadow-sm h-14 w-52 ${
|
||||
open ? "cursor-pointer hover:bg-accent-background" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (open) {
|
||||
open();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
className="
|
||||
w-8
|
||||
h-8
|
||||
bg-document
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
rounded-lg
|
||||
transition-all
|
||||
duration-200
|
||||
hover:bg-document-dark
|
||||
"
|
||||
>
|
||||
<FiFileText className="w-5 h-5 text-white" />
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-transparent">
|
||||
<div className="bg-background-tint-01 p-2 rounded-lg">
|
||||
<SvgFileText className="h-5 w-5 stroke-text-02" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 h-8 flex flex-col flex-grow">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
ref={fileNameRef}
|
||||
className={`font-medium text-sm line-clamp-1 break-all ellipsis ${
|
||||
maxWidth ? maxWidth : "max-w-48"
|
||||
}`}
|
||||
>
|
||||
{fileName}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
{fileName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="text-subtle text-xs">Document</div>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<Truncated text04 secondaryAction>
|
||||
{fileName}
|
||||
</Truncated>
|
||||
<Text text03 secondaryBody>
|
||||
{typeLabel}
|
||||
</Text>
|
||||
</div>
|
||||
{open && (
|
||||
<button
|
||||
onClick={() => open()}
|
||||
className="ml-2 p-2 rounded-full hover:bg-background-200 transition-colors duration-200"
|
||||
aria-label="Expand document"
|
||||
>
|
||||
<ExpandTwoIcon className="w-5 h-5 text-text-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,65 +53,22 @@ export function InputDocumentPreview({
|
||||
maxWidth?: string;
|
||||
alignBubble?: boolean;
|
||||
}) {
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const fileNameRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileNameRef.current) {
|
||||
setIsOverflowing(
|
||||
fileNameRef.current.scrollWidth > fileNameRef.current.clientWidth
|
||||
);
|
||||
}
|
||||
}, [fileName]);
|
||||
const typeLabel = getFileExtension(fileName);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${alignBubble && "w-64"}
|
||||
flex
|
||||
items-center
|
||||
p-2
|
||||
bg-accent-background-hovered
|
||||
border
|
||||
border-border
|
||||
rounded-md
|
||||
box-border
|
||||
h-10
|
||||
`}
|
||||
className={`relative group flex items-center gap-3 border border-border rounded-xl bg-accent-background px-3 py-1 shadow-sm h-14 w-52`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
className="
|
||||
w-6
|
||||
h-6
|
||||
bg-document
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
rounded-md
|
||||
"
|
||||
>
|
||||
<FiFileText className="w-4 h-4 text-white" />
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-transparent">
|
||||
<div className="bg-accent-background p-2 rounded-lg shadow-sm">
|
||||
<SvgFileText className="h-5 w-5 stroke-text-02" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 relative">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
ref={fileNameRef}
|
||||
className={`font-medium text-sm line-clamp-1 break-all ellipses ${
|
||||
maxWidth ? maxWidth : "max-w-48"
|
||||
}`}
|
||||
>
|
||||
{fileName}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
{fileName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="text-onyx-medium text-sm truncate" title={fileName}>
|
||||
{fileName}
|
||||
</span>
|
||||
<span className="text-onyx-muted text-xs truncate">{typeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
37
web/src/app/chat/components/files/files_utils.ts
Normal file
37
web/src/app/chat/components/files/files_utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Extracts the file extension from a filename and returns it in uppercase.
|
||||
* Returns an empty string if no valid extension is found.
|
||||
*/
|
||||
export function getFileExtension(fileName: string): string {
|
||||
const name = String(fileName || "");
|
||||
const lastDotIndex = name.lastIndexOf(".");
|
||||
if (lastDotIndex <= 0 || lastDotIndex === name.length - 1) {
|
||||
return "";
|
||||
}
|
||||
return name.slice(lastDotIndex + 1).toUpperCase();
|
||||
}
|
||||
|
||||
// Centralized list of image file extensions (lowercase, no leading dots)
|
||||
export const IMAGE_EXTENSIONS = [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"svg",
|
||||
"bmp",
|
||||
] as const;
|
||||
|
||||
export type ImageExtension = (typeof IMAGE_EXTENSIONS)[number];
|
||||
|
||||
// Checks whether a provided extension string corresponds to an image extension.
|
||||
// Accepts values with any casing and without a leading dot.
|
||||
export function isImageExtension(
|
||||
extension: string | null | undefined
|
||||
): boolean {
|
||||
if (!extension) {
|
||||
return false;
|
||||
}
|
||||
const normalized = extension.toLowerCase();
|
||||
return (IMAGE_EXTENSIONS as readonly string[]).includes(normalized);
|
||||
}
|
||||
@@ -839,7 +839,7 @@ export function ActionToggle({
|
||||
}
|
||||
}}
|
||||
className="
|
||||
w-[244px]
|
||||
w-[15.5rem]
|
||||
max-h-[300px]
|
||||
text-neutral-600 dark:text-neutral-400
|
||||
text-sm
|
||||
@@ -847,7 +847,6 @@ export function ActionToggle({
|
||||
overflow-hidden
|
||||
flex
|
||||
flex-col
|
||||
bg-white dark:bg-neutral-900
|
||||
border border-neutral-200 dark:border-transparent
|
||||
shadow-lg dark:shadow-xl dark:shadow-[0_0_8px_rgba(255,255,255,0.05)]
|
||||
"
|
||||
|
||||
@@ -36,6 +36,7 @@ import FilePicker from "@/app/chat/components/files/FilePicker";
|
||||
import { ActionToggle } from "@/app/chat/components/input/ActionManagement";
|
||||
import SelectButton from "@/refresh-components/buttons/SelectButton";
|
||||
import { getIconForAction } from "../../services/actionUtils";
|
||||
import SvgPlusCircle from "@/icons/plus-circle";
|
||||
|
||||
const MAX_INPUT_HEIGHT = 200;
|
||||
|
||||
@@ -385,7 +386,7 @@ function ChatInputBarInner({
|
||||
|
||||
<div className="w-full h-full flex flex-col shadow-01 bg-background-neutral-00 rounded-16">
|
||||
{currentMessageFiles.length > 0 && (
|
||||
<div className="px-4 pt-4">
|
||||
<div className="p-spacing-inline bg-background-neutral-01 rounded-t-16">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentMessageFiles.map((file) => (
|
||||
<FileCard
|
||||
@@ -500,6 +501,13 @@ function ChatInputBarInner({
|
||||
}}
|
||||
recentFiles={recentFiles}
|
||||
handleUploadChange={handleUploadChange}
|
||||
trigger={
|
||||
<IconButton
|
||||
icon={SvgPlusCircle}
|
||||
tooltip="Attach Files"
|
||||
tertiary
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{selectedAssistant.tools.length > 0 && (
|
||||
<ActionToggle
|
||||
|
||||
@@ -2,19 +2,14 @@
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChatBubbleIcon } from "@/components/icons/CustomIcons";
|
||||
import { ChatSessionMorePopup } from "@/components/sidebar/ChatSessionMorePopup";
|
||||
import { useProjectsContext } from "../../projects/ProjectsContext";
|
||||
import { ChatSession } from "@/app/chat/interfaces";
|
||||
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
|
||||
import { useAssistantsContext } from "@/components/context/AssistantsContext";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import SvgBubbleText from "@/icons/bubble-text";
|
||||
import { formatRelativeTime } from "./project_utils";
|
||||
import Text from "@/refresh-components/Text";
|
||||
|
||||
export default function ProjectChatSessionList() {
|
||||
const {
|
||||
@@ -39,15 +34,19 @@ export default function ProjectChatSessionList() {
|
||||
if (!currentProjectId) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-4 w-full max-w-[800px] mx-auto mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base text-onyx-muted">Recent Chats</h2>
|
||||
<div className="flex flex-col gap-2 px-2 w-full max-w-[800px] mx-auto mt-6">
|
||||
<div className="flex items-center pl-spacing-interline">
|
||||
<Text text02 secondaryBody>
|
||||
Recent Chats
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{projectChats.length === 0 ? (
|
||||
<p className="text-sm text-onyx-muted">No chats yet.</p>
|
||||
<Text text02 secondaryBody className="p-spacing-interline">
|
||||
No chats yet.
|
||||
</Text>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 max-h-[46vh] overflow-y-auto overscroll-y-none pr-1">
|
||||
<div className="flex flex-col gap-2 max-h-[46vh] overflow-y-auto overscroll-y-none">
|
||||
{projectChats.map((chat) => (
|
||||
<Link
|
||||
key={chat.id}
|
||||
@@ -57,7 +56,7 @@ export default function ProjectChatSessionList() {
|
||||
onMouseLeave={() => setHoveredChatId(null)}
|
||||
>
|
||||
<div
|
||||
className={`w-full rounded-xl bg-background-background px-1 py-2 transition-colors ${hoveredChatId === chat.id ? "bg-accent-background-hovered" : ""}`}
|
||||
className={`w-full rounded-08 py-2 transition-colors p-spacing-interline-mini ${hoveredChatId === chat.id ? "bg-background-tint-02" : ""}`}
|
||||
>
|
||||
<div className="flex gap-3 min-w-0 w-full">
|
||||
<div className="flex h-full w-fit pt-1 pl-1">
|
||||
@@ -82,19 +81,22 @@ export default function ProjectChatSessionList() {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<ChatBubbleIcon className="h-5 w-5 text-onyx-medium" />
|
||||
<SvgBubbleText className="h-4 w-4 stroke-text-02" />
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex items-center gap-1 w-full justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="text-lg text-onyx-emphasis truncate"
|
||||
<Text
|
||||
text03
|
||||
mainUiBody
|
||||
nowrap
|
||||
className="truncate"
|
||||
title={chat.name}
|
||||
>
|
||||
{chat.name || "Unnamed Chat"}
|
||||
</span>
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ChatSessionMorePopup
|
||||
@@ -119,9 +121,9 @@ export default function ProjectChatSessionList() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-base text-onyx-muted truncate">
|
||||
<Text text03 secondaryBody nowrap className="truncate">
|
||||
Last message {formatRelativeTime(chat.time_updated)}
|
||||
</span>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FileIcon, Loader2, X } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { RiPlayListAddFill } from "react-icons/ri";
|
||||
import { useProjectsContext } from "../../projects/ProjectsContext";
|
||||
import FilePicker from "../files/FilePicker";
|
||||
import FilesList from "../files/FilesList";
|
||||
import type {
|
||||
ProjectFile,
|
||||
CategorizedFiles,
|
||||
@@ -30,12 +14,20 @@ import { UserFileStatus } from "../../projects/projectsService";
|
||||
import { ChatFileType } from "@/app/chat/interfaces";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import {
|
||||
MultipleFilesIcon,
|
||||
OpenFolderIcon,
|
||||
ListSettingsIcon,
|
||||
DocumentIcon,
|
||||
} from "@/components/icons/CustomIcons";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import SvgPlusCircle from "@/icons/plus-circle";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { useModal, ModalIds } from "@/refresh-components/contexts/ModalContext";
|
||||
import AddInstructionModal from "@/components/modals/AddInstructionModal";
|
||||
import UserFilesModalContent from "@/components/modals/UserFilesModalContent";
|
||||
import { useEscape } from "@/hooks/useKeyPress";
|
||||
import CoreModal from "@/refresh-components/modals/CoreModal";
|
||||
import Text from "@/refresh-components/Text";
|
||||
import SvgFileText from "@/icons/file-text";
|
||||
import SvgFolderOpen from "@/icons/folder-open";
|
||||
import SvgAddLines from "@/icons/add-lines";
|
||||
import SvgFiles from "@/icons/files";
|
||||
import Truncated from "@/refresh-components/Truncated";
|
||||
|
||||
export function FileCard({
|
||||
file,
|
||||
@@ -58,7 +50,6 @@ export function FileCard({
|
||||
}, [file.name]);
|
||||
|
||||
const isActuallyProcessing =
|
||||
String(file.status).toLowerCase() === "processing" ||
|
||||
String(file.status).toLowerCase() === "uploading";
|
||||
|
||||
// When hideProcessingState is true, we treat processing files as completed for display purposes
|
||||
@@ -73,9 +64,9 @@ export function FileCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative group flex items-center gap-3 border border-border rounded-xl ${
|
||||
isProcessing ? "bg-accent-background" : "bg-background-background"
|
||||
} px-3 py-1 shadow-sm h-14 w-52 ${
|
||||
className={`relative group flex items-center gap-3 border border-border-01 rounded-12 ${
|
||||
isProcessing ? "bg-background-neutral-02" : "bg-background-tint-00"
|
||||
} p-spacing-inline h-14 w-40 ${
|
||||
onFileClick && !isProcessing
|
||||
? "cursor-pointer hover:bg-accent-background"
|
||||
: ""
|
||||
@@ -96,26 +87,31 @@ export function FileCard({
|
||||
<X className="h-4 w-4 dark:text-dark-tremor-background-muted" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-transparent">
|
||||
<div
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-08 p-spacing-interline
|
||||
${isProcessing ? "bg-background-neutral-03" : "bg-background-tint-01"}`}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-5 w-5 text-onyx-muted animate-spin" />
|
||||
<Loader2 className="h-5 w-5 text-text-01 animate-spin" />
|
||||
) : (
|
||||
<div className="bg-accent-background p-2 rounded-lg shadow-sm">
|
||||
<DocumentIcon className="h-5 w-5 text-onyx-muted" />
|
||||
</div>
|
||||
<SvgFileText className="h-5 w-5 stroke-text-02" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="text-onyx-medium text-sm truncate" title={file.name}>
|
||||
<Truncated
|
||||
className={`font-secondary-action truncate
|
||||
${isProcessing ? "text-text-03" : "text-text-04"}`}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-onyx-muted text-xs truncate">
|
||||
</Truncated>
|
||||
<Text text03 secondaryBody nowrap className="truncate">
|
||||
{isProcessing
|
||||
? file.status === UserFileStatus.UPLOADING
|
||||
? "Uploading..."
|
||||
: "Processing..."
|
||||
: typeLabel}
|
||||
</span>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -130,11 +126,13 @@ export default function ProjectContextPanel({
|
||||
availableContextTokens?: number;
|
||||
setPresentingDocument?: (document: MinimalOnyxDocument) => void;
|
||||
}) {
|
||||
const [isInstrOpen, setIsInstrOpen] = useState(false);
|
||||
const [showProjectFiles, setShowProjectFiles] = useState(false);
|
||||
const [instructionText, setInstructionText] = useState("");
|
||||
const { popup, setPopup } = usePopup();
|
||||
const [tempProjectFiles, setTempProjectFiles] = useState<ProjectFile[]>([]);
|
||||
const { isOpen, toggleModal } = useModal();
|
||||
const open = isOpen(ModalIds.ProjectFilesModal);
|
||||
|
||||
const onClose = () => toggleModal(ModalIds.ProjectFilesModal, false);
|
||||
useEscape(onClose, open);
|
||||
|
||||
// Convert ProjectFile to MinimalOnyxDocument format for viewing
|
||||
const handleFileClick = useCallback(
|
||||
@@ -151,7 +149,6 @@ export default function ProjectContextPanel({
|
||||
[setPresentingDocument]
|
||||
);
|
||||
const {
|
||||
upsertInstructions,
|
||||
currentProjectDetails,
|
||||
currentProjectId,
|
||||
uploadFiles,
|
||||
@@ -161,17 +158,8 @@ export default function ProjectContextPanel({
|
||||
} = useProjectsContext();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const preset = currentProjectDetails?.project?.instructions ?? "";
|
||||
setInstructionText(preset);
|
||||
}, [currentProjectDetails?.project?.instructions ?? ""]);
|
||||
|
||||
const totalFiles = (currentProjectDetails?.files || []).length;
|
||||
const displayFileCount = totalFiles > 100 ? "100+" : String(totalFiles);
|
||||
|
||||
const handleUploadChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
const handleUploadFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setIsUploading(true);
|
||||
try {
|
||||
@@ -196,20 +184,22 @@ export default function ProjectContextPanel({
|
||||
Array.from(files),
|
||||
currentProjectId
|
||||
);
|
||||
// Replace temp entries with backend entries (by index) so keys become backend IDs. This will prevent flickering.
|
||||
// Replace the first N temp entries with backend entries so they stay at the front
|
||||
setTempProjectFiles((prev) => [
|
||||
...prev.slice(0, -tempFiles.length),
|
||||
...result.user_files,
|
||||
...prev.slice(tempFiles.length),
|
||||
]);
|
||||
const unsupported = result?.unsupported_files || [];
|
||||
const nonAccepted = result?.non_accepted_files || [];
|
||||
if (unsupported.length > 0 || nonAccepted.length > 0) {
|
||||
const parts: string[] = [];
|
||||
if (unsupported.length > 0) {
|
||||
parts.push(`Unsupported: ${unsupported.join(", ")}`);
|
||||
parts.push(`File type not supported: ${unsupported.join(", ")}`);
|
||||
}
|
||||
if (nonAccepted.length > 0) {
|
||||
parts.push(`Not accepted: ${nonAccepted.join(", ")}`);
|
||||
parts.push(
|
||||
`Content exceeds allowed token limit: ${nonAccepted.join(", ")}`
|
||||
);
|
||||
}
|
||||
setPopup({
|
||||
type: "warning",
|
||||
@@ -219,62 +209,97 @@ export default function ProjectContextPanel({
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setTempProjectFiles([]);
|
||||
e.target.value = "";
|
||||
}
|
||||
},
|
||||
[currentProjectId, uploadFiles, setPopup]
|
||||
);
|
||||
|
||||
const totalFiles =
|
||||
(currentProjectDetails?.files || []).length + tempProjectFiles.length;
|
||||
const displayFileCount = totalFiles > 100 ? "100+" : String(totalFiles);
|
||||
|
||||
const handleUploadChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
await handleUploadFiles(Array.from(files));
|
||||
e.target.value = "";
|
||||
},
|
||||
[handleUploadFiles]
|
||||
);
|
||||
|
||||
// Nested dropzone for drag-and-drop within ProjectContextPanel
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
multiple: true,
|
||||
noDragEventsBubbling: true,
|
||||
onDrop: (acceptedFiles) => {
|
||||
void handleUploadFiles(acceptedFiles);
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentProjectId) return null; // no selection yet
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 p-4 w-full max-w-[800px] mx-auto mt-10">
|
||||
<div className="flex flex-col gap-2">
|
||||
<OpenFolderIcon size={34} className="text-onyx-ultra-strong" />
|
||||
<h1 className="text-onyx-strong text-4xl">
|
||||
<div className="flex flex-col gap-6 w-full max-w-[800px] mx-auto mt-10 mb-[1.5rem]">
|
||||
<div className="flex flex-col gap-1 text-text-04">
|
||||
<SvgFolderOpen className="h-8 w-8 text-text-04" />
|
||||
<Text headingH2 className="font-heading-h2">
|
||||
{currentProjectDetails?.project?.name || "Loading project..."}
|
||||
</h1>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Separator className="my-0" />
|
||||
<div className="flex flex-row gap-2 justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-onyx-medium text-2xl">Instructions</p>
|
||||
<Text headingH3 text04>
|
||||
Instructions
|
||||
</Text>
|
||||
{currentProjectDetails?.project?.instructions ? (
|
||||
<p
|
||||
className="text-onyx-muted text-base truncate"
|
||||
title={currentProjectDetails.project.instructions || ""}
|
||||
>
|
||||
<Text text02 secondaryBody className="truncate">
|
||||
{currentProjectDetails.project.instructions}
|
||||
</p>
|
||||
</Text>
|
||||
) : (
|
||||
<p className="text-onyx-muted text-base truncate">
|
||||
<Text text02 secondaryBody className="truncate">
|
||||
Add instructions to tailor the response in this project.
|
||||
</p>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsInstrOpen(true)}
|
||||
className="flex flex-row gap-2 items-center justify-center p-2 rounded-md bg-background-dark/75 hover:dark:bg-neutral-800/75 hover:bg-accent-background-hovered cursor-pointer transition-all duration-150 shrink-0 whitespace-nowrap h-12"
|
||||
<Button
|
||||
onClick={() => toggleModal(ModalIds.AddInstructionModal, true)}
|
||||
tertiary
|
||||
>
|
||||
<ListSettingsIcon size={20} className="text-onyx-emphasis" />
|
||||
<p className="text-onyx-emphasis text-lg whitespace-nowrap">
|
||||
Set Instructions
|
||||
</p>
|
||||
</button>
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
<SvgAddLines className="h-4 w-4 stroke-text-03" />
|
||||
<Text text03 mainUiAction className="whitespace-nowrap">
|
||||
Set Instructions
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div
|
||||
className="flex flex-col gap-2 "
|
||||
{...getRootProps({ onClick: (e) => e.stopPropagation() })}
|
||||
>
|
||||
<div className="flex flex-row gap-2 justify-between">
|
||||
<div>
|
||||
<p className="text-onyx-medium text-2xl">Files</p>
|
||||
<Text headingH3 text04>
|
||||
Files
|
||||
</Text>
|
||||
|
||||
<p className="text-onyx-muted text-base">
|
||||
<Text text02 secondaryBody>
|
||||
Chats in this project can access these files.
|
||||
</p>
|
||||
</Text>
|
||||
</div>
|
||||
<FilePicker
|
||||
showTriggerLabel
|
||||
triggerLabel="Add Files"
|
||||
trigger={
|
||||
<LineItem icon={SvgPlusCircle}>
|
||||
<Text text03 mainUiAction>
|
||||
Add Files
|
||||
</Text>
|
||||
</LineItem>
|
||||
}
|
||||
recentFiles={recentFiles}
|
||||
onFileClick={handleFileClick}
|
||||
onPickRecent={async (file) => {
|
||||
@@ -283,10 +308,11 @@ export default function ProjectContextPanel({
|
||||
await linkFileToProject(currentProjectId, file.id);
|
||||
}}
|
||||
handleUploadChange={handleUploadChange}
|
||||
triggerLabelClassName="text-lg text-onyx-emphasis"
|
||||
triggerClassName="h-12"
|
||||
className="mr-1.5"
|
||||
/>
|
||||
</div>
|
||||
{/* Hidden input just to satisfy dropzone contract; we rely on FilePicker for clicks */}
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{tempProjectFiles.length > 0 ||
|
||||
(currentProjectDetails?.files &&
|
||||
@@ -296,38 +322,36 @@ export default function ProjectContextPanel({
|
||||
<div className="sm:hidden">
|
||||
<button
|
||||
className="w-full rounded-xl px-3 py-3 text-left bg-transparent hover:bg-accent-background-hovered hover:dark:bg-neutral-800/75 transition-colors"
|
||||
onClick={() => setShowProjectFiles(true)}
|
||||
onClick={() => toggleModal(ModalIds.ProjectFilesModal, true)}
|
||||
>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<span className="text-onyx-medium text-sm truncate flex-1">
|
||||
<Text text04 secondaryAction>
|
||||
View files
|
||||
</span>
|
||||
<MultipleFilesIcon className="h-5 w-5 text-onyx-medium" />
|
||||
</Text>
|
||||
<SvgFiles className="h-5 w-5 stroke-text-02" />
|
||||
</div>
|
||||
<span className="text-onyx-muted text-sm">
|
||||
<Text text03 secondaryBody>
|
||||
{displayFileCount} files
|
||||
</span>
|
||||
</Text>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop / larger screens: show previews with optional View All */}
|
||||
<div className="hidden sm:flex gap-3">
|
||||
<div className="hidden sm:flex gap-spacing-inline relative">
|
||||
{(() => {
|
||||
const byId = new Map<string, ProjectFile>();
|
||||
// Prefer backend files when available
|
||||
(currentProjectDetails?.files || []).forEach((f) =>
|
||||
byId.set(f.id, f)
|
||||
);
|
||||
// Add temp files only if a backend file with same id doesn't exist yet
|
||||
tempProjectFiles.forEach((f) => {
|
||||
if (!byId.has(f.id)) byId.set(f.id, f);
|
||||
// Insert temp files first so new uploads appear at the front immediately
|
||||
tempProjectFiles.forEach((f) => byId.set(f.id, f));
|
||||
// Then insert backend files to overwrite temp entries while keeping order
|
||||
(currentProjectDetails?.files || []).forEach((f) => {
|
||||
byId.set(f.id, f);
|
||||
});
|
||||
return Array.from(byId.values())
|
||||
.slice(0, 3)
|
||||
.slice(0, 4)
|
||||
.map((f) => (
|
||||
<div key={f.id} className="w-52">
|
||||
<div key={f.id} className="w-40">
|
||||
<FileCard
|
||||
file={f}
|
||||
removeFile={async (fileId: string) => {
|
||||
@@ -339,101 +363,83 @@ export default function ProjectContextPanel({
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
{totalFiles > 3 && (
|
||||
{totalFiles > 4 && (
|
||||
<button
|
||||
className="rounded-xl px-3 py-1 text-left bg-transparent hover:bg-accent-background-hovered hover:dark:bg-neutral-800/75 transition-colors"
|
||||
onClick={() => setShowProjectFiles(true)}
|
||||
className="rounded-xl px-3 py-1 text-left transition-colors hover:bg-background-tint-02"
|
||||
onClick={() => toggleModal(ModalIds.ProjectFilesModal, true)}
|
||||
>
|
||||
<div className="flex flex-col overflow-hidden h-12 p-1">
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<span className="text-onyx-medium text-sm truncate flex-1">
|
||||
<Text text04 secondaryAction>
|
||||
View All
|
||||
</span>
|
||||
<MultipleFilesIcon className="h-5 w-5 text-onyx-medium" />
|
||||
</Text>
|
||||
<SvgFiles className="h-5 w-5 stroke-text-02" />
|
||||
</div>
|
||||
<span className="text-onyx-muted text-sm">
|
||||
<Text text03 secondaryBody>
|
||||
{displayFileCount} files
|
||||
</span>
|
||||
</Text>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{isDragActive && (
|
||||
<div className="pointer-events-none absolute inset-0 rounded-lg border-2 border-dashed border-action-link-05" />
|
||||
)}
|
||||
</div>
|
||||
{projectTokenCount > availableContextTokens && (
|
||||
<p className="text-onyx-muted text-base">
|
||||
<Text text02 secondaryBody>
|
||||
This project exceeds the model's context limits. Sessions
|
||||
will automatically search for relevant files first before
|
||||
generating response.
|
||||
</p>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-onyx-muted text-base">No files yet.</p>
|
||||
<div
|
||||
className={`h-12 rounded-lg border border-dashed ${
|
||||
isDragActive
|
||||
? "bg-action-link-01 border-action-link-05"
|
||||
: "border-border-01"
|
||||
} flex items-center pl-spacing-interline`}
|
||||
>
|
||||
<p
|
||||
className={`font-secondary-body ${
|
||||
isDragActive ? "text-action-link-05" : "text-text-02 "
|
||||
}`}
|
||||
>
|
||||
{isDragActive
|
||||
? "Drop files here to add to this project"
|
||||
: "Add documents, texts, or images to use in the project. Drag & drop supported."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isInstrOpen} onOpenChange={setIsInstrOpen}>
|
||||
<DialogContent className="w-[95%] max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<ListSettingsIcon size={22} />
|
||||
<DialogTitle>Set Project Instructions</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Instruct specific behaviors, focus, tones, or formats for the
|
||||
response in this project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={instructionText}
|
||||
onChange={(e) => setInstructionText(e.target.value)}
|
||||
placeholder="Think step by step and show reasoning for complex problems. Use specific examples."
|
||||
className="min-h-[140px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={() => setIsInstrOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsInstrOpen(false);
|
||||
upsertInstructions(instructionText);
|
||||
}}
|
||||
>
|
||||
Save Instructions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={showProjectFiles} onOpenChange={setShowProjectFiles}>
|
||||
<DialogContent
|
||||
className="w-full max-w-lg focus:outline-none focus-visible:outline-none"
|
||||
tabIndex={-1}
|
||||
onOpenAutoFocus={(e) => {
|
||||
// Prevent auto-focus which can interfere with input
|
||||
e.preventDefault();
|
||||
}}
|
||||
<AddInstructionModal />
|
||||
|
||||
{open && (
|
||||
<CoreModal
|
||||
className="w-[32rem] rounded-16 border flex flex-col bg-background-tint-00"
|
||||
onClickOutside={onClose}
|
||||
>
|
||||
<DialogHeader>
|
||||
<MultipleFilesIcon className="h-8 w-8 text-onyx-ultra-strong" />
|
||||
<DialogTitle>Project files</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sessions in this project can access the files here.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FilesList
|
||||
recentFiles={(currentProjectDetails?.files || []) as any}
|
||||
<UserFilesModalContent
|
||||
title="Project files"
|
||||
description="Sessions in this project can access the files here."
|
||||
icon={SvgFiles}
|
||||
recentFiles={[
|
||||
...tempProjectFiles,
|
||||
...(currentProjectDetails?.files || []),
|
||||
]}
|
||||
onFileClick={handleFileClick}
|
||||
handleUploadChange={handleUploadChange}
|
||||
showRemove
|
||||
onRemove={async (file) => {
|
||||
onRemove={async (file: ProjectFile) => {
|
||||
if (!currentProjectId) return;
|
||||
await unlinkFileFromProject(currentProjectId, file.id);
|
||||
}}
|
||||
onFileClick={handleFileClick}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CoreModal>
|
||||
)}
|
||||
{popup}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "./projectsService";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { SEARCH_PARAM_NAMES } from "@/app/chat/services/searchParams";
|
||||
import { useAppRouter } from "@/hooks/appNavigation";
|
||||
|
||||
export type { Project, ProjectFile } from "./projectsService";
|
||||
|
||||
@@ -101,6 +102,7 @@ export const ProjectsProvider: React.FC<ProjectsProviderProps> = ({
|
||||
const [trackedUploadIds, setTrackedUploadIds] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const route = useAppRouter();
|
||||
|
||||
const fetchProjects = useCallback(async (): Promise<Project[]> => {
|
||||
setError(null);
|
||||
@@ -141,6 +143,8 @@ export const ProjectsProvider: React.FC<ProjectsProviderProps> = ({
|
||||
setError(null);
|
||||
try {
|
||||
const project: Project = await svcCreateProject(name);
|
||||
// Navigate to the newly created project's page
|
||||
route({ projectId: project.id });
|
||||
// Refresh list to keep order consistent with backend
|
||||
await fetchProjects();
|
||||
return project;
|
||||
@@ -151,7 +155,7 @@ export const ProjectsProvider: React.FC<ProjectsProviderProps> = ({
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[fetchProjects]
|
||||
[fetchProjects, route]
|
||||
);
|
||||
|
||||
const renameProject = useCallback(
|
||||
|
||||
@@ -151,7 +151,8 @@ export default function TextView({
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
hideCloseIcon
|
||||
className="max-w-4xl w-[90vw] flex flex-col justify-between gap-y-0 h-[90vh] max-h-[90vh] p-0"
|
||||
overlayClassName="z-[3000]"
|
||||
className="z-[3001] max-w-4xl w-[90vw] flex flex-col justify-between gap-y-0 h-[90vh] max-h-[90vh] p-0"
|
||||
>
|
||||
<DialogHeader className="px-4 mb-0 pt-2 pb-3 flex flex-row items-center justify-between border-b">
|
||||
<DialogTitle className="text-lg font-medium truncate">
|
||||
|
||||
@@ -9,35 +9,6 @@ type IconProps = React.SVGProps<SVGSVGElement> & {
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export const MultipleFilesIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ size = 16, color = "currentColor", title, className, ...props }, ref) => (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 14"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
role={title ? "img" : "presentation"}
|
||||
aria-label={title}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
<path
|
||||
d="M5.5 0.999903H2.33334C1.97971 0.999903 1.64058 1.14038 1.39053 1.39043C1.14048 1.64048 1 1.97961 1 2.33324L1 11.6666C1 12.0202 1.14048 12.3593 1.39052 12.6094C1.64057 12.8594 1.97971 12.9999 2.33333 12.9999L8.33 12.9999C8.68362 12.9999 9.02276 12.8594 9.27281 12.6094C9.52286 12.3593 9.66333 12.0202 9.66333 11.6666L9.66334 5.1699M5.5 0.999903L9.66334 5.1699M5.5 0.999903V5.1699H9.66334M9.16167 0.999878L11.7475 3.58578C12.1226 3.96085 12.3333 4.46956 12.3333 4.99999V11.3332C12.3333 11.9076 12.2107 12.5182 11.9459 13.0032M14.6126 13.0033C14.8773 12.5182 15 11.9077 15 11.3333L15 5.24415C15 3.91915 14.4741 2.64833 13.5377 1.71083L12.8268 0.999909"
|
||||
stroke={color}
|
||||
strokeOpacity={1}
|
||||
strokeWidth={1.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
MultipleFilesIcon.displayName = "MultipleFilesIcon";
|
||||
|
||||
export const OpenFolderIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ size = 32, color = "currentColor", title, className, ...props }, ref) => (
|
||||
<svg
|
||||
@@ -56,7 +27,7 @@ export const OpenFolderIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
<path
|
||||
d="M30.4177 15.4931L29.1847 15.2876L30.4177 15.4931ZM29.4177 21.4932L30.6507 21.6987V21.6987L29.4177 21.4932ZM2.58209 21.4932L1.3491 21.6987L2.58209 21.4932ZM1.58209 15.4931L0.349095 15.6986L1.58209 15.4931ZM13.8786 2.87868L12.9947 3.76256V3.76256L13.8786 2.87868ZM16.1212 5.12132L17.0051 4.23744V4.23744L16.1212 5.12132ZM4.54127 11.9999V13.2499H27.4585V11.9999V10.7499H4.54127V11.9999ZM30.4177 15.4931L29.1847 15.2876L28.1847 21.2877L29.4177 21.4932L30.6507 21.6987L31.6507 15.6986L30.4177 15.4931ZM26.4585 24V22.75H5.54128V24V25.25H26.4585V24ZM2.58209 21.4932L3.81509 21.2877L2.81508 15.2876L1.58209 15.4931L0.349095 15.6986L1.3491 21.6987L2.58209 21.4932ZM5.54128 24V22.75C4.68581 22.75 3.95572 22.1315 3.81509 21.2877L2.58209 21.4932L1.3491 21.6987C1.69065 23.748 3.46371 25.25 5.54128 25.25V24ZM29.4177 21.4932L28.1847 21.2877C28.0441 22.1315 27.314 22.75 26.4585 22.75V24V25.25C28.5361 25.25 30.3091 23.748 30.6507 21.6987L29.4177 21.4932ZM18.2425 6V7.25H25.9999V6V4.75H18.2425V6ZM5.9999 2V3.25H11.7573V2V0.75H5.9999V2ZM13.8786 2.87868L12.9947 3.76256L15.2373 6.0052L16.1212 5.12132L17.0051 4.23744L14.7625 1.9948L13.8786 2.87868ZM11.7573 2V3.25C12.2214 3.25 12.6665 3.43437 12.9947 3.76256L13.8786 2.87868L14.7625 1.9948C13.9654 1.19777 12.8844 0.75 11.7573 0.75V2ZM18.2425 6V4.75C17.7784 4.75 17.3333 4.56563 17.0051 4.23744L16.1212 5.12132L15.2373 6.0052C16.0344 6.80223 17.1154 7.25 18.2425 7.25V6ZM28.9999 9H30.2499C30.2499 6.65279 28.3471 4.75 25.9999 4.75V6V7.25C26.9664 7.25 27.7499 8.0335 27.7499 9H28.9999ZM2.99989 5H4.24989C4.24989 4.0335 5.0334 3.25 5.9999 3.25V2V0.75C3.65269 0.75 1.74989 2.65279 1.74989 5H2.99989ZM28.9999 9H27.7499V12.4249H28.9999H30.2499V9H28.9999ZM27.4585 11.9999V13.2499C27.7932 13.2499 28.0975 13.3411 28.3564 13.4965L28.9999 12.4249L29.6434 11.3533C29.0065 10.9708 28.2589 10.7499 27.4585 10.7499V11.9999ZM28.9999 12.4249L28.3564 13.4965C28.9538 13.8553 29.3076 14.5505 29.1847 15.2876L30.4177 15.4931L31.6507 15.6986C31.9508 13.8982 31.0763 12.2138 29.6434 11.3533L28.9999 12.4249ZM2.99989 12.4249H4.24989V5H2.99989H1.74989V12.4249H2.99989ZM4.54127 11.9999V10.7499C3.74089 10.7499 2.99329 10.9708 2.35636 11.3533L2.99989 12.4249L3.64343 13.4965C3.90228 13.3411 4.20658 13.2499 4.54127 13.2499V11.9999ZM2.99989 12.4249L2.35636 11.3533C0.923529 12.2138 0.0490297 13.8982 0.349095 15.6986L1.58209 15.4931L2.81508 15.2876C2.69222 14.5505 3.04602 13.8553 3.64343 13.4965L2.99989 12.4249Z"
|
||||
fill={color}
|
||||
fillOpacity={0.8}
|
||||
fillOpacity={1}
|
||||
stroke={color}
|
||||
strokeOpacity={0.8}
|
||||
strokeWidth={0.2}
|
||||
@@ -66,119 +37,3 @@ export const OpenFolderIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
);
|
||||
|
||||
OpenFolderIcon.displayName = "OpenFolderIcon";
|
||||
|
||||
export const ListSettingsIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ size = 14, color = "currentColor", title, className, ...props }, ref) => (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
role={title ? "img" : "presentation"}
|
||||
aria-label={title}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
<path
|
||||
d="M13 4H1M13 1H1M5 10H1M10.5 7.5V10M10.5 10V12.5M10.5 10H8M10.5 10H13M7.5 7H1"
|
||||
stroke={color}
|
||||
strokeOpacity={0.8}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
ListSettingsIcon.displayName = "ListSettingsIcon";
|
||||
|
||||
export const ChatBubbleIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ size = 16, color = "currentColor", title, className, ...props }, ref) => (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
role={title ? "img" : "presentation"}
|
||||
aria-label={title}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
<path
|
||||
d="M10.4939 6.5H5.5M8.00607 9.5H5.50607M1.5 13.5H10.5C12.7091 13.5 14.5 11.7091 14.5 9.5V6.5C14.5 4.29086 12.7091 2.5 10.5 2.5H5.5C3.29086 2.5 1.5 4.29086 1.5 6.5V13.5Z"
|
||||
stroke={color}
|
||||
strokeOpacity={0.6}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
ChatBubbleIcon.displayName = "ChatBubbleIcon";
|
||||
|
||||
export const DocumentIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ size = 20, color = "currentColor", title, className, ...props }, ref) => (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
role={title ? "img" : "presentation"}
|
||||
aria-label={title}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
<path
|
||||
d="M11.6668 1.66669H5.00016C4.55814 1.66669 4.13421 1.84228 3.82165 2.15484C3.50909 2.4674 3.3335 2.89133 3.3335 3.33335V16.6667C3.3335 17.1087 3.50909 17.5326 3.82165 17.8452C4.13421 18.1578 4.55814 18.3334 5.00016 18.3334H15.0002C15.4422 18.3334 15.8661 18.1578 16.1787 17.8452C16.4912 17.5326 16.6668 17.1087 16.6668 16.6667V6.66669M11.6668 1.66669L16.6668 6.66669M11.6668 1.66669L11.6668 6.66669L16.6668 6.66669M13.3335 10.8334H6.66683M13.3335 14.1667H6.66683M8.3335 7.50002H6.66683"
|
||||
stroke={color}
|
||||
strokeOpacity={1}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
DocumentIcon.displayName = "DocumentIcon";
|
||||
|
||||
export const OpenInNewIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ size = 14, color = "currentColor", title, className, ...props }, ref) => (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
role={title ? "img" : "presentation"}
|
||||
aria-label={title}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
<path
|
||||
d="M11 7.66667V11.6667C11 12.0203 10.8595 12.3594 10.6095 12.6095C10.3594 12.8595 10.0203 13 9.66667 13H2.33333C1.97971 13 1.64057 12.8595 1.39052 12.6095C1.14048 12.3594 1 12.0203 1 11.6667V4.33333C1 3.97971 1.14048 3.64057 1.39052 3.39052C1.64057 3.14048 1.97971 3 2.33333 3H6.33333M9 1H13M13 1V5M13 1L5.66667 8.33333"
|
||||
stroke={color}
|
||||
strokeOpacity={1}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
OpenInNewIcon.displayName = "OpenInNewIcon";
|
||||
|
||||
79
web/src/components/modals/AddInstructionModal.tsx
Normal file
79
web/src/components/modals/AddInstructionModal.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import CoreModal from "@/refresh-components/modals/CoreModal";
|
||||
import { ModalIds, useModal } from "@/refresh-components/contexts/ModalContext";
|
||||
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
|
||||
import { useEscape, useKeyPress } from "@/hooks/useKeyPress";
|
||||
import Text from "@/refresh-components/Text";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import SvgX from "@/icons/x";
|
||||
import SvgAddLines from "@/icons/add-lines";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export default function AddInstructionModal() {
|
||||
const { isOpen, toggleModal } = useModal();
|
||||
const open = isOpen(ModalIds.AddInstructionModal);
|
||||
const { currentProjectDetails, upsertInstructions } = useProjectsContext();
|
||||
const [instructionText, setInstructionText] = useState("");
|
||||
|
||||
const onClose = () => toggleModal(ModalIds.AddInstructionModal, false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const preset = currentProjectDetails?.project?.instructions ?? "";
|
||||
setInstructionText(preset);
|
||||
}
|
||||
}, [open, currentProjectDetails?.project?.instructions]);
|
||||
|
||||
async function handleSubmit() {
|
||||
const value = instructionText.trim();
|
||||
try {
|
||||
await upsertInstructions(value);
|
||||
} catch (e) {
|
||||
console.error("Failed to save instructions", e);
|
||||
}
|
||||
toggleModal(ModalIds.AddInstructionModal, false);
|
||||
}
|
||||
|
||||
useKeyPress(handleSubmit, "Enter", open);
|
||||
useEscape(onClose, open);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<CoreModal
|
||||
className="w-[32rem] rounded-16 border flex flex-col bg-background-tint-00"
|
||||
onClickOutside={() => onClose()}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center gap-spacing-inline p-spacing-paragraph">
|
||||
<div className="h-[1.5rem] flex flex-row justify-between items-center w-full">
|
||||
<SvgAddLines className="w-[1.5rem] h-[1.5rem] stroke-text-04" />
|
||||
<IconButton icon={SvgX} internal onClick={onClose} />
|
||||
</div>
|
||||
<Text headingH3 text04 className="w-full text-left">
|
||||
Set Project Instructions
|
||||
</Text>
|
||||
<Text text03>
|
||||
Instruct specific behaviors, focus, tones, or formats for the response
|
||||
in this project.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="bg-background-tint-01 p-spacing-paragraph">
|
||||
<Textarea
|
||||
value={instructionText}
|
||||
onChange={(e) => setInstructionText(e.target.value)}
|
||||
placeholder="Think step by step and show reasoning for complex problems. Use specific examples."
|
||||
className="min-h-[140px] border-border-01 bg-background-neutral-00"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end gap-spacing-interline p-spacing-paragraph">
|
||||
<Button secondary onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>Save Instructions</Button>
|
||||
</div>
|
||||
</CoreModal>
|
||||
);
|
||||
}
|
||||
@@ -3,18 +3,23 @@
|
||||
import { useRef } from "react";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import SvgFolderPlus from "@/icons/folder-plus";
|
||||
import Modal from "@/refresh-components/modals/Modal";
|
||||
import CoreModal from "@/refresh-components/modals/CoreModal";
|
||||
import { ModalIds, useModal } from "@/refresh-components/contexts/ModalContext";
|
||||
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
|
||||
import { useKeyPress } from "@/hooks/useKeyPress";
|
||||
import { useEscape, useKeyPress } from "@/hooks/useKeyPress";
|
||||
import FieldInput from "@/refresh-components/inputs/FieldInput";
|
||||
import { useAppRouter } from "@/hooks/appNavigation";
|
||||
import Text from "@/refresh-components/Text";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import SvgX from "@/icons/x";
|
||||
|
||||
export default function CreateProjectModal() {
|
||||
const { createProject } = useProjectsContext();
|
||||
const { toggleModal } = useModal();
|
||||
const { toggleModal, isOpen } = useModal();
|
||||
const fieldInputRef = useRef<HTMLInputElement>(null);
|
||||
const route = useAppRouter();
|
||||
const onClose = () => toggleModal(ModalIds.CreateProjectModal, false);
|
||||
const open = isOpen(ModalIds.CreateProjectModal);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!fieldInputRef.current) return;
|
||||
@@ -22,8 +27,7 @@ export default function CreateProjectModal() {
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
const newProject = await createProject(name);
|
||||
route({ projectId: newProject.id });
|
||||
await createProject(name);
|
||||
} catch (e) {
|
||||
console.error(`Failed to create the project ${name}`);
|
||||
}
|
||||
@@ -31,17 +35,30 @@ export default function CreateProjectModal() {
|
||||
toggleModal(ModalIds.CreateProjectModal, false);
|
||||
}
|
||||
|
||||
useKeyPress(handleSubmit, "Enter");
|
||||
useKeyPress(handleSubmit, "Enter", open);
|
||||
useEscape(onClose, open);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
id={ModalIds.CreateProjectModal}
|
||||
icon={SvgFolderPlus}
|
||||
title="Create New Project"
|
||||
description="Use projects to organize your files and chats in one place, and add custom instructions for ongoing work."
|
||||
xs
|
||||
<CoreModal
|
||||
className="w-[32rem] rounded-16 border flex flex-col bg-background-tint-00"
|
||||
onClickOutside={() => onClose()}
|
||||
>
|
||||
<div className="flex flex-col p-spacing-paragraph bg-background-tint-01">
|
||||
<div className="flex flex-col items-center justify-center gap-spacing-interline p-spacing-paragraph">
|
||||
<div className="h-[1.5rem] flex flex-row justify-between items-center w-full">
|
||||
<SvgFolderPlus className="w-[1.5rem] h-[1.5rem] stroke-text-04" />
|
||||
<IconButton icon={SvgX} internal onClick={onClose} />
|
||||
</div>
|
||||
<Text headingH3 text04 className="w-full text-left">
|
||||
Create New Project
|
||||
</Text>
|
||||
<Text text03>
|
||||
Use projects to organize your files and chats in one place, and add
|
||||
custom instructions for ongoing work.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="bg-background-tint-01 p-spacing-paragraph">
|
||||
<FieldInput
|
||||
label="Project Name"
|
||||
placeholder="What are you working on?"
|
||||
@@ -49,14 +66,11 @@ export default function CreateProjectModal() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end gap-spacing-interline p-spacing-paragraph">
|
||||
<Button
|
||||
secondary
|
||||
onClick={() => toggleModal(ModalIds.CreateProjectModal, false)}
|
||||
>
|
||||
<Button secondary onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>Create Project</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</CoreModal>
|
||||
);
|
||||
}
|
||||
|
||||
316
web/src/components/modals/UserFilesModalContent.tsx
Normal file
316
web/src/components/modals/UserFilesModalContent.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef, useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProjectFile } from "@/app/chat/projects/ProjectsContext";
|
||||
import { formatRelativeTime } from "@/app/chat/components/projects/project_utils";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import SvgPlusCircle from "@/icons/plus-circle";
|
||||
import Text from "@/refresh-components/Text";
|
||||
import SvgX from "@/icons/x";
|
||||
import { SvgProps } from "@/icons";
|
||||
import SvgSearch from "@/icons/search";
|
||||
import SvgExternalLink from "@/icons/external-link";
|
||||
import SvgFileText from "@/icons/file-text";
|
||||
import SvgImage from "@/icons/image";
|
||||
import SvgTrash from "@/icons/trash";
|
||||
import Truncated from "@/refresh-components/Truncated";
|
||||
import { isImageExtension } from "@/app/chat/components/files/files_utils";
|
||||
|
||||
interface UserFilesModalProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.FunctionComponent<SvgProps>;
|
||||
recentFiles: ProjectFile[];
|
||||
onPickRecent?: (file: ProjectFile) => void;
|
||||
handleUploadChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
showRemove?: boolean;
|
||||
onRemove?: (file: ProjectFile) => void;
|
||||
onFileClick?: (file: ProjectFile) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const getFileExtension = (fileName: string): string => {
|
||||
const idx = fileName.lastIndexOf(".");
|
||||
if (idx === -1) return "";
|
||||
const ext = fileName.slice(idx + 1).toLowerCase();
|
||||
if (ext === "txt") return "PLAINTEXT";
|
||||
return ext.toUpperCase();
|
||||
};
|
||||
|
||||
export default function UserFilesModalContent({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
recentFiles,
|
||||
onPickRecent,
|
||||
handleUploadChange,
|
||||
showRemove,
|
||||
onRemove,
|
||||
onFileClick,
|
||||
onClose,
|
||||
}: UserFilesModalProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [containerHeight, setContainerHeight] = useState<number>(320);
|
||||
const [isScrollable, setIsScrollable] = useState(false);
|
||||
const [isInitialMount, setIsInitialMount] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const maxHeight = 588;
|
||||
const minHeight = 320;
|
||||
const triggerUploadPicker = () => fileInputRef.current?.click();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const s = search.trim().toLowerCase();
|
||||
if (!s) return recentFiles;
|
||||
return recentFiles.filter((f) => f.name.toLowerCase().includes(s));
|
||||
}, [recentFiles, search]);
|
||||
|
||||
// Track container height - only grow, never shrink
|
||||
useEffect(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
const viewport = scrollAreaRef.current.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
);
|
||||
if (viewport) {
|
||||
const contentHeight = viewport.scrollHeight;
|
||||
// Only update if content needs more space and we haven't hit max
|
||||
const newHeight = Math.min(
|
||||
Math.max(contentHeight, minHeight, containerHeight),
|
||||
maxHeight
|
||||
);
|
||||
if (newHeight > containerHeight) {
|
||||
setContainerHeight(newHeight);
|
||||
}
|
||||
// After initial mount, enable transitions
|
||||
if (isInitialMount) {
|
||||
setTimeout(() => setIsInitialMount(false), 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [recentFiles.length, containerHeight, isInitialMount]);
|
||||
|
||||
// Check if content is scrollable
|
||||
useEffect(() => {
|
||||
const checkScrollable = () => {
|
||||
if (scrollAreaRef.current) {
|
||||
const viewport = scrollAreaRef.current.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
);
|
||||
if (viewport) {
|
||||
const isContentScrollable =
|
||||
viewport.scrollHeight > viewport.clientHeight;
|
||||
setIsScrollable(isContentScrollable);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check initially and after content changes
|
||||
requestAnimationFrame(checkScrollable);
|
||||
|
||||
// Also check on resize
|
||||
window.addEventListener("resize", checkScrollable);
|
||||
return () => window.removeEventListener("resize", checkScrollable);
|
||||
}, [filtered.length, containerHeight]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shadow-01 rounded-t-12 relative z-20">
|
||||
<div className="flex flex-col gap-spacing-inline px-spacing-paragraph pt-spacing-paragraph">
|
||||
<div className="h-[1.5rem] flex flex-row justify-between items-center w-full">
|
||||
<Icon className="w-[1.5rem] h-[1.5rem] stroke-text-04" />
|
||||
{onClose && <IconButton icon={SvgX} internal onClick={onClose} />}
|
||||
</div>
|
||||
<Text headingH3 text04 className="w-full text-left">
|
||||
{title}
|
||||
</Text>
|
||||
<Text text03>{description}</Text>
|
||||
</div>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-spacing-interline">
|
||||
<div className="relative flex-1">
|
||||
<SvgSearch className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 stroke-text-02 pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search files..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 pl-8 bg-transparent border-0 shadow-none focus:bg-transparent focus:ring-0 focus-visible:ring-0 focus:border focus:border-border-dark"
|
||||
removeFocusRing
|
||||
autoComplete="off"
|
||||
tabIndex={0}
|
||||
onFocus={(e) => {
|
||||
e.target.select();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{handleUploadChange && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleUploadChange}
|
||||
accept={"*/*"}
|
||||
/>
|
||||
|
||||
<button onClick={triggerUploadPicker}>
|
||||
<LineItem icon={SvgPlusCircle}>
|
||||
<p className="text-text-03 font-main-action">Add Files</p>
|
||||
</LineItem>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
"relative rounded-b-12",
|
||||
!isInitialMount && "transition-all duration-200"
|
||||
)}
|
||||
style={{
|
||||
height: `${containerHeight}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
}}
|
||||
>
|
||||
<ScrollArea
|
||||
ref={scrollAreaRef}
|
||||
className="flex flex-col h-full bg-background-tint-01 px-spacing-paragraph rounded-b-12"
|
||||
>
|
||||
{filtered.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 text-left p-spacing-inline rounded-12 bg-background-tint-00 w-full my-spacing-inline group"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (onPickRecent) {
|
||||
onPickRecent(f);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center p-spacing-inline flex-1 min-w-0">
|
||||
<div className="flex h-9 w-9 items-center justify-center p-spacing-interline bg-background-tint-01 rounded-08">
|
||||
{String((f as ProjectFile).status).toLowerCase() ===
|
||||
"processing" ||
|
||||
String((f as ProjectFile).status).toLowerCase() ===
|
||||
"uploading" ? (
|
||||
<Loader2 className="h-5 w-5 text-text-02 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
const ext = getFileExtension(f.name).toLowerCase();
|
||||
const isImage = isImageExtension(ext);
|
||||
return isImage ? (
|
||||
<SvgImage className="h-5 w-5 stroke-text-02" />
|
||||
) : (
|
||||
<SvgFileText className="h-5 w-5 stroke-text-02" />
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-spacing-inline-mini flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="max-w-[250px] min-w-0 flex-none">
|
||||
<Truncated
|
||||
text04
|
||||
secondaryAction
|
||||
nowrap
|
||||
className="truncate w-full"
|
||||
>
|
||||
{f.name}
|
||||
</Truncated>
|
||||
</div>
|
||||
{onFileClick &&
|
||||
String(f.status).toLowerCase() !== "processing" &&
|
||||
String(f.status).toLowerCase() !== "uploading" && (
|
||||
<IconButton
|
||||
internal
|
||||
icon={SvgExternalLink}
|
||||
tooltip="View file"
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150 p-0 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onFileClick(f);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Text text03 secondaryBody>
|
||||
{(() => {
|
||||
const s = String(f.status || "").toLowerCase();
|
||||
const typeLabel = getFileExtension(f.name);
|
||||
if (s === "processing") return "Processing...";
|
||||
if (s === "uploading") return "Uploading...";
|
||||
if (s === "completed") return typeLabel;
|
||||
return f.status ? f.status : typeLabel;
|
||||
})()}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{f.last_accessed_at && (
|
||||
<Text text03 secondaryBody nowrap>
|
||||
{formatRelativeTime(f.last_accessed_at)}
|
||||
</Text>
|
||||
)}
|
||||
{!showRemove && <div className="p-spacing-inline"></div>}
|
||||
{showRemove &&
|
||||
String(f.status).toLowerCase() !== "processing" && (
|
||||
<IconButton
|
||||
internal
|
||||
icon={SvgTrash}
|
||||
tooltip="Remove from project"
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150 p-0 bg-transparent hover:bg-transparent shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove && onRemove(f);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Text text03 secondaryBody className="px-2 py-4">
|
||||
No files found.
|
||||
</Text>
|
||||
)}
|
||||
</ScrollArea>
|
||||
{/* Fade effect at bottom when scrollable */}
|
||||
{isScrollable && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background via-background/90 to-transparent pointer-events-none z-10 rounded-b-12" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
PopoverContent,
|
||||
PopoverMenu,
|
||||
} from "@/components/ui/popover";
|
||||
import { FiMoreHorizontal } from "react-icons/fi";
|
||||
import { useChatContext } from "@/refresh-components/contexts/ChatContext";
|
||||
import { useCallback, useState, useMemo } from "react";
|
||||
import MoveCustomAgentChatModal from "@/components/modals/MoveCustomAgentChatModal";
|
||||
@@ -27,6 +26,7 @@ import { cn, noProp } from "@/lib/utils";
|
||||
import ConfirmationModal from "@/refresh-components/modals/ConfirmationModal";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { PopoverSearchInput } from "@/sections/sidebar/AppSidebar";
|
||||
import SvgMoreHorizontal from "@/icons/more-horizontal";
|
||||
// Constants
|
||||
const DEFAULT_PERSONA_ID = 0;
|
||||
const LS_HIDE_MOVE_CUSTOM_AGENT_MODAL_KEY = "onyx:hideMoveCustomAgentModal";
|
||||
@@ -233,7 +233,7 @@ export function ChatSessionMorePopup({
|
||||
: "opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<FiMoreHorizontal size={iconSize} />
|
||||
<SvgMoreHorizontal className="stroke-text-02 h-4 w-4" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useProjectsContext,
|
||||
} from "@/app/chat/projects/ProjectsContext";
|
||||
import NavigationTab from "@/refresh-components/buttons/NavigationTab";
|
||||
import Text from "@/refresh-components/Text";
|
||||
import SvgFolder from "@/icons/folder";
|
||||
import SvgEdit from "@/icons/edit";
|
||||
import { PopoverMenu } from "@/components/ui/popover";
|
||||
@@ -17,7 +18,11 @@ import { useAppParams, useAppRouter } from "@/hooks/appNavigation";
|
||||
import SvgFolderPlus from "@/icons/folder-plus";
|
||||
import { ModalIds, useModal } from "@/refresh-components/contexts/ModalContext";
|
||||
import { SEARCH_PARAM_NAMES } from "@/app/chat/services/searchParams";
|
||||
import { noProp } from "@/lib/utils";
|
||||
import { cn, noProp } from "@/lib/utils";
|
||||
import { OpenFolderIcon } from "@/components/icons/CustomIcons";
|
||||
|
||||
import { SvgProps } from "@/icons";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
|
||||
interface ProjectFolderProps {
|
||||
project: Project;
|
||||
@@ -27,12 +32,23 @@ function ProjectFolder({ project }: ProjectFolderProps) {
|
||||
const route = useAppRouter();
|
||||
const params = useAppParams();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] =
|
||||
useState(false);
|
||||
const { renameProject, deleteProject } = useProjectsContext();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [name, setName] = useState(project.name);
|
||||
|
||||
// Make project droppable
|
||||
const dropId = `project-${project.id}`;
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: dropId,
|
||||
data: {
|
||||
type: "project",
|
||||
project,
|
||||
},
|
||||
});
|
||||
|
||||
async function submitRename(renamedValue: string) {
|
||||
const newName = renamedValue.trim();
|
||||
if (newName === "") return;
|
||||
@@ -42,6 +58,29 @@ function ProjectFolder({ project }: ProjectFolderProps) {
|
||||
await renameProject(project.id, newName);
|
||||
}
|
||||
|
||||
// Determine which icon to show based on open/closed state and hover
|
||||
const getFolderIcon = (): React.FunctionComponent<SvgProps> => {
|
||||
if (open) {
|
||||
return isHovering
|
||||
? SvgFolder
|
||||
: (OpenFolderIcon as React.FunctionComponent<SvgProps>);
|
||||
} else {
|
||||
return isHovering
|
||||
? (OpenFolderIcon as React.FunctionComponent<SvgProps>)
|
||||
: SvgFolder;
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
setOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleTextClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
route({ projectId: project.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Confirmation Modal (only for deletion) */}
|
||||
@@ -68,41 +107,48 @@ function ProjectFolder({ project }: ProjectFolderProps) {
|
||||
)}
|
||||
|
||||
{/* Project Folder */}
|
||||
<NavigationTab
|
||||
icon={SvgFolder}
|
||||
active={params(SEARCH_PARAM_NAMES.PROJECT_ID) === String(project.id)}
|
||||
onClick={() => {
|
||||
setOpen((prev) => !prev);
|
||||
route({ projectId: project.id });
|
||||
}}
|
||||
popover={
|
||||
<PopoverMenu>
|
||||
{[
|
||||
<NavigationTab
|
||||
key="rename-project"
|
||||
icon={SvgEdit}
|
||||
onClick={noProp(() => setIsEditing(true))}
|
||||
>
|
||||
Rename Project
|
||||
</NavigationTab>,
|
||||
null,
|
||||
<NavigationTab
|
||||
key="delete-project"
|
||||
icon={SvgTrash}
|
||||
onClick={noProp(() => setDeleteConfirmationModalOpen(true))}
|
||||
danger
|
||||
>
|
||||
Delete Project
|
||||
</NavigationTab>,
|
||||
]}
|
||||
</PopoverMenu>
|
||||
}
|
||||
renaming={isEditing}
|
||||
setRenaming={setIsEditing}
|
||||
submitRename={submitRename}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"transition-colors duration-200",
|
||||
isOver && "bg-background-tint-03 rounded-08"
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</NavigationTab>
|
||||
<NavigationTab
|
||||
icon={getFolderIcon()}
|
||||
active={params(SEARCH_PARAM_NAMES.PROJECT_ID) === String(project.id)}
|
||||
onIconClick={handleIconClick}
|
||||
onIconHover={setIsHovering}
|
||||
onTextClick={handleTextClick}
|
||||
popover={
|
||||
<PopoverMenu>
|
||||
{[
|
||||
<NavigationTab
|
||||
key="rename-project"
|
||||
icon={SvgEdit}
|
||||
onClick={noProp(() => setIsEditing(true))}
|
||||
>
|
||||
Rename Project
|
||||
</NavigationTab>,
|
||||
null,
|
||||
<NavigationTab
|
||||
key="delete-project"
|
||||
icon={SvgTrash}
|
||||
onClick={noProp(() => setDeleteConfirmationModalOpen(true))}
|
||||
danger
|
||||
>
|
||||
Delete Project
|
||||
</NavigationTab>,
|
||||
]}
|
||||
</PopoverMenu>
|
||||
}
|
||||
renaming={isEditing}
|
||||
setRenaming={setIsEditing}
|
||||
submitRename={submitRename}
|
||||
>
|
||||
{name}
|
||||
</NavigationTab>
|
||||
</div>
|
||||
|
||||
{/* Project Chat-Sessions */}
|
||||
{open &&
|
||||
@@ -111,8 +157,16 @@ function ProjectFolder({ project }: ProjectFolderProps) {
|
||||
key={chatSession.id}
|
||||
chatSession={chatSession}
|
||||
project={project}
|
||||
draggable
|
||||
/>
|
||||
))}
|
||||
{open && project.chat_sessions.length === 0 && (
|
||||
<div className="flex justify-center items-center">
|
||||
<Text mainUiMuted text01>
|
||||
No chat sessions yet.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -126,13 +180,15 @@ export default function Projects() {
|
||||
<ProjectFolder key={project.id} project={project} />
|
||||
))}
|
||||
|
||||
<NavigationTab
|
||||
icon={SvgFolderPlus}
|
||||
onClick={() => toggleModal(ModalIds.CreateProjectModal, true)}
|
||||
lowlight
|
||||
>
|
||||
New Project
|
||||
</NavigationTab>
|
||||
{projects.length === 0 && (
|
||||
<NavigationTab
|
||||
icon={SvgFolderPlus}
|
||||
onClick={() => toggleModal(ModalIds.CreateProjectModal, true)}
|
||||
lowlight
|
||||
>
|
||||
New Project
|
||||
</NavigationTab>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,13 +18,15 @@ const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
||||
backgroundColor?: string;
|
||||
overlayClassName?: string;
|
||||
}
|
||||
>(({ className, backgroundColor, ...props }, ref) => (
|
||||
>(({ className, backgroundColor, overlayClassName, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
backgroundColor || "bg-neutral-950/60",
|
||||
"fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
overlayClassName,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -37,28 +39,44 @@ const DialogContent = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
hideCloseIcon?: boolean;
|
||||
backgroundColor?: string;
|
||||
overlayClassName?: string;
|
||||
}
|
||||
>(({ className, children, hideCloseIcon, backgroundColor, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay backgroundColor={backgroundColor} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-neutral-200 bg-neutral-50 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg dark:border-neutral-800 dark:bg-neutral-900",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideCloseIcon && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
hideCloseIcon,
|
||||
backgroundColor,
|
||||
overlayClassName,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
backgroundColor={backgroundColor}
|
||||
overlayClassName={overlayClassName}
|
||||
/>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-neutral-200 bg-neutral-50 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg dark:border-neutral-800 dark:bg-neutral-900",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideCloseIcon && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
|
||||
18
web/src/icons/add-lines.tsx
Normal file
18
web/src/icons/add-lines.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
import type { SVGProps } from "react";
|
||||
const SvgAddLines = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M14 6H2M14 3H2M6 12H2M11.5 9.5V12M11.5 12V14.5M11.5 12H9M11.5 12H14M8.5 9H2"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgAddLines;
|
||||
18
web/src/icons/files.tsx
Normal file
18
web/src/icons/files.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
import type { SVGProps } from "react";
|
||||
const SvgFiles = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M5.5 1.9999H2.33334C1.97971 1.9999 1.64058 2.14038 1.39053 2.39043C1.14048 2.64048 1 2.97961 1 3.33324L1 12.6666C1 13.0202 1.14048 13.3593 1.39052 13.6094C1.64057 13.8594 1.97971 13.9999 2.33333 13.9999L8.33 13.9999C8.68362 13.9999 9.02276 13.8594 9.27281 13.6094C9.52286 13.3593 9.66333 13.0202 9.66333 12.6666L9.66334 6.1699M5.5 1.9999L9.66334 6.1699M5.5 1.9999V6.1699H9.66334M9.16167 1.99988L11.7475 4.58578C12.1226 4.96085 12.3333 5.46956 12.3333 5.99999V12.3332C12.3333 12.9076 12.2107 13.5182 11.9459 14.0032M14.6126 14.0033C14.8773 13.5182 15 12.9077 15 12.3333L15 6.24415C15 4.91915 14.4741 3.64833 13.5377 2.71083L12.8268 1.99991"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgFiles;
|
||||
17
web/src/icons/folder-open.tsx
Normal file
17
web/src/icons/folder-open.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import type { SVGProps } from "react";
|
||||
const SvgFolderOpen = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M15.2089 9.24657L14.4691 9.12327L15.2089 9.24657ZM14.7089 12.2466L15.4487 12.3699V12.3699L14.7089 12.2466ZM1.29109 12.2466L0.551297 12.3699L1.29109 12.2466ZM0.79109 9.24657L0.051294 9.36987L0.79109 9.24657ZM6.93933 2.93934L6.409 3.46967V3.46967L6.93933 2.93934ZM8.06065 4.06066L8.59098 3.53033V3.53033L8.06065 4.06066ZM2.27068 7.49997V8.24997H13.7293V7.49997V6.74997H2.27068V7.49997ZM15.2089 9.24657L14.4691 9.12327L13.9691 12.1233L14.7089 12.2466L15.4487 12.3699L15.9487 9.36987L15.2089 9.24657ZM13.2293 13.5V12.75H2.77068V13.5V14.25H13.2293V13.5ZM1.29109 12.2466L2.03089 12.1233L1.53089 9.12327L0.79109 9.24657L0.051294 9.36987L0.551297 12.3699L1.29109 12.2466ZM2.77068 13.5V12.75C2.40405 12.75 2.09116 12.4849 2.03089 12.1233L1.29109 12.2466L0.551297 12.3699C0.732116 13.4548 1.67079 14.25 2.77068 14.25V13.5ZM14.7089 12.2466L13.9691 12.1233C13.9088 12.4849 13.5959 12.75 13.2293 12.75V13.5V14.25C14.3292 14.25 15.2679 13.4548 15.4487 12.3699L14.7089 12.2466ZM9.12131 4.5V5.25H13V4.5V3.75H9.12131V4.5ZM2.99999 2.5V3.25H5.87867V2.5V1.75H2.99999V2.5ZM6.93933 2.93934L6.409 3.46967L7.53032 4.59099L8.06065 4.06066L8.59098 3.53033L7.46966 2.40901L6.93933 2.93934ZM5.87867 2.5V3.25C6.07759 3.25 6.26835 3.32902 6.409 3.46967L6.93933 2.93934L7.46966 2.40901C7.04771 1.98705 6.47541 1.75 5.87867 1.75V2.5ZM9.12131 4.5V3.75C8.9224 3.75 8.73164 3.67098 8.59098 3.53033L8.06065 4.06066L7.53032 4.59099C7.95228 5.01295 8.52458 5.25 9.12131 5.25V4.5ZM14.5 6H15.25C15.25 4.75736 14.2426 3.75 13 3.75V4.5V5.25C13.4142 5.25 13.75 5.58579 13.75 6H14.5ZM1.49999 4H2.24999C2.24999 3.58579 2.58578 3.25 2.99999 3.25V2.5V1.75C1.75735 1.75 0.749993 2.75736 0.749993 4H1.49999ZM14.5 6H13.75V7.71247H14.5H15.25V6H14.5ZM13.7293 7.49997V8.24997C13.8734 8.24997 14.0034 8.28906 14.1139 8.35544L14.5 7.71247L14.8861 7.0695C14.5487 6.8669 14.1528 6.74997 13.7293 6.74997V7.49997ZM14.5 7.71247L14.1139 8.35544C14.3708 8.50973 14.5217 8.80785 14.4691 9.12327L15.2089 9.24657L15.9487 9.36987C16.1076 8.41651 15.6443 7.52481 14.8861 7.0695L14.5 7.71247ZM1.49999 7.71246H2.24999V4H1.49999H0.749993V7.71246H1.49999ZM2.27068 7.49997V6.74997C1.8472 6.74997 1.45124 6.86689 1.11387 7.06949L1.49999 7.71246L1.88611 8.35543C1.99663 8.28906 2.12662 8.24997 2.27068 8.24997V7.49997ZM1.49999 7.71246L1.11387 7.06949C0.355686 7.5248 -0.107599 8.4165 0.051294 9.36987L0.79109 9.24657L1.53089 9.12327C1.47832 8.80785 1.62918 8.50973 1.88611 8.35543L1.49999 7.71246Z"
|
||||
fill="currentColor"
|
||||
fill-opacity="1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgFolderOpen;
|
||||
@@ -8,7 +8,7 @@ import { SvgProps } from "@/icons";
|
||||
interface LineItemProps {
|
||||
icon?: React.FunctionComponent<SvgProps>;
|
||||
description?: string;
|
||||
children?: string;
|
||||
children?: string | React.ReactNode;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function LineItem({
|
||||
}: LineItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-col w-full justify-center items-start p-spacing-interline hover:bg-background-tint-02 rounded-08"
|
||||
)}
|
||||
@@ -31,9 +32,13 @@ export default function LineItem({
|
||||
<Icon className="h-[1rem] w-[1rem] stroke-text-03" />
|
||||
</div>
|
||||
)}
|
||||
<Text mainUiMuted text04 className="text-left w-full">
|
||||
{children}
|
||||
</Text>
|
||||
{typeof children === "string" ? (
|
||||
<Text mainUiMuted text04 className="text-left w-full">
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<div className="flex flex-row">
|
||||
|
||||
@@ -74,6 +74,9 @@ export interface NavigationTabProps {
|
||||
|
||||
// Button properties:
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onIconClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onIconHover?: (isHovering: boolean) => void;
|
||||
onTextClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
href?: string;
|
||||
tooltip?: boolean;
|
||||
popover?: React.ReactNode;
|
||||
@@ -95,6 +98,9 @@ export default function NavigationTab({
|
||||
lowlight,
|
||||
|
||||
onClick,
|
||||
onIconClick,
|
||||
onIconHover,
|
||||
onTextClick,
|
||||
href,
|
||||
tooltip,
|
||||
popover,
|
||||
@@ -162,9 +168,23 @@ export default function NavigationTab({
|
||||
folded ? "justify-center" : "justify-start"
|
||||
)}
|
||||
>
|
||||
<div className="w-[1rem] h-[1rem]">
|
||||
<div
|
||||
className="w-[1rem] h-[1rem]"
|
||||
onClick={(e) => {
|
||||
if (onIconClick) {
|
||||
e.stopPropagation();
|
||||
onIconClick(e);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => onIconHover?.(true)}
|
||||
onMouseLeave={() => onIconHover?.(false)}
|
||||
>
|
||||
<Icon
|
||||
className={cn("h-[1rem] w-[1rem]", iconClasses(active)[variant])}
|
||||
className={cn(
|
||||
"h-[1rem] w-[1rem] transition-all duration-200 ease-in-out",
|
||||
iconClasses(active)[variant],
|
||||
onIconClick && "cursor-pointer"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{!folded &&
|
||||
@@ -179,16 +199,27 @@ export default function NavigationTab({
|
||||
/>
|
||||
</div>
|
||||
) : typeof children === "string" ? (
|
||||
<Truncated
|
||||
side="right"
|
||||
// We offset the "truncation popover" iff the popover "kebab menu" exists.
|
||||
// This is because the popover would hover OVER the kebab menu, creating a weird UI.
|
||||
// However, if no popover is specified, we don't need to offset anything.
|
||||
offset={!!popover ? 40 : 0}
|
||||
className={cn("text-left", textClasses(active)[variant])}
|
||||
<div
|
||||
className="w-full"
|
||||
onClick={(e) => {
|
||||
if (onTextClick) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onTextClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Truncated>
|
||||
<Truncated
|
||||
side="right"
|
||||
// We offset the "truncation popover" iff the popover "kebab menu" exists.
|
||||
// This is because the popover would hover OVER the kebab menu, creating a weird UI.
|
||||
// However, if no popover is specified, we don't need to offset anything.
|
||||
offset={!!popover ? 40 : 0}
|
||||
className={cn("text-left", textClasses(active)[variant])}
|
||||
>
|
||||
{children}
|
||||
</Truncated>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
))}
|
||||
|
||||
@@ -7,6 +7,8 @@ export enum ModalIds {
|
||||
AgentsModal = "AgentsModal",
|
||||
UserSettingsModal = "UserSettingsModal",
|
||||
CreateProjectModal = "CreateProjectModal",
|
||||
AddInstructionModal = "AddInstructionModal",
|
||||
ProjectFilesModal = "ProjectFilesModal",
|
||||
FeedbackModal = "FeedbackModal",
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
pointerWithin,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
@@ -21,6 +22,12 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import {
|
||||
restrictToFirstScrollableAncestor,
|
||||
restrictToVerticalAxis,
|
||||
} from "@dnd-kit/modifiers";
|
||||
import SvgSidebar from "@/icons/sidebar";
|
||||
import SvgEditBig from "@/icons/edit-big";
|
||||
import SvgMoreHorizontal from "@/icons/more-horizontal";
|
||||
import Settings from "@/sections/sidebar/Settings";
|
||||
@@ -62,10 +69,19 @@ import MoveCustomAgentChatModal from "@/components/modals/MoveCustomAgentChatMod
|
||||
import { UNNAMED_CHAT } from "@/lib/constants";
|
||||
import SidebarWrapper from "@/sections/sidebar/SidebarWrapper";
|
||||
import ShareChatSessionModal from "@/app/chat/components/modal/ShareChatSessionModal";
|
||||
|
||||
// Constants
|
||||
const DEFAULT_PERSONA_ID = 0;
|
||||
const LS_HIDE_MOVE_CUSTOM_AGENT_MODAL_KEY = "onyx:hideMoveCustomAgentModal";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DRAG_TYPES,
|
||||
DEFAULT_PERSONA_ID,
|
||||
LOCAL_STORAGE_KEYS,
|
||||
} from "./constants";
|
||||
import {
|
||||
shouldShowMoveModal,
|
||||
showErrorNotification,
|
||||
handleMoveOperation,
|
||||
} from "./sidebarUtils";
|
||||
|
||||
// Visible-agents = pinned-agents + current-agent (if current-agent not in pinned-agents)
|
||||
// OR Visible-agents = pinned-agents (if current-agent in pinned-agents)
|
||||
@@ -138,9 +154,14 @@ export function PopoverSearchInput({
|
||||
interface ChatButtonProps {
|
||||
chatSession: ChatSession;
|
||||
project?: Project;
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
function ChatButtonInner({ chatSession, project }: ChatButtonProps) {
|
||||
function ChatButtonInner({
|
||||
chatSession,
|
||||
project,
|
||||
draggable = false,
|
||||
}: ChatButtonProps) {
|
||||
const route = useAppRouter();
|
||||
const params = useAppParams();
|
||||
const [name, setName] = useState(chatSession.name || UNNAMED_CHAT);
|
||||
@@ -167,6 +188,19 @@ function ChatButtonInner({ chatSession, project }: ChatButtonProps) {
|
||||
const isChatUsingDefaultAssistant =
|
||||
chatSession.persona_id === DEFAULT_PERSONA_ID;
|
||||
|
||||
// Drag and drop setup for chat sessions
|
||||
const dragId = `${DRAG_TYPES.CHAT}-${chatSession.id}`;
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: dragId,
|
||||
data: {
|
||||
type: DRAG_TYPES.CHAT,
|
||||
chatSession,
|
||||
projectId: project?.id,
|
||||
},
|
||||
disabled: !draggable || renaming,
|
||||
});
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!searchTerm) return projects;
|
||||
const term = searchTerm.toLowerCase();
|
||||
@@ -264,6 +298,8 @@ function ChatButtonInner({ chatSession, project }: ChatButtonProps) {
|
||||
chatSession.id,
|
||||
]);
|
||||
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
async function handleChatDelete() {
|
||||
try {
|
||||
await deleteChatSession(chatSession.id);
|
||||
@@ -280,30 +316,37 @@ function ChatButtonInner({ chatSession, project }: ChatButtonProps) {
|
||||
await refreshChatSessions();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete chat:", error);
|
||||
showErrorNotification(
|
||||
setPopup,
|
||||
"Failed to delete chat. Please try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function performMove(targetProjectId: number) {
|
||||
try {
|
||||
await moveChatSession(targetProjectId, chatSession.id);
|
||||
const projectRefreshPromise = currentProjectId
|
||||
? refreshCurrentProjectDetails()
|
||||
: fetchProjects();
|
||||
await Promise.all([refreshChatSessions(), projectRefreshPromise]);
|
||||
await handleMoveOperation(
|
||||
{
|
||||
chatSession,
|
||||
targetProjectId,
|
||||
refreshChatSessions,
|
||||
refreshCurrentProjectDetails,
|
||||
fetchProjects,
|
||||
currentProjectId,
|
||||
},
|
||||
setPopup
|
||||
);
|
||||
setShowMoveOptions(false);
|
||||
setSearchTerm("");
|
||||
} catch (error) {
|
||||
// handleMoveOperation already handles error notification
|
||||
console.error("Failed to move chat:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChatMove(targetProject: Project) {
|
||||
const hideModal =
|
||||
typeof window !== "undefined" &&
|
||||
window.localStorage.getItem(LS_HIDE_MOVE_CUSTOM_AGENT_MODAL_KEY) ===
|
||||
"true";
|
||||
|
||||
if (!isChatUsingDefaultAssistant && !hideModal) {
|
||||
if (shouldShowMoveModal(chatSession)) {
|
||||
setPendingMoveProjectId(targetProject.id);
|
||||
setShowMoveCustomAgentModal(true);
|
||||
return;
|
||||
@@ -326,8 +369,24 @@ function ChatButtonInner({ chatSession, project }: ChatButtonProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const navTab = (
|
||||
<NavigationTab
|
||||
icon={project ? () => <></> : SvgBubbleText}
|
||||
onClick={() => route({ chatSessionId: chatSession.id })}
|
||||
active={params(SEARCH_PARAM_NAMES.CHAT_ID) === chatSession.id}
|
||||
popover={<PopoverMenu>{popoverItems}</PopoverMenu>}
|
||||
onPopoverChange={(open) => !open && setShowMoveOptions(false)}
|
||||
renaming={renaming}
|
||||
setRenaming={setRenaming}
|
||||
submitRename={submitRename}
|
||||
>
|
||||
{name}
|
||||
</NavigationTab>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{popup}
|
||||
{deleteConfirmationModalOpen && (
|
||||
<ConfirmationModal
|
||||
title="Delete Chat"
|
||||
@@ -359,7 +418,7 @@ function ChatButtonInner({ chatSession, project }: ChatButtonProps) {
|
||||
onConfirm={async (doNotShowAgain: boolean) => {
|
||||
if (doNotShowAgain && typeof window !== "undefined") {
|
||||
window.localStorage.setItem(
|
||||
LS_HIDE_MOVE_CUSTOM_AGENT_MODAL_KEY,
|
||||
LOCAL_STORAGE_KEYS.HIDE_MOVE_CUSTOM_AGENT_MODAL,
|
||||
"true"
|
||||
);
|
||||
}
|
||||
@@ -380,18 +439,23 @@ function ChatButtonInner({ chatSession, project }: ChatButtonProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<NavigationTab
|
||||
icon={project ? () => <></> : SvgBubbleText}
|
||||
onClick={() => route({ chatSessionId: chatSession.id })}
|
||||
active={params(SEARCH_PARAM_NAMES.CHAT_ID) === chatSession.id}
|
||||
popover={<PopoverMenu>{popoverItems}</PopoverMenu>}
|
||||
onPopoverChange={(open) => !open && setShowMoveOptions(false)}
|
||||
renaming={renaming}
|
||||
setRenaming={setRenaming}
|
||||
submitRename={submitRename}
|
||||
>
|
||||
{name}
|
||||
</NavigationTab>
|
||||
{draggable ? (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: transform
|
||||
? `translate3d(0px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{navTab}
|
||||
</div>
|
||||
) : (
|
||||
navTab
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -446,6 +510,46 @@ function AgentsButtonInner({ visibleAgent }: AgentsButtonProps) {
|
||||
|
||||
const AgentsButton = memo(AgentsButtonInner);
|
||||
|
||||
interface RecentsSectionProps {
|
||||
isHistoryEmpty: boolean;
|
||||
chatSessions: ChatSession[];
|
||||
}
|
||||
|
||||
function RecentsSection({ isHistoryEmpty, chatSessions }: RecentsSectionProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: DRAG_TYPES.RECENTS,
|
||||
data: {
|
||||
type: DRAG_TYPES.RECENTS,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef}>
|
||||
<SidebarSection
|
||||
title="Recents"
|
||||
className={cn(
|
||||
"transition-colors duration-200 rounded-08",
|
||||
isOver && "bg-background-tint-03"
|
||||
)}
|
||||
>
|
||||
{isHistoryEmpty ? (
|
||||
<Text text01 className="px-padding-button">
|
||||
Try sending a message! Your chat history will appear here.
|
||||
</Text>
|
||||
) : (
|
||||
chatSessions.map((chatSession) => (
|
||||
<ChatButton
|
||||
key={chatSession.id}
|
||||
chatSession={chatSession}
|
||||
draggable
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SidebarSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableItemProps {
|
||||
id: number;
|
||||
children?: React.ReactNode;
|
||||
@@ -477,8 +581,20 @@ function AppSidebarInner() {
|
||||
const { pinnedAgents, setPinnedAgents, currentAgent } = useAgentsContext();
|
||||
const { folded, setFolded } = useAppSidebarContext();
|
||||
const { toggleModal } = useModal();
|
||||
const { chatSessions } = useChatContext();
|
||||
const { chatSessions, refreshChatSessions } = useChatContext();
|
||||
const combinedSettings = useSettingsContext();
|
||||
const { refreshCurrentProjectDetails, fetchProjects, currentProjectId } =
|
||||
useProjectsContext();
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
// State for custom agent modal
|
||||
const [pendingMoveChatSession, setPendingMoveChatSession] =
|
||||
useState<ChatSession | null>(null);
|
||||
const [pendingMoveProjectId, setPendingMoveProjectId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [showMoveCustomAgentModal, setShowMoveCustomAgentModal] =
|
||||
useState(false);
|
||||
|
||||
const [visibleAgents, currentAgentIsPinned] = useMemo(
|
||||
() => buildVisibleAgents(pinnedAgents, currentAgent),
|
||||
@@ -500,7 +616,8 @@ function AppSidebarInner() {
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
// Handle agent drag and drop
|
||||
const handleAgentDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
@@ -529,6 +646,102 @@ function AppSidebarInner() {
|
||||
[visibleAgentIds, setPinnedAgents, currentAgent, currentAgentIsPinned]
|
||||
);
|
||||
|
||||
// Perform the actual move
|
||||
async function performChatMove(
|
||||
targetProjectId: number,
|
||||
chatSession: ChatSession
|
||||
) {
|
||||
try {
|
||||
await moveChatSession(targetProjectId, chatSession.id);
|
||||
const projectRefreshPromise = currentProjectId
|
||||
? refreshCurrentProjectDetails()
|
||||
: fetchProjects();
|
||||
await Promise.all([refreshChatSessions(), projectRefreshPromise]);
|
||||
} catch (error) {
|
||||
console.error("Failed to move chat:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle chat to project drag and drop
|
||||
const handleChatProjectDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeData = active.data.current;
|
||||
const overData = over.data.current;
|
||||
|
||||
// Check if we're dragging a chat onto a project
|
||||
if (
|
||||
activeData?.type === DRAG_TYPES.CHAT &&
|
||||
overData?.type === DRAG_TYPES.PROJECT
|
||||
) {
|
||||
const chatSession = activeData.chatSession as ChatSession;
|
||||
const targetProject = overData.project as Project;
|
||||
const sourceProjectId = activeData.projectId;
|
||||
|
||||
// Don't do anything if dropping on the same project
|
||||
if (sourceProjectId === targetProject.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hideModal =
|
||||
typeof window !== "undefined" &&
|
||||
window.localStorage.getItem(
|
||||
LOCAL_STORAGE_KEYS.HIDE_MOVE_CUSTOM_AGENT_MODAL
|
||||
) === "true";
|
||||
|
||||
const isChatUsingDefaultAssistant =
|
||||
chatSession.persona_id === DEFAULT_PERSONA_ID;
|
||||
|
||||
if (!isChatUsingDefaultAssistant && !hideModal) {
|
||||
setPendingMoveChatSession(chatSession);
|
||||
setPendingMoveProjectId(targetProject.id);
|
||||
setShowMoveCustomAgentModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await performChatMove(targetProject.id, chatSession);
|
||||
} catch (error) {
|
||||
showErrorNotification(
|
||||
setPopup,
|
||||
"Failed to move chat. Please try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're dragging a chat from a project to the Recents section
|
||||
if (
|
||||
activeData?.type === DRAG_TYPES.CHAT &&
|
||||
overData?.type === DRAG_TYPES.RECENTS
|
||||
) {
|
||||
const chatSession = activeData.chatSession as ChatSession;
|
||||
const sourceProjectId = activeData.projectId;
|
||||
|
||||
// Only remove from project if it was in a project
|
||||
if (sourceProjectId) {
|
||||
try {
|
||||
await removeChatSessionFromProject(chatSession.id);
|
||||
const projectRefreshPromise = currentProjectId
|
||||
? refreshCurrentProjectDetails()
|
||||
: fetchProjects();
|
||||
await Promise.all([refreshChatSessions(), projectRefreshPromise]);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove chat from project:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
currentProjectId,
|
||||
refreshChatSessions,
|
||||
refreshCurrentProjectDetails,
|
||||
fetchProjects,
|
||||
]
|
||||
);
|
||||
|
||||
const isHistoryEmpty = useMemo(
|
||||
() => !chatSessions || chatSessions.length === 0,
|
||||
[chatSessions]
|
||||
@@ -540,9 +753,43 @@ function AppSidebarInner() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{popup}
|
||||
<AgentsModal />
|
||||
<CreateProjectModal />
|
||||
|
||||
{showMoveCustomAgentModal && (
|
||||
<MoveCustomAgentChatModal
|
||||
onCancel={() => {
|
||||
setShowMoveCustomAgentModal(false);
|
||||
setPendingMoveChatSession(null);
|
||||
setPendingMoveProjectId(null);
|
||||
}}
|
||||
onConfirm={async (doNotShowAgain: boolean) => {
|
||||
if (doNotShowAgain && typeof window !== "undefined") {
|
||||
window.localStorage.setItem(
|
||||
LOCAL_STORAGE_KEYS.HIDE_MOVE_CUSTOM_AGENT_MODAL,
|
||||
"true"
|
||||
);
|
||||
}
|
||||
const chat = pendingMoveChatSession;
|
||||
const target = pendingMoveProjectId;
|
||||
setShowMoveCustomAgentModal(false);
|
||||
setPendingMoveChatSession(null);
|
||||
setPendingMoveProjectId(null);
|
||||
if (chat && target != null) {
|
||||
try {
|
||||
await performChatMove(target, chat);
|
||||
} catch (error) {
|
||||
showErrorNotification(
|
||||
setPopup,
|
||||
"Failed to move chat. Please try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarWrapper folded={folded} setFolded={setFolded}>
|
||||
<div className="flex flex-col gap-spacing-interline">
|
||||
<NavigationTab
|
||||
@@ -586,7 +833,7 @@ function AppSidebarInner() {
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragEnd={handleAgentDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={visibleAgentIds}
|
||||
@@ -609,25 +856,38 @@ function AppSidebarInner() {
|
||||
</NavigationTab>
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarSection title="Projects">
|
||||
<Projects />
|
||||
</SidebarSection>
|
||||
|
||||
{/* Recents */}
|
||||
<SidebarSection title="Recents">
|
||||
{isHistoryEmpty ? (
|
||||
<Text text01 className="px-padding-button">
|
||||
Try sending a message! Your chat history will appear here.
|
||||
</Text>
|
||||
) : (
|
||||
chatSessions.map((chatSession) => (
|
||||
<ChatButton
|
||||
key={chatSession.id}
|
||||
chatSession={chatSession}
|
||||
{/* Wrap Projects and Recents in a shared DndContext for chat-to-project drag */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
modifiers={[
|
||||
restrictToFirstScrollableAncestor,
|
||||
restrictToVerticalAxis,
|
||||
]}
|
||||
onDragEnd={handleChatProjectDragEnd}
|
||||
>
|
||||
<SidebarSection
|
||||
title="Projects"
|
||||
action={
|
||||
<IconButton
|
||||
icon={SvgFolderPlus}
|
||||
internal
|
||||
tooltip="New Project"
|
||||
onClick={() =>
|
||||
toggleModal(ModalIds.CreateProjectModal, true)
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SidebarSection>
|
||||
}
|
||||
>
|
||||
<Projects />
|
||||
</SidebarSection>
|
||||
|
||||
{/* Recents */}
|
||||
<RecentsSection
|
||||
isHistoryEmpty={isHistoryEmpty}
|
||||
chatSessions={chatSessions}
|
||||
/>
|
||||
</DndContext>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,22 +14,29 @@ import SvgLightbulbSimple from "@/icons/lightbulb-simple";
|
||||
import { OnyxIcon } from "@/components/icons/icons";
|
||||
import SvgImage from "@/icons/image";
|
||||
import { generateIdenticon } from "@/refresh-components/AgentIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SidebarSectionProps {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SidebarSection({ title, children }: SidebarSectionProps) {
|
||||
export function SidebarSection({
|
||||
title,
|
||||
children,
|
||||
action,
|
||||
className,
|
||||
}: SidebarSectionProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-spacing-inline">
|
||||
<Text
|
||||
secondaryBody
|
||||
text02
|
||||
className="px-spacing-interline sticky top-[0rem] bg-background-tint-02 z-10"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<div className={cn("flex flex-col gap-spacing-inline", className)}>
|
||||
<div className="px-spacing-interline sticky top-[0rem] bg-background-tint-02 z-10 flex flex-row items-center justify-between">
|
||||
<Text secondaryBody text02>
|
||||
{title}
|
||||
</Text>
|
||||
{action && <div className="flex-shrink-0">{action}</div>}
|
||||
</div>
|
||||
<div className="flex flex-col">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
11
web/src/sections/sidebar/constants.ts
Normal file
11
web/src/sections/sidebar/constants.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const DRAG_TYPES = {
|
||||
CHAT: "chat",
|
||||
PROJECT: "project",
|
||||
RECENTS: "recents",
|
||||
} as const;
|
||||
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
HIDE_MOVE_CUSTOM_AGENT_MODAL: "onyx:hideMoveCustomAgentModal",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_PERSONA_ID = 0;
|
||||
56
web/src/sections/sidebar/sidebarUtils.ts
Normal file
56
web/src/sections/sidebar/sidebarUtils.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ChatSession } from "@/app/chat/interfaces";
|
||||
import { LOCAL_STORAGE_KEYS, DEFAULT_PERSONA_ID } from "./constants";
|
||||
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
|
||||
|
||||
export const shouldShowMoveModal = (chatSession: ChatSession): boolean => {
|
||||
const hideModal =
|
||||
typeof window !== "undefined" &&
|
||||
window.localStorage.getItem(
|
||||
LOCAL_STORAGE_KEYS.HIDE_MOVE_CUSTOM_AGENT_MODAL
|
||||
) === "true";
|
||||
|
||||
return !hideModal && chatSession.persona_id !== DEFAULT_PERSONA_ID;
|
||||
};
|
||||
|
||||
type PopupType = "success" | "error" | "info" | "warning";
|
||||
|
||||
type SetPopupFn = (popup: { type: PopupType; message: string }) => void;
|
||||
|
||||
export const showErrorNotification = (
|
||||
setPopup: SetPopupFn,
|
||||
message: string
|
||||
) => {
|
||||
setPopup({ type: "error", message });
|
||||
};
|
||||
|
||||
export interface MoveOperationParams {
|
||||
chatSession: ChatSession;
|
||||
targetProjectId: number;
|
||||
refreshChatSessions: () => Promise<any>;
|
||||
refreshCurrentProjectDetails: () => Promise<any>;
|
||||
fetchProjects: () => Promise<any>;
|
||||
currentProjectId: number | null;
|
||||
}
|
||||
|
||||
export const handleMoveOperation = async (
|
||||
{
|
||||
chatSession,
|
||||
targetProjectId,
|
||||
refreshChatSessions,
|
||||
refreshCurrentProjectDetails,
|
||||
fetchProjects,
|
||||
currentProjectId,
|
||||
}: MoveOperationParams,
|
||||
setPopup: SetPopupFn
|
||||
) => {
|
||||
try {
|
||||
const projectRefreshPromise = currentProjectId
|
||||
? refreshCurrentProjectDetails()
|
||||
: fetchProjects();
|
||||
await Promise.all([refreshChatSessions(), projectRefreshPromise]);
|
||||
} catch (error) {
|
||||
console.error("Failed to perform move operation:", error);
|
||||
showErrorNotification(setPopup, "Failed to move chat. Please try again.");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user