'use client'; /** * The workspace's navigation — the one tree for the whole Pro surface. * * There used to be two: this tree on `/workspace`, and a two-row rail beside * an attached session. They are the same navigation and are now the same * component: whatever is open (a course, a conversation, both) is marked * `aria-current` in this tree, and opening something is a click on a row here * rather than a route change to a different shell. * * Shape, deliberately: * - TWO TABS over one body — chat and courses, a segmented control of two * equal halves. The three used to be stacked peer sections, which meant * three heads, three counts and three resting windows competing for one * column; only one of the three is ever the thing you came for. Tabs make * the active list own the rail's height (`workspace-paging` re-tuned with * it); * - the courses tab is ONE TREE, and only FOLDERS are containers in it. A * course that is in no folder is a row at the tree's top level, under the * folders — not a member of an "unfiled" group, which was a head, a count * and a twisty around the absence of filing, and which let one press fold * away the bulk of the list. There is still exactly one unbounded list in * the rail, and therefore one pager; * - course and chat rows are both compact, single-line entries. A course keeps * the stronger name treatment and states its page count at the far edge, * matching the folder/count rhythm above it; run state belongs to the chat * row that is actually running; * - the course list is `useHomeDiscovery`'s `classrooms`, never a second * `GET /api/stages` — a rail that contradicts the grid beside it is worse * than a rail that is one render late; * - long lists reveal one page at a time (`workspace-paging`), because this * account has 139 courses and "reveal everything" is not a navigation; * - the header utilities the classic chrome carries — language, theme, * feedback, community, account — survive at the foot, as the SAME * components SiteHeader mounts, with the two least-pressed behind one ⋯; * - rows are draggable: courses and sessions reorder against a hairline * insertion line, a course dropped on a folder row is filed into it, a * course dropped on the BLANK GROUND at the tree's foot is moved back out to * the top level (Finder's own gesture — the tree lost its "unfiled" row, * and with it the only place a filed course could be dropped to unfile it), * and the resulting sequence is decided entirely by `workspace-order` — this * file only wires the events; * - every row you authored carries the same hover-revealed ⋯: rename edits * the name IN PLACE (the row becomes an input; Enter commits, Escape leaves * it alone) and delete asks first. Folders and courses answer to different * endpoints — `PATCH /api/folders/:id` and `PATCH /api/stages/:id` — but not * to different interactions. * * Collapsed, it becomes a 60px icon strip: an explicit expand button in the * header slot, a new-session button, one glyph per destination (which expands * the rail INTO that destination — the chat tab, the course tab), and the * utilities. The compact header is an action rather than a second brand/home * affordance, so the way back out is visible immediately. * * It folds from its own header — the row with the wordmark and the PRO pill — * with the same `PaneFoldButton` the conversation and the classroom use. It used * to fold from a pill floating in the middle of its seam, on the argument that * the rail had no header; that left one line carrying two meanings, because the * seam is also the rail's width drag. The seam now resizes and nothing else, * like the other two. */ import { useCallback, useEffect, useId, useMemo, useRef, useState, type ComponentProps, type MouseEvent as ReactMouseEvent, type ReactNode, } from 'react'; import { createPortal } from 'react-dom'; import { Check, Folder, FolderOpen, FolderPlus, BookOpen, LoaderCircle, MessagesSquare, MoreHorizontal, Pencil, Plus, RefreshCw, Search, Settings, Trash2, Upload, X, } from 'lucide-react'; import { toast } from 'sonner'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useBrand } from '@/lib/brand/brand-context'; import type { HomeDiscoveryState, useHomeDiscovery } from '@/lib/hooks/use-home-discovery'; import { ProBadge } from '@/components/workbench/ProBadge'; import { LanguageSwitcher } from '@/components/language-switcher'; import { ThemeToggle } from '@/components/site-header/theme-toggle'; import { SettingsDialog } from '@/components/settings'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { cn } from '@/lib/utils/cn'; import { FloatingLayerOwner, installFloatingLayerDismissListeners, } from '@/components/ui/floating-layer-owner'; import { newestFirst, relativeBucket, type ProHomeSessionItem, } from '@/lib/workbench/pro-home-data'; import { filterByName, groupCoursesByFolder } from '@/lib/workbench/workspace-tree'; import { EMPTY_PAGES, RAIL_INITIAL_ROWS, nextPage, pageList, pagesFor, withPages, type PageMap, } from '@/lib/workbench/workspace-paging'; import { applyCustomOrder, parseOrder, reorderIds, serializeOrder, COURSE_ORDER_STORAGE_KEY, SESSION_ORDER_STORAGE_KEY, type DropTarget, } from '@/lib/workbench/workspace-order'; import { RAIL_TAB_STORAGE_KEY, resolveRailTab, type RailTab, } from '@/lib/workbench/workspace-rail-tab'; import { useTreeDrag, type DragKind } from './use-tree-drag'; import { PaneFoldButton } from './PaneFoldButton'; import { deleteWorkspaceSession } from '@/lib/workbench/workspace-actions'; import { apiRenameStage, StageRenameError, STAGE_NAME_MAX_LENGTH } from '@/lib/live/server-api'; import { SESSION_TITLE_MAX_LENGTH, workbenchSessionTitle } from '@/lib/workbench/session-title'; import { displayNameWidth, FOLDER_COUNT_LIMIT, FOLDER_NAME_MAX_WIDTH, validateFolderName, } from '@/lib/utils/folder-name-validation'; import { workspaceFolderAdapter, workspaceFoldersAvailable, WorkspaceFolderNameError, } from './workspace-folder-seam'; type Discovery = ReturnType; type StageListItem = Discovery['classrooms'][number]; /** * Rows a chat list shows before it pages. * * Twelve against the courses' ten (`RAIL_INITIAL_ROWS`). Both numbers grew * with the tab switch — one list on screen instead of three sections sharing * it — and both are still a window, not a dump. */ const SESSION_ROW_LIMIT = 12; export function WorkspaceRail({ courses, sessions, sessionState, onReloadSessions, activeCourseId, activeSessionId, collapsed, onToggleCollapsed, onOpenCourse, onOpenSession, onNewSession, onGoHome, onExitPro, onSessionDeleted, onRenameSession, onDeleteCourse, resizeHandle, }: { readonly courses: Discovery; readonly sessions: readonly ProHomeSessionItem[]; readonly sessionState: HomeDiscoveryState; readonly onReloadSessions: () => void; readonly activeCourseId: string | null; readonly activeSessionId: string | null; readonly collapsed: boolean; readonly onToggleCollapsed: () => void; readonly onOpenCourse: (id: string) => void; readonly onOpenSession: (id: string) => void; /** * A new conversation in the middle column. Both entries below (the compose row * and its collapsed `+`) call this ONE handler, and it deliberately does not * touch the classroom pane — see `startNewConversation` in the shell. */ readonly onNewSession: () => void; /** Back to the bare `/workspace` — the logo, and the shell's own handler. */ readonly onGoHome: () => void; readonly onExitPro: () => void; /** The shell drops the deleted chat's pane and URL param, once the server * confirmed the delete. Rows are already gone optimistically down here. */ readonly onSessionDeleted: (sessionId: string) => void; /** * Name a chat. The shell owns the write (one writer for this row and the * pane header both) and answers with a readable message when it is refused. */ readonly onRenameSession: (sessionId: string, title: string) => Promise; /** The shell deletes the course AND closes its classroom tab on success. */ readonly onDeleteCourse: (courseId: string) => Promise | void; /** The width drag, owned by the shell (it writes the CSS variable on the root). */ readonly resizeHandle: ReactNode; }) { const { t } = useI18n(); const brand = useBrand(); const foldersAvailable = workspaceFoldersAvailable(); const coursesSection = useListSearch(); const sessionsSection = useListSearch(); // The rail survives while the home surface is unmounted, so folder creation // belongs to this tree. It is an inline row at the top of the course tree: // creating organization should not interrupt navigation with a modal. const [newFolderOpen, setNewFolderOpen] = useState(false); const handleCreateFolder = useCallback( async (name: string) => { if (!workspaceFolderAdapter) return; await workspaceFolderAdapter.create(name); courses.reload(); }, [courses], ); // ── Row maintenance: rename in place, delete after a question ──────── // Which row is currently an input. One id each, because two rows cannot be // renamed at once and a rename that survived a tab switch would be an input // hovering over a list it no longer belongs to. const [renamingFolderId, setRenamingFolderId] = useState(null); const [renamingCourseId, setRenamingCourseId] = useState(null); const [renamingSessionId, setRenamingSessionId] = useState(null); /** The folder whose delete has been asked for, and not yet answered. */ const [folderToDelete, setFolderToDelete] = useState<{ id: string; name: string } | null>(null); /** The model/provider settings dialog, opened from the rail's foot cluster. */ const [settingsOpen, setSettingsOpen] = useState(false); /** * Commit a folder rename. Returns null when it landed and a readable message * when the name was refused — the same three refusals the create row maps * (`PATCH /api/folders/:id` answers 409 `FOLDER_NAME_DUPLICATE` / 400 * validation, and the live adapter turns both into `FolderNameError`). * * The duplicate check runs here as well as on the server: a name the local * list already holds should not cost a round trip to be told so. */ const submitFolderRename = useCallback( async (folderId: string, name: string): Promise => { const widthCheck = validateFolderName(name); if (!widthCheck.ok) { return widthCheck.kind === 'empty' ? t('classroom.folderNameEmpty') : t('classroom.folderWidth', { width: widthCheck.width, max: FOLDER_NAME_MAX_WIDTH }); } const trimmed = name.trim(); // Unchanged is not a rename; leave editing without writing anything. if (courses.folders.find((folder) => folder.id === folderId)?.name === trimmed) return null; if ( courses.folders.some( (folder) => folder.id !== folderId && folder.name.toLowerCase() === trimmed.toLowerCase(), ) ) { return t('classroom.folderNameExists'); } try { if (!workspaceFolderAdapter) return t('workspace.foldersUnavailable'); await workspaceFolderAdapter.rename(folderId, trimmed); await courses.reload(); return null; } catch (caught) { if (caught instanceof WorkspaceFolderNameError) { if (caught.kind === 'duplicate') return t('classroom.folderNameExists'); if (caught.kind === 'tooLong') { return t('classroom.folderWidth', { width: displayNameWidth(trimmed), max: FOLDER_NAME_MAX_WIDTH, }); } if (caught.kind === 'empty') return t('classroom.folderNameEmpty'); return t('classroom.folderNameHint'); } return t('classroom.folderRenameFailed'); } }, [courses, t], ); /** * Commit a course rename through `PATCH /api/stages/:id` — the route owns the * owner gate and the write, so this only maps its refusals onto copy. NOT the * storage boundary's `renameStage`: that reads and rewrites the whole document * client-side, which a name change in a list has no reason to do. */ const submitCourseRename = useCallback( async (courseId: string, name: string): Promise => { const trimmed = name.trim(); if (!trimmed || trimmed.length > STAGE_NAME_MAX_LENGTH) { return t('workspace.renameInvalid'); } if (courses.classrooms.find((course) => course.id === courseId)?.name === trimmed) return null; try { await apiRenameStage(courseId, trimmed); // The authoritative list is what every surface reads a course's name // from — the rail row, the classroom tab strip, an in-chat course link. // One reload renames all of them. await courses.reload(); return null; } catch (caught) { if (caught instanceof StageRenameError) { if (caught.kind === 'invalidName') return t('workspace.renameInvalid'); if (caught.kind === 'forbidden') return t('workspace.renameForbidden'); if (caught.kind === 'notFound') return t('workspace.renameGone'); } return t('classroom.renameFailed'); } }, [courses, t], ); /** * Delete a folder, keeping its courses (`mode=ungroup`, the endpoint's * default): they return to the tree's top level. The destructive mode that * also deletes the courses is deliberately NOT offered here — the rail is * navigation, and a container's ⋯ must not be able to destroy work. */ const deleteFolderKeepingCourses = useCallback( async (folderId: string) => { try { if (!workspaceFolderAdapter) return; await workspaceFolderAdapter.removeKeepingCourses(folderId); } catch { toast.error(t('classroom.folderDeleteFailed')); } finally { // Either way: the tree must show what the server actually has. await courses.reload(); } }, [courses, t], ); // ── Which list is on screen ────────────────────────────────────────── // A view preference, read from storage after mount (see the shell's own // collapse state for why never during render), and written on every press. const [tab, setTab] = useState('sessions'); const tabResolved = useRef(false); useEffect(() => { if (tabResolved.current) return; tabResolved.current = true; setTab( resolveRailTab({ stored: readStored(RAIL_TAB_STORAGE_KEY), hasOpenCourse: Boolean(activeCourseId), }), ); }, [activeCourseId]); const selectTab = useCallback((next: RailTab) => { // A press is a preference even before the stored one was read: it must not // be overwritten by a late first-visit resolution. tabResolved.current = true; // An open rename belongs to a row on the tab being left. Leaving it armed // would put an input back into a list the user has since walked away from. if (next !== 'courses') { setNewFolderOpen(false); setRenamingFolderId(null); setRenamingCourseId(null); } if (next !== 'sessions') setRenamingSessionId(null); setTab(next); writeStored(RAIL_TAB_STORAGE_KEY, next); }, []); // Which folders are open. One set keyed by id, so adding a folder costs no // new state. const [openFolders, setOpenFolders] = useState>(() => new Set()); // How many extra pages each capped list has revealed. One integer per list // (see `workspace-paging`); absent means "at rest". const [pages, setPages] = useState(EMPTY_PAGES); const toggleIn = ( set: ReadonlySet, apply: (next: ReadonlySet) => void, id: string, ) => { const next = new Set(set); if (!next.delete(id)) next.add(id); apply(next); }; // ── Manual order ───────────────────────────────────────────────────── // A per-browser view preference (see `workspace-order`): read once on the // client, applied before anything filters or groups, so every sub-list the // tree derives inherits it. const [courseOrder, setCourseOrder] = useState(EMPTY_ORDER); const [sessionOrder, setSessionOrder] = useState(EMPTY_ORDER); const [deletedSessionIds, setDeletedSessionIds] = useState>(() => new Set()); useEffect(() => { // localStorage does not exist during SSR and reading it while rendering // would make the first client paint disagree with the served HTML. setCourseOrder(parseOrder(readStored(COURSE_ORDER_STORAGE_KEY))); setSessionOrder(parseOrder(readStored(SESSION_ORDER_STORAGE_KEY))); }, []); // ── The course tree ────────────────────────────────────────────────── // Sorted once, ordered, then filtered, then grouped. There is no ownership // split to make first: the home/workspace listing is owner-scoped, so every // course in it is the user's own and the whole list is one tree. const sortedCourses = useMemo(() => newestFirst(courses.classrooms), [courses.classrooms]); const orderedCourses = useMemo( () => applyCustomOrder(sortedCourses, courseOrder), [sortedCourses, courseOrder], ); const orderedSessions = useMemo( () => applyCustomOrder(sessions, sessionOrder).filter((item) => !deletedSessionIds.has(item.id)), [deletedSessionIds, sessions, sessionOrder], ); const deleteSession = useCallback( async (sessionId: string) => { setDeletedSessionIds((current) => new Set(current).add(sessionId)); try { const result = await deleteWorkspaceSession(sessionId); if (!result.deleted) throw new Error('session was not found'); onReloadSessions(); // The delete landed: if this was the open chat, the shell drops its // pane and URL param now (not optimistically — a failed delete must // leave everything exactly where it was, with the row restored). onSessionDeleted(sessionId); } catch { setDeletedSessionIds((current) => { const next = new Set(current); next.delete(sessionId); return next; }); toast.error(t('workspace.deleteFailed')); } }, [onReloadSessions, onSessionDeleted, t], ); const courseQuery = coursesSection.query; const searchingCourses = courseQuery.trim().length > 0; const matchedOwned = useMemo( () => filterByName(orderedCourses, courseQuery), [orderedCourses, courseQuery], ); const tree = useMemo( () => groupCoursesByFolder(matchedOwned, courses.folders), [matchedOwned, courses.folders], ); const matchedSessions = useMemo( () => filterByName( // Search what the row SHOWS: a renamed chat has to be findable by the // name the user gave it, not only by its first message. orderedSessions.map((session) => ({ ...session, name: workbenchSessionTitle(session) ?? '', })), sessionsSection.query, ), [orderedSessions, sessionsSection.query], ); // ── Paging × search ────────────────────────────────────────────────── // Typing a query builds a different list under the same list ids, so the // pages a user opened on the unfiltered list must not carry over: five open // pages on 139 courses would otherwise cash themselves in the moment the // query was cleared. Adjusting state during render (React's own recipe for // "derived from a prop that changed") rather than in an effect, so the tree // never paints one frame with the stale window. const querySignature = `${courseQuery.trim()}\u0000${sessionsSection.query.trim()}`; const [pagedFor, setPagedFor] = useState(querySignature); if (pagedFor !== querySignature) { setPagedFor(querySignature); setPages(EMPTY_PAGES); } const openPage = useCallback((id: string, items: readonly unknown[], initial: number) => { setPages((current) => withPages(current, id, nextPage(items, pagesFor(current, id), { initial })), ); }, []); const collapsePage = useCallback((id: string) => { setPages((current) => withPages(current, id, 0)); }, []); // ── Drag to reorder ────────────────────────────────────────────────── // The rail wires events and renders; every decision about the resulting // sequence is made by the pure module. A drop is always expressed relative // to a concrete neighbour, so reordering inside one folder cannot disturb // any other list even though the persisted sequence is flat. const handleReorder = useCallback( (kind: DragKind, dragId: string, target: DropTarget) => { if (kind === 'course') { const next = reorderIds( orderedCourses.map((course) => course.id), dragId, target, ); setCourseOrder(next); writeStoredOrder(COURSE_ORDER_STORAGE_KEY, next); } else { const next = reorderIds( orderedSessions.map((session) => session.id), dragId, target, ); setSessionOrder(next); writeStoredOrder(SESSION_ORDER_STORAGE_KEY, next); } }, [orderedCourses, orderedSessions], ); const handleDropIntoFolder = useCallback( (courseId: string, folderId: string | undefined) => { // Filing belongs to the tree: dropping a course onto a folder row files // it there, and dropping it onto the tree's blank ground (`folderId === // undefined`, decoded from the empty drop zone at the foot) unfiles it. // A drop onto the folder the course already lives in is not a move, and // neither is dropping an already-loose course onto the blank ground. const current = courses.classrooms.find((course) => course.id === courseId)?.folderId; if (current === folderId) return; void courses.moveCourse(courseId, folderId); }, [courses], ); const drag = useTreeDrag({ onReorder: handleReorder, onMoveToFolder: handleDropIntoFolder }); /** * The keyboard's version of the same gesture: Alt+↑/↓ on a focused row moves * it past its neighbour *in the list it is displayed in*, which is what the * user can see. React keys rows by id, so the focused button survives the * move and focus travels with it. */ const moveWithKeyboard = useCallback( (kind: DragKind, id: string, siblings: readonly string[], delta: -1 | 1) => { const index = siblings.indexOf(id); if (index < 0) return; const neighbour = siblings[index + delta]; if (!neighbour) return; handleReorder(kind, id, delta < 0 ? { before: neighbour } : { after: neighbour }); }, [handleReorder], ); // A folder holding the open course opens itself, once: the row must be // reachable after a refresh that restored `?course=` without any clicks. const revealedFor = useRef(null); useEffect(() => { if (!activeCourseId || revealedFor.current === activeCourseId) return; const folderId = courses.classrooms.find((c) => c.id === activeCourseId)?.folderId; revealedFor.current = activeCourseId; if (!folderId) return; // Syncing a disclosure to an external fact (the URL), which is exactly // what an effect is for: the folder is not derivable during render // because the course list arrives asynchronously. setOpenFolders((open) => (open.has(folderId) ? open : new Set(open).add(folderId))); }, [activeCourseId, courses.classrooms]); /** * The collapsed strip's glyphs are DESTINATIONS, not one repeated "expand" * button: each opens the rail onto the thing it depicts. A strip whose three * marks all did the same thing would be describing a structure the expanded * rail no longer has. * * Each still reopens the rail into its destination. The compact header now * carries a separate, unambiguous expand control, while these shortcuts keep * their more specific "expand into this section" behavior. */ const expandInto = (destination: RailTab) => { selectTab(destination); onToggleCollapsed(); }; /** * What a collapsed glyph says it will do — BOTH halves of it. Naming only the * destination ("chat") hid the fact that the press also reopens the rail, * which is the one thing a reader of a 60px strip needs to be told. */ const expandLabel = (section: string) => t('workspace.expandNavInto', { section }); if (collapsed) { return ( ); } const renderCourseRow = (course: StageListItem, siblings: readonly string[]) => { const label = course.name || t('workspace.untitledCourse'); // Being renamed: the row IS the input, in place, at the row's own height. // Not a dialog — the thing being edited is the row you are looking at. if (course.id === renamingCourseId) { return ( submitCourseRename(course.id, name)} onClose={() => setRenamingCourseId(null)} /> ); } return ( moveWithKeyboard('course', course.id, siblings, delta)} // The page count takes the same trailing slot as a folder's count. // On actionable rows the hover-only menu overlays this slot while the // count fades, so the resting layout never reserves an action column. meta={t('workspace.sceneCount', { count: course.sceneCount })} metaTestId={`pro-nav-course-meta-${course.id}`} onClick={() => onOpenCourse(course.id)} trailing={ // Rename and one quiet destructive entry; filing is the tree's drag // interaction, so it is not repeated behind a nested "move" path. setRenamingCourseId(course.id)} onDelete={() => onDeleteCourse(course.id)} /> } /> ); }; const renderCourseList = (list: readonly StageListItem[], listId: string) => { // The sibling sequence the keyboard move walks: the whole sub-list, not // just the rows currently inside the page window. const siblings = list.map((course) => course.id); return ( openPage(listId, list, RAIL_INITIAL_ROWS)} onCollapse={() => collapsePage(listId)} render={(course) => renderCourseRow(course, siblings)} /> ); }; const renderSessionRow = (session: (typeof matchedSessions)[number]) => { const label = workbenchSessionTitle(session) ?? t('workspace.untitledSession'); // Being renamed: the row IS the input, in place, exactly as a course row is. if (session.id === renamingSessionId) { return ( onRenameSession(session.id, name)} onClose={() => setRenamingSessionId(null)} /> ); } return ( moveWithKeyboard( 'session', session.id, matchedSessions.map((item) => item.id), delta, ) } meta={relativeLabel(session.updatedAt || session.createdAt, t)} trailingMark={} statusLabel={t(`workspace.sessionStatus.${session.status}`)} onClick={() => onOpenSession(session.id)} trailing={ setRenamingSessionId(session.id)} onDelete={() => deleteSession(session.id)} /> } /> ); }; return ( ); } /* ── Rail lists ───────────────────────────────────────────────────────── */ interface ListSearch { readonly query: string; readonly setQuery: (value: string) => void; readonly searchOpen: boolean; readonly openSearch: () => void; readonly closeSearch: () => void; } /** * One list's filter state. Called once per list, and per-list is the point: * each tab keeps its own query, so switching tabs does not carry a search * across to a population it was never typed against. * * Collapse used to live here too. It does not any more — the chat and course * lists are collapsed by choosing the OTHER tab. */ function useListSearch(): ListSearch { const [query, setQuery] = useState(''); const [searchOpen, setSearchOpen] = useState(false); return { query, setQuery, searchOpen, openSearch: () => setSearchOpen(true), closeSearch: () => { setSearchOpen(false); setQuery(''); }, }; } /** * A row being renamed, in place. * * The row you are editing IS the row you were looking at: same height, same * indent, same type — a dialog would move the name somewhere else to change it, * and at rail width there is nothing a dialog could show that the row cannot. * Enter commits, Escape leaves the name alone, and a refusal from the server * (a duplicate folder name, a course that is not yours) is shown under the row * with the input still holding what was typed, so it can be fixed rather than * retyped. * * `onSubmit` returns the message to show, or null when the rename landed. */ function InlineNameRow({ testId, initialName, placeholder, ariaLabel, maxLength, leading, inputClassName, allowEmpty = false, onSubmit, onClose, }: { readonly testId: string; readonly initialName: string; readonly placeholder: string; readonly ariaLabel: string; readonly maxLength: number; /** Glyphs the row carries before the name, so the input lines up with it. */ readonly leading?: ReactNode; readonly inputClassName?: string; /** * An empty box is a legal answer. False for a folder or a course, whose names * are required; true for a chat, where clearing the box drops the override * and the title goes back to being derived from the first message. */ readonly allowEmpty?: boolean; readonly onSubmit: (name: string) => Promise; readonly onClose: () => void; }) { const { t } = useI18n(); const [name, setName] = useState(initialName); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const inputRef = useRef(null); const errorId = `${testId}-error`; useEffect(() => { // Selected, not just focused: renaming usually means replacing. inputRef.current?.focus(); inputRef.current?.select(); }, []); const submit = async () => { if (submitting) return; setSubmitting(true); try { const message = await onSubmit(name); if (message) { setError(message); return; } onClose(); } finally { setSubmitting(false); } }; return (
{ event.preventDefault(); void submit(); }} > {leading} { setName(event.target.value); setError(null); }} onKeyDown={(event) => { if (event.key !== 'Escape') return; event.preventDefault(); onClose(); }} placeholder={placeholder} aria-label={ariaLabel} aria-invalid={Boolean(error)} aria-describedby={error ? errorId : undefined} maxLength={maxLength} disabled={submitting} className={cn('ws-inline-folder-input min-w-0 flex-1', inputClassName)} />
{error ? ( ) : null}
); } /** * The blank ground under the tree, as the place a course is dropped to leave * its folder. * * Invisible and inert at rest — it is just the breathing room at the foot of * the list. While a course is being carried it becomes a stated destination * (a dashed outline and one line of copy), and it fills whole the moment the * pointer is over it, which is the same "this is a container, not an insertion * point" language a folder row speaks. */ function TreeGroundDrop({ armed, active, dropProps, }: { readonly armed: boolean; readonly active: boolean; readonly dropProps: Record; }) { const { t } = useI18n(); return (
)} className={cn('ws-ground-drop', armed && 'ws-ground-drop-armed', active && 'ws-drop-into')} > {armed ? {t('workspace.dropToUnfile')} : null}
); } /** A folder row in the course tree while its name is being entered. */ function InlineNewFolderRow({ folders, onCreate, onCancel, }: { readonly folders: readonly { readonly name: string }[]; readonly onCreate: (name: string) => Promise; readonly onCancel: () => void; }) { const { t } = useI18n(); const [name, setName] = useState(''); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); const submit = async () => { if (submitting) return; const widthCheck = validateFolderName(name); if (!widthCheck.ok) { setError( widthCheck.kind === 'empty' ? t('classroom.folderNameEmpty') : t('classroom.folderWidth', { width: widthCheck.width, max: FOLDER_NAME_MAX_WIDTH, }), ); return; } const trimmed = name.trim(); if (folders.some((folder) => folder.name.toLowerCase() === trimmed.toLowerCase())) { setError(t('classroom.folderNameExists')); return; } if (folders.length >= FOLDER_COUNT_LIMIT) { setError(t('classroom.folderCountLimit')); return; } setSubmitting(true); try { await onCreate(trimmed); onCancel(); } catch (caught) { if (caught instanceof WorkspaceFolderNameError) { setError( caught.kind === 'duplicate' ? t('classroom.folderNameExists') : caught.kind === 'tooLong' ? t('classroom.folderWidth', { width: displayNameWidth(trimmed), max: FOLDER_NAME_MAX_WIDTH, }) : caught.kind === 'limit' ? t('classroom.folderCountLimit') : t('classroom.folderNameHint'), ); } else { setError(t('classroom.folderCreateFailed')); } } finally { setSubmitting(false); } }; return (
{ event.preventDefault(); void submit(); }} > {/* The tree's leading slot, with the glyph inside it: a creation row keeps saying WHAT is being created, and its input still starts in the same column as every name in the list. */} { setName(event.target.value); setError(null); }} onKeyDown={(event) => { if (event.key !== 'Escape') return; event.preventDefault(); onCancel(); }} placeholder={t('classroom.folderNamePlaceholder')} aria-label={t('classroom.folderNameLabel')} aria-invalid={Boolean(error)} aria-describedby={error ? 'pro-nav-inline-new-folder-error' : undefined} maxLength={80} disabled={submitting} className="ws-inline-folder-input min-w-0 flex-1" />
{error ? ( ) : null}
); } /** * One tab's list, without a head of its own. * * The tab above it already carries the name, so repeating it here would rebuild * the stacked-section shape this pass replaced. The filter lives in the findrow * under the tab strip (see the rail's render); what is left here is the three * states a list can be in before it has any rows to show. `count` is read, not * rendered — it is how this component knows the list is empty. */ function RailList({ id, title, state, count, emptyLabel, onRetry, children, }: { readonly id: string; /** Names the region for assistive tech; the tab states it on screen. */ readonly title: string; readonly state: HomeDiscoveryState; readonly count: number; readonly emptyLabel: string; readonly onRetry: () => void; readonly children: ReactNode; }) { const { t } = useI18n(); const empty = count === 0; return (
{children} {empty && state === 'loading' ? (
) : null} {empty && state === 'ready' ? : null} {empty && state === 'error' ? ( ) : null}
); } /* ── The tab switch ───────────────────────────────────────────────────── */ export interface RailTabSpec { readonly id: RailTab; readonly label: string; } /** * Chat | courses, over one body — a SEGMENTED CONTROL of two equal halves. * * It used to be an underline strip sized to its own labels and parked at the * left, on the reasoning that a filled segment reads as a mode while tabs read * as views. Two lists that take turns owning the whole body ARE a mode, and at * rail width the underline was the quietest mark in a column of louder ones: * the selected tab got a 2px hairline while every row beneath it could take a * filled pill. So the switch spends the width it has — one track, two halves, * the selected half on a raised solid ground — and becomes the first thing the * eye lands on under the compose button. * * The truncation the old shape was avoiding is real and is paid for in TYPE, * not in width: "COURSES" as a 10px uppercase label with 0.145em of tracking * does not fit in half of a 200px rail, so the label drops the case transform * and the tracking (see `.ws-navtab-label`). Same two words, a third narrower, * and still legible at the narrowest rail. * * NOTHING but the label. Each half used to carry its list's total; a switch is * for choosing which list you are looking at, and how many things are in the * other one is not part of that choice — the totals said nothing you could act * on and were the loudest numerals in the rail. No glyph either, for the same * reason: a two-word text label does not need one. Run state is likewise not * summarized here; it stays on the chat row inside the chat list. */ function RailTabs({ tabs, active, onSelect, }: { readonly tabs: readonly RailTabSpec[]; readonly active: RailTab; readonly onSelect: (tab: RailTab) => void; }) { const { t } = useI18n(); const buttons = useRef(new Map()); const step = (delta: 1 | -1) => { const index = tabs.findIndex((tab) => tab.id === active); if (index < 0) return; // Wraps, as the ARIA tabs pattern expects of a horizontal tablist. const next = tabs[(index + delta + tabs.length) % tabs.length]; if (!next) return; onSelect(next.id); buttons.current.get(next.id)?.focus(); }; return (
{ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); step(event.key === 'ArrowRight' ? 1 : -1); }} > {tabs.map((tab) => { const selected = tab.id === active; return ( ); })}
); } function RailEmpty({ label }: { readonly label: string }) { return

{label}

; } /** * A list that reveals itself one page at a time. * * The arithmetic — how many rows are visible, how many the next press adds, * whether a collapse control belongs beside it — is `workspace-paging`'s; this * only renders it. Page counts are owned by the caller and keyed by `id`, so * every paged list in the rail shares one map instead of inventing its own * state. * * It composes with the two things around it because it touches neither: * - SEARCH filters the array before it arrives here, and the caller resets * every page count when a query changes, so a page window is never a window * onto a list the user has since replaced; * - DRAG-REORDER writes the full flat sequence through `workspace-order`, and * the keyboard move walks the whole sub-list, not the visible slice — so a * row can be moved past the window's edge, exactly as it could past the old * cap, and the persisted order is never a function of what was on screen. */ function PagedRows({ id, items, initial, pages, onMore, onCollapse, render, }: { readonly id: string; readonly items: readonly T[]; readonly initial: number; readonly pages: number; readonly onMore: () => void; readonly onCollapse: () => void; readonly render: (item: T) => ReactNode; }) { const { t } = useI18n(); const { visible, nextCount, canCollapse } = pageList(items, pages, { initial }); return ( <> {visible.map(render)} {nextCount > 0 ? ( ) : null} {canCollapse ? ( ) : null} ); } /** * The ⋯ shared by folder rows, authored course rows and chat rows. * * One menu shape for every maintainable row in the rail: rename first (it is * the one you press on purpose), then the destructive entry. The delete answers * in one of two places — in the menu, as a second press on the same item, or * elsewhere entirely (`deleteAsksElsewhere`) when the consequence needs a * sentence, as a folder's does. */ function WorkspaceRowMenu({ testId, label, onRename, onDelete, deleteAsksElsewhere = false, }: { readonly testId: string; readonly label: string; /** Absent on rows that cannot be renamed (a course somebody else authored). */ readonly onRename?: () => void; readonly onDelete: () => Promise | void; /** True when `onDelete` opens its own confirmation instead of deleting. */ readonly deleteAsksElsewhere?: boolean; }) { const { t } = useI18n(); const [open, setOpen] = useState(false); const [confirming, setConfirming] = useState(false); const [deleting, setDeleting] = useState(false); return ( { setOpen(next); if (!next) setConfirming(false); }} > event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > {onRename ? ( <> { setOpen(false); onRename(); }} > ) : null} { if (deleteAsksElsewhere) { setOpen(false); onDelete(); return; } if (!confirming) { event.preventDefault(); setConfirming(true); return; } setDeleting(true); void Promise.resolve(onDelete()).finally(() => { setDeleting(false); setOpen(false); }); }} > ); } function RailRow({ testId, label, meta, metaTestId, leading, trailing, statusLabel, course = false, rowClassName, labelClassName, active = false, onClick, dragProps, dragging = false, dropEdge = null, onMove, trailingMark, }: { readonly testId?: string; readonly label: string; readonly meta?: string | null; readonly metaTestId?: string; readonly leading?: ReactNode; readonly trailing?: ReactNode; readonly statusLabel?: string; /** Course entries use the rail's tighter artifact row and stronger name. */ readonly course?: boolean; readonly rowClassName?: string; readonly labelClassName?: string; readonly active?: boolean; readonly onClick: () => void; /** `useTreeDrag`'s row hooks — data attributes plus the pointer-down. */ readonly dragProps?: Record; readonly dragging?: boolean; readonly dropEdge?: 'before' | 'after' | null; /** Alt+↑/↓, the keyboard's version of the drag. */ readonly onMove?: (delta: -1 | 1) => void; /** * A mark that belongs with the row's metadata, not before its name — a chat's * status dot / spinner. It renders after `meta` so a chat name and a course * name begin in the same column; a leading mark is what made the two tabs * disagree about where the column is. */ readonly trailingMark?: ReactNode; }) { return ( // The row is a button; `trailing` (a menu) is a sibling, not a child, so // the markup never nests one button inside another.
| undefined)} className={cn( 'ws-row-wrap group relative flex items-center', // Only a row that HAS an action hides its metadata to make room for it. trailing && 'ws-row-wrap-actionable', dragging && 'ws-row-dragging', dropEdge === 'before' && 'ws-drop-before', dropEdge === 'after' && 'ws-drop-after', )} > {trailing ? ( // Its own pointer semantics — a press here opens the menu, it never // starts a drag.
{trailing}
) : null}
); } function SessionDot({ status }: { readonly status: ProHomeSessionItem['status'] }) { // Live states spin; EVERY other status — failed and the rest of the // terminal set — is a static dot, so a dead session can never render a // spinner even before the store's failure-status fix lands. Both marks sit // in the row's TRAILING metadata (`ws-status-slot`), beside the timestamp: a // chat's status is metadata like its time, and a leading mark was what made // every chat name start a slot right of every course name. The box is fixed // width so the spinner and the dot hold identical footprint and a running row // does not shift its neighbours. const live = status === 'running' || status === 'queued'; return (