diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 7ae9cd5f49..19bc7f34de 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -57,6 +57,7 @@ import { AppCommandProvider } from "./components/commands/AppCommandProvider"; import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provider-cli-install"; import { PluginSettingsCompatibilityRoute } from "./components/settings/PluginSettingsCompatibilityRoute"; import { RouteLoadingSkeleton } from "./components/ui/route-loading-skeleton"; +import { AppLocalStateInitialization } from "./components/AppLocalStateInitialization"; const SettingsView = lazy(() => import("./views/SettingsView").then((m) => ({ @@ -371,6 +372,7 @@ export function App() { return ( + @@ -387,8 +389,8 @@ export function App() { {/* Outside : a provider CLI install outlives the page that started it, so its failure toast can be clicked from any route — including auth callback, which renders no app shell. */} - - + + diff --git a/apps/app/src/components/AppLocalStateInitialization.test.tsx b/apps/app/src/components/AppLocalStateInitialization.test.tsx new file mode 100644 index 0000000000..0aa299cf3c --- /dev/null +++ b/apps/app/src/components/AppLocalStateInitialization.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react"; +import { StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + promptDraftSlotStorageKeysForTests, + readNewThreadDraftSlots, +} from "@/lib/prompt-draft-slots"; +import { useNewThreadDraftSlots } from "@/hooks/useNewThreadDraftSlots"; +import { AppLocalStateInitialization } from "./AppLocalStateInitialization"; + +function DraftRows() { + const drafts = useNewThreadDraftSlots(); + return {drafts.map((draft) => draft.title).join(", ")}; +} + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + cleanup(); + window.localStorage.clear(); +}); + +describe("AppLocalStateInitialization", () => { + it("migrates the legacy shared composer draft at app boot", () => { + window.localStorage.setItem( + "bb.root-compose.project-id", + "project-at-launch", + ); + window.localStorage.setItem( + promptDraftSlotStorageKeysForTests.legacy, + JSON.stringify({ text: "Never lose this draft", attachments: [] }), + ); + + render( + + + + , + ); + + expect(readNewThreadDraftSlots()).toEqual([ + expect.objectContaining({ + draft: { + attachments: [], + mentions: [], + text: "Never lose this draft", + }, + destination: { + projectId: "project-at-launch", + sectionId: null, + }, + }), + ]); + expect( + window.localStorage.getItem(promptDraftSlotStorageKeysForTests.legacy), + ).toBeNull(); + expect(screen.getByText("Never lose this draft")).not.toBeNull(); + }); +}); diff --git a/apps/app/src/components/AppLocalStateInitialization.tsx b/apps/app/src/components/AppLocalStateInitialization.tsx new file mode 100644 index 0000000000..b548dd765f --- /dev/null +++ b/apps/app/src/components/AppLocalStateInitialization.tsx @@ -0,0 +1,21 @@ +import { useEffect, useRef } from "react"; +import { refreshNewThreadDraftSlots } from "@/hooks/usePromptDraftStorage"; +import { initializeNewThreadDraftSlots } from "@/lib/prompt-draft-slots"; +import { readRootComposeProjectId } from "@/lib/root-compose-selection"; + +/** + * Launch-time client-local migrations that must run regardless of which + * sidebar surface owns thread-list rendering. + */ +export function AppLocalStateInitialization() { + const didInitializeDraftSlots = useRef(false); + + useEffect(() => { + if (didInitializeDraftSlots.current) return; + didInitializeDraftSlots.current = true; + initializeNewThreadDraftSlots(readRootComposeProjectId()); + refreshNewThreadDraftSlots(); + }, []); + + return null; +} diff --git a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx index e034107b7d..17edcd1c21 100644 --- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx @@ -17,7 +17,11 @@ import { } from "jotai"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ThreadListEntry } from "@bb/domain"; -import { ActiveSidebarModeSections, MachineModeSections } from "./ProjectList"; +import { + ActiveSidebarModeSections, + BuiltInSidebarLifecycleSections, + MachineModeSections, +} from "./ProjectList"; import { buildMachineThreadGroups } from "@bb/client-core"; import { collapsedSidebarSectionIdsAtom, @@ -195,6 +199,35 @@ afterEach(() => { }); describe("sidebar organization mode sections", () => { + it.each(["project", "chronological", "machine"])( + "keeps drafts above %s sections and archived rows trailing", + (mode) => { + const { container } = render( + Drafts} + activeModeSections={ +
Chronological
} + renderMachine={() =>
Machine
} + renderProject={() =>
Project
} + /> + } + archivedRows={
Archived
} + emptyState={null} + />, + ); + + const activeLabel = + mode === "chronological" + ? "Chronological" + : mode === "machine" + ? "Machine" + : "Project"; + expect(container.textContent).toBe(`Drafts${activeLabel}Archived`); + }, + ); + it("does not mount inactive ordering or machine-grouping work", async () => { const store = createStore(); store.set(sidebarSectionOrderAtom, ["threads", "project:a", "pinned"]); diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index d7c9bdd4b6..49994958f6 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -27,6 +27,7 @@ import { import { isTransientReadError } from "@/hooks/queries/query-helpers"; import { stripProjectThreads } from "@/hooks/queries/project-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; +import { useArchivedThreads } from "@/hooks/queries/thread-queries"; import { useReorderPinnedThread } from "@/hooks/mutations/thread-state-mutations"; import { useCreateThreadSection, @@ -40,8 +41,10 @@ import { import { useHosts, usePrimaryHost } from "@/hooks/queries/host-queries"; import { useDialogState } from "@/hooks/useDialogState"; import { usePromptDraftInputThreadIds } from "@/hooks/usePromptDraftStorage"; +import { useNewThreadDraftSlots } from "@/hooks/useNewThreadDraftSlots"; import { getCollapsedChildActivity } from "@bb/client-core"; import { getRootComposeRoutePath } from "@/lib/route-paths"; +import { withRootComposeDraftSlotId } from "@/lib/root-compose-location-state"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; import { BbHttpError } from "@bb/sdk/browser"; @@ -75,6 +78,10 @@ import { ChronologicalSectionThreadSections, ProjectThreadTree, } from "./ProjectRow"; +import { + SidebarArchivedThreadGroup, + SidebarDraftRows, +} from "./SidebarLifecycleRows"; import type { ProjectThreadListState } from "./ProjectRow"; import { buildMachineThreadGroups, @@ -130,7 +137,8 @@ import { SIDEBAR_ROW_INTERACTIVE_STATE_CLASS, SIDEBAR_STANDARD_ROW_PADDING_CLASS, } from "./sidebarRowClasses"; -export { TopLevelSidebarSection } from "./TopLevelSidebarSection"; +import { TopLevelSidebarSection } from "./TopLevelSidebarSection"; +export { TopLevelSidebarSection }; import { useAppCommandRunner, useAppCommandShortcut, @@ -150,6 +158,15 @@ import { resolveThreadTitleDisplayText, type ThreadTitleMentionResources, } from "@/components/thread/ThreadTitleMentions"; +import { + isDefaultSidebarThreadLifecycleSelection, + builtInSidebarDraftRowsVisibleAtom, + getBuiltInSidebarLifecycleRenderState, + sidebarThreadLifecycleSelectionAtom, + SIDEBAR_THREAD_LIFECYCLE_STATES, + toggleSidebarThreadLifecycleState, + type SidebarThreadLifecycleState, +} from "./sidebarThreadLifecycle"; interface ProjectListProps { onNewProject?: () => void; @@ -600,10 +617,12 @@ const SIDEBAR_SORT_OPTIONS = [ function SidebarDisplayMenuTrigger({ ariaLabel, + filtered, iconName, tooltip, }: { ariaLabel: string; + filtered: boolean; iconName: IconName; tooltip: string; }) { @@ -620,13 +639,20 @@ function SidebarDisplayMenuTrigger({ size="icon" aria-label={ariaLabel} className={cn( - "rounded-md p-0 text-muted-foreground", + "relative rounded-md p-0 text-muted-foreground", "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-foreground", LIST_HOVER_TRANSITION, COARSE_POINTER_ROW_ACTION_SIZE_CLASS, )} > + {filtered ? ( +