+
{content}
);
@@ -877,18 +1354,55 @@ const EnvironmentThreadGroupRow = memo(function EnvironmentThreadGroupRow({
);
});
-const ThreadTreeItemRow = memo(function ThreadTreeItemRow({
+export const ThreadTreeItemRow = memo(function ThreadTreeItemRow({
projectId,
item,
depthOffset,
+ insideFolder = false,
selectedThreadId,
collapsedThreadIds,
collapsedEnvironmentIds,
variant,
onProjectSelect,
+ onCreateThreadInFolder,
+ onViewArchivedThreadsInFolder,
+ onRenameFolder,
+ onRemoveFolder,
onToggleThreadCollapsed,
onToggleEnvironmentCollapsed,
+ consumeClickSuppression,
+ dragBindings,
+ isDropTargetActive,
+ manualSort,
+ sortableRef,
+ sortableStyle,
}: ThreadTreeItemRowProps) {
+ if (item.kind === "folder") {
+ return (
+
+ );
+ }
+
if (item.kind === "thread") {
return (
);
}
@@ -924,6 +1443,148 @@ const ThreadTreeItemRow = memo(function ThreadTreeItemRow({
);
});
+// A derived folder and its (recursively rendered) contents. Collapse state lives
+// 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.
+// 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 DropPreviewRow({ depth }: { depth: number }) {
+ return (
+
+ );
+}
+
+const FolderTreeItemRow = memo(function FolderTreeItemRow({
+ folder,
+ depthOffset,
+ selectedThreadId,
+ collapsedThreadIds,
+ collapsedEnvironmentIds,
+ variant,
+ onProjectSelect,
+ onCreateThreadInFolder,
+ onViewArchivedThreadsInFolder,
+ onRenameFolder,
+ onRemoveFolder,
+ onToggleThreadCollapsed,
+ onToggleEnvironmentCollapsed,
+ consumeClickSuppression,
+ dragBindings,
+ isDropTargetActive = false,
+ manualSort,
+ sortableRef,
+ sortableStyle,
+}: FolderTreeItemRowProps) {
+ const collapsedFolders = useAtomValue(sidebarCollapsedFoldersAtom);
+ const setCollapsedFolders = useSetAtom(sidebarCollapsedFoldersAtom);
+ const folderKey = folder.key;
+ const isCollapsed = collapsedFolders.includes(folderKey);
+ const handleToggleCollapsed = useCallback(() => {
+ 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;
+ const folderPath = folder.path.join("/");
+ const showDropPreview = manualSort?.dragOverParentKey === folderKey;
+ const showChildren = !isCollapsed && folder.items.length > 0;
+ // Force the children area open while a thread is dragged over this folder so
+ // the empty drop-placeholder row is visible even when the folder is empty.
+ const showChildrenArea = showChildren || showDropPreview;
+
+ return (
+
+ onCreateThreadInFolder(folderPath)
+ : undefined
+ }
+ onViewArchivedThreads={
+ onViewArchivedThreadsInFolder
+ ? () => onViewArchivedThreadsInFolder(folderPath)
+ : undefined
+ }
+ onRename={onRenameFolder ? () => onRenameFolder(folderPath) : undefined}
+ onRemove={onRemoveFolder ? () => onRemoveFolder(folderPath) : undefined}
+ onToggleCollapsed={handleToggleCollapsed}
+ stickyLevel={stickyLevel}
+ />
+ {showChildrenArea ? (
+
+
+ {showChildren ? (
+
+ {folder.items.map((item) => (
+
+ ))}
+
+ ) : null}
+ {showDropPreview ? (
+
+ ) : null}
+
+ ) : null}
+
+ );
+});
+
export const ThreadTreeNodeRow = memo(function ThreadTreeNodeRow({
projectId,
node,
@@ -940,6 +1601,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 +1648,20 @@ 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 title = getThreadDisplayTitle(node.thread);
+ const folders = splitFolderPath(node.thread.folderPath);
+ return {
+ displayTitle: title,
+ accessibleTitle:
+ folders.length > 0 ? formatFolderPathLabel([...folders, title]) : title,
+ };
+ }, [insideFolder, node.thread]);
const row = (
);
@@ -1013,11 +1691,7 @@ export const ThreadTreeNodeRow = memo(function ThreadTreeNodeRow({
{node.children.map((item) => (
+
+
+ );
+}
+
+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