From 3c8e3de76ea85609d35a79c1ef6d8f3054f58d62 Mon Sep 17 00:00:00 2001 From: Nilay <118994073+NilayYadav@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:32:29 +0530 Subject: [PATCH 1/8] show the mobile sidebar trigger above the chat header (#7267) * Studio: show the mobile sidebar trigger above the chat header * fix * correct z-index --------- Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> --- studio/frontend/src/components/navbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx index 44387f2480..1165705888 100644 --- a/studio/frontend/src/components/navbar.tsx +++ b/studio/frontend/src/components/navbar.tsx @@ -22,7 +22,7 @@ export function Navbar() { ); } return ( -
+
From d437bc56d51cad45f9022db7e28c9585294f7ecc Mon Sep 17 00:00:00 2001 From: Nilay <118994073+NilayYadav@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:32:56 +0530 Subject: [PATCH 2/8] collapse composer pills to icons on narrow screens (#7269) * Studio: wrap composer toolbar chips instead of clipping on narrow screens * Studio: collapse composer pills to icons on narrow screens * Studio: wrap compare composer pills instead of clipping --------- Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> --- .../src/components/assistant-ui/thread.tsx | 17 ++++++++------- .../src/features/chat/shared-composer.tsx | 21 +++++++++++-------- studio/frontend/src/index.css | 2 +- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index bece2930b3..5d22f82f92 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -176,6 +176,7 @@ import { } from "react"; import { create } from "zustand"; import { extractTaggedText, updateThreadMessage } from "@/features/chat/utils/update-thread-message"; +import { useIsMobile } from "@/hooks/use-mobile"; // True while a file is dragged anywhere over the chat page, so the composer // can show its "Drop files here" affordance. @@ -1436,14 +1437,16 @@ const Composer: FC<{ const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat); const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled); // More than 4 pills: collapse to icons only. Search, Code, and permissions - // always show; Images, RAG, Canvas and MCP are conditional. - const pillsCompact = + // always show; Images, RAG, Canvas and MCP are conditional. Narrow viewports + // collapse too: the labelled row is wider than a phone-width composer. + const isMobile = useIsMobile(); + const pillCount = 3 + - (ragEnabled ? 1 : 0) + - (supportsBuiltinImageGeneration ? 1 : 0) + - (artifactsEnabled ? 1 : 0) + - (mcpEnabledForChat ? 1 : 0) > - 4; + (ragEnabled ? 1 : 0) + + (supportsBuiltinImageGeneration ? 1 : 0) + + (artifactsEnabled ? 1 : 0) + + (mcpEnabledForChat ? 1 : 0); + const pillsCompact = isMobile || pillCount > 4; const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const setPendingImageEditReference = useChatRuntimeStore( (s) => s.setPendingImageEditReference, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index a72d21aa6b..3156a6411a 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -98,6 +98,7 @@ import { providerTypeSupportsVision, } from "./external-providers"; import { useExternalProvidersStore } from "./stores/external-providers-store"; +import { useIsMobile } from "@/hooks/use-mobile"; import { PLUS_MENU_ORDER, type PlusMenuItemId, @@ -813,15 +814,17 @@ export function SharedComposer({ const ragDisabled = modelLoaded && (isExternalModel || !supportsTools); const showRagPill = !isExternalModel; // Above 4 pills, collapse to icons only. Compare, Search, Code, and - // permissions always show; the rest are conditional. - const pillsCompact = + // permissions always show; the rest are conditional. Narrow viewports + // collapse too: the labelled row is wider than a phone-width composer. + const isMobile = useIsMobile(); + const pillCount = 4 + - (showImagePill ? 1 : 0) + - (showRagPill && ragEnabled ? 1 : 0) + - (showWebFetchPill ? 1 : 0) + - (artifactsEnabled ? 1 : 0) + - (mcpEnabledForChat ? 1 : 0) > - 4; + (showImagePill ? 1 : 0) + + (showRagPill && ragEnabled ? 1 : 0) + + (showWebFetchPill ? 1 : 0) + + (artifactsEnabled ? 1 : 0) + + (mcpEnabledForChat ? 1 : 0); + const pillsCompact = isMobile || pillCount > 4; // Backwards-compatible alias for call sites still referencing // `toolsDisabled` (rare; both pills used it before). const toolsDisabled = codeDisabled; @@ -1781,7 +1784,7 @@ export function SharedComposer({ />
Date: Tue, 21 Jul 2026 17:27:23 -0700 Subject: [PATCH 3/8] Add project pinning, Projects list view, and project chat-session fixes (#7291) * Add project pinning with sidebar chats, full project chat menu, and new-chat fixes * Fold pinned projects into the Pinned section with chat icons and lighter show-more * Address review: queue guard, HTTP-safe nonce, projects show-more, dialog state resets - Project new-chat composer now shows Stop instead of Queue while running, and the queue click is guarded so a disabled queue cannot enqueue. - Replace crypto.randomUUID with a helper that falls back on non-secure (HTTP LAN) contexts where it is undefined. - Projects page keeps all projects reachable: the grid caps at four with a Show more/Show less toggle instead of hiding the rest. - Reset the rename draft and the delete-files checkbox so a project dialog never opens with stale state. * Address review: dedupe pinned chats, close drawer on nested nav, gate saved-prompt queue, reset delete toggle - Pinned chats that live inside a pinned project now render only nested under the project, not a second time in the flat pinned list. - Opening a chat nested under a pinned project closes the mobile drawer, matching the other sidebar nav handlers. - The saved-prompt Run-list path now honours disableQueue, so running a prompt list from the project new-chat composer cannot queue against an unbound thread. - Reset the delete-files toggle when opening a project delete, since the Cancel button closes the dialog programmatically and skips the onOpenChange reset. * Sidebar: give nested project chats the full options menu and a pin quick-action - Chats nested under a pinned project now render through the shared chat row, so they get the same hover kebab (Rename, Pin, Move, Export, Archive, Delete) plus a pin/unpin quick-action, matching top-level chats. - Show more/less now uses the muted nav token with an override, since the sidebar-nav-btn color rule otherwise won so the label matched the chat rows; it now reads clearly dimmer in both light and dark mode. * Projects list view, pinned-chat promotion, and sidebar polish - Pinning a chat inside a project now promotes it into the pinned chats list and removes it from the project's nested list, so it shows once in the Pinned section. - Sidebar highlights only the open chat, not its parent project folder, while a chat inside the project is active. - Rename project uses the edit icon instead of the compose icon so it no longer matches New chat. - Projects page is now a list: Name and Modified columns, a pin indicator on pinned rows, and the row options menu on hover, replacing the card grid. * Redirect after deleting a project viewed via a thread-only URL commitDelete only redirected when the ?project= param matched the deleted project. On a thread-only URL the project is resolved from the thread into the runtime store, so also compare that resolved id, otherwise the user is left on a deleted thread. * Restore the unpin quick-action on pinned chats Pinned rows in Recents already reserved room for it, but only the kebab rendered. Show the unpin button on hover, left of the options button. * Projects list: alignment, spacing, no icon overlap, fit-to-height paging - Add side padding to the list and more room after the folder icon. - Column header now shares the row layout so Name and Modified line up with the values. - Pin indicator and options button swap by display so they no longer overlap. - Show more only appears once projects exceed the page height and reveals five more per click. * Projects list: align Name to the folder icon and narrow the table - Drop the header leading spacer so Name starts at the folder icon edge; the right-anchored columns keep Modified aligned. - Narrow the page to max-w-4xl so the table is less wide. * Projects list: widen slightly and add top spacing Bump the page to max-w-5xl and increase the top padding so the header sits a little lower. * Projects list: infinite scroll instead of Show more - First page fills the viewport, then a sentinel loads another batch as it scrolls into view, re-observing after each load so it keeps filling. - Search still filters the full project set and shows every match uncapped. * Projects list: drop dividers for a rounded-row hover style Remove the row and header borders and give each row a rounded hover fill so the list reads cleaner, closer to a modern file list. * Projects list: more row spacing and a tighter hover radius Increase row padding so projects sit further apart, and drop the hover corner radius so it reads as a rounded rectangle rather than a pill. * Projects list: more space below the title Increase the list top margin so the header sits further from the rows. * Project landing: narrow slightly and drop the Sources New badge Reduce the landing column to 44rem and remove the New badge from the Sources tab. * Project switcher: rounded-rectangle rows instead of pill The switcher rows are short, so the shared 12px item radius reads as a pill. Scope a smaller radius to this menu so the highlight is a rounded rectangle. * Project landing: add a header options menu Add a kebab menu next to the project title with Rename project, Pin or Unpin project, Export, and Delete project, reusing the existing project actions. * Project switcher: round the scrollbar-side corners Revert the earlier item-radius tweak. The container corners were squared on the scrollbar side because the container itself scrolled; move the scroll to an inner wrapper so the rounded container never scrolls. * Projects: keep row kebab focusable, gate off-route dialogs, refresh history on delete * Projects: fix delete copy, refresh history on landing delete, gate chat-delete dialog off-route --------- Co-authored-by: shimmyshimmer --- .../frontend/src/components/app-sidebar.tsx | 295 ++++++++++- .../src/components/assistant-ui/thread.tsx | 37 +- .../frontend/src/features/chat/chat-page.tsx | 499 +++++++++++++++++- .../chat/components/project-switcher.tsx | 6 +- studio/frontend/src/features/chat/index.ts | 2 + .../src/features/chat/projects-page.tsx | 213 ++++++-- .../chat/stores/pinned-projects-store.ts | 42 ++ 7 files changed, 985 insertions(+), 109 deletions(-) create mode 100644 studio/frontend/src/features/chat/stores/pinned-projects-store.ts diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 8dd7bcdd9a..a13828b06b 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -55,6 +55,7 @@ import { Archive03Icon, ArrowRight02Icon, BadgeInfoIcon, + BubbleChatIcon, ChefHatIcon, CloudIcon, CpuIcon, @@ -108,6 +109,7 @@ import { deleteChatItem, listStoredChatThreads, moveChatItemToProject, + notifyChatHistoryUpdated, renameChatItem, renameChatProject, useChatRuntimeStore, @@ -115,6 +117,7 @@ import { useChatSearchStore, useChatSidebarItems, usePinnedChatsStore, + usePinnedProjectsStore, useChatPreferencesStore, type ProjectRecord, type SidebarItem, @@ -140,7 +143,14 @@ import { } from "@/features/training"; import type { TrainingRunSummary } from "@/features/training"; import { useExportRuntimeStore } from "@/features/export"; -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + Fragment, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { isDownloadCancelled } from "@/lib/native-files"; import { toast } from "@/lib/toast"; import { ShutdownDialog } from "@/components/shutdown-dialog"; @@ -199,6 +209,9 @@ const TestTubeOutlineIcon = TestTube01Icon.slice( type ConversationExportFormat = "raw-jsonl" | "csv" | "sharegpt-jsonl"; +// A pinned project shows this many recent chats before "Show more". +const PINNED_PROJECT_CHAT_LIMIT = 4; + const CHAT_EXPORT_OPTIONS: Array<{ label: string; format: ConversationExportFormat; @@ -445,14 +458,63 @@ export function AppSidebar() { ), [allChatItems, pinnedIdSet], ); - // Pinned chats, in pin order (most recent first). + const [pinnedOpen, setPinnedOpen] = useState(true); + // "Projects" section: projects the user pinned, in pin order (most recent + // first). The section only appears once at least one project is pinned. + const pinnedProjectIds = usePinnedProjectsStore((s) => s.pinnedIds); + const unpinProject = usePinnedProjectsStore((s) => s.unpin); + const pinnedProjectRecords = useMemo(() => { + const byId = new Map(projects.map((p) => [p.id, p])); + return pinnedProjectIds + .map((id) => byId.get(id)) + .filter((p): p is ProjectRecord => Boolean(p)); + }, [projects, pinnedProjectIds]); + // Pinned chats, in pin order (most recent first). Includes chats that live + // inside a project: pinning promotes a chat into this list, and it is removed + // from the project's nested list below so it never shows twice. const pinnedChatItems = useMemo(() => { const byId = new Map(allChatItems.map((item) => [item.id, item])); return pinnedIds .map((id) => byId.get(id)) .filter((item): item is SidebarItem => Boolean(item)); }, [allChatItems, pinnedIds]); - const [pinnedOpen, setPinnedOpen] = useState(true); + // A pinned project reveals its recent chats (most recent first) nested below. + // Pinned chats are excluded here since they render in the pinned-chats list. + const chatsByProjectId = useMemo(() => { + const map = new Map(); + for (const item of allChatItems) { + if (!item.projectId) continue; + if (pinnedIdSet.has(item.id)) continue; + const list = map.get(item.projectId); + if (list) list.push(item); + else map.set(item.projectId, [item]); + } + for (const list of map.values()) + list.sort((a, b) => b.updatedAt - a.updatedAt); + return map; + }, [allChatItems, pinnedIdSet]); + // Default expanded (not collapsed); the row toggles this. Show-more reveals + // chats past the first PINNED_PROJECT_CHAT_LIMIT. + const [collapsedProjectIds, setCollapsedProjectIds] = useState>( + () => new Set(), + ); + const [expandedChatProjectIds, setExpandedChatProjectIds] = useState< + Set + >(() => new Set()); + const toggleProjectCollapsed = (id: string) => + setCollapsedProjectIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + const toggleProjectShowAll = (id: string) => + setExpandedChatProjectIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId); const anyChatRunning = useChatRuntimeStore((s) => @@ -732,6 +794,8 @@ export function AppSidebar() { const shouldDeleteProjectFiles = target.kind === "project" && deleteProjectFiles; setConfirmingDelete(null); + // Reset so the next project delete never inherits this checkbox. + setDeleteProjectFiles(false); if (target.kind === "chat") { await deleteChatWithCleanup(target.item); return; @@ -741,7 +805,20 @@ export function AppSidebar() { await deleteChatProject(target.project.id, { deleteFiles: shouldDeleteProjectFiles, }); - if (activeProjectId === target.project.id) { + // Refresh chat history so the project's reparented chats don't linger + // as stale top-level rows. + notifyChatHistoryUpdated(); + // activeProjectId is only the ?project= param; on a thread-only URL the + // project is resolved from the thread into the runtime store, so check + // that too or we strand the user on a now-deleted thread. Only redirect + // from a chat route: the runtime store value can be stale elsewhere. + const runtimeProjectId = + useChatRuntimeStore.getState().activeProjectId; + if ( + isChatRoute && + (activeProjectId === target.project.id || + runtimeProjectId === target.project.id) + ) { useChatRuntimeStore.getState().setActiveProjectId(null); navigate({ to: "/chat", search: { new: createNavigationNonce() } }); } @@ -828,8 +905,11 @@ export function AppSidebar() { // pl-3 (12px) over the content's pl-1.5 (6px) = 18px, aligning the // title with the nav items above. variant === "project" ? "pl-[39px]" : "pl-3", + // Pinned chats carry a chat icon, so add the nav-item icon gap. + isPinned && variant !== "project" && "gap-[8.5px]", variant === "project" - ? "group-hover/project-chat-item:pr-6 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-6" + ? // Room for the hover pin quick-action plus the kebab. + "group-hover/project-chat-item:pr-14 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8" : isPinned ? // Pinned rows show an extra unpin button on hover, so reserve more room // (pr-8 when the menu is open keeps the unpin button clear of the title). @@ -889,10 +969,43 @@ export function AppSidebar() { closeMobileIfOpen(); }} > + {isPinned && variant !== "project" && ( + + )} {pendingRename?.id === item.id ? pendingRename.title : item.title} + {variant === "project" && ( + + )} + {variant === "recent" && isPinned && ( + + )} + {/* Project options */} + + + + + + openProject(project.id)}> + + Project home + + openNewChat(project.id)}> + + New chat + + { + // Seed the shared draft so the dialog opens + // with the current name, not stale text. + setRenameDraft(project.name); + setRenamingTarget({ + kind: "project", + project, + current: project.name, + }); + }} + > + + Rename project + + unpinProject(project.id)}> + + Unpin project + + + { + // Start each delete with the file toggle off: + // Cancel closes programmatically and skips the + // dialog onOpenChange reset. + setDeleteProjectFiles(false); + setConfirmingDelete({ kind: "project", project }); + }} + > + + Delete project + + + + + {expanded && + visibleChats.map((chat) => + renderChatSidebarItem(chat, "project"), + )} + {expanded && + projectChats.length > PINNED_PROJECT_CHAT_LIMIT && ( + + toggleProjectShowAll(project.id)} + // Force the muted token: .sidebar-nav-btn's own + // color rule outweighs a plain text utility, so + // Show more would otherwise match the chat rows. + className="sidebar-nav-btn h-[30px] rounded-full pl-9 pr-4 font-medium text-nav-fg-muted!" + > + + {showAll ? "Show less" : "Show more"} + + + + )} + + ); + })} + {pinnedChatItems.map((item) => + renderChatSidebarItem(item, "recent"), + )} + + + + + + )} {!isStudioRoute && !showTrainingRecents && ( diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 5d22f82f92..adab56582b 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -1371,7 +1371,13 @@ export const ProjectComposer: FC<{ }> = ({ disabled, placeholder }) => { return ( - + {/* New chat in a project: queuing follow-ups here misbinds the thread, + so the queue only runs once the user is inside a chat session. */} + ); }; @@ -1381,11 +1387,17 @@ const ComposerAnimated: FC<{ placeholder?: string; threadId?: string | null; menuSide?: "top" | "bottom"; -}> = ({ disabled, threadId, menuSide }) => { + disableQueue?: boolean; +}> = ({ disabled, threadId, menuSide, disableQueue }) => { return (
- +
); @@ -1420,7 +1432,8 @@ const Composer: FC<{ placeholder?: string; threadId?: string | null; menuSide?: "top" | "bottom"; -}> = ({ disabled, threadId, menuSide }) => { + disableQueue?: boolean; +}> = ({ disabled, threadId, menuSide, disableQueue }) => { const aui = useAui(); const pageDragging = useContext(PageDragContext); const { overlay, closeOverlay } = useGeneratedImageOverlay(); @@ -1729,6 +1742,11 @@ const Composer: FC<{ if (threadIsRunning || promptQueueActive) { event.preventDefault(); + // Project new-chat composer: never queue, just ask the user to wait. + if (disableQueue) { + toast.error("Wait for the current response to finish"); + return; + } if (!canQueueCurrentPrompt) { if (overlay || hasAttachments || hasPendingAudio) { toast.error( @@ -1806,6 +1824,7 @@ const Composer: FC<{ composerText, createPromptQueueTarget, disabled, + disableQueue, hasAttachments, hasPendingAudio, interceptSend, @@ -1825,9 +1844,12 @@ const Composer: FC<{ const startQueue = useCallback( (items: string[], waitForCurrentRun = threadIsRunning) => { + // Saved-prompt Run-list calls this directly, so honour disableQueue here + // too: queuing from the project new-chat composer misbinds the thread. + if (disableQueue) return; startPromptQueue(items, createPromptQueueTarget(), waitForCurrentRun); }, - [createPromptQueueTarget, threadIsRunning], + [createPromptQueueTarget, threadIsRunning, disableQueue], ); const queueContextValue: PromptQueueCallbacks = { startQueue, stopQueue }; @@ -1887,8 +1909,11 @@ const Composer: FC<{ isComposing || hasPendingAttachments } - queueDisabled={!canQueueCurrentPrompt} + // disableQueue (project new-chat composer) also blocks the queue + // button, so a running thread shows Stop instead of Queue. + queueDisabled={disableQueue || !canQueueCurrentPrompt} onQueueClick={() => { + if (disableQueue) return; const queuedPrompt = composerText.trim(); if (queuedPrompt.length === 0) { return; diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index ef018445e0..ea5b3f724e 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -21,8 +21,31 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { ResizableHandle, ResizablePanel, @@ -46,14 +69,23 @@ import { } from "@/features/native-intents"; import { GuidedTour, useGuidedTourController } from "@/features/tour"; import { isTauri } from "@/lib/api-base"; +import { isDownloadCancelled } from "@/lib/native-files"; import { toast } from "@/lib/toast"; import { cn } from "@/lib/utils"; import { + Archive03Icon, BubbleChatTemporaryIcon, + Delete02Icon, + Download01Icon, Edit03Icon, + Folder01Icon, Folder02Icon, + FolderExportIcon, LayoutAlignRightIcon, + MoreHorizontalIcon, MoreVerticalIcon, + PinIcon, + PinOffIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useNavigate } from "@tanstack/react-router"; @@ -71,7 +103,7 @@ import { useState, } from "react"; import type { PanelImperativeHandle } from "react-resizable-panels"; -import { listLocalModels } from "./api/chat-api"; +import { listLocalModels, notifyChatHistoryUpdated } from "./api/chat-api"; import { ArtifactSurface } from "./artifacts/artifact-surface"; import { clearAutoOpenedArtifacts, @@ -90,12 +122,21 @@ import { } from "./external-providers"; import { useChatModelRuntime } from "./hooks/use-chat-model-runtime"; import type { SelectedModelInput } from "./hooks/use-chat-model-runtime"; -import { useChatProjects } from "./hooks/use-chat-projects"; +import { + deleteChatProject, + moveChatItemToProject, + renameChatProject, + useChatProjects, +} from "./hooks/use-chat-projects"; import { type SidebarItem, + archiveChatItem, + deleteChatItem, renameChatItem, useChatSidebarItems, } from "./hooks/use-chat-sidebar-items"; +import { usePinnedChatsStore } from "./stores/pinned-chats-store"; +import { usePinnedProjectsStore } from "./stores/pinned-projects-store"; import { clearTrainingCompareHandoff, getTrainingCompareHandoff, @@ -876,6 +917,47 @@ function formatProjectChatDate(timestamp: number): string { }).format(new Date(timestamp)); } +// Unique thread nonce; falls back off crypto.randomUUID for non-secure +// (HTTP LAN) contexts where it is unavailable. +function createThreadNonce(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +// Chat export formats, mirroring the sidebar chat menu. +type ProjectChatExportFormat = "raw-jsonl" | "csv" | "sharegpt-jsonl"; +const PROJECT_CHAT_EXPORT_OPTIONS: Array<{ + label: string; + format: ProjectChatExportFormat; +}> = [ + { label: "Raw JSONL", format: "raw-jsonl" }, + { label: "CSV", format: "csv" }, + { label: "ShareGPT JSONL", format: "sharegpt-jsonl" }, +]; + +async function exportProjectConversation( + threadId: string, + format: ProjectChatExportFormat, +): Promise { + const exports = await import("./prompt-storage/prompt-storage-dialog"); + if (format === "raw-jsonl") return exports.exportConversationRawJsonl(threadId); + if (format === "csv") return exports.exportConversationCsv(threadId); + return exports.exportConversationShareGPT(threadId); +} + +async function exportProjectChatItem( + item: SidebarItem, + format: ProjectChatExportFormat, +): Promise { + const ids = + item.type === "single" + ? [item.id] + : (await listStoredChatThreads({ pairId: item.id })).map((t) => t.id); + for (const id of ids) await exportProjectConversation(id, format); +} + function extractMessageText(content: MessageRecord["content"]): string { if (typeof content === "string") { return content; @@ -910,6 +992,9 @@ function ProjectLanding({ items: SidebarItem[]; }): ReactElement { const navigate = useNavigate(); + // Gates body-portaled surfaces so they can't linger or act while the landing + // is off-route (e.g. behind another tab). + const active = useChatActive(); const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const initialActiveThreadRef = useRef(null); const [projectTab, setProjectTab] = useState<"chats" | "sources">("chats"); @@ -917,7 +1002,7 @@ function ProjectLanding({ null, ); const [newThreadNonce, setNewThreadNonce] = useState(() => - crypto.randomUUID(), + createThreadNonce(), ); const [previews, setPreviews] = useState< Record @@ -936,13 +1021,65 @@ function ProjectLanding({ title: string; } | null>(null); + // Project-level options (the header kebab menu). + const pinnedProjectIds = usePinnedProjectsStore((s) => s.pinnedIds); + const togglePinProject = usePinnedProjectsStore((s) => s.togglePin); + const projectPinned = pinnedProjectIds.includes(projectId); + const [renamingProject, setRenamingProject] = useState(false); + const [projectNameDraft, setProjectNameDraft] = useState(""); + const [deletingProject, setDeletingProject] = useState(false); + + async function handleProjectExport( + format: ProjectChatExportFormat, + ): Promise { + try { + const threads = await listStoredChatThreads({ + projectId, + includeArchived: false, + }); + const ids = [...new Set(threads.map((t) => t.id))]; + for (const id of ids) await exportProjectConversation(id, format); + } catch (error) { + if (!isDownloadCancelled(error)) toast.error("Export failed."); + } + } + + async function commitProjectRename(): Promise { + const name = projectNameDraft.trim(); + setRenamingProject(false); + if (!name || name === projectName) return; + try { + await renameChatProject(projectId, name); + } catch (err) { + toast.error("Failed to rename project", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + async function commitProjectDelete(): Promise { + setDeletingProject(false); + try { + await deleteChatProject(projectId); + // Refresh chat history so the project's now-deleted chats don't linger + // in the sidebar, matching the sidebar delete path. + notifyChatHistoryUpdated(); + useChatRuntimeStore.getState().setActiveProjectId(null); + navigate({ to: "/chat", search: { new: createThreadNonce() } }); + } catch (err) { + toast.error("Failed to delete project", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + useEffect(() => { initialActiveThreadRef.current = useChatRuntimeStore.getState().activeThreadId; useChatRuntimeStore.getState().setActiveThreadId(null); useChatRuntimeStore.getState().setContextUsage(null); setPendingNewThreadId(null); - setNewThreadNonce(crypto.randomUUID()); + setNewThreadNonce(createThreadNonce()); setRenamingId(null); setPendingRename(null); }, [projectId]); @@ -977,16 +1114,98 @@ function ProjectLanding({ [renameDraft], ); + // Full chat actions, matching the sidebar chat menu. + const { projects } = useChatProjects(); + const pinnedChatIds = usePinnedChatsStore((s) => s.pinnedIds); + const togglePinnedChat = usePinnedChatsStore((s) => s.togglePin); + const confirmDeleteChats = useChatPreferencesStore( + (s) => s.confirmDeleteChats, + ); + const pinnedChatIdSet = useMemo( + () => new Set(pinnedChatIds), + [pinnedChatIds], + ); + const [confirmingDelete, setConfirmingDelete] = useState( + null, + ); + + // Landing has no active thread selected, so the onView callback here is a + // no-op; the items list refreshes itself once storage emits its update. + const noopView = useCallback(() => {}, []); + + const handleArchive = useCallback( + async (item: SidebarItem) => { + try { + await archiveChatItem(item, activeThreadId ?? undefined, noopView); + } catch (err) { + toast.error("Failed to archive chat", { + description: err instanceof Error ? err.message : undefined, + }); + } + }, + [activeThreadId, noopView], + ); + + const runDelete = useCallback( + async (item: SidebarItem) => { + try { + await deleteChatItem(item, activeThreadId ?? undefined, noopView); + } catch (err) { + toast.error("Failed to delete chat", { + description: err instanceof Error ? err.message : undefined, + }); + } + }, + [activeThreadId, noopView], + ); + + const handleDelete = useCallback( + (item: SidebarItem) => { + if (confirmDeleteChats) setConfirmingDelete(item); + else void runDelete(item); + }, + [confirmDeleteChats, runDelete], + ); + + const handleMoveToProject = useCallback( + async (item: SidebarItem, targetId: string | null) => { + try { + await moveChatItemToProject(item, targetId); + } catch (err) { + toast.error("Failed to move chat", { + description: err instanceof Error ? err.message : undefined, + }); + } + }, + [], + ); + + const handleExport = useCallback( + async (item: SidebarItem, format: ProjectChatExportFormat) => { + try { + await exportProjectChatItem(item, format); + } catch (error) { + if (!isDownloadCancelled(error)) toast.error("Export failed."); + } + }, + [], + ); + useEffect(() => { if (!activeThreadId) { - setPendingNewThreadId(null); + // Leaving a created chat for a new one: rotate the nonce so the runtime + // switches to a fresh thread instead of appending to the old chat. + if (pendingNewThreadId) { + setNewThreadNonce(createThreadNonce()); + setPendingNewThreadId(null); + } return; } if (activeThreadId === initialActiveThreadRef.current) { return; } setPendingNewThreadId(activeThreadId); - }, [activeThreadId]); + }, [activeThreadId, pendingNewThreadId]); useEffect(() => { let cancelled = false; @@ -1050,8 +1269,8 @@ function ProjectLanding({ } as CSSProperties } > - {/* 46rem matches the composer so every block shares the same edges. */} -
+ {/* Slightly narrower than the composer max; every block shares this. */} +
-

+

{projectName}

+ + + + + + { + setProjectNameDraft(projectName); + setRenamingProject(true); + }} + > + + Rename project + + togglePinProject(projectId)}> + + {projectPinned ? "Unpin project" : "Pin project"} + + + + + Export + + + {PROJECT_CHAT_EXPORT_OPTIONS.map(({ label, format }) => ( + void handleProjectExport(format)} + > + {label} + + ))} + + + + setDeletingProject(true)} + > + + Delete project + + +
setProjectTab("sources")} data-active={projectTab === "sources"} - className="flex h-10 items-center gap-1.5 rounded-full px-5 text-[14px] font-semibold transition-colors data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover" + className="h-10 rounded-full px-5 text-[14px] font-semibold transition-colors data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover" > Sources - - New -
@@ -1148,11 +1419,6 @@ function ProjectLanding({ aria-label="Rename chat" className="w-full border-0 bg-transparent text-[15px] font-semibold leading-5 text-foreground outline-none" /> - {preview?.snippet ? ( -
- {preview.snippet} -
- ) : null}
); @@ -1179,11 +1445,6 @@ function ProjectLanding({
{displayTitle}
- {preview?.snippet ? ( -
- {preview.snippet} -
- ) : null}
{preview?.date ?? @@ -1209,7 +1470,7 @@ function ProjectLanding({ side="bottom" align="end" sideOffset={4} - className="unsloth-plus-menu w-56" + className="unsloth-plus-menu menu-flat-destructive w-56" > openRename(item)}> Rename + togglePinnedChat(item.id)} + > + + + {pinnedChatIdSet.has(item.id) + ? "Unpin chat" + : "Pin chat"} + + + + + + Move to project + + + + void handleMoveToProject(item, null) + } + > + Recents + + {projects.map((p) => ( + + void handleMoveToProject(item, p.id) + } + > + + {p.name} + + ))} + + + + + + Export + + + {PROJECT_CHAT_EXPORT_OPTIONS.map( + ({ label, format }) => ( + + void handleExport(item, format) + } + > + {label} + + ), + )} + + + + void handleArchive(item)} + > + + Archive + + handleDelete(item)} + > + + Delete + @@ -1229,6 +1590,96 @@ function ProjectLanding({ )} + { + if (!open) setConfirmingDelete(null); + }} + > + + + Delete chat + + This permanently deletes "{confirmingDelete?.title}". This cannot + be undone. + + + + Cancel + { + const target = confirmingDelete; + setConfirmingDelete(null); + if (target) void runDelete(target); + }} + > + Delete + + + + + { + if (!open) setRenamingProject(false); + }} + > + + + Rename project + + setProjectNameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void commitProjectRename(); + } + }} + autoFocus={true} + maxLength={120} + placeholder="Project name" + aria-label="Project name" + className="focus-visible:border-input focus-visible:ring-0" + /> + + + + + + + { + if (!open) setDeletingProject(false); + }} + > + + + Delete project + + Delete "{projectName}"? Its chats will be permanently deleted. + + + + Cancel + void commitProjectDelete()}> + Delete + + + + ); } diff --git a/studio/frontend/src/features/chat/components/project-switcher.tsx b/studio/frontend/src/features/chat/components/project-switcher.tsx index 13360ccf7e..8f923a8c80 100644 --- a/studio/frontend/src/features/chat/components/project-switcher.tsx +++ b/studio/frontend/src/features/chat/components/project-switcher.tsx @@ -75,8 +75,11 @@ export function ProjectSwitcher({ side="bottom" align="start" sideOffset={0} - className="unsloth-plus-menu ring-0 min-w-56 max-w-72 max-h-72 font-heading" + className="unsloth-plus-menu ring-0 min-w-56 max-w-72 font-heading" > + {/* Scroll the list here, not the container, so the rounded corners on + the scrollbar side are not squared off. */} +
{showLoadingRow ? ( Loading… @@ -117,6 +120,7 @@ export function ProjectSwitcher({ View all projects +
); diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index c5b24bbb55..335421e145 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -17,6 +17,7 @@ export { listRecommendedFolders, listScanFolders, loadModel, + notifyChatHistoryUpdated, removeScanFolder, revealCachedModel, type BrowseFoldersResponse, @@ -53,6 +54,7 @@ export { export { PermissionModeDropdown } from "./permission-mode-select"; export { useChatSearchStore } from "./stores/chat-search-store"; export { usePinnedChatsStore } from "./stores/pinned-chats-store"; +export { usePinnedProjectsStore } from "./stores/pinned-projects-store"; export { useChatPreferencesStore } from "./stores/chat-preferences-store"; export { PLUS_MENU_ORDER, diff --git a/studio/frontend/src/features/chat/projects-page.tsx b/studio/frontend/src/features/chat/projects-page.tsx index ee94a9537f..c9960ffaca 100644 --- a/studio/frontend/src/features/chat/projects-page.tsx +++ b/studio/frontend/src/features/chat/projects-page.tsx @@ -39,6 +39,7 @@ import { renameChatProject, useChatProjects, useChatRuntimeStore, + usePinnedProjectsStore, type ProjectRecord, } from "@/features/chat"; import { @@ -47,13 +48,15 @@ import { Edit03Icon, Folder02Icon, FolderAddIcon, + PinIcon, + PinOffIcon, Search01Icon, Upload01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { MoreHorizontalIcon } from "lucide-react"; import { useNavigate } from "@tanstack/react-router"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { exportProjectConversations, exportBulkConversationsMerged, @@ -68,21 +71,38 @@ import { type SortMode = "activity" | "name"; -function formatUpdatedAgo(ts: number): string { - const diff = Date.now() - ts; - if (!Number.isFinite(diff) || diff < 0) return "just now"; - const s = Math.floor(diff / 1000); - if (s < 60) return "just now"; - const m = Math.floor(s / 60); - if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`; - const h = Math.floor(m / 60); - if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`; - const d = Math.floor(h / 24); - if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`; - const mo = Math.floor(d / 30); - if (mo < 12) return `${mo} month${mo === 1 ? "" : "s"} ago`; - const y = Math.floor(mo / 12); - return `${y} year${y === 1 ? "" : "s"} ago`; +// Reveal this many more projects each time the user scrolls near the bottom. +const PROJECTS_PAGE_STEP = 12; +// Visible count before the fit-to-height measurement runs. +const PROJECTS_INITIAL_FALLBACK = 8; +// Approx list row height in px, used to estimate how many rows fit the page. +const PROJECTS_ROW_HEIGHT = 68; + +// Modified column, matching a file-list feel: Today / Yesterday / N days ago, +// then a short date once it is over a week old. +function formatModified(ts: number): string { + if (!Number.isFinite(ts)) return ""; + const now = new Date(); + const then = new Date(ts); + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ).getTime(); + const startOfThen = new Date( + then.getFullYear(), + then.getMonth(), + then.getDate(), + ).getTime(); + const dayDiff = Math.round((startOfToday - startOfThen) / 86_400_000); + if (dayDiff <= 0) return "Today"; + if (dayDiff === 1) return "Yesterday"; + if (dayDiff < 7) return `${dayDiff} days ago`; + return then.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: then.getFullYear() === now.getFullYear() ? undefined : "numeric", + }); } export function ProjectsPage() { @@ -91,6 +111,17 @@ export function ProjectsPage() { const [query, setQuery] = useState(""); const [sortMode, setSortMode] = useState("activity"); + // Rows that fit the page height (measured), plus any revealed via Show more. + const [baseFit, setBaseFit] = useState(PROJECTS_INITIAL_FALLBACK); + const [extraCount, setExtraCount] = useState(0); + const listRef = useRef(null); + const sentinelRef = useRef(null); + const pinnedProjectIds = usePinnedProjectsStore((s) => s.pinnedIds); + const togglePinProject = usePinnedProjectsStore((s) => s.togglePin); + const pinnedProjectIdSet = useMemo( + () => new Set(pinnedProjectIds), + [pinnedProjectIds], + ); const [creating, setCreating] = useState(false); const [nameDraft, setNameDraft] = useState(""); @@ -162,7 +193,7 @@ export function ProjectsPage() { await handleImport(file, target); } - const visibleProjects = useMemo(() => { + const sortedProjects = useMemo(() => { const trimmed = query.trim().toLowerCase(); const filtered = trimmed ? projects.filter((p) => p.name.toLowerCase().includes(trimmed)) @@ -174,6 +205,51 @@ export function ProjectsPage() { ); return filtered; }, [projects, query, sortMode]); + // Default view shows as many rows as fit the page, then loads more as the + // user scrolls near the bottom. Search always spans every project. + const isSearching = query.trim() !== ""; + const visibleCount = baseFit + extraCount; + const visibleProjects = isSearching + ? sortedProjects + : sortedProjects.slice(0, visibleCount); + const hasMore = !isSearching && sortedProjects.length > visibleCount; + + // Estimate how many rows fit below the list's top so the first page fills the + // screen without loading everything up front. + useEffect(() => { + function measure() { + const el = listRef.current; + if (!el) return; + const top = el.getBoundingClientRect().top; + const reserve = 24; // bottom breathing room + const fits = Math.floor( + (window.innerHeight - top - reserve) / PROJECTS_ROW_HEIGHT, + ); + setBaseFit(Math.max(PROJECTS_PAGE_STEP, fits)); + } + measure(); + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + }, [hasLoaded]); + + // Infinite scroll: reveal another page-step whenever the sentinel near the + // list bottom scrolls into view. + useEffect(() => { + const el = sentinelRef.current; + if (!el || !hasMore) return; + const io = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setExtraCount((n) => n + PROJECTS_PAGE_STEP); + } + }, + { rootMargin: "300px" }, + ); + io.observe(el); + return () => io.disconnect(); + // Re-observe after each load so it keeps filling while the sentinel stays + // in view (IntersectionObserver does not re-fire on a steady intersection). + }, [hasMore, visibleCount]); function openProject(projectId: string) { const runtime = useChatRuntimeStore.getState(); @@ -274,7 +350,7 @@ export function ProjectsPage() { } return ( -
+
{/* Global import file input */} {!hasLoaded ? ( -
+
+
+ Name + Modified + +
{Array.from({ length: 6 }).map((_, index) => (
- - - - + + + + +
))}
@@ -440,9 +522,20 @@ export function ProjectsPage() { )}
) : ( -
- {visibleProjects.map((project) => ( -
+ <> +
+ {/* Column header. Name starts at the folder icon's left edge; the + right-anchored columns keep Modified over its values. */} +
+ Name + Modified + +
+
+ {visibleProjects.map((project) => { + const pinned = pinnedProjectIdSet.has(project.id); + return ( +
-
- - - + + + + + {project.name} + + + {formatModified(project.updatedAt)} + +
+ {/* Pin fades out and the kebab fades in on hover, focus, or + menu open. Absolute + opacity gating keeps them from + overlapping while leaving the button keyboard-focusable. */} + {pinned && ( + + + + )} @@ -498,6 +605,16 @@ export function ProjectsPage() { onKeyDown={(e) => e.stopPropagation()} className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0" > + togglePinProject(project.id)} + > + + {pinned ? "Unpin project" : "Pin project"} + { setRenameDraft(project.name); @@ -546,21 +663,15 @@ export function ProjectsPage() {
-

- {project.name} -

- {project.instructions ? ( -

- {project.instructions} -

- ) : null} - - Updated {formatUpdatedAgo(project.updatedAt)} -
- ))} + ); + })} + {/* Loads the next page-step when scrolled into view. */} + {hasMore &&
} +
+ )} {/* Create project */} @@ -684,8 +795,8 @@ export function ProjectsPage() { Delete project

- Are you sure you want to delete {deleting?.name}? Chats in this - project will be moved back to Recents. + Are you sure you want to delete {deleting?.name}? Its chats will + be permanently deleted.