diff --git a/apps/app/.ladle/components.tsx b/apps/app/.ladle/components.tsx index a1bcbe3cb..de4ffc1d9 100644 --- a/apps/app/.ladle/components.tsx +++ b/apps/app/.ladle/components.tsx @@ -6,6 +6,7 @@ import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import { Provider as JotaiProvider, createStore } from "jotai"; import { MemoryRouter } from "react-router-dom"; import { AppToaster } from "../src/components/AppToaster"; +import { TooltipProvider } from "../src/components/ui/tooltip"; import { setPreferredTheme } from "../src/hooks/useTheme"; import { createDiffWorker, @@ -65,10 +66,12 @@ export const Provider: GlobalProvider = ({ globalState, children }) => { }} highlighterOptions={{}} > -
- {children} - -
+ +
+ {children} + +
+
diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts index 476a66387..d84739c24 100644 --- a/apps/app/.ladle/story-fixtures.ts +++ b/apps/app/.ladle/story-fixtures.ts @@ -279,6 +279,7 @@ export function makeThread(overrides: Partial = {}): Thread { providerId: "codex", title: "Audit recurring permission failures", titleFallback: "Audit recurring permission failures", + folderPath: null, status: "idle", parentThreadId: null, sourceThreadId: null, @@ -305,6 +306,7 @@ export function makeThreadListEntry( providerId: "codex", title: "Audit recurring permission failures", titleFallback: "Audit recurring permission failures", + folderPath: null, status: "idle", parentThreadId: null, sourceThreadId: null, diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index dd4829fed..b5c80c426 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -56,9 +56,9 @@ const ProjectSettingsView = lazy(() => default: m.ProjectSettingsView, })), ); -const ProjectArchivedThreadsView = lazy(() => - import("./views/ProjectArchivedThreadsView").then((m) => ({ - default: m.ProjectArchivedThreadsView, +const ArchivedThreadsView = lazy(() => + import("./views/ArchivedThreadsView").then((m) => ({ + default: m.ArchivedThreadsView, })), ); const PopoutChatView = lazy(() => @@ -118,11 +118,11 @@ function AppRoutes() { /> } + element={} /> } + element={} /> void; + onCreate: (path: string) => void; +} + +export interface ThreadFolderRenameDialogTarget { + path: string; +} + +interface ThreadFolderRenameDialogProps { + target: ThreadFolderRenameDialogTarget | null; + pending?: boolean; + onOpenChange: (open: boolean) => void; + onRename: (path: string, newPath: string) => void; +} + +interface ThreadFolderDialogContentProps { + description: string; + initialPath: string; + inputLabel: string; + pending: boolean; + submitLabel: string; + title: string; + onSubmit: (path: string) => void; + inputRef: RefObject; +} + +export function ThreadFolderCreateDialog({ + open, + pending = false, + onOpenChange, + onCreate, +}: ThreadFolderCreateDialogProps) { + const { inputRef, handleOpenAutoFocus } = useRenameDialogAutoFocus(); + return ( + + + {open ? ( + + ) : null} + + + ); +} + +export function ThreadFolderRenameDialog({ + target, + pending = false, + onOpenChange, + onRename, +}: ThreadFolderRenameDialogProps) { + const { inputRef, handleOpenAutoFocus } = useRenameDialogAutoFocus(); + return ( + + + {target ? ( + onRename(target.path, newPath)} + inputRef={inputRef} + /> + ) : null} + + + ); +} + +function ThreadFolderDialogContent({ + description, + initialPath, + inputLabel, + pending, + submitLabel, + title, + onSubmit, + inputRef, +}: ThreadFolderDialogContentProps) { + const inputId = useId(); + const [path, setPath] = useState(initialPath); + const [folderPathMessage, setFolderPathMessage] = useState( + null, + ); + const { validationMessage, validate, clearMessage } = useNameValidation({ + emptyMessage: "Folder name cannot be empty.", + }); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (pending) return; + + const trimmedPath = validate(path); + if (trimmedPath === null) return; + const normalizedPath = normalizeFolderPath(trimmedPath); + if (normalizedPath === null) { + setFolderPathMessage("Folder name cannot be empty."); + return; + } + + onSubmit(normalizedPath); + }; + const displayedMessage = validationMessage ?? folderPathMessage; + + return ( + <> + + {title} + {description} + +
+
+ { + setPath(event.target.value); + setFolderPathMessage(null); + clearMessage(); + }} + /> + {displayedMessage ? ( +

{displayedMessage}

+ ) : null} +
+ + + +
+ + ); +} diff --git a/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx b/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx index 558998bb4..a23f13900 100644 --- a/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx +++ b/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx @@ -22,6 +22,11 @@ const parentTarget: ThreadRenameDialogTarget = { currentTitle: "Frontend Parent", }; +const slashTitleTarget: ThreadRenameDialogTarget = { + id: "thr_folder", + currentTitle: "test/say hi", +}; + const longTitleTarget: ThreadRenameDialogTarget = { id: "thr_long", currentTitle: @@ -55,6 +60,16 @@ export function Overview() { /> + + + + + void; - onRename: (threadId: string, title: string) => void; + onRename: (threadId: string, payload: ThreadRenameDialogPayload) => void; } export function ThreadRenameDialog({ @@ -45,7 +56,7 @@ export function ThreadRenameDialog({ export interface ThreadRenameDialogContentProps { target: ThreadRenameDialogTarget; pending: boolean; - onRename: (threadId: string, title: string) => void; + onRename: (threadId: string, payload: ThreadRenameDialogPayload) => void; inputRef: RefObject; } @@ -69,7 +80,7 @@ export function ThreadRenameDialogContent({ const trimmedTitle = validate(nextTitle); if (trimmedTitle === null) return; - onRename(target.id, trimmedTitle); + onRename(target.id, { title: trimmedTitle }); }; return ( diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index d84c6e0d0..9bd6acc1a 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -387,6 +387,11 @@ export function AppLayout({ children }: AppLayoutProps) { { enabled: isAutomationDetailView }, ); const automationName = automationDetail?.name ?? "Automation"; + // Folder-scoped archived list (`/archived?folder=`); drives the + // breadcrumb's folder segment. + const archivedFolderPath = isArchivedView + ? new URLSearchParams(location.search).get("folder") + : null; const sidebarNavigationQuery = useSidebarNavigation(); const projects = useMemo( () => sidebarNavigationQuery.data?.projects.map(stripProjectThreads), @@ -465,6 +470,9 @@ export function AppLayout({ children }: AppLayoutProps) { subtitle: undefined, breadcrumbs: [ { label: "Threads", to: getRootComposeRoutePath() }, + ...(archivedFolderPath + ? [{ label: archivedFolderPath }] + : []), { label: "Archived" }, ], } @@ -507,7 +515,9 @@ export function AppLayout({ children }: AppLayoutProps) { } if (isArchivedView && projectId) { if (isProjectlessProjectId(projectId)) { - return "Threads · Archived"; + return archivedFolderPath + ? `${archivedFolderPath} · Archived` + : "Threads · Archived"; } return `${projectLabel ?? projectId} · Archived`; } diff --git a/apps/app/src/components/pickers/OptionPicker.tsx b/apps/app/src/components/pickers/OptionPicker.tsx index d0957b5bf..46ccc81ce 100644 --- a/apps/app/src/components/pickers/OptionPicker.tsx +++ b/apps/app/src/components/pickers/OptionPicker.tsx @@ -2,23 +2,25 @@ import type { ComponentType, ReactNode } from "react"; import { Button } from "@/components/ui/button.js"; import { Icon } from "@/components/ui/icon.js"; import { COARSE_POINTER_ICON_SIZE_CLASS } from "@/components/ui/coarse-pointer-sizing.js"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "@/components/ui/dropdown-menu.js"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu.js"; import { cn } from "@/lib/utils"; export const OPTION_BASE_CLASS_NAME = "h-8 w-fit max-w-full min-w-0 items-center justify-start gap-1 px-1 text-xs leading-tight"; -// `data-[state=open]:hover:bg-transparent` overrides the ghost button variant's -// compound selector that otherwise paints bg-state-active when the trigger is -// hovered while the menu is open. Hidden in production by `modal=true` (the -// overlay blocks the hover), but visible whenever a picker is used non-modally -// — fixing it on the shared constant keeps every OPTION-styled trigger correct -// regardless of modal mode. +// Inline picker triggers keep flat resting chrome (no border/background/shadow +// so they sit inline with surrounding text) but use the ghost button variant's +// natural state backgrounds — bg-state-hover on hover and bg-state-active while +// the menu is open — so they read as interactive affordances. export const OPTION_INTERACTIVE_CLASS_NAME = - "border-none bg-transparent shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:hover:bg-transparent"; -export const OPTION_CONTENT_CLASS_NAME = - "flex min-w-0 items-center gap-1.5"; -export const OPTION_TRIGGER_CONTENT_CLASS_NAME = - "contents"; + "border-none bg-transparent shadow-none"; +export const OPTION_CONTENT_CLASS_NAME = "flex min-w-0 items-center gap-1.5"; +export const OPTION_TRIGGER_CONTENT_CLASS_NAME = "contents"; export const OPTION_MUTED_CLASS_NAME = "text-muted-foreground hover:text-foreground"; const OPTION_WARNING_TEXT_CLASS_NAME = "text-warning-text"; @@ -114,7 +116,8 @@ export function OptionDisplay({ )} > - {leading ?? (BrandIcon ? : null)} + {leading ?? + (BrandIcon ? : null)} {label}: {value} @@ -156,7 +159,8 @@ export function OptionPicker({ : selectedOption?.tone; const selectedIsWarning = selectedTone === "warning"; const SelectedIcon = selectedOption?.icon; - const selectedLabel = displayOverride?.label ?? selectedOption?.label ?? value; + const selectedLabel = + displayOverride?.label ?? selectedOption?.label ?? value; const selectedCompactLabel = displayOverride?.compactLabel ?? selectedOption?.compactLabel; const selectedDescription = @@ -165,7 +169,7 @@ export function OptionPicker({ ? displayOverride.title : selectedDescription ? `${label}: ${selectedLabel} - ${selectedDescription}` - : `${label}: ${selectedLabel}`; + : `${label}: ${selectedLabel}`; // The trigger renders identically whether interactive or disabled — the only // difference is the `disabled` button state — so the disabled read-only @@ -192,18 +196,13 @@ export function OptionPicker({ )} > - {SelectedIcon ? ( - - ) : null} + {SelectedIcon ? : null} {selectedCompactLabel ? ( <> {selectedLabel} - + {selectedCompactLabel} @@ -266,7 +265,8 @@ export function OptionPicker({ ) : null} - void; + /** Suppress the trigger's hover tooltip (the sidebar keeps tooltips minimal). */ + hideTriggerTooltip?: boolean; } interface ProjectActionsContextMenuProps extends ProjectActionsMenuBaseProps { @@ -51,6 +58,7 @@ interface ProjectActionsMenuItemsProps extends ProjectActionsMenuBaseProps { interface ProjectActionMenuItemProps { children: ReactNode; className?: string; + icon: IconName; onSelect?: (event: Event) => void; surface: ProjectActionsMenuSurface; } @@ -62,20 +70,29 @@ interface ProjectActionMenuSeparatorProps { function ProjectActionMenuItem({ children, className, + icon, onSelect, surface, }: ProjectActionMenuItemProps) { + // The menu item base styling sizes/spaces a direct child, so the Icon + // renders inline before the label. + const content = ( + <> + + {children} + + ); if (surface === "context") { return ( - {children} + {content} ); } return ( - {children} + {content} ); } @@ -106,6 +123,7 @@ function ProjectActionsMenuItems({ <> { navigate(getProjectSettingsRoutePath(project.id)); }} @@ -114,15 +132,17 @@ function ProjectActionsMenuItems({ { navigate(getProjectArchivedRoutePath(project.id)); }} > - Archived threads + View archive { if (surface === "dropdown") { event.preventDefault(); @@ -135,6 +155,7 @@ function ProjectActionsMenuItems({ {showAddLocalPath ? ( { if (surface === "dropdown") { event.preventDefault(); @@ -147,6 +168,7 @@ function ProjectActionsMenuItems({ ) : null} { if (surface === "dropdown") { @@ -166,29 +188,43 @@ export function ProjectActionsMenu({ triggerClassName, align = "end", onOpenChange, + hideTriggerTooltip = false, }: ProjectActionsMenuProps) { + const trigger = ( + + + + ); return ( - - - - + {hideTriggerTooltip ? ( + trigger + ) : ( + + {trigger} + Project actions + + )} + @@ -203,10 +239,7 @@ export function ProjectActionsContextMenu({ return ( {children} - + diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 0c6529961..c8c864331 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -2265,7 +2265,7 @@ export function PromptBoxInternal({ emitAttachmentFiles(Array.from(event.dataTransfer.files)); }} className={cn( - "relative w-full rounded-lg border border-border bg-background pb-2 shadow-lift", + "relative w-full rounded-xl rounded-l-2xl border border-border bg-background pb-2 shadow-lift", // Zen toggles only the *height* of the box; the inset padding stays // identical so the placeholder/text doesn't jump when toggling. // `flex flex-col` lets the editor's `flex-1` fill the dvh height. diff --git a/apps/app/src/components/promptbox/banner/PromptStackCard.tsx b/apps/app/src/components/promptbox/banner/PromptStackCard.tsx index 42ce3232b..5cbce0ff5 100644 --- a/apps/app/src/components/promptbox/banner/PromptStackCard.tsx +++ b/apps/app/src/components/promptbox/banner/PromptStackCard.tsx @@ -2,7 +2,7 @@ import { type CSSProperties, type ReactNode } from "react"; import { cn } from "@/lib/utils"; const BASE_CHROME = - "rounded-md border border-border bg-surface-recessed"; + "rounded-lg rounded-l-xl border border-border bg-surface-recessed shadow-sm"; export interface PromptStackCardProps { children: ReactNode; diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx index 443566244..8e9a82a45 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx @@ -247,7 +247,10 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ )} aria-hidden="true" /> - +
@@ -435,12 +438,12 @@ export function QueuedMessagesList({ // coming up from behind the composer rather than floating above it. className="-mb-3 overflow-hidden rounded-b-none border-b-0 pb-3" > -
+
); + + return ( + + + {disabled ? {button} : button} + + {title} + + ); } function ProjectListProjectsSectionActions({ @@ -428,58 +586,129 @@ function ProjectListProjectsSectionActions({ } function ProjectListThreadsSectionActions({ + isCreatingFolder, + onNewFolder, onNewThread, }: ProjectListThreadsSectionActionsProps) { return ( - + <> + {onNewFolder ? ( + + ) : null} + + ); } -interface SidebarOrganizeMenuSectionLabelProps { - children: ReactNode; - className?: string; +interface SidebarGroupMenuOptionProps { + disabled?: boolean; + label: string; + selected: boolean; + onSelect: (event: Event) => void; } -function SidebarOrganizeMenuSectionLabel({ - children, - className, -}: SidebarOrganizeMenuSectionLabelProps) { +function SidebarGroupMenuOption({ + disabled = false, + label, + selected, + onSelect, +}: SidebarGroupMenuOptionProps) { return ( - - {children} - + {label} + + ); } -interface SidebarOrganizeMenuOptionProps { +interface SidebarSortMenuOptionProps { + direction: SidebarSortDirection; label: string; selected: boolean; - onSelect: (event: Event) => void; + sort: SidebarChronologicalSort; + // Selecting an inactive field activates it descending; selecting the active + // field flips its direction. + onToggle: (sort: SidebarChronologicalSort) => void; +} + +function SidebarDisplayMenuTrigger({ + ariaLabel, + iconName, + tooltip, +}: { + ariaLabel: string; + iconName: IconName; + tooltip: string; +}) { + return ( + + + + + + + + {tooltip} + + + ); } -function SidebarOrganizeMenuOption({ +function SidebarSortMenuOption({ + direction, label, selected, - onSelect, -}: SidebarOrganizeMenuOptionProps) { + sort, + onToggle, +}: SidebarSortMenuOptionProps) { return ( { + event.preventDefault(); + onToggle(sort); + }} className="flex items-center justify-between gap-3" > {label} + {/* No sort field shows no glyph; the active field shows a single arrow + that points down for descending and up for ascending. */}