From 98f89008106befeaa237d38d828e5bdb559c64cc Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 20:44:50 +1100 Subject: [PATCH] fix(web): show sidebar startup loading state --- apps/web/src/components/Sidebar.browser.tsx | 210 +++++++ apps/web/src/components/Sidebar.tsx | 659 ++++++++++---------- apps/web/vitest.browser.config.ts | 1 + 3 files changed, 553 insertions(+), 317 deletions(-) create mode 100644 apps/web/src/components/Sidebar.browser.tsx diff --git a/apps/web/src/components/Sidebar.browser.tsx b/apps/web/src/components/Sidebar.browser.tsx new file mode 100644 index 000000000..16b11472f --- /dev/null +++ b/apps/web/src/components/Sidebar.browser.tsx @@ -0,0 +1,210 @@ +import "../index.css"; + +import type { NativeApi, OrchestrationReadModel, ServerConfig } from "@t3tools/contracts"; +import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { page } from "vitest/browser"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { render } from "vitest-browser-react"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { getRouter } from "../router"; +import { useStore } from "../store"; + +const NOW_ISO = "2026-03-11T00:00:00.000Z"; +const DESKTOP_VIEWPORT = { + width: 1280, + height: 900, +}; + +const EMPTY_SNAPSHOT: OrchestrationReadModel = { + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: NOW_ISO, +}; + +const SERVER_CONFIG: ServerConfig = { + cwd: "/repo/project", + keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", + keybindings: [], + issues: [], + providers: [ + { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: NOW_ISO, + }, + ], + availableEditors: [], +}; + +function createNativeApiStub(): NativeApi { + return { + dialogs: { + pickFolder: async () => null, + confirm: async () => false, + }, + terminal: { + open: async () => { + throw new Error("Not implemented in Sidebar.browser test"); + }, + write: async () => undefined, + resize: async () => undefined, + clear: async () => undefined, + restart: async () => { + throw new Error("Not implemented in Sidebar.browser test"); + }, + close: async () => undefined, + onEvent: () => () => undefined, + }, + projects: { + searchEntries: async () => ({ entries: [], truncated: false }), + writeFile: async () => ({ relativePath: "notes.txt" }), + }, + shell: { + openInEditor: async () => undefined, + openExternal: async () => undefined, + }, + git: { + listBranches: async () => ({ + isRepo: false, + hasOriginRemote: false, + branches: [], + }), + createWorktree: async () => { + throw new Error("Not implemented in Sidebar.browser test"); + }, + removeWorktree: async () => undefined, + createBranch: async () => undefined, + checkout: async () => undefined, + init: async () => undefined, + resolvePullRequest: async () => { + throw new Error("Not implemented in Sidebar.browser test"); + }, + preparePullRequestThread: async () => { + throw new Error("Not implemented in Sidebar.browser test"); + }, + pull: async () => ({ + status: "skipped_up_to_date", + branch: "main", + upstreamBranch: null, + }), + status: async () => ({ + branch: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + runStackedAction: async () => ({ + action: "commit", + branch: { + status: "skipped_not_requested", + }, + commit: { + status: "skipped_no_changes", + }, + push: { + status: "skipped_not_requested", + }, + pr: { + status: "skipped_not_requested", + }, + }), + }, + contextMenu: { + show: async () => null, + }, + server: { + getConfig: async () => SERVER_CONFIG, + upsertKeybinding: async () => ({ + keybindings: [], + issues: [], + }), + }, + orchestration: { + getSnapshot: async () => EMPTY_SNAPSHOT, + dispatchCommand: async () => ({ sequence: 1 }), + getTurnDiff: async () => { + throw new Error("Not implemented in Sidebar.browser test"); + }, + getFullThreadDiff: async () => { + throw new Error("Not implemented in Sidebar.browser test"); + }, + replayEvents: async () => [], + onDomainEvent: () => () => undefined, + }, + }; +} + +async function mountApp(): Promise<{ cleanup: () => Promise }> { + await page.viewport(DESKTOP_VIEWPORT.width, DESKTOP_VIEWPORT.height); + + const host = document.createElement("div"); + host.style.position = "fixed"; + host.style.inset = "0"; + host.style.width = "100vw"; + host.style.height = "100vh"; + host.style.display = "grid"; + host.style.overflow = "hidden"; + document.body.append(host); + + const router = getRouter(createMemoryHistory({ initialEntries: ["/"] })); + const screen = await render(, { container: host }); + + return { + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("Sidebar startup state", () => { + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = ""; + window.nativeApi = createNativeApiStub(); + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + useStore.setState({ + projects: [], + threads: [], + threadsHydrated: false, + }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("shows a loading spinner before hydration, then falls back to the empty state", async () => { + const mounted = await mountApp(); + + try { + await expect.element(page.getByText("Loading projects and threads")).toBeInTheDocument(); + await expect + .element(page.getByText("Restoring your workspace from the server.")) + .toBeInTheDocument(); + await expect.element(page.getByText("No projects yet")).not.toBeInTheDocument(); + + useStore.setState({ + projects: [], + threads: [], + threadsHydrated: true, + }); + + await expect.element(page.getByText("No projects yet")).toBeInTheDocument(); + await expect.element(page.getByText("Loading projects and threads")).not.toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b68c3b80..7f7092f3d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -64,6 +64,7 @@ import { import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { Collapsible, CollapsibleContent } from "./ui/collapsible"; +import { Spinner } from "./ui/spinner"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -255,6 +256,7 @@ function SortableProjectItem({ export default function Sidebar() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); + const threadsHydrated = useStore((store) => store.threadsHydrated); const markThreadUnread = useStore((store) => store.markThreadUnread); const toggleProject = useStore((store) => store.toggleProject); const reorderProjects = useStore((store) => store.reorderProjects); @@ -1158,6 +1160,7 @@ export default function Sidebar() { shortcutLabelForCommand(keybindings, "chat.new"), [keybindings], ); + const showSidebarStartupState = !threadsHydrated; const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -1408,330 +1411,352 @@ export default function Sidebar() { )} - - - project.id)} - strategy={verticalListSortingStrategy} + {showSidebarStartupState ? ( +
+
- {projects.map((project) => { - const projectThreads = threads - .filter((thread) => thread.projectId === project.id) - .toSorted((a, b) => { - const byDate = - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - if (byDate !== 0) return byDate; - return b.id.localeCompare(a.id); - }); - const isThreadListExpanded = expandedThreadListsByProject.has(project.id); - const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; - const visibleThreads = - hasHiddenThreads && !isThreadListExpanded - ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) - : projectThreads; - const orderedProjectThreadIds = projectThreads.map((t) => t.id); - - return ( - - {(dragHandleProps) => ( - -
- handleProjectTitleClick(event, project.id)} - onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} - onContextMenu={(event) => { - event.preventDefault(); - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - - - - {project.name} - - - - +

+ Loading projects and threads +

+

+ Restoring your workspace from the server. +

+
+
+ ) : ( + <> + + + project.id)} + strategy={verticalListSortingStrategy} + > + {projects.map((project) => { + const projectThreads = threads + .filter((thread) => thread.projectId === project.id) + .toSorted((a, b) => { + const byDate = + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + if (byDate !== 0) return byDate; + return b.id.localeCompare(a.id); + }); + const isThreadListExpanded = expandedThreadListsByProject.has(project.id); + const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const visibleThreads = + hasHiddenThreads && !isThreadListExpanded + ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) + : projectThreads; + const orderedProjectThreadIds = projectThreads.map((t) => t.id); + + return ( + + {(dragHandleProps) => ( + +
+ handleProjectTitleClick(event, project.id)} + onKeyDown={(event) => + handleProjectTitleKeyDown(event, project.id) + } + onContextMenu={(event) => { + event.preventDefault(); + void handleProjectContextMenu(project.id, { + x: event.clientX, + y: event.clientY, + }); + }} + > + + + + {project.name} + + + + - } - showOnHover - className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - void handleNewThread(project.id); - }} - > - - - } - /> - - {newThreadShortcutLabel - ? `New thread (${newThreadShortcutLabel})` - : "New thread"} - - -
- - - - {visibleThreads.map((thread) => { - const isActive = routeThreadId === thread.id; - const isSelected = selectedThreadIds.has(thread.id); - const isHighlighted = isActive || isSelected; - const threadStatus = resolveThreadStatusPill({ - thread, - hasPendingApprovals: - pendingApprovalByThreadId.get(thread.id) === true, - hasPendingUserInput: - pendingUserInputByThreadId.get(thread.id) === true, - }); - const prStatus = prStatusIndicator( - prByThreadId.get(thread.id) ?? null, - ); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id) - .runningTerminalIds, - ); - - return ( - - } - size="sm" - isActive={isActive} - className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none hover:bg-accent hover:text-foreground focus-visible:ring-0 ${ - isSelected - ? "bg-primary/15 text-foreground dark:bg-primary/10" - : isActive - ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55" - : "text-muted-foreground" - }`} - onClick={(event) => { - handleThreadClick( - event, - thread.id, - orderedProjectThreadIds, - ); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(thread.id); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onContextMenu={(event) => { - event.preventDefault(); - if ( - selectedThreadIds.size > 0 && - selectedThreadIds.has(thread.id) - ) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (selectedThreadIds.size > 0) { - clearSelection(); - } - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); + } - }} - > -
- {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - + showOnHover + className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(project.id); + }} + > + + + } + /> + + {newThreadShortcutLabel + ? `New thread (${newThreadShortcutLabel})` + : "New thread"} + + +
+ + + + {visibleThreads.map((thread) => { + const isActive = routeThreadId === thread.id; + const isSelected = selectedThreadIds.has(thread.id); + const isHighlighted = isActive || isSelected; + const threadStatus = resolveThreadStatusPill({ + thread, + hasPendingApprovals: + pendingApprovalByThreadId.get(thread.id) === true, + hasPendingUserInput: + pendingUserInputByThreadId.get(thread.id) === true, + }); + const prStatus = prStatusIndicator( + prByThreadId.get(thread.id) ?? null, + ); + const terminalStatus = terminalStatusFromRunningIds( + selectThreadTerminalState(terminalStateByThreadId, thread.id) + .runningTerminalIds, + ); + + return ( + + } + size="sm" + isActive={isActive} + className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none hover:bg-accent hover:text-foreground focus-visible:ring-0 ${ + isSelected + ? "bg-primary/15 text-foreground dark:bg-primary/10" + : isActive + ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55" + : "text-muted-foreground" + }`} + onClick={(event) => { + handleThreadClick( + event, + thread.id, + orderedProjectThreadIds, + ); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(thread.id); + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }} + onContextMenu={(event) => { + event.preventDefault(); + if ( + selectedThreadIds.size > 0 && + selectedThreadIds.has(thread.id) + ) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + } else { + if (selectedThreadIds.size > 0) { + clearSelection(); } - /> - - {prStatus.tooltip} - - - )} - {threadStatus && ( - + void handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + } + }} + > +
+ {prStatus && ( + + { + openPrLink(event, prStatus.url); + }} + > + + + } + /> + + {prStatus.tooltip} + + + )} + {threadStatus && ( + + + + {threadStatus.label} + + + )} + {renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => setRenamingTitle(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename( + thread.id, + renamingTitle, + thread.title, + ); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename( + thread.id, + renamingTitle, + thread.title, + ); + } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {thread.title} + + )} +
+
+ {terminalStatus && ( + + + + )} - - {threadStatus.label} + > + {formatRelativeTime(thread.createdAt)} - - )} - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } - }} - className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename( - thread.id, - renamingTitle, - thread.title, - ); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename( - thread.id, - renamingTitle, - thread.title, - ); - } - }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - - {thread.title} - - )} -
-
- {terminalStatus && ( - - - - )} - - {formatRelativeTime(thread.createdAt)} - -
-
-
- ); - })} - - {hasHiddenThreads && !isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - expandThreadListForProject(project.id); - }} - > - Show more - - - )} - {hasHiddenThreads && isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - collapseThreadListForProject(project.id); - }} - > - Show less - - - )} -
-
-
- )} -
- ); - })} -
-
-
- - {projects.length === 0 && !shouldShowProjectPathEntry && ( -
- No projects yet -
+
+ + + ); + })} + + {hasHiddenThreads && !isThreadListExpanded && ( + + } + data-thread-selection-safe + size="sm" + className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" + onClick={() => { + expandThreadListForProject(project.id); + }} + > + Show more + + + )} + {hasHiddenThreads && isThreadListExpanded && ( + + } + data-thread-selection-safe + size="sm" + className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" + onClick={() => { + collapseThreadListForProject(project.id); + }} + > + Show less + + + )} + + + + )} + + ); + })} +
+
+
+ + {projects.length === 0 && !shouldShowProjectPathEntry && ( +
+ No projects yet +
+ )} + )} diff --git a/apps/web/vitest.browser.config.ts b/apps/web/vitest.browser.config.ts index c67fdfbe9..d41cdc3b2 100644 --- a/apps/web/vitest.browser.config.ts +++ b/apps/web/vitest.browser.config.ts @@ -18,6 +18,7 @@ export default mergeConfig( include: [ "src/components/ChatView.browser.tsx", "src/components/KeybindingsToast.browser.tsx", + "src/components/Sidebar.browser.tsx", ], browser: { enabled: true,