From 0112f240634a3600ed5a64f018fae0e08271d501 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 18 Jun 2026 18:27:01 -0700 Subject: [PATCH 01/54] Add folder-path helpers and sidebar grouping prefs (S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First session of the Sidebar Nested Folders feature: the client-only foundation that later sessions build on. - folderPath.ts: pure parseThreadFolderPath / normalizeThreadTitle / titleCreatesFolder, plus buildFolderKey. "/" in a thread title is read as a folder separator; titles are split → trimmed → emptied → re-joined. - folderPath.test.ts: covers every normalization rule and the titleCreatesFolder / buildFolderKey boundaries (18 cases). - sidebarCollapsedAtoms.ts: three persisted prefs mirroring the existing atom pattern — sidebarGroupByAtom ("none"|"folder", default "none"), sidebarCollapsedFoldersAtom (string[]), folderOnboardingSeenAtom (bool). No rendering, tree-building, or rename changes yet (later sessions). Tests, typecheck, and lint pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/sidebar/folderPath.test.ts | 111 ++++++++++++++++++ apps/app/src/components/sidebar/folderPath.ts | 65 ++++++++++ .../sidebar/sidebarCollapsedAtoms.ts | 33 ++++++ 3 files changed, 209 insertions(+) create mode 100644 apps/app/src/components/sidebar/folderPath.test.ts create mode 100644 apps/app/src/components/sidebar/folderPath.ts diff --git a/apps/app/src/components/sidebar/folderPath.test.ts b/apps/app/src/components/sidebar/folderPath.test.ts new file mode 100644 index 000000000..5943bf2c5 --- /dev/null +++ b/apps/app/src/components/sidebar/folderPath.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + buildFolderKey, + normalizeThreadTitle, + parseThreadFolderPath, + titleCreatesFolder, +} from "./folderPath"; + +describe("parseThreadFolderPath", () => { + it("splits on '/', trims segments, and drops empties", () => { + expect(parseThreadFolderPath("Work/Q3/Plan")).toEqual({ + folders: ["Work", "Q3"], + leaf: "Plan", + }); + }); + + it("treats a single segment as a leaf with no folder", () => { + expect(parseThreadFolderPath("Standalone")).toEqual({ + folders: [], + leaf: "Standalone", + }); + }); + + it("collapses leading, trailing, and doubled slashes", () => { + expect(parseThreadFolderPath("/Work//Q3/")).toEqual({ + folders: ["Work"], + leaf: "Q3", + }); + }); + + it("trims whitespace around each segment", () => { + expect(parseThreadFolderPath("Work / Q3 ")).toEqual({ + folders: ["Work"], + leaf: "Q3", + }); + }); + + it("yields an empty path for an all-slashes or empty title", () => { + expect(parseThreadFolderPath("///")).toEqual({ folders: [], leaf: "" }); + expect(parseThreadFolderPath("")).toEqual({ folders: [], leaf: "" }); + }); +}); + +describe("normalizeThreadTitle", () => { + it("re-joins normalized segments with '/'", () => { + expect(normalizeThreadTitle("Work/Q3/Plan")).toBe("Work/Q3/Plan"); + }); + + it("collapses leading, trailing, and doubled slashes", () => { + expect(normalizeThreadTitle("/Work//Q3/")).toBe("Work/Q3"); + }); + + it("trims whitespace around each segment", () => { + expect(normalizeThreadTitle("Work / Q3 ")).toBe("Work/Q3"); + }); + + it("leaves a single segment unchanged (after trimming)", () => { + expect(normalizeThreadTitle(" Standalone ")).toBe("Standalone"); + }); + + it("normalizes an all-slashes or empty title to an empty string", () => { + expect(normalizeThreadTitle("///")).toBe(""); + expect(normalizeThreadTitle("")).toBe(""); + }); + + it("is idempotent on already-normalized titles", () => { + const normalized = normalizeThreadTitle("/Work//Q3/"); + expect(normalizeThreadTitle(normalized)).toBe(normalized); + }); +}); + +describe("titleCreatesFolder", () => { + it("is false for a single segment", () => { + expect(titleCreatesFolder("Standalone")).toBe(false); + }); + + it("is false when a trailing slash leaves a single segment", () => { + expect(titleCreatesFolder("Work/")).toBe(false); + }); + + it("is false for an all-slashes title", () => { + expect(titleCreatesFolder("///")).toBe(false); + }); + + it("is true once two or more segments survive normalization", () => { + expect(titleCreatesFolder("Work/Q3")).toBe(true); + expect(titleCreatesFolder("Work/Q3/")).toBe(true); + expect(titleCreatesFolder("Clients/Acme/Onboarding")).toBe(true); + }); +}); + +describe("buildFolderKey", () => { + it("namespaces a folder path by its container id", () => { + expect(buildFolderKey("proj_bb", ["Work", "Q3"])).toBe( + "proj_bb::Work/Q3", + ); + }); + + it("keeps same-named folders in different containers distinct", () => { + expect(buildFolderKey("proj_a", ["Work"])).not.toBe( + buildFolderKey("proj_b", ["Work"]), + ); + }); + + it("uses the global sentinels for the non-project sections", () => { + expect(buildFolderKey("pinned", ["Work"])).toBe("pinned::Work"); + expect(buildFolderKey("chronological", ["Work"])).toBe( + "chronological::Work", + ); + }); +}); diff --git a/apps/app/src/components/sidebar/folderPath.ts b/apps/app/src/components/sidebar/folderPath.ts new file mode 100644 index 000000000..8d17eabe9 --- /dev/null +++ b/apps/app/src/components/sidebar/folderPath.ts @@ -0,0 +1,65 @@ +// Pure helpers for the sidebar's derived folder layer. A thread title carries +// its folder path inline: "/" is read as a separator, so "Work/Q3/Plan" is a +// thread named "Plan" inside folders "Work" › "Q3". Nothing about folders is +// stored — these functions are the single canonical home for parsing a title +// into its folder ancestors + leaf, normalizing a title before it is written +// back on rename, and detecting whether a title creates a folder. + +export interface ThreadFolderPath { + /** Folder ancestors, outermost first. Empty when the title has no folder. */ + folders: string[]; + /** The thread's own name — the last path segment (or the whole title). */ + leaf: string; +} + +// Split a title into its non-empty, trimmed segments. Collapses leading, +// trailing, and doubled slashes and trims whitespace around each segment, so +// "/Work//Q3/" and "Work / Q3 " both yield ["Work", "Q3"]. Shared by every +// other helper here so parsing, normalizing, and detection stay consistent and +// idempotent (re-running on already-normalized input is a no-op). +function splitTitleSegments(title: string): string[] { + return title + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); +} + +// Split a stored title into folder ancestors + leaf for rendering. A single +// segment (or an all-slashes/empty title) has no folder. The leaf is always the +// final segment; for an empty result the leaf is "". +export function parseThreadFolderPath(title: string): ThreadFolderPath { + const segments = splitTitleSegments(title); + if (segments.length === 0) { + return { folders: [], leaf: "" }; + } + return { + folders: segments.slice(0, -1), + leaf: segments[segments.length - 1], + }; +} + +// Canonical form written back on rename submit: split → trim → drop empties → +// re-join with "/". "///" normalizes to "" (then blocked by empty-name +// validation upstream); "Work / Q3 " normalizes to "Work/Q3". +export function normalizeThreadTitle(title: string): string { + return splitTitleSegments(title).join("/"); +} + +// True when the normalized title yields at least one folder segment, i.e. two +// or more segments survive normalization. "Work/Q3" → true; "Work", "Work/", +// and "///" → false. +export function titleCreatesFolder(title: string): boolean { + return parseThreadFolderPath(title).folders.length > 0; +} + +// Stable identity for a folder within a section. `containerId` is the owner of +// the section — a `proj_*` id for project sections, or a fixed sentinel for the +// global sections — so "Work" in project A never collides with "Work" in +// project B or in pinned. Renaming an ancestor segment changes the key, which +// intentionally resets that folder's collapse state (it is a different folder). +export function buildFolderKey( + containerId: string, + path: readonly string[], +): string { + return `${containerId}::${path.join("/")}`; +} diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index e17d16263..01637103d 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -8,6 +8,9 @@ const COLLAPSED_SIDEBAR_SECTIONS_STORAGE_KEY = "bb.sidebar.collapsedSections"; const SIDEBAR_SECTION_ORDER_STORAGE_KEY = "bb.sidebar.sectionOrder"; const ORGANIZATION_MODE_STORAGE_KEY = "bb.sidebar.organizationMode"; const CHRONOLOGICAL_SORT_STORAGE_KEY = "bb.sidebar.chronologicalSort"; +const GROUP_BY_STORAGE_KEY = "bb.sidebar.groupBy"; +const COLLAPSED_FOLDERS_STORAGE_KEY = "bb.sidebar.collapsedFolders"; +const FOLDER_ONBOARDING_SEEN_STORAGE_KEY = "bb.sidebar.folderOnboardingSeen"; export type SidebarSectionId = | "pinned" @@ -24,6 +27,10 @@ export type SidebarOrganizationMode = "project" | "chronological"; // "updated" reuses the status-aware activity heuristic; "created" sorts by // the literal createdAt field. export type SidebarChronologicalSort = "updated" | "created"; +// Whether "/" in a thread title renders as nested folders. Orthogonal to the +// organization mode and sort: "none" keeps today's flat behavior (literal +// titles), "folder" buckets top-level threads into derived folders. +export type SidebarGroupBy = "none" | "folder"; export const DEFAULT_SIDEBAR_SECTION_ORDER: readonly SidebarSectionId[] = [ "pinned", @@ -83,3 +90,29 @@ export const sidebarChronologicalSortAtom = createJsonLocalStorage(), { getOnInit: true }, ); + +// Opt-in folder grouping. Default "none" keeps the current sidebar layout. +export const sidebarGroupByAtom = atomWithStorage( + GROUP_BY_STORAGE_KEY, + "none", + createJsonLocalStorage(), + { getOnInit: true }, +); + +// Collapsed folder keys (see buildFolderKey in folderPath.ts). A plain +// string[], matching collapsedThreadIds / collapsedProjectIds. +export const sidebarCollapsedFoldersAtom = atomWithStorage( + COLLAPSED_FOLDERS_STORAGE_KEY, + [], + createJsonLocalStorage(), + { getOnInit: true }, +); + +// Whether the first-folder onboarding modal has been accepted. Set on accept +// (not on open), so a declined modal still teaches on a later attempt. +export const folderOnboardingSeenAtom = atomWithStorage( + FOLDER_ONBOARDING_SEEN_STORAGE_KEY, + false, + createJsonLocalStorage(), + { getOnInit: true }, +); From bd27c2ceba943908eb22693db69b8eab1d078f32 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 18 Jun 2026 19:19:01 -0700 Subject: [PATCH 02/54] Render sidebar threads grouped into folders (S2+S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the opt-in "Group by: Folder" sidebar mode: top-level threads fold into nested, collapsible folders derived from "/" in their titles. Pure derived rendering — no DB/API/daemon changes. Assembly (S2): - SidebarFolderGroup item variant + bucketIntoFolders helper, folding the top-level item list into a nested folder tree. Folders render as a block above loose threads; folders, contents, and nested subfolders are each ordered by the active comparator (folders by their representative descendant). One parentKey-aware orderSiblingItems seam so S5 manual sort can swap ordering per list without re-cutting the tree walk. - buildProjectThreadGroups / buildChronologicalThreadList take folderOptions and early-return today's output untouched under Group by: None. - buildPinnedSidebarState folds pinned roots into folders ordered by comparePinnedRoots (pinned keeps its pinSortKey ordering), exposing rootItems while keeping rootNodes. Rendering (S3): - SidebarFolderRow collapsible header (icon, leaf name, descendant count, rolled-up activity) mirroring the parent/worktree row chrome. - ProjectRow renders folder items + recurses; ThreadRow gains displayTitle/accessibleTitle so a folder member shows its leaf while keeping the full "Work › Q3 › Planning" path for a11y + tooltip. - Collapse state in sidebarCollapsedFoldersAtom (read where rendered); selected thread's folder ancestors auto-expand. - SidebarViewOptionsMenu gains a Group by (None / Folder) section; the existing organization "Group by" is relabeled "Organize by" to avoid the label collision. - PinnedThreadTree renders folders statically (drag-reorder and derived folders don't compose), keeping the sortable flat list when no folders. Tests: bucketIntoFolders nesting, folders-first + representative-descendant ordering under both comparators, child-stays-under-parent, env-group-in- folder, pinned folder ordering, and the Group by: None regression (deep- equal to the pre-change builder + folder branch never entered). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/sidebar/PinnedThreadTree.tsx | 45 +++- .../src/components/sidebar/ProjectList.tsx | 68 +++++- .../app/src/components/sidebar/ProjectRow.tsx | 201 +++++++++++++--- .../components/sidebar/SidebarFolderRow.tsx | 152 ++++++++++++ apps/app/src/components/sidebar/ThreadRow.tsx | 19 +- apps/app/src/components/sidebar/folderPath.ts | 9 + .../sidebar/pinnedSidebarThreads.test.ts | 40 ++++ .../sidebar/pinnedSidebarThreads.ts | 43 +++- .../sidebar/projectThreadGroups.test.ts | 221 +++++++++++++++++- .../components/sidebar/projectThreadGroups.ts | 220 ++++++++++++++++- 10 files changed, 960 insertions(+), 58 deletions(-) create mode 100644 apps/app/src/components/sidebar/SidebarFolderRow.tsx diff --git a/apps/app/src/components/sidebar/PinnedThreadTree.tsx b/apps/app/src/components/sidebar/PinnedThreadTree.tsx index bf9073261..c11eb1a27 100644 --- a/apps/app/src/components/sidebar/PinnedThreadTree.tsx +++ b/apps/app/src/components/sidebar/PinnedThreadTree.tsx @@ -5,10 +5,18 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import type { NeighborReorderRequest } from "@/lib/neighbor-reorder"; -import { ThreadTreeNodeRow } from "./ProjectRow"; +import { + getItemKey, + getItemProjectId, + ThreadTreeItemRow, + ThreadTreeNodeRow, +} from "./ProjectRow"; import { useSidebarSortable } from "./sortableMotion"; import { useSidebarReorderDnd } from "./useSidebarReorderDnd"; -import type { ProjectThreadNode } from "./projectThreadGroups"; +import type { + ProjectThreadItem, + ProjectThreadNode, +} from "./projectThreadGroups"; import { useNeighborReorderSortable, type UseNeighborReorderSortableArgs, @@ -20,6 +28,10 @@ export interface PinnedThreadRootReorderCallbacks { export interface PinnedThreadTreeProps { rootNodes: readonly ProjectThreadNode[]; + // Folder-aware render list. When it contains folders (Group by: Folder), the + // pinned section renders them as a static tree; otherwise it keeps the + // existing sortable flat list (pinned reorder via pinSortKey). + rootItems: readonly ProjectThreadItem[]; selectedThreadId?: string; collapsedThreadIds: Set; collapsedEnvironmentIds: Set; @@ -118,6 +130,7 @@ const SortablePinnedRootItem = memo(function SortablePinnedRootItem({ export const PinnedThreadTree = memo(function PinnedThreadTree({ rootNodes, + rootItems, selectedThreadId, collapsedThreadIds, collapsedEnvironmentIds, @@ -127,6 +140,9 @@ export const PinnedThreadTree = memo(function PinnedThreadTree({ isPinnedReorderPending = false, onReorderPinnedRoot, }: PinnedThreadTreeProps) { + // Drag-reorder (pinSortKey) and derived folders don't compose — a folder has + // no sort key — so when folders are actually present we render a static tree. + const hasFolders = rootItems.some((item) => item.kind === "folder"); const handleReorderPinnedRoot = useCallback< UseNeighborReorderSortableArgs["onReorder"] >( @@ -150,6 +166,31 @@ export const PinnedThreadTree = memo(function PinnedThreadTree({ const { dndContextProps, consumeClickSuppression, onClickCapture } = useSidebarReorderDnd({ onDragEnd: handleSortableDragEnd }); + if (hasFolders) { + return ( +
+ {rootItems.map((item) => ( + + ))} +
+ ); + } + if (renderedRootNodes.length === 0) { return null; } diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 2be8d2e2f..8134f50b8 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -11,7 +11,7 @@ import { type ReactNode, } from "react"; import { useNavigate } from "react-router-dom"; -import { useAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import { DndContext, type DragEndEvent } from "@dnd-kit/core"; import { SortableContext, @@ -89,12 +89,19 @@ import { collapsedSidebarSectionIdsAtom, DEFAULT_SIDEBAR_SECTION_ORDER, sidebarChronologicalSortAtom, + sidebarCollapsedFoldersAtom, + sidebarGroupByAtom, sidebarOrganizationModeAtom, sidebarSectionOrderAtom, type CollapsibleSidebarSectionId, type SidebarOrganizationMode, type SidebarSectionId, } from "./sidebarCollapsedAtoms"; +import { buildFolderKey, parseThreadFolderPath } from "./folderPath"; +import { + CHRONOLOGICAL_CONTAINER_ID, + PINNED_CONTAINER_ID, +} from "./projectThreadGroups"; import { DropdownMenu, DropdownMenuContent, @@ -503,6 +510,7 @@ function SidebarViewOptionsMenu({ const [chronologicalSort, setChronologicalSort] = useAtom( sidebarChronologicalSortAtom, ); + const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); return ( @@ -528,7 +536,7 @@ function SidebarViewOptionsMenu({ mobileTitle="Sidebar display options" > - Group by + Organize by + + + Group by + + { + event.preventDefault(); + setGroupBy("none"); + }} + /> + { + event.preventDefault(); + setGroupBy("folder"); + }} + /> ); @@ -1162,6 +1190,8 @@ function ProjectListComponent({ ); const [organizationMode] = useAtom(sidebarOrganizationModeAtom); const [chronologicalSort] = useAtom(sidebarChronologicalSortAtom); + const [groupBy] = useAtom(sidebarGroupByAtom); + const setCollapsedFolderList = useSetAtom(sidebarCollapsedFoldersAtom); const sidebarThreadComparator = useMemo( () => chronologicalSort === "created" @@ -1219,8 +1249,8 @@ function ProjectListComponent({ setCollapsedSidebarSectionIdList, ]); const pinnedSidebarState = useMemo( - () => buildPinnedSidebarState({ threads }), - [threads], + () => buildPinnedSidebarState({ threads, groupBy }), + [threads, groupBy], ); const hasPinnedSection = pinnedSidebarState.rootNodes.length > 0; const visibleSidebarSectionOrder = useMemo( @@ -1274,6 +1304,32 @@ function ProjectListComponent({ removeCollapsedIds(current, environmentIdsToExpand), ); + // Under Group by: Folder, also un-collapse the selected thread's folder + // ancestors so it can't hide behind a collapsed folder. The folder key is + // scoped to whichever section renders the thread (pinned / chronological / + // its project), matching how the assembly sites namespace folder keys. + if (groupBy === "folder") { + const { folders } = parseThreadFolderPath(selectedThread.title ?? ""); + if (folders.length > 0) { + const folderContainerId = pinnedSidebarState.effectivePinnedThreadIds.has( + selectedThreadId, + ) + ? PINNED_CONTAINER_ID + : organizationMode === "chronological" + ? CHRONOLOGICAL_CONTAINER_ID + : selectedThread.projectId; + const folderKeysToExpand = new Set(); + for (let depth = 1; depth <= folders.length; depth += 1) { + folderKeysToExpand.add( + buildFolderKey(folderContainerId, folders.slice(0, depth)), + ); + } + setCollapsedFolderList((current) => + removeCollapsedIds(current, folderKeysToExpand), + ); + } + } + if (pinnedSidebarState.effectivePinnedThreadIds.has(selectedThreadId)) { return; } @@ -1292,9 +1348,12 @@ function ProjectListComponent({ removeCollapsedIds(current, new Set(["projects"])), ); }, [ + groupBy, + organizationMode, pinnedSidebarState.effectivePinnedThreadIds, selectedThreadId, setCollapsedEnvironmentIdList, + setCollapsedFolderList, setCollapsedProjectIdList, setCollapsedSidebarSectionIdList, setCollapsedThreadIdList, @@ -1469,6 +1528,7 @@ function ProjectListComponent({ const pinnedSectionContent = ( void; sortableStyle?: CSSProperties; + // True when this row is a direct member of a folder: show the leaf, keep the + // full path for a11y. Its own child threads stay full-titled. + insideFolder?: boolean; } interface ThreadTreeItemRowProps { projectId: string; item: ProjectThreadItem; depthOffset: number; + // True for the direct members of a folder, so thread rows show the leaf. + insideFolder?: boolean; + selectedThreadId?: string; + collapsedThreadIds: Set; + collapsedEnvironmentIds: Set; + variant: ProjectThreadTreeVariant; + onProjectSelect?: () => void; + onToggleThreadCollapsed: (threadId: string) => void; + onToggleEnvironmentCollapsed: (environmentId: string) => void; +} + +interface FolderTreeItemRowProps { + folder: SidebarFolderGroup; + depthOffset: number; selectedThreadId?: string; collapsedThreadIds: Set; collapsedEnvironmentIds: Set; @@ -192,6 +221,31 @@ interface ThreadTreeItemRowProps { onToggleEnvironmentCollapsed: (environmentId: string) => void; } +// Render key + routing projectId for any item kind. Folders derive from their +// first nested item, so a folder spanning projects (chronological) still routes +// each contained thread to its own project. +export function getItemKey(item: ProjectThreadItem): string { + switch (item.kind) { + case "thread": + return `thread:${item.node.thread.id}`; + case "environment": + return `env:${item.group.environmentId}`; + case "folder": + return `folder:${item.group.key}`; + } +} + +export function getItemProjectId(item: ProjectThreadItem): string { + switch (item.kind) { + case "thread": + return item.node.thread.projectId; + case "environment": + return item.group.nodes[0].thread.projectId; + case "folder": + return getItemProjectId(item.group.items[0]); + } +} + interface EnvironmentThreadGroupRowProps { projectId: string; environmentThreadGroup: EnvironmentThreadGroup; @@ -877,10 +931,11 @@ const EnvironmentThreadGroupRow = memo(function EnvironmentThreadGroupRow({ ); }); -const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ +export const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ projectId, item, depthOffset, + insideFolder = false, selectedThreadId, collapsedThreadIds, collapsedEnvironmentIds, @@ -889,6 +944,22 @@ const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ onToggleThreadCollapsed, onToggleEnvironmentCollapsed, }: ThreadTreeItemRowProps) { + if (item.kind === "folder") { + return ( + + ); + } + if (item.kind === "thread") { return ( { + setCollapsedFolders((current) => + current.includes(folderKey) + ? current.filter((key) => key !== folderKey) + : [...current, folderKey], + ); + }, [folderKey, setCollapsedFolders]); + + const headerDepth = getThreadRowDepth({ depthOffset, nodeDepth: 0, variant }); + const stickyLevel = + depthOffset < SIDEBAR_STICKY_PARENT_DEPTH_CAP ? depthOffset : undefined; + + return ( + + + {!isCollapsed ? ( +
+ + {folder.items.map((item) => ( + + ))} +
+ ) : null} +
+ ); +}); + export const ThreadTreeNodeRow = memo(function ThreadTreeNodeRow({ projectId, node, @@ -940,6 +1080,7 @@ export const ThreadTreeNodeRow = memo(function ThreadTreeNodeRow({ dragBindings, sortableRef, sortableStyle, + insideFolder = false, }: ThreadTreeNodeRowProps) { const isCollapsed = collapsedThreadIds.has(node.thread.id); const hasChildren = node.children.length > 0; @@ -986,6 +1127,18 @@ export const ThreadTreeNodeRow = memo(function ThreadTreeNodeRow({ projectId, threadId: node.thread.id, }); + // Inside a folder the row shows its leaf but keeps the full path for a11y; + // outside a folder (or for this node's own children) it shows the full title. + const folderTitles = useMemo(() => { + if (!insideFolder) { + return undefined; + } + const { folders, leaf } = parseThreadFolderPath(node.thread.title ?? ""); + return { + displayTitle: leaf || undefined, + accessibleTitle: formatFolderPathLabel([...folders, leaf]) || undefined, + }; + }, [insideFolder, node.thread.title]); const row = ( ); @@ -1013,11 +1168,7 @@ export const ThreadTreeNodeRow = memo(function ThreadTreeNodeRow({ {node.children.map((item) => ( buildProjectThreadGroups(projectThreads, compareThreads), - [compareThreads, projectThreads], + () => + buildProjectThreadGroups(projectThreads, compareThreads, { + groupBy, + containerId: projectId, + }), + [compareThreads, projectThreads, groupBy, projectId], ); if (threadListState.status === "loading") { @@ -1097,11 +1253,7 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ {rootItems.map((item) => ( buildChronologicalThreadList(threads, compareThreads), - [threads, compareThreads], + () => + buildChronologicalThreadList(threads, compareThreads, { + groupBy, + containerId: CHRONOLOGICAL_CONTAINER_ID, + }), + [threads, compareThreads, groupBy], ); if (threadListState.status === "loading") { @@ -1178,12 +1329,8 @@ export const ChronologicalThreadTree = memo(function ChronologicalThreadTree({ {rootItems.map((item) => ( void; + // Pin depth among parent rows when sticky; absent = not pinned (past the cap). + stickyLevel?: number; +} + +// The "Work › Q3 (2)" disclosure header for a derived folder. Not a thread: +// clicking toggles collapse, there is no navigation. Mirrors the parent-thread +// and worktree-header chrome (leading icon, truncating name, chevron, and a +// rolled-up activity glyph while collapsed). +function SidebarFolderRowComponent({ + name, + pathLabel, + depth, + threadCount, + activity, + isCollapsed, + onToggleCollapsed, + stickyLevel, +}: SidebarFolderRowProps) { + // Collapsed: the header speaks for its hidden descendants through one glyph + // (pending > working > unread). Expanded: descendants show their own glyphs. + const showRollupGlyph = + isCollapsed && (activity.pending || activity.working || activity.unread); + const className = cn( + SIDEBAR_HOVER_ACTIONS_ROW_CLASS, + // Only the non-sticky header needs `relative`; a sticky tier is already a + // positioned box. Mirrors ThreadRow / EnvironmentThreadGroupHeader. + stickyLevel === undefined && "relative", + SIDEBAR_ROW_BASE_CLASS, + SIDEBAR_ROW_INTERACTIVE_STATE_CLASS, + COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, + "cursor-pointer", + ); + const style: CSSProperties = { + paddingLeft: getSidebarThreadRowPaddingLeft(depth), + }; + const content = ( + <> + {/* Full-bleed toggle target for pointer users; the chevron owns keyboard + focus (mirrors the project row's hidden focus button). */} + + + + + ); +} + +interface FolderOnboardingDialogProps { + open: boolean; + pathLabel: string; + showGroupingHint: boolean; + pending: boolean; + onConfirm: () => void; + onCancel: () => void; + // Esc / overlay dismiss routes back through cancel (reopen rename). + onOpenChange: (open: boolean) => void; +} + +/** + * First-folder confirmation modal, shown the first time a rename would create a + * folder. Accepting submits the rename and enables folder grouping; declining + * (button, Esc, or overlay) hands control back to the rename dialog with the + * draft intact. + */ +export function FolderOnboardingDialog({ + open, + pathLabel, + showGroupingHint, + pending, + onConfirm, + onCancel, + onOpenChange, +}: FolderOnboardingDialogProps) { + return ( + + + {open ? ( + + ) : null} + + + ); +} diff --git a/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx b/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx index 558998bb4..95c81a703 100644 --- a/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx +++ b/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx @@ -1,8 +1,5 @@ import { useRef } from "react"; -import { - ThreadRenameDialogContent, - type ThreadRenameDialogTarget, -} from "./ThreadRenameDialog"; +import { ThreadRenameDialogContent } from "./ThreadRenameDialog"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { DialogStage } from "../../../.ladle/story-dialog-stage"; @@ -12,87 +9,65 @@ export default { const noop = () => {}; -const defaultTarget: ThreadRenameDialogTarget = { - id: "thr_demo", - currentTitle: "Audit recurring permission failures", -}; - -const parentTarget: ThreadRenameDialogTarget = { - id: "thr_parent", - currentTitle: "Frontend Parent", -}; - -const longTitleTarget: ThreadRenameDialogTarget = { - id: "thr_long", - currentTitle: - "Investigate slow tests on recurring CI failures after the timeline pagination v2 merge", -}; +function RenameStory({ + draft, + validationMessage = null, + pending = false, +}: { + draft: string; + validationMessage?: string | null; + pending?: boolean; +}) { + const inputRef = useRef(null); + return ( + + + + ); +} export function Overview() { - const inputRef = useRef(null); return ( - - - - + + - - - + + + + + + + - - - + - - - - - - - - + ); diff --git a/apps/app/src/components/dialogs/ThreadRenameDialog.tsx b/apps/app/src/components/dialogs/ThreadRenameDialog.tsx index d45e3e2d5..e074ba0c5 100644 --- a/apps/app/src/components/dialogs/ThreadRenameDialog.tsx +++ b/apps/app/src/components/dialogs/ThreadRenameDialog.tsx @@ -1,28 +1,44 @@ -import { capitalize } from "@bb/thread-view"; -import { useId, useState, type FormEvent, type RefObject } from "react"; +import { useId, type FormEvent, type RefObject } from "react"; import { Button } from "@/components/ui/button.js"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog.js"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.js"; import { Input } from "@/components/ui/input.js"; -import { useNameValidation } from "./useNameValidation.js"; +import { + formatFolderPathLabel, + parseThreadFolderPath, +} from "@/components/sidebar/folderPath"; import { useRenameDialogAutoFocus } from "./useRenameDialogAutoFocus.js"; export interface ThreadRenameDialogTarget { id: string; - currentTitle: string; } interface ThreadRenameDialogProps { target: ThreadRenameDialogTarget | null; + // The draft is lifted into the provider so it survives a rename → first-folder + // modal → rename round trip; the dialog renders it as a controlled input. + draft: string; + validationMessage: string | null; pending?: boolean; + onDraftChange: (value: string) => void; + onSubmit: () => void; onOpenChange: (open: boolean) => void; - onRename: (threadId: string, title: string) => void; } export function ThreadRenameDialog({ target, + draft, + validationMessage, pending = false, + onDraftChange, + onSubmit, onOpenChange, - onRename, }: ThreadRenameDialogProps) { const { inputRef, handleOpenAutoFocus } = useRenameDialogAutoFocus(); return ( @@ -30,10 +46,11 @@ export function ThreadRenameDialog({ {target ? ( ) : null} @@ -43,66 +60,74 @@ export function ThreadRenameDialog({ } export interface ThreadRenameDialogContentProps { - target: ThreadRenameDialogTarget; + draft: string; + validationMessage: string | null; pending: boolean; - onRename: (threadId: string, title: string) => void; + onDraftChange: (value: string) => void; + onSubmit: () => void; inputRef: RefObject; } +// Reveals the folder the row normally hides: parses the current draft and shows +// the resulting folder ancestors + leaf, or "No folder" for a single segment. +function RenameFolderPreview({ draft }: { draft: string }) { + const { folders, leaf } = parseThreadFolderPath(draft); + if (folders.length === 0) { + return

No folder

; + } + return ( +

+ Folder:{" "} + {formatFolderPathLabel(folders)} + {" · "} + Thread: {leaf} +

+ ); +} + export function ThreadRenameDialogContent({ - target, + draft, + validationMessage, pending, - onRename, + onDraftChange, + onSubmit, inputRef, }: ThreadRenameDialogContentProps) { const inputId = useId(); - const [nextTitle, setNextTitle] = useState(target.currentTitle); - const label = "thread"; - const { validationMessage, validate, clearMessage } = useNameValidation({ - emptyMessage: `${capitalize(label)} name cannot be empty.`, - }); const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (pending) return; - - const trimmedTitle = validate(nextTitle); - if (trimmedTitle === null) return; - - onRename(target.id, trimmedTitle); + onSubmit(); }; return ( <> - Rename {label} - - Choose a new name for this {label}. - + Rename thread + Choose a new name for this thread.
{ - setNextTitle(event.target.value); - clearMessage(); - }} + onChange={(event) => onDraftChange(event.target.value)} /> + {validationMessage ? (

{validationMessage}

) : null}
diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 8134f50b8..724dade35 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -97,7 +97,7 @@ import { type SidebarOrganizationMode, type SidebarSectionId, } from "./sidebarCollapsedAtoms"; -import { buildFolderKey, parseThreadFolderPath } from "./folderPath"; +import { folderAncestorKeys } from "./folderPath"; import { CHRONOLOGICAL_CONTAINER_ID, PINNED_CONTAINER_ID, @@ -1304,26 +1304,27 @@ function ProjectListComponent({ removeCollapsedIds(current, environmentIdsToExpand), ); - // Under Group by: Folder, also un-collapse the selected thread's folder - // ancestors so it can't hide behind a collapsed folder. The folder key is - // scoped to whichever section renders the thread (pinned / chronological / - // its project), matching how the assembly sites namespace folder keys. + // Under Group by: Folder, also un-collapse the folder ancestors hiding the + // selected thread. Folders derive from the TOP-LEVEL bucketed thread's + // title, not the selected child's own (a child's "/" is ignored) — so in + // project/pinned mode use the top-level ancestor the walk above ended on; + // in the flat chronological list the selected thread is itself top-level. if (groupBy === "folder") { - const { folders } = parseThreadFolderPath(selectedThread.title ?? ""); - if (folders.length > 0) { - const folderContainerId = pinnedSidebarState.effectivePinnedThreadIds.has( - selectedThreadId, - ) - ? PINNED_CONTAINER_ID - : organizationMode === "chronological" - ? CHRONOLOGICAL_CONTAINER_ID - : selectedThread.projectId; - const folderKeysToExpand = new Set(); - for (let depth = 1; depth <= folders.length; depth += 1) { - folderKeysToExpand.add( - buildFolderKey(folderContainerId, folders.slice(0, depth)), - ); - } + const isPinned = + pinnedSidebarState.effectivePinnedThreadIds.has(selectedThreadId); + const isChronological = + !isPinned && organizationMode === "chronological"; + const topLevelAncestor = currentThread ?? selectedThread; + const folderSource = isChronological ? selectedThread : topLevelAncestor; + const folderContainerId = isPinned + ? PINNED_CONTAINER_ID + : isChronological + ? CHRONOLOGICAL_CONTAINER_ID + : selectedThread.projectId; + const folderKeysToExpand = new Set( + folderAncestorKeys(folderContainerId, folderSource.title ?? ""), + ); + if (folderKeysToExpand.size > 0) { setCollapsedFolderList((current) => removeCollapsedIds(current, folderKeysToExpand), ); diff --git a/apps/app/src/components/sidebar/folderPath.test.ts b/apps/app/src/components/sidebar/folderPath.test.ts index 5943bf2c5..5f7f23956 100644 --- a/apps/app/src/components/sidebar/folderPath.test.ts +++ b/apps/app/src/components/sidebar/folderPath.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildFolderKey, + folderAncestorKeys, normalizeThreadTitle, parseThreadFolderPath, titleCreatesFolder, @@ -109,3 +110,29 @@ describe("buildFolderKey", () => { ); }); }); + +describe("folderAncestorKeys", () => { + it("returns every ancestor folder key, outermost first", () => { + expect(folderAncestorKeys("proj_1", "Work/Q3/Plan")).toEqual([ + "proj_1::Work", + "proj_1::Work/Q3", + ]); + }); + + it("returns no keys for a title with no folder", () => { + expect(folderAncestorKeys("proj_1", "Standalone")).toEqual([]); + expect(folderAncestorKeys("proj_1", "Work/")).toEqual([]); + }); + + it("excludes the leaf — only the folders that contain the thread", () => { + // "A/B" lives in folder "A"; "B" is the thread, not a folder. + expect(folderAncestorKeys("pinned", "A/B")).toEqual(["pinned::A"]); + }); + + it("namespaces by container, including the global sentinels", () => { + expect(folderAncestorKeys("pinned", "A/B/C")).toEqual([ + "pinned::A", + "pinned::A/B", + ]); + }); +}); diff --git a/apps/app/src/components/sidebar/folderPath.ts b/apps/app/src/components/sidebar/folderPath.ts index d4fa340a6..be133dbe2 100644 --- a/apps/app/src/components/sidebar/folderPath.ts +++ b/apps/app/src/components/sidebar/folderPath.ts @@ -52,6 +52,22 @@ export function titleCreatesFolder(title: string): boolean { return parseThreadFolderPath(title).folders.length > 0; } +// Every ancestor folder key for a title, outermost first — e.g. "Work/Q3/Plan" +// in container "p" → ["p::Work", "p::Work/Q3"]. Used to un-collapse the folders +// hiding a selected thread. Derive `title` from the thread that is actually +// bucketed (a section's top-level thread), since a nested child's "/" is ignored. +export function folderAncestorKeys( + containerId: string, + title: string, +): string[] { + const { folders } = parseThreadFolderPath(title); + const keys: string[] = []; + for (let depth = 1; depth <= folders.length; depth += 1) { + keys.push(buildFolderKey(containerId, folders.slice(0, depth))); + } + return keys; +} + // Human-readable folder path, used for tooltips, accessible names, and the // rename preview ("Work › Q3 › Planning"). The visible separator differs from // the stored "/" so a path reads as breadcrumbs, not a literal title. diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx index cf79ff9e4..d51494d1e 100644 --- a/apps/app/src/components/thread/ThreadActionsProvider.tsx +++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx @@ -5,8 +5,10 @@ import { useEffect, useMemo, useRef, + useState, type ReactNode, } from "react"; +import { useAtom } from "jotai"; import { useNavigate } from "react-router-dom"; import { appToast } from "@/components/ui/app-toast"; import { defaultExperiments, type Thread } from "@bb/domain"; @@ -32,6 +34,17 @@ import { ThreadRenameDialog, type ThreadRenameDialogTarget, } from "@/components/dialogs/ThreadRenameDialog"; +import { FolderOnboardingDialog } from "@/components/dialogs/FolderOnboardingDialog"; +import { + formatFolderPathLabel, + normalizeThreadTitle, + parseThreadFolderPath, + titleCreatesFolder, +} from "@/components/sidebar/folderPath"; +import { + folderOnboardingSeenAtom, + sidebarGroupByAtom, +} from "@/components/sidebar/sidebarCollapsedAtoms"; import { ThreadDeleteDialog, type ThreadDeleteDialogTarget, @@ -85,6 +98,13 @@ interface ThreadActionContext { childThreadCount: number; } +// Full breadcrumb segments for the first-folder modal preview +// ("Work › Q3 › Planning"): the folder ancestors plus the leaf. +function folderPreviewSegments(title: string): string[] { + const { folders, leaf } = parseThreadFolderPath(title); + return [...folders, leaf]; +} + export function ThreadActionsProvider({ children, }: ThreadActionsProviderProps) { @@ -121,6 +141,23 @@ export function ThreadActionsProvider({ const { onClose: closeRenameDialog, onOpen: openRenameDialog } = renameDialog; const { onClose: closeDeleteDialog, onOpen: openDeleteDialog } = deleteDialog; + // The rename draft is lifted here (not local to the dialog) so it survives a + // rename → first-folder modal → rename round trip. + const [renameDraft, setRenameDraft] = useState(""); + const [renameValidationError, setRenameValidationError] = useState< + string | null + >(null); + // Stashed rename awaiting first-folder confirmation; null when the modal is + // closed. Holds the raw draft so a decline can reopen rename unchanged. + const [pendingFolderRename, setPendingFolderRename] = useState<{ + threadId: string; + draft: string; + } | null>(null); + const [folderOnboardingSeen, setFolderOnboardingSeen] = useAtom( + folderOnboardingSeenAtom, + ); + const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); + useEffect(() => { return () => { threadActionContextAbortRef.current?.abort(); @@ -142,26 +179,88 @@ export function ThreadActionsProvider({ const requestRename = useCallback( (thread: Thread) => { - openRenameDialog({ - id: thread.id, - currentTitle: getThreadDisplayTitle(thread), - }); + // Seed from the raw stored title (not the display fallback) so an + // untitled thread never parses "Thread xxxx" as a folder path. + setRenameDraft(thread.title ?? ""); + setRenameValidationError(null); + openRenameDialog({ id: thread.id }); }, [openRenameDialog], ); - const submitRename = useCallback( - (threadId: string, title: string) => { - updateMutate( - { id: threadId, title }, - { - onSuccess: () => { - closeRenameDialog(); - }, + const handleRenameDraftChange = useCallback((value: string) => { + setRenameDraft(value); + setRenameValidationError(null); + }, []); + + const submitRename = useCallback(() => { + const target = renameDialog.target; + if (!target) return; + const normalized = normalizeThreadTitle(renameDraft); + if (!normalized) { + setRenameValidationError("Thread name cannot be empty."); + return; + } + // First time a rename creates a folder: stash and teach via the modal + // before submitting. Afterwards (seen === true) slash renames submit directly. + if (titleCreatesFolder(normalized) && !folderOnboardingSeen) { + setPendingFolderRename({ threadId: target.id, draft: renameDraft }); + closeRenameDialog(); + return; + } + updateMutate( + { id: target.id, title: normalized }, + { onSuccess: () => closeRenameDialog() }, + ); + }, [ + closeRenameDialog, + folderOnboardingSeen, + renameDialog.target, + renameDraft, + updateMutate, + ]); + + const handleFolderOnboardingConfirm = useCallback(() => { + if (!pendingFolderRename) return; + const { threadId, draft } = pendingFolderRename; + updateMutate( + { id: threadId, title: normalizeThreadTitle(draft) }, + { + onSuccess: () => { + setFolderOnboardingSeen(true); + // Auto-enable folder grouping once, so the new folder is visible. + if (groupBy === "none") { + setGroupBy("folder"); + } + setPendingFolderRename(null); }, - ); + }, + ); + }, [ + groupBy, + pendingFolderRename, + setFolderOnboardingSeen, + setGroupBy, + updateMutate, + ]); + + const handleFolderOnboardingCancel = useCallback(() => { + if (!pendingFolderRename) return; + // Reopen rename seeded from the stashed draft; seen stays false so the + // modal still teaches on a later attempt. + setRenameDraft(pendingFolderRename.draft); + setRenameValidationError(null); + openRenameDialog({ id: pendingFolderRename.threadId }); + setPendingFolderRename(null); + }, [openRenameDialog, pendingFolderRename]); + + const handleFolderOnboardingOpenChange = useCallback( + (open: boolean) => { + if (!open) { + handleFolderOnboardingCancel(); + } }, - [closeRenameDialog, updateMutate], + [handleFolderOnboardingCancel], ); // Fetches the delete dialog context. Returns null when the caller's request @@ -404,9 +503,27 @@ export function ThreadActionsProvider({ {children} + Date: Thu, 18 Jun 2026 20:56:56 -0700 Subject: [PATCH 04/54] Add manual sidebar sort state (S5) --- .../src/components/sidebar/ProjectList.tsx | 29 ++-- .../app/src/components/sidebar/ProjectRow.tsx | 24 ++- .../sidebar/projectThreadGroups.test.ts | 73 +++++++- .../components/sidebar/projectThreadGroups.ts | 158 +++++++++++++++--- .../sidebar/sidebarCollapsedAtoms.ts | 24 ++- 5 files changed, 254 insertions(+), 54 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 724dade35..7da10463e 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -576,6 +576,14 @@ function SidebarViewOptionsMenu({ setChronologicalSort("created"); }} /> + { + event.preventDefault(); + setChronologicalSort("none"); + }} + /> Group by @@ -708,12 +716,11 @@ function TopLevelSidebarSection({ }, [collapseControl], ); - const handleSectionLabelClick = useCallback>( - () => { - collapseControl?.onToggleCollapsed(); - }, - [collapseControl], - ); + const handleSectionLabelClick = useCallback< + MouseEventHandler + >(() => { + collapseControl?.onToggleCollapsed(); + }, [collapseControl]); const stopActionsClick = useCallback>( (event) => { event.stopPropagation(); @@ -804,9 +811,7 @@ function TopLevelSidebarSection({ onClick={stopActionsClick} > threads.filter( - (thread) => - !pinnedSidebarState.effectivePinnedThreadIds.has(thread.id), + (thread) => !pinnedSidebarState.effectivePinnedThreadIds.has(thread.id), ), [pinnedSidebarState.effectivePinnedThreadIds, threads], ); diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 885414a29..93bec596c 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -76,13 +76,12 @@ import { type ThreadComparator, } from "./projectThreadGroups"; import { SidebarFolderRow } from "./SidebarFolderRow"; +import { formatFolderPathLabel, parseThreadFolderPath } from "./folderPath"; import { - formatFolderPathLabel, - parseThreadFolderPath, -} from "./folderPath"; -import { + sidebarChronologicalSortAtom, sidebarCollapsedFoldersAtom, sidebarGroupByAtom, + sidebarManualOrderAtom, } from "./sidebarCollapsedAtoms"; import { SIDEBAR_PROJECT_GROUP_LINE_CLASS, @@ -1200,6 +1199,8 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ onToggleEnvironmentCollapsed, }: ProjectThreadTreeProps) { const groupBy = useAtomValue(sidebarGroupByAtom); + const chronologicalSort = useAtomValue(sidebarChronologicalSortAtom); + const manualOrder = useAtomValue(sidebarManualOrderAtom); const projectThreads = threadListState.status === "ready" ? threadListState.threads @@ -1209,8 +1210,16 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ buildProjectThreadGroups(projectThreads, compareThreads, { groupBy, containerId: projectId, + manualOrder: chronologicalSort === "none" ? manualOrder : undefined, }), - [compareThreads, projectThreads, groupBy, projectId], + [ + chronologicalSort, + compareThreads, + projectThreads, + groupBy, + manualOrder, + projectId, + ], ); if (threadListState.status === "loading") { @@ -1286,6 +1295,8 @@ export const ChronologicalThreadTree = memo(function ChronologicalThreadTree({ onToggleEnvironmentCollapsed, }: ChronologicalThreadTreeProps) { const groupBy = useAtomValue(sidebarGroupByAtom); + const chronologicalSort = useAtomValue(sidebarChronologicalSortAtom); + const manualOrder = useAtomValue(sidebarManualOrderAtom); const threads = threadListState.status === "ready" ? threadListState.threads @@ -1295,8 +1306,9 @@ export const ChronologicalThreadTree = memo(function ChronologicalThreadTree({ buildChronologicalThreadList(threads, compareThreads, { groupBy, containerId: CHRONOLOGICAL_CONTAINER_ID, + manualOrder: chronologicalSort === "none" ? manualOrder : undefined, }), - [threads, compareThreads, groupBy], + [chronologicalSort, threads, compareThreads, groupBy, manualOrder], ); if (threadListState.status === "loading") { diff --git a/apps/app/src/components/sidebar/projectThreadGroups.test.ts b/apps/app/src/components/sidebar/projectThreadGroups.test.ts index 8189eba50..0ca411624 100644 --- a/apps/app/src/components/sidebar/projectThreadGroups.test.ts +++ b/apps/app/src/components/sidebar/projectThreadGroups.test.ts @@ -6,6 +6,7 @@ import { buildProjectThreadGroups, compareByCreatedAtDescending, compareStandardThreads, + pruneManualOrderForChildren, type ProjectThreadItem, type ProjectThreadNode, } from "./projectThreadGroups"; @@ -459,6 +460,61 @@ describe("buildProjectThreadGroups", () => { }); }); +describe("manual order (Sort by: None)", () => { + it("orders a flat project section by stored manual order with new items at the top", () => { + const items = buildProjectThreadGroups( + [ + createThread({ id: "new", latestAttentionAt: 30 }), + createThread({ id: "stored-b", latestAttentionAt: 20 }), + createThread({ id: "stored-a", latestAttentionAt: 10 }), + ], + compareStandardThreads, + { + groupBy: "none", + containerId: "proj_1", + manualOrder: { + proj_1: ["stored-a", "stored-b"], + }, + }, + ); + + expect(summarizeItems(items)).toEqual(["new", "stored-a", "stored-b"]); + }); + + it("lets manual folder mode interleave folders and loose threads by parent", () => { + const items = buildProjectThreadGroups( + [ + createThread({ id: "a", title: "Work/A", latestAttentionAt: 30 }), + createThread({ id: "b", title: "Work/B", latestAttentionAt: 20 }), + createThread({ id: "loose", title: "Loose", latestAttentionAt: 10 }), + ], + compareStandardThreads, + { + groupBy: "folder", + containerId: "proj_1", + manualOrder: { + proj_1: ["loose", "proj_1::Work"], + "proj_1::Work": ["b", "a"], + }, + }, + ); + + expect(summarizeItems(items)).toEqual([ + "loose", + { folder: "proj_1::Work", items: ["b", "a"] }, + ]); + }); + + it("prunes stale and duplicate stored ids on read", () => { + expect( + pruneManualOrderForChildren( + ["missing", "b", "b", "a"], + new Set(["a", "b"]), + ), + ).toEqual(["b", "a"]); + }); +}); + const FOLDER_OPTIONS = { groupBy: "folder", containerId: "proj_1" } as const; describe("folder bucketing (Group by: Folder)", () => { @@ -479,10 +535,7 @@ describe("folder bucketing (Group by: Folder)", () => { expect(summarizeItems(items)).toEqual([ { folder: "proj_1::Work", - items: [ - { folder: "proj_1::Work/Q3", items: ["a", "b"] }, - "c", - ], + items: [{ folder: "proj_1::Work/Q3", items: ["a", "b"] }, "c"], }, "d", ]); @@ -562,7 +615,11 @@ describe("folder bucketing (Group by: Folder)", () => { // Standard comparator pins the active folder's representative first. expect( summarizeItems( - buildProjectThreadGroups(threads, compareStandardThreads, FOLDER_OPTIONS), + buildProjectThreadGroups( + threads, + compareStandardThreads, + FOLDER_OPTIONS, + ), ), ).toEqual([ { folder: "proj_1::FolderA", items: ["old-active"] }, @@ -587,7 +644,11 @@ describe("folder bucketing (Group by: Folder)", () => { it("rolls descendant count + activity up onto the folder group", () => { const items = bucketIntoFolders( buildProjectThreadGroups([ - createThread({ id: "busy", title: "Work/Busy", hasPendingInteraction: true }), + createThread({ + id: "busy", + title: "Work/Busy", + hasPendingInteraction: true, + }), createThread({ id: "quiet", title: "Work/Quiet" }), ]), "proj_1", diff --git a/apps/app/src/components/sidebar/projectThreadGroups.ts b/apps/app/src/components/sidebar/projectThreadGroups.ts index 703b52f1e..6f3af0ad5 100644 --- a/apps/app/src/components/sidebar/projectThreadGroups.ts +++ b/apps/app/src/components/sidebar/projectThreadGroups.ts @@ -8,7 +8,10 @@ import { type CollapsedChildActivity, } from "@/lib/thread-activity"; import { buildFolderKey, parseThreadFolderPath } from "./folderPath"; -import type { SidebarGroupBy } from "./sidebarCollapsedAtoms"; +import type { + SidebarGroupBy, + SidebarManualOrder, +} from "./sidebarCollapsedAtoms"; export interface ProjectThreadNodeStats { childCount: number; @@ -64,6 +67,7 @@ export type ProjectThreadItem = export interface SidebarFolderOptions { groupBy: SidebarGroupBy; containerId: string; + manualOrder?: SidebarManualOrder; } // Container-id sentinels for the global (non-project) sections; project @@ -351,11 +355,27 @@ export function buildProjectThreadGroups( } const rootItems = buildSortedItems(rootNodes, compareThreads); - // Group by: None — return today's output untouched, no folder logic. + // Group by: None — return today's output untouched unless Sort: None has + // explicitly supplied a manual order for this section. if (folderOptions?.groupBy !== "folder") { + if (folderOptions?.manualOrder) { + return orderSiblingItems( + rootItems, + folderOptions.containerId, + compareThreads, + { + manualOrder: folderOptions.manualOrder, + }, + ); + } return rootItems; } - return bucketIntoFolders(rootItems, folderOptions.containerId, compareThreads); + return bucketIntoFolders( + rootItems, + folderOptions.containerId, + compareThreads, + folderOptions.manualOrder, + ); } // Flat ordering for the chronological "All Threads" bucket: one top-level row @@ -383,11 +403,27 @@ export function buildChronologicalThreadList( }, }), ); - // Group by: None — flat globally-sorted list, no folder logic. + // Group by: None — flat globally-sorted list, no folder logic unless Sort: + // None has explicitly supplied a manual order for the chronological section. if (folderOptions?.groupBy !== "folder") { + if (folderOptions?.manualOrder) { + return orderSiblingItems( + items, + folderOptions.containerId, + compareThreads, + { + manualOrder: folderOptions.manualOrder, + }, + ); + } return items; } - return bucketIntoFolders(items, folderOptions.containerId, compareThreads); + return bucketIntoFolders( + items, + folderOptions.containerId, + compareThreads, + folderOptions.manualOrder, + ); } export function isSidebarProjectThread( @@ -421,9 +457,7 @@ function bucketWorktreeEnvironmentGroups( const environmentThreadGroups: EnvironmentThreadGroup[] = []; for (const [environmentId, bucket] of nodesByEnvironmentId) { if (!hasAtLeastTwoThreadNodes(bucket)) continue; - bucket.sort((left, right) => - compareThreads(left.thread, right.thread), - ); + bucket.sort((left, right) => compareThreads(left.thread, right.thread)); groupedEnvironmentIds.add(environmentId); environmentThreadGroups.push( buildEnvironmentThreadGroup(environmentId, bucket), @@ -435,9 +469,7 @@ function bucketWorktreeEnvironmentGroups( node.thread.environmentId === null || !groupedEnvironmentIds.has(node.thread.environmentId), ); - looseNodes.sort((left, right) => - compareThreads(left.thread, right.thread), - ); + looseNodes.sort((left, right) => compareThreads(left.thread, right.thread)); return { environmentThreadGroups, looseNodes }; } @@ -466,6 +498,10 @@ interface FolderBucket { items: ProjectThreadItem[]; } +interface ManualOrderSiblingOptions { + manualOrder?: SidebarManualOrder; +} + function createFolderBucket(): FolderBucket { return { subfolders: new Map(), items: [] }; } @@ -483,23 +519,95 @@ function getItemOrderingThread( case "environment": return item.group.nodes[0].thread; case "folder": - return getItemThreadDescendants(item.group.items).reduce((first, thread) => - compareThreads(thread, first) < 0 ? thread : first, + return getItemThreadDescendants(item.group.items).reduce( + (first, thread) => (compareThreads(thread, first) < 0 ? thread : first), ); } } +export function getManualOrderItemKey(item: ProjectThreadItem): string { + switch (item.kind) { + case "thread": + return item.node.thread.id; + case "environment": + return item.group.nodes[0].thread.id; + case "folder": + return item.group.key; + } +} + +export function pruneManualOrderForChildren( + storedOrder: readonly string[] | undefined, + childKeys: ReadonlySet, +): string[] { + if (!storedOrder) { + return []; + } + + const seen = new Set(); + const pruned: string[] = []; + for (const key of storedOrder) { + if (!childKeys.has(key) || seen.has(key)) { + continue; + } + seen.add(key); + pruned.push(key); + } + return pruned; +} + +function orderItemsByManualOrder( + items: readonly ProjectThreadItem[], + parentKey: string, + compareThreads: ThreadComparator, + manualOrder: SidebarManualOrder, +): ProjectThreadItem[] { + const itemsByKey = new Map(); + for (const item of items) { + itemsByKey.set(getManualOrderItemKey(item), item); + } + + const childKeys = new Set(itemsByKey.keys()); + const prunedOrder = pruneManualOrderForChildren( + manualOrder[parentKey], + childKeys, + ); + const orderedKeys = new Set(prunedOrder); + const unorderedItems = items + .filter((item) => !orderedKeys.has(getManualOrderItemKey(item))) + .sort((left, right) => + compareThreads( + getItemOrderingThread(left, compareThreads), + getItemOrderingThread(right, compareThreads), + ), + ); + const orderedItems = prunedOrder.flatMap((key) => { + const item = itemsByKey.get(key); + return item ? [item] : []; + }); + + return [...unorderedItems, ...orderedItems]; +} + // The one sibling-ordering hook. Today it orders folders-first, each block by -// the active comparator (ties already handled inside the comparator's codepoint -// fallback). `parentKey` is unused here but is the seam Sort: None (manual -// order) swaps to a stored per-parent order; threading it now keeps that change -// from re-cutting the tree walk. +// the active comparator. Under Sort: None it instead applies the stored +// per-parent manual order; missing child keys stay at the top in fallback order +// until the first drag writes a complete order for that parent. function orderSiblingItems( items: readonly ProjectThreadItem[], - // Seam for Sort: None — manual order will key off this parent. Unused today. - _parentKey: string, + parentKey: string, compareThreads: ThreadComparator, + options: ManualOrderSiblingOptions = {}, ): ProjectThreadItem[] { + if (options.manualOrder) { + return orderItemsByManualOrder( + items, + parentKey, + compareThreads, + options.manualOrder, + ); + } + const decorated = items.map((item) => ({ item, isFolder: item.kind === "folder", @@ -536,6 +644,7 @@ function buildFolderLevelItems( containerId: string, parentPath: readonly string[], compareThreads: ThreadComparator, + manualOrder?: SidebarManualOrder, ): ProjectThreadItem[] { const folderItems: ProjectThreadItem[] = []; for (const [name, subBucket] of bucket.subfolders) { @@ -545,6 +654,7 @@ function buildFolderLevelItems( containerId, path, compareThreads, + manualOrder, ); folderItems.push({ kind: "folder", @@ -559,6 +669,7 @@ function buildFolderLevelItems( [...folderItems, ...bucket.items], parentKey, compareThreads, + { manualOrder }, ); } @@ -570,6 +681,7 @@ export function bucketIntoFolders( items: readonly ProjectThreadItem[], containerId: string, compareThreads: ThreadComparator = compareStandardThreads, + manualOrder?: SidebarManualOrder, ): ProjectThreadItem[] { const root = createFolderBucket(); for (const item of items) { @@ -586,5 +698,11 @@ export function bucketIntoFolders( } bucket.items.push(item); } - return buildFolderLevelItems(root, containerId, [], compareThreads); + return buildFolderLevelItems( + root, + containerId, + [], + compareThreads, + manualOrder, + ); } diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index 01637103d..9c9742f2b 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -11,26 +11,25 @@ const CHRONOLOGICAL_SORT_STORAGE_KEY = "bb.sidebar.chronologicalSort"; const GROUP_BY_STORAGE_KEY = "bb.sidebar.groupBy"; const COLLAPSED_FOLDERS_STORAGE_KEY = "bb.sidebar.collapsedFolders"; const FOLDER_ONBOARDING_SEEN_STORAGE_KEY = "bb.sidebar.folderOnboardingSeen"; +const MANUAL_ORDER_STORAGE_KEY = "bb.sidebar.manualOrder"; -export type SidebarSectionId = - | "pinned" - | "projects" - | "threads"; -export type CollapsibleSidebarSectionId = - | "projects" - | "threads"; +export type SidebarSectionId = "pinned" | "projects" | "threads"; +export type CollapsibleSidebarSectionId = "projects" | "threads"; // "project" keeps the per-project grouping; "chronological" flattens every // non-pinned thread into a single All Threads bucket. export type SidebarOrganizationMode = "project" | "chronological"; // Controls thread ordering in both grouped and ungrouped sidebar views. // "updated" reuses the status-aware activity heuristic; "created" sorts by -// the literal createdAt field. -export type SidebarChronologicalSort = "updated" | "created"; +// the literal createdAt field; "none" applies the user's local manual order. +export type SidebarChronologicalSort = "updated" | "created" | "none"; // Whether "/" in a thread title renders as nested folders. Orthogonal to the // organization mode and sort: "none" keeps today's flat behavior (literal // titles), "folder" buckets top-level threads into derived folders. export type SidebarGroupBy = "none" | "folder"; +// Per-parent manual order for Sort: None. Keys are section/folder parent keys; +// values are child thread ids and child folder keys. +export type SidebarManualOrder = Record; export const DEFAULT_SIDEBAR_SECTION_ORDER: readonly SidebarSectionId[] = [ "pinned", @@ -116,3 +115,10 @@ export const folderOnboardingSeenAtom = atomWithStorage( createJsonLocalStorage(), { getOnInit: true }, ); + +export const sidebarManualOrderAtom = atomWithStorage( + MANUAL_ORDER_STORAGE_KEY, + {}, + createJsonLocalStorage(), + { getOnInit: true }, +); From 2377cf0b29d9698ba00bd798aed39fab07c24581 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 18 Jun 2026 21:04:42 -0700 Subject: [PATCH 05/54] Add manual sidebar drag reordering (S6) --- .../app/src/components/sidebar/ProjectRow.tsx | 471 ++++++++++++++++-- .../components/sidebar/SidebarFolderRow.tsx | 45 +- apps/app/src/components/sidebar/ThreadRow.tsx | 34 +- .../src/components/sidebar/folderPath.test.ts | 23 +- apps/app/src/components/sidebar/folderPath.ts | 10 + 5 files changed, 511 insertions(+), 72 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 93bec596c..a3745d03d 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -8,6 +8,11 @@ import { type ReactNode, } from "react"; import { useAtomValue, useSetAtom } from "jotai"; +import { DndContext, type DragEndEvent } from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import type { ThreadListEntry } from "@bb/domain"; import type { ProjectResponse } from "@bb/server-contract"; import { NavLink, useNavigate } from "react-router-dom"; @@ -17,6 +22,7 @@ import { useArchiveEnvironmentThreads, useUpdateEnvironment, } from "@/hooks/mutations/environment-mutations"; +import { useUpdateThread } from "@/hooks/mutations/thread-state-mutations"; import { useDialogState } from "@/hooks/useDialogState"; import { Button } from "@/components/ui/button.js"; import { @@ -69,6 +75,7 @@ import { buildChronologicalThreadList, buildProjectThreadGroups, CHRONOLOGICAL_CONTAINER_ID, + getManualOrderItemKey, type EnvironmentThreadGroup, type ProjectThreadItem, type ProjectThreadNode, @@ -76,12 +83,17 @@ import { type ThreadComparator, } from "./projectThreadGroups"; import { SidebarFolderRow } from "./SidebarFolderRow"; -import { formatFolderPathLabel, parseThreadFolderPath } from "./folderPath"; +import { + buildThreadTitleForFolderPath, + formatFolderPathLabel, + parseThreadFolderPath, +} from "./folderPath"; import { sidebarChronologicalSortAtom, sidebarCollapsedFoldersAtom, sidebarGroupByAtom, sidebarManualOrderAtom, + type SidebarManualOrder, } from "./sidebarCollapsedAtoms"; import { SIDEBAR_PROJECT_GROUP_LINE_CLASS, @@ -91,9 +103,14 @@ import { getSidebarThreadGroupLineLeft, getSidebarThreadRowPaddingLeft, } from "./sidebarRowClasses"; -import { type SidebarSortableDragBindings } from "./sortableMotion"; +import { + useSidebarSortable, + type SidebarSortableDragBindings, +} from "./sortableMotion"; import type { ConsumeDragClickSuppression } from "@/components/ui/use-drag-click-suppression"; import { SidebarChildToggleChevron } from "./SidebarChildToggleChevron"; +import type { SidebarReorderDndContextProps } from "./useSidebarReorderDnd"; +import { useSidebarReorderDnd } from "./useSidebarReorderDnd"; // Pin the project row plus this many parent levels (parent threads, // worktree group headers); rows deeper than the cap render non-sticky so a deep @@ -206,6 +223,11 @@ interface ThreadTreeItemRowProps { onProjectSelect?: () => void; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; + consumeClickSuppression?: ConsumeDragClickSuppression; + dragBindings?: SidebarSortableDragBindings; + manualSort?: ManualThreadTreeDndState; + sortableRef?: (element: HTMLDivElement | null) => void; + sortableStyle?: CSSProperties; } interface FolderTreeItemRowProps { @@ -218,6 +240,35 @@ interface FolderTreeItemRowProps { onProjectSelect?: () => void; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; + consumeClickSuppression?: ConsumeDragClickSuppression; + dragBindings?: SidebarSortableDragBindings; + manualSort?: ManualThreadTreeDndState; + sortableRef?: (element: HTMLDivElement | null) => void; + sortableStyle?: CSSProperties; +} + +interface ManualThreadTreeDndState { + consumeClickSuppression: ConsumeDragClickSuppression; + dndContextProps: SidebarReorderDndContextProps; + enabled: boolean; + itemIdsByParentKey: ReadonlyMap; + onClickCapture: MouseEventHandler; +} + +interface UseManualThreadTreeDndArgs { + containerId: string; + enabled: boolean; + rootItems: readonly ProjectThreadItem[]; +} + +type ManualSortableItemKind = "thread" | "folder" | "environment"; + +interface ManualThreadTreeLookup { + folderPathByParentKey: Map; + itemIdsByParentKey: Map; + itemKindById: Map; + parentKeyByItemId: Map; + threadByItemId: Map; } // Render key + routing projectId for any item kind. Folders derive from their @@ -245,6 +296,200 @@ export function getItemProjectId(item: ProjectThreadItem): string { } } +function getManualSortableItemKind( + item: ProjectThreadItem, +): ManualSortableItemKind { + return item.kind; +} + +function collectManualThreadTreeLookup( + items: readonly ProjectThreadItem[], + containerId: string, +): ManualThreadTreeLookup { + const lookup: ManualThreadTreeLookup = { + folderPathByParentKey: new Map([[containerId, []]]), + itemIdsByParentKey: new Map(), + itemKindById: new Map(), + parentKeyByItemId: new Map(), + threadByItemId: new Map(), + }; + + const walk = ( + siblingItems: readonly ProjectThreadItem[], + parentKey: string, + ) => { + const itemIds = siblingItems.map(getManualOrderItemKey); + lookup.itemIdsByParentKey.set(parentKey, itemIds); + + for (const item of siblingItems) { + const itemId = getManualOrderItemKey(item); + lookup.itemKindById.set(itemId, getManualSortableItemKind(item)); + lookup.parentKeyByItemId.set(itemId, parentKey); + + if (item.kind === "thread") { + lookup.threadByItemId.set(itemId, item.node.thread); + } else if (item.kind === "folder") { + lookup.folderPathByParentKey.set(item.group.key, item.group.path); + walk(item.group.items, item.group.key); + } + } + }; + + walk(items, containerId); + return lookup; +} + +function moveIdBefore({ + activeId, + itemIds, + overId, +}: { + activeId: string; + itemIds: readonly string[]; + overId: string | null; +}): string[] { + const withoutActive = itemIds.filter((id) => id !== activeId); + const insertIndex = + overId === null ? withoutActive.length : withoutActive.indexOf(overId); + if (insertIndex === -1) { + return [...itemIds]; + } + return [ + ...withoutActive.slice(0, insertIndex), + activeId, + ...withoutActive.slice(insertIndex), + ]; +} + +function writeManualOrderLists( + current: SidebarManualOrder, + updates: ReadonlyMap, +): SidebarManualOrder { + const next = { ...current }; + for (const [parentKey, itemIds] of updates) { + next[parentKey] = [...itemIds]; + } + return next; +} + +function useManualThreadTreeDnd({ + containerId, + enabled, + rootItems, +}: UseManualThreadTreeDndArgs): ManualThreadTreeDndState | null { + const lookup = useMemo( + () => collectManualThreadTreeLookup(rootItems, containerId), + [containerId, rootItems], + ); + const setManualOrder = useSetAtom(sidebarManualOrderAtom); + const updateThread = useUpdateThread(); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + if (!enabled) return; + + const { active, over } = event; + if ( + !over || + typeof active.id !== "string" || + typeof over.id !== "string" + ) { + return; + } + + const activeId = active.id; + const overId = over.id; + if (activeId === overId) return; + + const activeKind = lookup.itemKindById.get(activeId); + const overKind = lookup.itemKindById.get(overId); + const fromParentKey = lookup.parentKeyByItemId.get(activeId); + let toParentKey = lookup.parentKeyByItemId.get(overId); + let destinationOverId: string | null = overId; + + if (!activeKind || !overKind || !fromParentKey || !toParentKey) { + return; + } + + // Dropping a thread on a folder header means "move into this folder". + if (activeKind === "thread" && overKind === "folder") { + toParentKey = overId; + destinationOverId = null; + } + + // Folders can be reordered among siblings, but they are not folder + // entities and cannot be re-filed into other folders. + if (activeKind === "folder" && fromParentKey !== toParentKey) { + return; + } + + const sourceIds = lookup.itemIdsByParentKey.get(fromParentKey); + const destinationIds = lookup.itemIdsByParentKey.get(toParentKey); + if (!sourceIds || !destinationIds) { + return; + } + + const updates = new Map(); + if (fromParentKey === toParentKey) { + updates.set( + fromParentKey, + moveIdBefore({ + activeId, + itemIds: sourceIds, + overId: destinationOverId, + }), + ); + } else { + updates.set( + fromParentKey, + sourceIds.filter((id) => id !== activeId), + ); + updates.set( + toParentKey, + moveIdBefore({ + activeId, + itemIds: destinationIds, + overId: destinationOverId, + }), + ); + } + + setManualOrder((current) => writeManualOrderLists(current, updates)); + + if (activeKind !== "thread" || fromParentKey === toParentKey) { + return; + } + + const thread = lookup.threadByItemId.get(activeId); + if (!thread) return; + + const destinationFolders = + lookup.folderPathByParentKey.get(toParentKey) ?? []; + const title = buildThreadTitleForFolderPath( + thread.title ?? getThreadDisplayTitle(thread), + destinationFolders, + ); + updateThread.mutate({ id: activeId, title }); + }, + [enabled, lookup, setManualOrder, updateThread], + ); + + const { consumeClickSuppression, dndContextProps, onClickCapture } = + useSidebarReorderDnd({ onDragEnd: handleDragEnd }); + + if (!enabled) { + return null; + } + + return { + consumeClickSuppression, + dndContextProps, + enabled, + itemIdsByParentKey: lookup.itemIdsByParentKey, + onClickCapture, + }; +} + interface EnvironmentThreadGroupRowProps { projectId: string; environmentThreadGroup: EnvironmentThreadGroup; @@ -419,6 +664,8 @@ function getThreadRowOptions({ const baseOptions = { depth, isCompact: nodeDepth > 0 || isEnvGrouped, + ...(consumeClickSuppression ? { consumeClickSuppression } : {}), + ...(dragBindings ? { dragBindings } : {}), }; if (!isParent) { @@ -436,8 +683,6 @@ function getThreadRowOptions({ childActivity, ...(stickyLevel !== undefined ? { stickyLevel } : {}), onToggleCollapsed: onToggleThreadCollapsed, - ...(consumeClickSuppression ? { consumeClickSuppression } : {}), - ...(dragBindings ? { dragBindings } : {}), }; } @@ -515,6 +760,57 @@ function ProjectThreadTreeGroup({ ); } +function ManualSortableList({ + children, + manualSort, + parentKey, +}: { + children: ReactNode; + manualSort?: ManualThreadTreeDndState | null; + parentKey: string; +}) { + if (!manualSort?.enabled) { + return <>{children}; + } + + return ( + + {children} + + ); +} + +const ManualSortableThreadTreeItemRow = memo( + function ManualSortableThreadTreeItemRow({ + manualSort, + ...props + }: ThreadTreeItemRowProps) { + const itemId = getManualOrderItemKey(props.item); + const { dragBindings, setNodeRef, style } = useSidebarSortable({ + id: itemId, + disabled: !manualSort?.enabled || props.item.kind === "environment", + }); + + if (!manualSort?.enabled || props.item.kind === "environment") { + return ; + } + + return ( + + ); + }, +); + function useArchiveEnvironmentThreadGroupAction({ environmentId, projectId, @@ -942,6 +1238,11 @@ export const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ onProjectSelect, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, + consumeClickSuppression, + dragBindings, + manualSort, + sortableRef, + sortableStyle, }: ThreadTreeItemRowProps) { if (item.kind === "folder") { return ( @@ -955,6 +1256,11 @@ export const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ onProjectSelect={onProjectSelect} onToggleThreadCollapsed={onToggleThreadCollapsed} onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} + consumeClickSuppression={consumeClickSuppression} + dragBindings={dragBindings} + manualSort={manualSort} + sortableRef={sortableRef} + sortableStyle={sortableStyle} /> ); } @@ -974,6 +1280,10 @@ export const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ onProjectSelect={onProjectSelect} onToggleThreadCollapsed={onToggleThreadCollapsed} onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} + consumeClickSuppression={consumeClickSuppression} + dragBindings={dragBindings} + sortableRef={sortableRef} + sortableStyle={sortableStyle} /> ); } @@ -1009,6 +1319,11 @@ const FolderTreeItemRow = memo(function FolderTreeItemRow({ onProjectSelect, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, + consumeClickSuppression, + dragBindings, + manualSort, + sortableRef, + sortableStyle, }: FolderTreeItemRowProps) { const collapsedFolders = useAtomValue(sidebarCollapsedFoldersAtom); const setCollapsedFolders = useSetAtom(sidebarCollapsedFoldersAtom); @@ -1027,13 +1342,19 @@ const FolderTreeItemRow = memo(function FolderTreeItemRow({ depthOffset < SIDEBAR_STICKY_PARENT_DEPTH_CAP ? depthOffset : undefined; return ( - + - {folder.items.map((item) => ( - - ))} + + {folder.items.map((item) => ( + + ))} + ) : null} @@ -1221,6 +1545,11 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ projectId, ], ); + const manualSort = useManualThreadTreeDnd({ + containerId: projectId, + enabled: chronologicalSort === "none", + rootItems, + }); if (threadListState.status === "loading") { return ( @@ -1258,25 +1587,37 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ ); } - return ( - - {rootItems.map((item) => ( - - ))} + const tree = ( + + + {rootItems.map((item) => ( + + ))} + ); + + return manualSort ? ( + {tree} + ) : ( + tree + ); }); // Flat "All Threads" bucket for chronological mode: one top-level row per @@ -1310,6 +1651,11 @@ export const ChronologicalThreadTree = memo(function ChronologicalThreadTree({ }), [chronologicalSort, threads, compareThreads, groupBy, manualOrder], ); + const manualSort = useManualThreadTreeDnd({ + containerId: CHRONOLOGICAL_CONTAINER_ID, + enabled: chronologicalSort === "none", + rootItems, + }); if (threadListState.status === "loading") { return ( @@ -1337,25 +1683,40 @@ export const ChronologicalThreadTree = memo(function ChronologicalThreadTree({ ); } - return ( - - {rootItems.map((item) => ( - - ))} + const tree = ( + + + {rootItems.map((item) => ( + + ))} + ); + + return manualSort ? ( + {tree} + ) : ( + tree + ); }); function ProjectRowComponent({ diff --git a/apps/app/src/components/sidebar/SidebarFolderRow.tsx b/apps/app/src/components/sidebar/SidebarFolderRow.tsx index cd2db655d..fdf9f135b 100644 --- a/apps/app/src/components/sidebar/SidebarFolderRow.tsx +++ b/apps/app/src/components/sidebar/SidebarFolderRow.tsx @@ -1,4 +1,9 @@ -import { memo, type CSSProperties } from "react"; +import { + memo, + useCallback, + type CSSProperties, + type MouseEventHandler, +} from "react"; import { Icon } from "@/components/ui/icon.js"; import { SidebarStickyTier } from "@/components/ui/sidebar.js"; import { @@ -20,6 +25,8 @@ import { } from "./sidebarRowClasses"; import { SidebarChildToggleChevron } from "./SidebarChildToggleChevron"; import { ThreadStatusGlyph } from "./ThreadRow"; +import type { SidebarSortableDragBindings } from "./sortableMotion"; +import type { ConsumeDragClickSuppression } from "@/components/ui/use-drag-click-suppression"; interface SidebarFolderRowProps { // Leaf segment shown on the header ("Q3"). @@ -35,6 +42,8 @@ interface SidebarFolderRowProps { onToggleCollapsed: () => void; // Pin depth among parent rows when sticky; absent = not pinned (past the cap). stickyLevel?: number; + consumeClickSuppression?: ConsumeDragClickSuppression; + dragBindings?: SidebarSortableDragBindings; } // The "Work › Q3 (2)" disclosure header for a derived folder. Not a thread: @@ -47,6 +56,8 @@ function SidebarFolderRowComponent({ depth, threadCount, activity, + consumeClickSuppression, + dragBindings, isCollapsed, onToggleCollapsed, stickyLevel, @@ -64,10 +75,21 @@ function SidebarFolderRowComponent({ SIDEBAR_ROW_INTERACTIVE_STATE_CLASS, COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, "cursor-pointer", + dragBindings && !dragBindings.disabled && "select-none", ); const style: CSSProperties = { paddingLeft: getSidebarThreadRowPaddingLeft(depth), }; + const handleClickCapture = useCallback>( + (event) => { + if (!consumeClickSuppression?.()) { + return; + } + event.preventDefault(); + event.stopPropagation(); + }, + [consumeClickSuppression], + ); const content = ( <> {/* Full-bleed toggle target for pointer users; the chevron owns keyboard @@ -107,7 +129,10 @@ function SidebarFolderRowComponent({ /> {showRollupGlyph ? ( {content} @@ -143,7 +174,15 @@ function SidebarFolderRowComponent({ } return ( -
+
{content}
); diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index 1c3beb1d6..fa2ddf667 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -54,6 +54,8 @@ import { SidebarChildToggleChevron } from "./SidebarChildToggleChevron"; interface ThreadRowBaseOptions { depth: number; isCompact: boolean; + consumeClickSuppression?: ConsumeDragClickSuppression; + dragBindings?: SidebarSortableDragBindings; } export type ThreadRowOptions = @@ -69,8 +71,6 @@ export type ThreadRowOptions = // (deeper than the sticky cap, or not a sticky parent role). stickyLevel?: number; onToggleCollapsed: (threadId: string) => void; - consumeClickSuppression?: ConsumeDragClickSuppression; - dragBindings?: SidebarSortableDragBindings; }); interface ThreadRowProps { @@ -143,7 +143,14 @@ function renderThreadRowContainer({ } return ( -
+
{children}
); @@ -228,7 +235,10 @@ function ThreadTrailingIndicator({ return ( ( + const handleRowClickCapture = useCallback( (event) => { - if (!parentOptions?.consumeClickSuppression?.()) { + if (!options.consumeClickSuppression?.()) { return; } event.preventDefault(); event.stopPropagation(); }, - [parentOptions], + [options], ); const rowContent = ( @@ -397,8 +407,10 @@ function ThreadRowComponent({ const row = renderThreadRowContainer({ children: rowContent, className: rowClassName, - dragBindings: parentDragBindings, - onClickCapture: parentOptions ? handleParentClickCapture : undefined, + dragBindings: rowDragBindings, + onClickCapture: options.consumeClickSuppression + ? handleRowClickCapture + : undefined, stickyLevel: parentOptions?.stickyLevel, style: rowStyle, }); diff --git a/apps/app/src/components/sidebar/folderPath.test.ts b/apps/app/src/components/sidebar/folderPath.test.ts index 5f7f23956..83ae5b5cf 100644 --- a/apps/app/src/components/sidebar/folderPath.test.ts +++ b/apps/app/src/components/sidebar/folderPath.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildFolderKey, + buildThreadTitleForFolderPath, folderAncestorKeys, normalizeThreadTitle, parseThreadFolderPath, @@ -92,9 +93,7 @@ describe("titleCreatesFolder", () => { describe("buildFolderKey", () => { it("namespaces a folder path by its container id", () => { - expect(buildFolderKey("proj_bb", ["Work", "Q3"])).toBe( - "proj_bb::Work/Q3", - ); + expect(buildFolderKey("proj_bb", ["Work", "Q3"])).toBe("proj_bb::Work/Q3"); }); it("keeps same-named folders in different containers distinct", () => { @@ -111,6 +110,24 @@ describe("buildFolderKey", () => { }); }); +describe("buildThreadTitleForFolderPath", () => { + it("keeps the leaf and rewrites the folder prefix", () => { + expect(buildThreadTitleForFolderPath("Work/Q3/Plan", ["Personal"])).toBe( + "Personal/Plan", + ); + }); + + it("strips the folder prefix for a top-level destination", () => { + expect(buildThreadTitleForFolderPath("Work/Q3/Plan", [])).toBe("Plan"); + }); + + it("normalizes destination segments", () => { + expect( + buildThreadTitleForFolderPath(" Work / Q3 / Plan ", [" Personal "]), + ).toBe("Personal/Plan"); + }); +}); + describe("folderAncestorKeys", () => { it("returns every ancestor folder key, outermost first", () => { expect(folderAncestorKeys("proj_1", "Work/Q3/Plan")).toEqual([ diff --git a/apps/app/src/components/sidebar/folderPath.ts b/apps/app/src/components/sidebar/folderPath.ts index be133dbe2..83c6c367b 100644 --- a/apps/app/src/components/sidebar/folderPath.ts +++ b/apps/app/src/components/sidebar/folderPath.ts @@ -77,6 +77,16 @@ export function formatFolderPathLabel(segments: readonly string[]): string { return segments.join(FOLDER_PATH_SEPARATOR); } +// Re-file a thread into a destination folder path while keeping its current +// leaf. An empty destination strips the folder prefix and returns just the leaf. +export function buildThreadTitleForFolderPath( + title: string, + destinationFolders: readonly string[], +): string { + const { leaf } = parseThreadFolderPath(title); + return normalizeThreadTitle([...destinationFolders, leaf].join("/")); +} + // Stable identity for a folder within a section. `containerId` is the owner of // the section — a `proj_*` id for project sections, or a fixed sentinel for the // global sections — so "Work" in project A never collides with "Work" in From 0a75e06a460f9324b4ca98d06cbf6bdcb304931c Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 18 Jun 2026 21:10:05 -0700 Subject: [PATCH 06/54] Add sidebar folder Ladle stories (S7) --- .../FolderOnboardingDialog.stories.tsx | 53 ++++ .../sidebar/FolderGrouping.stories.tsx | 254 ++++++++++++++++++ .../src/components/sidebar/ProjectList.tsx | 2 +- .../sidebar/SidebarFolderRow.stories.tsx | 115 ++++++++ .../SidebarViewOptionsMenu.stories.tsx | 48 ++++ 5 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx create mode 100644 apps/app/src/components/sidebar/FolderGrouping.stories.tsx create mode 100644 apps/app/src/components/sidebar/SidebarFolderRow.stories.tsx create mode 100644 apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx diff --git a/apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx b/apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx new file mode 100644 index 000000000..dc588739f --- /dev/null +++ b/apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx @@ -0,0 +1,53 @@ +import { StoryCard, StoryRow } from "../../../.ladle/story-card"; +import { DialogStage } from "../../../.ladle/story-dialog-stage"; +import { FolderOnboardingDialogContent } from "./FolderOnboardingDialog"; + +export default { + title: "dialogs/Folder onboarding", +}; + +const noop = () => {}; + +function OnboardingStory({ + pathLabel, + pending = false, + showGroupingHint = true, +}: { + pathLabel: string; + pending?: boolean; + showGroupingHint?: boolean; +}) { + return ( + + + + ); +} + +export function Overview() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/src/components/sidebar/FolderGrouping.stories.tsx b/apps/app/src/components/sidebar/FolderGrouping.stories.tsx new file mode 100644 index 000000000..a02597678 --- /dev/null +++ b/apps/app/src/components/sidebar/FolderGrouping.stories.tsx @@ -0,0 +1,254 @@ +import { useMemo, type ReactNode } from "react"; +import { createStore, Provider as JotaiProvider } from "jotai"; +import type { ThreadListEntry } from "@bb/domain"; +import { + PROJECT_IDS, + makeThreadListEntry, +} from "../../../.ladle/story-fixtures"; +import { ProjectActionsProvider } from "@/components/project/ProjectActionsProvider"; +import { ThreadActionsProvider } from "@/components/thread/ThreadActionsProvider"; +import { SidebarStickyStack } from "@/components/ui/sidebar.js"; +import { StoryCard, StoryRow } from "../../../.ladle/story-card"; +import { + ChronologicalThreadTree, + ProjectThreadTree, + type ProjectThreadListState, +} from "./ProjectRow"; +import { compareStandardThreads } from "./projectThreadGroups"; +import { + sidebarChronologicalSortAtom, + sidebarGroupByAtom, + sidebarManualOrderAtom, + type SidebarChronologicalSort, + type SidebarGroupBy, + type SidebarManualOrder, +} from "./sidebarCollapsedAtoms"; + +export default { + title: "sidebar/Folder grouping", +}; + +const noop = () => {}; +const PROJECT_ID = PROJECT_IDS.bb; + +function makeThread(overrides: Partial): ThreadListEntry { + return makeThreadListEntry({ + projectId: PROJECT_ID, + titleFallback: overrides.title ?? "Story thread", + ...overrides, + }); +} + +const folderThreads: ThreadListEntry[] = [ + makeThread({ + id: "thr_work_plan", + title: "Work/Q3/Plan", + latestAttentionAt: 90, + createdAt: 90, + }), + makeThread({ + id: "thr_work_notes", + title: "Work/Q3/Notes", + latestAttentionAt: 80, + createdAt: 80, + }), + makeThread({ + id: "thr_work_parent", + title: "Work/Q4/Kickoff", + latestAttentionAt: 70, + createdAt: 70, + }), + makeThread({ + id: "thr_work_child", + parentThreadId: "thr_work_parent", + title: "Ignored/Child/Path", + latestAttentionAt: 65, + createdAt: 65, + }), + makeThread({ + id: "thr_personal_plan", + title: "Personal/Q3/Plan", + latestAttentionAt: 60, + createdAt: 60, + }), + makeThread({ + id: "thr_standalone", + title: "Standalone follow-up", + latestAttentionAt: 50, + createdAt: 50, + }), + makeThread({ + id: "thr_env_a", + title: "Work/Build/Daemon", + environmentId: "env_story_folder", + environmentName: "Folder build", + environmentBranchName: "bb/sidebar-folders", + environmentWorkspaceDisplayKind: "managed-worktree", + latestAttentionAt: 40, + createdAt: 40, + }), + makeThread({ + id: "thr_env_b", + title: "Work/Build/Stories", + environmentId: "env_story_folder", + environmentName: "Folder build", + environmentBranchName: "bb/sidebar-folders", + environmentWorkspaceDisplayKind: "managed-worktree", + hasPendingInteraction: true, + latestAttentionAt: 30, + createdAt: 30, + }), +]; + +const manualThreads: ThreadListEntry[] = [ + makeThread({ id: "thr_a", title: "Work/Q3/Plan", latestAttentionAt: 90 }), + makeThread({ id: "thr_b", title: "Work/Q3/Notes", latestAttentionAt: 80 }), + makeThread({ id: "thr_c", title: "Work/Q4/Kickoff", latestAttentionAt: 70 }), + makeThread({ id: "thr_d", title: "Personal/Plan", latestAttentionAt: 60 }), + makeThread({ id: "thr_e", title: "Loose thread", latestAttentionAt: 50 }), +]; + +const manualOrder: SidebarManualOrder = { + [PROJECT_ID]: ["thr_e", `${PROJECT_ID}::Personal`, `${PROJECT_ID}::Work`], + [`${PROJECT_ID}::Work`]: [`${PROJECT_ID}::Work/Q4`, `${PROJECT_ID}::Work/Q3`], + [`${PROJECT_ID}::Work/Q3`]: ["thr_b", "thr_a"], +}; + +function SidebarState({ + children, + groupBy, + manualOrder, + sort = "updated", +}: { + children: ReactNode; + groupBy: SidebarGroupBy; + manualOrder?: SidebarManualOrder; + sort?: SidebarChronologicalSort; +}) { + const store = useMemo(() => { + const next = createStore(); + next.set(sidebarGroupByAtom, groupBy); + next.set(sidebarChronologicalSortAtom, sort); + if (manualOrder) { + next.set(sidebarManualOrderAtom, manualOrder); + } + return next; + }, [groupBy, manualOrder, sort]); + + return {children}; +} + +function SidebarStage({ + children, + groupBy, + manualOrder, + sort, +}: { + children: ReactNode; + groupBy: SidebarGroupBy; + manualOrder?: SidebarManualOrder; + sort?: SidebarChronologicalSort; +}) { + return ( + + + +
+ {children} +
+
+
+
+ ); +} + +function projectTree( + threads: readonly ThreadListEntry[], +): ProjectThreadListState { + return { status: "ready", threads: [...threads] }; +} + +function ProjectTree({ threads }: { threads: readonly ThreadListEntry[] }) { + return ( + + ); +} + +export function NoneVsFolder() { + return ( + + + + + + + + + + + ); +} + +export function ChronologicalFolders() { + return ( + + + + + + + + ); +} + +export function ManualOrder() { + return ( + + + + + + + + ); +} + +export function CrossFolderRefile() { + const afterThreads = manualThreads.map((thread) => + thread.id === "thr_a" ? { ...thread, title: "Personal/Plan" } : thread, + ); + return ( + + + + + + + + + + + ); +} diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 7da10463e..035645aa0 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -499,7 +499,7 @@ function SidebarOrganizeMenuOption({ // Shared display menu rendered on both the Projects and Threads section // headers. The organization mode is global, so either header's menu drives the // whole sidebar. -function SidebarViewOptionsMenu({ +export function SidebarViewOptionsMenu({ open, onOpenChange, onOrganizationModeSelect, diff --git a/apps/app/src/components/sidebar/SidebarFolderRow.stories.tsx b/apps/app/src/components/sidebar/SidebarFolderRow.stories.tsx new file mode 100644 index 000000000..b44e068f9 --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarFolderRow.stories.tsx @@ -0,0 +1,115 @@ +import type { ReactNode } from "react"; +import { SidebarStickyStack } from "@/components/ui/sidebar.js"; +import { + NO_COLLAPSED_CHILD_ACTIVITY, + type CollapsedChildActivity, +} from "@/lib/thread-activity"; +import { StoryCard, StoryRow } from "../../../.ladle/story-card"; +import { SidebarFolderRow } from "./SidebarFolderRow"; + +export default { + title: "sidebar/Folder row", +}; + +const noop = () => {}; + +function activity( + overrides: Partial = {}, +): CollapsedChildActivity { + return { ...NO_COLLAPSED_CHILD_ACTIVITY, ...overrides }; +} + +function SidebarStage({ children }: { children: ReactNode }) { + return ( +
+ +
{children}
+
+
+ ); +} + +export function Overview() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx b/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx new file mode 100644 index 000000000..dd38ab475 --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx @@ -0,0 +1,48 @@ +import { useMemo } from "react"; +import { createStore, Provider as JotaiProvider } from "jotai"; +import { StoryCard, StoryRow } from "../../../.ladle/story-card"; +import { SidebarViewOptionsMenu } from "./ProjectList"; +import { + sidebarChronologicalSortAtom, + sidebarGroupByAtom, +} from "./sidebarCollapsedAtoms"; + +export default { + title: "sidebar/View options menu", +}; + +function MenuStory({ + groupBy, + sort, +}: { + groupBy: "none" | "folder"; + sort: "updated" | "created" | "none"; +}) { + const store = useMemo(() => { + const next = createStore(); + next.set(sidebarChronologicalSortAtom, sort); + next.set(sidebarGroupByAtom, groupBy); + return next; + }, [groupBy, sort]); + + return ( + +
+ +
+
+ ); +} + +export function Overview() { + return ( + + + + + + + + + ); +} From 9a9f3a66187337972c6a4a1573102f944f28ae30 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 18 Jun 2026 21:38:56 -0700 Subject: [PATCH 07/54] Keep rename modal unchanged for folder grouping --- .../FolderOnboardingDialog.stories.tsx | 53 ------- .../dialogs/FolderOnboardingDialog.tsx | 110 ------------- .../dialogs/ThreadRenameDialog.stories.tsx | 111 ++++++++----- .../components/dialogs/ThreadRenameDialog.tsx | 95 +++++------ .../src/components/sidebar/ProjectList.tsx | 23 ++- .../SidebarViewOptionsMenu.stories.tsx | 14 +- .../sidebar/sidebarCollapsedAtoms.ts | 10 -- .../thread/ThreadActionsProvider.tsx | 147 ++---------------- 8 files changed, 153 insertions(+), 410 deletions(-) delete mode 100644 apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx delete mode 100644 apps/app/src/components/dialogs/FolderOnboardingDialog.tsx diff --git a/apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx b/apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx deleted file mode 100644 index dc588739f..000000000 --- a/apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { StoryCard, StoryRow } from "../../../.ladle/story-card"; -import { DialogStage } from "../../../.ladle/story-dialog-stage"; -import { FolderOnboardingDialogContent } from "./FolderOnboardingDialog"; - -export default { - title: "dialogs/Folder onboarding", -}; - -const noop = () => {}; - -function OnboardingStory({ - pathLabel, - pending = false, - showGroupingHint = true, -}: { - pathLabel: string; - pending?: boolean; - showGroupingHint?: boolean; -}) { - return ( - - - - ); -} - -export function Overview() { - return ( - - - - - - - - - - - - - - - ); -} diff --git a/apps/app/src/components/dialogs/FolderOnboardingDialog.tsx b/apps/app/src/components/dialogs/FolderOnboardingDialog.tsx deleted file mode 100644 index 1df41f0d4..000000000 --- a/apps/app/src/components/dialogs/FolderOnboardingDialog.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Button } from "@/components/ui/button.js"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog.js"; - -interface FolderOnboardingDialogContentProps { - // The user's entry previewed as breadcrumbs ("Work › Q3 › Planning"). - pathLabel: string; - // Only shown when grouping is currently off, since accepting turns it on. - showGroupingHint: boolean; - pending: boolean; - onConfirm: () => void; - onCancel: () => void; -} - -/** - * Body of the first-folder confirmation: explains that "/" files a thread into - * a folder, previews the resulting path, and offers Create / Cancel. Split from - * the modal shell so stories can render it without the overlay (mirrors - * {@link ConfirmDeleteDialogContent}). - */ -export function FolderOnboardingDialogContent({ - pathLabel, - showGroupingHint, - pending, - onConfirm, - onCancel, -}: FolderOnboardingDialogContentProps) { - return ( - <> - - Organize threads into folders - - Using “/” groups this thread into a folder: - - -
-
- {pathLabel} -
- {showGroupingHint ? ( -

- Folder grouping will turn on so you can see it. -

- ) : null} -
- - - - - - ); -} - -interface FolderOnboardingDialogProps { - open: boolean; - pathLabel: string; - showGroupingHint: boolean; - pending: boolean; - onConfirm: () => void; - onCancel: () => void; - // Esc / overlay dismiss routes back through cancel (reopen rename). - onOpenChange: (open: boolean) => void; -} - -/** - * First-folder confirmation modal, shown the first time a rename would create a - * folder. Accepting submits the rename and enables folder grouping; declining - * (button, Esc, or overlay) hands control back to the rename dialog with the - * draft intact. - */ -export function FolderOnboardingDialog({ - open, - pathLabel, - showGroupingHint, - pending, - onConfirm, - onCancel, - onOpenChange, -}: FolderOnboardingDialogProps) { - return ( - - - {open ? ( - - ) : null} - - - ); -} diff --git a/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx b/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx index 95c81a703..558998bb4 100644 --- a/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx +++ b/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx @@ -1,5 +1,8 @@ import { useRef } from "react"; -import { ThreadRenameDialogContent } from "./ThreadRenameDialog"; +import { + ThreadRenameDialogContent, + type ThreadRenameDialogTarget, +} from "./ThreadRenameDialog"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { DialogStage } from "../../../.ladle/story-dialog-stage"; @@ -9,65 +12,87 @@ export default { const noop = () => {}; -function RenameStory({ - draft, - validationMessage = null, - pending = false, -}: { - draft: string; - validationMessage?: string | null; - pending?: boolean; -}) { - const inputRef = useRef(null); - return ( - - - - ); -} +const defaultTarget: ThreadRenameDialogTarget = { + id: "thr_demo", + currentTitle: "Audit recurring permission failures", +}; + +const parentTarget: ThreadRenameDialogTarget = { + id: "thr_parent", + currentTitle: "Frontend Parent", +}; + +const longTitleTarget: ThreadRenameDialogTarget = { + id: "thr_long", + currentTitle: + "Investigate slow tests on recurring CI failures after the timeline pagination v2 merge", +}; export function Overview() { + const inputRef = useRef(null); return ( - - + + + + - - - - - - - + + + - + + + - + + + + + + + + ); diff --git a/apps/app/src/components/dialogs/ThreadRenameDialog.tsx b/apps/app/src/components/dialogs/ThreadRenameDialog.tsx index e074ba0c5..d45e3e2d5 100644 --- a/apps/app/src/components/dialogs/ThreadRenameDialog.tsx +++ b/apps/app/src/components/dialogs/ThreadRenameDialog.tsx @@ -1,44 +1,28 @@ -import { useId, type FormEvent, type RefObject } from "react"; +import { capitalize } from "@bb/thread-view"; +import { useId, useState, type FormEvent, type RefObject } from "react"; import { Button } from "@/components/ui/button.js"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog.js"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog.js"; import { Input } from "@/components/ui/input.js"; -import { - formatFolderPathLabel, - parseThreadFolderPath, -} from "@/components/sidebar/folderPath"; +import { useNameValidation } from "./useNameValidation.js"; import { useRenameDialogAutoFocus } from "./useRenameDialogAutoFocus.js"; export interface ThreadRenameDialogTarget { id: string; + currentTitle: string; } interface ThreadRenameDialogProps { target: ThreadRenameDialogTarget | null; - // The draft is lifted into the provider so it survives a rename → first-folder - // modal → rename round trip; the dialog renders it as a controlled input. - draft: string; - validationMessage: string | null; pending?: boolean; - onDraftChange: (value: string) => void; - onSubmit: () => void; onOpenChange: (open: boolean) => void; + onRename: (threadId: string, title: string) => void; } export function ThreadRenameDialog({ target, - draft, - validationMessage, pending = false, - onDraftChange, - onSubmit, onOpenChange, + onRename, }: ThreadRenameDialogProps) { const { inputRef, handleOpenAutoFocus } = useRenameDialogAutoFocus(); return ( @@ -46,11 +30,10 @@ export function ThreadRenameDialog({ {target ? ( ) : null} @@ -60,74 +43,66 @@ export function ThreadRenameDialog({ } export interface ThreadRenameDialogContentProps { - draft: string; - validationMessage: string | null; + target: ThreadRenameDialogTarget; pending: boolean; - onDraftChange: (value: string) => void; - onSubmit: () => void; + onRename: (threadId: string, title: string) => void; inputRef: RefObject; } -// Reveals the folder the row normally hides: parses the current draft and shows -// the resulting folder ancestors + leaf, or "No folder" for a single segment. -function RenameFolderPreview({ draft }: { draft: string }) { - const { folders, leaf } = parseThreadFolderPath(draft); - if (folders.length === 0) { - return

No folder

; - } - return ( -

- Folder:{" "} - {formatFolderPathLabel(folders)} - {" · "} - Thread: {leaf} -

- ); -} - export function ThreadRenameDialogContent({ - draft, - validationMessage, + target, pending, - onDraftChange, - onSubmit, + onRename, inputRef, }: ThreadRenameDialogContentProps) { const inputId = useId(); + const [nextTitle, setNextTitle] = useState(target.currentTitle); + const label = "thread"; + const { validationMessage, validate, clearMessage } = useNameValidation({ + emptyMessage: `${capitalize(label)} name cannot be empty.`, + }); const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (pending) return; - onSubmit(); + + const trimmedTitle = validate(nextTitle); + if (trimmedTitle === null) return; + + onRename(target.id, trimmedTitle); }; return ( <> - Rename thread - Choose a new name for this thread. + Rename {label} + + Choose a new name for this {label}. +
onDraftChange(event.target.value)} + onChange={(event) => { + setNextTitle(event.target.value); + clearMessage(); + }} /> - {validationMessage ? (

{validationMessage}

) : null}
diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 035645aa0..f380325e4 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -70,6 +70,7 @@ import type { ProjectThreadListState } from "./ProjectRow"; import { compareByCreatedAtDescending, compareStandardThreads, + isSidebarProjectThread, type ThreadComparator, } from "./projectThreadGroups"; import { @@ -97,7 +98,7 @@ import { type SidebarOrganizationMode, type SidebarSectionId, } from "./sidebarCollapsedAtoms"; -import { folderAncestorKeys } from "./folderPath"; +import { folderAncestorKeys, titleCreatesFolder } from "./folderPath"; import { CHRONOLOGICAL_CONTAINER_ID, PINNED_CONTAINER_ID, @@ -175,6 +176,7 @@ interface ProjectListThreadsSectionActionsProps { } interface SidebarViewOptionsMenuProps { + folderGroupingAvailable?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; onOrganizationModeSelect?: (mode: SidebarOrganizationMode) => void; @@ -469,18 +471,21 @@ function SidebarOrganizeMenuSectionLabel({ } interface SidebarOrganizeMenuOptionProps { + disabled?: boolean; label: string; selected: boolean; onSelect: (event: Event) => void; } function SidebarOrganizeMenuOption({ + disabled = false, label, selected, onSelect, }: SidebarOrganizeMenuOptionProps) { return ( @@ -500,6 +505,7 @@ function SidebarOrganizeMenuOption({ // headers. The organization mode is global, so either header's menu drives the // whole sidebar. export function SidebarViewOptionsMenu({ + folderGroupingAvailable = true, open, onOpenChange, onOrganizationModeSelect, @@ -598,9 +604,13 @@ export function SidebarViewOptionsMenu({ /> { event.preventDefault(); + if (!folderGroupingAvailable) { + return; + } setGroupBy("folder"); }} /> @@ -1017,6 +1027,15 @@ function ProjectListComponent({ } return map; }, [threads]); + const folderGroupingAvailable = useMemo( + () => + threads.some( + (thread) => + isSidebarProjectThread(thread) && + titleCreatesFolder(thread.title ?? ""), + ), + [threads], + ); const projectsState = useConnectionAwareQueryState({ hasResolvedData: projects !== undefined, isFetching: sidebarNavigationQuery.isFetching, @@ -1589,6 +1608,7 @@ function ProjectListComponent({ const projectsSectionActions = ( <> diff --git a/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx b/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx index dd38ab475..5fcf281f7 100644 --- a/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx @@ -12,9 +12,11 @@ export default { }; function MenuStory({ + folderGroupingAvailable = true, groupBy, sort, }: { + folderGroupingAvailable?: boolean; groupBy: "none" | "folder"; sort: "updated" | "created" | "none"; }) { @@ -28,7 +30,10 @@ function MenuStory({ return (
- +
); @@ -43,6 +48,13 @@ export function Overview() { + + + ); } diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index 9c9742f2b..4b6993ca5 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -10,7 +10,6 @@ const ORGANIZATION_MODE_STORAGE_KEY = "bb.sidebar.organizationMode"; const CHRONOLOGICAL_SORT_STORAGE_KEY = "bb.sidebar.chronologicalSort"; const GROUP_BY_STORAGE_KEY = "bb.sidebar.groupBy"; const COLLAPSED_FOLDERS_STORAGE_KEY = "bb.sidebar.collapsedFolders"; -const FOLDER_ONBOARDING_SEEN_STORAGE_KEY = "bb.sidebar.folderOnboardingSeen"; const MANUAL_ORDER_STORAGE_KEY = "bb.sidebar.manualOrder"; export type SidebarSectionId = "pinned" | "projects" | "threads"; @@ -107,15 +106,6 @@ export const sidebarCollapsedFoldersAtom = atomWithStorage( { getOnInit: true }, ); -// Whether the first-folder onboarding modal has been accepted. Set on accept -// (not on open), so a declined modal still teaches on a later attempt. -export const folderOnboardingSeenAtom = atomWithStorage( - FOLDER_ONBOARDING_SEEN_STORAGE_KEY, - false, - createJsonLocalStorage(), - { getOnInit: true }, -); - export const sidebarManualOrderAtom = atomWithStorage( MANUAL_ORDER_STORAGE_KEY, {}, diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx index d51494d1e..cf79ff9e4 100644 --- a/apps/app/src/components/thread/ThreadActionsProvider.tsx +++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx @@ -5,10 +5,8 @@ import { useEffect, useMemo, useRef, - useState, type ReactNode, } from "react"; -import { useAtom } from "jotai"; import { useNavigate } from "react-router-dom"; import { appToast } from "@/components/ui/app-toast"; import { defaultExperiments, type Thread } from "@bb/domain"; @@ -34,17 +32,6 @@ import { ThreadRenameDialog, type ThreadRenameDialogTarget, } from "@/components/dialogs/ThreadRenameDialog"; -import { FolderOnboardingDialog } from "@/components/dialogs/FolderOnboardingDialog"; -import { - formatFolderPathLabel, - normalizeThreadTitle, - parseThreadFolderPath, - titleCreatesFolder, -} from "@/components/sidebar/folderPath"; -import { - folderOnboardingSeenAtom, - sidebarGroupByAtom, -} from "@/components/sidebar/sidebarCollapsedAtoms"; import { ThreadDeleteDialog, type ThreadDeleteDialogTarget, @@ -98,13 +85,6 @@ interface ThreadActionContext { childThreadCount: number; } -// Full breadcrumb segments for the first-folder modal preview -// ("Work › Q3 › Planning"): the folder ancestors plus the leaf. -function folderPreviewSegments(title: string): string[] { - const { folders, leaf } = parseThreadFolderPath(title); - return [...folders, leaf]; -} - export function ThreadActionsProvider({ children, }: ThreadActionsProviderProps) { @@ -141,23 +121,6 @@ export function ThreadActionsProvider({ const { onClose: closeRenameDialog, onOpen: openRenameDialog } = renameDialog; const { onClose: closeDeleteDialog, onOpen: openDeleteDialog } = deleteDialog; - // The rename draft is lifted here (not local to the dialog) so it survives a - // rename → first-folder modal → rename round trip. - const [renameDraft, setRenameDraft] = useState(""); - const [renameValidationError, setRenameValidationError] = useState< - string | null - >(null); - // Stashed rename awaiting first-folder confirmation; null when the modal is - // closed. Holds the raw draft so a decline can reopen rename unchanged. - const [pendingFolderRename, setPendingFolderRename] = useState<{ - threadId: string; - draft: string; - } | null>(null); - const [folderOnboardingSeen, setFolderOnboardingSeen] = useAtom( - folderOnboardingSeenAtom, - ); - const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); - useEffect(() => { return () => { threadActionContextAbortRef.current?.abort(); @@ -179,88 +142,26 @@ export function ThreadActionsProvider({ const requestRename = useCallback( (thread: Thread) => { - // Seed from the raw stored title (not the display fallback) so an - // untitled thread never parses "Thread xxxx" as a folder path. - setRenameDraft(thread.title ?? ""); - setRenameValidationError(null); - openRenameDialog({ id: thread.id }); + openRenameDialog({ + id: thread.id, + currentTitle: getThreadDisplayTitle(thread), + }); }, [openRenameDialog], ); - const handleRenameDraftChange = useCallback((value: string) => { - setRenameDraft(value); - setRenameValidationError(null); - }, []); - - const submitRename = useCallback(() => { - const target = renameDialog.target; - if (!target) return; - const normalized = normalizeThreadTitle(renameDraft); - if (!normalized) { - setRenameValidationError("Thread name cannot be empty."); - return; - } - // First time a rename creates a folder: stash and teach via the modal - // before submitting. Afterwards (seen === true) slash renames submit directly. - if (titleCreatesFolder(normalized) && !folderOnboardingSeen) { - setPendingFolderRename({ threadId: target.id, draft: renameDraft }); - closeRenameDialog(); - return; - } - updateMutate( - { id: target.id, title: normalized }, - { onSuccess: () => closeRenameDialog() }, - ); - }, [ - closeRenameDialog, - folderOnboardingSeen, - renameDialog.target, - renameDraft, - updateMutate, - ]); - - const handleFolderOnboardingConfirm = useCallback(() => { - if (!pendingFolderRename) return; - const { threadId, draft } = pendingFolderRename; - updateMutate( - { id: threadId, title: normalizeThreadTitle(draft) }, - { - onSuccess: () => { - setFolderOnboardingSeen(true); - // Auto-enable folder grouping once, so the new folder is visible. - if (groupBy === "none") { - setGroupBy("folder"); - } - setPendingFolderRename(null); + const submitRename = useCallback( + (threadId: string, title: string) => { + updateMutate( + { id: threadId, title }, + { + onSuccess: () => { + closeRenameDialog(); + }, }, - }, - ); - }, [ - groupBy, - pendingFolderRename, - setFolderOnboardingSeen, - setGroupBy, - updateMutate, - ]); - - const handleFolderOnboardingCancel = useCallback(() => { - if (!pendingFolderRename) return; - // Reopen rename seeded from the stashed draft; seen stays false so the - // modal still teaches on a later attempt. - setRenameDraft(pendingFolderRename.draft); - setRenameValidationError(null); - openRenameDialog({ id: pendingFolderRename.threadId }); - setPendingFolderRename(null); - }, [openRenameDialog, pendingFolderRename]); - - const handleFolderOnboardingOpenChange = useCallback( - (open: boolean) => { - if (!open) { - handleFolderOnboardingCancel(); - } + ); }, - [handleFolderOnboardingCancel], + [closeRenameDialog, updateMutate], ); // Fetches the delete dialog context. Returns null when the caller's request @@ -503,27 +404,9 @@ export function ThreadActionsProvider({ {children} - Date: Thu, 18 Jun 2026 22:11:34 -0700 Subject: [PATCH 08/54] Restore first folder onboarding flow --- .../FolderOnboardingDialog.stories.tsx | 53 +++++++++ .../dialogs/FolderOnboardingDialog.tsx | 110 ++++++++++++++++++ .../sidebar/sidebarCollapsedAtoms.ts | 10 ++ .../thread/ThreadActionsProvider.tsx | 105 ++++++++++++++++- 4 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx create mode 100644 apps/app/src/components/dialogs/FolderOnboardingDialog.tsx diff --git a/apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx b/apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx new file mode 100644 index 000000000..dc588739f --- /dev/null +++ b/apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx @@ -0,0 +1,53 @@ +import { StoryCard, StoryRow } from "../../../.ladle/story-card"; +import { DialogStage } from "../../../.ladle/story-dialog-stage"; +import { FolderOnboardingDialogContent } from "./FolderOnboardingDialog"; + +export default { + title: "dialogs/Folder onboarding", +}; + +const noop = () => {}; + +function OnboardingStory({ + pathLabel, + pending = false, + showGroupingHint = true, +}: { + pathLabel: string; + pending?: boolean; + showGroupingHint?: boolean; +}) { + return ( + + + + ); +} + +export function Overview() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/src/components/dialogs/FolderOnboardingDialog.tsx b/apps/app/src/components/dialogs/FolderOnboardingDialog.tsx new file mode 100644 index 000000000..1df41f0d4 --- /dev/null +++ b/apps/app/src/components/dialogs/FolderOnboardingDialog.tsx @@ -0,0 +1,110 @@ +import { Button } from "@/components/ui/button.js"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.js"; + +interface FolderOnboardingDialogContentProps { + // The user's entry previewed as breadcrumbs ("Work › Q3 › Planning"). + pathLabel: string; + // Only shown when grouping is currently off, since accepting turns it on. + showGroupingHint: boolean; + pending: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * Body of the first-folder confirmation: explains that "/" files a thread into + * a folder, previews the resulting path, and offers Create / Cancel. Split from + * the modal shell so stories can render it without the overlay (mirrors + * {@link ConfirmDeleteDialogContent}). + */ +export function FolderOnboardingDialogContent({ + pathLabel, + showGroupingHint, + pending, + onConfirm, + onCancel, +}: FolderOnboardingDialogContentProps) { + return ( + <> + + Organize threads into folders + + Using “/” groups this thread into a folder: + + +
+
+ {pathLabel} +
+ {showGroupingHint ? ( +

+ Folder grouping will turn on so you can see it. +

+ ) : null} +
+ + + + + + ); +} + +interface FolderOnboardingDialogProps { + open: boolean; + pathLabel: string; + showGroupingHint: boolean; + pending: boolean; + onConfirm: () => void; + onCancel: () => void; + // Esc / overlay dismiss routes back through cancel (reopen rename). + onOpenChange: (open: boolean) => void; +} + +/** + * First-folder confirmation modal, shown the first time a rename would create a + * folder. Accepting submits the rename and enables folder grouping; declining + * (button, Esc, or overlay) hands control back to the rename dialog with the + * draft intact. + */ +export function FolderOnboardingDialog({ + open, + pathLabel, + showGroupingHint, + pending, + onConfirm, + onCancel, + onOpenChange, +}: FolderOnboardingDialogProps) { + return ( + + + {open ? ( + + ) : null} + + + ); +} diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index 4b6993ca5..9c9742f2b 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -10,6 +10,7 @@ const ORGANIZATION_MODE_STORAGE_KEY = "bb.sidebar.organizationMode"; const CHRONOLOGICAL_SORT_STORAGE_KEY = "bb.sidebar.chronologicalSort"; const GROUP_BY_STORAGE_KEY = "bb.sidebar.groupBy"; const COLLAPSED_FOLDERS_STORAGE_KEY = "bb.sidebar.collapsedFolders"; +const FOLDER_ONBOARDING_SEEN_STORAGE_KEY = "bb.sidebar.folderOnboardingSeen"; const MANUAL_ORDER_STORAGE_KEY = "bb.sidebar.manualOrder"; export type SidebarSectionId = "pinned" | "projects" | "threads"; @@ -106,6 +107,15 @@ export const sidebarCollapsedFoldersAtom = atomWithStorage( { getOnInit: true }, ); +// Whether the first-folder onboarding modal has been accepted. Set on accept +// (not on open), so a declined modal still teaches on a later attempt. +export const folderOnboardingSeenAtom = atomWithStorage( + FOLDER_ONBOARDING_SEEN_STORAGE_KEY, + false, + createJsonLocalStorage(), + { getOnInit: true }, +); + export const sidebarManualOrderAtom = atomWithStorage( MANUAL_ORDER_STORAGE_KEY, {}, diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx index cf79ff9e4..8a5a22875 100644 --- a/apps/app/src/components/thread/ThreadActionsProvider.tsx +++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx @@ -5,8 +5,10 @@ import { useEffect, useMemo, useRef, + useState, type ReactNode, } from "react"; +import { useAtom } from "jotai"; import { useNavigate } from "react-router-dom"; import { appToast } from "@/components/ui/app-toast"; import { defaultExperiments, type Thread } from "@bb/domain"; @@ -32,17 +34,24 @@ import { ThreadRenameDialog, type ThreadRenameDialogTarget, } from "@/components/dialogs/ThreadRenameDialog"; +import { FolderOnboardingDialog } from "@/components/dialogs/FolderOnboardingDialog"; import { ThreadDeleteDialog, type ThreadDeleteDialogTarget, } from "@/components/dialogs/ThreadDeleteDialog"; import { ArchivedThreadToastTitle } from "@/components/thread/ArchivedThreadToastTitle"; import { destroyPersistedBrowserViewsForThread } from "@/components/secondary-panel/browserViewVisibilityCoordinator"; -import { getThreadReadToggleAction } from "@/components/sidebar/threadReadState"; import { - getRootComposeRoutePath, - getThreadRoutePath, -} from "@/lib/route-paths"; + formatFolderPathLabel, + parseThreadFolderPath, + titleCreatesFolder, +} from "@/components/sidebar/folderPath"; +import { + folderOnboardingSeenAtom, + sidebarGroupByAtom, +} from "@/components/sidebar/sidebarCollapsedAtoms"; +import { getThreadReadToggleAction } from "@/components/sidebar/threadReadState"; +import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths"; import { getDesktopBrowserApi, getDesktopPopoutApi } from "@/lib/bb-desktop"; import { useSetRootComposeProjectId } from "@/lib/root-compose-selection"; import { useSystemConfig } from "@/hooks/queries/system-queries"; @@ -85,6 +94,11 @@ interface ThreadActionContext { childThreadCount: number; } +function folderPreviewSegments(title: string): string[] { + const { folders, leaf } = parseThreadFolderPath(title); + return [...folders, leaf]; +} + export function ThreadActionsProvider({ children, }: ThreadActionsProviderProps) { @@ -100,6 +114,14 @@ export function ThreadActionsProvider({ const deleteThread = useDeleteThread(); const updateThread = useUpdateThread(); const systemConfigQuery = useSystemConfig(); + const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); + const [folderOnboardingSeen, setFolderOnboardingSeen] = useAtom( + folderOnboardingSeenAtom, + ); + const [pendingFolderRename, setPendingFolderRename] = useState<{ + threadId: string; + title: string; + } | null>(null); const threadActionContextAbortRef = useRef(null); // Destructure `.mutate` so useCallback deps see stable references across // renders. Depending on the full mutation objects would churn callback @@ -152,16 +174,70 @@ export function ThreadActionsProvider({ const submitRename = useCallback( (threadId: string, title: string) => { + if (titleCreatesFolder(title) && !folderOnboardingSeen) { + setPendingFolderRename({ threadId, title }); + closeRenameDialog(); + return; + } updateMutate( { id: threadId, title }, { onSuccess: () => { + if (groupBy === "none" && titleCreatesFolder(title)) { + setGroupBy("folder"); + } closeRenameDialog(); }, }, ); }, - [closeRenameDialog, updateMutate], + [ + closeRenameDialog, + folderOnboardingSeen, + groupBy, + setGroupBy, + updateMutate, + ], + ); + + const confirmFolderOnboarding = useCallback(() => { + if (!pendingFolderRename) return; + updateMutate( + { id: pendingFolderRename.threadId, title: pendingFolderRename.title }, + { + onSuccess: () => { + setFolderOnboardingSeen(true); + if (groupBy === "none") { + setGroupBy("folder"); + } + setPendingFolderRename(null); + }, + }, + ); + }, [ + groupBy, + pendingFolderRename, + setFolderOnboardingSeen, + setGroupBy, + updateMutate, + ]); + + const cancelFolderOnboarding = useCallback(() => { + if (!pendingFolderRename) return; + openRenameDialog({ + id: pendingFolderRename.threadId, + currentTitle: pendingFolderRename.title, + }); + setPendingFolderRename(null); + }, [openRenameDialog, pendingFolderRename]); + + const handleFolderOnboardingOpenChange = useCallback( + (open: boolean) => { + if (!open) { + cancelFolderOnboarding(); + } + }, + [cancelFolderOnboarding], ); // Fetches the delete dialog context. Returns null when the caller's request @@ -366,7 +442,9 @@ export function ThreadActionsProvider({ const experiments = systemConfigQuery.data?.experiments ?? defaultExperiments; const desktopPopout = getDesktopPopoutApi(); - const sendToPopout = useMemo(() => { + const sendToPopout = useMemo< + ThreadActionsContextValue["sendToPopout"] + >(() => { if (!experiments.popoutChat || desktopPopout === null) { return null; } @@ -408,6 +486,21 @@ export function ThreadActionsProvider({ onOpenChange={renameDialog.onOpenChange} onRename={submitRename} /> + Date: Thu, 18 Jun 2026 22:14:55 -0700 Subject: [PATCH 09/54] Auto-enable folders for existing slash titles --- .../src/components/sidebar/ProjectList.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index f380325e4..df6489302 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -89,6 +89,7 @@ import { collapsedProjectIdsAtom, collapsedSidebarSectionIdsAtom, DEFAULT_SIDEBAR_SECTION_ORDER, + folderOnboardingSeenAtom, sidebarChronologicalSortAtom, sidebarCollapsedFoldersAtom, sidebarGroupByAtom, @@ -1214,7 +1215,10 @@ function ProjectListComponent({ ); const [organizationMode] = useAtom(sidebarOrganizationModeAtom); const [chronologicalSort] = useAtom(sidebarChronologicalSortAtom); - const [groupBy] = useAtom(sidebarGroupByAtom); + const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); + const [folderOnboardingSeen, setFolderOnboardingSeen] = useAtom( + folderOnboardingSeenAtom, + ); const setCollapsedFolderList = useSetAtom(sidebarCollapsedFoldersAtom); const sidebarThreadComparator = useMemo( () => @@ -1272,6 +1276,26 @@ function ProjectListComponent({ normalizedCollapsedSidebarSectionIds, setCollapsedSidebarSectionIdList, ]); + // Existing slash-titled threads may predate the first-folder confirmation + // flow. Auto-enable grouping once so refreshes render those titles as folders, + // then mark the migration handled so a later explicit "None" choice sticks. + useEffect(() => { + if ( + !folderGroupingAvailable || + folderOnboardingSeen || + groupBy !== "none" + ) { + return; + } + setGroupBy("folder"); + setFolderOnboardingSeen(true); + }, [ + folderGroupingAvailable, + folderOnboardingSeen, + groupBy, + setFolderOnboardingSeen, + setGroupBy, + ]); const pinnedSidebarState = useMemo( () => buildPinnedSidebarState({ threads, groupBy }), [threads, groupBy], From a4b14f70a7d022fbccc3391754f353035cf244e8 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 18 Jun 2026 22:18:04 -0700 Subject: [PATCH 10/54] Inline folder rename hint --- .../FolderOnboardingDialog.stories.tsx | 53 --------- .../dialogs/FolderOnboardingDialog.tsx | 110 ------------------ .../dialogs/ThreadRenameDialog.stories.tsx | 15 +++ .../components/dialogs/ThreadRenameDialog.tsx | 18 ++- .../src/components/sidebar/ProjectList.tsx | 28 ++--- .../sidebar/sidebarCollapsedAtoms.ts | 11 +- .../thread/ThreadActionsProvider.tsx | 92 ++------------- 7 files changed, 61 insertions(+), 266 deletions(-) delete mode 100644 apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx delete mode 100644 apps/app/src/components/dialogs/FolderOnboardingDialog.tsx diff --git a/apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx b/apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx deleted file mode 100644 index dc588739f..000000000 --- a/apps/app/src/components/dialogs/FolderOnboardingDialog.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { StoryCard, StoryRow } from "../../../.ladle/story-card"; -import { DialogStage } from "../../../.ladle/story-dialog-stage"; -import { FolderOnboardingDialogContent } from "./FolderOnboardingDialog"; - -export default { - title: "dialogs/Folder onboarding", -}; - -const noop = () => {}; - -function OnboardingStory({ - pathLabel, - pending = false, - showGroupingHint = true, -}: { - pathLabel: string; - pending?: boolean; - showGroupingHint?: boolean; -}) { - return ( - - - - ); -} - -export function Overview() { - return ( - - - - - - - - - - - - - - - ); -} diff --git a/apps/app/src/components/dialogs/FolderOnboardingDialog.tsx b/apps/app/src/components/dialogs/FolderOnboardingDialog.tsx deleted file mode 100644 index 1df41f0d4..000000000 --- a/apps/app/src/components/dialogs/FolderOnboardingDialog.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Button } from "@/components/ui/button.js"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog.js"; - -interface FolderOnboardingDialogContentProps { - // The user's entry previewed as breadcrumbs ("Work › Q3 › Planning"). - pathLabel: string; - // Only shown when grouping is currently off, since accepting turns it on. - showGroupingHint: boolean; - pending: boolean; - onConfirm: () => void; - onCancel: () => void; -} - -/** - * Body of the first-folder confirmation: explains that "/" files a thread into - * a folder, previews the resulting path, and offers Create / Cancel. Split from - * the modal shell so stories can render it without the overlay (mirrors - * {@link ConfirmDeleteDialogContent}). - */ -export function FolderOnboardingDialogContent({ - pathLabel, - showGroupingHint, - pending, - onConfirm, - onCancel, -}: FolderOnboardingDialogContentProps) { - return ( - <> - - Organize threads into folders - - Using “/” groups this thread into a folder: - - -
-
- {pathLabel} -
- {showGroupingHint ? ( -

- Folder grouping will turn on so you can see it. -

- ) : null} -
- - - - - - ); -} - -interface FolderOnboardingDialogProps { - open: boolean; - pathLabel: string; - showGroupingHint: boolean; - pending: boolean; - onConfirm: () => void; - onCancel: () => void; - // Esc / overlay dismiss routes back through cancel (reopen rename). - onOpenChange: (open: boolean) => void; -} - -/** - * First-folder confirmation modal, shown the first time a rename would create a - * folder. Accepting submits the rename and enables folder grouping; declining - * (button, Esc, or overlay) hands control back to the rename dialog with the - * draft intact. - */ -export function FolderOnboardingDialog({ - open, - pathLabel, - showGroupingHint, - pending, - onConfirm, - onCancel, - onOpenChange, -}: FolderOnboardingDialogProps) { - return ( - - - {open ? ( - - ) : null} - - - ); -} diff --git a/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx b/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx index 558998bb4..db0fee1e7 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 folderTarget: ThreadRenameDialogTarget = { + id: "thr_folder", + currentTitle: "test/say hi", +}; + const longTitleTarget: ThreadRenameDialogTarget = { id: "thr_long", currentTitle: @@ -55,6 +60,16 @@ export function Overview() { /> + + + + + ) => { event.preventDefault(); @@ -99,10 +108,15 @@ export function ThreadRenameDialogContent({ {validationMessage ? (

{validationMessage}

) : null} + {createsFolder ? ( +

+ Using “/” groups this thread into a folder +

+ ) : null}
diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index df6489302..0477a9bbd 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -89,9 +89,9 @@ import { collapsedProjectIdsAtom, collapsedSidebarSectionIdsAtom, DEFAULT_SIDEBAR_SECTION_ORDER, - folderOnboardingSeenAtom, sidebarChronologicalSortAtom, sidebarCollapsedFoldersAtom, + sidebarFolderGroupingAutoEnabledAtom, sidebarGroupByAtom, sidebarOrganizationModeAtom, sidebarSectionOrderAtom, @@ -1216,8 +1216,8 @@ function ProjectListComponent({ const [organizationMode] = useAtom(sidebarOrganizationModeAtom); const [chronologicalSort] = useAtom(sidebarChronologicalSortAtom); const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); - const [folderOnboardingSeen, setFolderOnboardingSeen] = useAtom( - folderOnboardingSeenAtom, + const [folderGroupingAutoEnabled, setFolderGroupingAutoEnabled] = useAtom( + sidebarFolderGroupingAutoEnabledAtom, ); const setCollapsedFolderList = useSetAtom(sidebarCollapsedFoldersAtom); const sidebarThreadComparator = useMemo( @@ -1276,24 +1276,24 @@ function ProjectListComponent({ normalizedCollapsedSidebarSectionIds, setCollapsedSidebarSectionIdList, ]); - // Existing slash-titled threads may predate the first-folder confirmation - // flow. Auto-enable grouping once so refreshes render those titles as folders, - // then mark the migration handled so a later explicit "None" choice sticks. + // Existing slash-titled threads may predate folder grouping. Auto-enable once + // so refreshes render those titles as folders, then mark it handled so a + // later explicit "None" choice sticks. useEffect(() => { - if ( - !folderGroupingAvailable || - folderOnboardingSeen || - groupBy !== "none" - ) { + if (!folderGroupingAvailable || folderGroupingAutoEnabled) { + return; + } + if (groupBy === "folder") { + setFolderGroupingAutoEnabled(true); return; } setGroupBy("folder"); - setFolderOnboardingSeen(true); + setFolderGroupingAutoEnabled(true); }, [ + folderGroupingAutoEnabled, folderGroupingAvailable, - folderOnboardingSeen, groupBy, - setFolderOnboardingSeen, + setFolderGroupingAutoEnabled, setGroupBy, ]); const pinnedSidebarState = useMemo( diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index 9c9742f2b..8da02e3fe 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -10,7 +10,8 @@ const ORGANIZATION_MODE_STORAGE_KEY = "bb.sidebar.organizationMode"; const CHRONOLOGICAL_SORT_STORAGE_KEY = "bb.sidebar.chronologicalSort"; const GROUP_BY_STORAGE_KEY = "bb.sidebar.groupBy"; const COLLAPSED_FOLDERS_STORAGE_KEY = "bb.sidebar.collapsedFolders"; -const FOLDER_ONBOARDING_SEEN_STORAGE_KEY = "bb.sidebar.folderOnboardingSeen"; +const FOLDER_GROUPING_AUTO_ENABLED_STORAGE_KEY = + "bb.sidebar.folderGroupingAutoEnabled"; const MANUAL_ORDER_STORAGE_KEY = "bb.sidebar.manualOrder"; export type SidebarSectionId = "pinned" | "projects" | "threads"; @@ -107,10 +108,10 @@ export const sidebarCollapsedFoldersAtom = atomWithStorage( { getOnInit: true }, ); -// Whether the first-folder onboarding modal has been accepted. Set on accept -// (not on open), so a declined modal still teaches on a later attempt. -export const folderOnboardingSeenAtom = atomWithStorage( - FOLDER_ONBOARDING_SEEN_STORAGE_KEY, +// Whether slash-titled threads have already auto-enabled folder grouping. Once +// true, a later explicit Group by: None choice should stick across refreshes. +export const sidebarFolderGroupingAutoEnabledAtom = atomWithStorage( + FOLDER_GROUPING_AUTO_ENABLED_STORAGE_KEY, false, createJsonLocalStorage(), { getOnInit: true }, diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx index 8a5a22875..3a0fea4a4 100644 --- a/apps/app/src/components/thread/ThreadActionsProvider.tsx +++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx @@ -5,7 +5,6 @@ import { useEffect, useMemo, useRef, - useState, type ReactNode, } from "react"; import { useAtom } from "jotai"; @@ -34,20 +33,15 @@ import { ThreadRenameDialog, type ThreadRenameDialogTarget, } from "@/components/dialogs/ThreadRenameDialog"; -import { FolderOnboardingDialog } from "@/components/dialogs/FolderOnboardingDialog"; import { ThreadDeleteDialog, type ThreadDeleteDialogTarget, } from "@/components/dialogs/ThreadDeleteDialog"; import { ArchivedThreadToastTitle } from "@/components/thread/ArchivedThreadToastTitle"; import { destroyPersistedBrowserViewsForThread } from "@/components/secondary-panel/browserViewVisibilityCoordinator"; +import { titleCreatesFolder } from "@/components/sidebar/folderPath"; import { - formatFolderPathLabel, - parseThreadFolderPath, - titleCreatesFolder, -} from "@/components/sidebar/folderPath"; -import { - folderOnboardingSeenAtom, + sidebarFolderGroupingAutoEnabledAtom, sidebarGroupByAtom, } from "@/components/sidebar/sidebarCollapsedAtoms"; import { getThreadReadToggleAction } from "@/components/sidebar/threadReadState"; @@ -94,11 +88,6 @@ interface ThreadActionContext { childThreadCount: number; } -function folderPreviewSegments(title: string): string[] { - const { folders, leaf } = parseThreadFolderPath(title); - return [...folders, leaf]; -} - export function ThreadActionsProvider({ children, }: ThreadActionsProviderProps) { @@ -115,13 +104,9 @@ export function ThreadActionsProvider({ const updateThread = useUpdateThread(); const systemConfigQuery = useSystemConfig(); const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); - const [folderOnboardingSeen, setFolderOnboardingSeen] = useAtom( - folderOnboardingSeenAtom, + const [, setFolderGroupingAutoEnabled] = useAtom( + sidebarFolderGroupingAutoEnabledAtom, ); - const [pendingFolderRename, setPendingFolderRename] = useState<{ - threadId: string; - title: string; - } | null>(null); const threadActionContextAbortRef = useRef(null); // Destructure `.mutate` so useCallback deps see stable references across // renders. Depending on the full mutation objects would churn callback @@ -174,17 +159,15 @@ export function ThreadActionsProvider({ const submitRename = useCallback( (threadId: string, title: string) => { - if (titleCreatesFolder(title) && !folderOnboardingSeen) { - setPendingFolderRename({ threadId, title }); - closeRenameDialog(); - return; - } updateMutate( { id: threadId, title }, { onSuccess: () => { - if (groupBy === "none" && titleCreatesFolder(title)) { - setGroupBy("folder"); + if (titleCreatesFolder(title)) { + setFolderGroupingAutoEnabled(true); + if (groupBy === "none") { + setGroupBy("folder"); + } } closeRenameDialog(); }, @@ -193,53 +176,13 @@ export function ThreadActionsProvider({ }, [ closeRenameDialog, - folderOnboardingSeen, groupBy, + setFolderGroupingAutoEnabled, setGroupBy, updateMutate, ], ); - const confirmFolderOnboarding = useCallback(() => { - if (!pendingFolderRename) return; - updateMutate( - { id: pendingFolderRename.threadId, title: pendingFolderRename.title }, - { - onSuccess: () => { - setFolderOnboardingSeen(true); - if (groupBy === "none") { - setGroupBy("folder"); - } - setPendingFolderRename(null); - }, - }, - ); - }, [ - groupBy, - pendingFolderRename, - setFolderOnboardingSeen, - setGroupBy, - updateMutate, - ]); - - const cancelFolderOnboarding = useCallback(() => { - if (!pendingFolderRename) return; - openRenameDialog({ - id: pendingFolderRename.threadId, - currentTitle: pendingFolderRename.title, - }); - setPendingFolderRename(null); - }, [openRenameDialog, pendingFolderRename]); - - const handleFolderOnboardingOpenChange = useCallback( - (open: boolean) => { - if (!open) { - cancelFolderOnboarding(); - } - }, - [cancelFolderOnboarding], - ); - // Fetches the delete dialog context. Returns null when the caller's request // was superseded (a newer click aborted us) or the fetch errored; in the // error case, also surfaces a toast before returning. @@ -486,21 +429,6 @@ export function ThreadActionsProvider({ onOpenChange={renameDialog.onOpenChange} onRename={submitRename} /> - Date: Thu, 18 Jun 2026 22:23:58 -0700 Subject: [PATCH 11/54] Organize sidebar by cross-project folders --- .../src/components/sidebar/ProjectList.tsx | 70 +++++++++++-------- .../SidebarViewOptionsMenu.stories.tsx | 20 ++++-- .../thread/ThreadActionsProvider.tsx | 4 ++ 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 0477a9bbd..e91edab29 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -518,6 +518,9 @@ export function SidebarViewOptionsMenu({ sidebarChronologicalSortAtom, ); const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); + const setFolderGroupingAutoEnabled = useSetAtom( + sidebarFolderGroupingAutoEnabledAtom, + ); return ( @@ -547,19 +550,42 @@ export function SidebarViewOptionsMenu({ { event.preventDefault(); setOrganizationMode("project"); + setGroupBy("none"); + if (folderGroupingAvailable) { + setFolderGroupingAutoEnabled(true); + } onOrganizationModeSelect?.("project"); }} /> + { + event.preventDefault(); + if (!folderGroupingAvailable) { + return; + } + setOrganizationMode("chronological"); + setGroupBy("folder"); + setFolderGroupingAutoEnabled(true); + onOrganizationModeSelect?.("chronological"); + }} + /> { event.preventDefault(); setOrganizationMode("chronological"); + setGroupBy("none"); + if (folderGroupingAvailable) { + setFolderGroupingAutoEnabled(true); + } onOrganizationModeSelect?.("chronological"); }} /> @@ -591,30 +617,6 @@ export function SidebarViewOptionsMenu({ setChronologicalSort("none"); }} /> - - - Group by - - { - event.preventDefault(); - setGroupBy("none"); - }} - /> - { - event.preventDefault(); - if (!folderGroupingAvailable) { - return; - } - setGroupBy("folder"); - }} - /> ); @@ -1213,7 +1215,9 @@ function ProjectListComponent({ }, [], ); - const [organizationMode] = useAtom(sidebarOrganizationModeAtom); + const [organizationMode, setOrganizationMode] = useAtom( + sidebarOrganizationModeAtom, + ); const [chronologicalSort] = useAtom(sidebarChronologicalSortAtom); const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); const [folderGroupingAutoEnabled, setFolderGroupingAutoEnabled] = useAtom( @@ -1284,17 +1288,25 @@ function ProjectListComponent({ return; } if (groupBy === "folder") { + if (organizationMode !== "chronological") { + setOrganizationMode("chronological"); + } setFolderGroupingAutoEnabled(true); return; } + if (organizationMode !== "chronological") { + setOrganizationMode("chronological"); + } setGroupBy("folder"); setFolderGroupingAutoEnabled(true); }, [ folderGroupingAutoEnabled, folderGroupingAvailable, groupBy, + organizationMode, setFolderGroupingAutoEnabled, setGroupBy, + setOrganizationMode, ]); const pinnedSidebarState = useMemo( () => buildPinnedSidebarState({ threads, groupBy }), @@ -1692,7 +1704,7 @@ function ProjectListComponent({ ); } - if (organizationMode === "chronological") { + if (organizationMode === "chronological" || groupBy === "folder") { return (
@@ -1702,7 +1714,7 @@ function ProjectListComponent({ ) : null} { const next = createStore(); next.set(sidebarChronologicalSortAtom, sort); next.set(sidebarGroupByAtom, groupBy); + next.set(sidebarOrganizationModeAtom, organizationMode); return next; - }, [groupBy, sort]); + }, [groupBy, organizationMode, sort]); return ( @@ -42,17 +47,22 @@ function MenuStory({ export function Overview() { return ( - - + + - - + + diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx index 3a0fea4a4..bcccb7f76 100644 --- a/apps/app/src/components/thread/ThreadActionsProvider.tsx +++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx @@ -43,6 +43,7 @@ import { titleCreatesFolder } from "@/components/sidebar/folderPath"; import { sidebarFolderGroupingAutoEnabledAtom, sidebarGroupByAtom, + sidebarOrganizationModeAtom, } from "@/components/sidebar/sidebarCollapsedAtoms"; import { getThreadReadToggleAction } from "@/components/sidebar/threadReadState"; import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths"; @@ -104,6 +105,7 @@ export function ThreadActionsProvider({ const updateThread = useUpdateThread(); const systemConfigQuery = useSystemConfig(); const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); + const [, setOrganizationMode] = useAtom(sidebarOrganizationModeAtom); const [, setFolderGroupingAutoEnabled] = useAtom( sidebarFolderGroupingAutoEnabledAtom, ); @@ -165,6 +167,7 @@ export function ThreadActionsProvider({ onSuccess: () => { if (titleCreatesFolder(title)) { setFolderGroupingAutoEnabled(true); + setOrganizationMode("chronological"); if (groupBy === "none") { setGroupBy("folder"); } @@ -179,6 +182,7 @@ export function ThreadActionsProvider({ groupBy, setFolderGroupingAutoEnabled, setGroupBy, + setOrganizationMode, updateMutate, ], ); From 5c08aa2d76fbf7356e9894114384c3e926526368 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 18 Jun 2026 23:21:07 -0700 Subject: [PATCH 12/54] Store sidebar folder paths explicitly --- apps/app/.ladle/story-fixtures.ts | 2 + .../components/dialogs/ThreadRenameDialog.tsx | 20 +- .../ThreadMetadataContent.test.tsx | 1 + .../sidebar/FolderGrouping.stories.tsx | 146 +- .../src/components/sidebar/ProjectList.tsx | 140 +- .../app/src/components/sidebar/ProjectRow.tsx | 140 +- .../sidebar/SidebarFolderRow.stories.tsx | 6 - .../components/sidebar/SidebarFolderRow.tsx | 14 +- .../sidebar/SidebarThreadSearchPanel.test.tsx | 1 + .../SidebarViewOptionsMenu.stories.tsx | 33 +- .../src/components/sidebar/folderPath.test.ts | 121 +- apps/app/src/components/sidebar/folderPath.ts | 99 +- .../sidebar/pinnedSidebarThreads.test.ts | 19 +- .../sidebar/pinnedSidebarThreads.ts | 6 +- .../sidebar/projectThreadGroups.test.ts | 76 +- .../components/sidebar/projectThreadGroups.ts | 52 +- .../sidebar/sidebarCollapsedAtoms.ts | 23 +- .../thread/ThreadActionsProvider.tsx | 33 +- .../thread-state-cache-owner.test.ts | 1 + .../cache-owners/thread-state-cache-owner.ts | 29 +- .../mutations/thread-state-mutations.test.tsx | 71 + .../hooks/mutations/thread-state-mutations.ts | 8 +- .../src/hooks/queries/query-helpers.test.ts | 1 + .../hooks/threadMentionSuggestions.test.ts | 1 + .../hooks/useForkThreadFromMessage.test.tsx | 7 +- apps/app/src/lib/fork-thread-request.test.ts | 1 + .../views/RootComposeMobileRecents.test.tsx | 1 + apps/app/src/views/RootComposeView.test.ts | 1 + .../ThreadDetailSecondaryContent.test.tsx | 1 + .../threadParentSelectorOptions.test.ts | 1 + apps/server/src/routes/threads/base.ts | 17 + .../threads/thread-runtime-display.ts | 1 + .../db/drizzle/0042_thread_folder_path.sql | 1 + packages/db/drizzle/meta/0042_snapshot.json | 2732 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 9 +- packages/db/src/data/threads.ts | 6 +- packages/db/src/schema.ts | 1 + packages/db/test/data/threads.test.ts | 26 + packages/domain/src/thread.ts | 1 + packages/server-contract/src/api/threads.ts | 2 + .../server-contract/test/contract.test.ts | 2 + 41 files changed, 3222 insertions(+), 631 deletions(-) create mode 100644 packages/db/drizzle/0042_thread_folder_path.sql create mode 100644 packages/db/drizzle/meta/0042_snapshot.json 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/components/dialogs/ThreadRenameDialog.tsx b/apps/app/src/components/dialogs/ThreadRenameDialog.tsx index 843b5f09d..ad9025dcf 100644 --- a/apps/app/src/components/dialogs/ThreadRenameDialog.tsx +++ b/apps/app/src/components/dialogs/ThreadRenameDialog.tsx @@ -10,7 +10,10 @@ import { DialogTitle, } from "@/components/ui/dialog.js"; import { Input } from "@/components/ui/input.js"; -import { titleCreatesFolder } from "@/components/sidebar/folderPath"; +import { + parseThreadFolderShortcut, + titleCreatesFolder, +} from "@/components/sidebar/folderPath"; import { useNameValidation } from "./useNameValidation.js"; import { useRenameDialogAutoFocus } from "./useRenameDialogAutoFocus.js"; @@ -19,11 +22,16 @@ export interface ThreadRenameDialogTarget { currentTitle: string; } +export interface ThreadRenameDialogPayload { + title: string; + folderPath?: string | null; +} + interface ThreadRenameDialogProps { target: ThreadRenameDialogTarget | null; pending?: boolean; onOpenChange: (open: boolean) => void; - onRename: (threadId: string, title: string) => void; + onRename: (threadId: string, payload: ThreadRenameDialogPayload) => void; } export function ThreadRenameDialog({ @@ -53,7 +61,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; } @@ -78,7 +86,11 @@ export function ThreadRenameDialogContent({ const trimmedTitle = validate(nextTitle); if (trimmedTitle === null) return; - onRename(target.id, trimmedTitle); + const folderShortcut = parseThreadFolderShortcut(trimmedTitle); + onRename( + target.id, + folderShortcut.folderPath ? folderShortcut : { title: trimmedTitle }, + ); }; return ( diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx index 78a4a6f51..a301569ed 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx @@ -14,6 +14,7 @@ function makeThread(overrides: Partial = {}): Thread { providerId: "codex", title: null, titleFallback: null, + folderPath: null, status: "idle", parentThreadId: null, sourceThreadId: null, diff --git a/apps/app/src/components/sidebar/FolderGrouping.stories.tsx b/apps/app/src/components/sidebar/FolderGrouping.stories.tsx index a02597678..2e8bc74d5 100644 --- a/apps/app/src/components/sidebar/FolderGrouping.stories.tsx +++ b/apps/app/src/components/sidebar/FolderGrouping.stories.tsx @@ -1,5 +1,4 @@ -import { useMemo, type ReactNode } from "react"; -import { createStore, Provider as JotaiProvider } from "jotai"; +import type { ReactNode } from "react"; import type { ThreadListEntry } from "@bb/domain"; import { PROJECT_IDS, @@ -15,14 +14,6 @@ import { type ProjectThreadListState, } from "./ProjectRow"; import { compareStandardThreads } from "./projectThreadGroups"; -import { - sidebarChronologicalSortAtom, - sidebarGroupByAtom, - sidebarManualOrderAtom, - type SidebarChronologicalSort, - type SidebarGroupBy, - type SidebarManualOrder, -} from "./sidebarCollapsedAtoms"; export default { title: "sidebar/Folder grouping", @@ -42,32 +33,37 @@ function makeThread(overrides: Partial): ThreadListEntry { const folderThreads: ThreadListEntry[] = [ makeThread({ id: "thr_work_plan", - title: "Work/Q3/Plan", + title: "Plan", + folderPath: "Work/Q3", latestAttentionAt: 90, createdAt: 90, }), makeThread({ id: "thr_work_notes", - title: "Work/Q3/Notes", + title: "Notes", + folderPath: "Work/Q3", latestAttentionAt: 80, createdAt: 80, }), makeThread({ id: "thr_work_parent", - title: "Work/Q4/Kickoff", + title: "Kickoff", + folderPath: "Work/Q4", latestAttentionAt: 70, createdAt: 70, }), makeThread({ id: "thr_work_child", parentThreadId: "thr_work_parent", - title: "Ignored/Child/Path", + title: "Child path stays literal / not a folder", + folderPath: "Ignored/Child", latestAttentionAt: 65, createdAt: 65, }), makeThread({ id: "thr_personal_plan", - title: "Personal/Q3/Plan", + title: "Plan", + folderPath: "Personal/Q3", latestAttentionAt: 60, createdAt: 60, }), @@ -79,7 +75,8 @@ const folderThreads: ThreadListEntry[] = [ }), makeThread({ id: "thr_env_a", - title: "Work/Build/Daemon", + title: "Daemon", + folderPath: "Work/Build", environmentId: "env_story_folder", environmentName: "Folder build", environmentBranchName: "bb/sidebar-folders", @@ -89,7 +86,8 @@ const folderThreads: ThreadListEntry[] = [ }), makeThread({ id: "thr_env_b", - title: "Work/Build/Stories", + title: "Stories", + folderPath: "Work/Build", environmentId: "env_story_folder", environmentName: "Folder build", environmentBranchName: "bb/sidebar-folders", @@ -100,65 +98,15 @@ const folderThreads: ThreadListEntry[] = [ }), ]; -const manualThreads: ThreadListEntry[] = [ - makeThread({ id: "thr_a", title: "Work/Q3/Plan", latestAttentionAt: 90 }), - makeThread({ id: "thr_b", title: "Work/Q3/Notes", latestAttentionAt: 80 }), - makeThread({ id: "thr_c", title: "Work/Q4/Kickoff", latestAttentionAt: 70 }), - makeThread({ id: "thr_d", title: "Personal/Plan", latestAttentionAt: 60 }), - makeThread({ id: "thr_e", title: "Loose thread", latestAttentionAt: 50 }), -]; - -const manualOrder: SidebarManualOrder = { - [PROJECT_ID]: ["thr_e", `${PROJECT_ID}::Personal`, `${PROJECT_ID}::Work`], - [`${PROJECT_ID}::Work`]: [`${PROJECT_ID}::Work/Q4`, `${PROJECT_ID}::Work/Q3`], - [`${PROJECT_ID}::Work/Q3`]: ["thr_b", "thr_a"], -}; - -function SidebarState({ - children, - groupBy, - manualOrder, - sort = "updated", -}: { - children: ReactNode; - groupBy: SidebarGroupBy; - manualOrder?: SidebarManualOrder; - sort?: SidebarChronologicalSort; -}) { - const store = useMemo(() => { - const next = createStore(); - next.set(sidebarGroupByAtom, groupBy); - next.set(sidebarChronologicalSortAtom, sort); - if (manualOrder) { - next.set(sidebarManualOrderAtom, manualOrder); - } - return next; - }, [groupBy, manualOrder, sort]); - - return {children}; -} - -function SidebarStage({ - children, - groupBy, - manualOrder, - sort, -}: { - children: ReactNode; - groupBy: SidebarGroupBy; - manualOrder?: SidebarManualOrder; - sort?: SidebarChronologicalSort; -}) { +function SidebarStage({ children }: { children: ReactNode }) { return ( - - - -
- {children} -
-
-
-
+ + +
+ {children} +
+
+
); } @@ -183,14 +131,11 @@ function ProjectTree({ threads }: { threads: readonly ThreadListEntry[] }) { ); } -export function NoneVsFolder() { +export function ProjectFolders() { return ( - - - - - - + + + @@ -203,9 +148,9 @@ export function ChronologicalFolders() { - + ); } - -export function ManualOrder() { - return ( - - - - - - - - ); -} - -export function CrossFolderRefile() { - const afterThreads = manualThreads.map((thread) => - thread.id === "thr_a" ? { ...thread, title: "Personal/Plan" } : thread, - ); - return ( - - - - - - - - - - - ); -} diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index e91edab29..cd7b974e8 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -70,7 +70,6 @@ import type { ProjectThreadListState } from "./ProjectRow"; import { compareByCreatedAtDescending, compareStandardThreads, - isSidebarProjectThread, type ThreadComparator, } from "./projectThreadGroups"; import { @@ -91,15 +90,13 @@ import { DEFAULT_SIDEBAR_SECTION_ORDER, sidebarChronologicalSortAtom, sidebarCollapsedFoldersAtom, - sidebarFolderGroupingAutoEnabledAtom, - sidebarGroupByAtom, sidebarOrganizationModeAtom, sidebarSectionOrderAtom, type CollapsibleSidebarSectionId, type SidebarOrganizationMode, type SidebarSectionId, } from "./sidebarCollapsedAtoms"; -import { folderAncestorKeys, titleCreatesFolder } from "./folderPath"; +import { folderAncestorKeys } from "./folderPath"; import { CHRONOLOGICAL_CONTAINER_ID, PINNED_CONTAINER_ID, @@ -177,7 +174,6 @@ interface ProjectListThreadsSectionActionsProps { } interface SidebarViewOptionsMenuProps { - folderGroupingAvailable?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; onOrganizationModeSelect?: (mode: SidebarOrganizationMode) => void; @@ -506,7 +502,6 @@ function SidebarOrganizeMenuOption({ // headers. The organization mode is global, so either header's menu drives the // whole sidebar. export function SidebarViewOptionsMenu({ - folderGroupingAvailable = true, open, onOpenChange, onOrganizationModeSelect, @@ -517,10 +512,6 @@ export function SidebarViewOptionsMenu({ const [chronologicalSort, setChronologicalSort] = useAtom( sidebarChronologicalSortAtom, ); - const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); - const setFolderGroupingAutoEnabled = useSetAtom( - sidebarFolderGroupingAutoEnabledAtom, - ); return ( @@ -550,42 +541,19 @@ export function SidebarViewOptionsMenu({ { event.preventDefault(); setOrganizationMode("project"); - setGroupBy("none"); - if (folderGroupingAvailable) { - setFolderGroupingAutoEnabled(true); - } onOrganizationModeSelect?.("project"); }} /> - { - event.preventDefault(); - if (!folderGroupingAvailable) { - return; - } - setOrganizationMode("chronological"); - setGroupBy("folder"); - setFolderGroupingAutoEnabled(true); - onOrganizationModeSelect?.("chronological"); - }} - /> { event.preventDefault(); setOrganizationMode("chronological"); - setGroupBy("none"); - if (folderGroupingAvailable) { - setFolderGroupingAutoEnabled(true); - } onOrganizationModeSelect?.("chronological"); }} /> @@ -609,14 +577,6 @@ export function SidebarViewOptionsMenu({ setChronologicalSort("created"); }} /> - { - event.preventDefault(); - setChronologicalSort("none"); - }} - /> ); @@ -1030,15 +990,6 @@ function ProjectListComponent({ } return map; }, [threads]); - const folderGroupingAvailable = useMemo( - () => - threads.some( - (thread) => - isSidebarProjectThread(thread) && - titleCreatesFolder(thread.title ?? ""), - ), - [threads], - ); const projectsState = useConnectionAwareQueryState({ hasResolvedData: projects !== undefined, isFetching: sidebarNavigationQuery.isFetching, @@ -1215,14 +1166,11 @@ function ProjectListComponent({ }, [], ); - const [organizationMode, setOrganizationMode] = useAtom( - sidebarOrganizationModeAtom, - ); - const [chronologicalSort] = useAtom(sidebarChronologicalSortAtom); - const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); - const [folderGroupingAutoEnabled, setFolderGroupingAutoEnabled] = useAtom( - sidebarFolderGroupingAutoEnabledAtom, + const [organizationMode] = useAtom(sidebarOrganizationModeAtom); + const [chronologicalSort, setChronologicalSort] = useAtom( + sidebarChronologicalSortAtom, ); + const groupBy = "folder" as const; const setCollapsedFolderList = useSetAtom(sidebarCollapsedFoldersAtom); const sidebarThreadComparator = useMemo( () => @@ -1280,34 +1228,11 @@ function ProjectListComponent({ normalizedCollapsedSidebarSectionIds, setCollapsedSidebarSectionIdList, ]); - // Existing slash-titled threads may predate folder grouping. Auto-enable once - // so refreshes render those titles as folders, then mark it handled so a - // later explicit "None" choice sticks. useEffect(() => { - if (!folderGroupingAvailable || folderGroupingAutoEnabled) { - return; + if (chronologicalSort === "none") { + setChronologicalSort("updated"); } - if (groupBy === "folder") { - if (organizationMode !== "chronological") { - setOrganizationMode("chronological"); - } - setFolderGroupingAutoEnabled(true); - return; - } - if (organizationMode !== "chronological") { - setOrganizationMode("chronological"); - } - setGroupBy("folder"); - setFolderGroupingAutoEnabled(true); - }, [ - folderGroupingAutoEnabled, - folderGroupingAvailable, - groupBy, - organizationMode, - setFolderGroupingAutoEnabled, - setGroupBy, - setOrganizationMode, - ]); + }, [chronologicalSort, setChronologicalSort]); const pinnedSidebarState = useMemo( () => buildPinnedSidebarState({ threads, groupBy }), [threads, groupBy], @@ -1364,30 +1289,26 @@ function ProjectListComponent({ removeCollapsedIds(current, environmentIdsToExpand), ); - // Under Group by: Folder, also un-collapse the folder ancestors hiding the - // selected thread. Folders derive from the TOP-LEVEL bucketed thread's - // title, not the selected child's own (a child's "/" is ignored) — so in + // Also un-collapse folder ancestors hiding the selected thread. In // project/pinned mode use the top-level ancestor the walk above ended on; // in the flat chronological list the selected thread is itself top-level. - if (groupBy === "folder") { - const isPinned = - pinnedSidebarState.effectivePinnedThreadIds.has(selectedThreadId); - const isChronological = !isPinned && organizationMode === "chronological"; - const topLevelAncestor = currentThread ?? selectedThread; - const folderSource = isChronological ? selectedThread : topLevelAncestor; - const folderContainerId = isPinned - ? PINNED_CONTAINER_ID - : isChronological - ? CHRONOLOGICAL_CONTAINER_ID - : selectedThread.projectId; - const folderKeysToExpand = new Set( - folderAncestorKeys(folderContainerId, folderSource.title ?? ""), + const isPinned = + pinnedSidebarState.effectivePinnedThreadIds.has(selectedThreadId); + const isChronological = !isPinned && organizationMode === "chronological"; + const topLevelAncestor = currentThread ?? selectedThread; + const folderSource = isChronological ? selectedThread : topLevelAncestor; + const folderContainerId = isPinned + ? PINNED_CONTAINER_ID + : isChronological + ? CHRONOLOGICAL_CONTAINER_ID + : selectedThread.projectId; + const folderKeysToExpand = new Set( + folderAncestorKeys(folderContainerId, folderSource.folderPath), + ); + if (folderKeysToExpand.size > 0) { + setCollapsedFolderList((current) => + removeCollapsedIds(current, folderKeysToExpand), ); - if (folderKeysToExpand.size > 0) { - setCollapsedFolderList((current) => - removeCollapsedIds(current, folderKeysToExpand), - ); - } } if (pinnedSidebarState.effectivePinnedThreadIds.has(selectedThreadId)) { @@ -1408,7 +1329,6 @@ function ProjectListComponent({ removeCollapsedIds(current, new Set(["projects"])), ); }, [ - groupBy, organizationMode, pinnedSidebarState.effectivePinnedThreadIds, selectedThreadId, @@ -1644,7 +1564,6 @@ function ProjectListComponent({ const projectsSectionActions = ( <> @@ -1704,7 +1622,7 @@ function ProjectListComponent({ ); } - if (organizationMode === "chronological" || groupBy === "folder") { + if (organizationMode === "chronological") { return (
@@ -1714,7 +1632,7 @@ function ProjectListComponent({ ) : null} id !== activeId); - const insertIndex = - overId === null ? withoutActive.length : withoutActive.indexOf(overId); - if (insertIndex === -1) { - return [...itemIds]; - } - return [ - ...withoutActive.slice(0, insertIndex), - activeId, - ...withoutActive.slice(insertIndex), - ]; -} - -function writeManualOrderLists( - current: SidebarManualOrder, - updates: ReadonlyMap, -): SidebarManualOrder { - const next = { ...current }; - for (const [parentKey, itemIds] of updates) { - next[parentKey] = [...itemIds]; - } - return next; +function hasFolderItems(items: readonly ProjectThreadItem[]): boolean { + return items.some( + (item) => + item.kind === "folder" || + (item.kind === "thread" && hasFolderItems(item.node.children)) || + (item.kind === "environment" && + item.group.nodes.some((node) => hasFolderItems(node.children))), + ); } function useManualThreadTreeDnd({ @@ -381,7 +352,6 @@ function useManualThreadTreeDnd({ () => collectManualThreadTreeLookup(rootItems, containerId), [containerId, rootItems], ); - const setManualOrder = useSetAtom(sidebarManualOrderAtom); const updateThread = useUpdateThread(); const handleDragEnd = useCallback( @@ -405,7 +375,6 @@ function useManualThreadTreeDnd({ const overKind = lookup.itemKindById.get(overId); const fromParentKey = lookup.parentKeyByItemId.get(activeId); let toParentKey = lookup.parentKeyByItemId.get(overId); - let destinationOverId: string | null = overId; if (!activeKind || !overKind || !fromParentKey || !toParentKey) { return; @@ -414,48 +383,8 @@ function useManualThreadTreeDnd({ // Dropping a thread on a folder header means "move into this folder". if (activeKind === "thread" && overKind === "folder") { toParentKey = overId; - destinationOverId = null; - } - - // Folders can be reordered among siblings, but they are not folder - // entities and cannot be re-filed into other folders. - if (activeKind === "folder" && fromParentKey !== toParentKey) { - return; - } - - const sourceIds = lookup.itemIdsByParentKey.get(fromParentKey); - const destinationIds = lookup.itemIdsByParentKey.get(toParentKey); - if (!sourceIds || !destinationIds) { - return; - } - - const updates = new Map(); - if (fromParentKey === toParentKey) { - updates.set( - fromParentKey, - moveIdBefore({ - activeId, - itemIds: sourceIds, - overId: destinationOverId, - }), - ); - } else { - updates.set( - fromParentKey, - sourceIds.filter((id) => id !== activeId), - ); - updates.set( - toParentKey, - moveIdBefore({ - activeId, - itemIds: destinationIds, - overId: destinationOverId, - }), - ); } - setManualOrder((current) => writeManualOrderLists(current, updates)); - if (activeKind !== "thread" || fromParentKey === toParentKey) { return; } @@ -463,15 +392,12 @@ function useManualThreadTreeDnd({ const thread = lookup.threadByItemId.get(activeId); if (!thread) return; - const destinationFolders = - lookup.folderPathByParentKey.get(toParentKey) ?? []; - const title = buildThreadTitleForFolderPath( - thread.title ?? getThreadDisplayTitle(thread), - destinationFolders, + const destinationFolderPath = normalizeFolderPath( + (lookup.folderPathByParentKey.get(toParentKey) ?? []).join("/"), ); - updateThread.mutate({ id: activeId, title }); + updateThread.mutate({ id: activeId, folderPath: destinationFolderPath }); }, - [enabled, lookup, setManualOrder, updateThread], + [enabled, lookup, updateThread], ); const { consumeClickSuppression, dndContextProps, onClickCapture } = @@ -1351,7 +1277,6 @@ const FolderTreeItemRow = memo(function FolderTreeItemRow({ name={folder.name} pathLabel={formatFolderPathLabel(folder.path)} depth={headerDepth} - threadCount={folder.threadCount} activity={folder.activity} consumeClickSuppression={consumeClickSuppression} dragBindings={dragBindings} @@ -1456,12 +1381,14 @@ export const ThreadTreeNodeRow = memo(function ThreadTreeNodeRow({ if (!insideFolder) { return undefined; } - const { folders, leaf } = parseThreadFolderPath(node.thread.title ?? ""); + const title = getThreadDisplayTitle(node.thread); + const folders = splitFolderPath(node.thread.folderPath); return { - displayTitle: leaf || undefined, - accessibleTitle: formatFolderPathLabel([...folders, leaf]) || undefined, + displayTitle: title, + accessibleTitle: + folders.length > 0 ? formatFolderPathLabel([...folders, title]) : title, }; - }, [insideFolder, node.thread.title]); + }, [insideFolder, node.thread]); const row = ( void; @@ -46,15 +45,13 @@ interface SidebarFolderRowProps { dragBindings?: SidebarSortableDragBindings; } -// The "Work › Q3 (2)" disclosure header for a derived folder. Not a thread: -// clicking toggles collapse, there is no navigation. Mirrors the parent-thread -// and worktree-header chrome (leading icon, truncating name, chevron, and a -// rolled-up activity glyph while collapsed). +// The "Work › Q3" disclosure header for a folder. Not a thread: clicking +// toggles collapse, there is no navigation. It stays visually quieter than a +// project row while still mirroring parent-thread disclosure behavior. function SidebarFolderRowComponent({ name, pathLabel, depth, - threadCount, activity, consumeClickSuppression, dragBindings, @@ -115,10 +112,7 @@ function SidebarFolderRowComponent({ /> - {name} - - {threadCount} - + {name} { const next = createStore(); next.set(sidebarChronologicalSortAtom, sort); - next.set(sidebarGroupByAtom, groupBy); next.set(sidebarOrganizationModeAtom, organizationMode); return next; - }, [groupBy, organizationMode, sort]); + }, [organizationMode, sort]); return (
- +
); @@ -48,22 +39,10 @@ export function Overview() { return ( - + - - - - - + + ); diff --git a/apps/app/src/components/sidebar/folderPath.test.ts b/apps/app/src/components/sidebar/folderPath.test.ts index 83ae5b5cf..86bcd2141 100644 --- a/apps/app/src/components/sidebar/folderPath.test.ts +++ b/apps/app/src/components/sidebar/folderPath.test.ts @@ -1,73 +1,63 @@ import { describe, expect, it } from "vitest"; import { buildFolderKey, - buildThreadTitleForFolderPath, folderAncestorKeys, - normalizeThreadTitle, - parseThreadFolderPath, + normalizeFolderPath, + parseThreadFolderShortcut, + splitFolderPath, titleCreatesFolder, } from "./folderPath"; -describe("parseThreadFolderPath", () => { +describe("splitFolderPath", () => { it("splits on '/', trims segments, and drops empties", () => { - expect(parseThreadFolderPath("Work/Q3/Plan")).toEqual({ - folders: ["Work", "Q3"], - leaf: "Plan", - }); - }); - - it("treats a single segment as a leaf with no folder", () => { - expect(parseThreadFolderPath("Standalone")).toEqual({ - folders: [], - leaf: "Standalone", - }); - }); - - it("collapses leading, trailing, and doubled slashes", () => { - expect(parseThreadFolderPath("/Work//Q3/")).toEqual({ - folders: ["Work"], - leaf: "Q3", - }); + expect(splitFolderPath("Work/Q3")).toEqual(["Work", "Q3"]); + expect(splitFolderPath("/Work//Q3/")).toEqual(["Work", "Q3"]); + expect(splitFolderPath(" Work / Q3 ")).toEqual(["Work", "Q3"]); }); - it("trims whitespace around each segment", () => { - expect(parseThreadFolderPath("Work / Q3 ")).toEqual({ - folders: ["Work"], - leaf: "Q3", - }); - }); - - it("yields an empty path for an all-slashes or empty title", () => { - expect(parseThreadFolderPath("///")).toEqual({ folders: [], leaf: "" }); - expect(parseThreadFolderPath("")).toEqual({ folders: [], leaf: "" }); + it("returns an empty path for nullish or empty values", () => { + expect(splitFolderPath(null)).toEqual([]); + expect(splitFolderPath(undefined)).toEqual([]); + expect(splitFolderPath("///")).toEqual([]); + expect(splitFolderPath("")).toEqual([]); }); }); -describe("normalizeThreadTitle", () => { - it("re-joins normalized segments with '/'", () => { - expect(normalizeThreadTitle("Work/Q3/Plan")).toBe("Work/Q3/Plan"); +describe("normalizeFolderPath", () => { + it("normalizes paths to slash-separated segments", () => { + expect(normalizeFolderPath("Work / Q3 ")).toBe("Work/Q3"); }); - it("collapses leading, trailing, and doubled slashes", () => { - expect(normalizeThreadTitle("/Work//Q3/")).toBe("Work/Q3"); - }); - - it("trims whitespace around each segment", () => { - expect(normalizeThreadTitle("Work / Q3 ")).toBe("Work/Q3"); + it("normalizes empty paths to null", () => { + expect(normalizeFolderPath("///")).toBeNull(); + expect(normalizeFolderPath(null)).toBeNull(); }); +}); - it("leaves a single segment unchanged (after trimming)", () => { - expect(normalizeThreadTitle(" Standalone ")).toBe("Standalone"); +describe("parseThreadFolderShortcut", () => { + it("splits an explicit slash rename into folderPath and title", () => { + expect(parseThreadFolderShortcut("Work/Q3/Plan")).toEqual({ + folderPath: "Work/Q3", + title: "Plan", + }); }); - it("normalizes an all-slashes or empty title to an empty string", () => { - expect(normalizeThreadTitle("///")).toBe(""); - expect(normalizeThreadTitle("")).toBe(""); + it("keeps slashy titles literal when they do not define a folder and leaf", () => { + expect(parseThreadFolderShortcut("Standalone")).toEqual({ + folderPath: null, + title: "Standalone", + }); + expect(parseThreadFolderShortcut("Work/")).toEqual({ + folderPath: null, + title: "Work/", + }); }); - it("is idempotent on already-normalized titles", () => { - const normalized = normalizeThreadTitle("/Work//Q3/"); - expect(normalizeThreadTitle(normalized)).toBe(normalized); + it("collapses leading, trailing, and doubled slashes in explicit paths", () => { + expect(parseThreadFolderShortcut("/Work//Q3/Plan/")).toEqual({ + folderPath: "Work/Q3", + title: "Plan", + }); }); }); @@ -110,44 +100,21 @@ describe("buildFolderKey", () => { }); }); -describe("buildThreadTitleForFolderPath", () => { - it("keeps the leaf and rewrites the folder prefix", () => { - expect(buildThreadTitleForFolderPath("Work/Q3/Plan", ["Personal"])).toBe( - "Personal/Plan", - ); - }); - - it("strips the folder prefix for a top-level destination", () => { - expect(buildThreadTitleForFolderPath("Work/Q3/Plan", [])).toBe("Plan"); - }); - - it("normalizes destination segments", () => { - expect( - buildThreadTitleForFolderPath(" Work / Q3 / Plan ", [" Personal "]), - ).toBe("Personal/Plan"); - }); -}); - describe("folderAncestorKeys", () => { it("returns every ancestor folder key, outermost first", () => { - expect(folderAncestorKeys("proj_1", "Work/Q3/Plan")).toEqual([ + expect(folderAncestorKeys("proj_1", "Work/Q3")).toEqual([ "proj_1::Work", "proj_1::Work/Q3", ]); }); - it("returns no keys for a title with no folder", () => { - expect(folderAncestorKeys("proj_1", "Standalone")).toEqual([]); - expect(folderAncestorKeys("proj_1", "Work/")).toEqual([]); - }); - - it("excludes the leaf — only the folders that contain the thread", () => { - // "A/B" lives in folder "A"; "B" is the thread, not a folder. - expect(folderAncestorKeys("pinned", "A/B")).toEqual(["pinned::A"]); + it("returns no keys for no folder path", () => { + expect(folderAncestorKeys("proj_1", null)).toEqual([]); + expect(folderAncestorKeys("proj_1", "")).toEqual([]); }); it("namespaces by container, including the global sentinels", () => { - expect(folderAncestorKeys("pinned", "A/B/C")).toEqual([ + expect(folderAncestorKeys("pinned", "A/B")).toEqual([ "pinned::A", "pinned::A/B", ]); diff --git a/apps/app/src/components/sidebar/folderPath.ts b/apps/app/src/components/sidebar/folderPath.ts index 83c6c367b..0549c9830 100644 --- a/apps/app/src/components/sidebar/folderPath.ts +++ b/apps/app/src/components/sidebar/folderPath.ts @@ -1,66 +1,60 @@ -// Pure helpers for the sidebar's derived folder layer. A thread title carries -// its folder path inline: "/" is read as a separator, so "Work/Q3/Plan" is a -// thread named "Plan" inside folders "Work" › "Q3". Nothing about folders is -// stored — these functions are the single canonical home for parsing a title -// into its folder ancestors + leaf, normalizing a title before it is written -// back on rename, and detecting whether a title creates a folder. +// Pure helpers for sidebar folders. Thread titles are display text; folder +// membership lives in `thread.folderPath`. Slash parsing is only used by +// explicit UI affordances that choose to write folder metadata. -export interface ThreadFolderPath { - /** Folder ancestors, outermost first. Empty when the title has no folder. */ - folders: string[]; - /** The thread's own name — the last path segment (or the whole title). */ - leaf: string; +export interface ThreadFolderShortcut { + /** Normalized folder path written to thread.folderPath, or null for none. */ + folderPath: string | null; + /** Thread title written separately from the folder path. */ + title: string; } -// Split a title into its non-empty, trimmed segments. Collapses leading, -// trailing, and doubled slashes and trims whitespace around each segment, so -// "/Work//Q3/" and "Work / Q3 " both yield ["Work", "Q3"]. Shared by every -// other helper here so parsing, normalizing, and detection stay consistent and -// idempotent (re-running on already-normalized input is a no-op). -function splitTitleSegments(title: string): string[] { - return title +function splitPathSegments(value: string): string[] { + return value .split("/") .map((segment) => segment.trim()) .filter((segment) => segment.length > 0); } -// Split a stored title into folder ancestors + leaf for rendering. A single -// segment (or an all-slashes/empty title) has no folder. The leaf is always the -// final segment; for an empty result the leaf is "". -export function parseThreadFolderPath(title: string): ThreadFolderPath { - const segments = splitTitleSegments(title); - if (segments.length === 0) { - return { folders: [], leaf: "" }; +export function splitFolderPath( + folderPath: string | null | undefined, +): string[] { + if (!folderPath) { + return []; } - return { - folders: segments.slice(0, -1), - leaf: segments[segments.length - 1], - }; + return splitPathSegments(folderPath); } -// Canonical form written back on rename submit: split → trim → drop empties → -// re-join with "/". "///" normalizes to "" (then blocked by empty-name -// validation upstream); "Work / Q3 " normalizes to "Work/Q3". -export function normalizeThreadTitle(title: string): string { - return splitTitleSegments(title).join("/"); +export function normalizeFolderPath( + folderPath: string | null | undefined, +): string | null { + const normalized = splitFolderPath(folderPath).join("/"); + return normalized.length > 0 ? normalized : null; } -// True when the normalized title yields at least one folder segment, i.e. two -// or more segments survive normalization. "Work/Q3" → true; "Work", "Work/", -// and "///" → false. -export function titleCreatesFolder(title: string): boolean { - return parseThreadFolderPath(title).folders.length > 0; +export function parseThreadFolderShortcut(value: string): ThreadFolderShortcut { + const segments = splitPathSegments(value); + if (segments.length <= 1) { + return { folderPath: null, title: value.trim() }; + } + return { + folderPath: segments.slice(0, -1).join("/"), + title: segments[segments.length - 1], + }; } -// Every ancestor folder key for a title, outermost first — e.g. "Work/Q3/Plan" -// in container "p" → ["p::Work", "p::Work/Q3"]. Used to un-collapse the folders -// hiding a selected thread. Derive `title` from the thread that is actually -// bucketed (a section's top-level thread), since a nested child's "/" is ignored. +export function titleCreatesFolder(value: string): boolean { + return parseThreadFolderShortcut(value).folderPath !== null; +} + +// Every ancestor folder key for a stored folder path, outermost first — e.g. +// "Work/Q3" in container "p" → ["p::Work", "p::Work/Q3"]. Used to un-collapse +// the folders hiding a selected thread. export function folderAncestorKeys( containerId: string, - title: string, + folderPath: string | null | undefined, ): string[] { - const { folders } = parseThreadFolderPath(title); + const folders = splitFolderPath(folderPath); const keys: string[] = []; for (let depth = 1; depth <= folders.length; depth += 1) { keys.push(buildFolderKey(containerId, folders.slice(0, depth))); @@ -68,25 +62,14 @@ export function folderAncestorKeys( return keys; } -// Human-readable folder path, used for tooltips, accessible names, and the -// rename preview ("Work › Q3 › Planning"). The visible separator differs from -// the stored "/" so a path reads as breadcrumbs, not a literal title. +// Human-readable folder path, used for tooltips and accessible names. The +// visible separator differs from the stored "/" so paths read as breadcrumbs. export const FOLDER_PATH_SEPARATOR = " › "; export function formatFolderPathLabel(segments: readonly string[]): string { return segments.join(FOLDER_PATH_SEPARATOR); } -// Re-file a thread into a destination folder path while keeping its current -// leaf. An empty destination strips the folder prefix and returns just the leaf. -export function buildThreadTitleForFolderPath( - title: string, - destinationFolders: readonly string[], -): string { - const { leaf } = parseThreadFolderPath(title); - return normalizeThreadTitle([...destinationFolders, leaf].join("/")); -} - // Stable identity for a folder within a section. `containerId` is the owner of // the section — a `proj_*` id for project sections, or a fixed sentinel for the // global sections — so "Work" in project A never collides with "Work" in diff --git a/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts b/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts index c28099934..135b8b05a 100644 --- a/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts +++ b/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts @@ -14,6 +14,7 @@ function createThread( providerId: "codex", title: "Thread", titleFallback: "Thread", + folderPath: null, status: "idle", parentThreadId: null, sourceThreadId: null, @@ -187,18 +188,20 @@ describe("buildPinnedSidebarState", () => { it("folds pinned roots into folders ordered by the pinned comparator", () => { const state = buildPinnedSidebarState({ threads: [ - // FolderA's pinned thread sorts after FolderB's by pinSortKey, so - // FolderB's folder must render first under the pinned (not sidebar) + // Work's pinned thread sorts after Personal's by pinSortKey, so + // Personal's folder must render first under the pinned (not sidebar) // ordering. createThread({ id: "a", - title: "Work/Alpha", + title: "Alpha", + folderPath: "Work", pinnedAt: 1_000, pinSortKey: "b", }), createThread({ id: "b", - title: "Personal/Beta", + title: "Beta", + folderPath: "Personal", pinnedAt: 1_000, pinSortKey: "a", }), @@ -216,7 +219,13 @@ describe("buildPinnedSidebarState", () => { it("keeps a flat thread list under Group by: None", () => { const state = buildPinnedSidebarState({ threads: [ - createThread({ id: "a", title: "Work/Alpha", pinnedAt: 1_000, pinSortKey: "a" }), + createThread({ + id: "a", + title: "Alpha", + folderPath: "Work", + pinnedAt: 1_000, + pinSortKey: "a", + }), ], groupBy: "none", }); diff --git a/apps/app/src/components/sidebar/pinnedSidebarThreads.ts b/apps/app/src/components/sidebar/pinnedSidebarThreads.ts index 0831599ae..87a241a6f 100644 --- a/apps/app/src/components/sidebar/pinnedSidebarThreads.ts +++ b/apps/app/src/components/sidebar/pinnedSidebarThreads.ts @@ -13,7 +13,7 @@ export interface PinnedSidebarState { effectivePinnedThreadIds: Set; rootNodes: ProjectThreadNode[]; // Folder-aware render list: flat thread items under Group by: None, folded - // into folders (ordered by comparePinnedRoots) under Group by: Folder. + // by stored folderPath (ordered by comparePinnedRoots) under Group by: Folder. rootItems: ProjectThreadItem[]; } @@ -134,7 +134,9 @@ export function buildPinnedSidebarState({ ); const projectItems = buildProjectThreadGroups(effectivePinnedThreads); const rootNodes = collectRootNodes(projectItems); - rootNodes.sort((left, right) => comparePinnedRoots(left.thread, right.thread)); + rootNodes.sort((left, right) => + comparePinnedRoots(left.thread, right.thread), + ); // Pinned has its own root order (pinSortKey via comparePinnedRoots), never the // sidebar sort. Fold into folders using that same comparator so folders order diff --git a/apps/app/src/components/sidebar/projectThreadGroups.test.ts b/apps/app/src/components/sidebar/projectThreadGroups.test.ts index 0ca411624..0bcc7de3c 100644 --- a/apps/app/src/components/sidebar/projectThreadGroups.test.ts +++ b/apps/app/src/components/sidebar/projectThreadGroups.test.ts @@ -28,6 +28,7 @@ function createThread( providerId: "codex", title: "Thread", titleFallback: "Thread", + folderPath: null, status: "idle", parentThreadId: null, sourceThreadId: null, @@ -484,8 +485,18 @@ describe("manual order (Sort by: None)", () => { it("lets manual folder mode interleave folders and loose threads by parent", () => { const items = buildProjectThreadGroups( [ - createThread({ id: "a", title: "Work/A", latestAttentionAt: 30 }), - createThread({ id: "b", title: "Work/B", latestAttentionAt: 20 }), + createThread({ + id: "a", + title: "A", + folderPath: "Work", + latestAttentionAt: 30, + }), + createThread({ + id: "b", + title: "B", + folderPath: "Work", + latestAttentionAt: 20, + }), createThread({ id: "loose", title: "Loose", latestAttentionAt: 10 }), ], compareStandardThreads, @@ -517,13 +528,13 @@ describe("manual order (Sort by: None)", () => { const FOLDER_OPTIONS = { groupBy: "folder", containerId: "proj_1" } as const; -describe("folder bucketing (Group by: Folder)", () => { - it("nests threads into folders derived from their titles, folders above loose threads", () => { +describe("folder bucketing", () => { + it("nests threads into folders derived from folderPath, folders above loose threads", () => { const items = buildProjectThreadGroups( [ - createThread({ id: "a", title: "Work/Q3/Plan" }), - createThread({ id: "b", title: "Work/Q3/Notes" }), - createThread({ id: "c", title: "Work/Q4" }), + createThread({ id: "a", title: "Plan", folderPath: "Work/Q3" }), + createThread({ id: "b", title: "Notes", folderPath: "Work/Q3" }), + createThread({ id: "c", title: "Q4", folderPath: "Work" }), createThread({ id: "d", title: "Standalone" }), ], compareStandardThreads, @@ -541,14 +552,28 @@ describe("folder bucketing (Group by: Folder)", () => { ]); }); + it("does not derive folders from slashes in titles", () => { + const items = buildProjectThreadGroups( + [ + createThread({ id: "a", title: "Work/Q3/Plan" }), + createThread({ id: "b", title: "Work/Notes" }), + ], + compareStandardThreads, + FOLDER_OPTIONS, + ); + + expect(summarizeItems(items)).toEqual(["a", "b"]); + }); + it("keeps a folder thread's own children nested under it and ignores their slashes", () => { const items = buildProjectThreadGroups( [ - createThread({ id: "parent", title: "Work/Project" }), + createThread({ id: "parent", title: "Project", folderPath: "Work" }), createThread({ id: "child", parentThreadId: "parent", - title: "Ignored/Child/Path", + title: "Path", + folderPath: "Ignored/Child", }), ], compareStandardThreads, @@ -556,7 +581,7 @@ describe("folder bucketing (Group by: Folder)", () => { ); // Only the top-level "parent" forms a folder; the child stays nested under - // it and its own "/" does not create a folder. + // it and its own folderPath does not create a second folder branch. expect(summarizeItems(items)).toEqual([ { folder: "proj_1::Work", @@ -570,13 +595,15 @@ describe("folder bucketing (Group by: Folder)", () => { [ createThread({ id: "w1", - title: "Work/Alpha", + title: "Alpha", + folderPath: "Work", environmentId: "env_shared", environmentWorkspaceDisplayKind: "managed-worktree", }), createThread({ id: "w2", - title: "Work/Beta", + title: "Beta", + folderPath: "Work", environmentId: "env_shared", environmentWorkspaceDisplayKind: "managed-worktree", }), @@ -597,7 +624,8 @@ describe("folder bucketing (Group by: Folder)", () => { const threads = [ createThread({ id: "old-active", - title: "FolderA/x", + title: "x", + folderPath: "FolderA", status: "active", createdAt: 10, latestAttentionAt: 5, @@ -605,7 +633,8 @@ describe("folder bucketing (Group by: Folder)", () => { }), createThread({ id: "new-idle", - title: "FolderB/y", + title: "y", + folderPath: "FolderB", status: "idle", createdAt: 50, latestAttentionAt: 5, @@ -646,10 +675,11 @@ describe("folder bucketing (Group by: Folder)", () => { buildProjectThreadGroups([ createThread({ id: "busy", - title: "Work/Busy", + title: "Busy", + folderPath: "Work", hasPendingInteraction: true, }), - createThread({ id: "quiet", title: "Work/Quiet" }), + createThread({ id: "quiet", title: "Quiet", folderPath: "Work" }), ]), "proj_1", compareStandardThreads, @@ -667,8 +697,18 @@ describe("folder bucketing (Group by: Folder)", () => { it("folds the chronological list into folders too", () => { const items = buildChronologicalThreadList( [ - createThread({ id: "a", title: "Work/One", createdAt: 20 }), - createThread({ id: "b", title: "Personal/Two", createdAt: 10 }), + createThread({ + id: "a", + title: "One", + folderPath: "Work", + createdAt: 20, + }), + createThread({ + id: "b", + title: "Two", + folderPath: "Personal", + createdAt: 10, + }), ], compareByCreatedAtDescending, { groupBy: "folder", containerId: "chronological" }, diff --git a/apps/app/src/components/sidebar/projectThreadGroups.ts b/apps/app/src/components/sidebar/projectThreadGroups.ts index 6f3af0ad5..79fa7db70 100644 --- a/apps/app/src/components/sidebar/projectThreadGroups.ts +++ b/apps/app/src/components/sidebar/projectThreadGroups.ts @@ -7,7 +7,7 @@ import { getCollapsedChildActivity, type CollapsedChildActivity, } from "@/lib/thread-activity"; -import { buildFolderKey, parseThreadFolderPath } from "./folderPath"; +import { buildFolderKey, splitFolderPath } from "./folderPath"; import type { SidebarGroupBy, SidebarManualOrder, @@ -37,8 +37,7 @@ export interface EnvironmentThreadGroup { stats: ProjectThreadNodeStats; } -// A derived folder node (Group by: Folder). Folders are a pure rendering layer -// folded out of "/"-separated thread titles by bucketIntoFolders — never stored. +// A folder node folded out of stored thread.folderPath metadata. // A folder holds further items (threads, env groups, and nested folders) and // rolls up its descendants' count + activity so a collapsed header can speak for // them, mirroring a collapsed parent thread. @@ -60,10 +59,10 @@ export type ProjectThreadItem = | { kind: "environment"; group: EnvironmentThreadGroup } | { kind: "folder"; group: SidebarFolderGroup }; -// Opt-in folder grouping, threaded into the three assembly sites. `containerId` -// scopes folder identity to its section (a `proj_*` id, or the sentinels -// below). When groupBy is "none" each site early-returns its current output -// untouched — no folder logic runs. +// Folder grouping, threaded into the three assembly sites. `containerId` scopes +// folder identity to its section (a `proj_*` id, or the sentinels below). When +// groupBy is "none" each site early-returns its current output untouched — no +// folder logic runs. export interface SidebarFolderOptions { groupBy: SidebarGroupBy; containerId: string; @@ -355,8 +354,8 @@ export function buildProjectThreadGroups( } const rootItems = buildSortedItems(rootNodes, compareThreads); - // Group by: None — return today's output untouched unless Sort: None has - // explicitly supplied a manual order for this section. + // Group by: None — return today's output untouched unless an internal test + // path explicitly supplied a manual order for this section. if (folderOptions?.groupBy !== "folder") { if (folderOptions?.manualOrder) { return orderSiblingItems( @@ -481,16 +480,14 @@ function hasAtLeastTwoThreadNodes( } // --------------------------------------------------------------------------- -// Folder bucketing (Group by: Folder) +// Folder bucketing // -// A pure, opt-in layer that folds an already-built top-level item list into a -// nested folder tree derived from each top-level thread's "/"-separated title. -// Only top-level items form folders; a thread's own children and env groups -// keep nesting under its leaf exactly as today (a child's "/" is ignored). The -// active comparator still drives order — folders render as a block above loose -// items, and folders, their contents, and the loose block are each sorted -// recursively by the same comparator (folders by their representative -// descendant — the descendant that sorts first). +// A pure layer that folds an already-built top-level item list into a nested +// folder tree derived from each top-level thread's stored folderPath. Only +// top-level items form folders; a thread's own children and env groups keep +// nesting under it exactly as today. The active comparator still drives order: +// folders render as a block above loose items, and folders, their contents, and +// the loose block are each sorted recursively by the same comparator. // --------------------------------------------------------------------------- interface FolderBucket { @@ -506,8 +503,8 @@ function createFolderBucket(): FolderBucket { return { subfolders: new Map(), items: [] }; } -// The thread that orders an item among its siblings, and whose title decides -// its folder path: a thread/env item keeps today's representative; a folder +// The thread that orders an item among its siblings, and whose folderPath +// decides its folder path: a thread/env item keeps today's representative; a folder // uses the descendant thread that sorts first under the active comparator. function getItemOrderingThread( item: ProjectThreadItem, @@ -589,10 +586,9 @@ function orderItemsByManualOrder( return [...unorderedItems, ...orderedItems]; } -// The one sibling-ordering hook. Today it orders folders-first, each block by -// the active comparator. Under Sort: None it instead applies the stored -// per-parent manual order; missing child keys stay at the top in fallback order -// until the first drag writes a complete order for that parent. +// The one sibling-ordering hook. It orders folders-first, each block by the +// active comparator. Internal manual-order tests can still supply a stored +// per-parent order; missing child keys stay at the top in fallback order. function orderSiblingItems( items: readonly ProjectThreadItem[], parentKey: string, @@ -674,9 +670,9 @@ function buildFolderLevelItems( } // Fold a top-level item list into a nested folder tree. Items whose -// representative thread's title has no folder segment stay loose at the top -// level; the rest nest into the deepest folder of their path. No empty folders: -// a folder node exists only because >=1 item resolved into it. +// representative thread has no folderPath stay loose at the top level; the rest +// nest into the deepest folder of their path. No empty folders: a folder node +// exists only because >=1 item resolved into it. export function bucketIntoFolders( items: readonly ProjectThreadItem[], containerId: string, @@ -686,7 +682,7 @@ export function bucketIntoFolders( const root = createFolderBucket(); for (const item of items) { const orderingThread = getItemOrderingThread(item, compareThreads); - const { folders } = parseThreadFolderPath(orderingThread.title ?? ""); + const folders = splitFolderPath(orderingThread.folderPath); let bucket = root; for (const segment of folders) { let next = bucket.subfolders.get(segment); diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index 8da02e3fe..a80987310 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -10,8 +10,6 @@ const ORGANIZATION_MODE_STORAGE_KEY = "bb.sidebar.organizationMode"; const CHRONOLOGICAL_SORT_STORAGE_KEY = "bb.sidebar.chronologicalSort"; const GROUP_BY_STORAGE_KEY = "bb.sidebar.groupBy"; const COLLAPSED_FOLDERS_STORAGE_KEY = "bb.sidebar.collapsedFolders"; -const FOLDER_GROUPING_AUTO_ENABLED_STORAGE_KEY = - "bb.sidebar.folderGroupingAutoEnabled"; const MANUAL_ORDER_STORAGE_KEY = "bb.sidebar.manualOrder"; export type SidebarSectionId = "pinned" | "projects" | "threads"; @@ -22,11 +20,12 @@ export type CollapsibleSidebarSectionId = "projects" | "threads"; export type SidebarOrganizationMode = "project" | "chronological"; // Controls thread ordering in both grouped and ungrouped sidebar views. // "updated" reuses the status-aware activity heuristic; "created" sorts by -// the literal createdAt field; "none" applies the user's local manual order. +// the literal createdAt field. "none" is a legacy/internal value that the +// runtime normalizes back to "updated". export type SidebarChronologicalSort = "updated" | "created" | "none"; -// Whether "/" in a thread title renders as nested folders. Orthogonal to the -// organization mode and sort: "none" keeps today's flat behavior (literal -// titles), "folder" buckets top-level threads into derived folders. +// Low-level folder grouping switch used by folder helpers and tests. The app +// runtime always renders stored folderPath metadata; "none" remains for +// regression coverage. export type SidebarGroupBy = "none" | "folder"; // Per-parent manual order for Sort: None. Keys are section/folder parent keys; // values are child thread ids and child folder keys. @@ -91,7 +90,8 @@ export const sidebarChronologicalSortAtom = { getOnInit: true }, ); -// Opt-in folder grouping. Default "none" keeps the current sidebar layout. +// Story/test control for the low-level folder grouping path. Runtime sidebar +// trees pass "folder" directly so stored folderPath metadata always renders. export const sidebarGroupByAtom = atomWithStorage( GROUP_BY_STORAGE_KEY, "none", @@ -108,15 +108,6 @@ export const sidebarCollapsedFoldersAtom = atomWithStorage( { getOnInit: true }, ); -// Whether slash-titled threads have already auto-enabled folder grouping. Once -// true, a later explicit Group by: None choice should stick across refreshes. -export const sidebarFolderGroupingAutoEnabledAtom = atomWithStorage( - FOLDER_GROUPING_AUTO_ENABLED_STORAGE_KEY, - false, - createJsonLocalStorage(), - { getOnInit: true }, -); - export const sidebarManualOrderAtom = atomWithStorage( MANUAL_ORDER_STORAGE_KEY, {}, diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx index bcccb7f76..6b31bc3d6 100644 --- a/apps/app/src/components/thread/ThreadActionsProvider.tsx +++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx @@ -7,7 +7,6 @@ import { useRef, type ReactNode, } from "react"; -import { useAtom } from "jotai"; import { useNavigate } from "react-router-dom"; import { appToast } from "@/components/ui/app-toast"; import { defaultExperiments, type Thread } from "@bb/domain"; @@ -31,6 +30,7 @@ import { import { getThreadDisplayTitle } from "@/lib/thread-title"; import { ThreadRenameDialog, + type ThreadRenameDialogPayload, type ThreadRenameDialogTarget, } from "@/components/dialogs/ThreadRenameDialog"; import { @@ -39,12 +39,6 @@ import { } from "@/components/dialogs/ThreadDeleteDialog"; import { ArchivedThreadToastTitle } from "@/components/thread/ArchivedThreadToastTitle"; import { destroyPersistedBrowserViewsForThread } from "@/components/secondary-panel/browserViewVisibilityCoordinator"; -import { titleCreatesFolder } from "@/components/sidebar/folderPath"; -import { - sidebarFolderGroupingAutoEnabledAtom, - sidebarGroupByAtom, - sidebarOrganizationModeAtom, -} from "@/components/sidebar/sidebarCollapsedAtoms"; import { getThreadReadToggleAction } from "@/components/sidebar/threadReadState"; import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths"; import { getDesktopBrowserApi, getDesktopPopoutApi } from "@/lib/bb-desktop"; @@ -104,11 +98,6 @@ export function ThreadActionsProvider({ const deleteThread = useDeleteThread(); const updateThread = useUpdateThread(); const systemConfigQuery = useSystemConfig(); - const [groupBy, setGroupBy] = useAtom(sidebarGroupByAtom); - const [, setOrganizationMode] = useAtom(sidebarOrganizationModeAtom); - const [, setFolderGroupingAutoEnabled] = useAtom( - sidebarFolderGroupingAutoEnabledAtom, - ); const threadActionContextAbortRef = useRef(null); // Destructure `.mutate` so useCallback deps see stable references across // renders. Depending on the full mutation objects would churn callback @@ -160,31 +149,17 @@ export function ThreadActionsProvider({ ); const submitRename = useCallback( - (threadId: string, title: string) => { + (threadId: string, payload: ThreadRenameDialogPayload) => { updateMutate( - { id: threadId, title }, + { id: threadId, ...payload }, { onSuccess: () => { - if (titleCreatesFolder(title)) { - setFolderGroupingAutoEnabled(true); - setOrganizationMode("chronological"); - if (groupBy === "none") { - setGroupBy("folder"); - } - } closeRenameDialog(); }, }, ); }, - [ - closeRenameDialog, - groupBy, - setFolderGroupingAutoEnabled, - setGroupBy, - setOrganizationMode, - updateMutate, - ], + [closeRenameDialog, updateMutate], ); // Fetches the delete dialog context. Returns null when the caller's request diff --git a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts index 2d995a36d..1fdc6401d 100644 --- a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts @@ -23,6 +23,7 @@ function makeThreadWithRuntime( providerId: "codex", title: null, titleFallback: null, + folderPath: null, status: "active", parentThreadId: null, sourceThreadId: null, diff --git a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.ts index cc11c23d5..a62303756 100644 --- a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.ts @@ -62,9 +62,15 @@ interface BeginThreadReadStateTransactionArgs extends ThreadIdCacheArgs { } interface BeginThreadTitleTransactionArgs extends ThreadIdCacheArgs { + folderPath?: string | null; title: string | null; } +interface BeginThreadMetadataTransactionArgs extends ThreadIdCacheArgs { + folderPath?: string | null; + title?: string | null; +} + interface ReorderPinnedThreadTransactionRequest extends ReorderPinnedThreadRequest { id: string; } @@ -361,18 +367,37 @@ export function beginThreadReadStateTransaction({ } export function beginThreadTitleTransaction({ + folderPath, queryClient, threadId, title, }: BeginThreadTitleTransactionArgs): Promise { + return beginThreadMetadataTransaction({ + folderPath, + queryClient, + threadId, + title, + }); +} + +export function beginThreadMetadataTransaction({ + folderPath, + queryClient, + threadId, + title, +}: BeginThreadMetadataTransactionArgs): Promise { + const patch = { + ...(title !== undefined ? { title } : {}), + ...(folderPath !== undefined ? { folderPath } : {}), + }; return runOptimisticThreadFieldTransaction({ applyToLists: (queryClient, threadId) => applyToCachedThreadListsAndSidebarNavigation(queryClient, (list) => list.map((thread) => - thread.id === threadId ? { ...thread, title } : thread, + thread.id === threadId ? { ...thread, ...patch } : thread, ), ), - patch: { title }, + patch, queryClient, threadId, }); diff --git a/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx index bd9017127..a46ce0a18 100644 --- a/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx @@ -34,6 +34,7 @@ function makeThreadWithRuntime( providerId: "codex", title: null, titleFallback: null, + folderPath: null, status: "active", parentThreadId: null, sourceThreadId: null, @@ -182,4 +183,74 @@ describe("thread state mutations", () => { expect(result.current.isSuccess).toBe(true); }); }); + + it("optimistically moves a thread between folders while the update request is pending", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const threadId = "thread-1"; + const thread = makeThreadWithRuntime({ + id: threadId, + folderPath: "Work", + }); + const listEntry = makeThreadListEntry({ + id: threadId, + folderPath: "Work", + }); + const threadListKey = threadListQueryKey({ + archived: false, + projectId: "project-1", + }); + let resolveUpdate: (thread: ThreadResponse) => void = () => {}; + + queryClient.setQueryData(threadQueryKey(threadId), thread); + queryClient.setQueryData(threadListKey, [listEntry]); + queryClient.setQueryData( + sidebarNavigationQueryKey(), + makeSidebarNavigation([listEntry]), + ); + vi.mocked(api.updateThread).mockImplementation( + () => + new Promise((resolve) => { + resolveUpdate = resolve; + }), + ); + + const { result } = renderHook(() => useUpdateThread(), { wrapper }); + + act(() => { + result.current.mutate({ id: threadId, folderPath: "Personal" }); + }); + + await waitFor(() => { + expect( + queryClient.getQueryData(threadQueryKey(threadId)) + ?.folderPath, + ).toBe("Personal"); + }); + expect( + queryClient.getQueryData(threadListKey)?.[0] + ?.folderPath, + ).toBe("Personal"); + expect( + queryClient.getQueryData( + sidebarNavigationQueryKey(), + )?.projects[0]?.threads[0]?.folderPath, + ).toBe("Personal"); + expect(api.updateThread).toHaveBeenCalledWith(threadId, { + folderPath: "Personal", + }); + + act(() => { + resolveUpdate( + makeThreadResponse({ + id: threadId, + folderPath: "Personal", + updatedAt: 2, + }), + ); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + }); }); diff --git a/apps/app/src/hooks/mutations/thread-state-mutations.ts b/apps/app/src/hooks/mutations/thread-state-mutations.ts index 37c0990c3..caef8340b 100644 --- a/apps/app/src/hooks/mutations/thread-state-mutations.ts +++ b/apps/app/src/hooks/mutations/thread-state-mutations.ts @@ -17,7 +17,7 @@ import { beginDeleteThreadTransaction, beginPinThreadTransaction, beginThreadReadStateTransaction, - beginThreadTitleTransaction, + beginThreadMetadataTransaction, beginReorderPinnedThreadTransaction, beginUnarchiveThreadTransaction, beginUnpinThreadTransaction, @@ -78,14 +78,16 @@ export function useUpdateThread(options?: UpdateThreadMutationOptions) { mutationFn: ({ id, ...request }: UpdateThreadMutationRequest) => api.updateThread(id, request), onMutate: ({ + folderPath, id, title, }): Promise | undefined => { - if (title === undefined) { + if (title === undefined && folderPath === undefined) { return undefined; } - return beginThreadTitleTransaction({ + return beginThreadMetadataTransaction({ + folderPath, queryClient, threadId: id, title, diff --git a/apps/app/src/hooks/queries/query-helpers.test.ts b/apps/app/src/hooks/queries/query-helpers.test.ts index 5f3f81e30..9f0159a7c 100644 --- a/apps/app/src/hooks/queries/query-helpers.test.ts +++ b/apps/app/src/hooks/queries/query-helpers.test.ts @@ -130,6 +130,7 @@ function makeThreadWithRuntime( environmentId: "env-1", title: null, titleFallback: null, + folderPath: null, parentThreadId: null, sourceThreadId: null, originKind: null, diff --git a/apps/app/src/hooks/threadMentionSuggestions.test.ts b/apps/app/src/hooks/threadMentionSuggestions.test.ts index 52755c535..7393ad371 100644 --- a/apps/app/src/hooks/threadMentionSuggestions.test.ts +++ b/apps/app/src/hooks/threadMentionSuggestions.test.ts @@ -26,6 +26,7 @@ function makeThread(options: ThreadFixtureOptions): Thread { providerId: "openai", title: options.title, titleFallback: options.titleFallback ?? null, + folderPath: null, status: "idle", parentThreadId: options.parentThreadId ?? null, sourceThreadId: null, diff --git a/apps/app/src/hooks/useForkThreadFromMessage.test.tsx b/apps/app/src/hooks/useForkThreadFromMessage.test.tsx index dcbe315d1..125396b57 100644 --- a/apps/app/src/hooks/useForkThreadFromMessage.test.tsx +++ b/apps/app/src/hooks/useForkThreadFromMessage.test.tsx @@ -57,6 +57,7 @@ function makeThread(overrides: Partial = {}): Thread { status: "idle", title: null, titleFallback: "Fallback fork title", + folderPath: null, updatedAt: 1, }; return { ...base, ...overrides }; @@ -97,9 +98,9 @@ describe("useForkThreadFromMessage", () => { const navigateState = mocks.navigate.mock.calls[0]?.[1]?.state as | Record | undefined; - const seed = navigateState?.[ - FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY - ] as ForkThreadCreateSeed | undefined; + const seed = navigateState?.[FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY] as + | ForkThreadCreateSeed + | undefined; expect(seed).toMatchObject({ environmentId: "env_source", model: "gpt-5", diff --git a/apps/app/src/lib/fork-thread-request.test.ts b/apps/app/src/lib/fork-thread-request.test.ts index 8e8338dbe..8b540e5d4 100644 --- a/apps/app/src/lib/fork-thread-request.test.ts +++ b/apps/app/src/lib/fork-thread-request.test.ts @@ -24,6 +24,7 @@ function makeThread(overrides: Partial = {}): Thread { status: "idle", title: "Investigate flaky test", titleFallback: null, + folderPath: null, updatedAt: 1, }; return { ...base, ...overrides }; diff --git a/apps/app/src/views/RootComposeMobileRecents.test.tsx b/apps/app/src/views/RootComposeMobileRecents.test.tsx index 1f7835146..2b91dcae2 100644 --- a/apps/app/src/views/RootComposeMobileRecents.test.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.test.tsx @@ -18,6 +18,7 @@ function makeThread(args: MakeThreadArgs): ThreadListEntry { providerId: "codex", title: args.title, titleFallback: args.title, + folderPath: null, status: "idle", parentThreadId: null, sourceThreadId: null, diff --git a/apps/app/src/views/RootComposeView.test.ts b/apps/app/src/views/RootComposeView.test.ts index ba0e48e00..e9ecf4ed5 100644 --- a/apps/app/src/views/RootComposeView.test.ts +++ b/apps/app/src/views/RootComposeView.test.ts @@ -57,6 +57,7 @@ function makeThread(args: MakeThreadArgs): ThreadListEntry { providerId: "codex", title: args.id, titleFallback: args.id, + folderPath: null, status: "idle", parentThreadId: null, sourceThreadId: null, diff --git a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx index 40fc5265e..0c6b29c69 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx @@ -244,6 +244,7 @@ function makeThread( stopRequestedAt: null, title: null, titleFallback: "Test thread", + folderPath: null, updatedAt: 0, } as ThreadDetailSecondaryContentProps["metadata"]["thread"]; } diff --git a/apps/app/src/views/thread-detail/threadParentSelectorOptions.test.ts b/apps/app/src/views/thread-detail/threadParentSelectorOptions.test.ts index 0b1f2d6c7..c7be3570d 100644 --- a/apps/app/src/views/thread-detail/threadParentSelectorOptions.test.ts +++ b/apps/app/src/views/thread-detail/threadParentSelectorOptions.test.ts @@ -36,6 +36,7 @@ function makeThread(overrides: ThreadListEntryOverrides = {}): ThreadListEntry { status: "idle", title: "Thread", titleFallback: "Thread", + folderPath: null, updatedAt: 1, ...overrides, }; diff --git a/apps/server/src/routes/threads/base.ts b/apps/server/src/routes/threads/base.ts index eb5a4b1f0..26c489a3c 100644 --- a/apps/server/src/routes/threads/base.ts +++ b/apps/server/src/routes/threads/base.ts @@ -79,6 +79,18 @@ interface BuildThreadSearchResponseArgs { archived: DbThreadSearchResultGroup; } +function normalizeThreadFolderPath(folderPath: string | null): string | null { + if (folderPath === null) { + return null; + } + const normalized = folderPath + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) + .join("/"); + return normalized.length > 0 ? normalized : null; +} + function resolveIncludedThreadEnvironment( deps: Pick, thread: Thread, @@ -295,6 +307,11 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { if ("title" in payload) { metadataUpdate.title = payload.title; } + if ("folderPath" in payload) { + metadataUpdate.folderPath = normalizeThreadFolderPath( + payload.folderPath ?? null, + ); + } if ("parentThreadId" in payload) { metadataUpdate.parentThreadId = payload.parentThreadId; } diff --git a/apps/server/src/services/threads/thread-runtime-display.ts b/apps/server/src/services/threads/thread-runtime-display.ts index 035c8f3d5..3f474e885 100644 --- a/apps/server/src/services/threads/thread-runtime-display.ts +++ b/apps/server/src/services/threads/thread-runtime-display.ts @@ -91,6 +91,7 @@ function toPublicThread(thread: Thread): Thread { providerId: thread.providerId, title: thread.title, titleFallback: thread.titleFallback, + folderPath: thread.folderPath, status: thread.status, parentThreadId: thread.parentThreadId, sourceThreadId: thread.sourceThreadId, diff --git a/packages/db/drizzle/0042_thread_folder_path.sql b/packages/db/drizzle/0042_thread_folder_path.sql new file mode 100644 index 000000000..10bdafb24 --- /dev/null +++ b/packages/db/drizzle/0042_thread_folder_path.sql @@ -0,0 +1 @@ +ALTER TABLE `threads` ADD `folder_path` text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0042_snapshot.json b/packages/db/drizzle/meta/0042_snapshot.json new file mode 100644 index 000000000..b1030286f --- /dev/null +++ b/packages/db/drizzle/meta/0042_snapshot.json @@ -0,0 +1,2732 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9e7dc789-70c5-49c0-9b06-270a021de7c6", + "prevId": "2932d6d3-e6e4-41b4-ac3f-2091e2cc7bed", + "tables": { + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refillInterval": { + "name": "refillInterval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refillAmount": { + "name": "refillAmount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRefillAt": { + "name": "lastRefillAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitEnabled": { + "name": "rateLimitEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitTimeWindow": { + "name": "rateLimitTimeWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitMax": { + "name": "rateLimitMax", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestCount": { + "name": "requestCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configId": { + "name": "configId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_unique": { + "name": "apikey_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "referenceId" + ], + "isUnique": false + }, + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "configId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "apikey_referenceId_user_id_fk": { + "name": "apikey_referenceId_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "referenceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_mode": { + "name": "run_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "automation_runs_automation_started_idx": { + "name": "automation_runs_automation_started_idx", + "columns": [ + "automation_id", + "started_at" + ], + "isUnique": false + }, + "automation_runs_thread_idx": { + "name": "automation_runs_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_thread_id_threads_id_fk": { + "name": "automation_runs_thread_id_threads_id_fk", + "tableFrom": "automation_runs", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_thread_id": { + "name": "target_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_config": { + "name": "trigger_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_mode": { + "name": "run_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "execution": { + "name": "execution", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_archive": { + "name": "auto_archive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_thread_id": { + "name": "created_by_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_thread_id": { + "name": "last_run_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "automations_project_idx": { + "name": "automations_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "automations_due_idx": { + "name": "automations_due_idx", + "columns": [ + "enabled", + "trigger_type", + "next_run_at" + ], + "isUnique": false + }, + "automations_target_thread_idx": { + "name": "automations_target_thread_idx", + "columns": [ + "target_thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_project_id_projects_id_fk": { + "name": "automations_project_id_projects_id_fk", + "tableFrom": "automations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automations_target_thread_id_threads_id_fk": { + "name": "automations_target_thread_id_threads_id_fk", + "tableFrom": "automations", + "tableTo": "threads", + "columnsFrom": [ + "target_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environments": { + "name": "environments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "managed": { + "name": "managed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_git_repo": { + "name": "is_git_repo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_worktree": { + "name": "is_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_base_branch": { + "name": "merge_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destroy_attempt_id": { + "name": "destroy_attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provision_type": { + "name": "workspace_provision_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provisioning'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environments_host_path_idx": { + "name": "environments_host_path_idx", + "columns": [ + "host_id", + "path" + ], + "isUnique": true + }, + "environments_project_idx": { + "name": "environments_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "environments_status_idx": { + "name": "environments_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environments_project_id_projects_id_fk": { + "name": "environments_project_id_projects_id_fk", + "tableFrom": "environments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_host_id_hosts_id_fk": { + "name": "environments_host_id_hosts_id_fk", + "tableFrom": "environments", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "events_thread_sequence_idx": { + "name": "events_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": true + }, + "events_thread_type_item_kind_sequence_idx": { + "name": "events_thread_type_item_kind_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_kind", + "sequence" + ], + "isUnique": false + }, + "events_thread_type_sequence_idx": { + "name": "events_thread_type_sequence_idx", + "columns": [ + "thread_id", + "type", + "sequence" + ], + "isUnique": false + }, + "events_thread_turn_type_item_sequence_idx": { + "name": "events_thread_turn_type_item_sequence_idx", + "columns": [ + "thread_id", + "turn_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false + }, + "events_environment_idx": { + "name": "events_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "events_completed_item_truncation_idx": { + "name": "events_completed_item_truncation_idx", + "columns": [ + "item_kind", + "created_at", + "id" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'item/completed'" + } + }, + "foreignKeys": { + "events_thread_id_threads_id_fk": { + "name": "events_thread_id_threads_id_fk", + "tableFrom": "events", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "events_environment_id_environments_id_fk": { + "name": "events_environment_id_environments_id_fk", + "tableFrom": "events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "events_scope_shape_check": { + "name": "events_scope_shape_check", + "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )" + } + } + }, + "host_daemon_sessions": { + "name": "host_daemon_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_type": { + "name": "host_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_dir": { + "name": "data_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heartbeat_interval_ms": { + "name": "heartbeat_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_timeout_ms": { + "name": "lease_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_daemon_sessions_host_status_idx": { + "name": "host_daemon_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "host_daemon_sessions_host_latest_idx": { + "name": "host_daemon_sessions_host_latest_idx", + "columns": [ + "host_id", + "updated_at", + "created_at", + "id" + ], + "isUnique": false + }, + "host_daemon_sessions_closed_prune_idx": { + "name": "host_daemon_sessions_closed_prune_idx", + "columns": [ + "status", + "closed_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_daemon_sessions_host_id_hosts_id_fk": { + "name": "host_daemon_sessions_host_id_hosts_id_fk", + "tableFrom": "host_daemon_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hosts": { + "name": "hosts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hosts_last_seen_idx": { + "name": "hosts_last_seen_idx", + "columns": [ + "last_seen_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_scan_cursors": { + "name": "maintenance_scan_cursors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_created_at": { + "name": "last_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_scan_cursors_path_idx": { + "name": "maintenance_scan_cursors_path_idx", + "columns": [ + "policy", + "version", + "item_kind", + "output_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_interactions": { + "name": "pending_interactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_interactions_provider_request_idx": { + "name": "pending_interactions_provider_request_idx", + "columns": [ + "provider_id", + "provider_thread_id", + "provider_request_id" + ], + "isUnique": true + }, + "pending_interactions_thread_created_idx": { + "name": "pending_interactions_thread_created_idx", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_thread_status_created_idx": { + "name": "pending_interactions_thread_status_created_idx", + "columns": [ + "thread_id", + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_status_created_idx": { + "name": "pending_interactions_status_created_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pending_interactions_thread_id_threads_id_fk": { + "name": "pending_interactions_thread_id_threads_id_fk", + "tableFrom": "pending_interactions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_execution_defaults": { + "name": "project_execution_defaults", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_execution_defaults_project_idx": { + "name": "project_execution_defaults_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_execution_defaults_project_id_projects_id_fk": { + "name": "project_execution_defaults_project_id_projects_id_fk", + "tableFrom": "project_execution_defaults", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_sources": { + "name": "project_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_sources_project_idx": { + "name": "project_sources_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "project_sources_host_idx": { + "name": "project_sources_host_idx", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "project_sources_project_host_idx": { + "name": "project_sources_project_host_idx", + "columns": [ + "project_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_sources_project_id_projects_id_fk": { + "name": "project_sources_project_id_projects_id_fk", + "tableFrom": "project_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_sources_host_id_hosts_id_fk": { + "name": "project_sources_host_id_hosts_id_fk", + "tableFrom": "project_sources", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_sources_shape_check": { + "name": "project_sources_shape_check", + "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'V'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_updated_idx": { + "name": "projects_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "projects_deleted_idx": { + "name": "projects_deleted_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "projects_sort_idx": { + "name": "projects_sort_idx", + "columns": [ + "sort_key", + "id" + ], + "isUnique": false + }, + "projects_personal_singleton_idx": { + "name": "projects_personal_singleton_idx", + "columns": [ + "kind" + ], + "isUnique": true, + "where": "\"projects\".\"kind\" = 'personal'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_history_entries": { + "name": "prompt_history_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "prompt_history_entries_thread_request_idx": { + "name": "prompt_history_entries_thread_request_idx", + "columns": [ + "thread_id", + "request_sequence" + ], + "isUnique": true + }, + "prompt_history_entries_project_scope_created_idx": { + "name": "prompt_history_entries_project_scope_created_idx", + "columns": [ + "project_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + }, + "prompt_history_entries_thread_scope_created_idx": { + "name": "prompt_history_entries_thread_scope_created_idx", + "columns": [ + "thread_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "prompt_history_entries_project_id_projects_id_fk": { + "name": "prompt_history_entries_project_id_projects_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_history_entries_thread_id_threads_id_fk": { + "name": "prompt_history_entries_thread_id_threads_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queued_thread_messages": { + "name": "queued_thread_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_thread_id": { + "name": "sender_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "queued_thread_messages_thread_created_idx": { + "name": "queued_thread_messages_thread_created_idx", + "columns": [ + "thread_id", + "created_at", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_thread_sort_idx": { + "name": "queued_thread_messages_thread_sort_idx", + "columns": [ + "thread_id", + "sort_key", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "queued_thread_messages_thread_id_threads_id_fk": { + "name": "queued_thread_messages_thread_id_threads_id_fk", + "tableFrom": "queued_thread_messages", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_experiments": { + "name": "system_experiments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "claude_code_mock_cli_traffic": { + "name": "claude_code_mock_cli_traffic", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "popout_chat": { + "name": "popout_chat", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "popout_chat_hotkey": { + "name": "popout_chat_hotkey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daemon_session_id": { + "name": "daemon_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initial_cwd": { + "name": "initial_cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cols": { + "name": "cols", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rows": { + "name": "rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_input_at": { + "name": "last_user_input_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_thread_status_updated_idx": { + "name": "terminal_sessions_thread_status_updated_idx", + "columns": [ + "thread_id", + "status", + "updated_at" + ], + "isUnique": false + }, + "terminal_sessions_environment_status_idx": { + "name": "terminal_sessions_environment_status_idx", + "columns": [ + "environment_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_host_status_idx": { + "name": "terminal_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_daemon_session_idx": { + "name": "terminal_sessions_daemon_session_idx", + "columns": [ + "daemon_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_thread_id_threads_id_fk": { + "name": "terminal_sessions_thread_id_threads_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_environment_id_environments_id_fk": { + "name": "terminal_sessions_environment_id_environments_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_host_id_hosts_id_fk": { + "name": "terminal_sessions_host_id_hosts_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { + "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "host_daemon_sessions", + "columnsFrom": [ + "daemon_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_dynamic_context_file_states": { + "name": "thread_dynamic_context_file_states", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_status": { + "name": "content_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shown_at": { + "name": "shown_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_dynamic_context_file_states_thread_file_idx": { + "name": "thread_dynamic_context_file_states_thread_file_idx", + "columns": [ + "thread_id", + "file_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "thread_dynamic_context_file_states_thread_id_threads_id_fk": { + "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk", + "tableFrom": "thread_dynamic_context_file_states", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_search_segments": { + "name": "thread_search_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_seq": { + "name": "source_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_search_segments_source_idx": { + "name": "thread_search_segments_source_idx", + "columns": [ + "thread_id", + "source_kind", + "source_key" + ], + "isUnique": true + }, + "thread_search_segments_thread_idx": { + "name": "thread_search_segments_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_search_segments_thread_id_threads_id_fk": { + "name": "thread_search_segments_thread_id_threads_id_fk", + "tableFrom": "thread_search_segments", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_override": { + "name": "model_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_level_override": { + "name": "reasoning_level_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title_fallback": { + "name": "title_fallback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_path": { + "name": "folder_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'starting'" + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "child_origin": { + "name": "child_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_sort_key": { + "name": "pin_sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_attention_at": { + "name": "latest_attention_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "threads_project_updated_idx": { + "name": "threads_project_updated_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + }, + "threads_project_archived_deleted_idx": { + "name": "threads_project_archived_deleted_idx", + "columns": [ + "project_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_pin_sort_idx": { + "name": "threads_pin_sort_idx", + "columns": [ + "archived_at", + "deleted_at", + "pin_sort_key", + "id" + ], + "isUnique": false, + "where": "\"threads\".\"pinned_at\" IS NOT NULL" + }, + "threads_environment_idx": { + "name": "threads_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "threads_parent_idx": { + "name": "threads_parent_idx", + "columns": [ + "parent_thread_id" + ], + "isUnique": false + }, + "threads_source_origin_idx": { + "name": "threads_source_origin_idx", + "columns": [ + "source_thread_id", + "origin_kind" + ], + "isUnique": false + }, + "threads_archived_status_idx": { + "name": "threads_archived_status_idx", + "columns": [ + "archived_at", + "status" + ], + "isUnique": false + }, + "threads_environment_archived_deleted_idx": { + "name": "threads_environment_archived_deleted_idx", + "columns": [ + "environment_id", + "archived_at", + "deleted_at" + ], + "isUnique": false + }, + "threads_active_maintenance_idx": { + "name": "threads_active_maintenance_idx", + "columns": [ + "status" + ], + "isUnique": false, + "where": "\"threads\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "threads_project_id_projects_id_fk": { + "name": "threads_project_id_projects_id_fk", + "tableFrom": "threads", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "threads_environment_id_environments_id_fk": { + "name": "threads_environment_id_environments_id_fk", + "tableFrom": "threads", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_parent_thread_id_threads_id_fk": { + "name": "threads_parent_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "parent_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_source_thread_id_threads_id_fk": { + "name": "threads_source_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "source_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 22a5cc02e..ad598da05 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -295,6 +295,13 @@ "when": 1781660000003, "tag": "0041_add_automations", "breakpoints": true + }, + { + "idx": 42, + "version": "6", + "when": 1781849421982, + "tag": "0042_thread_folder_path", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index c8007a9bf..987381560 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -243,6 +243,7 @@ export interface CreateThreadInput { providerId: string; title?: string | null; titleFallback?: string | null; + folderPath?: string | null; status?: ThreadStatus; parentThreadId?: string | null; sourceThreadId?: string | null; @@ -270,6 +271,7 @@ export function createThread( providerId: input.providerId, title: input.title ?? null, titleFallback: input.titleFallback ?? null, + folderPath: input.folderPath ?? null, status: input.status ?? "starting", parentThreadId: originKind === null ? input.parentThreadId ?? null : null, @@ -1507,6 +1509,7 @@ export function reorderPinnedThread({ export interface UpdateThreadInput { environmentId?: string | null; + folderPath?: string | null; lastReadAt?: number | null; parentThreadId?: string | null; title?: string | null; @@ -1525,7 +1528,7 @@ export function updateThread( } const changes: ThreadChangeKind[] = []; - if ("title" in input) changes.push("title-changed"); + if ("title" in input || "folderPath" in input) changes.push("title-changed"); if ("lastReadAt" in input) changes.push("read-state-changed"); if ( "parentThreadId" in input && @@ -1542,6 +1545,7 @@ export function updateThread( const set: Partial = { updatedAt: now }; if ("title" in input) set.title = input.title; + if ("folderPath" in input) set.folderPath = input.folderPath; if ("environmentId" in input) set.environmentId = input.environmentId; if ("lastReadAt" in input) { set.lastReadAt = input.lastReadAt; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 2856f3837..75f685c79 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -249,6 +249,7 @@ export const threads = sqliteTable( ).$type(), title: text("title"), titleFallback: text("title_fallback"), + folderPath: text("folder_path"), status: text("status", { enum: threadStatusValues }) .notNull() .default("starting"), diff --git a/packages/db/test/data/threads.test.ts b/packages/db/test/data/threads.test.ts index e0e8c07a7..6a9ce2cce 100644 --- a/packages/db/test/data/threads.test.ts +++ b/packages/db/test/data/threads.test.ts @@ -600,6 +600,32 @@ describe("threads", () => { expect(updated?.title).toBe("New title"); }); + it("updates thread folder path", () => { + const { db, project } = setup(); + const spy: DbNotifier = { + notifyThread: vi.fn(), + notifyEnvironment: vi.fn(), + notifyHost: vi.fn(), + notifyProject: vi.fn(), + notifySystem: vi.fn(), + }; + const thread = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + }); + + const updated = updateThread(db, spy, thread.id, { + folderPath: "Work/Q3", + }); + + expect(updated?.folderPath).toBe("Work/Q3"); + expect(spy.notifyThread).toHaveBeenCalledWith( + thread.id, + ["title-changed"], + { projectId: project.id }, + ); + }); + it("notifies when a thread parent changes", () => { const { db, project } = setup(); const spy: DbNotifier = { diff --git a/packages/domain/src/thread.ts b/packages/domain/src/thread.ts index d3f91c9b0..6e3856c6c 100644 --- a/packages/domain/src/thread.ts +++ b/packages/domain/src/thread.ts @@ -369,6 +369,7 @@ export const threadSchema = z.object({ providerId: z.string(), title: z.string().nullable(), titleFallback: z.string().nullable(), + folderPath: z.string().nullable(), status: threadStatusSchema, parentThreadId: z.string().nullable(), sourceThreadId: z.string().nullable(), diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 17d34c4cd..255fd614c 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -311,6 +311,7 @@ export type DeleteThreadRequest = z.infer; export const updateThreadRequestSchema = z .object({ title: z.string().min(1).nullable(), + folderPath: z.string().min(1).nullable(), parentThreadId: z.string().min(1).nullable(), // Sticky thread-level execution overrides applied on the next turn. `null` // clears the override; an omitted field is left unchanged. Settable @@ -322,6 +323,7 @@ export const updateThreadRequestSchema = z .refine( (value) => value.title !== undefined || + value.folderPath !== undefined || value.parentThreadId !== undefined || value.model !== undefined || value.reasoningLevel !== undefined, diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 50f7e4011..7a0a3ea6d 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -143,6 +143,7 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ "Thread PATCH requests omit fields that should be left unchanged; null explicitly clears nullable values.", fields: [ "updateThreadRequestSchema.model", + "updateThreadRequestSchema.folderPath", "updateThreadRequestSchema.parentThreadId", "updateThreadRequestSchema.reasoningLevel", "updateThreadRequestSchema.title", @@ -720,6 +721,7 @@ describe("server-contract canonical schemas", () => { providerId: "codex", title: "Pending thread", titleFallback: "Pending thread", + folderPath: null, status: "idle", parentThreadId: null, sourceThreadId: null, From 8ef0f569736ca54907c23dfc81353ad8afe8351e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 18 Jun 2026 23:49:05 -0700 Subject: [PATCH 13/54] Add explicit sidebar folder creation --- .../dialogs/ThreadFolderCreateDialog.tsx | 116 + .../src/components/sidebar/ProjectList.tsx | 76 +- .../sidebar/ProjectListProjects.tsx | 3 + .../app/src/components/sidebar/ProjectRow.tsx | 26 +- .../sidebar/SidebarOverview.stories.tsx | 1 + .../sidebar/projectThreadGroups.test.ts | 16 + .../components/sidebar/projectThreadGroups.ts | 72 +- .../app/src/hooks/cache-owners/query-cache.ts | 1 + .../thread-state-cache-owner.test.ts | 1 + .../mutations/thread-folder-mutations.ts | 19 + .../mutations/thread-state-mutations.test.tsx | 1 + apps/app/src/lib/api.ts | 10 + apps/app/src/views/RootComposeView.test.ts | 1 + apps/server/src/routes/projects.ts | 2 + apps/server/src/routes/thread-folders.ts | 29 + apps/server/src/server.ts | 2 + .../drizzle/0043_unique_phantom_reporter.sql | 9 + packages/db/drizzle/meta/0043_snapshot.json | 2785 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/data/index.ts | 12 + packages/db/src/data/thread-folders.ts | 118 + packages/db/src/data/threads.ts | 7 +- packages/db/src/ids.ts | 4 + packages/db/src/schema.ts | 14 + packages/db/test/data/threads.test.ts | 22 + packages/server-contract/src/api/projects.ts | 20 + packages/server-contract/src/public-api.ts | 14 + 27 files changed, 3362 insertions(+), 26 deletions(-) create mode 100644 apps/app/src/components/dialogs/ThreadFolderCreateDialog.tsx create mode 100644 apps/app/src/hooks/mutations/thread-folder-mutations.ts create mode 100644 apps/server/src/routes/thread-folders.ts create mode 100644 packages/db/drizzle/0043_unique_phantom_reporter.sql create mode 100644 packages/db/drizzle/meta/0043_snapshot.json create mode 100644 packages/db/src/data/thread-folders.ts diff --git a/apps/app/src/components/dialogs/ThreadFolderCreateDialog.tsx b/apps/app/src/components/dialogs/ThreadFolderCreateDialog.tsx new file mode 100644 index 000000000..406984e1b --- /dev/null +++ b/apps/app/src/components/dialogs/ThreadFolderCreateDialog.tsx @@ -0,0 +1,116 @@ +import { useId, useState, type FormEvent, type RefObject } from "react"; +import { Button } from "@/components/ui/button.js"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.js"; +import { Input } from "@/components/ui/input.js"; +import { normalizeFolderPath } from "@/components/sidebar/folderPath"; +import { useNameValidation } from "./useNameValidation.js"; +import { useRenameDialogAutoFocus } from "./useRenameDialogAutoFocus.js"; + +interface ThreadFolderCreateDialogProps { + open: boolean; + pending?: boolean; + onOpenChange: (open: boolean) => void; + onCreate: (path: string) => void; +} + +interface ThreadFolderCreateDialogContentProps { + pending: boolean; + onCreate: (path: string) => void; + inputRef: RefObject; +} + +export function ThreadFolderCreateDialog({ + open, + pending = false, + onOpenChange, + onCreate, +}: ThreadFolderCreateDialogProps) { + const { inputRef, handleOpenAutoFocus } = useRenameDialogAutoFocus(); + return ( + + + {open ? ( + + ) : null} + + + ); +} + +function ThreadFolderCreateDialogContent({ + pending, + onCreate, + inputRef, +}: ThreadFolderCreateDialogContentProps) { + const inputId = useId(); + const [path, setPath] = useState(""); + 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; + } + + onCreate(normalizedPath); + }; + const displayedMessage = validationMessage ?? folderPathMessage; + + return ( + <> + + New folder + Create a folder for threads. + +
+
+ { + setPath(event.target.value); + setFolderPathMessage(null); + clearMessage(); + }} + /> + {displayedMessage ? ( +

{displayedMessage}

+ ) : null} +
+ + + +
+ + ); +} diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index cd7b974e8..674e77463 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -32,6 +32,7 @@ import { stripProjectThreads } from "@/hooks/queries/project-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { useReorderProject } from "@/hooks/mutations/project-mutations"; import { useReorderPinnedThread } from "@/hooks/mutations/thread-state-mutations"; +import { useCreateThreadFolder } from "@/hooks/mutations/thread-folder-mutations"; import { isLocalPathMissing, useLocalPathExistence, @@ -48,6 +49,7 @@ import { import { useSetRootComposeProjectId } from "@/lib/root-compose-selection"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button.js"; +import { ThreadFolderCreateDialog } from "@/components/dialogs/ThreadFolderCreateDialog"; import { CHROME_SECTION_LABEL_CLASS } from "@/components/ui/chromeStyleTokens"; import { Icon, type IconName } from "@/components/ui/icon.js"; import { Skeleton } from "@/components/ui/skeleton.js"; @@ -170,6 +172,8 @@ interface ProjectListProjectsSectionActionsProps { } interface ProjectListThreadsSectionActionsProps { + isCreatingFolder: boolean; + onNewFolder: () => void; onNewThread: () => void; } @@ -335,6 +339,7 @@ const EMPTY_PROJECT_THREAD_LIST_STATE: ProjectThreadListState = { }; const EMPTY_PROJECTS: readonly ProjectResponse[] = []; +const EMPTY_FOLDER_PATHS: readonly string[] = []; function getProjectId(project: ProjectResponse): string { return project.id; @@ -434,15 +439,26 @@ function ProjectListProjectsSectionActions({ } function ProjectListThreadsSectionActions({ + isCreatingFolder, + onNewFolder, onNewThread, }: ProjectListThreadsSectionActionsProps) { return ( - + <> + + + ); } @@ -739,11 +755,11 @@ function TopLevelSidebarSection({ {label} - {/* Reserve room for up to three section action buttons on the right; + {/* Reserve room for up to four section action buttons on the right; coarse pointers need a little more. */} {collapseControl ? (
+ {folderCreateDialog}
); } @@ -1718,6 +1777,7 @@ function ProjectListComponent({
+ {folderCreateDialog}
); } diff --git a/apps/app/src/components/sidebar/ProjectListProjects.tsx b/apps/app/src/components/sidebar/ProjectListProjects.tsx index f0c168b04..3c0f6f8d6 100644 --- a/apps/app/src/components/sidebar/ProjectListProjects.tsx +++ b/apps/app/src/components/sidebar/ProjectListProjects.tsx @@ -51,6 +51,7 @@ export interface ProjectListProjectsProps { collapsedThreadIds: Set; collapsedEnvironmentIds: Set; compareThreads: ThreadComparator; + folderPaths?: readonly string[]; onProjectSelect?: () => void; onCreateProjectThread?: (projectId: string) => void; onToggleProjectCollapsed: (projectId: string) => void; @@ -99,6 +100,7 @@ export function ProjectListProjects({ collapsedThreadIds, collapsedEnvironmentIds, compareThreads, + folderPaths, onProjectSelect, onCreateProjectThread, onToggleProjectCollapsed, @@ -109,6 +111,7 @@ export function ProjectListProjects({ const sharedRowProps = (row: ProjectListRowModel) => ({ project: row.project, threadListState: row.threadListState, + folderPaths, selectedThreadId, isActive: row.isActive, isCollapsed: collapsedProjectIds.has(row.project.id), diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index ba7dd5881..0924ef60f 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -13,7 +13,7 @@ import { SortableContext, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import type { ThreadListEntry } from "@bb/domain"; +import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain"; import type { ProjectResponse } from "@bb/server-contract"; import { NavLink, useNavigate } from "react-router-dom"; import { useCreateThreadInWorktree } from "@/hooks/useCreateThreadInWorktree"; @@ -126,6 +126,7 @@ export type ProjectThreadListState = export interface ProjectRowProps { project: ProjectResponse; threadListState: ProjectThreadListState; + folderPaths?: readonly string[]; selectedThreadId?: string; isActive: boolean; isCollapsed: boolean; @@ -148,6 +149,7 @@ export interface ProjectThreadTreeProps { projectId: string; threadListState: ProjectThreadListState; compareThreads: ThreadComparator; + folderPaths?: readonly string[]; selectedThreadId?: string; collapsedThreadIds: Set; collapsedEnvironmentIds: Set; @@ -160,6 +162,7 @@ export interface ProjectThreadTreeProps { export interface ChronologicalThreadTreeProps { threadListState: ProjectThreadListState; compareThreads: ThreadComparator; + folderPaths?: readonly string[]; selectedThreadId?: string; collapsedThreadIds: Set; collapsedEnvironmentIds: Set; @@ -174,6 +177,7 @@ type ProjectItemClickCaptureHandler = MouseEventHandler; type ProjectThreadListClickCaptureHandler = MouseEventHandler; const EMPTY_PROJECT_THREADS: ThreadListEntry[] = []; +const EMPTY_FOLDER_PATHS: readonly string[] = []; const PROJECT_ROW_LEADING_SLOT_CLASS = "h-7 w-8 max-md:pointer-coarse:h-10 max-md:pointer-coarse:w-10"; @@ -286,6 +290,9 @@ export function getItemProjectId(item: ProjectThreadItem): string { case "environment": return item.group.nodes[0].thread.projectId; case "folder": + if (item.group.items.length === 0) { + return PERSONAL_PROJECT_ID; + } return getItemProjectId(item.group.items[0]); } } @@ -1284,7 +1291,7 @@ const FolderTreeItemRow = memo(function FolderTreeItemRow({ onToggleCollapsed={handleToggleCollapsed} stickyLevel={stickyLevel} /> - {!isCollapsed ? ( + {!isCollapsed && folder.items.length > 0 ? (
@@ -1441,6 +1448,7 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ projectId, threadListState, compareThreads, + folderPaths = EMPTY_FOLDER_PATHS, selectedThreadId, collapsedThreadIds, collapsedEnvironmentIds, @@ -1459,8 +1467,9 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ buildProjectThreadGroups(projectThreads, compareThreads, { groupBy, containerId: projectId, + folderPaths, }), - [compareThreads, projectThreads, groupBy, projectId], + [compareThreads, projectThreads, groupBy, projectId, folderPaths], ); const manualSort = useManualThreadTreeDnd({ containerId: projectId, @@ -1476,7 +1485,7 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ ); } - if (projectThreads.length === 0) { + if (rootItems.length === 0) { const emptyState = ( { expect(summarizeItems(items)).toEqual(["a", "b"]); }); + it("renders explicit empty folders without a thread using that path", () => { + const items = buildProjectThreadGroups( + [createThread({ id: "a", title: "Standalone" })], + compareStandardThreads, + { + ...FOLDER_OPTIONS, + folderPaths: ["Work/Q3"], + }, + ); + + expect(summarizeItems(items)).toEqual([ + { folder: "proj_1::Work", items: [{ folder: "proj_1::Work/Q3", items: [] }] }, + "a", + ]); + }); + it("keeps a folder thread's own children nested under it and ignores their slashes", () => { const items = buildProjectThreadGroups( [ diff --git a/apps/app/src/components/sidebar/projectThreadGroups.ts b/apps/app/src/components/sidebar/projectThreadGroups.ts index 79fa7db70..284a19a2c 100644 --- a/apps/app/src/components/sidebar/projectThreadGroups.ts +++ b/apps/app/src/components/sidebar/projectThreadGroups.ts @@ -66,6 +66,7 @@ export type ProjectThreadItem = export interface SidebarFolderOptions { groupBy: SidebarGroupBy; containerId: string; + folderPaths?: readonly string[]; manualOrder?: SidebarManualOrder; } @@ -374,6 +375,7 @@ export function buildProjectThreadGroups( folderOptions.containerId, compareThreads, folderOptions.manualOrder, + folderOptions.folderPaths, ); } @@ -422,6 +424,7 @@ export function buildChronologicalThreadList( folderOptions.containerId, compareThreads, folderOptions.manualOrder, + folderOptions.folderPaths, ); } @@ -509,16 +512,21 @@ function createFolderBucket(): FolderBucket { function getItemOrderingThread( item: ProjectThreadItem, compareThreads: ThreadComparator, -): ThreadListEntry { +): ThreadListEntry | null { switch (item.kind) { case "thread": return item.node.thread; case "environment": return item.group.nodes[0].thread; - case "folder": - return getItemThreadDescendants(item.group.items).reduce( + case "folder": { + const descendants = getItemThreadDescendants(item.group.items); + if (descendants.length === 0) { + return null; + } + return descendants.reduce( (first, thread) => (compareThreads(thread, first) < 0 ? thread : first), ); + } } } @@ -573,10 +581,7 @@ function orderItemsByManualOrder( const unorderedItems = items .filter((item) => !orderedKeys.has(getManualOrderItemKey(item))) .sort((left, right) => - compareThreads( - getItemOrderingThread(left, compareThreads), - getItemOrderingThread(right, compareThreads), - ), + compareSiblingItems(left, right, compareThreads), ); const orderedItems = prunedOrder.flatMap((key) => { const item = itemsByKey.get(key); @@ -607,17 +612,46 @@ function orderSiblingItems( const decorated = items.map((item) => ({ item, isFolder: item.kind === "folder", - orderingThread: getItemOrderingThread(item, compareThreads), })); decorated.sort((left, right) => { if (left.isFolder !== right.isFolder) { return left.isFolder ? -1 : 1; } - return compareThreads(left.orderingThread, right.orderingThread); + return compareSiblingItems(left.item, right.item, compareThreads); }); return decorated.map((entry) => entry.item); } +function getItemFallbackSortLabel(item: ProjectThreadItem): string { + switch (item.kind) { + case "thread": + return item.node.thread.id; + case "environment": + return item.group.environmentId; + case "folder": + return item.group.path.join("/"); + } +} + +function compareSiblingItems( + left: ProjectThreadItem, + right: ProjectThreadItem, + compareThreads: ThreadComparator, +): number { + const leftThread = getItemOrderingThread(left, compareThreads); + const rightThread = getItemOrderingThread(right, compareThreads); + if (leftThread && rightThread) { + return compareThreads(leftThread, rightThread); + } + if (leftThread || rightThread) { + return leftThread ? -1 : 1; + } + return compareCodepoint( + getItemFallbackSortLabel(left), + getItemFallbackSortLabel(right), + ); +} + function buildFolderGroup( containerId: string, path: string[], @@ -671,17 +705,33 @@ function buildFolderLevelItems( // Fold a top-level item list into a nested folder tree. Items whose // representative thread has no folderPath stay loose at the top level; the rest -// nest into the deepest folder of their path. No empty folders: a folder node -// exists only because >=1 item resolved into it. +// nest into the deepest folder of their path. Explicit folder paths can create +// empty folder nodes so new folders exist before their first thread is dropped. export function bucketIntoFolders( items: readonly ProjectThreadItem[], containerId: string, compareThreads: ThreadComparator = compareStandardThreads, manualOrder?: SidebarManualOrder, + folderPaths: readonly string[] = [], ): ProjectThreadItem[] { const root = createFolderBucket(); + for (const folderPath of folderPaths) { + const folders = splitFolderPath(folderPath); + let bucket = root; + for (const segment of folders) { + let next = bucket.subfolders.get(segment); + if (!next) { + next = createFolderBucket(); + bucket.subfolders.set(segment, next); + } + bucket = next; + } + } for (const item of items) { const orderingThread = getItemOrderingThread(item, compareThreads); + if (!orderingThread) { + continue; + } const folders = splitFolderPath(orderingThread.folderPath); let bucket = root; for (const segment of folders) { diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts index 256d46910..3c7d7915e 100644 --- a/apps/app/src/hooks/cache-owners/query-cache.ts +++ b/apps/app/src/hooks/cache-owners/query-cache.ts @@ -248,6 +248,7 @@ export function applyToCachedSidebarNavigationThreads({ return currentNavigation; } return { + folders: currentNavigation.folders, projects: currentNavigation.projects.map((project) => mapSidebarNavigationProjectThreads(project, mapper), ), diff --git a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts index 1fdc6401d..8f37cd132 100644 --- a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts @@ -63,6 +63,7 @@ function makeSidebarNavigation( threads: ThreadListEntry[], ): SidebarBootstrapResponse { return { + folders: [], projects: [ { id: "project-1", diff --git a/apps/app/src/hooks/mutations/thread-folder-mutations.ts b/apps/app/src/hooks/mutations/thread-folder-mutations.ts new file mode 100644 index 000000000..e2122eb71 --- /dev/null +++ b/apps/app/src/hooks/mutations/thread-folder-mutations.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { CreateThreadFolderRequest } from "@bb/server-contract"; +import * as api from "@/lib/api"; +import { invalidateProjectListQueries } from "../cache-owners/mutation-cache-effects"; + +export function useCreateThreadFolder() { + const queryClient = useQueryClient(); + + return useMutation({ + meta: { + errorMessage: "Failed to create folder.", + }, + mutationFn: (request: CreateThreadFolderRequest) => + api.createThreadFolder(request), + onSuccess: () => { + invalidateProjectListQueries({ queryClient }); + }, + }); +} diff --git a/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx index a46ce0a18..50c6c02f8 100644 --- a/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx @@ -84,6 +84,7 @@ function makeSidebarNavigation( threads: ThreadListEntry[], ): SidebarBootstrapResponse { return { + folders: [], projects: [ { id: "project-1", diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts index baf1a30a0..0d0000ff4 100644 --- a/apps/app/src/lib/api.ts +++ b/apps/app/src/lib/api.ts @@ -18,6 +18,7 @@ import type { CommandListResponse, CreateProjectSourceRequest, CreateProjectRequest, + CreateThreadFolderRequest, CreateQueuedMessageRequest, DeleteThreadRequest, EnvironmentArchiveThreadsResponse, @@ -50,6 +51,7 @@ import type { SystemVoiceTranscriptionResponse, ThreadArchiveAllResponse, ThreadChildSummaryResponse, + ThreadFolderResponse, ThreadPendingInteractionsResponse, ThreadQueuedMessageListResponse, ThreadListResponse, @@ -480,6 +482,14 @@ export async function createProject( return request(apiClient.projects.$post({ json: req })); } +export async function createThreadFolder( + req: CreateThreadFolderRequest, +): Promise { + return request( + apiClient["thread-folders"].$post({ json: req }), + ); +} + export async function updateProject( id: string, req: UpdateProjectRequest, diff --git a/apps/app/src/views/RootComposeView.test.ts b/apps/app/src/views/RootComposeView.test.ts index e9ecf4ed5..572b87861 100644 --- a/apps/app/src/views/RootComposeView.test.ts +++ b/apps/app/src/views/RootComposeView.test.ts @@ -99,6 +99,7 @@ function makeProject(args: MakeProjectArgs): ProjectWithThreadsResponse { describe("buildMobileRecentThreads", () => { it("includes projectless and every project thread", () => { const sidebarNavigation: SidebarBootstrapResponse = { + folders: [], personalProject: makeProject({ id: PERSONAL_PROJECT_ID, kind: "personal", diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index 9c4487458..d55fa05cc 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -9,6 +9,7 @@ import { listProjectExecutionDefaultsByProjectIds, listPublicProjects, listProjectSourcesByProjectIds, + listThreadFolders, listThreadsWithPendingInteractionStateForProjects, reorderProject, updateProject, @@ -235,6 +236,7 @@ function buildSidebarBootstrapResponse(deps: AppDeps) { ); } return { + folders: listThreadFolders(deps.db), projects: buildProjectsWithThreadsResponseFromRows( deps, listPublicProjects(deps.db), diff --git a/apps/server/src/routes/thread-folders.ts b/apps/server/src/routes/thread-folders.ts new file mode 100644 index 000000000..7d730a63d --- /dev/null +++ b/apps/server/src/routes/thread-folders.ts @@ -0,0 +1,29 @@ +import { createThreadFolder, normalizeThreadFolderPath } from "@bb/db"; +import { + publicApiRoutes, + typedRoutes, + type PublicApiSchema, +} from "@bb/server-contract"; +import type { Hono } from "hono"; +import type { AppDeps } from "../types.js"; +import { ApiError } from "../errors.js"; + +export function registerThreadFolderRoutes(app: Hono, deps: AppDeps): void { + const { post } = typedRoutes(app, { + onValidationError: (msg) => new ApiError(400, "invalid_request", msg), + }); + const routes = publicApiRoutes.threadFolders; + + post(routes.create, (context, payload) => { + const path = normalizeThreadFolderPath(payload.path); + if (!path) { + throw new ApiError(400, "invalid_request", "Folder name cannot be empty"); + } + return context.json( + createThreadFolder(deps.db, deps.hub, { + path, + }), + 201, + ); + }); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fe325c771..9337b8329 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -14,6 +14,7 @@ import { registerEnvironmentRoutes } from "./routes/environments.js"; import { registerFileRoutes } from "./routes/files.js"; import { registerHostRoutes } from "./routes/hosts.js"; import { registerProjectRoutes } from "./routes/projects.js"; +import { registerThreadFolderRoutes } from "./routes/thread-folders.js"; import { registerAutomationRoutes } from "./routes/automations.js"; import { registerSystemRoutes } from "./routes/system.js"; import { registerThreadRoutes } from "./routes/threads/index.js"; @@ -243,6 +244,7 @@ export function createApp( }); const publicApi = new Hono(); registerProjectRoutes(publicApi, deps); + registerThreadFolderRoutes(publicApi, deps); registerAutomationRoutes(publicApi, deps); registerFileRoutes(publicApi, deps); registerHostRoutes(publicApi, deps); diff --git a/packages/db/drizzle/0043_unique_phantom_reporter.sql b/packages/db/drizzle/0043_unique_phantom_reporter.sql new file mode 100644 index 000000000..b2e5b93de --- /dev/null +++ b/packages/db/drizzle/0043_unique_phantom_reporter.sql @@ -0,0 +1,9 @@ +CREATE TABLE `thread_folders` ( + `id` text PRIMARY KEY NOT NULL, + `path` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `thread_folders_path_idx` ON `thread_folders` (`path`);--> statement-breakpoint +CREATE INDEX `thread_folders_updated_idx` ON `thread_folders` (`updated_at`); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0043_snapshot.json b/packages/db/drizzle/meta/0043_snapshot.json new file mode 100644 index 000000000..1e146b331 --- /dev/null +++ b/packages/db/drizzle/meta/0043_snapshot.json @@ -0,0 +1,2785 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e455b374-86f9-4bcf-93dc-d77a46b9fec0", + "prevId": "9e7dc789-70c5-49c0-9b06-270a021de7c6", + "tables": { + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refillInterval": { + "name": "refillInterval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refillAmount": { + "name": "refillAmount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRefillAt": { + "name": "lastRefillAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitEnabled": { + "name": "rateLimitEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitTimeWindow": { + "name": "rateLimitTimeWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitMax": { + "name": "rateLimitMax", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestCount": { + "name": "requestCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configId": { + "name": "configId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_unique": { + "name": "apikey_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "referenceId" + ], + "isUnique": false + }, + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "configId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "apikey_referenceId_user_id_fk": { + "name": "apikey_referenceId_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "referenceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_mode": { + "name": "run_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "automation_runs_automation_started_idx": { + "name": "automation_runs_automation_started_idx", + "columns": [ + "automation_id", + "started_at" + ], + "isUnique": false + }, + "automation_runs_thread_idx": { + "name": "automation_runs_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_thread_id_threads_id_fk": { + "name": "automation_runs_thread_id_threads_id_fk", + "tableFrom": "automation_runs", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_thread_id": { + "name": "target_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_config": { + "name": "trigger_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_mode": { + "name": "run_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "execution": { + "name": "execution", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_archive": { + "name": "auto_archive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_thread_id": { + "name": "created_by_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_thread_id": { + "name": "last_run_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "automations_project_idx": { + "name": "automations_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "automations_due_idx": { + "name": "automations_due_idx", + "columns": [ + "enabled", + "trigger_type", + "next_run_at" + ], + "isUnique": false + }, + "automations_target_thread_idx": { + "name": "automations_target_thread_idx", + "columns": [ + "target_thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_project_id_projects_id_fk": { + "name": "automations_project_id_projects_id_fk", + "tableFrom": "automations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automations_target_thread_id_threads_id_fk": { + "name": "automations_target_thread_id_threads_id_fk", + "tableFrom": "automations", + "tableTo": "threads", + "columnsFrom": [ + "target_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environments": { + "name": "environments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "managed": { + "name": "managed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_git_repo": { + "name": "is_git_repo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_worktree": { + "name": "is_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_base_branch": { + "name": "merge_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destroy_attempt_id": { + "name": "destroy_attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provision_type": { + "name": "workspace_provision_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provisioning'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environments_host_path_idx": { + "name": "environments_host_path_idx", + "columns": [ + "host_id", + "path" + ], + "isUnique": true + }, + "environments_project_idx": { + "name": "environments_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "environments_status_idx": { + "name": "environments_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environments_project_id_projects_id_fk": { + "name": "environments_project_id_projects_id_fk", + "tableFrom": "environments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_host_id_hosts_id_fk": { + "name": "environments_host_id_hosts_id_fk", + "tableFrom": "environments", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "events_thread_sequence_idx": { + "name": "events_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": true + }, + "events_thread_type_item_kind_sequence_idx": { + "name": "events_thread_type_item_kind_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_kind", + "sequence" + ], + "isUnique": false + }, + "events_thread_type_sequence_idx": { + "name": "events_thread_type_sequence_idx", + "columns": [ + "thread_id", + "type", + "sequence" + ], + "isUnique": false + }, + "events_thread_turn_type_item_sequence_idx": { + "name": "events_thread_turn_type_item_sequence_idx", + "columns": [ + "thread_id", + "turn_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false + }, + "events_environment_idx": { + "name": "events_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "events_completed_item_truncation_idx": { + "name": "events_completed_item_truncation_idx", + "columns": [ + "item_kind", + "created_at", + "id" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'item/completed'" + } + }, + "foreignKeys": { + "events_thread_id_threads_id_fk": { + "name": "events_thread_id_threads_id_fk", + "tableFrom": "events", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "events_environment_id_environments_id_fk": { + "name": "events_environment_id_environments_id_fk", + "tableFrom": "events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "events_scope_shape_check": { + "name": "events_scope_shape_check", + "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )" + } + } + }, + "host_daemon_sessions": { + "name": "host_daemon_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_type": { + "name": "host_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_dir": { + "name": "data_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heartbeat_interval_ms": { + "name": "heartbeat_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_timeout_ms": { + "name": "lease_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_daemon_sessions_host_status_idx": { + "name": "host_daemon_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "host_daemon_sessions_host_latest_idx": { + "name": "host_daemon_sessions_host_latest_idx", + "columns": [ + "host_id", + "updated_at", + "created_at", + "id" + ], + "isUnique": false + }, + "host_daemon_sessions_closed_prune_idx": { + "name": "host_daemon_sessions_closed_prune_idx", + "columns": [ + "status", + "closed_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_daemon_sessions_host_id_hosts_id_fk": { + "name": "host_daemon_sessions_host_id_hosts_id_fk", + "tableFrom": "host_daemon_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hosts": { + "name": "hosts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hosts_last_seen_idx": { + "name": "hosts_last_seen_idx", + "columns": [ + "last_seen_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_scan_cursors": { + "name": "maintenance_scan_cursors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_created_at": { + "name": "last_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_scan_cursors_path_idx": { + "name": "maintenance_scan_cursors_path_idx", + "columns": [ + "policy", + "version", + "item_kind", + "output_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_interactions": { + "name": "pending_interactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_interactions_provider_request_idx": { + "name": "pending_interactions_provider_request_idx", + "columns": [ + "provider_id", + "provider_thread_id", + "provider_request_id" + ], + "isUnique": true + }, + "pending_interactions_thread_created_idx": { + "name": "pending_interactions_thread_created_idx", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_thread_status_created_idx": { + "name": "pending_interactions_thread_status_created_idx", + "columns": [ + "thread_id", + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_status_created_idx": { + "name": "pending_interactions_status_created_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pending_interactions_thread_id_threads_id_fk": { + "name": "pending_interactions_thread_id_threads_id_fk", + "tableFrom": "pending_interactions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_execution_defaults": { + "name": "project_execution_defaults", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_execution_defaults_project_idx": { + "name": "project_execution_defaults_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_execution_defaults_project_id_projects_id_fk": { + "name": "project_execution_defaults_project_id_projects_id_fk", + "tableFrom": "project_execution_defaults", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_sources": { + "name": "project_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_sources_project_idx": { + "name": "project_sources_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "project_sources_host_idx": { + "name": "project_sources_host_idx", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "project_sources_project_host_idx": { + "name": "project_sources_project_host_idx", + "columns": [ + "project_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_sources_project_id_projects_id_fk": { + "name": "project_sources_project_id_projects_id_fk", + "tableFrom": "project_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_sources_host_id_hosts_id_fk": { + "name": "project_sources_host_id_hosts_id_fk", + "tableFrom": "project_sources", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_sources_shape_check": { + "name": "project_sources_shape_check", + "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'V'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_updated_idx": { + "name": "projects_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "projects_deleted_idx": { + "name": "projects_deleted_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "projects_sort_idx": { + "name": "projects_sort_idx", + "columns": [ + "sort_key", + "id" + ], + "isUnique": false + }, + "projects_personal_singleton_idx": { + "name": "projects_personal_singleton_idx", + "columns": [ + "kind" + ], + "isUnique": true, + "where": "\"projects\".\"kind\" = 'personal'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_history_entries": { + "name": "prompt_history_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "prompt_history_entries_thread_request_idx": { + "name": "prompt_history_entries_thread_request_idx", + "columns": [ + "thread_id", + "request_sequence" + ], + "isUnique": true + }, + "prompt_history_entries_project_scope_created_idx": { + "name": "prompt_history_entries_project_scope_created_idx", + "columns": [ + "project_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + }, + "prompt_history_entries_thread_scope_created_idx": { + "name": "prompt_history_entries_thread_scope_created_idx", + "columns": [ + "thread_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "prompt_history_entries_project_id_projects_id_fk": { + "name": "prompt_history_entries_project_id_projects_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_history_entries_thread_id_threads_id_fk": { + "name": "prompt_history_entries_thread_id_threads_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queued_thread_messages": { + "name": "queued_thread_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_thread_id": { + "name": "sender_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "queued_thread_messages_thread_created_idx": { + "name": "queued_thread_messages_thread_created_idx", + "columns": [ + "thread_id", + "created_at", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_thread_sort_idx": { + "name": "queued_thread_messages_thread_sort_idx", + "columns": [ + "thread_id", + "sort_key", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "queued_thread_messages_thread_id_threads_id_fk": { + "name": "queued_thread_messages_thread_id_threads_id_fk", + "tableFrom": "queued_thread_messages", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_experiments": { + "name": "system_experiments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "claude_code_mock_cli_traffic": { + "name": "claude_code_mock_cli_traffic", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "popout_chat": { + "name": "popout_chat", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "popout_chat_hotkey": { + "name": "popout_chat_hotkey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daemon_session_id": { + "name": "daemon_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initial_cwd": { + "name": "initial_cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cols": { + "name": "cols", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rows": { + "name": "rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_input_at": { + "name": "last_user_input_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_thread_status_updated_idx": { + "name": "terminal_sessions_thread_status_updated_idx", + "columns": [ + "thread_id", + "status", + "updated_at" + ], + "isUnique": false + }, + "terminal_sessions_environment_status_idx": { + "name": "terminal_sessions_environment_status_idx", + "columns": [ + "environment_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_host_status_idx": { + "name": "terminal_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_daemon_session_idx": { + "name": "terminal_sessions_daemon_session_idx", + "columns": [ + "daemon_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_thread_id_threads_id_fk": { + "name": "terminal_sessions_thread_id_threads_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_environment_id_environments_id_fk": { + "name": "terminal_sessions_environment_id_environments_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_host_id_hosts_id_fk": { + "name": "terminal_sessions_host_id_hosts_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { + "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "host_daemon_sessions", + "columnsFrom": [ + "daemon_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_dynamic_context_file_states": { + "name": "thread_dynamic_context_file_states", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_status": { + "name": "content_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shown_at": { + "name": "shown_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_dynamic_context_file_states_thread_file_idx": { + "name": "thread_dynamic_context_file_states_thread_file_idx", + "columns": [ + "thread_id", + "file_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "thread_dynamic_context_file_states_thread_id_threads_id_fk": { + "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk", + "tableFrom": "thread_dynamic_context_file_states", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_folders": { + "name": "thread_folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_folders_path_idx": { + "name": "thread_folders_path_idx", + "columns": [ + "path" + ], + "isUnique": true + }, + "thread_folders_updated_idx": { + "name": "thread_folders_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_search_segments": { + "name": "thread_search_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_seq": { + "name": "source_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_search_segments_source_idx": { + "name": "thread_search_segments_source_idx", + "columns": [ + "thread_id", + "source_kind", + "source_key" + ], + "isUnique": true + }, + "thread_search_segments_thread_idx": { + "name": "thread_search_segments_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_search_segments_thread_id_threads_id_fk": { + "name": "thread_search_segments_thread_id_threads_id_fk", + "tableFrom": "thread_search_segments", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_override": { + "name": "model_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_level_override": { + "name": "reasoning_level_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title_fallback": { + "name": "title_fallback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_path": { + "name": "folder_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'starting'" + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "child_origin": { + "name": "child_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_sort_key": { + "name": "pin_sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_attention_at": { + "name": "latest_attention_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "threads_project_updated_idx": { + "name": "threads_project_updated_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + }, + "threads_project_archived_deleted_idx": { + "name": "threads_project_archived_deleted_idx", + "columns": [ + "project_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_pin_sort_idx": { + "name": "threads_pin_sort_idx", + "columns": [ + "archived_at", + "deleted_at", + "pin_sort_key", + "id" + ], + "isUnique": false, + "where": "\"threads\".\"pinned_at\" IS NOT NULL" + }, + "threads_environment_idx": { + "name": "threads_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "threads_parent_idx": { + "name": "threads_parent_idx", + "columns": [ + "parent_thread_id" + ], + "isUnique": false + }, + "threads_source_origin_idx": { + "name": "threads_source_origin_idx", + "columns": [ + "source_thread_id", + "origin_kind" + ], + "isUnique": false + }, + "threads_archived_status_idx": { + "name": "threads_archived_status_idx", + "columns": [ + "archived_at", + "status" + ], + "isUnique": false + }, + "threads_environment_archived_deleted_idx": { + "name": "threads_environment_archived_deleted_idx", + "columns": [ + "environment_id", + "archived_at", + "deleted_at" + ], + "isUnique": false + }, + "threads_active_maintenance_idx": { + "name": "threads_active_maintenance_idx", + "columns": [ + "status" + ], + "isUnique": false, + "where": "\"threads\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "threads_project_id_projects_id_fk": { + "name": "threads_project_id_projects_id_fk", + "tableFrom": "threads", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "threads_environment_id_environments_id_fk": { + "name": "threads_environment_id_environments_id_fk", + "tableFrom": "threads", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_parent_thread_id_threads_id_fk": { + "name": "threads_parent_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "parent_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_source_thread_id_threads_id_fk": { + "name": "threads_source_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "source_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index ad598da05..d007f1d99 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -302,6 +302,13 @@ "when": 1781849421982, "tag": "0042_thread_folder_path", "breakpoints": true + }, + { + "idx": 43, + "version": "6", + "when": 1781851451425, + "tag": "0043_unique_phantom_reporter", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 59c688566..98e680db0 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -19,6 +19,18 @@ export type { UpdateProjectInput, } from "./projects.js"; +export { + createThreadFolder, + ensureThreadFolderPath, + getThreadFolderByPath, + listThreadFolders, + normalizeThreadFolderPath, +} from "./thread-folders.js"; +export type { + CreateThreadFolderInput, + ThreadFolderRow, +} from "./thread-folders.js"; + export { createAutomation, getAutomation, diff --git a/packages/db/src/data/thread-folders.ts b/packages/db/src/data/thread-folders.ts new file mode 100644 index 000000000..f7ff4f5fa --- /dev/null +++ b/packages/db/src/data/thread-folders.ts @@ -0,0 +1,118 @@ +import { asc, eq } from "drizzle-orm"; +import { PERSONAL_PROJECT_ID } from "@bb/domain"; +import type { + DbConnection, + DbQueryConnection, + DbTransaction, +} from "../connection.js"; +import { createThreadFolderId } from "../ids.js"; +import type { DbNotifier } from "../notifier.js"; +import { threadFolders } from "../schema.js"; + +type ThreadFolderWriteConnection = DbConnection | DbTransaction; + +export type ThreadFolderRow = typeof threadFolders.$inferSelect; + +export interface CreateThreadFolderInput { + path: string; +} + +function splitFolderSegments(path: string): string[] { + return path + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); +} + +export function normalizeThreadFolderPath( + path: string | null | undefined, +): string | null { + const normalized = splitFolderSegments(path ?? "").join("/"); + return normalized.length > 0 ? normalized : null; +} + +function folderAncestors(path: string): string[] { + const segments = splitFolderSegments(path); + const ancestors: string[] = []; + for (let depth = 1; depth <= segments.length; depth += 1) { + ancestors.push(segments.slice(0, depth).join("/")); + } + return ancestors; +} + +export function getThreadFolderByPath( + db: DbQueryConnection, + path: string, +): ThreadFolderRow | null { + const normalized = normalizeThreadFolderPath(path); + if (!normalized) { + return null; + } + return ( + db + .select() + .from(threadFolders) + .where(eq(threadFolders.path, normalized)) + .get() ?? null + ); +} + +export function listThreadFolders(db: DbQueryConnection): ThreadFolderRow[] { + return db + .select() + .from(threadFolders) + .orderBy(asc(threadFolders.path), asc(threadFolders.id)) + .all(); +} + +export function ensureThreadFolderPath( + db: ThreadFolderWriteConnection, + notifier: DbNotifier, + path: string | null | undefined, +): ThreadFolderRow | null { + const normalized = normalizeThreadFolderPath(path); + if (!normalized) { + return null; + } + + const now = Date.now(); + let createdAny = false; + let deepest: ThreadFolderRow | null = null; + for (const ancestorPath of folderAncestors(normalized)) { + const inserted = + db + .insert(threadFolders) + .values({ + id: createThreadFolderId(), + path: ancestorPath, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() + .returning() + .get() ?? null; + if (inserted) { + createdAny = true; + deepest = inserted; + continue; + } + deepest = getThreadFolderByPath(db, ancestorPath); + } + + if (createdAny) { + notifier.notifyProject(PERSONAL_PROJECT_ID, ["threads-changed"]); + } + return deepest; +} + +export function createThreadFolder( + db: DbConnection, + notifier: DbNotifier, + input: CreateThreadFolderInput, +): ThreadFolderRow { + const folder = ensureThreadFolderPath(db, notifier, input.path); + if (!folder) { + throw new Error("Thread folder path cannot be empty"); + } + return folder; +} diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index 987381560..c1e3df023 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -42,6 +42,7 @@ import { createThreadId } from "../ids.js"; import { createOrderKeyBetween, } from "./order-keys.js"; +import { ensureThreadFolderPath } from "./thread-folders.js"; type ThreadWriteConnection = DbConnection | DbTransaction; @@ -287,6 +288,7 @@ export function createThread( }) .returning() .get(); + ensureThreadFolderPath(tx, notifier, createdThread.folderPath); upsertThreadTitleSearchSegments(tx, { threadId: createdThread.id, title: createdThread.title, @@ -1545,7 +1547,10 @@ export function updateThread( const set: Partial = { updatedAt: now }; if ("title" in input) set.title = input.title; - if ("folderPath" in input) set.folderPath = input.folderPath; + if ("folderPath" in input) { + ensureThreadFolderPath(db, notifier, input.folderPath); + set.folderPath = input.folderPath; + } if ("environmentId" in input) set.environmentId = input.environmentId; if ("lastReadAt" in input) { set.lastReadAt = input.lastReadAt; diff --git a/packages/db/src/ids.ts b/packages/db/src/ids.ts index be05f926c..94a1a5483 100644 --- a/packages/db/src/ids.ts +++ b/packages/db/src/ids.ts @@ -36,6 +36,10 @@ export function createThreadId(): string { return createId("thr"); } +export function createThreadFolderId(): string { + return createId("fld"); +} + export function createAutomationId(): string { return createId("auto"); } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 75f685c79..db3b76fc7 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -307,6 +307,20 @@ export const threads = sqliteTable( ], ); +export const threadFolders = sqliteTable( + "thread_folders", + { + id: text("id").primaryKey(), + path: text("path").notNull(), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [ + uniqueIndex("thread_folders_path_idx").on(table.path), + index("thread_folders_updated_idx").on(table.updatedAt), + ], +); + export const threadSearchSegments = sqliteTable( "thread_search_segments", { diff --git a/packages/db/test/data/threads.test.ts b/packages/db/test/data/threads.test.ts index 6a9ce2cce..2d3addfda 100644 --- a/packages/db/test/data/threads.test.ts +++ b/packages/db/test/data/threads.test.ts @@ -28,6 +28,10 @@ import { applyThreadLifecycleEvent, requireThreadLifecycleEventApplied, } from "../../src/data/threads.js"; +import { + createThreadFolder, + listThreadFolders, +} from "../../src/data/thread-folders.js"; import { createProject } from "../../src/data/projects.js"; import { upsertHost } from "../../src/data/hosts.js"; import { createEnvironment } from "../../src/data/environments.js"; @@ -619,6 +623,10 @@ describe("threads", () => { }); expect(updated?.folderPath).toBe("Work/Q3"); + expect(listThreadFolders(db).map((folder) => folder.path)).toEqual([ + "Work", + "Work/Q3", + ]); expect(spy.notifyThread).toHaveBeenCalledWith( thread.id, ["title-changed"], @@ -626,6 +634,20 @@ describe("threads", () => { ); }); + it("creates explicit thread folders with ancestors", () => { + const { db } = setup(); + + const folder = createThreadFolder(db, noopNotifier, { + path: " Work / Q3 ", + }); + + expect(folder.path).toBe("Work/Q3"); + expect(listThreadFolders(db).map((entry) => entry.path)).toEqual([ + "Work", + "Work/Q3", + ]); + }); + it("notifies when a thread parent changes", () => { const { db, project } = setup(); const spy: DbNotifier = { diff --git a/packages/server-contract/src/api/projects.ts b/packages/server-contract/src/api/projects.ts index a11baa117..85ed98c35 100644 --- a/packages/server-contract/src/api/projects.ts +++ b/packages/server-contract/src/api/projects.ts @@ -54,6 +54,25 @@ export const createProjectRequestSchema = z.object({ }); export type CreateProjectRequest = z.infer; +export const threadFolderSchema = z + .object({ + id: z.string(), + path: z.string().min(1), + createdAt: z.number(), + updatedAt: z.number(), + }) + .strict(); +export type ThreadFolderResponse = z.infer; + +export const createThreadFolderRequestSchema = z + .object({ + path: z.string().min(1), + }) + .strict(); +export type CreateThreadFolderRequest = z.infer< + typeof createThreadFolderRequestSchema +>; + export const reorderProjectRequestSchema = z.object({ previousProjectId: z.string().min(1).nullable(), nextProjectId: z.string().min(1).nullable(), @@ -273,6 +292,7 @@ export type ProjectWithThreadsResponse = z.infer< >; export const sidebarBootstrapResponseSchema = z.object({ + folders: z.array(threadFolderSchema), projects: z.array(projectWithThreadsResponseSchema), personalProject: projectWithThreadsResponseSchema, }); diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 74129784d..073e96b3e 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -46,6 +46,7 @@ import type { CreateProjectRequest, CreateProjectSourceRequest, CreateQueuedMessageRequest, + CreateThreadFolderRequest, CreateThreadRequest, CreateThreadTerminalRequest, DeleteThreadRequest, @@ -101,6 +102,7 @@ import type { ThreadComposerBootstrapResponse, ThreadEventWaitQuery, ThreadEventsQuery, + ThreadFolderResponse, ThreadFilesRawQuery, ThreadGetQuery, ThreadHostFileContentQuery, @@ -137,6 +139,7 @@ import { runAutomationRequestSchema, updateAutomationRequestSchema, closeThreadTerminalRequestSchema, + createThreadFolderRequestSchema, createProjectRequestSchema, createProjectSourceRequestSchema, createQueuedMessageRequestSchema, @@ -449,6 +452,17 @@ export const publicApiRoutes = { }), }, + threadFolders: { + create: defineRoute({ + path: "/thread-folders", + method: "post", + request: jsonRequest( + createThreadFolderRequestSchema, + ), + response: jsonResponse({ status: 201 }), + }), + }, + threads: { list: defineRoute({ path: "/threads", From 71ea5dbc63881a8321fa7c51821d8e23a62c8314 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 01:16:55 -0700 Subject: [PATCH 14/54] Add project-scoped thread folders --- .../src/components/sidebar/ProjectList.tsx | 110 +- .../sidebar/ProjectListProjects.tsx | 16 +- .../app/src/components/sidebar/ProjectRow.tsx | 28 + .../components/sidebar/SidebarFolderRow.tsx | 12 +- .../sidebar/projectThreadGroups.test.ts | 36 +- .../src/components/sidebar/sortableMotion.ts | 12 +- apps/app/src/components/ui/icon.tsx | 2 + apps/server/src/routes/thread-folders.ts | 6 + packages/db/drizzle/0044_solid_mysterio.sql | 5 + packages/db/drizzle/meta/0044_snapshot.json | 2823 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/data/thread-folders.ts | 32 +- packages/db/src/data/threads.ts | 9 +- packages/db/src/schema.ts | 11 +- packages/db/test/data/threads.test.ts | 47 +- packages/server-contract/src/api/projects.ts | 2 + 16 files changed, 3108 insertions(+), 50 deletions(-) create mode 100644 packages/db/drizzle/0044_solid_mysterio.sql create mode 100644 packages/db/drizzle/meta/0044_snapshot.json diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 674e77463..113e72bfd 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -17,7 +17,10 @@ import { SortableContext, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import type { ProjectResponse } from "@bb/server-contract"; +import type { + ProjectResponse, + ThreadFolderResponse, +} from "@bb/server-contract"; import { findLocalPathProjectSourceForHost, PERSONAL_PROJECT_ID, @@ -173,7 +176,7 @@ interface ProjectListProjectsSectionActionsProps { interface ProjectListThreadsSectionActionsProps { isCreatingFolder: boolean; - onNewFolder: () => void; + onNewFolder?: () => void; onNewThread: () => void; } @@ -339,7 +342,7 @@ const EMPTY_PROJECT_THREAD_LIST_STATE: ProjectThreadListState = { }; const EMPTY_PROJECTS: readonly ProjectResponse[] = []; -const EMPTY_FOLDER_PATHS: readonly string[] = []; +const EMPTY_FOLDER_DEFINITIONS: readonly ThreadFolderResponse[] = []; function getProjectId(project: ProjectResponse): string { return project.id; @@ -392,6 +395,39 @@ function normalizeCollapsedSidebarSectionIds( return normalized; } +function uniqueFolderPaths( + folders: readonly ThreadFolderResponse[], +): readonly string[] { + const paths = new Set(); + for (const folder of folders) { + paths.add(folder.path); + } + return Array.from(paths).sort((left, right) => left.localeCompare(right)); +} + +function groupFolderPathsByProjectId( + folders: readonly ThreadFolderResponse[], +): ReadonlyMap { + const pathsByProjectId = new Map(); + for (const folder of folders) { + if (folder.projectId === null) { + continue; + } + const paths = pathsByProjectId.get(folder.projectId); + if (paths) { + paths.push(folder.path); + } else { + pathsByProjectId.set(folder.projectId, [folder.path]); + } + } + + for (const paths of pathsByProjectId.values()) { + paths.sort((left, right) => left.localeCompare(right)); + } + + return pathsByProjectId; +} + function ProjectListSectionIconButton({ ariaLabel, disabled = false, @@ -445,13 +481,15 @@ function ProjectListThreadsSectionActions({ }: ProjectListThreadsSectionActionsProps) { return ( <> - + {onNewFolder ? ( + + ) : null} - sidebarNavigation?.folders.map((folder) => folder.path) ?? - EMPTY_FOLDER_PATHS, - [sidebarNavigation], + const folders = sidebarNavigation?.folders ?? EMPTY_FOLDER_DEFINITIONS; + const folderPathsByProjectId = useMemo( + () => groupFolderPathsByProjectId(folders), + [folders], + ); + const chronologicalFolderPaths = useMemo( + () => uniqueFolderPaths(folders), + [folders], ); const projects = useMemo( () => sidebarNavigation?.projects.map(stripProjectThreads), @@ -1131,24 +1172,34 @@ function ProjectListComponent({ const handleCreateProjectlessThread = useCallback(() => { openRootComposeForProject(PERSONAL_PROJECT_ID); }, [openRootComposeForProject]); - const [isFolderCreateDialogOpen, setIsFolderCreateDialogOpen] = - useState(false); - const handleOpenCreateFolderDialog = useCallback(() => { - setIsFolderCreateDialogOpen(true); + const [folderCreateProjectId, setFolderCreateProjectId] = useState< + string | null + >(null); + const isFolderCreateDialogOpen = folderCreateProjectId !== null; + const handleOpenCreateProjectFolderDialog = useCallback( + (projectId: string) => { + setFolderCreateProjectId(projectId); + }, + [], + ); + const handleOpenCreateProjectlessFolderDialog = useCallback(() => { + setFolderCreateProjectId(PERSONAL_PROJECT_ID); }, []); const handleCreateFolderDialogOpenChange = useCallback((open: boolean) => { - setIsFolderCreateDialogOpen(open); + if (!open) { + setFolderCreateProjectId(null); + } }, []); const handleCreateThreadFolder = useCallback( (path: string) => { createThreadFolderMutate( - { path }, + { path, projectId: folderCreateProjectId }, { - onSuccess: () => setIsFolderCreateDialogOpen(false), + onSuccess: () => setFolderCreateProjectId(null), }, ); }, - [createThreadFolderMutate], + [createThreadFolderMutate, folderCreateProjectId], ); const handleOpenProjectlessArchivedThreads = useCallback(() => { onProjectSelect?.(); @@ -1574,7 +1625,8 @@ function ProjectListComponent({ compareThreads={sidebarThreadComparator} onProjectSelect={onProjectSelect} onCreateProjectThread={handleCreateProjectThread} - folderPaths={folderPaths} + onCreateProjectFolder={handleOpenCreateProjectFolderDialog} + folderPathsByProjectId={folderPathsByProjectId} onToggleProjectCollapsed={toggleProjectCollapsed} onToggleThreadCollapsed={toggleThreadCollapsed} onToggleEnvironmentCollapsed={toggleEnvironmentCollapsed} @@ -1589,7 +1641,7 @@ function ProjectListComponent({ collapsedThreadIds={collapsedThreadIds} collapsedEnvironmentIds={collapsedEnvironmentIds} compareThreads={sidebarThreadComparator} - folderPaths={folderPaths} + folderPaths={folderPathsByProjectId.get(PERSONAL_PROJECT_ID)} variant="section" onProjectSelect={onProjectSelect} onToggleThreadCollapsed={toggleThreadCollapsed} @@ -1600,7 +1652,7 @@ function ProjectListComponent({ diff --git a/apps/app/src/components/sidebar/ProjectListProjects.tsx b/apps/app/src/components/sidebar/ProjectListProjects.tsx index 3c0f6f8d6..ad94d3ba0 100644 --- a/apps/app/src/components/sidebar/ProjectListProjects.tsx +++ b/apps/app/src/components/sidebar/ProjectListProjects.tsx @@ -51,9 +51,10 @@ export interface ProjectListProjectsProps { collapsedThreadIds: Set; collapsedEnvironmentIds: Set; compareThreads: ThreadComparator; - folderPaths?: readonly string[]; + folderPathsByProjectId?: ReadonlyMap; onProjectSelect?: () => void; onCreateProjectThread?: (projectId: string) => void; + onCreateProjectFolder?: (projectId: string) => void; onToggleProjectCollapsed: (projectId: string) => void; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; @@ -64,6 +65,8 @@ interface SortableProjectRowProps extends ProjectRowProps { reorderDisabled: boolean; } +const EMPTY_FOLDER_PATHS: readonly string[] = []; + const SortableProjectRow = memo(function SortableProjectRow({ project, reorderDisabled, @@ -100,9 +103,10 @@ export function ProjectListProjects({ collapsedThreadIds, collapsedEnvironmentIds, compareThreads, - folderPaths, + folderPathsByProjectId, onProjectSelect, onCreateProjectThread, + onCreateProjectFolder, onToggleProjectCollapsed, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, @@ -111,7 +115,8 @@ export function ProjectListProjects({ const sharedRowProps = (row: ProjectListRowModel) => ({ project: row.project, threadListState: row.threadListState, - folderPaths, + folderPaths: + folderPathsByProjectId?.get(row.project.id) ?? EMPTY_FOLDER_PATHS, selectedThreadId, isActive: row.isActive, isCollapsed: collapsedProjectIds.has(row.project.id), @@ -121,6 +126,7 @@ export function ProjectListProjects({ isLocalPathInvalid: row.isLocalPathInvalid, onProjectSelect, onCreateProjectThread, + onCreateProjectFolder, onToggleProjectCollapsed, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, @@ -157,9 +163,7 @@ export function ProjectListProjects({ void; onCreateProjectThread?: (projectId: string) => void; + onCreateProjectFolder?: (projectId: string) => void; onToggleProjectCollapsed: (projectId: string) => void; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; @@ -1287,6 +1288,7 @@ const FolderTreeItemRow = memo(function FolderTreeItemRow({ activity={folder.activity} consumeClickSuppression={consumeClickSuppression} dragBindings={dragBindings} + isDropTargetActive={dragBindings?.isOver === true} isCollapsed={isCollapsed} onToggleCollapsed={handleToggleCollapsed} stickyLevel={stickyLevel} @@ -1657,6 +1659,7 @@ function ProjectRowComponent({ isLocalPathInvalid, onProjectSelect, onCreateProjectThread, + onCreateProjectFolder, onToggleProjectCollapsed, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, @@ -1685,6 +1688,9 @@ function ProjectRowComponent({ const handleCreateThread = useCallback(() => { onCreateProjectThread?.(project.id); }, [onCreateProjectThread, project.id]); + const handleCreateFolder = useCallback(() => { + onCreateProjectFolder?.(project.id); + }, [onCreateProjectFolder, project.id]); return ( + - + + + + + + + Project actions + diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 7ada141df..a2c4481df 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -111,6 +111,11 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip.js"; import { SIDEBAR_HOVER_ACTIONS_CLASS, SIDEBAR_HOVER_ACTIONS_GAP_CLASS, @@ -417,12 +422,12 @@ function ProjectListSectionIconButton({ [onClick], ); - return ( + const button = ( ); + + return ( + + + {disabled ? {button} : button} + + {title} + + ); } function ProjectListProjectsSectionActions({ @@ -543,22 +557,27 @@ export function SidebarViewOptionsMenu({ return ( - - - + + + + + + + Sidebar display options + - - - + + + + + + + Threads actions + + + + + + {collapseControl.isCollapsed ? `Expand ${label}` - : `Collapse ${label}` - } - className={cn( - !collapseControl.isCollapsed && SIDEBAR_HOVER_ACTIONS_CLASS, - "relative z-20 inline-flex size-5 shrink-0 items-center justify-center rounded-md text-subtle-foreground outline-none ring-sidebar-ring transition-colors hover:text-sidebar-foreground focus-visible:ring-2", - )} - onClick={handleCollapseControlClick} - onPointerDown={stopCollapseControlPointerDown} - onKeyDown={stopCollapseControlKeyDown} - > - + ) : null} {actions ? ( diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index dc59dd226..fb23aa277 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -39,6 +39,11 @@ import { SidebarStickyGroup, SidebarStickyTier, } from "@/components/ui/sidebar.js"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip.js"; import { ProjectActionsContextMenu, ProjectActionsMenu, @@ -1747,24 +1752,30 @@ function ProjectRowComponent({ /> {isLocalPathInvalid ? ( - { - event.stopPropagation(); - onProjectSelect?.(); - }} - title="Project folder not found. Open project settings to fix." - aria-label="Project folder not found" - className={cn( - "relative z-10 inline-flex shrink-0 items-center justify-center rounded-md text-destructive outline-none ring-sidebar-ring transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-2", - COARSE_POINTER_ROW_ACTION_SIZE_CLASS, - )} - > - - + + + { + event.stopPropagation(); + onProjectSelect?.(); + }} + aria-label="Project folder not found" + className={cn( + "relative z-10 inline-flex shrink-0 items-center justify-center rounded-md text-destructive outline-none ring-sidebar-ring transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-2", + COARSE_POINTER_ROW_ACTION_SIZE_CLASS, + )} + > + + + + + Open project settings to fix folder + + ) : null} - + + + {onCreateProjectThread ? ( + + ) : ( + + + + )} + + New thread + From 240e38cce7f26f04dda467ad8157ec73e8519c0d Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 01:42:25 -0700 Subject: [PATCH 19/54] Remove folder shortcut from thread rename --- .../dialogs/ThreadRenameDialog.stories.tsx | 6 +-- .../components/dialogs/ThreadRenameDialog.tsx | 19 +------ .../src/components/sidebar/folderPath.test.ts | 49 ------------------- apps/app/src/components/sidebar/folderPath.ts | 25 +--------- 4 files changed, 6 insertions(+), 93 deletions(-) diff --git a/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx b/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx index db0fee1e7..a23f13900 100644 --- a/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx +++ b/apps/app/src/components/dialogs/ThreadRenameDialog.stories.tsx @@ -22,7 +22,7 @@ const parentTarget: ThreadRenameDialogTarget = { currentTitle: "Frontend Parent", }; -const folderTarget: ThreadRenameDialogTarget = { +const slashTitleTarget: ThreadRenameDialogTarget = { id: "thr_folder", currentTitle: "test/say hi", }; @@ -60,10 +60,10 @@ export function Overview() { /> - + ) => { event.preventDefault(); @@ -86,11 +80,7 @@ export function ThreadRenameDialogContent({ const trimmedTitle = validate(nextTitle); if (trimmedTitle === null) return; - const folderShortcut = parseThreadFolderShortcut(trimmedTitle); - onRename( - target.id, - folderShortcut.folderPath ? folderShortcut : { title: trimmedTitle }, - ); + onRename(target.id, { title: trimmedTitle }); }; return ( @@ -120,15 +110,10 @@ export function ThreadRenameDialogContent({ {validationMessage ? (

{validationMessage}

) : null} - {createsFolder ? ( -

- Using “/” groups this thread into a folder -

- ) : null}
diff --git a/apps/app/src/components/sidebar/folderPath.test.ts b/apps/app/src/components/sidebar/folderPath.test.ts index 86bcd2141..13bc545f4 100644 --- a/apps/app/src/components/sidebar/folderPath.test.ts +++ b/apps/app/src/components/sidebar/folderPath.test.ts @@ -3,9 +3,7 @@ import { buildFolderKey, folderAncestorKeys, normalizeFolderPath, - parseThreadFolderShortcut, splitFolderPath, - titleCreatesFolder, } from "./folderPath"; describe("splitFolderPath", () => { @@ -34,53 +32,6 @@ describe("normalizeFolderPath", () => { }); }); -describe("parseThreadFolderShortcut", () => { - it("splits an explicit slash rename into folderPath and title", () => { - expect(parseThreadFolderShortcut("Work/Q3/Plan")).toEqual({ - folderPath: "Work/Q3", - title: "Plan", - }); - }); - - it("keeps slashy titles literal when they do not define a folder and leaf", () => { - expect(parseThreadFolderShortcut("Standalone")).toEqual({ - folderPath: null, - title: "Standalone", - }); - expect(parseThreadFolderShortcut("Work/")).toEqual({ - folderPath: null, - title: "Work/", - }); - }); - - it("collapses leading, trailing, and doubled slashes in explicit paths", () => { - expect(parseThreadFolderShortcut("/Work//Q3/Plan/")).toEqual({ - folderPath: "Work/Q3", - title: "Plan", - }); - }); -}); - -describe("titleCreatesFolder", () => { - it("is false for a single segment", () => { - expect(titleCreatesFolder("Standalone")).toBe(false); - }); - - it("is false when a trailing slash leaves a single segment", () => { - expect(titleCreatesFolder("Work/")).toBe(false); - }); - - it("is false for an all-slashes title", () => { - expect(titleCreatesFolder("///")).toBe(false); - }); - - it("is true once two or more segments survive normalization", () => { - expect(titleCreatesFolder("Work/Q3")).toBe(true); - expect(titleCreatesFolder("Work/Q3/")).toBe(true); - expect(titleCreatesFolder("Clients/Acme/Onboarding")).toBe(true); - }); -}); - describe("buildFolderKey", () => { it("namespaces a folder path by its container id", () => { expect(buildFolderKey("proj_bb", ["Work", "Q3"])).toBe("proj_bb::Work/Q3"); diff --git a/apps/app/src/components/sidebar/folderPath.ts b/apps/app/src/components/sidebar/folderPath.ts index 0549c9830..b0e637111 100644 --- a/apps/app/src/components/sidebar/folderPath.ts +++ b/apps/app/src/components/sidebar/folderPath.ts @@ -1,13 +1,5 @@ // Pure helpers for sidebar folders. Thread titles are display text; folder -// membership lives in `thread.folderPath`. Slash parsing is only used by -// explicit UI affordances that choose to write folder metadata. - -export interface ThreadFolderShortcut { - /** Normalized folder path written to thread.folderPath, or null for none. */ - folderPath: string | null; - /** Thread title written separately from the folder path. */ - title: string; -} +// membership lives in `thread.folderPath`. function splitPathSegments(value: string): string[] { return value @@ -32,21 +24,6 @@ export function normalizeFolderPath( return normalized.length > 0 ? normalized : null; } -export function parseThreadFolderShortcut(value: string): ThreadFolderShortcut { - const segments = splitPathSegments(value); - if (segments.length <= 1) { - return { folderPath: null, title: value.trim() }; - } - return { - folderPath: segments.slice(0, -1).join("/"), - title: segments[segments.length - 1], - }; -} - -export function titleCreatesFolder(value: string): boolean { - return parseThreadFolderShortcut(value).folderPath !== null; -} - // Every ancestor folder key for a stored folder path, outermost first — e.g. // "Work/Q3" in container "p" → ["p::Work", "p::Work/Q3"]. Used to un-collapse // the folders hiding a selected thread. From cda69415ec55b50ffd67ba0b3f42587e221c553e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 01:45:20 -0700 Subject: [PATCH 20/54] Show sidebar action tooltips below buttons --- apps/app/src/components/project/ProjectActionsMenu.tsx | 2 +- apps/app/src/components/sidebar/ProjectList.tsx | 8 ++++---- apps/app/src/components/sidebar/ProjectRow.tsx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/app/src/components/project/ProjectActionsMenu.tsx b/apps/app/src/components/project/ProjectActionsMenu.tsx index 822923e5f..332b208f2 100644 --- a/apps/app/src/components/project/ProjectActionsMenu.tsx +++ b/apps/app/src/components/project/ProjectActionsMenu.tsx @@ -199,7 +199,7 @@ export function ProjectActionsMenu({ - Project actions + Project actions diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index a2c4481df..d8b7b401e 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -442,7 +442,7 @@ function ProjectListSectionIconButton({ {disabled ? {button} : button} - {title} + {title} ); } @@ -576,7 +576,7 @@ export function SidebarViewOptionsMenu({ - Sidebar display options + Sidebar display options - Threads actions + Threads actions - + {collapseControl.isCollapsed ? `Expand ${label}` : `Collapse ${label}`} diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index fb23aa277..56e4fcff3 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -1772,7 +1772,7 @@ function ProjectRowComponent({ /> - + Open project settings to fix folder @@ -1844,7 +1844,7 @@ function ProjectRowComponent({ )} - New thread + New thread From 0ed5bd65f7f7764ca445115550f2612a24385aa6 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 02:17:50 -0700 Subject: [PATCH 21/54] Add folder actions and loose threads section --- .../dialogs/ThreadFolderCreateDialog.tsx | 80 +++++- .../src/components/sidebar/ProjectList.tsx | 167 ++++++++++--- .../app/src/components/sidebar/ProjectRow.tsx | 229 +++++++++++++++++- .../components/sidebar/SidebarFolderRow.tsx | 97 +++++++- .../mutations/thread-folder-mutations.ts | 50 +++- apps/app/src/lib/api.ts | 19 ++ apps/app/src/views/RootComposeView.tsx | 22 ++ apps/server/src/routes/thread-folders.ts | 34 ++- apps/server/src/routes/threads/base.ts | 4 + .../services/threads/thread-create-helpers.ts | 1 + .../services/threads/thread-create-request.ts | 1 + packages/db/src/data/index.ts | 5 + packages/db/src/data/thread-folders.ts | 184 +++++++++++++- packages/db/test/data/threads.test.ts | 87 +++++++ packages/server-contract/src/api/projects.ts | 29 +++ packages/server-contract/src/api/threads.ts | 1 + packages/server-contract/src/public-api.ts | 21 ++ .../server-contract/test/contract.test.ts | 1 + 18 files changed, 963 insertions(+), 69 deletions(-) diff --git a/apps/app/src/components/dialogs/ThreadFolderCreateDialog.tsx b/apps/app/src/components/dialogs/ThreadFolderCreateDialog.tsx index 406984e1b..4ffb5643b 100644 --- a/apps/app/src/components/dialogs/ThreadFolderCreateDialog.tsx +++ b/apps/app/src/components/dialogs/ThreadFolderCreateDialog.tsx @@ -20,9 +20,25 @@ interface ThreadFolderCreateDialogProps { onCreate: (path: string) => void; } -interface ThreadFolderCreateDialogContentProps { +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; - onCreate: (path: string) => void; + submitLabel: string; + title: string; + onSubmit: (path: string) => void; inputRef: RefObject; } @@ -37,9 +53,42 @@ export function ThreadFolderCreateDialog({ {open ? ( - + ) : null} + + + ); +} + +export function ThreadFolderRenameDialog({ + target, + pending = false, + onOpenChange, + onRename, +}: ThreadFolderRenameDialogProps) { + const { inputRef, handleOpenAutoFocus } = useRenameDialogAutoFocus(); + return ( + + + {target ? ( + onRename(target.path, newPath)} inputRef={inputRef} /> ) : null} @@ -48,13 +97,18 @@ export function ThreadFolderCreateDialog({ ); } -function ThreadFolderCreateDialogContent({ +function ThreadFolderDialogContent({ + description, + initialPath, + inputLabel, pending, - onCreate, + submitLabel, + title, + onSubmit, inputRef, -}: ThreadFolderCreateDialogContentProps) { +}: ThreadFolderDialogContentProps) { const inputId = useId(); - const [path, setPath] = useState(""); + const [path, setPath] = useState(initialPath); const [folderPathMessage, setFolderPathMessage] = useState( null, ); @@ -74,22 +128,22 @@ function ThreadFolderCreateDialogContent({ return; } - onCreate(normalizedPath); + onSubmit(normalizedPath); }; const displayedMessage = validationMessage ?? folderPathMessage; return ( <> - New folder - Create a folder for threads. + {title} + {description}
diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index d8b7b401e..877aead3a 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -35,12 +35,17 @@ import { stripProjectThreads } from "@/hooks/queries/project-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { useReorderProject } from "@/hooks/mutations/project-mutations"; import { useReorderPinnedThread } from "@/hooks/mutations/thread-state-mutations"; -import { useCreateThreadFolder } from "@/hooks/mutations/thread-folder-mutations"; +import { + useCreateThreadFolder, + useDeleteThreadFolder, + useUpdateThreadFolder, +} from "@/hooks/mutations/thread-folder-mutations"; import { isLocalPathMissing, useLocalPathExistence, } from "@/hooks/queries/host-path-queries"; import { useHostDaemon } from "@/hooks/useHostDaemon"; +import { useDialogState } from "@/hooks/useDialogState"; import { getProjectlessArchivedRoutePath, getRootComposeRoutePath, @@ -52,7 +57,11 @@ import { import { useSetRootComposeProjectId } from "@/lib/root-compose-selection"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button.js"; -import { ThreadFolderCreateDialog } from "@/components/dialogs/ThreadFolderCreateDialog"; +import { + ThreadFolderCreateDialog, + ThreadFolderRenameDialog, + type ThreadFolderRenameDialogTarget, +} from "@/components/dialogs/ThreadFolderCreateDialog"; import { CHROME_SECTION_LABEL_CLASS } from "@/components/ui/chromeStyleTokens"; import { Icon, type IconName } from "@/components/ui/icon.js"; import { Skeleton } from "@/components/ui/skeleton.js"; @@ -69,7 +78,10 @@ import { COARSE_POINTER_ROW_HEIGHT_CLASS, COARSE_POINTER_TEXT_SM_CLASS, } from "@/components/ui/coarse-pointer-sizing.js"; -import { ChronologicalThreadTree, ProjectThreadTree } from "./ProjectRow"; +import { + ChronologicalFolderThreadSections, + ProjectThreadTree, +} from "./ProjectRow"; import { SidebarThreadSearchPanel } from "./SidebarThreadSearchPanel"; import type { ProjectThreadListState } from "./ProjectRow"; import { @@ -1101,6 +1113,11 @@ function ProjectListComponent({ isPending: isCreateThreadFolderPending, mutate: createThreadFolderMutate, } = useCreateThreadFolder(); + const { + isPending: isUpdateThreadFolderPending, + mutate: updateThreadFolderMutate, + } = useUpdateThreadFolder(); + const { mutate: deleteThreadFolderMutate } = useDeleteThreadFolder(); const projectItems = projects ?? EMPTY_PROJECTS; const handleReorderProject = useCallback< UseNeighborReorderSortableArgs["onReorder"] @@ -1153,11 +1170,14 @@ function ProjectListComponent({ [reorderPinnedThreadMutate], ); const openRootComposeForProject = useCallback( - (projectId: string) => { + (projectId: string, folderPath?: string) => { setRootComposeProjectId(projectId); onProjectSelect?.(); navigate(getRootComposeRoutePath(), { - state: { focusPrompt: true }, + state: { + focusPrompt: true, + ...(folderPath ? { folderPath } : {}), + }, }); }, [navigate, onProjectSelect, setRootComposeProjectId], @@ -1171,9 +1191,16 @@ function ProjectListComponent({ const handleCreateProjectlessThread = useCallback(() => { openRootComposeForProject(PERSONAL_PROJECT_ID); }, [openRootComposeForProject]); + const handleCreateThreadInFolder = useCallback( + (folderPath: string) => { + openRootComposeForProject(PERSONAL_PROJECT_ID, folderPath); + }, + [openRootComposeForProject], + ); const [folderCreateTarget, setFolderCreateTarget] = useState<{ projectId: string | null; } | null>(null); + const folderRenameDialog = useDialogState(); const isFolderCreateDialogOpen = folderCreateTarget !== null; const handleOpenCreateFolderDialog = useCallback(() => { setFolderCreateTarget({ projectId: null }); @@ -1194,6 +1221,27 @@ function ProjectListComponent({ }, [createThreadFolderMutate, folderCreateTarget], ); + const handleOpenRenameThreadFolder = useCallback( + (path: string) => { + folderRenameDialog.onOpen({ path }); + }, + [folderRenameDialog], + ); + const handleRenameThreadFolder = useCallback( + (path: string, newPath: string) => { + updateThreadFolderMutate( + { path, newPath }, + { onSuccess: () => folderRenameDialog.onClose() }, + ); + }, + [folderRenameDialog, updateThreadFolderMutate], + ); + const handleRemoveThreadFolder = useCallback( + (path: string) => { + deleteThreadFolderMutate({ path }); + }, + [deleteThreadFolderMutate], + ); const handleOpenProjectlessArchivedThreads = useCallback(() => { onProjectSelect?.(); navigate(getProjectlessArchivedRoutePath()); @@ -1633,19 +1681,6 @@ function ProjectListComponent({ onToggleEnvironmentCollapsed={toggleEnvironmentCollapsed} /> ); - const allThreadsSectionContent = ( - - ); const projectsSectionActions = ( <> + + + + ); + const folderModeThreadsSectionActions = ( + <> + + + + ); const threadsSectionActions = ( <> ); + const folderModeSectionsContent = ( + ( + + {content} + + )} + renderThreadsSection={(content) => ( + toggleSidebarSectionCollapsed("threads"), + }} + > + {content} + + )} + /> + ); const folderCreateDialog = ( ); + const folderRenameDialogContent = ( + + ); if (threadSearch?.isActive) { return ( @@ -1728,22 +1836,10 @@ function ProjectListComponent({ {pinnedSectionContent} ) : null} - toggleSidebarSectionCollapsed("threads"), - }} - > - {allThreadsSectionContent} - + {folderModeSectionsContent}
{folderCreateDialog} + {folderRenameDialogContent} ); } @@ -1817,6 +1913,7 @@ function ProjectListComponent({ {folderCreateDialog} + {folderRenameDialogContent} ); } diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 56e4fcff3..812445b54 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -8,7 +8,7 @@ import { type ReactNode, } from "react"; import { useAtomValue, useSetAtom } from "jotai"; -import { DndContext, type DragEndEvent } from "@dnd-kit/core"; +import { DndContext, useDroppable, type DragEndEvent } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, @@ -172,10 +172,18 @@ export interface ChronologicalThreadTreeProps { collapsedThreadIds: Set; collapsedEnvironmentIds: Set; onProjectSelect?: () => void; + onCreateThreadInFolder?: (folderPath: string) => void; + onRenameFolder?: (folderPath: string) => void; + onRemoveFolder?: (folderPath: string) => void; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; } +export interface ChronologicalFolderThreadSectionsProps extends ChronologicalThreadTreeProps { + renderFoldersSection: (content: ReactNode) => ReactNode; + renderThreadsSection: (content: ReactNode) => ReactNode; +} + export type ProjectThreadTreeVariant = "project" | "section"; type ProjectItemClickCaptureHandler = MouseEventHandler; @@ -224,6 +232,9 @@ interface ThreadTreeItemRowProps { collapsedEnvironmentIds: Set; variant: ProjectThreadTreeVariant; onProjectSelect?: () => void; + onCreateThreadInFolder?: (folderPath: string) => void; + onRenameFolder?: (folderPath: string) => void; + onRemoveFolder?: (folderPath: string) => void; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; consumeClickSuppression?: ConsumeDragClickSuppression; @@ -241,6 +252,9 @@ interface FolderTreeItemRowProps { collapsedEnvironmentIds: Set; variant: ProjectThreadTreeVariant; onProjectSelect?: () => void; + onCreateThreadInFolder?: (folderPath: string) => void; + onRenameFolder?: (folderPath: string) => void; + onRemoveFolder?: (folderPath: string) => void; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; consumeClickSuppression?: ConsumeDragClickSuppression; @@ -386,17 +400,25 @@ function useManualThreadTreeDnd({ const activeKind = lookup.itemKindById.get(activeId); const overKind = lookup.itemKindById.get(overId); const fromParentKey = lookup.parentKeyByItemId.get(activeId); - let toParentKey = lookup.parentKeyByItemId.get(overId); - if (!activeKind || !overKind || !fromParentKey || !toParentKey) { + if (!activeKind || !fromParentKey) { return; } - // Dropping a thread on a folder header means "move into this folder". - if (activeKind === "thread" && overKind === "folder") { + let toParentKey = overKind + ? lookup.parentKeyByItemId.get(overId) + : undefined; + if (!overKind && lookup.folderPathByParentKey.has(overId)) { + toParentKey = overId; + } else if (activeKind === "thread" && overKind === "folder") { + // Dropping a thread on a folder header means "move into this folder". toParentKey = overId; } + if (!toParentKey) { + return; + } + if (activeKind !== "thread" || fromParentKey === toParentKey) { return; } @@ -721,6 +743,36 @@ function ManualSortableList({ ); } +function ManualDroppableParent({ + children, + className, + manualSort, + parentKey, +}: { + children: ReactNode; + className?: string; + manualSort?: ManualThreadTreeDndState | null; + parentKey: string; +}) { + const { isOver, setNodeRef } = useDroppable({ + id: parentKey, + disabled: !manualSort?.enabled, + }); + + return ( +
+ {children} +
+ ); +} + const ManualSortableThreadTreeItemRow = memo( function ManualSortableThreadTreeItemRow({ manualSort, @@ -1174,6 +1226,9 @@ export const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ collapsedEnvironmentIds, variant, onProjectSelect, + onCreateThreadInFolder, + onRenameFolder, + onRemoveFolder, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, consumeClickSuppression, @@ -1192,6 +1247,9 @@ export const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ collapsedEnvironmentIds={collapsedEnvironmentIds} variant={variant} onProjectSelect={onProjectSelect} + onCreateThreadInFolder={onCreateThreadInFolder} + onRenameFolder={onRenameFolder} + onRemoveFolder={onRemoveFolder} onToggleThreadCollapsed={onToggleThreadCollapsed} onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} consumeClickSuppression={consumeClickSuppression} @@ -1255,6 +1313,9 @@ const FolderTreeItemRow = memo(function FolderTreeItemRow({ collapsedEnvironmentIds, variant, onProjectSelect, + onCreateThreadInFolder, + onRenameFolder, + onRemoveFolder, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, consumeClickSuppression, @@ -1278,12 +1339,17 @@ const FolderTreeItemRow = memo(function FolderTreeItemRow({ const headerDepth = getThreadRowDepth({ depthOffset, nodeDepth: 0, variant }); const stickyLevel = depthOffset < SIDEBAR_STICKY_PARENT_DEPTH_CAP ? depthOffset : undefined; + const folderPath = folder.path.join("/"); + const isDropTargetActive = dragBindings?.isOver === true; return ( onCreateThreadInFolder(folderPath) + : undefined + } + onRename={onRenameFolder ? () => onRenameFolder(folderPath) : undefined} + onRemove={onRemoveFolder ? () => onRemoveFolder(folderPath) : undefined} onToggleCollapsed={handleToggleCollapsed} stickyLevel={stickyLevel} /> @@ -1313,6 +1386,9 @@ const FolderTreeItemRow = memo(function FolderTreeItemRow({ collapsedEnvironmentIds={collapsedEnvironmentIds} variant={variant} onProjectSelect={onProjectSelect} + onCreateThreadInFolder={onCreateThreadInFolder} + onRenameFolder={onRenameFolder} + onRemoveFolder={onRemoveFolder} onToggleThreadCollapsed={onToggleThreadCollapsed} onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} manualSort={manualSort} @@ -1650,6 +1726,145 @@ export const ChronologicalThreadTree = memo(function ChronologicalThreadTree({ ); }); +export const ChronologicalFolderThreadSections = memo( + function ChronologicalFolderThreadSections({ + threadListState, + compareThreads, + folderPaths = EMPTY_FOLDER_PATHS, + selectedThreadId, + collapsedThreadIds, + collapsedEnvironmentIds, + onProjectSelect, + onCreateThreadInFolder, + onRenameFolder, + onRemoveFolder, + onToggleThreadCollapsed, + onToggleEnvironmentCollapsed, + renderFoldersSection, + renderThreadsSection, + }: ChronologicalFolderThreadSectionsProps) { + const groupBy = "folder" as const; + const threads = + threadListState.status === "ready" + ? threadListState.threads + : EMPTY_PROJECT_THREADS; + const rootItems = useMemo( + () => + buildChronologicalThreadList(threads, compareThreads, { + groupBy, + containerId: CHRONOLOGICAL_CONTAINER_ID, + folderPaths, + }), + [threads, compareThreads, groupBy, folderPaths], + ); + const manualSort = useManualThreadTreeDnd({ + containerId: CHRONOLOGICAL_CONTAINER_ID, + enabled: hasFolderItems(rootItems), + rootItems, + }); + const folderItems = rootItems.filter((item) => item.kind === "folder"); + const looseItems = rootItems.filter((item) => item.kind !== "folder"); + + const renderItems = (items: readonly ProjectThreadItem[]) => ( + + {items.map((item) => ( + + ))} + + ); + + const foldersContent = + threadListState.status === "loading" ? ( +
+ +
+ ) : folderItems.length > 0 ? ( + renderItems(folderItems) + ) : ( + + ); + const threadsListContent = + threadListState.status === "loading" ? ( +
+ +
+ ) : looseItems.length > 0 ? ( + renderItems(looseItems) + ) : ( + + ); + const threadsContent = manualSort?.enabled ? ( + + {threadsListContent} + + ) : ( + threadsListContent + ); + + const sections = ( + + {renderFoldersSection(foldersContent)} + {renderThreadsSection(threadsContent)} + + ); + + return manualSort ? ( + {sections} + ) : ( + sections + ); + }, +); + function ProjectRowComponent({ project, threadListState, diff --git a/apps/app/src/components/sidebar/SidebarFolderRow.tsx b/apps/app/src/components/sidebar/SidebarFolderRow.tsx index c5d1f0b12..833e8a344 100644 --- a/apps/app/src/components/sidebar/SidebarFolderRow.tsx +++ b/apps/app/src/components/sidebar/SidebarFolderRow.tsx @@ -1,11 +1,24 @@ import { memo, useCallback, + useState, type CSSProperties, type MouseEventHandler, } from "react"; +import { Button } from "@/components/ui/button.js"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu.js"; import { Icon } from "@/components/ui/icon.js"; import { SidebarStickyTier } from "@/components/ui/sidebar.js"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip.js"; import { COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, COARSE_POINTER_GLYPH_BOX_CLASS, @@ -13,7 +26,10 @@ import { COARSE_POINTER_ROW_ACTION_SIZE_CLASS, } from "@/components/ui/coarse-pointer-sizing.js"; import { + SIDEBAR_HOVER_ACTIONS_CLASS, SIDEBAR_HOVER_ACTIONS_FADE_CLASS, + SIDEBAR_HOVER_ACTIONS_GAP_CLASS, + SIDEBAR_HOVER_ACTIONS_MOBILE_ALWAYS_VALUE, SIDEBAR_HOVER_ACTIONS_ROW_CLASS, } from "@/components/ui/sidebar-hover-actions.js"; import { cn } from "@/lib/utils"; @@ -44,6 +60,9 @@ interface SidebarFolderRowProps { consumeClickSuppression?: ConsumeDragClickSuppression; dragBindings?: SidebarSortableDragBindings; isDropTargetActive?: boolean; + onCreateThread?: () => void; + onRename?: () => void; + onRemove?: () => void; } // The "Work › Q3" disclosure header for a folder. Not a thread: clicking @@ -59,8 +78,13 @@ function SidebarFolderRowComponent({ isDropTargetActive = false, isCollapsed, onToggleCollapsed, + onCreateThread, + onRename, + onRemove, stickyLevel, }: SidebarFolderRowProps) { + const [isActionsOpen, setIsActionsOpen] = useState(false); + const hasActions = Boolean(onCreateThread || onRename || onRemove); // Collapsed: the header speaks for its hidden descendants through one glyph // (pending > working > unread). Expanded: descendants show their own glyphs. const showRollupGlyph = @@ -75,8 +99,7 @@ function SidebarFolderRowComponent({ COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, "cursor-pointer", dragBindings && !dragBindings.disabled && "select-none", - isDropTargetActive && - "bg-sidebar-accent text-sidebar-accent-foreground ring-1 ring-sidebar-ring", + isDropTargetActive && "bg-sidebar-accent text-sidebar-accent-foreground", ); const style: CSSProperties = { paddingLeft: getSidebarThreadRowPaddingLeft(depth), @@ -91,6 +114,12 @@ function SidebarFolderRowComponent({ }, [consumeClickSuppression], ); + const stopActionsClick = useCallback>( + (event) => { + event.stopPropagation(); + }, + [], + ); const content = ( <> {/* Full-bleed toggle target for pointer users; the chevron owns keyboard @@ -132,11 +161,7 @@ function SidebarFolderRowComponent({ COARSE_POINTER_ROW_ACTION_SIZE_CLASS, )} > - {isDropTargetActive ? ( - - Drop inside - - ) : showRollupGlyph ? ( + {showRollupGlyph ? ( ) : null} + {hasActions ? ( + + + + + + + + + Folder actions + + + {onCreateThread ? ( + + New thread + + ) : null} + {onRename ? ( + Rename + ) : null} + {onRemove ? ( + + Remove + + ) : null} + + + + ) : null} ); diff --git a/apps/app/src/hooks/mutations/thread-folder-mutations.ts b/apps/app/src/hooks/mutations/thread-folder-mutations.ts index e2122eb71..36c11ee64 100644 --- a/apps/app/src/hooks/mutations/thread-folder-mutations.ts +++ b/apps/app/src/hooks/mutations/thread-folder-mutations.ts @@ -1,7 +1,21 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { CreateThreadFolderRequest } from "@bb/server-contract"; +import type { + CreateThreadFolderRequest, + DeleteThreadFolderRequest, + UpdateThreadFolderRequest, +} from "@bb/server-contract"; import * as api from "@/lib/api"; -import { invalidateProjectListQueries } from "../cache-owners/mutation-cache-effects"; +import { + invalidateProjectListQueries, + invalidateThreadListQueries, +} from "../cache-owners/mutation-cache-effects"; + +function invalidateThreadFolderQueries( + queryClient: ReturnType, +) { + invalidateProjectListQueries({ queryClient }); + invalidateThreadListQueries({ queryClient }); +} export function useCreateThreadFolder() { const queryClient = useQueryClient(); @@ -13,7 +27,37 @@ export function useCreateThreadFolder() { mutationFn: (request: CreateThreadFolderRequest) => api.createThreadFolder(request), onSuccess: () => { - invalidateProjectListQueries({ queryClient }); + invalidateThreadFolderQueries(queryClient); + }, + }); +} + +export function useUpdateThreadFolder() { + const queryClient = useQueryClient(); + + return useMutation({ + meta: { + errorMessage: "Failed to rename folder.", + }, + mutationFn: (request: UpdateThreadFolderRequest) => + api.updateThreadFolder(request), + onSuccess: () => { + invalidateThreadFolderQueries(queryClient); + }, + }); +} + +export function useDeleteThreadFolder() { + const queryClient = useQueryClient(); + + return useMutation({ + meta: { + errorMessage: "Failed to remove folder.", + }, + mutationFn: (request: DeleteThreadFolderRequest) => + api.deleteThreadFolder(request), + onSuccess: () => { + invalidateThreadFolderQueries(queryClient); }, }); } diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts index 0d0000ff4..52f3fc44e 100644 --- a/apps/app/src/lib/api.ts +++ b/apps/app/src/lib/api.ts @@ -20,6 +20,7 @@ import type { CreateProjectRequest, CreateThreadFolderRequest, CreateQueuedMessageRequest, + DeleteThreadFolderRequest, DeleteThreadRequest, EnvironmentArchiveThreadsResponse, EnvironmentActionRequest, @@ -51,6 +52,7 @@ import type { SystemVoiceTranscriptionResponse, ThreadArchiveAllResponse, ThreadChildSummaryResponse, + ThreadFolderMutationResponse, ThreadFolderResponse, ThreadPendingInteractionsResponse, ThreadQueuedMessageListResponse, @@ -72,6 +74,7 @@ import type { CloseThreadTerminalRequest, ResolvePendingInteractionRequest, UpdateEnvironmentRequest, + UpdateThreadFolderRequest, UpdateProjectRequest, UpdateThreadRequest, UpdateThreadTerminalRequest, @@ -490,6 +493,22 @@ export async function createThreadFolder( ); } +export async function updateThreadFolder( + req: UpdateThreadFolderRequest, +): Promise { + return request( + apiClient["thread-folders"].$patch({ json: req }), + ); +} + +export async function deleteThreadFolder( + req: DeleteThreadFolderRequest, +): Promise { + return request( + apiClient["thread-folders"].$delete({ json: req }), + ); +} + export async function updateProject( id: string, req: UpdateProjectRequest, diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index be21a7be4..425f50d81 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -82,6 +82,17 @@ interface LegacyProjectComposeRedirectProps { projectId: string; } +function readFolderPathFromLocationState(state: unknown): string | null { + if (typeof state !== "object" || state === null) { + return null; + } + if (!("folderPath" in state) || typeof state.folderPath !== "string") { + return null; + } + const folderPath = state.folderPath.trim(); + return folderPath.length > 0 ? folderPath : null; +} + type RootComposeViewProps = | { surface: "page"; @@ -352,6 +363,9 @@ export function RootComposeView(props: RootComposeViewProps) { useRootComposeProjectId(); const location = useLocation(); const navigate = useNavigate(); + const [rootComposeFolderPath, setRootComposeFolderPath] = useState< + string | null + >(() => readFolderPathFromLocationState(location.state)); const promptBoxRef = useRef(null); const quickCreateProject = useQuickCreateProjectController(); const sidebarNavigationQuery = useSidebarNavigation(); @@ -387,6 +401,9 @@ export function RootComposeView(props: RootComposeViewProps) { const [forkSeed, setForkSeed] = useState(() => readForkThreadCreateSeedFromLocationState(location.state), ); + useEffect(() => { + setRootComposeFolderPath(readFolderPathFromLocationState(location.state)); + }, [location.key, location.state]); const primaryHostId = usePrimaryHost()?.id ?? null; const uploadPromptAttachment = useUploadPromptAttachment(); const promptDraft = usePromptDraftStorage({ kind: "new-thread" }); @@ -825,6 +842,9 @@ export function RootComposeView(props: RootComposeViewProps) { projectId, providerId: selectedProviderId, model: selectedThreadModel, + ...(rootComposeFolderPath + ? { folderPath: rootComposeFolderPath } + : {}), ...(supportsServiceTier && serviceTier ? { serviceTier } : {}), reasoningLevel, permissionMode, @@ -839,6 +859,7 @@ export function RootComposeView(props: RootComposeViewProps) { setLastCreatedThreadId(thread.id); clearReuseEnvironment(); setForkSeed(null); + setRootComposeFolderPath(null); promptDraft.clearIfCurrentMatches(submittedDraft); if (props.surface === "popout") { props.onThreadCreated({ @@ -868,6 +889,7 @@ export function RootComposeView(props: RootComposeViewProps) { props, promptDraft, reasoningLevel, + rootComposeFolderPath, selectedEnvironment, selectedProviderId, selectedThreadModel, diff --git a/apps/server/src/routes/thread-folders.ts b/apps/server/src/routes/thread-folders.ts index fa5a17e65..ed01cf9f9 100644 --- a/apps/server/src/routes/thread-folders.ts +++ b/apps/server/src/routes/thread-folders.ts @@ -1,4 +1,9 @@ -import { createThreadFolder, normalizeThreadFolderPath } from "@bb/db"; +import { + createThreadFolder, + deleteThreadFolder, + normalizeThreadFolderPath, + renameThreadFolder, +} from "@bb/db"; import { publicApiRoutes, typedRoutes, @@ -10,7 +15,7 @@ import { ApiError } from "../errors.js"; import { requirePublicProject } from "../services/lib/entity-lookup.js"; export function registerThreadFolderRoutes(app: Hono, deps: AppDeps): void { - const { post } = typedRoutes(app, { + const { del, patch, post } = typedRoutes(app, { onValidationError: (msg) => new ApiError(400, "invalid_request", msg), }); const routes = publicApiRoutes.threadFolders; @@ -32,4 +37,29 @@ export function registerThreadFolderRoutes(app: Hono, deps: AppDeps): void { 201, ); }); + + patch(routes.update, (context, payload) => { + const path = normalizeThreadFolderPath(payload.path); + const newPath = normalizeThreadFolderPath(payload.newPath); + if (!path || !newPath) { + throw new ApiError(400, "invalid_request", "Folder name cannot be empty"); + } + const result = renameThreadFolder(deps.db, deps.hub, { path, newPath }); + if (!result) { + throw new ApiError(404, "folder_not_found", "Folder not found"); + } + return context.json(result); + }); + + del(routes.delete, (context, payload) => { + const path = normalizeThreadFolderPath(payload.path); + if (!path) { + throw new ApiError(400, "invalid_request", "Folder name cannot be empty"); + } + const result = deleteThreadFolder(deps.db, deps.hub, { path }); + if (!result) { + throw new ApiError(404, "folder_not_found", "Folder not found"); + } + return context.json(result); + }); } diff --git a/apps/server/src/routes/threads/base.ts b/apps/server/src/routes/threads/base.ts index 26c489a3c..a8b658165 100644 --- a/apps/server/src/routes/threads/base.ts +++ b/apps/server/src/routes/threads/base.ts @@ -249,6 +249,10 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { post(routes.create, async (context, payload) => { const thread = await createThreadFromRequest(deps, { ...payload, + folderPath: + payload.folderPath === undefined + ? undefined + : normalizeThreadFolderPath(payload.folderPath), origin: payload.origin, }); return context.json(toThreadResponseFromThread(deps, { thread }), 201); diff --git a/apps/server/src/services/threads/thread-create-helpers.ts b/apps/server/src/services/threads/thread-create-helpers.ts index 0490fba4b..34a9fd234 100644 --- a/apps/server/src/services/threads/thread-create-helpers.ts +++ b/apps/server/src/services/threads/thread-create-helpers.ts @@ -168,6 +168,7 @@ export function createThreadRecord( providerId: args.request.providerId, title: args.request.title ?? null, titleFallback: deriveTitleFallback(args.request.input), + folderPath: args.request.folderPath ?? null, parentThreadId: args.request.parentThreadId ?? null, sourceThreadId: args.request.sourceThreadId ?? null, originKind: args.request.originKind ?? args.request.childOrigin, diff --git a/apps/server/src/services/threads/thread-create-request.ts b/apps/server/src/services/threads/thread-create-request.ts index 179e9ae03..bd1790ff6 100644 --- a/apps/server/src/services/threads/thread-create-request.ts +++ b/apps/server/src/services/threads/thread-create-request.ts @@ -16,6 +16,7 @@ export interface ThreadCreateServiceRequestInput { environment: EnvironmentArgs; executionInputSources?: CreateThreadRequest["executionInputSources"]; input: PromptInput[]; + folderPath?: CreateThreadRequest["folderPath"]; model?: CreateThreadRequest["model"]; origin: ThreadCreateOrigin | null; originKind?: ThreadOriginKind | null; diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 98e680db0..feeb712ce 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -21,13 +21,18 @@ export type { export { createThreadFolder, + deleteThreadFolder, ensureThreadFolderPath, getThreadFolderByPath, listThreadFolders, normalizeThreadFolderPath, + renameThreadFolder, } from "./thread-folders.js"; export type { CreateThreadFolderInput, + DeleteThreadFolderInput, + RenameThreadFolderInput, + ThreadFolderMutationResult, ThreadFolderRow, } from "./thread-folders.js"; diff --git a/packages/db/src/data/thread-folders.ts b/packages/db/src/data/thread-folders.ts index ecc187c0e..4c146afbf 100644 --- a/packages/db/src/data/thread-folders.ts +++ b/packages/db/src/data/thread-folders.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, isNull } from "drizzle-orm"; +import { and, asc, eq, isNull, or, sql } from "drizzle-orm"; import { PERSONAL_PROJECT_ID } from "@bb/domain"; import type { DbConnection, @@ -7,7 +7,7 @@ import type { } from "../connection.js"; import { createThreadFolderId } from "../ids.js"; import type { DbNotifier } from "../notifier.js"; -import { threadFolders } from "../schema.js"; +import { threadFolders, threads } from "../schema.js"; type ThreadFolderWriteConnection = DbConnection | DbTransaction; @@ -18,6 +18,20 @@ export interface CreateThreadFolderInput { projectId?: string | null; } +export interface RenameThreadFolderInput { + path: string; + newPath: string; +} + +export interface DeleteThreadFolderInput { + path: string; +} + +export interface ThreadFolderMutationResult { + path: string; + updatedThreadCount: number; +} + function splitFolderSegments(path: string): string[] { return path .split("/") @@ -41,6 +55,38 @@ function folderAncestors(path: string): string[] { return ancestors; } +function folderPathSubtreeFilter( + column: typeof threadFolders.path | typeof threads.folderPath, + path: string, +) { + return or( + eq(column, path), + sql`substr(${column}, 1, ${path.length + 1}) = ${`${path}/`}`, + ); +} + +function replaceFolderPathPrefix( + value: string, + oldPath: string, + newPath: string, +): string { + if (value === oldPath) { + return newPath; + } + return `${newPath}/${value.slice(oldPath.length + 1)}`; +} + +function notifyThreadFolderMutationProjects( + notifier: DbNotifier, + projectIds: ReadonlySet, +): void { + for (const projectId of projectIds) { + notifier.notifyProject(projectId ?? PERSONAL_PROJECT_ID, [ + "threads-changed", + ]); + } +} + export function getThreadFolderByPath( db: DbQueryConnection, path: string, @@ -136,3 +182,137 @@ export function createThreadFolder( } return folder; } + +export function renameThreadFolder( + db: DbConnection, + notifier: DbNotifier, + input: RenameThreadFolderInput, +): ThreadFolderMutationResult | null { + const path = normalizeThreadFolderPath(input.path); + const newPath = normalizeThreadFolderPath(input.newPath); + if (!path || !newPath) { + return null; + } + if (path === newPath) { + return { path: newPath, updatedThreadCount: 0 }; + } + + return db.transaction( + (tx) => { + const matchingFolders = tx + .select() + .from(threadFolders) + .where(folderPathSubtreeFilter(threadFolders.path, path)) + .all(); + const matchingThreads = tx + .select({ + id: threads.id, + projectId: threads.projectId, + folderPath: threads.folderPath, + }) + .from(threads) + .where(folderPathSubtreeFilter(threads.folderPath, path)) + .all(); + + if (matchingFolders.length === 0 && matchingThreads.length === 0) { + return null; + } + + tx.delete(threadFolders) + .where(folderPathSubtreeFilter(threadFolders.path, path)) + .run(); + + const affectedProjects = new Set(); + for (const folder of matchingFolders) { + affectedProjects.add(folder.projectId); + ensureThreadFolderPath( + tx, + notifier, + replaceFolderPathPrefix(folder.path, path, newPath), + folder.projectId, + ); + } + + const now = Date.now(); + for (const thread of matchingThreads) { + if (!thread.folderPath) { + continue; + } + const nextFolderPath = replaceFolderPathPrefix( + thread.folderPath, + path, + newPath, + ); + affectedProjects.add(thread.projectId); + ensureThreadFolderPath(tx, notifier, nextFolderPath, thread.projectId); + tx.update(threads) + .set({ folderPath: nextFolderPath, updatedAt: now }) + .where(eq(threads.id, thread.id)) + .run(); + notifier.notifyThread(thread.id, ["title-changed"], { + projectId: thread.projectId, + }); + } + + notifyThreadFolderMutationProjects(notifier, affectedProjects); + return { path: newPath, updatedThreadCount: matchingThreads.length }; + }, + { behavior: "immediate" }, + ); +} + +export function deleteThreadFolder( + db: DbConnection, + notifier: DbNotifier, + input: DeleteThreadFolderInput, +): ThreadFolderMutationResult | null { + const path = normalizeThreadFolderPath(input.path); + if (!path) { + return null; + } + + return db.transaction( + (tx) => { + const matchingFolders = tx + .select() + .from(threadFolders) + .where(folderPathSubtreeFilter(threadFolders.path, path)) + .all(); + const matchingThreads = tx + .select({ + id: threads.id, + projectId: threads.projectId, + }) + .from(threads) + .where(folderPathSubtreeFilter(threads.folderPath, path)) + .all(); + + if (matchingFolders.length === 0 && matchingThreads.length === 0) { + return null; + } + + tx.delete(threadFolders) + .where(folderPathSubtreeFilter(threadFolders.path, path)) + .run(); + + const now = Date.now(); + const affectedProjects = new Set( + matchingFolders.map((folder) => folder.projectId), + ); + for (const thread of matchingThreads) { + affectedProjects.add(thread.projectId); + tx.update(threads) + .set({ folderPath: null, updatedAt: now }) + .where(eq(threads.id, thread.id)) + .run(); + notifier.notifyThread(thread.id, ["title-changed"], { + projectId: thread.projectId, + }); + } + + notifyThreadFolderMutationProjects(notifier, affectedProjects); + return { path, updatedThreadCount: matchingThreads.length }; + }, + { behavior: "immediate" }, + ); +} diff --git a/packages/db/test/data/threads.test.ts b/packages/db/test/data/threads.test.ts index 57599c8a0..7db6400c7 100644 --- a/packages/db/test/data/threads.test.ts +++ b/packages/db/test/data/threads.test.ts @@ -30,7 +30,9 @@ import { } from "../../src/data/threads.js"; import { createThreadFolder, + deleteThreadFolder, listThreadFolders, + renameThreadFolder, } from "../../src/data/thread-folders.js"; import { createProject } from "../../src/data/projects.js"; import { upsertHost } from "../../src/data/hosts.js"; @@ -689,6 +691,91 @@ describe("threads", () => { expect(folders).toEqual(expectedFolders); }); + it("renames thread folders and moves descendant threads", () => { + const { db, host, project } = setup(); + const { project: otherProject } = createProject(db, noopNotifier, { + name: "other-project", + source: { type: "local_path", hostId: host.id, path: "/tmp/other" }, + }); + const spy: DbNotifier = { + notifyThread: vi.fn(), + notifyEnvironment: vi.fn(), + notifyHost: vi.fn(), + notifyProject: vi.fn(), + notifySystem: vi.fn(), + }; + const firstThread = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + folderPath: "Work/Q3", + }); + const secondThread = createThread(db, noopNotifier, { + projectId: otherProject.id, + providerId: "codex", + folderPath: "Work", + }); + createThreadFolder(db, noopNotifier, { path: "Work/Empty" }); + + const result = renameThreadFolder(db, spy, { + path: "Work", + newPath: "Archive", + }); + + expect(result).toEqual({ path: "Archive", updatedThreadCount: 2 }); + expect(getThread(db, firstThread.id)?.folderPath).toBe("Archive/Q3"); + expect(getThread(db, secondThread.id)?.folderPath).toBe("Archive"); + expect( + listThreadFolders(db) + .map((folder) => ({ + path: folder.path, + projectId: folder.projectId, + })) + .sort((left, right) => + `${left.projectId ?? ""}:${left.path}`.localeCompare( + `${right.projectId ?? ""}:${right.path}`, + ), + ), + ).toEqual( + [ + { path: "Archive", projectId: null }, + { path: "Archive/Empty", projectId: null }, + { path: "Archive", projectId: project.id }, + { path: "Archive/Q3", projectId: project.id }, + { path: "Archive", projectId: otherProject.id }, + ].sort((left, right) => + `${left.projectId ?? ""}:${left.path}`.localeCompare( + `${right.projectId ?? ""}:${right.path}`, + ), + ), + ); + expect(spy.notifyThread).toHaveBeenCalledWith( + firstThread.id, + ["title-changed"], + { projectId: project.id }, + ); + expect(spy.notifyThread).toHaveBeenCalledWith( + secondThread.id, + ["title-changed"], + { projectId: otherProject.id }, + ); + }); + + it("removes thread folders and clears descendant thread folder paths", () => { + const { db, project } = setup(); + const thread = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + folderPath: "Work/Q3", + }); + createThreadFolder(db, noopNotifier, { path: "Work/Empty" }); + + const result = deleteThreadFolder(db, noopNotifier, { path: "Work" }); + + expect(result).toEqual({ path: "Work", updatedThreadCount: 1 }); + expect(getThread(db, thread.id)?.folderPath).toBeNull(); + expect(listThreadFolders(db)).toEqual([]); + }); + it("notifies when a thread parent changes", () => { const { db, project } = setup(); const spy: DbNotifier = { diff --git a/packages/server-contract/src/api/projects.ts b/packages/server-contract/src/api/projects.ts index 0fabeecd3..876423370 100644 --- a/packages/server-contract/src/api/projects.ts +++ b/packages/server-contract/src/api/projects.ts @@ -75,6 +75,35 @@ export type CreateThreadFolderRequest = z.infer< typeof createThreadFolderRequestSchema >; +export const updateThreadFolderRequestSchema = z + .object({ + path: z.string().min(1), + newPath: z.string().min(1), + }) + .strict(); +export type UpdateThreadFolderRequest = z.infer< + typeof updateThreadFolderRequestSchema +>; + +export const deleteThreadFolderRequestSchema = z + .object({ + path: z.string().min(1), + }) + .strict(); +export type DeleteThreadFolderRequest = z.infer< + typeof deleteThreadFolderRequestSchema +>; + +export const threadFolderMutationResponseSchema = z + .object({ + path: z.string().min(1), + updatedThreadCount: z.number().int().nonnegative(), + }) + .strict(); +export type ThreadFolderMutationResponse = z.infer< + typeof threadFolderMutationResponseSchema +>; + export const reorderProjectRequestSchema = z.object({ previousProjectId: z.string().min(1).nullable(), nextProjectId: z.string().min(1).nullable(), diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 255fd614c..03a93ecd8 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -112,6 +112,7 @@ export const createThreadRequestSchema = z executionInputSources: createExecutionInputSourcesSchema.optional(), environment: environmentArgsSchema, parentThreadId: z.string().min(1).optional(), + folderPath: z.string().min(1).nullable().optional(), sourceThreadId: z.string().min(1).optional(), sourceSeqEnd: z.number().int().nonnegative().optional(), startedOnBehalfOf: startedOnBehalfOfSchema.nullable().default(null), diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 073e96b3e..011e00abd 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -49,6 +49,7 @@ import type { CreateThreadFolderRequest, CreateThreadRequest, CreateThreadTerminalRequest, + DeleteThreadFolderRequest, DeleteThreadRequest, EnvironmentActionApiError, EnvironmentActionRequest, @@ -102,6 +103,7 @@ import type { ThreadComposerBootstrapResponse, ThreadEventWaitQuery, ThreadEventsQuery, + ThreadFolderMutationResponse, ThreadFolderResponse, ThreadFilesRawQuery, ThreadGetQuery, @@ -125,6 +127,7 @@ import type { TimelineTurnSummaryDetailsQuery, TimelineTurnSummaryDetailsResponse, UpdateEnvironmentRequest, + UpdateThreadFolderRequest, UpdateProjectRequest, UpdateProjectSourceRequest, UpdateThreadRequest, @@ -140,6 +143,7 @@ import { updateAutomationRequestSchema, closeThreadTerminalRequestSchema, createThreadFolderRequestSchema, + deleteThreadFolderRequestSchema, createProjectRequestSchema, createProjectSourceRequestSchema, createQueuedMessageRequestSchema, @@ -181,6 +185,7 @@ import { threadTimelineQuerySchema, timelineTurnSummaryDetailsQuerySchema, updateEnvironmentRequestSchema, + updateThreadFolderRequestSchema, updateProjectRequestSchema, updateProjectSourceRequestSchema, updateThreadRequestSchema, @@ -461,6 +466,22 @@ export const publicApiRoutes = { ), response: jsonResponse({ status: 201 }), }), + update: defineRoute({ + path: "/thread-folders", + method: "patch", + request: jsonRequest( + updateThreadFolderRequestSchema, + ), + response: jsonResponse(), + }), + delete: defineRoute({ + path: "/thread-folders", + method: "delete", + request: jsonRequest( + deleteThreadFolderRequestSchema, + ), + response: jsonResponse(), + }), }, threads: { diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 7a0a3ea6d..feb97e880 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -66,6 +66,7 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ reason: "Thread creation may omit root-thread presentation and execution fields so the server can resolve project/provider defaults.", fields: [ + "createThreadRequestSchema.folderPath", "createThreadRequestSchema.model", "createThreadRequestSchema.parentThreadId", "createThreadRequestSchema.providerId", From 206518c08a11b79554b9a49673127c7964a6834c Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 02:54:02 -0700 Subject: [PATCH 22/54] Paint folder drop hover on rows --- apps/app/src/components/sidebar/ProjectRow.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 812445b54..718b06a76 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -1348,7 +1348,8 @@ const FolderTreeItemRow = memo(function FolderTreeItemRow({ style={sortableStyle} className={cn( "space-y-0.5 rounded-md transition-colors", - isDropTargetActive && "bg-sidebar-accent", + isDropTargetActive && + "[&_.bb-sidebar-hover-actions-row]:!bg-sidebar-accent [&_.bb-sidebar-hover-actions-row]:!text-sidebar-accent-foreground", )} > Date: Fri, 19 Jun 2026 02:56:50 -0700 Subject: [PATCH 23/54] Prevent dragging folders into loose threads --- apps/app/src/components/sidebar/ProjectRow.tsx | 8 +++++++- apps/app/src/components/sidebar/sortableMotion.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 718b06a76..c792e58fe 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -779,9 +779,15 @@ const ManualSortableThreadTreeItemRow = memo( ...props }: ThreadTreeItemRowProps) { const itemId = getManualOrderItemKey(props.item); + const sortableDisabled = + !manualSort?.enabled || props.item.kind === "environment" + ? true + : props.item.kind === "folder" + ? { draggable: true } + : false; const { dragBindings, setNodeRef, style } = useSidebarSortable({ id: itemId, - disabled: !manualSort?.enabled || props.item.kind === "environment", + disabled: sortableDisabled, }); if (!manualSort?.enabled || props.item.kind === "environment") { diff --git a/apps/app/src/components/sidebar/sortableMotion.ts b/apps/app/src/components/sidebar/sortableMotion.ts index e1fbca873..dab75c268 100644 --- a/apps/app/src/components/sidebar/sortableMotion.ts +++ b/apps/app/src/components/sidebar/sortableMotion.ts @@ -11,6 +11,11 @@ const SIDEBAR_SORTABLE_TRANSITION = { easing: "cubic-bezier(0.2, 0, 0, 1)", }; +type SortableDisabled = { + draggable?: boolean; + droppable?: boolean; +}; + /** * Drag-handle plumbing shared by every sortable sidebar surface (sections, * projects, pinned roots, manager roots). Spread `attributes`/`listeners` onto @@ -18,7 +23,7 @@ const SIDEBAR_SORTABLE_TRANSITION = { */ export interface SidebarSortableDragBindings { attributes: DraggableAttributes; - disabled: boolean; + disabled: boolean | SortableDisabled; isOver: boolean; listeners: DraggableSyntheticListeners; setActivatorNodeRef: (element: HTMLDivElement | null) => void; @@ -26,7 +31,7 @@ export interface SidebarSortableDragBindings { export interface UseSidebarSortableArgs { id: string; - disabled: boolean; + disabled: boolean | SortableDisabled; } export interface UseSidebarSortableResult { From ba5d2f0640bb4b3bc89819eaac625ae177aed033 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 03:01:25 -0700 Subject: [PATCH 24/54] Restrict folder drag targets to threads --- .../app/src/components/sidebar/ProjectRow.tsx | 59 +++++++++++++++---- .../src/components/sidebar/sortableMotion.ts | 21 ++----- 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index c792e58fe..4fff61b1d 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -239,6 +239,7 @@ interface ThreadTreeItemRowProps { onToggleEnvironmentCollapsed: (environmentId: string) => void; consumeClickSuppression?: ConsumeDragClickSuppression; dragBindings?: SidebarSortableDragBindings; + isDropTargetActive?: boolean; manualSort?: ManualThreadTreeDndState; sortableRef?: (element: HTMLDivElement | null) => void; sortableStyle?: CSSProperties; @@ -259,6 +260,7 @@ interface FolderTreeItemRowProps { onToggleEnvironmentCollapsed: (environmentId: string) => void; consumeClickSuppression?: ConsumeDragClickSuppression; dragBindings?: SidebarSortableDragBindings; + isDropTargetActive?: boolean; manualSort?: ManualThreadTreeDndState; sortableRef?: (element: HTMLDivElement | null) => void; sortableStyle?: CSSProperties; @@ -778,22 +780,33 @@ const ManualSortableThreadTreeItemRow = memo( manualSort, ...props }: ThreadTreeItemRowProps) { + if (!manualSort?.enabled || props.item.kind === "environment") { + return ; + } + + if (props.item.kind === "folder") { + return ( + + ); + } + + return ( + + ); + }, +); + +const ManualDraggableThreadTreeItemRow = memo( + function ManualDraggableThreadTreeItemRow({ + manualSort, + ...props + }: ThreadTreeItemRowProps & { manualSort: ManualThreadTreeDndState }) { const itemId = getManualOrderItemKey(props.item); - const sortableDisabled = - !manualSort?.enabled || props.item.kind === "environment" - ? true - : props.item.kind === "folder" - ? { draggable: true } - : false; const { dragBindings, setNodeRef, style } = useSidebarSortable({ id: itemId, - disabled: sortableDisabled, + disabled: false, }); - if (!manualSort?.enabled || props.item.kind === "environment") { - return ; - } - return ( + ); + }, +); + function useArchiveEnvironmentThreadGroupAction({ environmentId, projectId, @@ -1239,6 +1272,7 @@ export const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ onToggleEnvironmentCollapsed, consumeClickSuppression, dragBindings, + isDropTargetActive, manualSort, sortableRef, sortableStyle, @@ -1260,6 +1294,7 @@ export const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} consumeClickSuppression={consumeClickSuppression} dragBindings={dragBindings} + isDropTargetActive={isDropTargetActive} manualSort={manualSort} sortableRef={sortableRef} sortableStyle={sortableStyle} @@ -1326,6 +1361,7 @@ const FolderTreeItemRow = memo(function FolderTreeItemRow({ onToggleEnvironmentCollapsed, consumeClickSuppression, dragBindings, + isDropTargetActive = false, manualSort, sortableRef, sortableStyle, @@ -1346,7 +1382,6 @@ const FolderTreeItemRow = memo(function FolderTreeItemRow({ const stickyLevel = depthOffset < SIDEBAR_STICKY_PARENT_DEPTH_CAP ? depthOffset : undefined; const folderPath = folder.path.join("/"); - const isDropTargetActive = dragBindings?.isOver === true; return ( void; } export interface UseSidebarSortableArgs { id: string; - disabled: boolean | SortableDisabled; + disabled: boolean; } export interface UseSidebarSortableResult { @@ -52,7 +46,6 @@ export function useSidebarSortable({ const { attributes, isDragging, - isOver, listeners, setActivatorNodeRef, setNodeRef, @@ -75,14 +68,8 @@ export function useSidebarSortable({ [isDragging, transform, transition], ); const dragBindings = useMemo( - () => ({ - attributes, - disabled, - isOver, - listeners, - setActivatorNodeRef, - }), - [attributes, disabled, isOver, listeners, setActivatorNodeRef], + () => ({ attributes, disabled, listeners, setActivatorNodeRef }), + [attributes, disabled, listeners, setActivatorNodeRef], ); return { dragBindings, setNodeRef, style }; From fce12a1e020b579ed9c2b6066be208780aa326c1 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 03:04:01 -0700 Subject: [PATCH 25/54] Polish sidebar display tooltips --- apps/app/src/components/sidebar/ProjectList.tsx | 4 +++- apps/app/src/components/sidebar/ProjectRow.tsx | 4 ---- .../app/src/components/sidebar/SidebarChildToggleChevron.tsx | 5 ----- apps/app/src/components/sidebar/SidebarFolderRow.tsx | 2 -- apps/app/src/components/sidebar/ThreadRow.tsx | 2 -- 5 files changed, 3 insertions(+), 14 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 877aead3a..2a7d74d50 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -588,7 +588,9 @@ export function SidebarViewOptionsMenu({ - Sidebar display options + + Display + onToggleCollapsed(environmentId)} revealOnHover /> @@ -2002,8 +2000,6 @@ function ProjectRowComponent({ isCollapsed={isCollapsed} expandLabel={`Expand ${project.name}`} collapseLabel={`Collapse ${project.name}`} - expandTitle="Expand project threads" - collapseTitle="Collapse project threads" onToggle={handleProjectRowToggle} revealOnHover /> diff --git a/apps/app/src/components/sidebar/SidebarChildToggleChevron.tsx b/apps/app/src/components/sidebar/SidebarChildToggleChevron.tsx index 55b7a1799..e375723d1 100644 --- a/apps/app/src/components/sidebar/SidebarChildToggleChevron.tsx +++ b/apps/app/src/components/sidebar/SidebarChildToggleChevron.tsx @@ -8,8 +8,6 @@ export interface SidebarChildToggleChevronProps { isCollapsed: boolean; expandLabel: string; collapseLabel: string; - expandTitle: string; - collapseTitle: string; onToggle: SidebarChildToggleHandler; revealOnHover?: boolean; } @@ -18,8 +16,6 @@ export function SidebarChildToggleChevron({ isCollapsed, expandLabel, collapseLabel, - expandTitle, - collapseTitle, onToggle, revealOnHover = false, }: SidebarChildToggleChevronProps) { @@ -28,7 +24,6 @@ export function SidebarChildToggleChevron({ type="button" aria-expanded={!isCollapsed} aria-label={isCollapsed ? expandLabel : collapseLabel} - title={isCollapsed ? expandTitle : collapseTitle} onClick={(event) => { event.preventDefault(); event.stopPropagation(); diff --git a/apps/app/src/components/sidebar/SidebarFolderRow.tsx b/apps/app/src/components/sidebar/SidebarFolderRow.tsx index 833e8a344..9dcf42fd0 100644 --- a/apps/app/src/components/sidebar/SidebarFolderRow.tsx +++ b/apps/app/src/components/sidebar/SidebarFolderRow.tsx @@ -150,8 +150,6 @@ function SidebarFolderRowComponent({ isCollapsed={isCollapsed} expandLabel={`Expand ${pathLabel} folder`} collapseLabel={`Collapse ${pathLabel} folder`} - expandTitle="Expand folder" - collapseTitle="Collapse folder" onToggle={onToggleCollapsed} /> diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index fa2ddf667..b2e931879 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -349,8 +349,6 @@ function ThreadRowComponent({ isCollapsed={isParentCollapsed} expandLabel={`Expand ${threadTitle} threads`} collapseLabel={`Collapse ${threadTitle} threads`} - expandTitle="Expand child threads" - collapseTitle="Collapse child threads" onToggle={() => parentOptions.onToggleCollapsed(thread.id)} revealOnHover /> From 36609c8f5c4f48cc6b7e014a655c06addcdc8e01 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 03:09:23 -0700 Subject: [PATCH 26/54] Make sidebar thread dragging feel lighter --- apps/app/src/components/sidebar/ThreadRow.tsx | 67 +++++++++++++++---- .../src/components/sidebar/sortableMotion.ts | 3 +- .../sidebar/useSidebarReorderDnd.ts | 12 ++++ 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index b2e931879..e5917621b 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -9,6 +9,7 @@ import { import { useSetAtom } from "jotai"; import type { ThreadListEntry } from "@bb/domain"; import { getThreadConversationCollapsedAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms"; +import { Button } from "@/components/ui/button.js"; import { Icon } from "@/components/ui/icon.js"; import { SidebarStickyTier } from "@/components/ui/sidebar.js"; import { NavLink } from "react-router-dom"; @@ -95,12 +96,16 @@ type ThreadRowClickCaptureHandler = MouseEventHandler; interface ThreadRowContainerArgs { children: ReactNode; className: string; - dragBindings?: SidebarSortableDragBindings; onClickCapture?: ThreadRowClickCaptureHandler; stickyLevel?: number; style: CSSProperties; } +interface ThreadDragHandleProps { + dragBindings: SidebarSortableDragBindings; + label: string; +} + function ThreadDraftIndicator() { return ( {children} @@ -143,19 +144,51 @@ function renderThreadRowContainer({ } return ( -
+
{children}
); } +function ThreadDragHandle({ dragBindings, label }: ThreadDragHandleProps) { + const handleClick = useCallback>( + (event) => { + event.preventDefault(); + event.stopPropagation(); + }, + [], + ); + + return ( + + ); +} + interface ThreadStatusGlyphProps { hasPendingInteraction: boolean; isBusy: boolean; @@ -302,6 +335,7 @@ function ThreadRowComponent({ : `Open ${labelTitle}`; const linkTitle = linkLabel; const rowDragBindings = options.dragBindings; + const showDragHandle = Boolean(rowDragBindings && !rowDragBindings.disabled); const rowClassName = cn( SIDEBAR_HOVER_ACTIONS_ROW_CLASS, "group/thread-row", @@ -342,6 +376,12 @@ function ThreadRowComponent({ title={linkTitle} className="absolute inset-0 rounded-md outline-none ring-sidebar-ring focus-visible:ring-2" /> + {showDragHandle && rowDragBindings ? ( + + ) : null} {visibleTitle} {parentOptions && hasChildren ? ( @@ -405,7 +445,6 @@ function ThreadRowComponent({ const row = renderThreadRowContainer({ children: rowContent, className: rowClassName, - dragBindings: rowDragBindings, onClickCapture: options.consumeClickSuppression ? handleRowClickCapture : undefined, diff --git a/apps/app/src/components/sidebar/sortableMotion.ts b/apps/app/src/components/sidebar/sortableMotion.ts index 5b2e9a739..a1afd1db0 100644 --- a/apps/app/src/components/sidebar/sortableMotion.ts +++ b/apps/app/src/components/sidebar/sortableMotion.ts @@ -20,7 +20,7 @@ export interface SidebarSortableDragBindings { attributes: DraggableAttributes; disabled: boolean; listeners: DraggableSyntheticListeners; - setActivatorNodeRef: (element: HTMLDivElement | null) => void; + setActivatorNodeRef: (element: HTMLElement | null) => void; } export interface UseSidebarSortableArgs { @@ -64,6 +64,7 @@ export function useSidebarSortable({ // unless we lift it above them while dragging. position: isDragging ? "relative" : undefined, zIndex: isDragging ? 20 : undefined, + opacity: isDragging ? 0.8 : undefined, }), [isDragging, transform, transition], ); diff --git a/apps/app/src/components/sidebar/useSidebarReorderDnd.ts b/apps/app/src/components/sidebar/useSidebarReorderDnd.ts index 0d41ba925..4ec9a2d91 100644 --- a/apps/app/src/components/sidebar/useSidebarReorderDnd.ts +++ b/apps/app/src/components/sidebar/useSidebarReorderDnd.ts @@ -11,6 +11,7 @@ import { type DndContextProps, type DragEndEvent, type DragStartEvent, + type Modifier, } from "@dnd-kit/core"; import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { @@ -31,6 +32,15 @@ const sidebarReorderCollisionDetection: CollisionDetection = (args) => { return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args); }; +const restrictSidebarDragToVerticalAxis: Modifier = ({ transform }) => ({ + ...transform, + x: 0, +}); + +const SIDEBAR_REORDER_MODIFIERS: Modifier[] = [ + restrictSidebarDragToVerticalAxis, +]; + export interface UseSidebarReorderDndArgs { /** * Performs the reorder once a drag settles. The hook clears the drag-click @@ -46,6 +56,7 @@ export type SidebarReorderDndContextProps = Pick< | "onDragStart" | "onDragCancel" | "onDragEnd" + | "modifiers" >; export interface UseSidebarReorderDndResult { @@ -113,6 +124,7 @@ export function useSidebarReorderDnd({ () => ({ sensors, collisionDetection: sidebarReorderCollisionDetection, + modifiers: SIDEBAR_REORDER_MODIFIERS, onDragStart: handleDragStart, onDragCancel: handleDragCancel, onDragEnd: handleDragEnd, From 80c18bafeaf58db613f8fa8233c24684a6a23674 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 03:13:30 -0700 Subject: [PATCH 27/54] Restore row-level thread folder dragging --- apps/app/src/components/sidebar/ThreadRow.tsx | 67 ++++--------------- 1 file changed, 14 insertions(+), 53 deletions(-) diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index e5917621b..b2e931879 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -9,7 +9,6 @@ import { import { useSetAtom } from "jotai"; import type { ThreadListEntry } from "@bb/domain"; import { getThreadConversationCollapsedAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms"; -import { Button } from "@/components/ui/button.js"; import { Icon } from "@/components/ui/icon.js"; import { SidebarStickyTier } from "@/components/ui/sidebar.js"; import { NavLink } from "react-router-dom"; @@ -96,16 +95,12 @@ type ThreadRowClickCaptureHandler = MouseEventHandler; interface ThreadRowContainerArgs { children: ReactNode; className: string; + dragBindings?: SidebarSortableDragBindings; onClickCapture?: ThreadRowClickCaptureHandler; stickyLevel?: number; style: CSSProperties; } -interface ThreadDragHandleProps { - dragBindings: SidebarSortableDragBindings; - label: string; -} - function ThreadDraftIndicator() { return ( {children} @@ -144,51 +143,19 @@ function renderThreadRowContainer({ } return ( -
+
{children}
); } -function ThreadDragHandle({ dragBindings, label }: ThreadDragHandleProps) { - const handleClick = useCallback>( - (event) => { - event.preventDefault(); - event.stopPropagation(); - }, - [], - ); - - return ( - - ); -} - interface ThreadStatusGlyphProps { hasPendingInteraction: boolean; isBusy: boolean; @@ -335,7 +302,6 @@ function ThreadRowComponent({ : `Open ${labelTitle}`; const linkTitle = linkLabel; const rowDragBindings = options.dragBindings; - const showDragHandle = Boolean(rowDragBindings && !rowDragBindings.disabled); const rowClassName = cn( SIDEBAR_HOVER_ACTIONS_ROW_CLASS, "group/thread-row", @@ -376,12 +342,6 @@ function ThreadRowComponent({ title={linkTitle} className="absolute inset-0 rounded-md outline-none ring-sidebar-ring focus-visible:ring-2" /> - {showDragHandle && rowDragBindings ? ( - - ) : null} {visibleTitle} {parentOptions && hasChildren ? ( @@ -445,6 +405,7 @@ function ThreadRowComponent({ const row = renderThreadRowContainer({ children: rowContent, className: rowClassName, + dragBindings: rowDragBindings, onClickCapture: options.consumeClickSuppression ? handleRowClickCapture : undefined, From 5f6839e097a0c15921cd3f4151e32d8786fda254 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 03:16:47 -0700 Subject: [PATCH 28/54] Use icon button for new thread action --- apps/app/src/components/sidebar/ProjectList.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 2a7d74d50..be33815d2 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -962,17 +962,18 @@ export function ProjectListActionButtons({
{threadSearch ? ( + + + + {tooltip} + + + ); +} + +function SidebarSortDirectionButton({ + active, + ariaLabel, + direction, + onClick, +}: { + active: boolean; + ariaLabel: string; + direction: SidebarSortDirection; + onClick: () => void; +}) { + return ( + + ); +} + +function SidebarSortMenuOption({ + direction, + label, + selected, + sort, + onDirectionSelect, + onSortSelect, +}: SidebarSortMenuOptionProps) { + return ( + { + event.preventDefault(); + onSortSelect(sort); + }} + className="flex items-center gap-2" + > + {label} + + + onDirectionSelect(sort, "asc")} + /> + onDirectionSelect(sort, "desc")} + /> + + + ); +} + +// Shared organization menu rendered on both the Projects and Threads section // headers. The organization mode is global, so either header's menu drives the // whole sidebar. -export function SidebarViewOptionsMenu({ +export function SidebarOrganizeOptionsMenu({ open, onOpenChange, onOrganizationModeSelect, -}: SidebarViewOptionsMenuProps) { +}: SidebarOrganizeOptionsMenuProps) { const [organizationMode, setOrganizationMode] = useAtom( sidebarOrganizationModeAtom, ); - const [chronologicalSort, setChronologicalSort] = useAtom( - sidebarChronologicalSortAtom, - ); return ( - - - - - - - - Display - - + Organize by @@ -618,25 +825,69 @@ export function SidebarViewOptionsMenu({ onOrganizationModeSelect?.("chronological"); }} /> - - + + + ); +} + +export function SidebarSortOptionsMenu({ + open, + onOpenChange, +}: SidebarSortOptionsMenuProps) { + const [chronologicalSort, setChronologicalSort] = useAtom( + sidebarChronologicalSortAtom, + ); + const [sortDirection, setSortDirection] = useAtom(sidebarSortDirectionAtom); + const selectedSort: SidebarChronologicalSort = + chronologicalSort === "none" ? "updated" : chronologicalSort; + const handleSortSelect = useCallback( + (sort: SidebarChronologicalSort) => { + setChronologicalSort(sort); + }, + [setChronologicalSort], + ); + const handleDirectionSelect = useCallback( + (sort: SidebarChronologicalSort, direction: SidebarSortDirection) => { + setChronologicalSort(sort); + setSortDirection(direction); + }, + [setChronologicalSort, setSortDirection], + ); + + return ( + + + + Sort by - { - event.preventDefault(); - setChronologicalSort("updated"); - }} + sort="updated" + selected={selectedSort === "updated"} + direction={sortDirection} + onSortSelect={handleSortSelect} + onDirectionSelect={handleDirectionSelect} /> - { - event.preventDefault(); - setChronologicalSort("created"); - }} + sort="created" + selected={selectedSort === "created"} + direction={sortDirection} + onSortSelect={handleSortSelect} + onDirectionSelect={handleDirectionSelect} + /> + @@ -1263,18 +1514,18 @@ function ProjectListComponent({ const [sidebarSectionOrderList, setSidebarSectionOrderList] = useAtom( sidebarSectionOrderAtom, ); - const [isProjectsViewOptionsMenuOpen, setIsProjectsViewOptionsMenuOpen] = - useState(false); + const [projectsDisplayOptionsMenuOpen, setProjectsDisplayOptionsMenuOpen] = + useState(null); const [isThreadsActionsMenuOpen, setIsThreadsActionsMenuOpen] = useState(false); - const [isThreadsViewOptionsMenuOpen, setIsThreadsViewOptionsMenuOpen] = - useState(false); - const handleProjectsViewOptionsMenuOpenChange = useCallback( - (open: boolean) => { - setIsProjectsViewOptionsMenuOpen(open); + const [threadsDisplayOptionsMenuOpen, setThreadsDisplayOptionsMenuOpen] = + useState(null); + const handleProjectsDisplayOptionsMenuOpenChange = useCallback( + (menu: SidebarDisplayOptionsMenuKind, open: boolean) => { + setProjectsDisplayOptionsMenuOpen(open ? menu : null); if (open) { setIsThreadsActionsMenuOpen(false); - setIsThreadsViewOptionsMenuOpen(false); + setThreadsDisplayOptionsMenuOpen(null); } }, [], @@ -1282,15 +1533,15 @@ function ProjectListComponent({ const handleThreadsActionsMenuOpenChange = useCallback((open: boolean) => { setIsThreadsActionsMenuOpen(open); if (open) { - setIsProjectsViewOptionsMenuOpen(false); - setIsThreadsViewOptionsMenuOpen(false); + setProjectsDisplayOptionsMenuOpen(null); + setThreadsDisplayOptionsMenuOpen(null); } }, []); - const handleThreadsViewOptionsMenuOpenChange = useCallback( - (open: boolean) => { - setIsThreadsViewOptionsMenuOpen(open); + const handleThreadsDisplayOptionsMenuOpenChange = useCallback( + (menu: SidebarDisplayOptionsMenuKind, open: boolean) => { + setThreadsDisplayOptionsMenuOpen(open ? menu : null); if (open) { - setIsProjectsViewOptionsMenuOpen(false); + setProjectsDisplayOptionsMenuOpen(null); setIsThreadsActionsMenuOpen(false); } }, @@ -1299,9 +1550,9 @@ function ProjectListComponent({ const handleProjectsViewOptionsOrganizationModeSelect = useCallback( (mode: SidebarOrganizationMode) => { if (mode === "chronological") { - setIsProjectsViewOptionsMenuOpen(false); + setProjectsDisplayOptionsMenuOpen(null); setIsThreadsActionsMenuOpen(false); - setIsThreadsViewOptionsMenuOpen(true); + setThreadsDisplayOptionsMenuOpen("organize"); } }, [], @@ -1310,14 +1561,16 @@ function ProjectListComponent({ const [chronologicalSort, setChronologicalSort] = useAtom( sidebarChronologicalSortAtom, ); + const [sortDirection] = useAtom(sidebarSortDirectionAtom); const isFolderOrganizationMode = organizationMode === "chronological"; const setCollapsedFolderList = useSetAtom(sidebarCollapsedFoldersAtom); const sidebarThreadComparator = useMemo( () => - chronologicalSort === "created" - ? compareByCreatedAtDescending - : compareStandardThreads, - [chronologicalSort], + getSidebarThreadComparator({ + direction: sortDirection, + sort: chronologicalSort, + }), + [chronologicalSort, sortDirection], ); const collapsedProjectIds = useMemo( () => new Set(collapsedProjectIdList), @@ -1686,13 +1939,21 @@ function ProjectListComponent({ ); const projectsSectionActions = ( <> - + handleProjectsDisplayOptionsMenuOpenChange("organize", open) + } onOrganizationModeSelect={ handleProjectsViewOptionsOrganizationModeSelect } /> + + handleProjectsDisplayOptionsMenuOpenChange("sort", open) + } + /> {onNewProject ? ( - + handleThreadsDisplayOptionsMenuOpenChange("organize", open) + } + /> + + handleThreadsDisplayOptionsMenuOpenChange("sort", open) + } /> - + handleThreadsDisplayOptionsMenuOpenChange("organize", open) + } + /> + + handleThreadsDisplayOptionsMenuOpenChange("sort", open) + } /> {content} @@ -1875,7 +2152,7 @@ function ProjectListComponent({ label="Projects" disabled={visibleSidebarSectionOrder.length < 2} actions={projectsSectionActions} - actionsOpen={isProjectsViewOptionsMenuOpen} + actionsOpen={projectsDisplayOptionsMenuOpen !== null} actionsAlwaysVisible={projectsSectionActionsAlwaysVisible} collapseControl={{ isCollapsed: collapsedSidebarSectionIds.has("projects"), @@ -1896,7 +2173,8 @@ function ProjectListComponent({ disabled={visibleSidebarSectionOrder.length < 2} actions={threadsSectionActions} actionsOpen={ - isThreadsActionsMenuOpen || isThreadsViewOptionsMenuOpen + isThreadsActionsMenuOpen || + threadsDisplayOptionsMenuOpen !== null } actionsMobileAlways collapseControl={{ diff --git a/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx b/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx index 0aa81ff8a..0d1b82f2d 100644 --- a/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx @@ -1,10 +1,15 @@ import { useMemo } from "react"; import { createStore, Provider as JotaiProvider } from "jotai"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; -import { SidebarViewOptionsMenu } from "./ProjectList"; +import { + SidebarOrganizeOptionsMenu, + SidebarSortOptionsMenu, +} from "./ProjectList"; import { sidebarChronologicalSortAtom, sidebarOrganizationModeAtom, + sidebarSortDirectionAtom, + type SidebarSortDirection, type SidebarOrganizationMode, } from "./sidebarCollapsedAtoms"; @@ -13,23 +18,27 @@ export default { }; function MenuStory({ + direction, organizationMode, sort, }: { + direction: SidebarSortDirection; organizationMode: SidebarOrganizationMode; - sort: "updated" | "created"; + sort: "updated" | "created" | "alpha"; }) { const store = useMemo(() => { const next = createStore(); next.set(sidebarChronologicalSortAtom, sort); + next.set(sidebarSortDirectionAtom, direction); next.set(sidebarOrganizationModeAtom, organizationMode); return next; - }, [organizationMode, sort]); + }, [direction, organizationMode, sort]); return ( -
- +
+ +
); @@ -39,10 +48,14 @@ export function Overview() { return ( - + - + ); diff --git a/apps/app/src/components/sidebar/projectThreadGroups.ts b/apps/app/src/components/sidebar/projectThreadGroups.ts index 284a19a2c..0542cef82 100644 --- a/apps/app/src/components/sidebar/projectThreadGroups.ts +++ b/apps/app/src/components/sidebar/projectThreadGroups.ts @@ -79,10 +79,17 @@ export const PINNED_CONTAINER_ID = "pinned"; // Orders sibling threads. The default keeps active rows pinned to createdAt and // inactive rows on attention recency; chronological mode can swap in a literal // createdAt comparator instead. -export type ThreadComparator = ( +export type ThreadItemComparator = ( + left: ProjectThreadItem, + right: ProjectThreadItem, +) => number; + +export type ThreadComparator = (( left: ThreadListEntry, right: ThreadListEntry, -) => number; +) => number) & { + compareItems?: ThreadItemComparator; +}; type WorktreeDisplayKind = "managed-worktree" | "unmanaged-worktree"; type SidebarProjectThreadShape = Pick< @@ -523,8 +530,8 @@ function getItemOrderingThread( if (descendants.length === 0) { return null; } - return descendants.reduce( - (first, thread) => (compareThreads(thread, first) < 0 ? thread : first), + return descendants.reduce((first, thread) => + compareThreads(thread, first) < 0 ? thread : first, ); } } @@ -580,9 +587,7 @@ function orderItemsByManualOrder( const orderedKeys = new Set(prunedOrder); const unorderedItems = items .filter((item) => !orderedKeys.has(getManualOrderItemKey(item))) - .sort((left, right) => - compareSiblingItems(left, right, compareThreads), - ); + .sort((left, right) => compareSiblingItems(left, right, compareThreads)); const orderedItems = prunedOrder.flatMap((key) => { const item = itemsByKey.get(key); return item ? [item] : []; @@ -638,6 +643,10 @@ function compareSiblingItems( right: ProjectThreadItem, compareThreads: ThreadComparator, ): number { + if (compareThreads.compareItems) { + return compareThreads.compareItems(left, right); + } + const leftThread = getItemOrderingThread(left, compareThreads); const rightThread = getItemOrderingThread(right, compareThreads); if (leftThread && rightThread) { diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index 05cebc330..892154c0e 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -8,6 +8,7 @@ const COLLAPSED_SIDEBAR_SECTIONS_STORAGE_KEY = "bb.sidebar.collapsedSections"; const SIDEBAR_SECTION_ORDER_STORAGE_KEY = "bb.sidebar.sectionOrder"; const ORGANIZATION_MODE_STORAGE_KEY = "bb.sidebar.organizationMode"; const CHRONOLOGICAL_SORT_STORAGE_KEY = "bb.sidebar.chronologicalSort"; +const SORT_DIRECTION_STORAGE_KEY = "bb.sidebar.sortDirection"; const GROUP_BY_STORAGE_KEY = "bb.sidebar.groupBy"; const COLLAPSED_FOLDERS_STORAGE_KEY = "bb.sidebar.collapsedFolders"; const MANUAL_ORDER_STORAGE_KEY = "bb.sidebar.manualOrder"; @@ -20,9 +21,10 @@ export type CollapsibleSidebarSectionId = "projects" | "threads"; export type SidebarOrganizationMode = "project" | "chronological"; // Controls thread ordering in both grouped and ungrouped sidebar views. // "updated" reuses the status-aware activity heuristic; "created" sorts by -// the literal createdAt field. "none" is a legacy/internal value that the -// runtime normalizes back to "updated". -export type SidebarChronologicalSort = "updated" | "created" | "none"; +// the literal createdAt field; "alpha" sorts by display title. "none" is a +// legacy/internal value that the runtime normalizes back to "updated". +export type SidebarChronologicalSort = "updated" | "created" | "alpha" | "none"; +export type SidebarSortDirection = "asc" | "desc"; // Low-level folder grouping switch used by folder helpers and tests. The app // runtime always renders stored folderPath metadata; "none" remains for // regression coverage. @@ -90,6 +92,13 @@ export const sidebarChronologicalSortAtom = { getOnInit: true }, ); +export const sidebarSortDirectionAtom = atomWithStorage( + SORT_DIRECTION_STORAGE_KEY, + "desc", + createJsonLocalStorage(), + { getOnInit: true }, +); + // Story/test control for the low-level folder grouping path. Runtime sidebar // trees enable "folder" only in the Folders organization mode. export const sidebarGroupByAtom = atomWithStorage( From b65d070851537cb5f7c34352ec2a060f65829b55 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 14:02:26 -0700 Subject: [PATCH 30/54] Refine sidebar organize and sort menus - Sort options use a single state-driven arrow (none / down=desc / up=asc) instead of a check plus dual direction buttons; re-selecting the active field flips its direction. - Organize trigger uses the Layers icon; Sort trigger uses up/down arrows. - Align the menu section labels to the standard DropdownMenuLabel hierarchy used by the rest of the app's dropdowns. - Make the View options menu story interactive and add TooltipProvider to the Ladle harness so tooltip-using stories render instead of crashing blank. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/app/.ladle/components.tsx | 11 +- .../src/components/sidebar/ProjectList.tsx | 133 ++++-------------- .../SidebarViewOptionsMenu.stories.tsx | 75 ++++++---- apps/app/src/components/ui/icon.tsx | 4 + 4 files changed, 85 insertions(+), 138 deletions(-) diff --git a/apps/app/.ladle/components.tsx b/apps/app/.ladle/components.tsx index a1bcbe3cb..b295452ea 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/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index e4fce1ac7..710bf13c9 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -601,27 +601,6 @@ function ProjectListThreadsSectionActions({ ); } -interface SidebarOrganizeMenuSectionLabelProps { - children: ReactNode; - className?: string; -} - -function SidebarOrganizeMenuSectionLabel({ - children, - className, -}: SidebarOrganizeMenuSectionLabelProps) { - return ( - - {children} - - ); -} - interface SidebarOrganizeMenuOptionProps { disabled?: boolean; label: string; @@ -658,11 +637,9 @@ interface SidebarSortMenuOptionProps { label: string; selected: boolean; sort: SidebarChronologicalSort; - onDirectionSelect: ( - sort: SidebarChronologicalSort, - direction: SidebarSortDirection, - ) => void; - onSortSelect: (sort: SidebarChronologicalSort) => void; + // Selecting an inactive field activates it descending; selecting the active + // field flips its direction. + onToggle: (sort: SidebarChronologicalSort) => void; } function SidebarDisplayMenuTrigger({ @@ -701,81 +678,32 @@ function SidebarDisplayMenuTrigger({ ); } -function SidebarSortDirectionButton({ - active, - ariaLabel, - direction, - onClick, -}: { - active: boolean; - ariaLabel: string; - direction: SidebarSortDirection; - onClick: () => void; -}) { - return ( - - ); -} - function SidebarSortMenuOption({ direction, label, selected, sort, - onDirectionSelect, - onSortSelect, + onToggle, }: SidebarSortMenuOptionProps) { return ( { event.preventDefault(); - onSortSelect(sort); + onToggle(sort); }} - className="flex items-center gap-2" + className="flex items-center justify-between gap-3" > - {label} + {label} + {/* No sort field shows no glyph; the active field shows a single arrow + that points down for descending and up for ascending. */} ); } @@ -796,7 +724,7 @@ export function SidebarOrganizeOptionsMenu({ - - Organize by - + Organize by { + if (selectedSort === sort) { + // Re-selecting the active field flips its direction. + setSortDirection((current) => (current === "desc" ? "asc" : "desc")); + return; + } + // A newly selected field starts descending. setChronologicalSort(sort); + setSortDirection("desc"); }, - [setChronologicalSort], - ); - const handleDirectionSelect = useCallback( - (sort: SidebarChronologicalSort, direction: SidebarSortDirection) => { - setChronologicalSort(sort); - setSortDirection(direction); - }, - [setChronologicalSort, setSortDirection], + [selectedSort, setChronologicalSort, setSortDirection], ); return ( - - Sort by - + Sort by diff --git a/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx b/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx index 0d1b82f2d..335a56c62 100644 --- a/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarViewOptionsMenu.stories.tsx @@ -1,5 +1,9 @@ import { useMemo } from "react"; -import { createStore, Provider as JotaiProvider } from "jotai"; +import { + createStore, + Provider as JotaiProvider, + useAtomValue, +} from "jotai"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { SidebarOrganizeOptionsMenu, @@ -9,36 +13,55 @@ import { sidebarChronologicalSortAtom, sidebarOrganizationModeAtom, sidebarSortDirectionAtom, - type SidebarSortDirection, - type SidebarOrganizationMode, } from "./sidebarCollapsedAtoms"; export default { title: "sidebar/View options menu", }; -function MenuStory({ - direction, - organizationMode, - sort, -}: { - direction: SidebarSortDirection; - organizationMode: SidebarOrganizationMode; - sort: "updated" | "created" | "alpha"; -}) { +// Live readout of the atoms the menus drive, so the effect of each click is +// visible even after a menu closes. +function StateReadout() { + const organizationMode = useAtomValue(sidebarOrganizationModeAtom); + const sort = useAtomValue(sidebarChronologicalSortAtom); + const direction = useAtomValue(sidebarSortDirectionAtom); + return ( +
+
organize
+
{organizationMode}
+
sort
+
{sort}
+
direction
+
{direction}
+
+ ); +} + +// The menus write to global (atomWithStorage) atoms. A story-local Jotai store +// keeps each mount self-contained and seeded with the same defaults the app +// ships, instead of inheriting whatever the last Ladle session left behind. +function InteractiveMenus() { const store = useMemo(() => { const next = createStore(); - next.set(sidebarChronologicalSortAtom, sort); - next.set(sidebarSortDirectionAtom, direction); - next.set(sidebarOrganizationModeAtom, organizationMode); + next.set(sidebarOrganizationModeAtom, "project"); + next.set(sidebarChronologicalSortAtom, "updated"); + next.set(sidebarSortDirectionAtom, "desc"); return next; - }, [direction, organizationMode, sort]); + }, []); return ( -
- - +
+
+ + Threads + +
+ + +
+
+
); @@ -47,15 +70,11 @@ function MenuStory({ export function Overview() { return ( - - - - - + + ); diff --git a/apps/app/src/components/ui/icon.tsx b/apps/app/src/components/ui/icon.tsx index 14ecfa0e7..28d95ed37 100644 --- a/apps/app/src/components/ui/icon.tsx +++ b/apps/app/src/components/ui/icon.tsx @@ -16,6 +16,7 @@ import { ArrowUp01Icon, ArrowUp02Icon, ArrowUpDoubleIcon, + ArrowUpDownIcon, ArrowTurnBackwardIcon, ArrowTurnForwardIcon, ArrowUpRight01Icon, @@ -61,6 +62,7 @@ import { InformationCircleIcon, InternetIcon, LaptopIcon, + Layers01Icon, LockIcon, LayoutTwoColumnIcon, LayoutTwoRowIcon, @@ -108,6 +110,7 @@ const ICON_MAP = { ArrowDown: ArrowDown02Icon, ArrowRight: ArrowRight02Icon, ArrowUp: ArrowUp02Icon, + ArrowUpDown: ArrowUpDownIcon, ArrowTurnBackward: ArrowTurnBackwardIcon, ArrowTurnForward: ArrowTurnForwardIcon, ArrowUpRight: ArrowUpRight01Icon, @@ -159,6 +162,7 @@ const ICON_MAP = { GridView: GridViewIcon, Info: InformationCircleIcon, Laptop: LaptopIcon, + Layers: Layers01Icon, ListTodo: CheckListIcon, Lock: LockIcon, Maximize2: ArrowExpand01Icon, From 5ae181afa4b04fe8e95dc567d31a26af5a068493 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 14:13:29 -0700 Subject: [PATCH 31/54] Drop "folder" from project path-missing tooltip Reads "Open project settings to fix". Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/app/src/components/sidebar/ProjectRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 225c3cd89..46c472b75 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -2026,7 +2026,7 @@ function ProjectRowComponent({ - Open project settings to fix folder + Open project settings to fix ) : null} From 6b262c40b08b62a65c5fe2891db954fc46b13a8a Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 15:09:11 -0700 Subject: [PATCH 32/54] Align folder row actions with project rows; compact sidebar menus - Folder rows now expose a dedicated New thread (+) button alongside a "..." menu (Rename, Remove), mirroring the project row's action cluster instead of stuffing New thread into the menu. - Size the sidebar/project/thread action and view-options dropdowns to their content (drop fixed w-44/w-52/w-56; the shared min-w-[8rem] floor remains) so menus wrap their contents. - Clean up the project path-missing affordance: tooltip reads "Open project settings" and the icon's accessible name is "Project path not found". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/project/ProjectActionsMenu.tsx | 7 +- .../src/components/sidebar/ProjectList.tsx | 4 +- .../app/src/components/sidebar/ProjectRow.tsx | 6 +- .../components/sidebar/SidebarFolderRow.tsx | 102 +++++++++++------- .../components/thread/ThreadActionsMenu.tsx | 2 +- 5 files changed, 70 insertions(+), 51 deletions(-) diff --git a/apps/app/src/components/project/ProjectActionsMenu.tsx b/apps/app/src/components/project/ProjectActionsMenu.tsx index 332b208f2..a9f91d8d8 100644 --- a/apps/app/src/components/project/ProjectActionsMenu.tsx +++ b/apps/app/src/components/project/ProjectActionsMenu.tsx @@ -201,7 +201,7 @@ export function ProjectActionsMenu({ Project actions - + @@ -216,10 +216,7 @@ export function ProjectActionsContextMenu({ return ( {children} - + diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 710bf13c9..991166e21 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -729,7 +729,6 @@ export function SidebarOrganizeOptionsMenu({ /> Organize by @@ -787,7 +786,7 @@ export function SidebarSortOptionsMenu({ iconName="ArrowUpDown" tooltip="Sort" /> - + Sort by diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 46c472b75..7cf33adb5 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -975,7 +975,7 @@ function EnvironmentThreadGroupHeaderActions({ /> - + {onCreateNewThread ? ( New thread @@ -2013,7 +2013,7 @@ function ProjectRowComponent({ event.stopPropagation(); onProjectSelect?.(); }} - aria-label="Project folder not found" + aria-label="Project path not found" className={cn( "relative z-10 inline-flex shrink-0 items-center justify-center rounded-md text-destructive outline-none ring-sidebar-ring transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-2", COARSE_POINTER_ROW_ACTION_SIZE_CLASS, @@ -2026,7 +2026,7 @@ function ProjectRowComponent({ - Open project settings to fix + Open project settings ) : null} diff --git a/apps/app/src/components/sidebar/SidebarFolderRow.tsx b/apps/app/src/components/sidebar/SidebarFolderRow.tsx index 9dcf42fd0..f4c370bf2 100644 --- a/apps/app/src/components/sidebar/SidebarFolderRow.tsx +++ b/apps/app/src/components/sidebar/SidebarFolderRow.tsx @@ -188,49 +188,73 @@ function SidebarFolderRowComponent({ )} onClick={stopActionsClick} > - + {onRename || onRemove ? ( + + + + + + + + Folder actions + + + {onRename ? ( + Rename + ) : null} + {onRemove ? ( + + Remove + + ) : null} + + + ) : null} + {onCreateThread ? ( - - - + - Folder actions + New thread - - {onCreateThread ? ( - - New thread - - ) : null} - {onRename ? ( - Rename - ) : null} - {onRemove ? ( - - Remove - - ) : null} - - + ) : null} ) : null} diff --git a/apps/app/src/components/thread/ThreadActionsMenu.tsx b/apps/app/src/components/thread/ThreadActionsMenu.tsx index afe3fdbc8..94352c28e 100644 --- a/apps/app/src/components/thread/ThreadActionsMenu.tsx +++ b/apps/app/src/components/thread/ThreadActionsMenu.tsx @@ -190,7 +190,7 @@ export function ThreadActionsMenu({ /> - + Date: Fri, 19 Jun 2026 15:38:38 -0700 Subject: [PATCH 33/54] Restore label on sidebar New thread button The icon-only New thread action read inconsistently next to the labeled Automations button below it. Bring back the "New thread" label (Search stays an icon button alongside). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/app/src/components/sidebar/ProjectList.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 991166e21..1450aee19 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -1132,18 +1132,17 @@ export function ProjectListActionButtons({
{threadSearch ? ( - ) : ( - + > + + + + {onCreateProjectThread ? ( - - )} - - New thread - - + ) : ( + + + + )} + + New thread + + + )} From 7b4e57ddfe0b89f0d3fd9c25dc458f9539a225c3 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 17:05:24 -0700 Subject: [PATCH 42/54] Share sidebar header actions across project and folders views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Threads header in the folders view was missing the Organize/Sort actions, and each view defined its own near-duplicate action clusters. Extract shared components so the headers can't drift: - SidebarDisplayOptionsActions: the Organize + Sort menu pair, used by the Projects, Folders, and Threads headers. - SidebarThreadsSectionActions: the full Threads-header cluster (archived menu + display options + new thread), used by the Threads header in BOTH project mode and the folders view — so it now has Organize/Sort there too. Rebalanced the two display-menu open states so each mode's two sections own one ("primary": Projects/Folders, plus Threads) and stay independent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/sidebar/ProjectList.tsx | 172 +++++++++++------- 1 file changed, 102 insertions(+), 70 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 22ecf4b6e..04a1aca32 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -511,7 +511,9 @@ export function getSidebarThreadComparator({ direction === "asc" ? compareProjectThreadItemsByTitleAscending : (left, right) => - invertNumber(compareProjectThreadItemsByTitleAscending(left, right)); + invertNumber( + compareProjectThreadItemsByTitleAscending(left, right), + ); return comparator; } @@ -733,10 +735,7 @@ export function SidebarOrganizeOptionsMenu({ iconName="Layers" tooltip="Organize" /> - + Organize by Threads actions - + View archived threads @@ -868,6 +864,79 @@ function SidebarThreadActionsMenu({ ); } +interface SidebarDisplayOptionsActionsProps { + open: SidebarDisplayOptionsMenuKind | null; + onOpenChange: (menu: SidebarDisplayOptionsMenuKind, open: boolean) => void; + onOrganizationModeSelect?: (mode: SidebarOrganizationMode) => void; +} + +// The Organize + Sort menu pair shown on every sidebar section header. Shared +// so the project, folders, and threads headers stay identical and changes land +// in one place instead of being copied per view. +function SidebarDisplayOptionsActions({ + open, + onOpenChange, + onOrganizationModeSelect, +}: SidebarDisplayOptionsActionsProps) { + return ( + <> + onOpenChange("organize", next)} + onOrganizationModeSelect={onOrganizationModeSelect} + /> + onOpenChange("sort", next)} + /> + + ); +} + +interface SidebarThreadsSectionActionsProps { + displayOptionsOpen: SidebarDisplayOptionsMenuKind | null; + onDisplayOptionsOpenChange: ( + menu: SidebarDisplayOptionsMenuKind, + open: boolean, + ) => void; + isActionsMenuOpen: boolean; + onActionsMenuOpenChange: (open: boolean) => void; + onOpenArchivedThreads?: () => void; + isCreatingFolder: boolean; + onNewThread: () => void; +} + +// The complete Threads-section header cluster (archived menu + display options + +// new thread). One component drives the Threads header in both project mode and +// the folders view, so they can never drift apart. +function SidebarThreadsSectionActions({ + displayOptionsOpen, + onDisplayOptionsOpenChange, + isActionsMenuOpen, + onActionsMenuOpenChange, + onOpenArchivedThreads, + isCreatingFolder, + onNewThread, +}: SidebarThreadsSectionActionsProps) { + return ( + <> + + + + + ); +} + export function ProjectListNavigationLoadingState() { return (
); + // The "primary" section (Projects in project mode, Folders in the folders + // view) and the Threads section each own one display-options menu state, so + // both can be open independently — and never both at once across sections. const projectsSectionActions = ( <> - - handleProjectsDisplayOptionsMenuOpenChange("organize", open) - } + - - handleProjectsDisplayOptionsMenuOpenChange("sort", open) - } - /> {onNewProject ? ( - - handleThreadsDisplayOptionsMenuOpenChange("organize", open) - } - /> - - handleThreadsDisplayOptionsMenuOpenChange("sort", open) - } + ); - const folderModeThreadsSectionActions = ( - <> - - - - ); + // One Threads-header cluster shared by project mode and the folders view. const threadsSectionActions = ( - <> - - - handleThreadsDisplayOptionsMenuOpenChange("organize", open) - } - /> - - handleThreadsDisplayOptionsMenuOpenChange("sort", open) - } - /> - - + ); const folderModeSectionsContent = ( {content} @@ -1985,8 +2015,10 @@ function ProjectListComponent({ renderThreadsSection={(content) => ( Date: Fri, 19 Jun 2026 17:12:29 -0700 Subject: [PATCH 43/54] Render every sidebar thread list through one shared component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectThreadTree, ChronologicalThreadTree, and the Folders view each had their own near-identical copy of "group + sortable list + map item rows" plus the loading skeleton, so every row-prop change had to be repeated per view. - Add ManualThreadTreeItems: the single place that maps thread-tree items to rows. Variants via props — fixed vs per-item projectId, and an optional sortableParentKey (wrap in a SortableContext, or let an outer one provide it for the split Folders/Threads view). - Add ThreadTreeLoadingSkeleton for the repeated loading state. - Route all three renderers through them. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/src/components/sidebar/ProjectRow.tsx | 222 +++++++++++------- 1 file changed, 135 insertions(+), 87 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index a98c608fc..004818408 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -1696,6 +1696,98 @@ export const ThreadTreeNodeRow = memo(function ThreadTreeNodeRow({ ); }); +function ThreadTreeLoadingSkeleton() { + return ( +
+ +
+ ); +} + +interface ManualThreadTreeItemsProps { + items: readonly ProjectThreadItem[]; + manualSort: ManualThreadTreeDndState | null; + variant: ProjectThreadTreeVariant; + // Route every row to this project; omit to derive each row's project from its + // own thread (the cross-project Folders view). + projectId?: string; + depthOffset?: number; + // Wrap the rows in a SortableContext for this parent. Omit when an outer + // SortableList already provides the context (the split Folders/Threads view). + sortableParentKey?: string; + selectedThreadId?: string; + collapsedThreadIds: Set; + collapsedEnvironmentIds: Set; + onProjectSelect?: () => void; + onToggleThreadCollapsed: (threadId: string) => void; + onToggleEnvironmentCollapsed: (environmentId: string) => void; + onCreateThreadInFolder?: (folderPath: string) => void; + onViewArchivedThreadsInFolder?: (folderPath: string) => void; + onRenameFolder?: (folderPath: string) => void; + onRemoveFolder?: (folderPath: string) => void; +} + +// The one place that maps thread-tree items to rows. Every sidebar view +// (project, flat chronological, folders) renders through this, so a row-prop +// change lands once instead of being copied across each view's renderer. +function ManualThreadTreeItems({ + items, + manualSort, + variant, + projectId, + depthOffset = 0, + sortableParentKey, + selectedThreadId, + collapsedThreadIds, + collapsedEnvironmentIds, + onProjectSelect, + onToggleThreadCollapsed, + onToggleEnvironmentCollapsed, + onCreateThreadInFolder, + onViewArchivedThreadsInFolder, + onRenameFolder, + onRemoveFolder, +}: ManualThreadTreeItemsProps) { + const rows = items.map((item) => ( + + )); + + return ( + + {sortableParentKey !== undefined ? ( + + {rows} + + ) : ( + rows + )} + + ); +} + export const ProjectThreadTree = memo(function ProjectThreadTree({ projectId, threadListState, @@ -1730,11 +1822,7 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ }); if (threadListState.status === "loading") { - return ( -
- -
- ); + return ; } if (rootItems.length === 0) { @@ -1766,29 +1854,19 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ } const tree = ( - - - {rootItems.map((item) => ( - - ))} - - + projectId={projectId} + sortableParentKey={projectId} + selectedThreadId={selectedThreadId} + collapsedThreadIds={collapsedThreadIds} + collapsedEnvironmentIds={collapsedEnvironmentIds} + onProjectSelect={onProjectSelect} + onToggleThreadCollapsed={onToggleThreadCollapsed} + onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} + /> ); return manualSort ? ( @@ -1835,11 +1913,7 @@ export const ChronologicalThreadTree = memo(function ChronologicalThreadTree({ }); if (threadListState.status === "loading") { - return ( -
- -
- ); + return ; } if (rootItems.length === 0) { @@ -1861,32 +1935,18 @@ export const ChronologicalThreadTree = memo(function ChronologicalThreadTree({ } const tree = ( - - - {rootItems.map((item) => ( - - ))} - - + sortableParentKey={CHRONOLOGICAL_CONTAINER_ID} + selectedThreadId={selectedThreadId} + collapsedThreadIds={collapsedThreadIds} + collapsedEnvironmentIds={collapsedEnvironmentIds} + onProjectSelect={onProjectSelect} + onToggleThreadCollapsed={onToggleThreadCollapsed} + onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} + /> ); return manualSort ? ( @@ -1936,39 +1996,29 @@ export const ChronologicalFolderThreadSections = memo( const folderItems = rootItems.filter((item) => item.kind === "folder"); const looseItems = rootItems.filter((item) => item.kind !== "folder"); + // No sortableParentKey: the outer ManualSortableList below provides the + // SortableContext spanning both the folders and loose-threads sections. const renderItems = (items: readonly ProjectThreadItem[]) => ( - - {items.map((item) => ( - - ))} - + selectedThreadId={selectedThreadId} + collapsedThreadIds={collapsedThreadIds} + collapsedEnvironmentIds={collapsedEnvironmentIds} + onProjectSelect={onProjectSelect} + onToggleThreadCollapsed={onToggleThreadCollapsed} + onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} + onCreateThreadInFolder={onCreateThreadInFolder} + onViewArchivedThreadsInFolder={onViewArchivedThreadsInFolder} + onRenameFolder={onRenameFolder} + onRemoveFolder={onRemoveFolder} + /> ); const foldersContent = threadListState.status === "loading" ? ( -
- -
+ ) : folderItems.length > 0 ? ( renderItems(folderItems) ) : ( @@ -1988,9 +2038,7 @@ export const ChronologicalFolderThreadSections = memo( ); const threadsListContent = threadListState.status === "loading" ? ( -
- -
+ ) : looseItems.length > 0 ? ( renderItems(looseItems) ) : ( From db083d2601514425830f4eaa75c6afa330459bf4 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 17:19:04 -0700 Subject: [PATCH 44/54] Spring-load folder drag preview/expand to stop the drag from sticking Expanding the hovered folder and inserting the drop-preview row mid-drag shifted layout under the in-flow dragged item, so dragging a thread up out of its folder got shoved back down ("stuck"). Defer both behind a short hover dwell so passing through a folder doesn't mutate layout; the expand + preview only fire once the pointer settles over a target. Drop still works immediately regardless. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/src/components/sidebar/ProjectRow.tsx | 83 ++++++++++++++----- 1 file changed, 60 insertions(+), 23 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 004818408..c9b72f427 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, + useEffect, useMemo, useRef, useState, @@ -422,6 +423,12 @@ function resolveThreadDropTarget( return { activeId, fromParentKey, toParentKey }; } +// Spring-loaded delay before a hovered folder expands + shows the drop preview. +// The preview/expand shift layout, so deferring them until the pointer settles +// keeps dragging *through* a folder (e.g. up out of one's own folder) smooth +// instead of the inserted row shoving the dragged item back down. +const FOLDER_DRAG_DWELL_MS = 350; + function useManualThreadTreeDnd({ containerId, enabled, @@ -434,10 +441,24 @@ function useManualThreadTreeDnd({ const updateThread = useUpdateThread(); const setCollapsedFolders = useSetAtom(sidebarCollapsedFoldersAtom); const activeThreadRef = useRef(null); + const dwellTimerRef = useRef | null>(null); + // The folder the dwell timer is currently counting toward (or showing a + // preview for); null when the pointer isn't over a droppable target folder. + const dwellFolderKeyRef = useRef(null); const [dragPreview, setDragPreview] = useState( null, ); + const clearFolderDwell = useCallback(() => { + if (dwellTimerRef.current !== null) { + clearTimeout(dwellTimerRef.current); + dwellTimerRef.current = null; + } + dwellFolderKeyRef.current = null; + }, []); + + useEffect(() => clearFolderDwell, [clearFolderDwell]); + const handleDragStart = useCallback( (event: DragStartEvent) => { const activeId = event.active.id; @@ -445,9 +466,10 @@ function useManualThreadTreeDnd({ typeof activeId === "string" ? (lookup.threadByItemId.get(activeId) ?? null) : null; + clearFolderDwell(); setDragPreview(null); }, - [lookup], + [clearFolderDwell, lookup], ); const handleDragOver = useCallback( @@ -456,32 +478,46 @@ function useManualThreadTreeDnd({ const thread = activeThreadRef.current; if (!thread) return; const drop = resolveThreadDropTarget(lookup, event.active, event.over); - // Only a real folder (not the loose root) gets a preview + auto-expand. - if (!drop || drop.toParentKey === containerId) { - setDragPreview((current) => (current ? null : current)); - return; - } - const overFolderKey = drop.toParentKey; - // Expand a collapsed target so its contents — and the preview row — - // are visible to drop into. No-op if already expanded. - setCollapsedFolders((current) => - current.includes(overFolderKey) - ? current.filter((key) => key !== overFolderKey) - : current, - ); - setDragPreview((current) => - current?.overFolderKey === overFolderKey && - current.thread.id === thread.id - ? current - : { thread, overFolderKey }, - ); + // Only a real folder (not the loose root) is a preview/expand target. + const targetFolderKey = + drop && drop.toParentKey !== containerId ? drop.toParentKey : null; + + // Same target as the in-flight dwell/preview: nothing to do (don't + // thrash timers or layout on every pointer move). + if (targetFolderKey === dwellFolderKeyRef.current) return; + + // Target changed: cancel the pending dwell and drop any shown preview. + clearFolderDwell(); + dwellFolderKeyRef.current = targetFolderKey; + setDragPreview((current) => (current ? null : current)); + if (targetFolderKey === null) return; + + // Spring-loaded: expand + preview only after the pointer settles, so + // passing through a folder mid-drag doesn't shift it under the cursor. + dwellTimerRef.current = setTimeout(() => { + dwellTimerRef.current = null; + const draggingThread = activeThreadRef.current; + if (!draggingThread || dwellFolderKeyRef.current !== targetFolderKey) { + return; + } + setCollapsedFolders((current) => + current.includes(targetFolderKey) + ? current.filter((key) => key !== targetFolderKey) + : current, + ); + setDragPreview({ + thread: draggingThread, + overFolderKey: targetFolderKey, + }); + }, FOLDER_DRAG_DWELL_MS); }, - [containerId, enabled, lookup, setCollapsedFolders], + [clearFolderDwell, containerId, enabled, lookup, setCollapsedFolders], ); const handleDragEnd = useCallback( (event: DragEndEvent) => { activeThreadRef.current = null; + clearFolderDwell(); setDragPreview(null); if (!enabled) return; @@ -499,13 +535,14 @@ function useManualThreadTreeDnd({ folderPath: destinationFolderPath, }); }, - [enabled, lookup, updateThread], + [clearFolderDwell, enabled, lookup, updateThread], ); const handleDragCancel = useCallback(() => { activeThreadRef.current = null; + clearFolderDwell(); setDragPreview(null); - }, []); + }, [clearFolderDwell]); const { consumeClickSuppression, dndContextProps, onClickCapture } = useSidebarReorderDnd({ From fc6d066ba2b38faf84644467bc2ec0afe172978a Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 17:37:25 -0700 Subject: [PATCH 45/54] Shorten folder drag-dwell to 200ms for a snappier spring-load Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/app/src/components/sidebar/ProjectRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index c9b72f427..367f3cc7f 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -427,7 +427,7 @@ function resolveThreadDropTarget( // The preview/expand shift layout, so deferring them until the pointer settles // keeps dragging *through* a folder (e.g. up out of one's own folder) smooth // instead of the inserted row shoving the dragged item back down. -const FOLDER_DRAG_DWELL_MS = 350; +const FOLDER_DRAG_DWELL_MS = 200; function useManualThreadTreeDnd({ containerId, From a448caac30592af0df40bcc351dd5e81597ef39e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 17:54:09 -0700 Subject: [PATCH 46/54] Make folder drop placeholder an empty slot; add a story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drop preview duplicated the dragged thread's title (once in the injected row, once on the natural dragged row). Keep the placeholder as the drop target but render it empty — the dragged row carries the title, like dragging a queued message. Folders still highlight and spring-open on hover; dropping onto the loose Threads section (a droppable parent) clears the thread's folder. Add a "Drag into" story for the drop-target highlight + empty placeholder. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/src/components/sidebar/ProjectRow.tsx | 99 ++++++++----------- .../sidebar/SidebarFolderRow.stories.tsx | 45 +++++++++ 2 files changed, 85 insertions(+), 59 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 367f3cc7f..56ac5f2cc 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -277,21 +277,15 @@ interface FolderTreeItemRowProps { sortableStyle?: CSSProperties; } -// While a thread is dragged over a folder, the folder renders an optimistic -// preview row for that thread (and is auto-expanded) so it reads as the drop -// target before the move is committed. -interface FolderDragPreview { - thread: ThreadListEntry; - overFolderKey: string; -} - interface ManualThreadTreeDndState { consumeClickSuppression: ConsumeDragClickSuppression; dndContextProps: SidebarReorderDndContextProps; enabled: boolean; itemIdsByParentKey: ReadonlyMap; onClickCapture: MouseEventHandler; - dragPreview: FolderDragPreview | null; + // The folder showing an empty drop-placeholder row while a thread is dragged + // over it (after a short hover); the dragged row itself carries the title. + dragOverFolderKey: string | null; } interface UseManualThreadTreeDndArgs { @@ -440,12 +434,14 @@ function useManualThreadTreeDnd({ ); const updateThread = useUpdateThread(); const setCollapsedFolders = useSetAtom(sidebarCollapsedFoldersAtom); - const activeThreadRef = useRef(null); + // Whether a thread (vs. nothing droppable) is currently being dragged. + const draggingThreadRef = useRef(false); const dwellTimerRef = useRef | null>(null); - // The folder the dwell timer is currently counting toward (or showing a - // preview for); null when the pointer isn't over a droppable target folder. + // The folder the dwell timer is currently counting toward; null when the + // pointer isn't over a droppable target folder. const dwellFolderKeyRef = useRef(null); - const [dragPreview, setDragPreview] = useState( + // The folder currently showing an (empty) drop-placeholder row, after dwell. + const [dragOverFolderKey, setDragOverFolderKey] = useState( null, ); @@ -462,42 +458,40 @@ function useManualThreadTreeDnd({ const handleDragStart = useCallback( (event: DragStartEvent) => { const activeId = event.active.id; - activeThreadRef.current = - typeof activeId === "string" - ? (lookup.threadByItemId.get(activeId) ?? null) - : null; + draggingThreadRef.current = + typeof activeId === "string" && lookup.threadByItemId.has(activeId); clearFolderDwell(); - setDragPreview(null); + setDragOverFolderKey(null); }, [clearFolderDwell, lookup], ); const handleDragOver = useCallback( (event: DragOverEvent) => { - if (!enabled) return; - const thread = activeThreadRef.current; - if (!thread) return; + if (!enabled || !draggingThreadRef.current) return; const drop = resolveThreadDropTarget(lookup, event.active, event.over); - // Only a real folder (not the loose root) is a preview/expand target. + // Only a real folder (not the loose root) is a spring-load target. const targetFolderKey = drop && drop.toParentKey !== containerId ? drop.toParentKey : null; - // Same target as the in-flight dwell/preview: nothing to do (don't - // thrash timers or layout on every pointer move). + // Same target as the in-flight dwell: nothing to do (don't thrash timers + // on every pointer move). if (targetFolderKey === dwellFolderKeyRef.current) return; - // Target changed: cancel the pending dwell and drop any shown preview. clearFolderDwell(); dwellFolderKeyRef.current = targetFolderKey; - setDragPreview((current) => (current ? null : current)); + setDragOverFolderKey((current) => (current ? null : current)); if (targetFolderKey === null) return; - // Spring-loaded: expand + preview only after the pointer settles, so - // passing through a folder mid-drag doesn't shift it under the cursor. + // Spring-loaded: expand a collapsed target and show the empty placeholder + // only after the pointer settles, so passing through a folder mid-drag + // doesn't shift it under the cursor. dwellTimerRef.current = setTimeout(() => { dwellTimerRef.current = null; - const draggingThread = activeThreadRef.current; - if (!draggingThread || dwellFolderKeyRef.current !== targetFolderKey) { + if ( + !draggingThreadRef.current || + dwellFolderKeyRef.current !== targetFolderKey + ) { return; } setCollapsedFolders((current) => @@ -505,10 +499,7 @@ function useManualThreadTreeDnd({ ? current.filter((key) => key !== targetFolderKey) : current, ); - setDragPreview({ - thread: draggingThread, - overFolderKey: targetFolderKey, - }); + setDragOverFolderKey(targetFolderKey); }, FOLDER_DRAG_DWELL_MS); }, [clearFolderDwell, containerId, enabled, lookup, setCollapsedFolders], @@ -516,9 +507,9 @@ function useManualThreadTreeDnd({ const handleDragEnd = useCallback( (event: DragEndEvent) => { - activeThreadRef.current = null; + draggingThreadRef.current = false; clearFolderDwell(); - setDragPreview(null); + setDragOverFolderKey(null); if (!enabled) return; const drop = resolveThreadDropTarget(lookup, event.active, event.over); @@ -539,9 +530,9 @@ function useManualThreadTreeDnd({ ); const handleDragCancel = useCallback(() => { - activeThreadRef.current = null; + draggingThreadRef.current = false; clearFolderDwell(); - setDragPreview(null); + setDragOverFolderKey(null); }, [clearFolderDwell]); const { consumeClickSuppression, dndContextProps, onClickCapture } = @@ -562,7 +553,7 @@ function useManualThreadTreeDnd({ enabled, itemIdsByParentKey: lookup.itemIdsByParentKey, onClickCapture, - dragPreview, + dragOverFolderKey, }; } @@ -1460,15 +1451,10 @@ export const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ // in sidebarCollapsedFoldersAtom — read here rather than threaded so the rest of // the tree's prop wiring and memo equality stay untouched. Children render one // depth deeper and, when threads, show their leaf via insideFolder. -// Non-interactive placeholder for the thread being dragged into a folder, -// rendered inside the (auto-expanded) folder so it reads as the drop target. -function FolderDropPreviewRow({ - depth, - title, -}: { - depth: number; - title: string; -}) { +// Empty drop-slot rendered inside the (auto-expanded) hovered folder so the +// landing spot is visible. The dragged row itself carries the title (like +// dragging a queued message), so this placeholder stays intentionally blank. +export function FolderDropPreviewRow({ depth }: { depth: number }) { return ( + /> ); } @@ -1522,13 +1506,11 @@ const FolderTreeItemRow = memo(function FolderTreeItemRow({ const stickyLevel = depthOffset < SIDEBAR_STICKY_PARENT_DEPTH_CAP ? depthOffset : undefined; const folderPath = folder.path.join("/"); - const dragPreview = manualSort?.dragPreview ?? null; - const previewThread = - dragPreview?.overFolderKey === folderKey ? dragPreview.thread : null; + const showDropPreview = manualSort?.dragOverFolderKey === folderKey; const showChildren = !isCollapsed && folder.items.length > 0; // Force the children area open while a thread is dragged over this folder so - // the optimistic drop-preview row is visible even when empty/collapsed. - const showChildrenArea = showChildren || previewThread !== null; + // the empty drop-placeholder row is visible even when the folder is empty. + const showChildrenArea = showChildren || showDropPreview; return ( ) : null} - {previewThread ? ( + {showDropPreview ? ( ) : null}
diff --git a/apps/app/src/components/sidebar/SidebarFolderRow.stories.tsx b/apps/app/src/components/sidebar/SidebarFolderRow.stories.tsx index 5d1d2aeb6..f566d7e14 100644 --- a/apps/app/src/components/sidebar/SidebarFolderRow.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarFolderRow.stories.tsx @@ -6,6 +6,7 @@ import { } from "@/lib/thread-activity"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { SidebarFolderRow } from "./SidebarFolderRow"; +import { FolderDropPreviewRow } from "./ProjectRow"; export default { title: "sidebar/Folder row", @@ -107,3 +108,47 @@ export function Overview() { ); } + +// Drag-into-folder affordance: the folder highlights as a drop target, and +// after a short hover it springs open with an empty placeholder slot. The +// dragged row keeps its own title (like dragging a queued message), so the +// placeholder stays blank rather than duplicating the title. +export function DragInto() { + return ( + + + + + + + + + + + + + + ); +} From c67a76af94fc4d01cd1fb1582de56db7cd494214 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 18:09:59 -0700 Subject: [PATCH 47/54] Harden sidebar folder review issues --- .../src/components/sidebar/ProjectList.tsx | 54 +++++++- apps/app/src/hooks/queries/query-keys.ts | 2 +- .../src/hooks/queries/thread-queries.test.tsx | 82 ++++++++++++ apps/app/src/hooks/queries/thread-queries.ts | 10 +- apps/app/src/views/ArchivedThreadsView.tsx | 14 +- apps/server/src/routes/thread-folders.ts | 29 ++++- packages/db/src/data/index.ts | 1 + packages/db/src/data/thread-folders.ts | 76 ++++++++++- packages/db/test/data/threads.test.ts | 121 ++++++++++++++++++ packages/server-contract/src/api/projects.ts | 2 + .../server-contract/test/contract.test.ts | 2 + 11 files changed, 373 insertions(+), 20 deletions(-) create mode 100644 apps/app/src/hooks/queries/thread-queries.test.tsx diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 04a1aca32..b91180604 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -64,6 +64,10 @@ import { ThreadFolderRenameDialog, type ThreadFolderRenameDialogTarget, } from "@/components/dialogs/ThreadFolderCreateDialog"; +import { + ConfirmDeleteDialog, + ConfirmDeleteDialogContent, +} from "@/components/dialogs/ConfirmDeleteDialog"; import { CHROME_SECTION_LABEL_CLASS } from "@/components/ui/chromeStyleTokens"; import { Icon, type IconName } from "@/components/ui/icon.js"; import { Skeleton } from "@/components/ui/skeleton.js"; @@ -1365,7 +1369,10 @@ function ProjectListComponent({ isPending: isUpdateThreadFolderPending, mutate: updateThreadFolderMutate, } = useUpdateThreadFolder(); - const { mutate: deleteThreadFolderMutate } = useDeleteThreadFolder(); + const { + isPending: isDeleteThreadFolderPending, + mutate: deleteThreadFolderMutate, + } = useDeleteThreadFolder(); const projectItems = projects ?? EMPTY_PROJECTS; const handleReorderProject = useCallback< UseNeighborReorderSortableArgs["onReorder"] @@ -1456,6 +1463,7 @@ function ProjectListComponent({ projectId: string | null; } | null>(null); const folderRenameDialog = useDialogState(); + const folderDeleteDialog = useDialogState(); const isFolderCreateDialogOpen = folderCreateTarget !== null; const handleOpenCreateFolderDialog = useCallback(() => { setFolderCreateTarget({ projectId: null }); @@ -1485,7 +1493,7 @@ function ProjectListComponent({ const handleRenameThreadFolder = useCallback( (path: string, newPath: string) => { updateThreadFolderMutate( - { path, newPath }, + { path, newPath, projectId: null }, { onSuccess: () => folderRenameDialog.onClose() }, ); }, @@ -1493,9 +1501,28 @@ function ProjectListComponent({ ); const handleRemoveThreadFolder = useCallback( (path: string) => { - deleteThreadFolderMutate({ path }); + folderDeleteDialog.onOpen(path); + }, + [folderDeleteDialog], + ); + const handleConfirmRemoveThreadFolder = useCallback(() => { + const path = folderDeleteDialog.target; + if (!path) { + return; + } + deleteThreadFolderMutate( + { path, projectId: null }, + { onSuccess: () => folderDeleteDialog.onClose() }, + ); + }, [deleteThreadFolderMutate, folderDeleteDialog]); + const handleFolderDeleteDialogOpenChange = useCallback( + (open: boolean) => { + if (open) { + return; + } + folderDeleteDialog.onClose(); }, - [deleteThreadFolderMutate], + [folderDeleteDialog], ); const handleOpenProjectlessArchivedThreads = useCallback(() => { onProjectSelect?.(); @@ -2046,6 +2073,23 @@ function ProjectListComponent({ onRename={handleRenameThreadFolder} /> ); + const folderDeleteDialogContent = ( + + {folderDeleteDialog.target ? ( + + ) : null} + + ); if (threadSearch?.isActive) { return ( @@ -2085,6 +2129,7 @@ function ProjectListComponent({
{folderCreateDialog} {folderRenameDialogContent} + {folderDeleteDialogContent} ); } @@ -2160,6 +2205,7 @@ function ProjectListComponent({ {folderCreateDialog} {folderRenameDialogContent} + {folderDeleteDialogContent} ); } diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 04a23dffd..1d63dbc4f 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -79,7 +79,7 @@ export interface ThreadSearchQueryFilters { export type ArchivedThreadsKindFilter = "all" | "root" | "child"; export interface ArchivedThreadsListFilters { - projectId: string; + projectId?: string; folderPath?: string; unfiled?: boolean; } diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx new file mode 100644 index 000000000..ea47bcce1 --- /dev/null +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom + +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "@/lib/api"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { ARCHIVED_THREADS_PAGE_SIZE } from "./archived-threads-page-size"; +import { useArchivedThreads } from "./thread-queries"; + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listThreads: vi.fn(), + }; +}); + +vi.mock("@/hooks/useRealtimeSubscription", () => ({ + useThreadDetailRealtimeSubscription: vi.fn(), + useThreadListRealtimeSubscription: vi.fn(), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +beforeEach(() => { + vi.mocked(api.listThreads).mockResolvedValue([]); +}); + +describe("useArchivedThreads", () => { + it("omits projectId for folder-scoped archived lists", async () => { + const { wrapper } = createQueryClientTestHarness(); + + renderHook(() => useArchivedThreads({ folderPath: "Work" }), { wrapper }); + + await waitFor(() => { + expect(api.listThreads).toHaveBeenCalled(); + }); + expect(vi.mocked(api.listThreads).mock.calls[0]?.[0]).toEqual({ + archived: true, + folderPath: "Work", + limit: ARCHIVED_THREADS_PAGE_SIZE, + offset: 0, + }); + }); + + it("keeps project scope for project archived lists", async () => { + const { wrapper } = createQueryClientTestHarness(); + + renderHook(() => useArchivedThreads({ projectId: "proj_1" }), { + wrapper, + }); + + await waitFor(() => { + expect(api.listThreads).toHaveBeenCalled(); + }); + expect(vi.mocked(api.listThreads).mock.calls[0]?.[0]).toEqual({ + archived: true, + limit: ARCHIVED_THREADS_PAGE_SIZE, + offset: 0, + projectId: "proj_1", + }); + }); + + it("omits projectId for global loose archived lists", async () => { + const { wrapper } = createQueryClientTestHarness(); + + renderHook(() => useArchivedThreads({ unfiled: true }), { wrapper }); + + await waitFor(() => { + expect(api.listThreads).toHaveBeenCalled(); + }); + expect(vi.mocked(api.listThreads).mock.calls[0]?.[0]).toEqual({ + archived: true, + limit: ARCHIVED_THREADS_PAGE_SIZE, + offset: 0, + unfiled: true, + }); + }); +}); diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index 0806aa1b0..80f747f16 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -268,7 +268,7 @@ export function hasThreadSearchableQuery(value: string): boolean { } export interface UseArchivedThreadsFilters { - projectId: string | undefined; + projectId?: string; /** Restrict to threads filed directly under this folder path. */ folderPath?: string; /** Restrict to loose threads — those not filed under any folder. */ @@ -280,7 +280,9 @@ export function useArchivedThreads( options?: QueryOptions, ) { const { projectId, folderPath, unfiled } = filters; - const enabled = (options?.enabled ?? true) && Boolean(projectId); + const enabled = + (options?.enabled ?? true) && + (Boolean(projectId) || Boolean(folderPath) || Boolean(unfiled)); useThreadListRealtimeSubscription({ enabled }); return useInfiniteQuery< @@ -291,14 +293,14 @@ export function useArchivedThreads( number >({ queryKey: archivedThreadsListQueryKey({ - projectId: projectId ?? "", + ...(projectId ? { projectId } : {}), ...(folderPath ? { folderPath } : {}), ...(unfiled ? { unfiled: true } : {}), }), queryFn: ({ pageParam, signal }) => api.listThreads( { - projectId: requireThreadId(projectId ?? "", "useArchivedThreads"), + ...(projectId ? { projectId } : {}), ...(folderPath ? { folderPath } : {}), ...(unfiled ? { unfiled: true } : {}), archived: true, diff --git a/apps/app/src/views/ArchivedThreadsView.tsx b/apps/app/src/views/ArchivedThreadsView.tsx index 27d3501b5..04e4079cd 100644 --- a/apps/app/src/views/ArchivedThreadsView.tsx +++ b/apps/app/src/views/ArchivedThreadsView.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { Link, useSearchParams } from "react-router-dom"; +import { useAtomValue } from "jotai"; import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain"; import { Button } from "@/components/ui/button.js"; import { EmptyStatePanel } from "@/components/ui/empty-state.js"; @@ -11,6 +12,7 @@ import { useArchivedThreads } from "@/hooks/queries/thread-queries"; import { useRouteState } from "@/hooks/useRouteState"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { getThreadRoutePath } from "@/lib/route-paths"; +import { sidebarOrganizationModeAtom } from "@/components/sidebar/sidebarCollapsedAtoms"; type ArchivedThreadPillLabel = "child"; @@ -30,12 +32,18 @@ function getArchivedThreadPillLabel( export function ArchivedThreadsView() { const { projectId } = useRouteState(); const [searchParams] = useSearchParams(); + const sidebarOrganizationMode = useAtomValue(sidebarOrganizationModeAtom); const folderPath = searchParams.get("folder") ?? undefined; - // The personal section's archived list shows loose threads only; threads - // filed in a folder live in that folder's archived list instead. + const isGlobalFoldersMode = + projectId === PERSONAL_PROJECT_ID && + sidebarOrganizationMode === "chronological"; + const archivedProjectId = + folderPath || isGlobalFoldersMode ? undefined : projectId; + // The loose archived list mirrors the current sidebar scope: in project mode + // it is personal-only; in Folders mode it is cross-project loose threads. const restrictToLoose = !folderPath && projectId === PERSONAL_PROJECT_ID; const archivedThreadsQuery = useArchivedThreads({ - projectId, + projectId: archivedProjectId, ...(folderPath ? { folderPath } : {}), ...(restrictToLoose ? { unfiled: true } : {}), }); diff --git a/apps/server/src/routes/thread-folders.ts b/apps/server/src/routes/thread-folders.ts index ed01cf9f9..983df66a5 100644 --- a/apps/server/src/routes/thread-folders.ts +++ b/apps/server/src/routes/thread-folders.ts @@ -1,6 +1,7 @@ import { createThreadFolder, deleteThreadFolder, + isThreadFolderDescendantPath, normalizeThreadFolderPath, renameThreadFolder, } from "@bb/db"; @@ -14,6 +15,15 @@ import type { AppDeps } from "../types.js"; import { ApiError } from "../errors.js"; import { requirePublicProject } from "../services/lib/entity-lookup.js"; +function requireThreadFolderProjectScope( + db: AppDeps["db"], + projectId: string | null | undefined, +): void { + if (projectId !== undefined && projectId !== null) { + requirePublicProject(db, projectId); + } +} + export function registerThreadFolderRoutes(app: Hono, deps: AppDeps): void { const { del, patch, post } = typedRoutes(app, { onValidationError: (msg) => new ApiError(400, "invalid_request", msg), @@ -44,7 +54,20 @@ export function registerThreadFolderRoutes(app: Hono, deps: AppDeps): void { if (!path || !newPath) { throw new ApiError(400, "invalid_request", "Folder name cannot be empty"); } - const result = renameThreadFolder(deps.db, deps.hub, { path, newPath }); + const projectId = payload.projectId; + requireThreadFolderProjectScope(deps.db, projectId); + if (isThreadFolderDescendantPath(path, newPath)) { + throw new ApiError( + 400, + "invalid_request", + "Folder cannot be moved into one of its subfolders", + ); + } + const result = renameThreadFolder(deps.db, deps.hub, { + path, + newPath, + projectId, + }); if (!result) { throw new ApiError(404, "folder_not_found", "Folder not found"); } @@ -56,7 +79,9 @@ export function registerThreadFolderRoutes(app: Hono, deps: AppDeps): void { if (!path) { throw new ApiError(400, "invalid_request", "Folder name cannot be empty"); } - const result = deleteThreadFolder(deps.db, deps.hub, { path }); + const projectId = payload.projectId; + requireThreadFolderProjectScope(deps.db, projectId); + const result = deleteThreadFolder(deps.db, deps.hub, { path, projectId }); if (!result) { throw new ApiError(404, "folder_not_found", "Folder not found"); } diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 258822057..920119a2c 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -24,6 +24,7 @@ export { deleteThreadFolder, ensureThreadFolderPath, getThreadFolderByPath, + isThreadFolderDescendantPath, listThreadFolders, normalizeThreadFolderPath, renameThreadFolder, diff --git a/packages/db/src/data/thread-folders.ts b/packages/db/src/data/thread-folders.ts index 4c146afbf..fc2e932a0 100644 --- a/packages/db/src/data/thread-folders.ts +++ b/packages/db/src/data/thread-folders.ts @@ -21,10 +21,12 @@ export interface CreateThreadFolderInput { export interface RenameThreadFolderInput { path: string; newPath: string; + projectId?: string | null; } export interface DeleteThreadFolderInput { path: string; + projectId?: string | null; } export interface ThreadFolderMutationResult { @@ -65,6 +67,22 @@ function folderPathSubtreeFilter( ); } +function folderProjectScopeFilter(projectId: string | null | undefined) { + if (projectId === undefined) { + return undefined; + } + return projectId === null + ? isNull(threadFolders.projectId) + : eq(threadFolders.projectId, projectId); +} + +function threadProjectScopeFilter(projectId: string | null | undefined) { + if (projectId === undefined) { + return undefined; + } + return projectId === null ? sql`1 = 0` : eq(threads.projectId, projectId); +} + function replaceFolderPathPrefix( value: string, oldPath: string, @@ -87,6 +105,19 @@ function notifyThreadFolderMutationProjects( } } +export function isThreadFolderDescendantPath( + path: string | null | undefined, + possibleDescendantPath: string | null | undefined, +): boolean { + const normalizedPath = normalizeThreadFolderPath(path); + const normalizedDescendant = normalizeThreadFolderPath(possibleDescendantPath); + return Boolean( + normalizedPath && + normalizedDescendant && + normalizedDescendant.startsWith(`${normalizedPath}/`), + ); +} + export function getThreadFolderByPath( db: DbQueryConnection, path: string, @@ -196,13 +227,21 @@ export function renameThreadFolder( if (path === newPath) { return { path: newPath, updatedThreadCount: 0 }; } + if (isThreadFolderDescendantPath(path, newPath)) { + return null; + } return db.transaction( (tx) => { const matchingFolders = tx .select() .from(threadFolders) - .where(folderPathSubtreeFilter(threadFolders.path, path)) + .where( + and( + folderPathSubtreeFilter(threadFolders.path, path), + folderProjectScopeFilter(input.projectId), + ), + ) .all(); const matchingThreads = tx .select({ @@ -211,7 +250,12 @@ export function renameThreadFolder( folderPath: threads.folderPath, }) .from(threads) - .where(folderPathSubtreeFilter(threads.folderPath, path)) + .where( + and( + folderPathSubtreeFilter(threads.folderPath, path), + threadProjectScopeFilter(input.projectId), + ), + ) .all(); if (matchingFolders.length === 0 && matchingThreads.length === 0) { @@ -219,7 +263,12 @@ export function renameThreadFolder( } tx.delete(threadFolders) - .where(folderPathSubtreeFilter(threadFolders.path, path)) + .where( + and( + folderPathSubtreeFilter(threadFolders.path, path), + folderProjectScopeFilter(input.projectId), + ), + ) .run(); const affectedProjects = new Set(); @@ -276,7 +325,12 @@ export function deleteThreadFolder( const matchingFolders = tx .select() .from(threadFolders) - .where(folderPathSubtreeFilter(threadFolders.path, path)) + .where( + and( + folderPathSubtreeFilter(threadFolders.path, path), + folderProjectScopeFilter(input.projectId), + ), + ) .all(); const matchingThreads = tx .select({ @@ -284,7 +338,12 @@ export function deleteThreadFolder( projectId: threads.projectId, }) .from(threads) - .where(folderPathSubtreeFilter(threads.folderPath, path)) + .where( + and( + folderPathSubtreeFilter(threads.folderPath, path), + threadProjectScopeFilter(input.projectId), + ), + ) .all(); if (matchingFolders.length === 0 && matchingThreads.length === 0) { @@ -292,7 +351,12 @@ export function deleteThreadFolder( } tx.delete(threadFolders) - .where(folderPathSubtreeFilter(threadFolders.path, path)) + .where( + and( + folderPathSubtreeFilter(threadFolders.path, path), + folderProjectScopeFilter(input.projectId), + ), + ) .run(); const now = Date.now(); diff --git a/packages/db/test/data/threads.test.ts b/packages/db/test/data/threads.test.ts index 3bf8381f6..dca8cd6ae 100644 --- a/packages/db/test/data/threads.test.ts +++ b/packages/db/test/data/threads.test.ts @@ -829,6 +829,88 @@ describe("threads", () => { ); }); + it("renames only one project folder scope when projectId is supplied", () => { + const { db, host, project } = setup(); + const { project: otherProject } = createProject(db, noopNotifier, { + name: "other-project", + source: { type: "local_path", hostId: host.id, path: "/tmp/other" }, + }); + const projectThread = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + folderPath: "Work/Q3", + }); + const otherProjectThread = createThread(db, noopNotifier, { + projectId: otherProject.id, + providerId: "codex", + folderPath: "Work/Q3", + }); + createThreadFolder(db, noopNotifier, { path: "Work/Global" }); + + const result = renameThreadFolder(db, noopNotifier, { + path: "Work", + newPath: "Archive", + projectId: project.id, + }); + + expect(result).toEqual({ path: "Archive", updatedThreadCount: 1 }); + expect(getThread(db, projectThread.id)?.folderPath).toBe("Archive/Q3"); + expect(getThread(db, otherProjectThread.id)?.folderPath).toBe("Work/Q3"); + expect( + listThreadFolders(db) + .map((folder) => ({ + path: folder.path, + projectId: folder.projectId, + })) + .sort((left, right) => + `${left.projectId ?? ""}:${left.path}`.localeCompare( + `${right.projectId ?? ""}:${right.path}`, + ), + ), + ).toEqual( + [ + { path: "Work", projectId: null }, + { path: "Work/Global", projectId: null }, + { path: "Archive", projectId: project.id }, + { path: "Archive/Q3", projectId: project.id }, + { path: "Work", projectId: otherProject.id }, + { path: "Work/Q3", projectId: otherProject.id }, + ].sort((left, right) => + `${left.projectId ?? ""}:${left.path}`.localeCompare( + `${right.projectId ?? ""}:${right.path}`, + ), + ), + ); + }); + + it("does not rename a thread folder into its own descendant", () => { + const { db, project } = setup(); + const thread = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + folderPath: "Work/Q3", + }); + createThreadFolder(db, noopNotifier, { path: "Work/Empty" }); + const foldersBefore = listThreadFolders(db).map((folder) => ({ + path: folder.path, + projectId: folder.projectId, + })); + + const result = renameThreadFolder(db, noopNotifier, { + path: "Work", + newPath: "Work/Q3", + }); + + expect(result).toBeNull(); + expect(getThread(db, thread.id)?.folderPath).toBe("Work/Q3"); + expect( + listThreadFolders(db).map((folder) => ({ + path: folder.path, + projectId: folder.projectId, + })), + ).toEqual(foldersBefore); + }); + it("removes thread folders and clears descendant thread folder paths", () => { const { db, project } = setup(); const thread = createThread(db, noopNotifier, { @@ -845,6 +927,45 @@ describe("threads", () => { expect(listThreadFolders(db)).toEqual([]); }); + it("removes only one project folder scope when projectId is supplied", () => { + const { db, host, project } = setup(); + const { project: otherProject } = createProject(db, noopNotifier, { + name: "other-project", + source: { type: "local_path", hostId: host.id, path: "/tmp/other" }, + }); + const projectThread = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + folderPath: "Work/Q3", + }); + const otherProjectThread = createThread(db, noopNotifier, { + projectId: otherProject.id, + providerId: "codex", + folderPath: "Work/Q3", + }); + createThreadFolder(db, noopNotifier, { path: "Work/Global" }); + + const result = deleteThreadFolder(db, noopNotifier, { + path: "Work", + projectId: project.id, + }); + + expect(result).toEqual({ path: "Work", updatedThreadCount: 1 }); + expect(getThread(db, projectThread.id)?.folderPath).toBeNull(); + expect(getThread(db, otherProjectThread.id)?.folderPath).toBe("Work/Q3"); + expect( + listThreadFolders(db).map((folder) => ({ + path: folder.path, + projectId: folder.projectId, + })), + ).toEqual([ + { path: "Work", projectId: null }, + { path: "Work/Global", projectId: null }, + { path: "Work", projectId: otherProject.id }, + { path: "Work/Q3", projectId: otherProject.id }, + ]); + }); + it("notifies when a thread parent changes", () => { const { db, project } = setup(); const spy: DbNotifier = { diff --git a/packages/server-contract/src/api/projects.ts b/packages/server-contract/src/api/projects.ts index 876423370..668a66ba1 100644 --- a/packages/server-contract/src/api/projects.ts +++ b/packages/server-contract/src/api/projects.ts @@ -79,6 +79,7 @@ export const updateThreadFolderRequestSchema = z .object({ path: z.string().min(1), newPath: z.string().min(1), + projectId: z.string().min(1).nullable().optional(), }) .strict(); export type UpdateThreadFolderRequest = z.infer< @@ -88,6 +89,7 @@ export type UpdateThreadFolderRequest = z.infer< export const deleteThreadFolderRequestSchema = z .object({ path: z.string().min(1), + projectId: z.string().min(1).nullable().optional(), }) .strict(); export type DeleteThreadFolderRequest = z.infer< diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index a193bf5b6..35fb825f1 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -184,6 +184,7 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ fields: [ "threadListQuerySchema.archived", "threadListQuerySchema.childOrigin", + "threadListQuerySchema.folderPath", "threadListQuerySchema.limit", "threadListQuerySchema.hasParent", "threadListQuerySchema.offset", @@ -191,6 +192,7 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ "threadListQuerySchema.parentThreadId", "threadListQuerySchema.projectId", "threadListQuerySchema.sourceThreadId", + "threadListQuerySchema.unfiled", ], }, { From c6015d31fddee20485c78133b089f2a61d1403e4 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 18:13:25 -0700 Subject: [PATCH 48/54] Add folderPath to CLI thread test fixture after main merge Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/__tests__/helpers/command-output-fixtures.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/src/__tests__/helpers/command-output-fixtures.ts b/apps/cli/src/__tests__/helpers/command-output-fixtures.ts index 041f5ff07..fbed5e550 100644 --- a/apps/cli/src/__tests__/helpers/command-output-fixtures.ts +++ b/apps/cli/src/__tests__/helpers/command-output-fixtures.ts @@ -106,6 +106,7 @@ export function makeThread(overrides: MakeThreadArgs): Thread { status: "idle", title: null, titleFallback: null, + folderPath: null, environmentId: null, parentThreadId: null, sourceThreadId: null, From e94094ccaab0d0b8651c1438a396f95b38fc7387 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 19 Jun 2026 18:25:39 -0700 Subject: [PATCH 49/54] Drop folder schema in migrate-test replay rollbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread-folder migrations (0042 folder_path, 0043 thread_folders, 0044 project scoping) land after thread-search, so migrate-test scenarios that rewind the ledger past them must drop the folder schema too — otherwise migrate() re-runs ADD folder_path / CREATE thread_folders against a DB that still has them (duplicate column / table already exists). Add dropThreadFolderSchema to the reset/rollback paths (thread-search replay, post-0023, event-large-value, and the fork/side-chat rewind). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/db/test/migrate.test.ts | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 89046ed22..97b2fd162 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -195,7 +195,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const latestMigrationWhen = Math.max( ...( JSON.parse( - readFileSync(resolve(__dirname, "../drizzle/meta/_journal.json"), "utf-8"), + readFileSync( + resolve(__dirname, "../drizzle/meta/_journal.json"), + "utf-8", + ), ) as { entries: { when: number }[] } ).entries.map((entry) => entry.when), ); @@ -291,6 +294,7 @@ function closeConnection(db: DbConnection): void { // NOTE: when adding a migration after thread-search, drop its schema here too. function resetMigrationsAfterThreadSearch(db: DbConnection): void { dropAutomationsSchema(db); + dropThreadFolderSchema(db); db.$client .prepare<[number]>("DELETE FROM __drizzle_migrations WHERE created_at > ?") .run(threadSearchRowidFtsMigrationWhen); @@ -379,6 +383,25 @@ function dropPost0023Tables(db: DbConnection): void { ]) { db.$client.prepare(`DROP TABLE IF EXISTS ${table}`).run(); } + + dropThreadFolderSchema(db); +} + +/** + * Folder schema lands after thread-search (0042 folder_path column, 0043 + * thread_folders table, 0044 project scoping). Replay scenarios that rewind the + * ledger past it must drop the schema too, or migrate() re-runs the ADD/CREATE + * against a DB that already has them. + */ +function dropThreadFolderSchema(db: DbConnection): void { + db.$client.exec("DROP TABLE IF EXISTS thread_folders;"); + const hasFolderPath = db.$client + .prepare<[], TableInfoRow>("PRAGMA table_info(threads)") + .all() + .some((row) => row.name === "folder_path"); + if (hasFolderPath) { + db.$client.prepare("ALTER TABLE threads DROP COLUMN folder_path").run(); + } } function restorePre0022ThreadTypeSchema(db: DbConnection): void { @@ -489,6 +512,7 @@ function markEventLargeValuesMigrationUnapplied(db: DbConnection): void { restoreEnvironmentCleanupModeColumn(db); restoreEnvironmentCleanupRequestedAtColumn(db); restoreThreadStopRequestedAtColumn(db); + dropThreadFolderSchema(db); db.$client .prepare( ` @@ -716,7 +740,9 @@ function expectEventLargeValuesInline( expect(webSearchData.item.resultText).toBe(values.webSearchResult); expect(webSearchData.item.truncation).toBeUndefined(); - const fileData = JSON.parse(readMigratedEventData(db, "evt_large_file_diffs")); + const fileData = JSON.parse( + readMigratedEventData(db, "evt_large_file_diffs"), + ); expect(fileData.item.changes).toEqual([ { path: "a.ts", diff: values.firstDiff }, { path: "b.ts", diff: "small diff" }, @@ -1388,6 +1414,7 @@ describe("migrate", () => { ) .run(threadSourceOriginMigrationWhen); dropAutomationsSchema(db); + dropThreadFolderSchema(db); db.$client.exec(` DROP TRIGGER IF EXISTS thread_search_segments_after_text_update; DROP TRIGGER IF EXISTS thread_search_segments_after_delete; From 848f110bfc96a0d8156a5de5d1c54100b539f0c9 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 20 Jun 2026 01:34:02 -0700 Subject: [PATCH 50/54] Make thread folders a global cross-project namespace Folders are organized by path and a single folder is meant to hold threads from any project, but the prior implementation added a project_id scope to thread_folders plus a projectId field on the create/rename/delete contract. In practice the UI only ever sent projectId: null, and the global (null) path matched zero threads (threadProjectScopeFilter returned 1 = 0), so renaming or deleting a folder in the app orphaned every thread inside it. Filing a thread into a folder also created a project-scoped folder row, diverging from the global rows the create dialog made. Remove project scoping entirely: - Drop thread_folders.project_id (fold into migration 0043; delete the project-scoping migration 0044 + its snapshot/journal entry, no new migration needed since folders never shipped). - Match a folder's threads by path across all projects on rename/delete. - Strip projectId from the contract request/response schemas, the server route, the data layer, and the sidebar create/rename/delete calls. - Replace the project-scope unit tests with cross-project coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/sidebar/ProjectList.tsx | 20 +- apps/server/src/routes/thread-folders.ts | 29 +- packages/db/drizzle/0044_solid_mysterio.sql | 5 - packages/db/drizzle/meta/0044_snapshot.json | 2823 ----------------- packages/db/drizzle/meta/_journal.json | 7 - packages/db/src/data/thread-folders.ts | 107 +- packages/db/src/data/threads.ts | 9 +- packages/db/src/schema.ts | 11 +- packages/db/test/data/threads.test.ts | 152 +- packages/db/test/migrate.test.ts | 6 +- packages/server-contract/src/api/projects.ts | 4 - 11 files changed, 62 insertions(+), 3111 deletions(-) delete mode 100644 packages/db/drizzle/0044_solid_mysterio.sql delete mode 100644 packages/db/drizzle/meta/0044_snapshot.json diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index b91180604..54ab2bbff 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -1459,30 +1459,28 @@ function ProjectListComponent({ }, [navigate, onProjectSelect], ); - const [folderCreateTarget, setFolderCreateTarget] = useState<{ - projectId: string | null; - } | null>(null); + const [isFolderCreateDialogOpen, setIsFolderCreateDialogOpen] = + useState(false); const folderRenameDialog = useDialogState(); const folderDeleteDialog = useDialogState(); - const isFolderCreateDialogOpen = folderCreateTarget !== null; const handleOpenCreateFolderDialog = useCallback(() => { - setFolderCreateTarget({ projectId: null }); + setIsFolderCreateDialogOpen(true); }, []); const handleCreateFolderDialogOpenChange = useCallback((open: boolean) => { if (!open) { - setFolderCreateTarget(null); + setIsFolderCreateDialogOpen(false); } }, []); const handleCreateThreadFolder = useCallback( (path: string) => { createThreadFolderMutate( - { path, projectId: folderCreateTarget?.projectId ?? null }, + { path }, { - onSuccess: () => setFolderCreateTarget(null), + onSuccess: () => setIsFolderCreateDialogOpen(false), }, ); }, - [createThreadFolderMutate, folderCreateTarget], + [createThreadFolderMutate], ); const handleOpenRenameThreadFolder = useCallback( (path: string) => { @@ -1493,7 +1491,7 @@ function ProjectListComponent({ const handleRenameThreadFolder = useCallback( (path: string, newPath: string) => { updateThreadFolderMutate( - { path, newPath, projectId: null }, + { path, newPath }, { onSuccess: () => folderRenameDialog.onClose() }, ); }, @@ -1511,7 +1509,7 @@ function ProjectListComponent({ return; } deleteThreadFolderMutate( - { path, projectId: null }, + { path }, { onSuccess: () => folderDeleteDialog.onClose() }, ); }, [deleteThreadFolderMutate, folderDeleteDialog]); diff --git a/apps/server/src/routes/thread-folders.ts b/apps/server/src/routes/thread-folders.ts index 983df66a5..e13a43f2d 100644 --- a/apps/server/src/routes/thread-folders.ts +++ b/apps/server/src/routes/thread-folders.ts @@ -13,16 +13,6 @@ import { import type { Hono } from "hono"; import type { AppDeps } from "../types.js"; import { ApiError } from "../errors.js"; -import { requirePublicProject } from "../services/lib/entity-lookup.js"; - -function requireThreadFolderProjectScope( - db: AppDeps["db"], - projectId: string | null | undefined, -): void { - if (projectId !== undefined && projectId !== null) { - requirePublicProject(db, projectId); - } -} export function registerThreadFolderRoutes(app: Hono, deps: AppDeps): void { const { del, patch, post } = typedRoutes(app, { @@ -35,17 +25,7 @@ export function registerThreadFolderRoutes(app: Hono, deps: AppDeps): void { if (!path) { throw new ApiError(400, "invalid_request", "Folder name cannot be empty"); } - const projectId = payload.projectId ?? null; - if (projectId !== null) { - requirePublicProject(deps.db, projectId); - } - return context.json( - createThreadFolder(deps.db, deps.hub, { - path, - projectId, - }), - 201, - ); + return context.json(createThreadFolder(deps.db, deps.hub, { path }), 201); }); patch(routes.update, (context, payload) => { @@ -54,8 +34,6 @@ export function registerThreadFolderRoutes(app: Hono, deps: AppDeps): void { if (!path || !newPath) { throw new ApiError(400, "invalid_request", "Folder name cannot be empty"); } - const projectId = payload.projectId; - requireThreadFolderProjectScope(deps.db, projectId); if (isThreadFolderDescendantPath(path, newPath)) { throw new ApiError( 400, @@ -66,7 +44,6 @@ export function registerThreadFolderRoutes(app: Hono, deps: AppDeps): void { const result = renameThreadFolder(deps.db, deps.hub, { path, newPath, - projectId, }); if (!result) { throw new ApiError(404, "folder_not_found", "Folder not found"); @@ -79,9 +56,7 @@ export function registerThreadFolderRoutes(app: Hono, deps: AppDeps): void { if (!path) { throw new ApiError(400, "invalid_request", "Folder name cannot be empty"); } - const projectId = payload.projectId; - requireThreadFolderProjectScope(deps.db, projectId); - const result = deleteThreadFolder(deps.db, deps.hub, { path, projectId }); + const result = deleteThreadFolder(deps.db, deps.hub, { path }); if (!result) { throw new ApiError(404, "folder_not_found", "Folder not found"); } diff --git a/packages/db/drizzle/0044_solid_mysterio.sql b/packages/db/drizzle/0044_solid_mysterio.sql deleted file mode 100644 index 29b8342b8..000000000 --- a/packages/db/drizzle/0044_solid_mysterio.sql +++ /dev/null @@ -1,5 +0,0 @@ -DROP INDEX `thread_folders_path_idx`;--> statement-breakpoint -ALTER TABLE `thread_folders` ADD `project_id` text REFERENCES projects(id) ON DELETE cascade;--> statement-breakpoint -CREATE UNIQUE INDEX `thread_folders_global_path_idx` ON `thread_folders` (`path`) WHERE "thread_folders"."project_id" IS NULL;--> statement-breakpoint -CREATE UNIQUE INDEX `thread_folders_project_path_idx` ON `thread_folders` (`project_id`,`path`) WHERE "thread_folders"."project_id" IS NOT NULL;--> statement-breakpoint -CREATE INDEX `thread_folders_project_idx` ON `thread_folders` (`project_id`); diff --git a/packages/db/drizzle/meta/0044_snapshot.json b/packages/db/drizzle/meta/0044_snapshot.json deleted file mode 100644 index 7378e9bb6..000000000 --- a/packages/db/drizzle/meta/0044_snapshot.json +++ /dev/null @@ -1,2823 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "16d6dffb-2400-4fb6-b6c7-a17c9e224a9a", - "prevId": "e455b374-86f9-4bcf-93dc-d77a46b9fec0", - "tables": { - "apikey": { - "name": "apikey", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "start": { - "name": "start", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "referenceId": { - "name": "referenceId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "refillInterval": { - "name": "refillInterval", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "refillAmount": { - "name": "refillAmount", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "lastRefillAt": { - "name": "lastRefillAt", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "enabled": { - "name": "enabled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rateLimitEnabled": { - "name": "rateLimitEnabled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rateLimitTimeWindow": { - "name": "rateLimitTimeWindow", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rateLimitMax": { - "name": "rateLimitMax", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "requestCount": { - "name": "requestCount", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "remaining": { - "name": "remaining", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "lastRequest": { - "name": "lastRequest", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "expiresAt": { - "name": "expiresAt", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "permissions": { - "name": "permissions", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "configId": { - "name": "configId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "apikey_key_unique": { - "name": "apikey_key_unique", - "columns": [ - "key" - ], - "isUnique": true - }, - "apikey_reference_id_idx": { - "name": "apikey_reference_id_idx", - "columns": [ - "referenceId" - ], - "isUnique": false - }, - "apikey_config_id_idx": { - "name": "apikey_config_id_idx", - "columns": [ - "configId" - ], - "isUnique": false - } - }, - "foreignKeys": { - "apikey_referenceId_user_id_fk": { - "name": "apikey_referenceId_user_id_fk", - "tableFrom": "apikey", - "tableTo": "user", - "columnsFrom": [ - "referenceId" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "user": { - "name": "user", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "emailVerified": { - "name": "emailVerified", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "user_email_unique": { - "name": "user_email_unique", - "columns": [ - "email" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "automation_runs": { - "name": "automation_runs", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "automation_id": { - "name": "automation_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "run_mode": { - "name": "run_mode", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "trigger": { - "name": "trigger", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "skip_reason": { - "name": "skip_reason", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "output": { - "name": "output", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "exit_code": { - "name": "exit_code", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "idempotency_key": { - "name": "idempotency_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scheduled_for": { - "name": "scheduled_for", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "started_at": { - "name": "started_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "finished_at": { - "name": "finished_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "automation_runs_automation_started_idx": { - "name": "automation_runs_automation_started_idx", - "columns": [ - "automation_id", - "started_at" - ], - "isUnique": false - }, - "automation_runs_thread_idx": { - "name": "automation_runs_thread_idx", - "columns": [ - "thread_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "automation_runs_automation_id_automations_id_fk": { - "name": "automation_runs_automation_id_automations_id_fk", - "tableFrom": "automation_runs", - "tableTo": "automations", - "columnsFrom": [ - "automation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "automation_runs_thread_id_threads_id_fk": { - "name": "automation_runs_thread_id_threads_id_fk", - "tableFrom": "automation_runs", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "automations": { - "name": "automations", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_thread_id": { - "name": "target_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "enabled": { - "name": "enabled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "trigger_type": { - "name": "trigger_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "trigger_config": { - "name": "trigger_config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "run_mode": { - "name": "run_mode", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "execution": { - "name": "execution", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "environment": { - "name": "environment", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "auto_archive": { - "name": "auto_archive", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "origin": { - "name": "origin", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_by_thread_id": { - "name": "created_by_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "next_run_at": { - "name": "next_run_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_run_at": { - "name": "last_run_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "run_count": { - "name": "run_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "last_run_status": { - "name": "last_run_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_run_thread_id": { - "name": "last_run_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_error": { - "name": "last_error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "automations_project_idx": { - "name": "automations_project_idx", - "columns": [ - "project_id" - ], - "isUnique": false - }, - "automations_due_idx": { - "name": "automations_due_idx", - "columns": [ - "enabled", - "trigger_type", - "next_run_at" - ], - "isUnique": false - }, - "automations_target_thread_idx": { - "name": "automations_target_thread_idx", - "columns": [ - "target_thread_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "automations_project_id_projects_id_fk": { - "name": "automations_project_id_projects_id_fk", - "tableFrom": "automations", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "automations_target_thread_id_threads_id_fk": { - "name": "automations_target_thread_id_threads_id_fk", - "tableFrom": "automations", - "tableTo": "threads", - "columnsFrom": [ - "target_thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "environments": { - "name": "environments", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "host_id": { - "name": "host_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "managed": { - "name": "managed", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "is_git_repo": { - "name": "is_git_repo", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "is_worktree": { - "name": "is_worktree", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "base_branch": { - "name": "base_branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "default_branch": { - "name": "default_branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "merge_base_branch": { - "name": "merge_base_branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "destroy_attempt_id": { - "name": "destroy_attempt_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "workspace_provision_type": { - "name": "workspace_provision_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'provisioning'" - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "environments_host_path_idx": { - "name": "environments_host_path_idx", - "columns": [ - "host_id", - "path" - ], - "isUnique": true - }, - "environments_project_idx": { - "name": "environments_project_idx", - "columns": [ - "project_id" - ], - "isUnique": false - }, - "environments_status_idx": { - "name": "environments_status_idx", - "columns": [ - "status" - ], - "isUnique": false - } - }, - "foreignKeys": { - "environments_project_id_projects_id_fk": { - "name": "environments_project_id_projects_id_fk", - "tableFrom": "environments", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "environments_host_id_hosts_id_fk": { - "name": "environments_host_id_hosts_id_fk", - "tableFrom": "environments", - "tableTo": "hosts", - "columnsFrom": [ - "host_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "events": { - "name": "events", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "environment_id": { - "name": "environment_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope_kind": { - "name": "scope_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "turn_id": { - "name": "turn_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_thread_id": { - "name": "provider_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sequence": { - "name": "sequence", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "item_id": { - "name": "item_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "item_kind": { - "name": "item_kind", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "data": { - "name": "data", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'{}'" - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "events_thread_sequence_idx": { - "name": "events_thread_sequence_idx", - "columns": [ - "thread_id", - "sequence" - ], - "isUnique": true - }, - "events_thread_type_item_kind_sequence_idx": { - "name": "events_thread_type_item_kind_sequence_idx", - "columns": [ - "thread_id", - "type", - "item_kind", - "sequence" - ], - "isUnique": false - }, - "events_thread_type_sequence_idx": { - "name": "events_thread_type_sequence_idx", - "columns": [ - "thread_id", - "type", - "sequence" - ], - "isUnique": false - }, - "events_thread_turn_type_item_sequence_idx": { - "name": "events_thread_turn_type_item_sequence_idx", - "columns": [ - "thread_id", - "turn_id", - "type", - "item_id", - "sequence" - ], - "isUnique": false - }, - "events_environment_idx": { - "name": "events_environment_idx", - "columns": [ - "environment_id" - ], - "isUnique": false - }, - "events_completed_item_truncation_idx": { - "name": "events_completed_item_truncation_idx", - "columns": [ - "item_kind", - "created_at", - "id" - ], - "isUnique": false, - "where": "\"events\".\"type\" = 'item/completed'" - } - }, - "foreignKeys": { - "events_thread_id_threads_id_fk": { - "name": "events_thread_id_threads_id_fk", - "tableFrom": "events", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "events_environment_id_environments_id_fk": { - "name": "events_environment_id_environments_id_fk", - "tableFrom": "events", - "tableTo": "environments", - "columnsFrom": [ - "environment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": { - "events_scope_shape_check": { - "name": "events_scope_shape_check", - "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )" - } - } - }, - "host_daemon_sessions": { - "name": "host_daemon_sessions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "host_id": { - "name": "host_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "instance_id": { - "name": "instance_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "host_name": { - "name": "host_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "host_type": { - "name": "host_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "data_dir": { - "name": "data_dir", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "protocol_version": { - "name": "protocol_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "heartbeat_interval_ms": { - "name": "heartbeat_interval_ms", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "lease_timeout_ms": { - "name": "lease_timeout_ms", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "lease_expires_at": { - "name": "lease_expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "closed_at": { - "name": "closed_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "close_reason": { - "name": "close_reason", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "host_daemon_sessions_host_status_idx": { - "name": "host_daemon_sessions_host_status_idx", - "columns": [ - "host_id", - "status" - ], - "isUnique": false - }, - "host_daemon_sessions_host_latest_idx": { - "name": "host_daemon_sessions_host_latest_idx", - "columns": [ - "host_id", - "updated_at", - "created_at", - "id" - ], - "isUnique": false - }, - "host_daemon_sessions_closed_prune_idx": { - "name": "host_daemon_sessions_closed_prune_idx", - "columns": [ - "status", - "closed_at", - "id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "host_daemon_sessions_host_id_hosts_id_fk": { - "name": "host_daemon_sessions_host_id_hosts_id_fk", - "tableFrom": "host_daemon_sessions", - "tableTo": "hosts", - "columnsFrom": [ - "host_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "hosts": { - "name": "hosts", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "destroyed_at": { - "name": "destroyed_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_seen_at": { - "name": "last_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "hosts_last_seen_idx": { - "name": "hosts_last_seen_idx", - "columns": [ - "last_seen_at" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "maintenance_scan_cursors": { - "name": "maintenance_scan_cursors", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "policy": { - "name": "policy", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "item_kind": { - "name": "item_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "output_path": { - "name": "output_path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_created_at": { - "name": "last_created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "last_event_id": { - "name": "last_event_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "''" - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "maintenance_scan_cursors_path_idx": { - "name": "maintenance_scan_cursors_path_idx", - "columns": [ - "policy", - "version", - "item_kind", - "output_path" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "pending_interactions": { - "name": "pending_interactions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "turn_id": { - "name": "turn_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider_thread_id": { - "name": "provider_thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider_request_id": { - "name": "provider_request_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "payload": { - "name": "payload", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "resolution": { - "name": "resolution", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status_reason": { - "name": "status_reason", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "resolved_at": { - "name": "resolved_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "pending_interactions_provider_request_idx": { - "name": "pending_interactions_provider_request_idx", - "columns": [ - "provider_id", - "provider_thread_id", - "provider_request_id" - ], - "isUnique": true - }, - "pending_interactions_thread_created_idx": { - "name": "pending_interactions_thread_created_idx", - "columns": [ - "thread_id", - "created_at" - ], - "isUnique": false - }, - "pending_interactions_thread_status_created_idx": { - "name": "pending_interactions_thread_status_created_idx", - "columns": [ - "thread_id", - "status", - "created_at" - ], - "isUnique": false - }, - "pending_interactions_status_created_idx": { - "name": "pending_interactions_status_created_idx", - "columns": [ - "status", - "created_at" - ], - "isUnique": false - } - }, - "foreignKeys": { - "pending_interactions_thread_id_threads_id_fk": { - "name": "pending_interactions_thread_id_threads_id_fk", - "tableFrom": "pending_interactions", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "project_execution_defaults": { - "name": "project_execution_defaults", - "columns": { - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "model": { - "name": "model", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "service_tier": { - "name": "service_tier", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "reasoning_level": { - "name": "reasoning_level", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "permission_mode": { - "name": "permission_mode", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "project_execution_defaults_project_idx": { - "name": "project_execution_defaults_project_idx", - "columns": [ - "project_id" - ], - "isUnique": true - } - }, - "foreignKeys": { - "project_execution_defaults_project_id_projects_id_fk": { - "name": "project_execution_defaults_project_id_projects_id_fk", - "tableFrom": "project_execution_defaults", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "project_sources": { - "name": "project_sources", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "host_id": { - "name": "host_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_default": { - "name": "is_default", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "project_sources_project_idx": { - "name": "project_sources_project_idx", - "columns": [ - "project_id" - ], - "isUnique": false - }, - "project_sources_host_idx": { - "name": "project_sources_host_idx", - "columns": [ - "host_id" - ], - "isUnique": false - }, - "project_sources_project_host_idx": { - "name": "project_sources_project_host_idx", - "columns": [ - "project_id", - "host_id" - ], - "isUnique": true - } - }, - "foreignKeys": { - "project_sources_project_id_projects_id_fk": { - "name": "project_sources_project_id_projects_id_fk", - "tableFrom": "project_sources", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_sources_host_id_hosts_id_fk": { - "name": "project_sources_host_id_hosts_id_fk", - "tableFrom": "project_sources", - "tableTo": "hosts", - "columnsFrom": [ - "host_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": { - "project_sources_shape_check": { - "name": "project_sources_shape_check", - "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )" - } - } - }, - "projects": { - "name": "projects", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'standard'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sort_key": { - "name": "sort_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'V'" - }, - "deleted_at": { - "name": "deleted_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "projects_updated_idx": { - "name": "projects_updated_idx", - "columns": [ - "updated_at" - ], - "isUnique": false - }, - "projects_deleted_idx": { - "name": "projects_deleted_idx", - "columns": [ - "deleted_at" - ], - "isUnique": false - }, - "projects_sort_idx": { - "name": "projects_sort_idx", - "columns": [ - "sort_key", - "id" - ], - "isUnique": false - }, - "projects_personal_singleton_idx": { - "name": "projects_personal_singleton_idx", - "columns": [ - "kind" - ], - "isUnique": true, - "where": "\"projects\".\"kind\" = 'personal'" - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "prompt_history_entries": { - "name": "prompt_history_entries", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "request_sequence": { - "name": "request_sequence", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "input": { - "name": "input", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "prompt_history_entries_thread_request_idx": { - "name": "prompt_history_entries_thread_request_idx", - "columns": [ - "thread_id", - "request_sequence" - ], - "isUnique": true - }, - "prompt_history_entries_project_scope_created_idx": { - "name": "prompt_history_entries_project_scope_created_idx", - "columns": [ - "project_id", - "scope", - "created_at", - "request_sequence", - "id" - ], - "isUnique": false - }, - "prompt_history_entries_thread_scope_created_idx": { - "name": "prompt_history_entries_thread_scope_created_idx", - "columns": [ - "thread_id", - "scope", - "created_at", - "request_sequence", - "id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "prompt_history_entries_project_id_projects_id_fk": { - "name": "prompt_history_entries_project_id_projects_id_fk", - "tableFrom": "prompt_history_entries", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "prompt_history_entries_thread_id_threads_id_fk": { - "name": "prompt_history_entries_thread_id_threads_id_fk", - "tableFrom": "prompt_history_entries", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "queued_thread_messages": { - "name": "queued_thread_messages", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sender_thread_id": { - "name": "sender_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "model": { - "name": "model", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "reasoning_level": { - "name": "reasoning_level", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "permission_mode": { - "name": "permission_mode", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "service_tier": { - "name": "service_tier", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "claimed_at": { - "name": "claimed_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "claim_token": { - "name": "claim_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_key": { - "name": "sort_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "queued_thread_messages_thread_created_idx": { - "name": "queued_thread_messages_thread_created_idx", - "columns": [ - "thread_id", - "created_at", - "id" - ], - "isUnique": false - }, - "queued_thread_messages_thread_sort_idx": { - "name": "queued_thread_messages_thread_sort_idx", - "columns": [ - "thread_id", - "sort_key", - "id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "queued_thread_messages_thread_id_threads_id_fk": { - "name": "queued_thread_messages_thread_id_threads_id_fk", - "tableFrom": "queued_thread_messages", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "system_experiments": { - "name": "system_experiments", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "claude_code_mock_cli_traffic": { - "name": "claude_code_mock_cli_traffic", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "popout_chat": { - "name": "popout_chat", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "popout_chat_hotkey": { - "name": "popout_chat_hotkey", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "terminal_sessions": { - "name": "terminal_sessions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "environment_id": { - "name": "environment_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "host_id": { - "name": "host_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "daemon_session_id": { - "name": "daemon_session_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "initial_cwd": { - "name": "initial_cwd", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "cols": { - "name": "cols", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rows": { - "name": "rows", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "exit_code": { - "name": "exit_code", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "close_reason": { - "name": "close_reason", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_user_input_at": { - "name": "last_user_input_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "terminal_sessions_thread_status_updated_idx": { - "name": "terminal_sessions_thread_status_updated_idx", - "columns": [ - "thread_id", - "status", - "updated_at" - ], - "isUnique": false - }, - "terminal_sessions_environment_status_idx": { - "name": "terminal_sessions_environment_status_idx", - "columns": [ - "environment_id", - "status" - ], - "isUnique": false - }, - "terminal_sessions_host_status_idx": { - "name": "terminal_sessions_host_status_idx", - "columns": [ - "host_id", - "status" - ], - "isUnique": false - }, - "terminal_sessions_daemon_session_idx": { - "name": "terminal_sessions_daemon_session_idx", - "columns": [ - "daemon_session_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "terminal_sessions_thread_id_threads_id_fk": { - "name": "terminal_sessions_thread_id_threads_id_fk", - "tableFrom": "terminal_sessions", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "terminal_sessions_environment_id_environments_id_fk": { - "name": "terminal_sessions_environment_id_environments_id_fk", - "tableFrom": "terminal_sessions", - "tableTo": "environments", - "columnsFrom": [ - "environment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "terminal_sessions_host_id_hosts_id_fk": { - "name": "terminal_sessions_host_id_hosts_id_fk", - "tableFrom": "terminal_sessions", - "tableTo": "hosts", - "columnsFrom": [ - "host_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { - "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", - "tableFrom": "terminal_sessions", - "tableTo": "host_daemon_sessions", - "columnsFrom": [ - "daemon_session_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "thread_dynamic_context_file_states": { - "name": "thread_dynamic_context_file_states", - "columns": { - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "file_key": { - "name": "file_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "content_status": { - "name": "content_status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "content_hash": { - "name": "content_hash", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "shown_at": { - "name": "shown_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "thread_dynamic_context_file_states_thread_file_idx": { - "name": "thread_dynamic_context_file_states_thread_file_idx", - "columns": [ - "thread_id", - "file_key" - ], - "isUnique": true - } - }, - "foreignKeys": { - "thread_dynamic_context_file_states_thread_id_threads_id_fk": { - "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk", - "tableFrom": "thread_dynamic_context_file_states", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "thread_folders": { - "name": "thread_folders", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "thread_folders_global_path_idx": { - "name": "thread_folders_global_path_idx", - "columns": [ - "path" - ], - "isUnique": true, - "where": "\"thread_folders\".\"project_id\" IS NULL" - }, - "thread_folders_project_path_idx": { - "name": "thread_folders_project_path_idx", - "columns": [ - "project_id", - "path" - ], - "isUnique": true, - "where": "\"thread_folders\".\"project_id\" IS NOT NULL" - }, - "thread_folders_project_idx": { - "name": "thread_folders_project_idx", - "columns": [ - "project_id" - ], - "isUnique": false - }, - "thread_folders_updated_idx": { - "name": "thread_folders_updated_idx", - "columns": [ - "updated_at" - ], - "isUnique": false - } - }, - "foreignKeys": { - "thread_folders_project_id_projects_id_fk": { - "name": "thread_folders_project_id_projects_id_fk", - "tableFrom": "thread_folders", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "thread_search_segments": { - "name": "thread_search_segments", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_key": { - "name": "source_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_seq": { - "name": "source_seq", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "text": { - "name": "text", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "thread_search_segments_source_idx": { - "name": "thread_search_segments_source_idx", - "columns": [ - "thread_id", - "source_kind", - "source_key" - ], - "isUnique": true - }, - "thread_search_segments_thread_idx": { - "name": "thread_search_segments_thread_idx", - "columns": [ - "thread_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "thread_search_segments_thread_id_threads_id_fk": { - "name": "thread_search_segments_thread_id_threads_id_fk", - "tableFrom": "thread_search_segments", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "threads": { - "name": "threads", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "environment_id": { - "name": "environment_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "model_override": { - "name": "model_override", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "reasoning_level_override": { - "name": "reasoning_level_override", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "title_fallback": { - "name": "title_fallback", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "folder_path": { - "name": "folder_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'starting'" - }, - "parent_thread_id": { - "name": "parent_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_thread_id": { - "name": "source_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "origin_kind": { - "name": "origin_kind", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "child_origin": { - "name": "child_origin", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "archived_at": { - "name": "archived_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pinned_at": { - "name": "pinned_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pin_sort_key": { - "name": "pin_sort_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_read_at": { - "name": "last_read_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "latest_attention_at": { - "name": "latest_attention_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "threads_project_updated_idx": { - "name": "threads_project_updated_idx", - "columns": [ - "project_id", - "updated_at" - ], - "isUnique": false - }, - "threads_project_archived_deleted_idx": { - "name": "threads_project_archived_deleted_idx", - "columns": [ - "project_id", - "archived_at", - "deleted_at", - "id" - ], - "isUnique": false - }, - "threads_pin_sort_idx": { - "name": "threads_pin_sort_idx", - "columns": [ - "archived_at", - "deleted_at", - "pin_sort_key", - "id" - ], - "isUnique": false, - "where": "\"threads\".\"pinned_at\" IS NOT NULL" - }, - "threads_environment_idx": { - "name": "threads_environment_idx", - "columns": [ - "environment_id" - ], - "isUnique": false - }, - "threads_parent_idx": { - "name": "threads_parent_idx", - "columns": [ - "parent_thread_id" - ], - "isUnique": false - }, - "threads_source_origin_idx": { - "name": "threads_source_origin_idx", - "columns": [ - "source_thread_id", - "origin_kind" - ], - "isUnique": false - }, - "threads_archived_status_idx": { - "name": "threads_archived_status_idx", - "columns": [ - "archived_at", - "status" - ], - "isUnique": false - }, - "threads_environment_archived_deleted_idx": { - "name": "threads_environment_archived_deleted_idx", - "columns": [ - "environment_id", - "archived_at", - "deleted_at" - ], - "isUnique": false - }, - "threads_active_maintenance_idx": { - "name": "threads_active_maintenance_idx", - "columns": [ - "status" - ], - "isUnique": false, - "where": "\"threads\".\"deleted_at\" IS NULL" - } - }, - "foreignKeys": { - "threads_project_id_projects_id_fk": { - "name": "threads_project_id_projects_id_fk", - "tableFrom": "threads", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "threads_environment_id_environments_id_fk": { - "name": "threads_environment_id_environments_id_fk", - "tableFrom": "threads", - "tableTo": "environments", - "columnsFrom": [ - "environment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "threads_parent_thread_id_threads_id_fk": { - "name": "threads_parent_thread_id_threads_id_fk", - "tableFrom": "threads", - "tableTo": "threads", - "columnsFrom": [ - "parent_thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "threads_source_thread_id_threads_id_fk": { - "name": "threads_source_thread_id_threads_id_fk", - "tableFrom": "threads", - "tableTo": "threads", - "columnsFrom": [ - "source_thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index c361ab1f8..d007f1d99 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -309,13 +309,6 @@ "when": 1781851451425, "tag": "0043_unique_phantom_reporter", "breakpoints": true - }, - { - "idx": 44, - "version": "6", - "when": 1781856789567, - "tag": "0044_solid_mysterio", - "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/thread-folders.ts b/packages/db/src/data/thread-folders.ts index fc2e932a0..1a39d541d 100644 --- a/packages/db/src/data/thread-folders.ts +++ b/packages/db/src/data/thread-folders.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, isNull, or, sql } from "drizzle-orm"; +import { asc, eq, or, sql } from "drizzle-orm"; import { PERSONAL_PROJECT_ID } from "@bb/domain"; import type { DbConnection, @@ -15,18 +15,15 @@ export type ThreadFolderRow = typeof threadFolders.$inferSelect; export interface CreateThreadFolderInput { path: string; - projectId?: string | null; } export interface RenameThreadFolderInput { path: string; newPath: string; - projectId?: string | null; } export interface DeleteThreadFolderInput { path: string; - projectId?: string | null; } export interface ThreadFolderMutationResult { @@ -67,22 +64,6 @@ function folderPathSubtreeFilter( ); } -function folderProjectScopeFilter(projectId: string | null | undefined) { - if (projectId === undefined) { - return undefined; - } - return projectId === null - ? isNull(threadFolders.projectId) - : eq(threadFolders.projectId, projectId); -} - -function threadProjectScopeFilter(projectId: string | null | undefined) { - if (projectId === undefined) { - return undefined; - } - return projectId === null ? sql`1 = 0` : eq(threads.projectId, projectId); -} - function replaceFolderPathPrefix( value: string, oldPath: string, @@ -121,21 +102,16 @@ export function isThreadFolderDescendantPath( export function getThreadFolderByPath( db: DbQueryConnection, path: string, - projectId: string | null = null, ): ThreadFolderRow | null { const normalized = normalizeThreadFolderPath(path); if (!normalized) { return null; } - const projectFilter = - projectId === null - ? isNull(threadFolders.projectId) - : eq(threadFolders.projectId, projectId); return ( db .select() .from(threadFolders) - .where(and(eq(threadFolders.path, normalized), projectFilter)) + .where(eq(threadFolders.path, normalized)) .get() ?? null ); } @@ -144,11 +120,7 @@ export function listThreadFolders(db: DbQueryConnection): ThreadFolderRow[] { return db .select() .from(threadFolders) - .orderBy( - asc(threadFolders.projectId), - asc(threadFolders.path), - asc(threadFolders.id), - ) + .orderBy(asc(threadFolders.path), asc(threadFolders.id)) .all(); } @@ -156,13 +128,11 @@ export function ensureThreadFolderPath( db: ThreadFolderWriteConnection, notifier: DbNotifier, path: string | null | undefined, - projectId: string | null = null, ): ThreadFolderRow | null { const normalized = normalizeThreadFolderPath(path); if (!normalized) { return null; } - const scopedProjectId = projectId ?? null; const now = Date.now(); let createdAny = false; @@ -173,7 +143,6 @@ export function ensureThreadFolderPath( .insert(threadFolders) .values({ id: createThreadFolderId(), - projectId: scopedProjectId, path: ancestorPath, createdAt: now, updatedAt: now, @@ -186,13 +155,11 @@ export function ensureThreadFolderPath( deepest = inserted; continue; } - deepest = getThreadFolderByPath(db, ancestorPath, scopedProjectId); + deepest = getThreadFolderByPath(db, ancestorPath); } if (createdAny) { - notifier.notifyProject(scopedProjectId ?? PERSONAL_PROJECT_ID, [ - "threads-changed", - ]); + notifier.notifyProject(PERSONAL_PROJECT_ID, ["threads-changed"]); } return deepest; } @@ -202,12 +169,7 @@ export function createThreadFolder( notifier: DbNotifier, input: CreateThreadFolderInput, ): ThreadFolderRow { - const folder = ensureThreadFolderPath( - db, - notifier, - input.path, - input.projectId ?? null, - ); + const folder = ensureThreadFolderPath(db, notifier, input.path); if (!folder) { throw new Error("Thread folder path cannot be empty"); } @@ -236,12 +198,7 @@ export function renameThreadFolder( const matchingFolders = tx .select() .from(threadFolders) - .where( - and( - folderPathSubtreeFilter(threadFolders.path, path), - folderProjectScopeFilter(input.projectId), - ), - ) + .where(folderPathSubtreeFilter(threadFolders.path, path)) .all(); const matchingThreads = tx .select({ @@ -250,12 +207,7 @@ export function renameThreadFolder( folderPath: threads.folderPath, }) .from(threads) - .where( - and( - folderPathSubtreeFilter(threads.folderPath, path), - threadProjectScopeFilter(input.projectId), - ), - ) + .where(folderPathSubtreeFilter(threads.folderPath, path)) .all(); if (matchingFolders.length === 0 && matchingThreads.length === 0) { @@ -263,22 +215,18 @@ export function renameThreadFolder( } tx.delete(threadFolders) - .where( - and( - folderPathSubtreeFilter(threadFolders.path, path), - folderProjectScopeFilter(input.projectId), - ), - ) + .where(folderPathSubtreeFilter(threadFolders.path, path)) .run(); - const affectedProjects = new Set(); + // Folders are a single global namespace keyed by path, so the global + // folder list (PERSONAL_PROJECT_ID) always refreshes; each moved thread's + // own project refreshes too. + const affectedProjects = new Set([null]); for (const folder of matchingFolders) { - affectedProjects.add(folder.projectId); ensureThreadFolderPath( tx, notifier, replaceFolderPathPrefix(folder.path, path, newPath), - folder.projectId, ); } @@ -293,7 +241,7 @@ export function renameThreadFolder( newPath, ); affectedProjects.add(thread.projectId); - ensureThreadFolderPath(tx, notifier, nextFolderPath, thread.projectId); + ensureThreadFolderPath(tx, notifier, nextFolderPath); tx.update(threads) .set({ folderPath: nextFolderPath, updatedAt: now }) .where(eq(threads.id, thread.id)) @@ -325,12 +273,7 @@ export function deleteThreadFolder( const matchingFolders = tx .select() .from(threadFolders) - .where( - and( - folderPathSubtreeFilter(threadFolders.path, path), - folderProjectScopeFilter(input.projectId), - ), - ) + .where(folderPathSubtreeFilter(threadFolders.path, path)) .all(); const matchingThreads = tx .select({ @@ -338,12 +281,7 @@ export function deleteThreadFolder( projectId: threads.projectId, }) .from(threads) - .where( - and( - folderPathSubtreeFilter(threads.folderPath, path), - threadProjectScopeFilter(input.projectId), - ), - ) + .where(folderPathSubtreeFilter(threads.folderPath, path)) .all(); if (matchingFolders.length === 0 && matchingThreads.length === 0) { @@ -351,18 +289,13 @@ export function deleteThreadFolder( } tx.delete(threadFolders) - .where( - and( - folderPathSubtreeFilter(threadFolders.path, path), - folderProjectScopeFilter(input.projectId), - ), - ) + .where(folderPathSubtreeFilter(threadFolders.path, path)) .run(); const now = Date.now(); - const affectedProjects = new Set( - matchingFolders.map((folder) => folder.projectId), - ); + // The global folder list (PERSONAL_PROJECT_ID) always refreshes; each + // cleared thread's own project refreshes too. + const affectedProjects = new Set([null]); for (const thread of matchingThreads) { affectedProjects.add(thread.projectId); tx.update(threads) diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index 507f5c570..6acd2151a 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -288,12 +288,7 @@ export function createThread( }) .returning() .get(); - ensureThreadFolderPath( - tx, - notifier, - createdThread.folderPath, - createdThread.projectId, - ); + ensureThreadFolderPath(tx, notifier, createdThread.folderPath); upsertThreadTitleSearchSegments(tx, { threadId: createdThread.id, title: createdThread.title, @@ -1559,7 +1554,7 @@ export function updateThread( const set: Partial = { updatedAt: now }; if ("title" in input) set.title = input.title; if ("folderPath" in input) { - ensureThreadFolderPath(db, notifier, input.folderPath, existing.projectId); + ensureThreadFolderPath(db, notifier, input.folderPath); set.folderPath = input.folderPath; } if ("environmentId" in input) set.environmentId = input.environmentId; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index e20d0d8b4..db3b76fc7 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -311,21 +311,12 @@ export const threadFolders = sqliteTable( "thread_folders", { id: text("id").primaryKey(), - projectId: text("project_id").references(() => projects.id, { - onDelete: "cascade", - }), path: text("path").notNull(), createdAt: integer("created_at").notNull(), updatedAt: integer("updated_at").notNull(), }, (table) => [ - uniqueIndex("thread_folders_global_path_idx") - .on(table.path) - .where(sql`${table.projectId} IS NULL`), - uniqueIndex("thread_folders_project_path_idx") - .on(table.projectId, table.path) - .where(sql`${table.projectId} IS NOT NULL`), - index("thread_folders_project_idx").on(table.projectId), + uniqueIndex("thread_folders_path_idx").on(table.path), index("thread_folders_updated_idx").on(table.updatedAt), ], ); diff --git a/packages/db/test/data/threads.test.ts b/packages/db/test/data/threads.test.ts index dca8cd6ae..d910ace57 100644 --- a/packages/db/test/data/threads.test.ts +++ b/packages/db/test/data/threads.test.ts @@ -694,14 +694,9 @@ describe("threads", () => { }); expect(updated?.folderPath).toBe("Work/Q3"); - expect( - listThreadFolders(db).map((folder) => ({ - path: folder.path, - projectId: folder.projectId, - })), - ).toEqual([ - { path: "Work", projectId: project.id }, - { path: "Work/Q3", projectId: project.id }, + expect(listThreadFolders(db).map((folder) => folder.path)).toEqual([ + "Work", + "Work/Q3", ]); expect(spy.notifyThread).toHaveBeenCalledWith( thread.id, @@ -722,45 +717,9 @@ describe("threads", () => { "Work", "Work/Q3", ]); - expect(folder.projectId).toBeNull(); }); - it("scopes explicit thread folders by project", () => { - const { db, host, project } = setup(); - const { project: otherProject } = createProject(db, noopNotifier, { - name: "other-project", - source: { type: "local_path", hostId: host.id, path: "/tmp/other" }, - }); - - const first = createThreadFolder(db, noopNotifier, { - path: "Work", - projectId: project.id, - }); - const second = createThreadFolder(db, noopNotifier, { - path: "Work", - projectId: otherProject.id, - }); - - expect(first.projectId).toBe(project.id); - expect(second.projectId).toBe(otherProject.id); - const folders = listThreadFolders(db) - .map((entry) => ({ - path: entry.path, - projectId: entry.projectId, - })) - .sort((left, right) => - (left.projectId ?? "").localeCompare(right.projectId ?? ""), - ); - const expectedFolders = [ - { path: "Work", projectId: project.id }, - { path: "Work", projectId: otherProject.id }, - ].sort((left, right) => - (left.projectId ?? "").localeCompare(right.projectId ?? ""), - ); - expect(folders).toEqual(expectedFolders); - }); - - it("renames thread folders and moves descendant threads", () => { + it("renames thread folders and moves descendant threads across projects", () => { const { db, host, project } = setup(); const { project: otherProject } = createProject(db, noopNotifier, { name: "other-project", @@ -793,30 +752,11 @@ describe("threads", () => { expect(result).toEqual({ path: "Archive", updatedThreadCount: 2 }); expect(getThread(db, firstThread.id)?.folderPath).toBe("Archive/Q3"); expect(getThread(db, secondThread.id)?.folderPath).toBe("Archive"); - expect( - listThreadFolders(db) - .map((folder) => ({ - path: folder.path, - projectId: folder.projectId, - })) - .sort((left, right) => - `${left.projectId ?? ""}:${left.path}`.localeCompare( - `${right.projectId ?? ""}:${right.path}`, - ), - ), - ).toEqual( - [ - { path: "Archive", projectId: null }, - { path: "Archive/Empty", projectId: null }, - { path: "Archive", projectId: project.id }, - { path: "Archive/Q3", projectId: project.id }, - { path: "Archive", projectId: otherProject.id }, - ].sort((left, right) => - `${left.projectId ?? ""}:${left.path}`.localeCompare( - `${right.projectId ?? ""}:${right.path}`, - ), - ), - ); + expect(listThreadFolders(db).map((folder) => folder.path).sort()).toEqual([ + "Archive", + "Archive/Empty", + "Archive/Q3", + ]); expect(spy.notifyThread).toHaveBeenCalledWith( firstThread.id, ["title-changed"], @@ -829,7 +769,7 @@ describe("threads", () => { ); }); - it("renames only one project folder scope when projectId is supplied", () => { + it("renames a folder for threads across every project", () => { const { db, host, project } = setup(); const { project: otherProject } = createProject(db, noopNotifier, { name: "other-project", @@ -845,42 +785,19 @@ describe("threads", () => { providerId: "codex", folderPath: "Work/Q3", }); - createThreadFolder(db, noopNotifier, { path: "Work/Global" }); const result = renameThreadFolder(db, noopNotifier, { path: "Work", newPath: "Archive", - projectId: project.id, }); - expect(result).toEqual({ path: "Archive", updatedThreadCount: 1 }); + expect(result).toEqual({ path: "Archive", updatedThreadCount: 2 }); expect(getThread(db, projectThread.id)?.folderPath).toBe("Archive/Q3"); - expect(getThread(db, otherProjectThread.id)?.folderPath).toBe("Work/Q3"); - expect( - listThreadFolders(db) - .map((folder) => ({ - path: folder.path, - projectId: folder.projectId, - })) - .sort((left, right) => - `${left.projectId ?? ""}:${left.path}`.localeCompare( - `${right.projectId ?? ""}:${right.path}`, - ), - ), - ).toEqual( - [ - { path: "Work", projectId: null }, - { path: "Work/Global", projectId: null }, - { path: "Archive", projectId: project.id }, - { path: "Archive/Q3", projectId: project.id }, - { path: "Work", projectId: otherProject.id }, - { path: "Work/Q3", projectId: otherProject.id }, - ].sort((left, right) => - `${left.projectId ?? ""}:${left.path}`.localeCompare( - `${right.projectId ?? ""}:${right.path}`, - ), - ), - ); + expect(getThread(db, otherProjectThread.id)?.folderPath).toBe("Archive/Q3"); + expect(listThreadFolders(db).map((folder) => folder.path).sort()).toEqual([ + "Archive", + "Archive/Q3", + ]); }); it("does not rename a thread folder into its own descendant", () => { @@ -891,10 +808,7 @@ describe("threads", () => { folderPath: "Work/Q3", }); createThreadFolder(db, noopNotifier, { path: "Work/Empty" }); - const foldersBefore = listThreadFolders(db).map((folder) => ({ - path: folder.path, - projectId: folder.projectId, - })); + const foldersBefore = listThreadFolders(db).map((folder) => folder.path); const result = renameThreadFolder(db, noopNotifier, { path: "Work", @@ -903,12 +817,9 @@ describe("threads", () => { expect(result).toBeNull(); expect(getThread(db, thread.id)?.folderPath).toBe("Work/Q3"); - expect( - listThreadFolders(db).map((folder) => ({ - path: folder.path, - projectId: folder.projectId, - })), - ).toEqual(foldersBefore); + expect(listThreadFolders(db).map((folder) => folder.path)).toEqual( + foldersBefore, + ); }); it("removes thread folders and clears descendant thread folder paths", () => { @@ -927,7 +838,7 @@ describe("threads", () => { expect(listThreadFolders(db)).toEqual([]); }); - it("removes only one project folder scope when projectId is supplied", () => { + it("removes a folder for threads across every project", () => { const { db, host, project } = setup(); const { project: otherProject } = createProject(db, noopNotifier, { name: "other-project", @@ -945,25 +856,12 @@ describe("threads", () => { }); createThreadFolder(db, noopNotifier, { path: "Work/Global" }); - const result = deleteThreadFolder(db, noopNotifier, { - path: "Work", - projectId: project.id, - }); + const result = deleteThreadFolder(db, noopNotifier, { path: "Work" }); - expect(result).toEqual({ path: "Work", updatedThreadCount: 1 }); + expect(result).toEqual({ path: "Work", updatedThreadCount: 2 }); expect(getThread(db, projectThread.id)?.folderPath).toBeNull(); - expect(getThread(db, otherProjectThread.id)?.folderPath).toBe("Work/Q3"); - expect( - listThreadFolders(db).map((folder) => ({ - path: folder.path, - projectId: folder.projectId, - })), - ).toEqual([ - { path: "Work", projectId: null }, - { path: "Work/Global", projectId: null }, - { path: "Work", projectId: otherProject.id }, - { path: "Work/Q3", projectId: otherProject.id }, - ]); + expect(getThread(db, otherProjectThread.id)?.folderPath).toBeNull(); + expect(listThreadFolders(db)).toEqual([]); }); it("notifies when a thread parent changes", () => { diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 97b2fd162..8627f48af 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -389,9 +389,9 @@ function dropPost0023Tables(db: DbConnection): void { /** * Folder schema lands after thread-search (0042 folder_path column, 0043 - * thread_folders table, 0044 project scoping). Replay scenarios that rewind the - * ledger past it must drop the schema too, or migrate() re-runs the ADD/CREATE - * against a DB that already has them. + * thread_folders table). Replay scenarios that rewind the ledger past it must + * drop the schema too, or migrate() re-runs the ADD/CREATE against a DB that + * already has them. */ function dropThreadFolderSchema(db: DbConnection): void { db.$client.exec("DROP TABLE IF EXISTS thread_folders;"); diff --git a/packages/server-contract/src/api/projects.ts b/packages/server-contract/src/api/projects.ts index 668a66ba1..2cece0319 100644 --- a/packages/server-contract/src/api/projects.ts +++ b/packages/server-contract/src/api/projects.ts @@ -57,7 +57,6 @@ export type CreateProjectRequest = z.infer; export const threadFolderSchema = z .object({ id: z.string(), - projectId: z.string().min(1).nullable(), path: z.string().min(1), createdAt: z.number(), updatedAt: z.number(), @@ -68,7 +67,6 @@ export type ThreadFolderResponse = z.infer; export const createThreadFolderRequestSchema = z .object({ path: z.string().min(1), - projectId: z.string().min(1).nullable().optional(), }) .strict(); export type CreateThreadFolderRequest = z.infer< @@ -79,7 +77,6 @@ export const updateThreadFolderRequestSchema = z .object({ path: z.string().min(1), newPath: z.string().min(1), - projectId: z.string().min(1).nullable().optional(), }) .strict(); export type UpdateThreadFolderRequest = z.infer< @@ -89,7 +86,6 @@ export type UpdateThreadFolderRequest = z.infer< export const deleteThreadFolderRequestSchema = z .object({ path: z.string().min(1), - projectId: z.string().min(1).nullable().optional(), }) .strict(); export type DeleteThreadFolderRequest = z.infer< From a2fa0ef2a3ea3f32b96f15d2135149bb5bff2fae Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 20 Jun 2026 04:52:11 -0700 Subject: [PATCH 51/54] Polish sidebar, search, and thread-menu UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar menus: rename Organize→Group by, drop Group from the loose Threads header, narrow the Group/Sort menus and style their titles as section labels. - Add icons + grouping dividers to all ... menus (thread/project/folder/ threads-section); envelope icon for the read toggle; "Mark read/unread" and "View archive" labels. - Tooltips: keep only sort, group, and the new project/folder/thread buttons; remove the rest (incl. native title tooltips) and dismiss on pointer leave. - Drag affordances: unify the folder/loose drop-preview into one inserted placeholder; grab cursor on draggable threads. - Cursors: pointer for clickable rows by default; project/folder header rows are static (caret is the click target); pointer on carets/top buttons. - Carets get the standard sidebar hover style. - Search: title-first results with a labeled 2-line message snippet, "Recent" empty state showing project + relative time; drop the per-row glyph and the Archived pill. - Subtle empty-state text/icons; lighter selection token; lighter user-message bubble border; full-width Queued header toggle. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/app/.ladle/components.tsx | 2 +- .../components/project/ProjectActionsMenu.tsx | 85 ++++--- .../promptbox/banner/QueuedMessagesList.tsx | 9 +- .../app/src/components/sidebar/AppSidebar.tsx | 9 +- .../src/components/sidebar/ProjectList.tsx | 203 +++++++--------- .../sidebar/ProjectListProjects.tsx | 4 +- .../app/src/components/sidebar/ProjectRow.tsx | 227 +++++++++--------- .../sidebar/SidebarChildToggleChevron.tsx | 2 +- .../sidebar/SidebarFolderRow.stories.tsx | 12 +- .../components/sidebar/SidebarFolderRow.tsx | 63 +++-- .../SidebarHistoryNavigationControls.tsx | 1 - .../sidebar/SidebarThreadSearchPanel.tsx | 11 +- .../SidebarViewOptionsMenu.stories.tsx | 17 +- apps/app/src/components/sidebar/ThreadRow.tsx | 24 +- .../sidebar/ThreadSearchResultRow.tsx | 81 ++++--- .../components/sidebar/sidebarRowClasses.ts | 11 +- .../thread/ThreadActionsMenu.test.tsx | 6 +- .../components/thread/ThreadActionsMenu.tsx | 48 +++- .../timeline/ConversationMessageContent.tsx | 2 +- apps/app/src/components/ui/icon.tsx | 8 + apps/app/src/components/ui/sidebar.tsx | 10 +- 21 files changed, 434 insertions(+), 401 deletions(-) diff --git a/apps/app/.ladle/components.tsx b/apps/app/.ladle/components.tsx index de4979a95..de4ffc1d9 100644 --- a/apps/app/.ladle/components.tsx +++ b/apps/app/.ladle/components.tsx @@ -66,7 +66,7 @@ export const Provider: GlobalProvider = ({ globalState, children }) => { }} highlighterOptions={{}} > - +
{children} diff --git a/apps/app/src/components/project/ProjectActionsMenu.tsx b/apps/app/src/components/project/ProjectActionsMenu.tsx index cf8665a57..ebd6f141f 100644 --- a/apps/app/src/components/project/ProjectActionsMenu.tsx +++ b/apps/app/src/components/project/ProjectActionsMenu.tsx @@ -3,7 +3,7 @@ import type { ProjectResponse } from "@bb/server-contract"; import type { ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button.js"; -import { Icon } from "@/components/ui/icon.js"; +import { Icon, type IconName } from "@/components/ui/icon.js"; import { COARSE_POINTER_ICON_SIZE_CLASS } from "@/components/ui/coarse-pointer-sizing.js"; import { ContextMenu, @@ -40,6 +40,8 @@ interface ProjectActionsMenuProps extends ProjectActionsMenuBaseProps { triggerClassName?: string; align?: "start" | "center" | "end"; onOpenChange?: (open: boolean) => void; + /** Suppress the trigger's hover tooltip (the sidebar keeps tooltips minimal). */ + hideTriggerTooltip?: boolean; } interface ProjectActionsContextMenuProps extends ProjectActionsMenuBaseProps { @@ -56,6 +58,7 @@ interface ProjectActionsMenuItemsProps extends ProjectActionsMenuBaseProps { interface ProjectActionMenuItemProps { children: ReactNode; className?: string; + icon: IconName; onSelect?: (event: Event) => void; surface: ProjectActionsMenuSurface; } @@ -67,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} ); } @@ -111,6 +123,7 @@ function ProjectActionsMenuItems({ <> { navigate(getProjectSettingsRoutePath(project.id)); }} @@ -119,15 +132,17 @@ function ProjectActionsMenuItems({ { navigate(getProjectArchivedRoutePath(project.id)); }} > - View archived threads + View archive { if (surface === "dropdown") { event.preventDefault(); @@ -140,6 +155,7 @@ function ProjectActionsMenuItems({ {showAddLocalPath ? ( { if (surface === "dropdown") { event.preventDefault(); @@ -152,6 +168,7 @@ function ProjectActionsMenuItems({ ) : null} { if (surface === "dropdown") { @@ -171,36 +188,42 @@ export function ProjectActionsMenu({ triggerClassName, align = "end", onOpenChange, + hideTriggerTooltip = false, }: ProjectActionsMenuProps) { + const trigger = ( + + + + ); return ( - - - - - - - Project actions - + {hideTriggerTooltip ? ( + trigger + ) : ( + + {trigger} + Project actions + + )} diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx index 443566244..91ed549ee 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" > -
+
- - - Threads actions - + + + - View archived threads + @@ -871,23 +870,20 @@ function SidebarThreadActionsMenu({ interface SidebarDisplayOptionsActionsProps { open: SidebarDisplayOptionsMenuKind | null; onOpenChange: (menu: SidebarDisplayOptionsMenuKind, open: boolean) => void; - onOrganizationModeSelect?: (mode: SidebarOrganizationMode) => void; } -// The Organize + Sort menu pair shown on every sidebar section header. Shared -// so the project, folders, and threads headers stay identical and changes land -// in one place instead of being copied per view. +// The Group + Sort menu pair shown on the primary section header (Projects in +// project mode, Folders in the folders view). Shared so both headers stay +// identical and changes land in one place instead of being copied per view. function SidebarDisplayOptionsActions({ open, onOpenChange, - onOrganizationModeSelect, }: SidebarDisplayOptionsActionsProps) { return ( <> - onOpenChange("organize", next)} - onOrganizationModeSelect={onOrganizationModeSelect} + onOpenChange("group", next)} /> void; } -// The complete Threads-section header cluster (archived menu + display options + -// new thread). One component drives the Threads header in both project mode and -// the folders view, so they can never drift apart. +// The complete Threads-section header cluster (archived menu + sort + new +// thread). One component drives the Threads header in both project mode and the +// folders view, so they can never drift apart. The Threads section is always the +// loose/unfiled set, so it offers sorting but not the Group-by toggle (that +// lives on the primary section header). function SidebarThreadsSectionActions({ displayOptionsOpen, onDisplayOptionsOpenChange, @@ -929,9 +927,9 @@ function SidebarThreadsSectionActions({ onOpenChange={onActionsMenuOpenChange} onOpenArchivedThreads={onOpenArchivedThreads} /> - onDisplayOptionsOpenChange("sort", next)} /> - - - - - {collapseControl.isCollapsed - ? `Expand ${label}` - : `Collapse ${label}`} - - + ) : null} {actions ? ( @@ -1156,9 +1144,7 @@ export function ProjectListActionButtons({ threadSearch, }: ProjectListActionButtonsProps) { const isNewChatDisabled = !onNewChat; - const newChatTitle = isNewChatDisabled ? "Start a new thread" : "New thread"; const threadSearchShortcut = getSidebarThreadSearchShortcutLabel(); - const threadSearchTitle = `Search threads - ${threadSearchShortcut}`; const handleSearchClose = useCallback(() => { if (threadSearch?.query.trim()) { threadSearch.onQueryChange(""); @@ -1201,7 +1187,6 @@ export function ProjectListActionButtons({ aria-label={ threadSearch.query.trim() ? "Clear search" : "Close search" } - title={threadSearch.query.trim() ? "Clear search" : "Close search"} className={PROJECT_LIST_SEARCH_CLOSE_BUTTON_CLASS} onClick={handleSearchClose} > @@ -1217,7 +1202,6 @@ export function ProjectListActionButtons({ className={cn(PROJECT_LIST_ACTION_BUTTON_CLASS, "flex-1")} onClick={onNewChat} disabled={isNewChatDisabled} - title={newChatTitle} > @@ -1230,7 +1214,6 @@ export function ProjectListActionButtons({ size="icon" variant="ghost" aria-label={`Search threads (${threadSearchShortcut})`} - title={threadSearchTitle} className={PROJECT_LIST_ACTION_ICON_BUTTON_CLASS} onClick={threadSearch.onActivate} > @@ -1250,7 +1233,6 @@ export function ProjectListActionButtons({ )} aria-current={isAutomationsActive ? "page" : undefined} onClick={onOpenAutomations} - title="Automations" > Automations @@ -1573,16 +1555,6 @@ function ProjectListComponent({ }, [], ); - const handleProjectsViewOptionsOrganizationModeSelect = useCallback( - (mode: SidebarOrganizationMode) => { - if (mode === "chronological") { - setProjectsDisplayOptionsMenuOpen(null); - setIsThreadsActionsMenuOpen(false); - setThreadsDisplayOptionsMenuOpen("organize"); - } - }, - [], - ); const [organizationMode] = useAtom(sidebarOrganizationModeAtom); const [chronologicalSort, setChronologicalSort] = useAtom( sidebarChronologicalSortAtom, @@ -1971,9 +1943,6 @@ function ProjectListComponent({ {onNewProject ? ( )} diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 56ac5f2cc..99be6aef7 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -105,8 +105,8 @@ import { sidebarCollapsedFoldersAtom } from "./sidebarCollapsedAtoms"; import { SIDEBAR_PROJECT_GROUP_LINE_CLASS, SIDEBAR_ROW_BASE_CLASS, - SIDEBAR_ROW_INTERACTIVE_STATE_CLASS, SIDEBAR_ROW_SELECTED_STATE_CLASS, + SIDEBAR_ROW_STATIC_STATE_CLASS, getSidebarThreadGroupLineLeft, getSidebarThreadRowPaddingLeft, } from "./sidebarRowClasses"; @@ -283,9 +283,11 @@ interface ManualThreadTreeDndState { enabled: boolean; itemIdsByParentKey: ReadonlyMap; onClickCapture: MouseEventHandler; - // The folder showing an empty drop-placeholder row while a thread is dragged - // over it (after a short hover); the dragged row itself carries the title. - dragOverFolderKey: string | null; + // The drop target showing an empty placeholder row while a thread is dragged + // over it (after a short hover): a folder key, or the loose root container id. + // The dragged row itself carries the title. One field drives both folder and + // loose-list previews so they stay visually identical. + dragOverParentKey: string | null; } interface UseManualThreadTreeDndArgs { @@ -417,11 +419,11 @@ function resolveThreadDropTarget( return { activeId, fromParentKey, toParentKey }; } -// Spring-loaded delay before a hovered folder expands + shows the drop preview. -// The preview/expand shift layout, so deferring them until the pointer settles -// keeps dragging *through* a folder (e.g. up out of one's own folder) smooth -// instead of the inserted row shoving the dragged item back down. -const FOLDER_DRAG_DWELL_MS = 200; +// Spring-loaded delay before a hovered drop target shows its placeholder (and a +// folder expands). The placeholder/expand shift layout, so deferring them until +// the pointer settles keeps dragging *through* a folder (e.g. up out of one's +// own folder) smooth instead of the inserted row shoving the dragged item down. +const DRAG_DWELL_MS = 200; function useManualThreadTreeDnd({ containerId, @@ -437,79 +439,83 @@ function useManualThreadTreeDnd({ // Whether a thread (vs. nothing droppable) is currently being dragged. const draggingThreadRef = useRef(false); const dwellTimerRef = useRef | null>(null); - // The folder the dwell timer is currently counting toward; null when the - // pointer isn't over a droppable target folder. - const dwellFolderKeyRef = useRef(null); - // The folder currently showing an (empty) drop-placeholder row, after dwell. - const [dragOverFolderKey, setDragOverFolderKey] = useState( + // The drop target the dwell timer is counting toward (folder key or the loose + // root container); null when the pointer isn't over a droppable target. + const dwellParentKeyRef = useRef(null); + // The drop target currently showing an (empty) placeholder row, after dwell: + // a folder key, or the loose root container id. + const [dragOverParentKey, setDragOverParentKey] = useState( null, ); - const clearFolderDwell = useCallback(() => { + const clearDropDwell = useCallback(() => { if (dwellTimerRef.current !== null) { clearTimeout(dwellTimerRef.current); dwellTimerRef.current = null; } - dwellFolderKeyRef.current = null; + dwellParentKeyRef.current = null; }, []); - useEffect(() => clearFolderDwell, [clearFolderDwell]); + useEffect(() => clearDropDwell, [clearDropDwell]); const handleDragStart = useCallback( (event: DragStartEvent) => { const activeId = event.active.id; draggingThreadRef.current = typeof activeId === "string" && lookup.threadByItemId.has(activeId); - clearFolderDwell(); - setDragOverFolderKey(null); + clearDropDwell(); + setDragOverParentKey(null); }, - [clearFolderDwell, lookup], + [clearDropDwell, lookup], ); const handleDragOver = useCallback( (event: DragOverEvent) => { if (!enabled || !draggingThreadRef.current) return; const drop = resolveThreadDropTarget(lookup, event.active, event.over); - // Only a real folder (not the loose root) is a spring-load target. - const targetFolderKey = - drop && drop.toParentKey !== containerId ? drop.toParentKey : null; + // The drop target the placeholder will mark: a folder key, or the loose + // root container. Null when the pointer isn't over a valid target. + const targetParentKey = drop ? drop.toParentKey : null; // Same target as the in-flight dwell: nothing to do (don't thrash timers // on every pointer move). - if (targetFolderKey === dwellFolderKeyRef.current) return; + if (targetParentKey === dwellParentKeyRef.current) return; - clearFolderDwell(); - dwellFolderKeyRef.current = targetFolderKey; - setDragOverFolderKey((current) => (current ? null : current)); - if (targetFolderKey === null) return; + clearDropDwell(); + dwellParentKeyRef.current = targetParentKey; + setDragOverParentKey((current) => (current ? null : current)); + if (targetParentKey === null) return; - // Spring-loaded: expand a collapsed target and show the empty placeholder - // only after the pointer settles, so passing through a folder mid-drag - // doesn't shift it under the cursor. + // Spring-loaded: reveal the placeholder (and expand a collapsed target + // folder) only after the pointer settles, so passing through a folder + // mid-drag doesn't shift it under the cursor. The loose root is never + // collapsed, so it only gets the placeholder. dwellTimerRef.current = setTimeout(() => { dwellTimerRef.current = null; if ( !draggingThreadRef.current || - dwellFolderKeyRef.current !== targetFolderKey + dwellParentKeyRef.current !== targetParentKey ) { return; } - setCollapsedFolders((current) => - current.includes(targetFolderKey) - ? current.filter((key) => key !== targetFolderKey) - : current, - ); - setDragOverFolderKey(targetFolderKey); - }, FOLDER_DRAG_DWELL_MS); + if (targetParentKey !== containerId) { + setCollapsedFolders((current) => + current.includes(targetParentKey) + ? current.filter((key) => key !== targetParentKey) + : current, + ); + } + setDragOverParentKey(targetParentKey); + }, DRAG_DWELL_MS); }, - [clearFolderDwell, containerId, enabled, lookup, setCollapsedFolders], + [clearDropDwell, containerId, enabled, lookup, setCollapsedFolders], ); const handleDragEnd = useCallback( (event: DragEndEvent) => { draggingThreadRef.current = false; - clearFolderDwell(); - setDragOverFolderKey(null); + clearDropDwell(); + setDragOverParentKey(null); if (!enabled) return; const drop = resolveThreadDropTarget(lookup, event.active, event.over); @@ -526,14 +532,14 @@ function useManualThreadTreeDnd({ folderPath: destinationFolderPath, }); }, - [clearFolderDwell, enabled, lookup, updateThread], + [clearDropDwell, enabled, lookup, updateThread], ); const handleDragCancel = useCallback(() => { draggingThreadRef.current = false; - clearFolderDwell(); - setDragOverFolderKey(null); - }, [clearFolderDwell]); + clearDropDwell(); + setDragOverParentKey(null); + }, [clearDropDwell]); const { consumeClickSuppression, dndContextProps, onClickCapture } = useSidebarReorderDnd({ @@ -553,7 +559,7 @@ function useManualThreadTreeDnd({ enabled, itemIdsByParentKey: lookup.itemIdsByParentKey, onClickCapture, - dragOverFolderKey, + dragOverParentKey, }; } @@ -678,15 +684,10 @@ function getProjectThreadTreeEmptyStateClassName( ); } -function getProjectThreadTreeEmptyStateMessageClassName( - variant: ProjectThreadTreeVariant, -): string { - return cn( - "text-xs leading-4", - variant === "project" - ? "font-medium text-sidebar-foreground/85" - : "text-muted-foreground", - ); +function getProjectThreadTreeEmptyStateMessageClassName(): string { + // One notch below the section-header label so an empty placeholder never + // out-emphasizes the header it sits under. + return "text-xs leading-4 text-subtle-foreground/60"; } function getProjectThreadTreeGroupLineClassName( @@ -850,6 +851,9 @@ function ManualSortableList({ ); } +// Registers the loose root as a droppable so drops onto its bare/empty area +// resolve to the loose container. Drop feedback is the inserted placeholder row +// (see the loose section), matching how folders preview a drop. function ManualDroppableParent({ children, className, @@ -861,20 +865,13 @@ function ManualDroppableParent({ manualSort?: ManualThreadTreeDndState | null; parentKey: string; }) { - const { isOver, setNodeRef } = useDroppable({ + const { setNodeRef } = useDroppable({ id: parentKey, disabled: !manualSort?.enabled, }); return ( -
+
{children}
); @@ -1067,7 +1064,7 @@ function EnvironmentThreadGroupHeaderActions({ variant="ghost" size="icon" aria-label="Worktree actions" - title="Worktree actions" + title={undefined} className={cn( "rounded-md p-0 text-muted-foreground", "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-foreground", @@ -1150,7 +1147,7 @@ function EnvironmentThreadGroupHeader({ stickyLevel === undefined && "relative", SIDEBAR_ROW_BASE_CLASS, COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, - "cursor-default", + "cursor-pointer", ); const style = { paddingLeft: getSidebarThreadRowPaddingLeft(rowDepth), @@ -1233,7 +1230,6 @@ function EnvironmentThreadGroupHeader({ level={stickyLevel} className={className} style={style} - title={displayName} > {content} @@ -1241,7 +1237,7 @@ function EnvironmentThreadGroupHeader({ } return ( -
+
{content}
); @@ -1454,7 +1450,7 @@ export const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ // Empty drop-slot rendered inside the (auto-expanded) hovered folder so the // landing spot is visible. The dragged row itself carries the title (like // dragging a queued message), so this placeholder stays intentionally blank. -export function FolderDropPreviewRow({ depth }: { depth: number }) { +export function DropPreviewRow({ depth }: { depth: number }) { return (