diff --git a/components/SessionSidebar.test.mjs b/components/SessionSidebar.test.mjs index f4d963bb1..b88dac9c5 100644 --- a/components/SessionSidebar.test.mjs +++ b/components/SessionSidebar.test.mjs @@ -100,3 +100,18 @@ test("hides subagent rows and aggregates their state into the main session row", assert.match(source, /familySessions\.some\(\(session\) => runningSessionIds\.has\(session\.id\)\)/); assert.doesNotMatch(source, /function SessionTreeItem/); }); + +test("organizes conversation families into persistent drag-and-drop folders", () => { + assert.match(source, /loadConversationFolderState\(\)/); + assert.match(source, /saveConversationFolderState\(next\)/); + assert.match(source, /const folderFamilies = new Map/); + assert.match(source, /event\.dataTransfer\.setData\("text\/session-id", session\.id\)/); + assert.match(source, / { + assert.match(source, /const folderSessions = families\.flatMap/); + assert.match(source, /running: folderSessions\.filter\(\(session\) => runningSessionIds\.has\(session\.id\)\)\.length/); + assert.match(source, /unread: folderSessions\.filter\(\(session\) => unreadSessionIds\.has\(session\.id\)\)\.length/); +}); diff --git a/components/SessionSidebar.tsx b/components/SessionSidebar.tsx index f8d7522c6..efe0ead1c 100644 --- a/components/SessionSidebar.tsx +++ b/components/SessionSidebar.tsx @@ -2,8 +2,17 @@ import { useEffect, useLayoutEffect, useState, useCallback, useMemo, useRef, type CSSProperties, type ReactNode } from "react"; import type { SessionInfo } from "@/lib/types"; -import { listSessionFamilies } from "@/lib/session-family"; +import { listSessionFamilies, type SessionFamily } from "@/lib/session-family"; import { loadExplorerOpen, saveExplorerOpen } from "@/lib/file-explorer-state"; +import { + deleteConversationFolder, + emptyConversationFolderState, + loadConversationFolderState, + resolveConversationFolderAssignments, + saveConversationFolderState, + type ConversationFolder, + type ConversationFolderState, +} from "@/lib/conversation-folder-state"; import { dispatchSessionRowContextMenu } from "@/lib/session-row-context-menu"; import { skillExpansionToCommand } from "@/lib/slash-display"; import { getProjectActivity, getRecentProjects, sessionsForProject } from "@/lib/project-groups"; @@ -379,6 +388,10 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio const wtDropdownRef = useRef(null); const wtNewInputRef = useRef(null); const [explorerOpen, setExplorerOpen] = useState(true); + const [folderState, setFolderState] = useState(() => emptyConversationFolderState()); + const [creatingFolder, setCreatingFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(""); + const [draggingSessionId, setDraggingSessionId] = useState(null); const [explorerKey, setExplorerKey] = useState(0); const [explorerUploadBusy, setExplorerUploadBusy] = useState(false); const [fileSearchOpen, setFileSearchOpen] = useState(false); @@ -397,6 +410,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio const sessionRefreshTimerRef = useRef | null>(null); const explorerRefreshTimerRef = useRef | null>(null); const fileExplorerRef = useRef(null); + const newFolderInputRef = useRef(null); const loadSessions = useCallback(async (showLoading = false, force = false) => { try { @@ -455,6 +469,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio // preference after hydration so a collapsed explorer stays collapsed on reload. useEffect(() => { setExplorerOpen(loadExplorerOpen()); + setFolderState(loadConversationFolderState()); }, []); // Persist unread markers so they survive a browser refresh before the user @@ -900,6 +915,55 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio // Sessions of every worktree in the selected project are shown together const selectedProject = projectFor(selectedCwd); + const updateFolderState = useCallback((update: (current: ConversationFolderState) => ConversationFolderState) => { + setFolderState((current) => { + const next = update(current); + saveConversationFolderState(next); + return next; + }); + }, []); + + const commitNewFolder = useCallback(() => { + const name = newFolderName.trim(); + if (!name || !selectedProject) return; + const id = typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + updateFolderState((current) => ({ + ...current, + folders: [...current.folders, { id, name, projectKey: selectedProject.key, collapsed: false }], + })); + setNewFolderName(""); + setCreatingFolder(false); + }, [newFolderName, selectedProject, updateFolderState]); + + const moveSessionToFolder = useCallback((sessionId: string, folderId: string | null) => { + updateFolderState((current) => ({ + ...current, + assignments: { ...current.assignments, [sessionId]: folderId }, + })); + }, [updateFolderState]); + + const renameFolder = useCallback((folderId: string, name: string) => { + const trimmed = name.trim(); + if (!trimmed) return; + updateFolderState((current) => ({ + ...current, + folders: current.folders.map((folder) => folder.id === folderId ? { ...folder, name: trimmed } : folder), + })); + }, [updateFolderState]); + + const toggleFolder = useCallback((folderId: string) => { + updateFolderState((current) => ({ + ...current, + folders: current.folders.map((folder) => folder.id === folderId ? { ...folder, collapsed: !folder.collapsed } : folder), + })); + }, [updateFolderState]); + + const removeFolder = useCallback((folderId: string) => { + updateFolderState((current) => deleteConversationFolder(current, folderId)); + }, [updateFolderState]); + // Per-project activity counts (running / unread) for the workspace selector. // Uses the same stable server key as the project list and filtering. const projectActivity = useMemo( @@ -949,7 +1013,53 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio } : null); + const projectFolders = selectedProject + ? folderState.folders.filter((folder) => folder.projectKey === selectedProject.key) + : []; + const validFolderIds = new Set(projectFolders.map((folder) => folder.id)); + const resolvedFolderAssignments = resolveConversationFolderAssignments( + filteredSessions.map((session) => ({ + id: session.id, + ...(session.relation?.kind === "subagent" ? { parentSessionId: session.relation.parentSessionId } : {}), + })), + folderState.assignments, + validFolderIds, + ); const sessionFamilies = listSessionFamilies(filteredSessions); + const folderFamilies = new Map(projectFolders.map((folder) => [ + folder.id, + sessionFamilies.filter((family) => resolvedFolderAssignments.get(family.root.id) === folder.id), + ])); + const unfiledFamilies = sessionFamilies.filter((family) => !resolvedFolderAssignments.get(family.root.id)); + + const renderSessionFamily = (family: SessionFamily) => { + const familySessions = [family.root, ...family.subagents]; + const displaySession = family.latestModified === family.root.modified + ? family.root + : { ...family.root, modified: family.latestModified }; + return ( + session.id === selectedSessionId)} + isRunning={familySessions.some((session) => runningSessionIds.has(session.id))} + isUnread={familySessions.some((session) => unreadSessionIds.has(session.id))} + onClick={() => handleSelectSessionFromList(family.root)} + onSessionDragStart={setDraggingSessionId} + onSessionDragEnd={() => setDraggingSessionId(null)} + onRenamed={loadSessions} + onDeleted={(id) => { + updateFolderState((current) => { + const assignments = { ...current.assignments }; + delete assignments[id]; + return { ...current, assignments }; + }); + onSessionDeleted?.(id); + loadSessions(); + }} + /> + ); + }; return (
@@ -1014,6 +1124,28 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio {t("sidebar.new")} + + +
+ )} + + {!folder.collapsed && ( +
+ {families.length === 0 && ( +
+ {t("sidebar.dropSessionsHere")} +
+ )} + {families.map(renderFamily)} +
+ )} + + ); +} + function SessionItem({ session, isSelected, isRunning, isUnread, onClick, + onSessionDragStart, + onSessionDragEnd, onRenamed, onDeleted, depth = 0, @@ -1917,6 +2256,8 @@ function SessionItem({ isRunning?: boolean; isUnread?: boolean; onClick: () => void; + onSessionDragStart?: (sessionId: string) => void; + onSessionDragEnd?: () => void; onRenamed?: () => void; onDeleted?: (id: string) => void; depth?: number; @@ -2027,6 +2368,13 @@ function SessionItem({
{ + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/session-id", session.id); + onSessionDragStart?.(session.id); + }} + onDragEnd={() => onSessionDragEnd?.()} onMouseEnter={() => setHovered(true)} onMouseLeave={() => { setHovered(false); }} style={{ diff --git a/lib/conversation-folder-state.test.mjs b/lib/conversation-folder-state.test.mjs new file mode 100644 index 000000000..34122185c --- /dev/null +++ b/lib/conversation-folder-state.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const { + deleteConversationFolder, + emptyConversationFolderState, + loadConversationFolderState, + resolveConversationFolderAssignments, + saveConversationFolderState, +} = await createJiti(import.meta.url).import("./conversation-folder-state.ts"); + +function createStorage(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + values, + getItem(key) { return values.get(key) ?? null; }, + setItem(key, value) { values.set(key, value); }, + }; +} + +test("saves and restores conversation folders", () => { + const storage = createStorage(); + const state = { + folders: [{ id: "folder-1", name: "Research", projectKey: "repo:1", collapsed: false }], + assignments: { child: "folder-1", unfiled: null }, + }; + saveConversationFolderState(state, storage); + assert.deepEqual(loadConversationFolderState(storage), state); +}); + +test("inherits a parent folder while allowing an explicit unfiled override", () => { + const resolved = resolveConversationFolderAssignments( + [ + { id: "root" }, + { id: "child", parentSessionId: "root" }, + { id: "grandchild", parentSessionId: "child" }, + ], + { root: "folder-1", child: null }, + new Set(["folder-1"]), + ); + assert.equal(resolved.get("root"), "folder-1"); + assert.equal(resolved.get("child"), null); + assert.equal(resolved.get("grandchild"), null); +}); + +test("deleting a folder keeps its sessions explicitly unfiled", () => { + const state = { + folders: [{ id: "folder-1", name: "Research", projectKey: "repo:1", collapsed: false }], + assignments: { root: "folder-1" }, + }; + assert.deepEqual(deleteConversationFolder(state, "folder-1"), { + folders: [], + assignments: { root: null }, + }); +}); + +test("rejects malformed and duplicate folder records", () => { + const storage = createStorage({ + "pi-web:conversation-folders": JSON.stringify({ + folders: [ + { id: "folder-1", name: "Research", projectKey: "repo:1", collapsed: false }, + { id: "folder-1", name: "Duplicate", projectKey: "repo:1", collapsed: false }, + { id: "folder-2", name: "", projectKey: "repo:1", collapsed: false }, + ], + assignments: [], + }), + }); + assert.deepEqual(loadConversationFolderState(storage), { + folders: [{ id: "folder-1", name: "Research", projectKey: "repo:1", collapsed: false }], + assignments: {}, + }); + assert.deepEqual(loadConversationFolderState(null), emptyConversationFolderState()); +}); diff --git a/lib/conversation-folder-state.ts b/lib/conversation-folder-state.ts new file mode 100644 index 000000000..367ef0805 --- /dev/null +++ b/lib/conversation-folder-state.ts @@ -0,0 +1,131 @@ +export interface ConversationFolder { + id: string; + name: string; + projectKey: string; + collapsed: boolean; +} + +export interface ConversationFolderState { + folders: ConversationFolder[]; + assignments: Record; +} + +interface FolderableSession { + id: string; + parentSessionId?: string; +} + +interface StorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +const STORAGE_KEY = "pi-web:conversation-folders"; + +function browserStorage(): StorageLike | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +export function emptyConversationFolderState(): ConversationFolderState { + return { folders: [], assignments: {} }; +} + +function parseConversationFolderState(value: unknown): ConversationFolderState { + if (!value || typeof value !== "object" || Array.isArray(value)) return emptyConversationFolderState(); + const candidate = value as Partial; + const seenIds = new Set(); + const folders = Array.isArray(candidate.folders) + ? candidate.folders.filter((folder): folder is ConversationFolder => { + if (!folder || typeof folder !== "object") return false; + const valid = typeof folder.id === "string" + && folder.id.length > 0 + && !seenIds.has(folder.id) + && typeof folder.name === "string" + && folder.name.trim().length > 0 + && typeof folder.projectKey === "string" + && folder.projectKey.length > 0 + && typeof folder.collapsed === "boolean"; + if (valid) seenIds.add(folder.id); + return valid; + }) + : []; + const assignments: Record = {}; + if (candidate.assignments && typeof candidate.assignments === "object" && !Array.isArray(candidate.assignments)) { + for (const [sessionId, folderId] of Object.entries(candidate.assignments)) { + if (typeof folderId === "string" || folderId === null) assignments[sessionId] = folderId; + } + } + return { folders, assignments }; +} + +export function loadConversationFolderState( + storage: StorageLike | null = browserStorage(), +): ConversationFolderState { + if (!storage) return emptyConversationFolderState(); + try { + const raw = storage.getItem(STORAGE_KEY); + return raw ? parseConversationFolderState(JSON.parse(raw) as unknown) : emptyConversationFolderState(); + } catch { + return emptyConversationFolderState(); + } +} + +export function saveConversationFolderState( + state: ConversationFolderState, + storage: StorageLike | null = browserStorage(), +): void { + if (!storage) return; + try { + storage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // Browser storage is best-effort. + } +} + +export function deleteConversationFolder( + state: ConversationFolderState, + folderId: string, +): ConversationFolderState { + const assignments = { ...state.assignments }; + for (const [sessionId, assignedFolderId] of Object.entries(assignments)) { + if (assignedFolderId === folderId) assignments[sessionId] = null; + } + return { + folders: state.folders.filter((folder) => folder.id !== folderId), + assignments, + }; +} + +export function resolveConversationFolderAssignments( + sessions: FolderableSession[], + assignments: Record, + validFolderIds: ReadonlySet, +): Map { + const sessionsById = new Map(sessions.map((session) => [session.id, session])); + const resolved = new Map(); + + const resolveFolder = (sessionId: string, visiting: Set): string | null => { + if (resolved.has(sessionId)) return resolved.get(sessionId) ?? null; + if (visiting.has(sessionId)) return null; + visiting.add(sessionId); + const explicit = assignments[sessionId]; + let folderId = Object.hasOwn(assignments, sessionId) + ? explicit !== null && validFolderIds.has(explicit) ? explicit : null + : null; + if (!Object.hasOwn(assignments, sessionId)) { + const parentId = sessionsById.get(sessionId)?.parentSessionId; + if (parentId && sessionsById.has(parentId)) folderId = resolveFolder(parentId, visiting); + } + visiting.delete(sessionId); + resolved.set(sessionId, folderId); + return folderId; + }; + + for (const session of sessions) resolveFolder(session.id, new Set()); + return resolved; +} diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index c4f1d8095..40085c804 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -218,6 +218,13 @@ export const enLocale: LocalePlugin = { "sidebar.changedFiles": "{count} changed files", "sidebar.loading": "Loading...", "sidebar.noSessions": "No sessions found", + "sidebar.newFolder": "New conversation folder", + "sidebar.folderName": "Folder name", + "sidebar.renameFolder": "Rename folder", + "sidebar.deleteFolder": "Delete folder", + "sidebar.deleteFolderConfirm": "Delete folder “{name}”? Conversations will move to Unfiled.", + "sidebar.dropSessionsHere": "Drop conversations here", + "sidebar.unfiled": "Unfiled", "sidebar.agentRunning": "Agent running…", "sidebar.newActivity": "New activity", "sidebar.newSessionActivity": "New session activity", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index eedc13190..79834018a 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -218,6 +218,13 @@ export const zhCNLocale: LocalePlugin = { "sidebar.changedFiles": "{count} 个变更文件", "sidebar.loading": "加载中...", "sidebar.noSessions": "未找到会话", + "sidebar.newFolder": "新建对话文件夹", + "sidebar.folderName": "文件夹名称", + "sidebar.renameFolder": "重命名文件夹", + "sidebar.deleteFolder": "删除文件夹", + "sidebar.deleteFolderConfirm": "删除文件夹“{name}”?其中的对话将移到未分类。", + "sidebar.dropSessionsHere": "将对话拖到这里", + "sidebar.unfiled": "未分类", "sidebar.agentRunning": "Agent 运行中…", "sidebar.newActivity": "有新活动", "sidebar.newSessionActivity": "会话有新活动", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index 826287dd0..0b8cf3cb3 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -218,6 +218,13 @@ export const zhTWLocale: LocalePlugin = { "sidebar.changedFiles": "{count} 個已變更檔案", "sidebar.loading": "載入中...", "sidebar.noSessions": "找不到工作階段", + "sidebar.newFolder": "新增對話資料夾", + "sidebar.folderName": "資料夾名稱", + "sidebar.renameFolder": "重新命名資料夾", + "sidebar.deleteFolder": "刪除資料夾", + "sidebar.deleteFolderConfirm": "刪除資料夾「{name}」?其中的對話將移至未分類。", + "sidebar.dropSessionsHere": "將對話拖曳到這裡", + "sidebar.unfiled": "未分類", "sidebar.agentRunning": "Agent 執行中…", "sidebar.newActivity": "有新活動", "sidebar.newSessionActivity": "工作階段有新活動",