Compare commits

...

155 Commits

Author SHA1 Message Date
SubashMohan
ec85100c29 fix human message userfile style 2025-10-07 18:13:10 +05:30
SubashMohan
46667f9f55 fix image issue in projects 2025-10-07 16:56:26 +05:30
SubashMohan
a51f193211 fix routing when creating projects and show all the files we are uploading in panel 2025-10-07 16:36:27 +05:30
SubashMohan
18ef4cc095 fix popover spills outside of container 2025-10-07 13:46:04 +05:30
SubashMohan
0e4a0578de allow remove file while processing and do rag if file token length exceeds context length 2025-10-07 13:01:08 +05:30
SubashMohan
d4d0106d8f fix ai comments 2025-10-06 19:55:16 +05:30
SubashMohan
5c7417fa97 merge main with projects-refresh 2025-10-06 17:46:38 +05:30
SubashMohan
185e57d55e fix projects click issue 2025-10-04 16:40:12 +05:30
SubashMohan
e4777338b8 fix build issues 2025-10-04 16:19:58 +05:30
SubashMohan
9e5fea5395 add bg for file selection 2025-10-04 16:06:14 +05:30
SubashMohan
eb39ba9be4 projects ui fixes set2 2025-10-04 16:06:13 +05:30
SubashMohan
34a48b1a15 project chatsessions ui fix 2025-10-04 16:06:13 +05:30
SubashMohan
e8141db66d projects ui fixes set 1 2025-10-04 16:06:11 +05:30
SubashMohan
7027b3e385 make chatsessions draggable 2025-10-04 16:05:08 +05:30
SubashMohan
dd123bb1af projects qa fixes set1 2025-10-04 16:00:01 +05:30
Raunak Bhagat
013648e205 Fix overflow clipping on AgentsModal 2025-10-03 17:02:22 -07:00
Raunak Bhagat
e313fd7431 Use display name for forced tools instead 2025-10-03 16:56:54 -07:00
Raunak Bhagat
fd2cef73ba Make the card styles similar 2025-10-03 16:45:15 -07:00
Raunak Bhagat
1203ae174f Fix share button 2025-10-03 16:27:54 -07:00
Raunak Bhagat
11d784c0a7 Update font usages 2025-10-03 14:11:54 -07:00
Raunak Bhagat
73bb1c5445 Update code to use new font definitions 2025-10-03 13:26:10 -07:00
Raunak Bhagat
4e7ee20406 Update references to old main body font 2025-10-03 13:19:18 -07:00
Raunak Bhagat
c44d3c0332 Update font definitions in globals file 2025-10-03 13:18:43 -07:00
Raunak Bhagat
d599935d4a Update some ConfirmationModals here and there 2025-10-03 12:04:33 -07:00
Raunak Bhagat
c56575d6f9 Fix stupid bug in which the wrong sidebar would show the collapse button 2025-10-03 10:47:40 -07:00
Raunak Bhagat
44c30b45bd Update more buttons 2025-10-02 19:15:34 -07:00
Raunak Bhagat
ca4908f2d1 Edit look of KG Admin page 2025-10-02 18:56:54 -07:00
Raunak Bhagat
e417da31c3 Use proper icon for agents 2025-10-02 17:44:09 -07:00
Raunak Bhagat
944bff8a45 Update AdminSidebar to have the same size logo as the AppSidebar 2025-10-02 17:00:19 -07:00
Raunak Bhagat
885a484900 Rename sidebars 2025-10-02 16:41:09 -07:00
Raunak Bhagat
d21eaa92e4 Fix up logic for sidebars a bit 2025-10-02 16:31:18 -07:00
Raunak Bhagat
423961878e Remove all unnecessary files 2025-10-02 14:41:08 -07:00
Raunak Bhagat
49fdf2cc78 Move all sidebar components to their new appropriate place in the sections dir 2025-10-02 14:00:57 -07:00
Raunak Bhagat
4f3d1466fb Fix AgentIcon and move LLMPopover to refresh-components directory (now that it's cleaned up) 2025-10-02 13:43:34 -07:00
Raunak Bhagat
0d78fefaa4 Add rotating chevron to SelectButton 2025-10-02 13:35:54 -07:00
Raunak Bhagat
192cfd6965 Edit LLMPopover component 2025-10-02 13:12:24 -07:00
Raunak Bhagat
c0858484a4 Use new buttons for FederatedOAuthModal 2025-10-02 12:31:15 -07:00
Raunak Bhagat
873730cb02 Fix tool menu 2025-10-02 11:28:58 -07:00
Raunak Bhagat
acecca0de5 Minor edits to styling of components + add new icon 2025-10-02 10:51:45 -07:00
Raunak Bhagat
9aed84ec66 Add styling for SelectButton 2025-10-02 09:53:10 -07:00
Raunak Bhagat
5e28d86a18 Make function default export 2025-10-01 21:04:04 -07:00
Raunak Bhagat
313e38cf3e Finish more buttons 2025-10-01 19:54:50 -07:00
Raunak Bhagat
f3dcb31b6c Update buttons on /admin/settings page 2025-10-01 19:37:29 -07:00
Raunak Bhagat
b22e16b604 Update one more button again 2025-10-01 19:32:46 -07:00
Raunak Bhagat
43c62cfe5b Update more buttons 2025-10-01 19:30:27 -07:00
Raunak Bhagat
196945378f Use iconbutton instead 2025-10-01 19:16:28 -07:00
Raunak Bhagat
851a14ce68 Fix background colour for dropdowns 2025-10-01 19:11:04 -07:00
Raunak Bhagat
ac7d1e358f Edit styling + logic of ConfirmEntityModal 2025-10-01 19:06:18 -07:00
Raunak Bhagat
b1124fd042 Modify ConfirmEntityModal 2025-10-01 18:50:32 -07:00
Raunak Bhagat
92ef44972d Update CreateButton component 2025-10-01 18:12:05 -07:00
Raunak Bhagat
e00d175b06 Edit more buttons 2025-10-01 16:12:38 -07:00
Raunak Bhagat
289cc2d83a Update create button (location + styling) 2025-10-01 16:05:37 -07:00
Raunak Bhagat
c38078ef67 Edit more buttons 2025-10-01 15:55:29 -07:00
Raunak Bhagat
e08ec11b4f Fix type error 2025-10-01 15:40:10 -07:00
Raunak Bhagat
8fbf06fcdb Update App and Admin sidebars 2025-10-01 15:39:36 -07:00
Raunak Bhagat
f14e450f1e Replace more buttons 2025-10-01 15:09:59 -07:00
Raunak Bhagat
e388c26a60 Update more buttons 2025-10-01 15:02:37 -07:00
Raunak Bhagat
8c2514ccbd Edit more buttons in search-settings sidebar 2025-10-01 14:58:30 -07:00
Raunak Bhagat
0c95af31c7 Update more buttons 2025-10-01 14:28:31 -07:00
Raunak Bhagat
656e1fe7cf Update more of the buttons in the admin panel 2025-10-01 14:24:44 -07:00
Raunak Bhagat
6461638b65 Add new icons 2025-10-01 14:24:19 -07:00
Raunak Bhagat
157ae8e18e Edit more buttons in connector creation flow 2025-10-01 13:58:57 -07:00
Raunak Bhagat
ef0e7984ca Add left and right icons to Button 2025-10-01 13:21:40 -07:00
Raunak Bhagat
74455041be Fix bug in which collapsed thinking steps would interfere with content below it 2025-10-01 11:20:16 -07:00
Raunak Bhagat
769c24272f Add back hover state to ChatDocumentDisplay 2025-10-01 11:09:28 -07:00
Raunak Bhagat
d718dd485d Update basic structuring of DocumentResults 2025-10-01 11:00:40 -07:00
Raunak Bhagat
7b533ef535 Make Regenerate button show again 2025-10-01 09:58:20 -07:00
Raunak Bhagat
3410e5b59b Edit styling of button 2025-10-01 09:57:52 -07:00
Raunak Bhagat
b4e453f3d1 Edit Citation component 2025-10-01 09:47:14 -07:00
Raunak Bhagat
9d62d83c5c Fix up reasoning pills (SourceChip2) 2025-10-01 01:01:55 -07:00
Raunak Bhagat
cf33d1ebb9 Update starter-messages component 2025-09-30 23:49:32 -07:00
Raunak Bhagat
9604ddb089 Edit sources 2025-09-30 22:47:26 -07:00
Raunak Bhagat
73c7cb1aed Update sizing of AI texts 2025-09-30 22:02:42 -07:00
Raunak Bhagat
2fb5ef3a9b Fix up BlinkingDot animation 2025-09-30 21:28:31 -07:00
Raunak Bhagat
cd8bb439bb Fix tooltip colouring 2025-09-30 21:27:17 -07:00
Raunak Bhagat
8f2107f61e Add unnamed chat name to failed chats 2025-09-30 20:44:06 -07:00
Raunak Bhagat
3b42f0556a Edit sidebars and actions page 2025-09-30 20:39:34 -07:00
Raunak Bhagat
59db35cf2d Partially clean up Explorer component 2025-09-30 19:40:45 -07:00
Raunak Bhagat
e6479410c4 Edit AddConnectorPage 2025-09-30 19:30:54 -07:00
Raunak Bhagat
a4c271b5ca Update package-lock 2025-09-30 18:51:31 -07:00
Raunak Bhagat
70dad196ca Update package.json 2025-09-30 18:35:24 -07:00
Raunak Bhagat
ed6272dac6 Update package again? 2025-09-30 18:21:13 -07:00
Raunak Bhagat
4fa1050d4f Update package lock again 2025-09-30 18:18:58 -07:00
Raunak Bhagat
dfdb94269b Update package-lock.json 2025-09-30 18:08:36 -07:00
Raunak Bhagat
692766d1f7 Edit styling for versioning blurb 2025-09-30 18:06:30 -07:00
Raunak Bhagat
0fa48521de Fix up existing-connectors page 2025-09-30 18:03:04 -07:00
Raunak Bhagat
1166697599 Update minor styling of SourceTile components 2025-09-30 17:50:23 -07:00
Raunak Bhagat
a831c54a85 Update styles of refresh-components 2025-09-30 17:50:02 -07:00
Raunak Bhagat
1bbf7211ba Remove unused files 2025-09-30 17:29:51 -07:00
Raunak Bhagat
438b762360 Revert unnecessary changes 2025-09-30 17:19:47 -07:00
Raunak Bhagat
fb54e41337 Add new InputTypeIn component and rename stuff 2025-09-30 17:17:43 -07:00
Raunak Bhagat
5427a7d766 Update package lock 2025-09-30 15:18:59 -07:00
Raunak Bhagat
7d822f6ee9 Rename components-2 directory to refresh-components 2025-09-30 15:17:38 -07:00
Raunak Bhagat
ac98043bad Update AdminSidebar 2025-09-30 15:02:35 -07:00
Raunak Bhagat
f46a27cf22 Merge branch 'main' into refresh 2025-09-30 14:46:17 -07:00
Raunak Bhagat
18535d58d4 Update PopoverMenu rendering 2025-09-30 12:32:09 -07:00
Raunak Bhagat
1707e41683 Merge branch 'main' into refresh 2025-09-30 12:11:40 -07:00
Raunak Bhagat
7e25322bce Fix build errors 2025-09-30 11:29:53 -07:00
Raunak Bhagat
508b9076a7 Merge prod docker compose files 2025-09-30 11:07:48 -07:00
Raunak Bhagat
4df416e482 Remove WelcomeMessage texts 2025-09-30 11:05:04 -07:00
Raunak Bhagat
80444f6bbc Add popover-closing callback to NavigationTab 2025-09-30 10:49:55 -07:00
Raunak Bhagat
23f335f033 Make NavigationTabs shorter 2025-09-30 10:33:07 -07:00
Raunak Bhagat
265eca2195 Edit AgentsModal some more 2025-09-30 09:58:55 -07:00
Raunak Bhagat
2587e5bfb2 Edit shadows for ChatInputBar 2025-09-30 09:21:55 -07:00
Raunak Bhagat
936500ca8b Update tooltips 2025-09-30 08:44:44 -07:00
Raunak Bhagat
bdc6ddea1d Merge remote-tracking branch 'origin/refresh' into refresh 2025-09-30 08:39:07 -07:00
Raunak Bhagat
d1a739c6d4 Edit AgentModal 2025-09-30 08:37:11 -07:00
SubashMohan
ab28f67386 add move projects popup and change modal in chatsession list 2025-09-30 17:46:50 +05:30
Raunak Bhagat
ecfada63bb Update styling of tooltips 2025-09-29 22:36:21 -07:00
Raunak Bhagat
17a1d3b234 Fix weird state persistence issues 2025-09-29 22:28:02 -07:00
Raunak Bhagat
8e0dd12ab3 Saving changes 2025-09-29 22:15:29 -07:00
Raunak Bhagat
a9eb256e6d Fix positioning of ChatInputBar on ChatPage 2025-09-29 20:58:46 -07:00
Raunak Bhagat
17c5d1b740 Update colours for login 2025-09-29 19:33:14 -07:00
Raunak Bhagat
e533e98f9b Edit styling for login pages 2025-09-29 19:04:18 -07:00
Raunak Bhagat
1e3cbc1856 Prevent propagation 2025-09-29 18:31:49 -07:00
Raunak Bhagat
779397d9b8 Remove fallbackAgent 2025-09-29 18:02:01 -07:00
Raunak Bhagat
2ac0133b0b Update lock file 2025-09-29 17:35:16 -07:00
Raunak Bhagat
9b88e778e1 Fix build errors 2025-09-29 17:27:38 -07:00
Raunak Bhagat
7199bb980a Fix build errors 2025-09-29 17:21:40 -07:00
Raunak Bhagat
238518af72 Add callback to dep list 2025-09-29 17:18:35 -07:00
Raunak Bhagat
c11b78cfd1 Fix bug in which deletion of project chat session would NOT delete the session right away 2025-09-29 17:15:51 -07:00
Raunak Bhagat
996cc7265c Minor nits 2025-09-29 15:47:34 -07:00
Raunak Bhagat
103cea9edf Implement folder hiding/showing 2025-09-29 15:35:55 -07:00
Raunak Bhagat
27a745413d Edit chat refreshing to also include project chats 2025-09-29 15:33:15 -07:00
Raunak Bhagat
d9a56b3bd5 Edit how NavigationTab button is selected as being active 2025-09-29 15:22:44 -07:00
Raunak Bhagat
66a779990a Route to new project when created 2025-09-29 14:47:13 -07:00
Raunak Bhagat
14da796a88 Edit folded button to also toggle CreateProjectModal 2025-09-29 14:43:19 -07:00
Raunak Bhagat
81f73ab388 Get rid of dbg-red border 2025-09-29 14:39:19 -07:00
Raunak Bhagat
10e153b420 Use group-hover instead of manual state 2025-09-29 14:38:48 -07:00
Raunak Bhagat
da8f0ff589 Fix navigation 2025-09-29 14:02:27 -07:00
Raunak Bhagat
8c76194cf6 Nit cleanups 2025-09-29 13:01:41 -07:00
Raunak Bhagat
1f9e5e3ac9 Add ability to add new projects 2025-09-29 12:56:21 -07:00
Raunak Bhagat
cf63c61b33 Add new FieldInput component 2025-09-29 12:52:50 -07:00
Raunak Bhagat
446440aec0 Integrate Projects into AppSidebar 2025-09-29 11:50:26 -07:00
Raunak Bhagat
2b3b9b82c2 Edit icons 2025-09-29 08:46:15 -07:00
Raunak Bhagat
621b3e7819 Small nits to AppSidebarContext + new icon 2025-09-29 08:24:20 -07:00
Raunak Bhagat
690734029f Edit tooltip style 2025-09-28 19:34:38 -07:00
Raunak Bhagat
897615da71 Add tooltips to ChatInputBar 2025-09-28 18:48:08 -07:00
Raunak Bhagat
7086afaf6e Add copying utils to HumanMessage 2025-09-28 18:45:09 -07:00
Raunak Bhagat
f02cb76e1d Remove unused variable 2025-09-28 18:35:05 -07:00
Raunak Bhagat
8374fcef63 Update MessageSwitcher 2025-09-28 18:32:33 -07:00
Raunak Bhagat
5b06d0355b Update basic styling for AIMessage 2025-09-28 18:17:50 -07:00
Raunak Bhagat
3e27df819e Update editing message lag (where editedContent wouldn't be reflected) 2025-09-28 18:08:53 -07:00
Raunak Bhagat
9e8ab9e3dc Edit editing section for HumanMessage 2025-09-28 18:02:35 -07:00
Raunak Bhagat
6674cdd516 Update standards for HumanMessage 2025-09-28 15:49:45 -07:00
Raunak Bhagat
b251ea795e Edit icon for ActionToggle 2025-09-28 15:36:20 -07:00
Raunak Bhagat
dcd3f009ee Add AppSidebar 2025-09-28 15:22:29 -07:00
Raunak Bhagat
277065181f Add AppSidebarContext to AppProvider 2025-09-28 13:16:12 -07:00
Raunak Bhagat
7b881dd9a4 Bring over styles from colours branch 2025-09-28 12:57:02 -07:00
Raunak Bhagat
56cd0e6725 Bring in the rest of the colours changes 2025-09-28 12:48:20 -07:00
Raunak Bhagat
a223dc7aea Add ui components + helpers 2025-09-28 12:42:49 -07:00
Raunak Bhagat
c8cc9ee590 Merge branch 'main' into refresh 2025-09-28 12:37:11 -07:00
Raunak Bhagat
e7290385bd Add avatar 2025-09-28 12:35:57 -07:00
Raunak Bhagat
8df45b5950 Add hooks, icons, and SS functions 2025-09-28 12:34:11 -07:00
Raunak Bhagat
2b7d361c73 Add various sections and components 2025-09-28 12:31:32 -07:00
31 changed files with 1567 additions and 1070 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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>
)}
</>
);
}}

View File

@@ -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();

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);

View 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);
}

View File

@@ -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)]
"

View File

@@ -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

View File

@@ -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>

View File

@@ -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&apos;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>
);

View File

@@ -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(

View File

@@ -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">

View File

@@ -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";

View 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>
);
}

View File

@@ -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>
);
}

View 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>
</>
);
}

View File

@@ -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

View File

@@ -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>
)}
</>
);
}

View File

@@ -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 = ({

View 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
View 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;

View 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;

View File

@@ -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">

View File

@@ -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
))}

View File

@@ -7,6 +7,8 @@ export enum ModalIds {
AgentsModal = "AgentsModal",
UserSettingsModal = "UserSettingsModal",
CreateProjectModal = "CreateProjectModal",
AddInstructionModal = "AddInstructionModal",
ProjectFilesModal = "ProjectFilesModal",
FeedbackModal = "FeedbackModal",
}

View File

@@ -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>

View File

@@ -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>
);

View 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;

View 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;
}
};