diff --git a/.github/pr-assets/workspace-session-tree-dark.png b/.github/pr-assets/workspace-session-tree-dark.png new file mode 100644 index 000000000..e4ed7b149 Binary files /dev/null and b/.github/pr-assets/workspace-session-tree-dark.png differ diff --git a/.github/pr-assets/workspace-session-tree-light.png b/.github/pr-assets/workspace-session-tree-light.png new file mode 100644 index 000000000..33151fcfa Binary files /dev/null and b/.github/pr-assets/workspace-session-tree-light.png differ diff --git a/crates/agent-gateway/web/src/app/GatewayAppView.tsx b/crates/agent-gateway/web/src/app/GatewayAppView.tsx index cf523b9d0..b4185c673 100644 --- a/crates/agent-gateway/web/src/app/GatewayAppView.tsx +++ b/crates/agent-gateway/web/src/app/GatewayAppView.tsx @@ -40,6 +40,7 @@ import { import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; import { mergePendingUploadedFiles } from "@liveagent/ui/lib/chat/uploadedFiles"; import { cn } from "@liveagent/ui/lib/shared/utils"; +import { setSidebarConversationArchived } from "@liveagent/ui/lib/sidebar/preferences"; import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; import { mergeTerminalSession, @@ -1269,6 +1270,24 @@ export function GatewayAppView({ viewModel }: { viewModel: GatewayAppViewModel }
+ setSettings((previous) => ({ + ...previous, + system: { ...previous.system, sidebarPinnedOrder }, + })) + } + projectOrder={settings.system.workspaceProjectOrder} + onReorderProjects={(workspaceProjectOrder) => + setSettings((previous) => ({ + ...previous, + system: { ...previous.system, workspaceProjectOrder }, + })) + } + archivedConversations={settings.system.archivedConversations} + onSetConversationArchived={(item, archived) => + setSettings((previous) => setSidebarConversationArchived(previous, item, archived)) + } store={sidebarStore} approvalConversationIds={approvalConversationIds} transientRunningConversations={manualCompactTransientConversations} diff --git a/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx b/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx index 3212fc93d..49d512796 100644 --- a/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx +++ b/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx @@ -12,7 +12,10 @@ import { } from "@liveagent/ui/components/chat/ChatHistorySidebar"; import { useLocale } from "@liveagent/ui/i18n/index"; import type { SidebarBatchDeleteOptions } from "@liveagent/ui/lib/sidebar/batchDelete"; -import { deleteSidebarConversations } from "@liveagent/ui/lib/sidebar/batchDelete"; +import { + deleteSidebarConversation, + deleteSidebarConversations, +} from "@liveagent/ui/lib/sidebar/batchDelete"; import type { SidebarSnapshot, SidebarStore } from "@liveagent/ui/lib/sidebar/store"; import type { TransientSidebarRunningConversation } from "@liveagent/ui/lib/sidebar/transientActivity"; import { mergeTransientSidebarRunningActivity } from "@liveagent/ui/lib/sidebar/transientActivity"; @@ -124,7 +127,8 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { mutations, mutationErrors, projectActivityInputs, - } = useSidebarContainerState(store); + workspaceHistory, + } = useSidebarContainerState(store, props.showProjects); const conversationIndex = useSidebarSelector(store, selectConversationIndex); const effectiveRunningActivity = useMemo( () => @@ -231,7 +235,7 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { props.onLocalDraftDeleted(id); return; } - void store.remove(id); + void deleteSidebarConversation(id, props); }); const handleDeleteConversations = useStableCallback( @@ -249,7 +253,7 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { props.onLocalDraftDeleted(id); return true; } - return store.remove(id); + return deleteSidebarConversation(id, props); }, options, ); @@ -319,6 +323,7 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { const sortedProjects = useMemo( () => sortWorkspaceProjectsByActivity(projects, { + projectOrder: props.projectOrder, projectActivityUpdatedAts: projectActivityInputs.workdirActivity, runningProjectPathKeys: effectiveRunningActivity.runningProjectPathKeys, }), @@ -326,11 +331,14 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { effectiveRunningActivity.runningProjectPathKeys, projectActivityInputs.workdirActivity, projects, + props.projectOrder, ], ); return ( + listPinnedSidebarConversations(async (page, pageSize) => { + const response = await api.listHistory(page, pageSize); + return { + items: response.conversations.map(normalizeGatewayConversationSummary), + totalCount: response.total_count, + }; + }), async listConversations(page, pageSize, scope): Promise { const filter = scopeToHistoryListFilter(scope); if (!filter) { diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs index 034541808..9a04326fb 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs @@ -46,6 +46,9 @@ const SYSTEM_COMMAND_SAFETY_MODE_KEY: &str = "commandSafetyMode"; const SYSTEM_BROWSER_AUTOMATION_MODE_KEY: &str = "browserAutomationMode"; const SYSTEM_WORKSPACE_PROJECTS_KEY: &str = "workspaceProjects"; const SYSTEM_WORKSPACE_PROJECT_GROUPS_KEY: &str = "workspaceProjectGroups"; +const SYSTEM_WORKSPACE_PROJECT_ORDER_KEY: &str = "workspaceProjectOrder"; +const SYSTEM_SIDEBAR_PINNED_ORDER_KEY: &str = "sidebarPinnedOrder"; +const SYSTEM_ARCHIVED_CONVERSATIONS_KEY: &str = "archivedConversations"; const SYSTEM_ACTIVE_WORKSPACE_PROJECT_ID_KEY: &str = "activeWorkspaceProjectId"; const SYSTEM_HIDDEN_WORKSPACE_PROJECT_PATHS_KEY: &str = "hiddenWorkspaceProjectPaths"; const SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY: &str = "missingWorkspaceProjectPaths"; diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs index 7a3eea389..5eadb0f89 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs @@ -648,7 +648,17 @@ fn save_system_with_default_workdir( SYSTEM_WORKSPACE_RESOURCE_SETTINGS_KEY, SYSTEM_SYSTEM_PROXY_KEY, SYSTEM_CUA_ALLOW_SELF_TARGETING_KEY, - ] { + ] + .into_iter() + .chain( + [ + SYSTEM_WORKSPACE_PROJECT_ORDER_KEY, + SYSTEM_SIDEBAR_PINNED_ORDER_KEY, + SYSTEM_ARCHIVED_CONVERSATIONS_KEY, + ] + .into_iter() + .filter(|key| system.contains_key(*key)), + ) { let value = system.get(key).cloned().unwrap_or(Value::Null); tx.execute( SYSTEM_SETTINGS_INSERT_SQL, diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs index a65847b7d..10bf3ea23 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs @@ -19,6 +19,51 @@ mod tests { .expect("collect table columns") } + #[test] + fn save_system_preserves_sidebar_order_across_database_reopen() { + let temp = tempfile::tempdir().expect("temp database"); + let path = temp.path().join("settings.sqlite"); + let order = json!(["/workspace/b", "/workspace/a"]); + let pins = json!([ + "workspace:/workspace/a", + "conversation:one", + "workspace:/workspace/b" + ]); + let archive = + json!([{ "id": "archived", "title": "Archived conversation", "cwd": "/workspace/a" }]); + { + let mut conn = Connection::open(&path).expect("open database"); + initialize_schema(&conn).expect("initialize database"); + save_system_with_default_workdir( + &mut conn, + json!({ + "workspaceProjectOrder": order, + "sidebarPinnedOrder": pins, + "archivedConversations": archive, + }), + "/workspace/default", + ) + .expect("save sidebar preferences"); + } + let mut conn = Connection::open(&path).expect("reopen database"); + let mut loaded = + load_system_with_defaults(&conn, "/workspace/default").expect("load preferences"); + assert_eq!(loaded.get(SYSTEM_WORKSPACE_PROJECT_ORDER_KEY), Some(&order)); + assert_eq!(loaded.get(SYSTEM_SIDEBAR_PINNED_ORDER_KEY), Some(&pins)); + assert_eq!( + loaded.get(SYSTEM_ARCHIVED_CONVERSATIONS_KEY), + Some(&archive) + ); + loaded["executionMode"] = json!("tools"); + save_system_with_default_workdir(&mut conn, loaded, "/workspace/default") + .expect("save unrelated setting"); + let loaded = load_system(&conn) + .expect("reload preferences") + .expect("system settings"); + assert_eq!(loaded.get(SYSTEM_SIDEBAR_PINNED_ORDER_KEY), Some(&pins)); + assert_eq!(loaded.get(SYSTEM_WORKSPACE_PROJECT_ORDER_KEY), Some(&order)); + } + #[test] fn initialize_schema_creates_all_tables() { let conn = open_memory_db(); diff --git a/crates/agent-gui/src/lib/sidebar/guiSidebarBackend.ts b/crates/agent-gui/src/lib/sidebar/guiSidebarBackend.ts index c16501605..d6f2c9ccf 100644 --- a/crates/agent-gui/src/lib/sidebar/guiSidebarBackend.ts +++ b/crates/agent-gui/src/lib/sidebar/guiSidebarBackend.ts @@ -1,3 +1,4 @@ +import { listPinnedSidebarConversations } from "@liveagent/ui/lib/sidebar/pinnedHistory"; // GUI adapter for the shared sidebar state layer: wraps the Tauri chat-history // IPC surface and the single CHAT_HISTORY_SYNC_EVENT subscription. This file // is NOT mirrored — it is the desktop end's platform boundary. @@ -61,6 +62,8 @@ function toSidebarBackendEvent(event: ChatHistorySyncEvent): SidebarBackendEvent export function createGuiSidebarBackend(): SidebarBackend { return { + listPinnedConversations: () => + listPinnedSidebarConversations((page, pageSize) => listChatHistory(page, pageSize)), // scope.kind === "none" never reaches the adapter — the store resolves it // locally to an empty list without an IPC round-trip. listConversations: async (page, pageSize, scope) => { diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 0894fa0bc..f4803ea85 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -50,6 +50,7 @@ import { type ConversationOpenState, createConversationOpenController, } from "@liveagent/ui/lib/sidebar/openController"; +import { setSidebarConversationArchived } from "@liveagent/ui/lib/sidebar/preferences"; import { conversationMatchesScope } from "@liveagent/ui/lib/sidebar/scope"; import { selectConversations, @@ -3917,6 +3918,24 @@ export function ChatPage(props: ChatPageProps) { {workbenchDragGhost} {/* ---- Left column: navigation/sidebar ---- */} + setSettings((previous) => ({ + ...previous, + system: { ...previous.system, sidebarPinnedOrder }, + })) + } + projectOrder={settings.system.workspaceProjectOrder} + onReorderProjects={(workspaceProjectOrder) => + setSettings((previous) => ({ + ...previous, + system: { ...previous.system, workspaceProjectOrder }, + })) + } + archivedConversations={settings.system.archivedConversations} + onSetConversationArchived={(item, archived) => + setSettings((previous) => setSidebarConversationArchived(previous, item, archived)) + } store={sidebarStore} approvalStore={conversationRuntimeRegistry.approvals} questionStore={conversationRuntimeRegistry.questions} diff --git a/crates/agent-gui/src/pages/chat/hooks/useTauriFileDrop.ts b/crates/agent-gui/src/pages/chat/hooks/useTauriFileDrop.ts index 05b7387d3..d6cb83499 100644 --- a/crates/agent-gui/src/pages/chat/hooks/useTauriFileDrop.ts +++ b/crates/agent-gui/src/pages/chat/hooks/useTauriFileDrop.ts @@ -1,4 +1,6 @@ import { + absoluteWorkspacePath, + clearActiveWorkspacePathDrag, clearActiveWorkspacePathNativeHover, dispatchActiveWorkspacePathDrop, dispatchActiveWorkspacePathNativeHover, @@ -34,11 +36,10 @@ type UseTauriFileDropParams = { * surface ignores the drop. */ export function useTauriFileDrop(params: UseTauriFileDropParams) { - const { importUploadZonePaths, importWorkspaceFolderPaths, onDropPositionChange } = params; + const callbacksRef = useRef(params); + callbacksRef.current = params; const [activeDropTarget, setActiveDropTarget] = useState(null); const activeDropTargetRef = useRef(null); - const onDropPositionChangeRef = useRef(onDropPositionChange); - onDropPositionChangeRef.current = onDropPositionChange; useEffect(() => { // The Vite page can also be opened directly in a browser during @@ -47,22 +48,47 @@ export function useTauriFileDrop(params: UseTauriFileDropParams) { if (!isTauri()) return; let cancelled = false; + let nativeFileDragActive = false; let unlisten: (() => void) | null = null; getCurrentWebview() .onDragDropEvent((event) => { + if (cancelled) return; + const { importUploadZonePaths, importWorkspaceFolderPaths, onDropPositionChange } = + callbacksRef.current; if (event.payload.type === "enter" || event.payload.type === "over") { const scaleFactor = nativeDropPositionScaleFactor( window.navigator.userAgent, window.devicePixelRatio, ); - // WKWebView forwards an in-app HTML drag through this native API as - // well. It is not an OS file upload and must keep its own target - // semantics (composer mention / terminal path insertion). + // WKWebView also reports HTML/text drags here. Only file payloads or + // our explicit file-tree bridge may show file-import feedback. + if (event.payload.type === "enter") { + nativeFileDragActive = event.payload.paths.length > 0; + if (nativeFileDragActive) clearActiveWorkspacePathDrag(); + } + if (!nativeFileDragActive && !getActiveWorkspacePathDrag()) { + activeDropTargetRef.current = null; + setActiveDropTarget(null); + onDropPositionChange?.(null); + return; + } + const nextTarget = resolveNativeFileDropTarget(event.payload.position, { scaleFactor }); + // A folder from the file tree follows the same workspace import rule + // as a Finder/Explorer folder. Elsewhere it keeps its mention semantics. + const internalDrag = getActiveWorkspacePathDrag(); + if (internalDrag && nextTarget === "workspace") { + clearActiveWorkspacePathNativeHover(); + const target = internalDrag.entryKind === "dir" ? "workspace" : null; + activeDropTargetRef.current = target; + setActiveDropTarget(target); + onDropPositionChange?.(null); + return; + } if (getActiveWorkspacePathDrag()) { activeDropTargetRef.current = null; setActiveDropTarget(null); - onDropPositionChangeRef.current?.(null); + onDropPositionChange?.(null); dispatchActiveWorkspacePathNativeHover({ x: event.payload.position.x / (scaleFactor || 1), y: event.payload.position.y / (scaleFactor || 1), @@ -70,10 +96,9 @@ export function useTauriFileDrop(params: UseTauriFileDropParams) { return; } clearActiveWorkspacePathNativeHover(); - const nextTarget = resolveNativeFileDropTarget(event.payload.position, { scaleFactor }); activeDropTargetRef.current = nextTarget; setActiveDropTarget(nextTarget); - onDropPositionChangeRef.current?.({ + onDropPositionChange?.({ x: event.payload.position.x / (scaleFactor || 1), y: event.payload.position.y / (scaleFactor || 1), }); @@ -81,14 +106,33 @@ export function useTauriFileDrop(params: UseTauriFileDropParams) { } if (event.payload.type === "drop") { + nativeFileDragActive = false; const scaleFactor = nativeDropPositionScaleFactor( window.navigator.userAgent, window.devicePixelRatio, ); + if (event.payload.paths.length > 0) clearActiveWorkspacePathDrag(); + const dropTarget = resolveFinalNativeFileDropTarget( + activeDropTargetRef.current, + event.payload.position, + { scaleFactor }, + ); + if (dropTarget === "workspace") { + const internalDrag = getActiveWorkspacePathDrag(); + const folderPath = + internalDrag?.entryKind === "dir" ? absoluteWorkspacePath(internalDrag) : null; + clearActiveWorkspacePathDrag(); + setActiveDropTarget(null); + activeDropTargetRef.current = null; + onDropPositionChange?.(null); + const paths = internalDrag ? (folderPath ? [folderPath] : []) : event.payload.paths; + if (paths.length > 0) void importWorkspaceFolderPaths(paths); + return; + } if (getActiveWorkspacePathDrag()) { setActiveDropTarget(null); activeDropTargetRef.current = null; - onDropPositionChangeRef.current?.(null); + onDropPositionChange?.(null); dispatchActiveWorkspacePathDrop({ x: event.payload.position.x / (scaleFactor || 1), y: event.payload.position.y / (scaleFactor || 1), @@ -96,18 +140,9 @@ export function useTauriFileDrop(params: UseTauriFileDropParams) { return; } clearActiveWorkspacePathNativeHover(); - const dropTarget = resolveFinalNativeFileDropTarget( - activeDropTargetRef.current, - event.payload.position, - { scaleFactor }, - ); setActiveDropTarget(null); activeDropTargetRef.current = null; - onDropPositionChangeRef.current?.(null); - if (dropTarget === "workspace") { - void importWorkspaceFolderPaths(event.payload.paths); - return; - } + onDropPositionChange?.(null); if (dropTarget !== "upload") return; // An empty native payload is never an upload. In particular, this // prevents non-file drags from reaching Rust's path classifier. @@ -120,10 +155,11 @@ export function useTauriFileDrop(params: UseTauriFileDropParams) { return; } + nativeFileDragActive = false; clearActiveWorkspacePathNativeHover(); setActiveDropTarget(null); activeDropTargetRef.current = null; - onDropPositionChangeRef.current?.(null); + onDropPositionChange?.(null); }) .then((nextUnlisten) => { if (cancelled) { @@ -143,7 +179,7 @@ export function useTauriFileDrop(params: UseTauriFileDropParams) { unlisten(); } }; - }, [importUploadZonePaths, importWorkspaceFolderPaths]); + }, []); return { isFileDropActive: activeDropTarget === "upload", diff --git a/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx b/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx index 406717aca..51149827f 100644 --- a/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx +++ b/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx @@ -12,7 +12,10 @@ import { } from "@liveagent/ui/components/chat/ChatHistorySidebar"; import { useLocale } from "@liveagent/ui/i18n/index"; import type { SidebarBatchDeleteOptions } from "@liveagent/ui/lib/sidebar/batchDelete"; -import { deleteSidebarConversations } from "@liveagent/ui/lib/sidebar/batchDelete"; +import { + deleteSidebarConversation, + deleteSidebarConversations, +} from "@liveagent/ui/lib/sidebar/batchDelete"; import type { SidebarStore } from "@liveagent/ui/lib/sidebar/store"; import type { SidebarConversation } from "@liveagent/ui/lib/sidebar/types"; import { useSidebarContainerState } from "@liveagent/ui/lib/sidebar/useSidebarContainerState"; @@ -116,6 +119,8 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { projects, onConversationDeleted, onConversationCwdChanged, + archivedConversations, + onSetConversationArchived, } = props; const { t } = useLocale(); @@ -127,7 +132,8 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { mutations: busyConversationIds, mutationErrors, projectActivityInputs, - } = useSidebarContainerState(store); + workspaceHistory, + } = useSidebarContainerState(store, props.showProjects); const [renamingId, setRenamingId] = useState(null); const [renameDraft, setRenameDraft] = useState(""); @@ -137,10 +143,16 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { const sortedProjects = useMemo( () => sortWorkspaceProjectsByActivity(projects, { + projectOrder: props.projectOrder, projectActivityUpdatedAts: projectActivityInputs.workdirActivity, runningProjectPathKeys: projectActivityInputs.runningWorkdirPathKeys, }), - [projectActivityInputs.runningWorkdirPathKeys, projectActivityInputs.workdirActivity, projects], + [ + projectActivityInputs.runningWorkdirPathKeys, + projectActivityInputs.workdirActivity, + projects, + props.projectOrder, + ], ); const handleStartRenaming = useCallback( @@ -194,16 +206,22 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { [onConversationCwdChanged, store], ); + const removeConversation = useCallback( + (id: string) => + deleteSidebarConversation(id, { store, archivedConversations, onSetConversationArchived }), + [store, archivedConversations, onSetConversationArchived], + ); + const handleDeleteConversation = useCallback( (id: string) => { store.clearMutationError(id); - void store.remove(id).then((removed) => { + void removeConversation(id).then((removed) => { if (removed) { onConversationDeleted(id); } }); }, - [onConversationDeleted, store], + [onConversationDeleted, store, removeConversation], ); const handleDeleteConversations = useCallback( @@ -212,7 +230,7 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { ids, async (id) => { store.clearMutationError(id); - return store.remove(id); + return removeConversation(id); }, options, ); @@ -221,7 +239,7 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { } return result; }, - [onConversationDeleted, store], + [onConversationDeleted, store, removeConversation], ); const handleLoadMore = useCallback(() => { @@ -244,6 +262,8 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { return ( { test("shared sidebar and composer wire native drag to a structured mention insertion", () => { assert.match(sidebarRowsSource, /writeConversationReferenceDragPayload/); - assert.match(sidebarRowsSource, /draggable=\{!onWorkbenchDragIntent && !item\.isPending\}/); + assert.match(sidebarRowsSource, /draggable=\{!props\.onReorderPointerDown && !onWorkbenchDragIntent && !item\.isPending\}/); assert.match(composerBarSource, /data-conversation-reference-drop-zone/); assert.match(composerBarSource, /readConversationReferenceDragPayload/); assert.match(composerBarSource, /insertConversationMention\(reference\)/); diff --git a/crates/agent-gui/test/chat/folder-drop-feedback-ui.test.mjs b/crates/agent-gui/test/chat/folder-drop-feedback-ui.test.mjs index 6ab069805..aef5c8a36 100644 --- a/crates/agent-gui/test/chat/folder-drop-feedback-ui.test.mjs +++ b/crates/agent-gui/test/chat/folder-drop-feedback-ui.test.mjs @@ -33,6 +33,7 @@ test("folder import notifications expose accessible status and motion behavior", test("workspace drop label truncates safely in narrow translated layouts", () => { assert.match( sidebarSource, - /\s*\{workspaceFolderDropActive[\s\S]*?chat\.workspaceDropFolder/, + /\s*\{showWorkspaceFolderDrop[\s\S]*?chat\.workspaceDropFolder/, ); + assert.match(sidebarSource, /showWorkspaceFolderDrop = workspaceFolderDropActive && !reorder\.draggingKey/); }); diff --git a/crates/agent-gui/test/chat/native-workspace-folder-drop.test.mjs b/crates/agent-gui/test/chat/native-workspace-folder-drop.test.mjs new file mode 100644 index 000000000..62fbaf63e --- /dev/null +++ b/crates/agent-gui/test/chat/native-workspace-folder-drop.test.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +function mountDropHook(t) { + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + globalThis.window = { + navigator: { userAgent: "Macintosh; Intel Mac OS X" }, + devicePixelRatio: 2, + }; + globalThis.document = { + querySelectorAll(selector) { + return selector === "[data-workspace-folder-drop-zone]" + ? [{ getBoundingClientRect: () => ({ left: 0, top: 300, right: 272, bottom: 680 }) }] + : [{ + getBoundingClientRect: () => ({ left: 400, top: 500, right: 1100, bottom: 660 }), + getAttribute: () => "conversation-b", + }]; + }, + }; + const refs = []; + let refIndex = 0; + let handler; + let cleanup; + let registrations = 0; + let internalDrag = null; + const imports = []; + const uploads = []; + const mentions = []; + const activeTargets = []; + const loader = createTsModuleLoader({ + mocks: { + react: { + useRef(initialValue) { + const index = refIndex++; + refs[index] ??= { current: initialValue }; + return refs[index]; + }, + useState: (initialValue) => [initialValue, (target) => activeTargets.push(target)], + useEffect(effect) { + if (!cleanup) cleanup = effect(); + }, + }, + "@tauri-apps/api/core": { isTauri: () => true }, + "@tauri-apps/api/webview": { + getCurrentWebview: () => ({ + onDragDropEvent: async (callback) => { + registrations += 1; + handler = callback; + return () => {}; + }, + }), + }, + "@liveagent/ui/lib/chat/workspacePathDrag": { + getActiveWorkspacePathDrag: () => internalDrag, + clearActiveWorkspacePathDrag: () => { internalDrag = null; }, + clearActiveWorkspacePathNativeHover: () => {}, + dispatchActiveWorkspacePathNativeHover: () => {}, + dispatchActiveWorkspacePathDrop: (position) => { mentions.push(position); }, + absoluteWorkspacePath: (payload) => `${payload.cwd}/${payload.relativePath}`, + }, + }, + }); + const { useTauriFileDrop } = loader.loadModule("src/pages/chat/hooks/useTauriFileDrop.ts"); + const params = { + importWorkspaceFolderPaths: async (paths) => { imports.push(paths); }, + importUploadZonePaths: async (paths, conversationId) => { uploads.push({ paths, conversationId }); }, + }; + const render = (overrides = {}) => { + refIndex = 0; + useTauriFileDrop({ ...params, ...overrides }); + }; + render(); + t.after(() => { + cleanup?.(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + }); + return { + imports, + activeTargets, + uploads, + mentions, + render, + registrations: () => registrations, + setInternalDrag(entryKind = "dir") { + internalDrag = { entryKind, cwd: "/workspace", relativePath: "nested" }; + }, + emit(type, position, paths = []) { handler({ payload: { type, position, paths } }); }, + }; +} + +test("native folders dropped on workspace rows and empty margins import as workspaces", (t) => { + const hook = mountDropHook(t); + for (const position of [{ x: 2, y: 350 }, { x: 150, y: 450 }, { x: 270, y: 678 }]) { + hook.emit("drop", position, ["/new/workspace"]); + } + assert.deepEqual(hook.imports, Array(3).fill(["/new/workspace"])); + assert.deepEqual(hook.uploads, []); + assert.deepEqual(hook.mentions, []); +}); + +test("release over the workspace wins over earlier composer hover", (t) => { + const hook = mountDropHook(t); + hook.emit("enter", { x: 700, y: 600 }, ["/new/workspace"]); + hook.emit("drop", { x: 150, y: 450 }, ["/new/workspace"]); + assert.deepEqual(hook.imports, [["/new/workspace"]]); + assert.deepEqual(hook.uploads, []); +}); + +test("a folder dragged from the file tree into the sidebar creates a workspace", (t) => { + const hook = mountDropHook(t); + hook.setInternalDrag(); + hook.emit("over", { x: 150, y: 450 }); + hook.emit("drop", { x: 150, y: 450 }); + assert.deepEqual(hook.imports, [["/workspace/nested"]]); + assert.deepEqual(hook.uploads, []); + assert.deepEqual(hook.mentions, []); +}); + +test("a file-tree file dropped into the sidebar is neither a workspace nor a mention", (t) => { + const hook = mountDropHook(t); + hook.setInternalDrag("file"); + hook.emit("drop", { x: 150, y: 450 }); + assert.deepEqual(hook.imports, []); + assert.deepEqual(hook.uploads, []); + assert.deepEqual(hook.mentions, []); +}); + +test("composer drops preserve the conversation-specific upload and internal mention routes", (t) => { + const hook = mountDropHook(t); + hook.emit("drop", { x: 700, y: 600 }, ["/new/workspace"]); + assert.deepEqual(hook.uploads, [{ paths: ["/new/workspace"], conversationId: "conversation-b" }]); + hook.setInternalDrag(); + hook.emit("drop", { x: 700, y: 600 }); + assert.deepEqual(hook.mentions, [{ x: 700, y: 600 }]); + assert.deepEqual(hook.imports, []); +}); + +test("an external drop is not claimed by stale internal drag data", (t) => { + const hook = mountDropHook(t); + hook.setInternalDrag(); + hook.emit("drop", { x: 150, y: 450 }, ["/finder/folder"]); + assert.deepEqual(hook.imports, [["/finder/folder"]]); + assert.deepEqual(hook.mentions, []); +}); + +test("native subscription stays stable while drop callbacks receive fresh state", (t) => { + const hook = mountDropHook(t); + const updatedImports = []; + hook.render({ importWorkspaceFolderPaths: async (paths) => { updatedImports.push(paths); } }); + hook.emit("drop", { x: 150, y: 450 }, ["/new/workspace"]); + assert.equal(hook.registrations(), 1); + assert.deepEqual(hook.imports, []); + assert.deepEqual(updatedImports, [["/new/workspace"]]); +}); + +test("empty OS drops and drops above the workspace area are ignored", (t) => { + const hook = mountDropHook(t); + hook.emit("drop", { x: 150, y: 450 }); + hook.emit("drop", { x: 150, y: 100 }, ["/new/workspace"]); + assert.deepEqual(hook.imports, []); + assert.deepEqual(hook.uploads, []); +}); + + +test("internal sidebar/text drags never show import feedback, while real files still do", (t) => { + const hook = mountDropHook(t); + const position = { x: 150, y: 450 }; + hook.emit("enter", position); + hook.emit("over", position); + assert.ok(hook.activeTargets.every((target) => target === null)); + hook.emit("enter", position, ["/finder/folder"]); + hook.emit("over", position); + assert.equal(hook.activeTargets.at(-1), "workspace"); + hook.emit("leave", position); + hook.emit("over", position); + assert.equal(hook.activeTargets.at(-1), null); + hook.emit("drop", position); + assert.deepEqual(hook.imports, []); +}); diff --git a/crates/agent-gui/test/chat/sidebar-archived-delete.test.mjs b/crates/agent-gui/test/chat/sidebar-archived-delete.test.mjs new file mode 100644 index 000000000..c43469cad --- /dev/null +++ b/crates/agent-gui/test/chat/sidebar-archived-delete.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { createSidebarStore } = loader.loadModule("@liveagent/ui/lib/sidebar/store.ts"); +const { deleteSidebarConversation } = loader.loadModule("@liveagent/ui/lib/sidebar/batchDelete.ts"); +const archived = { id: "archived", title: "Archived conversation", cwd: "/workspace" }; + +function setup({ cached = true, fail = false } = {}) { + const requests = []; + let confirm; + const deletion = new Promise((resolve) => { confirm = resolve; }); + const store = createSidebarStore({ + async deleteConversation(id) { + requests.push(id); + await deletion; + if (fail) throw new Error("delete failed"); + }, + async listWorkdirs() { return []; }, + }); + if (cached) store.upsertLocal({ ...archived, providerId: "test", model: "test", createdAt: 1, updatedAt: 1 }); + const context = { + store, + archivedConversations: [archived], + onSetConversationArchived(item, value) { + assert.equal(value, false); + context.archivedConversations = context.archivedConversations.filter((row) => row.id !== item.id); + }, + }; + return { context, store, requests, confirm }; +} + +for (const cached of [true, false]) { + test(`deleting an archived conversation clears metadata only after confirmation (cached=${cached})`, async () => { + const { context, store, requests, confirm } = setup({ cached }); + const pending = deleteSidebarConversation(archived.id, context); + assert.deepEqual(requests, [archived.id]); + assert.equal(context.archivedConversations.length, 1, "optimistic removal must retain archive metadata"); + confirm(); + assert.equal(await pending, true); + assert.equal(context.archivedConversations.length, 0); + assert.equal(store.peek(archived.id), undefined); + }); +} + +test("a failed archived deletion keeps a restorable row and exposes the error", async () => { + const { context, store, confirm } = setup({ cached: false, fail: true }); + const pending = deleteSidebarConversation(archived.id, context); + confirm(); + assert.equal(await pending, false); + assert.equal(context.archivedConversations.length, 1); + assert.equal(store.peek(archived.id)?.title, archived.title); + assert.equal(store.getSnapshot().mutationErrors.get(archived.id), "deleteFailed"); +}); + +test("running archived conversations remain protected from deletion", async () => { + const { context, store, requests } = setup(); + store.applyRunningPatch({ conversationId: archived.id, running: true }); + assert.equal(await deleteSidebarConversation(archived.id, context), false); + assert.deepEqual(requests, []); + assert.equal(context.archivedConversations.length, 1); +}); + +test("a repeated delete cannot recreate the row while its first deletion is pending", async () => { + const { context, store, requests, confirm } = setup({ cached: false }); + const pending = deleteSidebarConversation(archived.id, context); + assert.equal(await deleteSidebarConversation(archived.id, context), false); + assert.equal(store.peek(archived.id), undefined); + assert.deepEqual(requests, [archived.id]); + confirm(); + assert.equal(await pending, true); + assert.equal(context.archivedConversations.length, 0); + assert.equal(store.peek(archived.id), undefined); +}); diff --git a/crates/agent-gui/test/chat/sidebar-preferences.test.mjs b/crates/agent-gui/test/chat/sidebar-preferences.test.mjs new file mode 100644 index 000000000..f09e6c5b7 --- /dev/null +++ b/crates/agent-gui/test/chat/sidebar-preferences.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { reorderSidebarProjects, setSidebarConversationArchived } = loader.loadModule("@liveagent/ui/lib/sidebar/preferences.ts"); +const { sortWorkspaceProjectsByActivity } = loader.loadModule("@liveagent/ui/lib/workspaceProjects.ts"); +const { getDefaultSettings, normalizeSettings } = loader.loadModule("@liveagent/ui/lib/settings/index.ts"); +const { buildGatewaySettingsSyncPayload, applyGatewaySettingsSyncPayload } = loader.loadModule("@liveagent/ui/lib/settings/sync.ts"); +const { listPinnedSidebarConversations } = loader.loadModule("@liveagent/ui/lib/sidebar/pinnedHistory.ts"); +const plain = (value) => JSON.parse(JSON.stringify(value)); +const project = (id, overrides = {}) => ({ id, name: id, path: `/projects/${id}`, kind: "folder", createdAt: 1, updatedAt: 1, ...overrides }); + +test("dragging changes the persisted order and new activity does not override it", () => { + const projects = [project("a"), project("b"), project("c")]; + const order = reorderSidebarProjects(projects, "c", "a", "before"); + const settings = normalizeSettings({ ...getDefaultSettings(), system: { ...getDefaultSettings().system, workspaceProjectOrder: order } }); + const sorted = sortWorkspaceProjectsByActivity(projects, { projectOrder: settings.system.workspaceProjectOrder, projectActivityUpdatedAts: new Map([["/projects/b", 999999]]), runningProjectPathKeys: new Set(["/projects/b"]) }); + assert.deepEqual(plain(sorted.map((item) => item.id)), ["c", "a", "b"]); + assert.deepEqual(plain(reorderSidebarProjects(sorted, "c", "b", "after")), ["/projects/a", "/projects/b", "/projects/c"]); +}); + +test("dragging does not implicitly pin or unpin workspaces", () => { + const projects = [project("p", { isPinned: true }), project("a")]; + assert.equal(reorderSidebarProjects(projects, "a", "p", "before"), null); + assert.equal(reorderSidebarProjects(projects, "missing", "a", "after"), null); + assert.equal(reorderSidebarProjects(projects, "a", "a", "after"), null); +}); + +test("archiving records restorable metadata and survives settings serialization", () => { + const original = getDefaultSettings(); + const entry = { id: "conversation-1", title: "Keep all message content", cwd: "/project" }; + const archived = setSidebarConversationArchived(original, entry, true); + const roundtrip = normalizeSettings(plain(archived)); + assert.deepEqual(plain(roundtrip.system.archivedConversations), [entry]); + assert.deepEqual(plain(original.system.archivedConversations), []); + const restored = setSidebarConversationArchived(roundtrip, entry, false); + assert.deepEqual(plain(restored.system.archivedConversations), []); +}); + +test("normalization deduplicates archives and rejects malformed preferences", () => { + const settings = normalizeSettings({ system: { archivedConversations: [null, { id: "" }, { id: " a ", title: "First" }, { id: "a", title: "Latest" }], workspaceProjectOrder: [" /one/ ", "/one", null] } }); + assert.deepEqual(plain(settings.system.archivedConversations), [{ id: "a", title: "Latest" }]); + assert.deepEqual(plain(settings.system.workspaceProjectOrder), ["/one"]); +}); + +test("global pinned history reads subsequent pages and stops at the first ordinary row", async () => { + const calls = []; + const result = await listPinnedSidebarConversations(async (page, pageSize) => { + calls.push([page, pageSize]); + return { items: page === 1 ? Array.from({ length: 200 }, (_, i) => ({ id: String(i), isPinned: true })) : [{ id: "last", isPinned: true }, { id: "ordinary", isPinned: false }], totalCount: 10000 }; + }); + assert.equal(result.length, 201); + assert.deepEqual(calls, [[1, 200], [2, 200]]); +}); + + +test("archive metadata and manual ordering survive gateway settings synchronization", () => { + const settings = getDefaultSettings(); + settings.system.workspaceProjectOrder = ["/b", "/a"]; + const next = setSidebarConversationArchived(settings, { id: "saved", title: "Archived", cwd: "/b" }, true); + const payload = buildGatewaySettingsSyncPayload(next); + const received = applyGatewaySettingsSyncPayload(getDefaultSettings(), plain(payload)); + assert.deepEqual(plain(received.system.workspaceProjectOrder), ["/b", "/a"]); + assert.deepEqual(plain(received.system.archivedConversations), [{ id: "saved", title: "Archived", cwd: "/b" }]); +}); + +test("pinned conversations and workspaces share one persistent ordering", () => { + const { buildSidebarPinnedEntries, reorderSidebarPinnedEntries } = loader.loadModule("@liveagent/ui/lib/sidebar/preferences.ts"); + const conversations = [{ id: "one" }, { id: "two" }]; + const workspaces = [{ id: "a", path: "/a" }, { id: "b", path: "/b" }]; + const entries = buildSidebarPinnedEntries(conversations, workspaces); + const order = reorderSidebarPinnedEntries(entries, "workspace:/b", "conversation:two", "before"); + assert.deepEqual(plain(order), ["conversation:one", "workspace:/b", "conversation:two", "workspace:/a"]); + const settings = normalizeSettings({ ...getDefaultSettings(), system: { ...getDefaultSettings().system, sidebarPinnedOrder: order } }); + const restored = buildSidebarPinnedEntries(conversations, workspaces, settings.system.sidebarPinnedOrder); + assert.deepEqual(plain(restored.map((entry) => entry.key)), plain(order)); + assert.deepEqual(plain(reorderSidebarPinnedEntries(restored, "conversation:one", "workspace:/a", "after")), + ["workspace:/b", "conversation:two", "workspace:/a", "conversation:one"]); + assert.equal(reorderSidebarPinnedEntries(restored, "missing", "workspace:/a", "after"), null); + assert.equal(reorderSidebarPinnedEntries(restored, "workspace:/a", "workspace:/a", "after"), null); +}); diff --git a/crates/agent-gui/test/chat/sidebar-reorder-drag.test.mjs b/crates/agent-gui/test/chat/sidebar-reorder-drag.test.mjs new file mode 100644 index 000000000..dac297cd0 --- /dev/null +++ b/crates/agent-gui/test/chat/sidebar-reorder-drag.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; + +const env = await createDomTestEnv(); +const { act, createRoot } = env; +const { useSidebarReorderDrag } = env.loadModule("@liveagent/ui/lib/sidebar/useSidebarReorderDrag.ts"); +let drag; +let hit; +document.elementFromPoint = () => hit; +HTMLElement.prototype.scrollBy = () => {}; + +async function setup(t) { + const container = document.createElement("div"); + document.body.append(container); + container.getBoundingClientRect = () => ({ left: 0, right: 272, top: 0, bottom: 600 }); + const rows = ["conversation:one", "workspace:/a", "workspace:/b"].map((key) => { + const button = document.createElement("button"); + button.dataset.sidebarReorderKey = key; + button.getBoundingClientRect = () => ({ left: 0, right: 272, top: 100, bottom: 130, height: 30 }); + container.append(button); + return button; + }); + const calls = []; + let props = { + containerRef: { current: container }, disabled: false, scopeKey: "workspace", + canDrop: (source, target) => source !== target, + onDrop: (...args) => calls.push(args), + }; + function Harness() { drag = useSidebarReorderDrag(props); return null; } + const root = createRoot(document.createElement("div")); + await act(async () => root.render(React.createElement(Harness))); + t.after(async () => { await act(async () => root.unmount()); container.remove(); }); + return { + rows, calls, + async start(index = 0) { + await act(async () => drag.onPointerDown(rows[index].dataset.sidebarReorderKey, + { pointerId: 1, clientX: 40, clientY: 20, currentTarget: rows[index] })); + }, + async render(changes) { props = { ...props, ...changes }; await act(async () => root.render(React.createElement(Harness))); }, + }; +} + +async function pointer(type, x, y) { + const event = new window.MouseEvent(type, { clientX: x, clientY: y, cancelable: true }); + Object.defineProperty(event, "pointerId", { value: 1 }); + await act(async () => window.dispatchEvent(event)); +} + +test("plain clicks do not sort; dragging inserts at the final pointer target and suppresses the row click", async (t) => { + const h = await setup(t); + await h.start(); + await pointer("pointermove", 42, 22); + await pointer("pointerup", 42, 22); + assert.equal(drag.draggingKey, null); + assert.deepEqual(h.calls, []); + hit = h.rows[1]; + await h.start(); + await pointer("pointermove", 50, 105); + assert.equal(drag.draggingKey, "conversation:one"); + assert.equal(drag.dropTarget.position, "before"); + // Final release chooses the final target and lower half, not the stale hover. + hit = h.rows[2]; + await pointer("pointerup", 50, 128); + assert.deepEqual(h.calls, [["conversation:one", "workspace:/b", "after"]]); + assert.equal(drag.draggingKey, null); + assert.equal(document.body.style.cursor, ""); + let blocked = false; + drag.onClickCapture({ preventDefault() { blocked = true; }, stopPropagation() {} }); + assert.equal(blocked, true); + blocked = false; + drag.onPointerDownCapture(); + drag.onClickCapture({ preventDefault() { blocked = true; }, stopPropagation() {} }); + assert.equal(blocked, false); +}); + +test("invalid regions, Escape and scope changes cancel without persisting a sort", async (t) => { + const h = await setup(t); + hit = h.rows[1]; + await h.render({ canDrop: () => false }); + await h.start(); + await pointer("pointermove", 50, 105); + assert.equal(drag.dropTarget, null); + await pointer("pointerup", 50, 105); + assert.deepEqual(h.calls, []); + await h.render({ canDrop: () => true }); + await h.start(); + await pointer("pointermove", 50, 105); + await act(async () => window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }))); + await pointer("pointerup", 50, 105); + assert.deepEqual(h.calls, []); + await h.start(); + await pointer("pointermove", 50, 105); + await h.render({ scopeKey: "new-workspace" }); + await pointer("pointerup", 50, 105); + assert.deepEqual(h.calls, []); + assert.equal(document.body.style.userSelect, ""); +}); diff --git a/crates/agent-gui/test/chat/sidebar-selection.test.mjs b/crates/agent-gui/test/chat/sidebar-selection.test.mjs index c4f5feba8..b5a6adfed 100644 --- a/crates/agent-gui/test/chat/sidebar-selection.test.mjs +++ b/crates/agent-gui/test/chat/sidebar-selection.test.mjs @@ -153,26 +153,3 @@ test("conversation rename suppresses the menu's return-focus without changing do assert.match(source, /onDoubleClick=\{\(event\) => \{[\s\S]*?handleStartRenaming\(\);/); assert.doesNotMatch(source, /ignoreMenuCloseBlurRef/); }); - -test("the new-group draft survives the add menu's return-focus and cancels on demand", () => { - const source = readFileSync( - new URL("../../../agent-ui/src/components/chat/ChatHistorySidebar.tsx", import.meta.url), - "utf8", - ); - - // Only the "new group" item mounts an input in the commit that unmounts the - // menu, so only it opts out of Base UI's return-focus. "New workspace" opens - // a dialog and still wants the trigger back. - assert.equal((source.match(/suppressAddMenuReturnFocusRef\.current = true;/g) ?? []).length, 1); - assert.equal((source.match(/finalFocus=\{\(\) => \{/g) ?? []).length, 1); - // An effect owns focus placement; autoFocus loses the race against the - // menu's synchronous return-focus resolution. - assert.match(source, /groupDraftInputRef\.current\?\.focus\(\);/); - assert.doesNotMatch(source, /placeholder=\{t\("chat\.workspaceGroupNamePlaceholder"\)\}[\s\S]{0,400}?autoFocus/); - // Enter/Escape and both action buttons mark the blur handled, so the group is - // created exactly once no matter how the row is dismissed. - assert.equal((source.match(/skipNextGroupBlurCommitRef\.current = true;/g) ?? []).length, 4); - // Confirm/cancel must not blur the input first — otherwise onBlur commits the - // draft and the row unmounts before the cancel click ever lands. - assert.equal((source.match(/onMouseDown=\{\(event\) => \{\s*event\.preventDefault\(\);/g) ?? []).length, 2); -}); diff --git a/crates/agent-gui/test/chat/sidebar-workspace-history.test.mjs b/crates/agent-gui/test/chat/sidebar-workspace-history.test.mjs new file mode 100644 index 000000000..281094f1b --- /dev/null +++ b/crates/agent-gui/test/chat/sidebar-workspace-history.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const { createSidebarStore } = createTsModuleLoader().loadModule("@liveagent/ui/lib/sidebar/store.ts"); +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +const row = (id, cwd = "/alpha") => ({ id, title: id, cwd, providerId: "test", model: "test", createdAt: 1, updatedAt: 1 }); +function setup() { + const rows = new Map(Array.from({ length: 235 }, (_, i) => [String(i), row(String(i))])); + rows.set("beta", row("beta", "/beta")); + const calls = []; + let listener; + let connectionListener; + let override; + const backend = { + async listConversations(page, pageSize, scope) { + calls.push({ page, pageSize, scope }); + if (override) return override(page, pageSize, scope); + const matching = [...rows.values()].filter((item) => item.cwd === scope.cwd) + .sort((a, b) => Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned))); + const size = Math.min(pageSize, 200); + return { items: matching.slice((page - 1) * size, page * size), totalCount: matching.length }; + }, + async listWorkdirs() { return []; }, + subscribeEvents(fn) { listener = fn; return () => { listener = null; }; }, + subscribeConnection(fn) { connectionListener = fn; return () => { connectionListener = null; }; }, + async renameConversation(id, title) { const next = { ...rows.get(id), title }; rows.set(id, next); return next; }, + async setConversationPinned(id, isPinned) { const next = { ...rows.get(id), isPinned }; rows.set(id, next); return next; }, + async setConversationCwd(id, cwd) { const next = { ...rows.get(id), cwd }; rows.set(id, next); return next; }, + async deleteConversation(id) { rows.delete(id); }, + }; + const store = createSidebarStore(backend); + store.start(); + return { store, rows, calls, connect: (connected) => connectionListener?.(connected), emit: (event) => listener?.(event), setOverride: (fn) => { override = fn; } }; +} + +test("projects load lazily and retain independent 10-row limits without changing active scope", async (t) => { + const { store, calls } = setup(); t.after(() => store.stop()); + await tick(); + assert.equal(calls.length, 0); + await store.loadWorkspaceHistory("/alpha"); + await store.loadWorkspaceHistory("/beta"); + await store.loadWorkspaceHistory("/alpha", true); + assert.equal(store.getSnapshot().scopeKey, "none"); + assert.equal(store.getSnapshot().workspaceHistory.get("/alpha").limit, 20); + assert.equal(store.getSnapshot().workspaceHistory.get("/beta").limit, 10); + assert.equal(store.peek("19").cwd, "/alpha"); + assert.equal(store.peek("20"), undefined); +}); + +test("load more continues past the gateway's 200-row cap and reaches the end", async (t) => { + const { store, calls } = setup(); t.after(() => store.stop()); + await store.loadWorkspaceHistory("/alpha"); + for (let i = 0; i < 23; i++) await store.loadWorkspaceHistory("/alpha", true); + assert.equal(store.getSnapshot().workspaceHistory.get("/alpha").totalCount, 235); + assert.equal([...store.getSnapshot().byId.values()].length, 235); + assert.ok(calls.some(({ page }) => page === 2)); + assert.ok(calls.every(({ pageSize }) => pageSize <= 200)); +}); + +test("off-scope rows can be pinned, renamed and moved through the shared store", async (t) => { + const { store } = setup(); t.after(() => store.stop()); + await store.loadWorkspaceHistory("/alpha"); + assert.equal(await store.setPinned("4", true), true); + assert.equal(await store.rename("4", "Renamed"), true); + assert.equal(await store.setCwd("4", "/beta"), true); + assert.equal(store.peek("4").isPinned, true); + assert.equal(store.peek("4").title, "Renamed"); + assert.equal(store.peek("4").cwd, "/beta"); + assert.equal(store.getSnapshot().scopeKey, "none"); +}); + +test("a failed next page keeps existing rows and retries the same limit", async (t) => { + const { store, setOverride } = setup(); t.after(() => store.stop()); + await store.loadWorkspaceHistory("/alpha"); + setOverride(async () => { throw new Error("offline"); }); + await store.loadWorkspaceHistory("/alpha", true); + assert.equal(store.getSnapshot().workspaceHistory.get("/alpha").limit, 10); + assert.equal(store.getSnapshot().workspaceHistory.get("/alpha").error, "offline"); + assert.ok(store.peek("0")); + setOverride(null); + await store.loadWorkspaceHistory("/alpha"); + assert.equal(store.getSnapshot().workspaceHistory.get("/alpha").limit, 20); + assert.equal(store.getSnapshot().workspaceHistory.get("/alpha").error, null); +}); + +test("late project responses cannot overwrite rename events or resurrect deletions", async (t) => { + const { store, setOverride, emit } = setup(); t.after(() => store.stop()); + await store.loadWorkspaceHistory("/alpha"); + let resolve; + setOverride(() => new Promise((done) => { resolve = done; })); + const pending = store.loadWorkspaceHistory("/alpha", true); + emit({ kind: "delete", conversationId: "0" }); + emit({ kind: "upsert", conversationId: "1", conversation: { ...row("1"), title: "Fresh" } }); + resolve({ items: [row("0"), row("1")], totalCount: 2 }); + await pending; + assert.equal(store.peek("0"), undefined); + assert.equal(store.peek("1").title, "Fresh"); +}); + +test("stopping invalidates pending requests and allows a fresh load after restarting", async () => { + const { store, setOverride } = setup(); + let resolve; + setOverride(() => new Promise((done) => { resolve = done; })); + const pending = store.loadWorkspaceHistory("/alpha"); + store.stop(); + resolve({ items: [row("old")], totalCount: 1 }); + await pending; + assert.equal(store.peek("old"), undefined); + assert.equal(store.getSnapshot().workspaceHistory.get("/alpha").loading, false); + setOverride(null); + store.start(); + await store.loadWorkspaceHistory("/alpha"); + assert.ok(store.peek("0")); + store.stop(); +}); + + +test("a project requested before start loads when its parent starts the store", async () => { + const { store, calls } = setup(); + store.stop(); + await store.loadWorkspaceHistory("/beta"); + assert.equal(calls.length, 0); + store.start(); + await tick(); + assert.equal(store.peek("beta").cwd, "/beta"); + store.stop(); +}); + +test("deletion of an uncached row during the first request is not resurrected", async (t) => { + const { store, setOverride, emit } = setup(); t.after(() => store.stop()); + let resolve; + setOverride(() => new Promise((done) => { resolve = done; })); + const pending = store.loadWorkspaceHistory("/alpha"); + emit({ kind: "delete", conversationId: "0" }); + resolve({ items: [row("0")], totalCount: 1 }); + await pending; + assert.equal(store.peek("0"), undefined); +}); + + +test("reconnect supersedes a pending project request and preserves the raw workspace path", async (t) => { + const { store, rows, calls, setOverride, connect } = setup(); t.after(() => store.stop()); + const cwd = "C:\\Projects\\MixedCase"; + rows.set("windows", row("windows", cwd)); + await store.loadWorkspaceHistory(cwd); + let resolve; + setOverride(() => new Promise((done) => { resolve = done; })); + const pending = store.loadWorkspaceHistory(cwd); + connect(false); + rows.set("windows", { ...row("windows", cwd), title: "Fresh reconnect" }); + setOverride(null); + connect(true); + await tick(); + resolve({ items: [{ ...row("windows", cwd), title: "Old response" }], totalCount: 1 }); + await pending; + assert.equal(store.peek("windows").title, "Fresh reconnect"); + assert.ok(calls.every(({ scope }) => scope.cwd === cwd)); +}); + +test("pinned and archived rows do not consume a project's visible page", async (t) => { + const { store, rows } = setup(); t.after(() => store.stop()); + for (let i = 0; i < 9; i++) rows.set(String(i), { ...rows.get(String(i)), isPinned: true }); + const excluded = new Set(["9", "10", "11", "12"]); + await store.loadWorkspaceHistory("/alpha", false, excluded); + const visible = [...store.getSnapshot().byId.values()].filter((item) => !item.isPinned && !excluded.has(item.id)); + assert.ok(visible.length >= 10); + assert.equal(store.getSnapshot().workspaceHistory.get("/alpha").limit, 10); +}); diff --git a/crates/agent-gui/test/chat/sidebar-workspace-sections.test.mjs b/crates/agent-gui/test/chat/sidebar-workspace-sections.test.mjs new file mode 100644 index 000000000..cf06ee046 --- /dev/null +++ b/crates/agent-gui/test/chat/sidebar-workspace-sections.test.mjs @@ -0,0 +1,241 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; +import React from "react"; + +let dialogProps; +const scrolls = []; +const virtualizer = { + getVirtualItems: () => [], + getTotalSize: () => 3200, + scrollToIndex: (index) => scrolls.push(index), + measureElement() {}, +}; +const icons = [ + "AlertCircle", + "Blend", + "Cable", + "Check", + "ChevronRight", + "CirclePlus", + "Folder", + "FolderClosed", + "FolderOpen", + "ListChecks", + "Loader2", + "PanelLeftClose", + "Plus", + "Search", + "Settings", + "Share2", + "Trash2", + "X", +]; +const env = await createDomTestEnv({ + mocks: { + "@liveagent/ui/components/IconSet": Object.fromEntries(icons.map((name) => [name, () => null])), + "@liveagent/ui/components/ui/button": { + Button: ({ children, onClick, ...props }) => + React.createElement( + "button", + { onClick, disabled: props.disabled, "aria-expanded": props["aria-expanded"], "aria-label": props["aria-label"], "aria-pressed": props["aria-pressed"], "data-testid": props["data-testid"] }, + children, + ), + }, + "@liveagent/ui/components/ui/confirm-dialog": { + useConfirmDialog: () => ({ requestConfirmDialog: async () => false }), + }, + "@liveagent/ui/components/ui/dropdown-menu": Object.fromEntries( + ["DropdownMenu", "DropdownMenuContent", "DropdownMenuItem", "DropdownMenuTrigger"].map( + (name) => [name, ({ children }) => React.createElement("div", null, children)], + ), + ), + "@liveagent/ui/components/ui/input": { Input: (props) => React.createElement("input", props) }, + "@liveagent/ui/i18n/index": { useLocale: () => ({ t: (key) => key, locale: "en" }) }, + "@tanstack/react-virtual": { useVirtualizer: () => virtualizer }, + "./ChatHistorySidebarRows": { + HistoryRow: (props) => React.createElement("div", { "data-conversation-id": props.item.id }, + React.createElement("button", { onClick: () => props.isSelectionMode && props.onSelectForBulk(props.item.id, { shiftKey: false, toggleKey: false }) }, props.item.title), + props.isSelectionMode && React.createElement("input", { type: "checkbox", checked: props.isSelected, readOnly: true })), + ProjectRow: ({ project, expanded, onToggleExpanded, onSelectProject }) => React.createElement("button", { + "data-project-id": project.id, "aria-expanded": expanded, + onClick: () => { onToggleExpanded?.(project); onSelectProject(project); }, + }, project.name), + ProjectGroupHeader: () => null, + }, + "./ConversationSearchDialog": { + ConversationSearchDialog: (props) => { + dialogProps = props; + return null; + }, + }, + }, +}); +const { act, createRoot } = env; +window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); +HTMLElement.prototype.scrollTo = () => {}; +HTMLElement.prototype.scrollIntoView = function () { scrolls.push(this.dataset.conversationId); }; +const { ChatHistorySidebar } = env.loadModule( + "@liveagent/ui/components/chat/ChatHistorySidebar.tsx", +); +const items = Array.from({ length: 100 }, (_, index) => ({ + id: String(index), + title: String(index), + cwd: "/repo/b", + createdAt: 1, + updatedAt: index, + providerId: "p", + model: "m", +})); + + +const baseProps = { + items: [{ ...items[0], id: "pinned", title: "Pinned conversation", isPinned: true }, ...items], + currentConversationId: "0", busyConversationIds: new Map(), runningConversationIds: new Set(), + runningProjectPathKeys: new Set(), missingProjectPathKeys: new Set(), listStatus: "ready", scopeKey: "cwd:/repo/b", + hasMore: false, isLoadingMore: false, isOpen: true, showProjects: true, canShareConversations: true, + sharedConversationCount: 1, renamingId: null, renameDraft: "", activeProjectId: "b", + projects: [{ id: "a", name: "Pinned workspace", path: "/repo/a", isPinned: true }, { id: "b", name: "Workspace B", path: "/repo/b" }], + workspaceHistory: new Map([["/repo/b", { limit: 10, loaded: true, excludedIds: new Set() }]]), + onCancelRename() {}, + onRecentCollapsedChange() { assert.fail("workspace bulk selection must not save recent-list preferences"); }, +}; +const click = async (element) => { + assert.ok(element, "click target exists"); + await act(async () => element.click()); +}; + +test("bulk selection toggles off using the same button, clears selection and keeps share visible", async () => { + const container = document.createElement("div"), root = createRoot(container); + try { + await act(async () => root.render(React.createElement(ChatHistorySidebar, baseProps))); + const toggle = () => container.querySelector('[aria-label="chat.conversationBulkSelect"]'); + await click(toggle()); + assert.equal(toggle().getAttribute("aria-pressed"), "true"); + await click(container.querySelector('[data-conversation-id="0"] button')); + assert.equal(container.querySelectorAll('input:checked').length, 1); + assert.ok(container.querySelector('[aria-label="chat.manageSharedConversations"]')); + await click(toggle()); + assert.equal(toggle().getAttribute("aria-pressed"), "false"); + assert.equal(container.querySelectorAll('input[type="checkbox"]').length, 0); + await click(toggle()); + assert.equal(container.querySelectorAll('input:checked').length, 0); + await act(async () => root.render(React.createElement(ChatHistorySidebar, { ...baseProps, items: [] }))); + assert.equal(toggle().disabled, false, "can still exit when the last selectable row disappears"); + await click(toggle()); + assert.equal(toggle().getAttribute("aria-pressed"), "false"); + } finally { await act(async () => root.unmount()); } +}); + +test("pinned and workspace sections collapse independently and only workspace accepts folder drops", async () => { + const container = document.createElement("div"), root = createRoot(container); + let props = { ...baseProps }; + const render = () => root.render(React.createElement(ChatHistorySidebar, props)); + props.onProjectsCollapsedChange = (collapsed) => { props = { ...props, projectsCollapsed: collapsed }; render(); }; + try { + await act(async () => render()); + const pinned = container.querySelector('section[aria-label="chat.pinnedSection"]'); + const workspace = container.querySelector('section[aria-label="chat.workspaceSection"]'); + assert.equal(pinned.parentElement, workspace.parentElement); + assert.equal(pinned.closest('[data-workspace-folder-drop-zone]'), null); + assert.equal(workspace.hasAttribute('data-workspace-folder-drop-zone'), true); + assert.equal(pinned.querySelectorAll('[data-conversation-id="pinned"]').length, 1); + assert.equal(workspace.querySelectorAll('[data-conversation-id="pinned"]').length, 0); + await click(workspace.querySelector('button[aria-expanded]')); + assert.equal(workspace.querySelector('button[aria-expanded]').getAttribute('aria-expanded'), 'false'); + assert.ok(pinned.querySelector('[data-conversation-id="pinned"]')); + const toggle = workspace.querySelector('[aria-label="chat.conversationBulkSelect"]'); + assert.equal(toggle.disabled, false, "pinned conversations stay selectable when workspaces collapse"); + await click(pinned.querySelector('button[aria-expanded]')); + assert.equal(pinned.querySelector('[data-conversation-id="pinned"]'), null); + await click(workspace.querySelector('button[aria-expanded]')); + assert.equal(pinned.querySelector('button[aria-expanded]').getAttribute('aria-expanded'), 'false'); + } finally { await act(async () => root.unmount()); } +}); + +test("search reveals a result beyond the first workspace page and scrolls once", async () => { + const container = document.createElement("div"), root = createRoot(container); + scrolls.length = 0; + let completion; + let props = { ...baseProps, projectsCollapsed: true, onSelectConversation: (_id, options) => { completion = options.afterCommit; } }; + try { + await act(async () => root.render(React.createElement(ChatHistorySidebar, props))); + await act(async () => dialogProps.onSelectConversation("99", { source: "search" })); + props = { ...props, currentConversationId: "99" }; + await act(async () => { root.render(React.createElement(ChatHistorySidebar, props)); completion(); }); + assert.ok(container.querySelector('[data-conversation-id="99"]')); + assert.deepEqual(scrolls, ["99"]); + assert.equal(container.querySelectorAll('[data-testid="workspace-conversations-b"] [data-conversation-id]').length, 11); + await act(async () => root.render(React.createElement(ChatHistorySidebar, { ...props, items: [...props.items] }))); + assert.deepEqual(scrolls, ["99"]); + } finally { await act(async () => root.unmount()); } +}); + + +test("search reveals a workspace hidden beyond the collapsed workspace limit", async () => { + const container = document.createElement("div"), root = createRoot(container); + scrolls.length = 0; + let completion; + let props = { + ...baseProps, + activeProjectId: "other-0", + projects: [ + ...Array.from({ length: 30 }, (_, index) => ({ + id: `other-${index}`, name: `Workspace ${index}`, path: `/repo/other-${index}`, + })), + baseProps.projects[1], + ], + onSelectConversation: (_id, options) => { completion = options.afterCommit; }, + }; + try { + await act(async () => root.render(React.createElement(ChatHistorySidebar, props))); + assert.equal(container.querySelector('[data-project-id="b"]'), null); + await act(async () => dialogProps.onSelectConversation("99", { source: "search" })); + props = { ...props, currentConversationId: "99", activeProjectId: "b" }; + await act(async () => { root.render(React.createElement(ChatHistorySidebar, props)); completion(); }); + assert.ok(container.querySelector('[data-project-id="b"]')); + assert.ok(container.querySelector('[data-conversation-id="99"]')); + assert.deepEqual(scrolls, ["99"]); + } finally { await act(async () => root.unmount()); } +}); + +test("workspace plus opens creation directly without a group menu", async () => { + const container = document.createElement("div"), root = createRoot(container); + let creations = 0; + try { + await act(async () => root.render(React.createElement(ChatHistorySidebar, { ...baseProps, onCreateProject: () => { creations++; } }))); + await click(container.querySelector('[aria-label="chat.workspaceCreate"]')); + assert.equal(creations, 1); + assert.equal(container.querySelector('[aria-label="chat.workspaceAdd"]'), null); + } finally { await act(async () => root.unmount()); } +}); + + +test("an expanded inactive workspace collapses on the first click even when it becomes active", async () => { + const container = document.createElement("div"), root = createRoot(container); + let props = { ...baseProps }; + const render = () => root.render(React.createElement(ChatHistorySidebar, props)); + props.onSelectProject = (project) => { props = { ...props, activeProjectId: project.id }; render(); }; + const row = (id) => container.querySelector(`[data-project-id="${id}"]`); + try { + await act(async () => render()); + assert.equal(row("b").getAttribute("aria-expanded"), "true"); + await click(row("a")); + assert.equal(props.activeProjectId, "a"); + assert.equal(row("a").getAttribute("aria-expanded"), "true"); + // B is still expanded, but no longer active. Its click must close it. + await click(row("b")); + assert.equal(props.activeProjectId, "b"); + assert.equal(row("b").getAttribute("aria-expanded"), "false"); + assert.equal(container.querySelector('[data-testid="workspace-conversations-b"]'), null); + await click(row("b")); + assert.equal(row("b").getAttribute("aria-expanded"), "true"); + // Pinned workspaces use the same click behavior. + await click(row("a")); + assert.equal(row("a").getAttribute("aria-expanded"), "false"); + // Navigation from outside the tree can still reveal a workspace normally. + await act(async () => { props = { ...props, activeProjectId: "b" }; render(); }); + await act(async () => { props = { ...props, activeProjectId: "a" }; render(); }); + assert.equal(row("a").getAttribute("aria-expanded"), "true"); + } finally { await act(async () => root.unmount()); } +}); diff --git a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx index 12f0380b3..d8f262ce7 100644 --- a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx @@ -3,7 +3,6 @@ import { AlertCircle, Blend, Cable, - Check, ChevronRight, CirclePlus, Folder, @@ -11,6 +10,7 @@ import { FolderOpen, ListChecks, Loader2, + MessageSquare, PanelLeftClose, Plus, Search, @@ -27,7 +27,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@liveagent/ui/components/ui/dropdown-menu"; -import { Input } from "@liveagent/ui/components/ui/input"; import { useLocale } from "@liveagent/ui/i18n/index"; import { cn } from "@liveagent/ui/lib/shared/utils"; import type { @@ -43,7 +42,6 @@ import { type CSSProperties, Fragment, memo, - type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, @@ -51,10 +49,18 @@ import { useState, } from "react"; import type { ConversationOpenOptions } from "../../lib/sidebar/openController"; +import { + buildSidebarPinnedEntries, + reorderSidebarPinnedEntries, + reorderSidebarProjects, + sidebarConversationOrderKey, + sidebarWorkspaceOrderKey, +} from "../../lib/sidebar/preferences"; +import { WORKSPACE_HISTORY_PAGE_SIZE } from "../../lib/sidebar/store"; import type { SidebarConversation } from "../../lib/sidebar/types"; +import { useSidebarReorderDrag } from "../../lib/sidebar/useSidebarReorderDrag"; import { buildWorkspaceProjectSections, - firstUnpinnedWorkspaceProjectIndex, sliceWorkspaceProjectSections, } from "../../lib/workspaceProjects"; import type { WorkspaceProjectGroup } from "../../lib/workspaceProjectTypes"; @@ -88,20 +94,8 @@ const MOBILE_SIDEBAR_MEDIA_QUERY = "(max-width: 820px)"; const HISTORY_ROW_ESTIMATED_HEIGHT = 30; const HISTORY_ROW_GAP = 2; const HISTORY_ROW_OVERSCAN_COUNT = 8; -const HISTORY_LOAD_MORE_THRESHOLD = 12; const PROJECT_ICON_BUTTON_CLASS = "h-7 w-7 rounded-lg !bg-transparent text-muted-foreground transition-colors hover:!bg-transparent hover:!text-foreground active:!bg-transparent focus-visible:!bg-transparent data-[state=open]:!bg-transparent data-[state=open]:text-foreground data-[popup-open]:!bg-transparent data-[popup-open]:text-foreground"; -const SIDEBAR_SECTION_ROWS_TRANSITION_CLASS = - "transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none"; -const SIDEBAR_PROJECT_MIN_BODY_HEIGHT = 96; -const SIDEBAR_RECENT_MIN_BODY_HEIGHT = 160; -// Default share of the available height the workspace (projects) section claims -// before the user drags the resize handle. Desktop splits evenly; on mobile the -// resize handle is hidden, so bias toward the recent-conversation list — the -// primary content of the drawer — by giving the workspace a smaller default -// share so the recent section sits a little higher and gets a little more room. -const SIDEBAR_PROJECTS_BODY_DEFAULT_RATIO = 0.5; -const SIDEBAR_MOBILE_PROJECTS_BODY_DEFAULT_RATIO = 0.4; const PROJECT_LIST_COLLAPSED_MAX = 30; const EMPTY_PROJECT_PATH_KEYS = new Set(); const EMPTY_APPROVAL_CONVERSATION_IDS = new Set(); @@ -114,10 +108,6 @@ const HISTORY_LOADING_SKELETON_ROWS = [ { title: "w-28", meta: "w-20" }, ] as const; -function clampSidebarSectionHeight(height: number, minHeight: number, maxHeight: number) { - return Math.round(Math.min(Math.max(height, minHeight), Math.max(minHeight, maxHeight))); -} - function isMobileSidebarLayout() { if (typeof window === "undefined") { return false; @@ -169,6 +159,13 @@ function HistoryListLoadingSkeleton() { export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHistorySidebarProps) { const { items, + archivedConversations = [], + onSetConversationArchived, + onReorderProjects, + pinnedOrder, + onReorderPinned, + workspaceHistory, + onLoadWorkspaceHistory, currentConversationId, busyConversationIds, runningConversationIds, @@ -194,19 +191,19 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi activeProjectId, missingProjectPathKeys, runningProjectPathKeys, - projectsCollapsed = false, + projectsCollapsed: persistedProjectsCollapsed = false, workspaceFolderDropActive = false, workspaceFolderDropHandlers, recentCollapsed: persistedRecentCollapsed = false, onProjectsCollapsedChange, onRecentCollapsedChange, onCreateProject, - onCreateWorkspaceGroup, onRenameWorkspaceGroup, onDeleteWorkspaceGroup, onMoveProjectToGroup, onToggleWorkspaceGroupCollapsed, onSelectProject, + onNewConversationForProject, onBrowseProjectInFileTree, onConfigureProject, onBrowseProjectInSystemFileManager, @@ -250,6 +247,10 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi null, ); const pendingSearchScrollRef = useRef(null); + const workspaceTreeRef = useRef(null); + const [pinnedCollapsed, setPinnedCollapsed] = useState(false); + const revealingSearch = revealedSearchConversationId === currentConversationId; + const projectsCollapsed = revealingSearch ? false : persistedProjectsCollapsed; const recentCollapsed = revealedSearchConversationId === currentConversationId ? false : persistedRecentCollapsed; const lastConversationSearchRequestKeyRef = useRef(conversationSearchRequestKey); @@ -267,15 +268,93 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi const [openMenuId, setOpenMenuId] = useState(null); const [openProjectMenuId, setOpenProjectMenuId] = useState(null); const [isMobileMenuLayout, setIsMobileMenuLayout] = useState(isMobileSidebarLayout); - const [projectSectionHeight, setProjectSectionHeight] = useState(null); - const [isProjectSectionResizing, setIsProjectSectionResizing] = useState(false); - const [sidebarSectionMetrics, setSidebarSectionMetrics] = useState({ - containerHeight: 0, - projectsHeaderHeight: 0, - recentHeaderHeight: 0, - handleHeight: 0, - projectsContentHeight: 0, - }); + const archivedIds = useMemo( + () => new Set(archivedConversations.map((item) => item.id)), + [archivedConversations], + ); + const sidebarSelectedProjectRef = useRef(null); + const [expandedProjectIds, setExpandedProjectIds] = useState>( + () => new Set(activeProjectId ? [activeProjectId] : []), + ); + useEffect(() => { + // A title click already chose whether to expand or collapse this folder. + // Selecting that workspace must not undo the same click's collapse. + const selectedFromSidebar = sidebarSelectedProjectRef.current === activeProjectId; + sidebarSelectedProjectRef.current = null; + if (activeProjectId && !selectedFromSidebar) { + setExpandedProjectIds((current) => new Set(current).add(activeProjectId)); + } + }, [activeProjectId]); + useEffect(() => { + if (!showProjects || !isOpen || sectionsDisabled) return; + for (const project of projects) { + if (project.isPinned ? pinnedCollapsed : projectsCollapsed) continue; + const history = workspaceHistory?.get(workspaceProjectPathKey(project.path)); + const archiveChanged = + history && + (history.excludedIds.size !== archivedIds.size || + Array.from(archivedIds).some((id) => !history.excludedIds.has(id))); + if (expandedProjectIds.has(project.id) && (!history || archiveChanged)) { + void onLoadWorkspaceHistory?.(project.path, false, archivedIds); + } + } + }, [ + expandedProjectIds, + archivedIds, + isOpen, + onLoadWorkspaceHistory, + projects, + projectsCollapsed, + pinnedCollapsed, + sectionsDisabled, + showProjects, + workspaceHistory, + ]); + const toggleProjectExpanded = (project: WorkspaceProject) => { + setExpandedProjectIds((current) => { + const next = new Set(current); + if (next.has(project.id)) next.delete(project.id); + else next.add(project.id); + return next; + }); + }; + + const [archivedConversationsOpen, setArchivedConversationsOpen] = useState(false); + const unarchivedItems = useMemo( + () => items.filter((item) => !archivedIds.has(item.id)), + [archivedIds, items], + ); + const pinnedConversations = useMemo( + () => unarchivedItems.filter((item) => item.isPinned), + [unarchivedItems], + ); + const archivedItems = useMemo( + () => + archivedConversations.map( + (entry): SidebarConversation => + items.find((item) => item.id === entry.id) ?? { + ...entry, + providerId: "", + model: "", + createdAt: 0, + updatedAt: 0, + }, + ), + [archivedConversations, items], + ); + const handleSetConversationArchived = useStableEvent( + (item: SidebarConversation, archived: boolean) => { + if ( + sectionsDisabled || + busyConversationIds.has(item.id) || + runningConversationIds.has(item.id) || + item.isPending + ) + return; + setOpenMenuId(null); + onSetConversationArchived?.(item, archived); + }, + ); const currentConversationWorkdir = useMemo( () => items.find((item) => item.id === currentConversationId)?.cwd, @@ -299,18 +378,6 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi setConversationSearchOpen(true); } }, [conversationSearchRequestKey, sectionsDisabled]); - const sidebarSectionsRef = useRef(null); - const projectsHeaderRef = useRef(null); - const recentHeaderRef = useRef(null); - const sectionResizeHandleRef = useRef(null); - const projectsBodyRef = useRef(null); - const sidebarSectionLayoutRef = useRef({ - projectsBodyHeight: 0, - resizeMinHeight: 0, - resizeMaxHeight: 0, - }); - const projectSectionResizeFrameRef = useRef(null); - const projectSectionResizeCleanupRef = useRef<(() => void) | null>(null); const selectionAnchorRef = useRef(null); const bulkConfirmOpenRef = useRef(false); // Bumped to invalidate an in-flight bulk delete: its shouldStop callback @@ -318,7 +385,124 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi const bulkDeleteRunRef = useRef(0); const bulkMoveRunRef = useRef(0); const { confirm: requestBulkDeleteConfirm, dialog: bulkDeleteDialog } = useConfirmDialog(); - const orderedConversationIds = useMemo(() => items.map((item) => item.id), [items]); + // Archived rows are split into their own collapsed group at the list end; + // the render cap only applies to the active rows. + const activeProjects = useMemo( + () => + projects.filter( + (project) => !archivedProjectPathKeys.has(workspaceProjectPathKey(project.path)), + ), + [archivedProjectPathKeys, projects], + ); + const archivedProjects = useMemo( + () => + projects.filter((project) => + archivedProjectPathKeys.has(workspaceProjectPathKey(project.path)), + ), + [archivedProjectPathKeys, projects], + ); + // Projects arrive pre-sorted from the container; the view organizes them + // into group sections (worktree projects auto-grouped under their source + // repository) plus the ungrouped remainder. The collapsed view slices by + // section so a group is never split. + const pinnedProjects = useMemo( + () => activeProjects.filter((project) => project.isPinned), + [activeProjects], + ); + const pinnedEntries = useMemo( + () => buildSidebarPinnedEntries(pinnedConversations, pinnedProjects, pinnedOrder), + [pinnedConversations, pinnedProjects, pinnedOrder], + ); + const projectSections = useMemo( + () => + buildWorkspaceProjectSections( + activeProjects.filter((project) => !project.isPinned), + workspaceProjectGroups ?? [], + ), + [activeProjects, workspaceProjectGroups], + ); + const slicedSections = useMemo( + () => + showAllProjects || revealingSearch + ? { sections: projectSections, hiddenProjectCount: 0 } + : sliceWorkspaceProjectSections(projectSections, PROJECT_LIST_COLLAPSED_MAX), + [projectSections, showAllProjects, revealingSearch], + ); + const renderedSections = slicedSections.sections; + const hiddenProjectCount = slicedSections.hiddenProjectCount; + const projectConversations = useMemo(() => { + const result = new Map(); + for (const item of unarchivedItems) { + if (item.isPinned) continue; + const key = workspaceProjectPathKey(item.cwd ?? ""); + const group = result.get(key) ?? []; + group.push(item); + result.set(key, group); + } + return result; + }, [unarchivedItems]); + const visibleProjectConversations = useCallback( + (project: WorkspaceProject) => { + const key = workspaceProjectPathKey(project.path); + const conversations = projectConversations.get(key) ?? []; + const visible = conversations.slice( + 0, + workspaceHistory?.get(key)?.limit ?? WORKSPACE_HISTORY_PAGE_SIZE, + ); + const revealed = revealingSearch + ? conversations.find((item) => item.id === currentConversationId) + : undefined; + return revealed && !visible.some((item) => item.id === revealed.id) + ? [...visible, revealed] + : visible; + }, + [currentConversationId, projectConversations, revealingSearch, workspaceHistory], + ); + const isGroupCollapsed = useCallback( + (group: WorkspaceProjectGroup, members: WorkspaceProject[]) => + group.collapsed === true && + !(revealingSearch && members.some((project) => project.id === activeProjectId)), + [activeProjectId, revealingSearch], + ); + const visibleItems = useMemo(() => { + if (!showProjects) return unarchivedItems; + const visibleProjects = [ + ...(projectsCollapsed + ? [] + : [ + ...renderedSections.grouped.flatMap((section) => + isGroupCollapsed(section.group, section.projects) ? [] : section.projects, + ), + ...renderedSections.ungrouped, + ]), + ]; + return [ + ...(pinnedCollapsed + ? [] + : pinnedEntries.flatMap((entry) => + entry.kind === "conversation" + ? [entry.item] + : expandedProjectIds.has(entry.project.id) + ? visibleProjectConversations(entry.project) + : [], + )), + ...visibleProjects.flatMap((project) => { + if (!expandedProjectIds.has(project.id)) return []; + return visibleProjectConversations(project); + }), + ]; + }, [ + expandedProjectIds, + unarchivedItems, + pinnedEntries, + projectsCollapsed, + pinnedCollapsed, + renderedSections, + showProjects, + isGroupCollapsed, + visibleProjectConversations, + ]); + const orderedConversationIds = useMemo(() => visibleItems.map((item) => item.id), [visibleItems]); const visibleRunningProjectPathKeys = useMemo(() => { // A conversation waiting on the user — for a tool approval or for an // AskUserQuestion answer — is suspended, not working. Both must drop out of @@ -360,13 +544,13 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi new Set( sectionsDisabled ? [] - : items + : visibleItems .filter( (item) => !runningConversationIds.has(item.id) && !busyConversationIds.has(item.id), ) .map((item) => item.id), ), - [busyConversationIds, items, runningConversationIds, sectionsDisabled], + [busyConversationIds, visibleItems, runningConversationIds, sectionsDisabled], ); const handleSelectConversation = useStableEvent( (id: string, options?: ConversationOpenOptions) => { @@ -467,6 +651,7 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi }, ); const handleProjectsCollapsedChange = useStableEvent(() => { + setRevealedSearchConversationId(null); if (!sectionsDisabled) { onProjectsCollapsedChange?.(!projectsCollapsed); } @@ -484,6 +669,7 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi }); const handleSelectProject = useStableEvent((project: WorkspaceProject) => { if (!sectionsDisabled) { + sidebarSelectedProjectRef.current = project.id; onSelectProject?.(project); } }); @@ -541,7 +727,7 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi setPendingDeleteId(null); setOpenMenuId(null); handleCancelRename(); - onRecentCollapsedChange?.(false); + if (!showProjects) onRecentCollapsedChange?.(false); setSelectionMode(true); if (initialId && selectableConversationIds.has(initialId)) { setSelectedConversationIds(new Set([initialId])); @@ -655,98 +841,13 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi } } }); - // Archived rows are split into their own collapsed group at the list end; - // the render cap only applies to the active rows. - const activeProjects = useMemo( - () => - projects.filter( - (project) => !archivedProjectPathKeys.has(workspaceProjectPathKey(project.path)), - ), - [archivedProjectPathKeys, projects], - ); - const archivedProjects = useMemo( - () => - projects.filter((project) => - archivedProjectPathKeys.has(workspaceProjectPathKey(project.path)), - ), - [archivedProjectPathKeys, projects], - ); - // Projects arrive pre-sorted from the container; the view organizes them - // into group sections (worktree projects auto-grouped under their source - // repository) plus the ungrouped remainder. The collapsed view slices by - // section so a group is never split. - const projectSections = useMemo( - () => buildWorkspaceProjectSections(activeProjects, workspaceProjectGroups ?? []), - [activeProjects, workspaceProjectGroups], - ); - const slicedSections = useMemo( - () => - showAllProjects - ? { sections: projectSections, hiddenProjectCount: 0 } - : sliceWorkspaceProjectSections(projectSections, PROJECT_LIST_COLLAPSED_MAX), - [projectSections, showAllProjects], - ); - const renderedSections = slicedSections.sections; - const hiddenProjectCount = slicedSections.hiddenProjectCount; - // Divider slot between the pinned block and the rest of the projects. - // The first section's first member determines pinned placement; a pinned or - // running member promotes its whole section via the earliest sorted index. - const firstUnpinnedSectionIndex = useMemo(() => { - const firstMember = renderedSections.grouped[0]?.projects[0] ?? renderedSections.ungrouped[0]; - if (firstMember?.isPinned !== true) { - return -1; - } - const groupedIndex = renderedSections.grouped.findIndex( - (section) => section.projects[0]?.isPinned !== true, - ); - if (groupedIndex > 0) return groupedIndex; - if (renderedSections.ungrouped[0]?.isPinned === true) return -1; - return renderedSections.grouped.length; - }, [renderedSections]); - const firstUnpinnedUngroupedIndex = - renderedSections.grouped.length === 0 - ? firstUnpinnedWorkspaceProjectIndex(renderedSections.ungrouped) - : -1; // Archiving must always leave at least one active workspace behind. const canArchiveProjects = Boolean(onArchiveProject) && activeProjects.length > 1; const [archivedGroupOpen, setArchivedGroupOpen] = useState(false); - const [creatingGroup, setCreatingGroup] = useState(false); - const [groupDraft, setGroupDraft] = useState(""); - // Base UI resolves the "+" menu's return-focus target synchronously while the - // menu unmounts — the same commit that mounts the draft input — so the trigger - // would take focus straight back and the empty-draft blur would silently close - // the row again ("new group does nothing"). The menu's finalFocus consumes this - // one-shot flag and the effect below owns focus placement, which is why the - // input has no autoFocus. Same failure and same fix as the conversation rename - // input in ChatHistorySidebarRows. - const suppressAddMenuReturnFocusRef = useRef(false); - const groupDraftInputRef = useRef(null); - // Enter/Escape mark the blur as handled so onBlur commits exactly once — - // without it, committing on Enter unmounts a focused input and the trailing - // focusout creates the group a second time. - const skipNextGroupBlurCommitRef = useRef(false); - - useEffect(() => { - if (!creatingGroup) return; - skipNextGroupBlurCommitRef.current = false; - groupDraftInputRef.current?.focus(); - }, [creatingGroup]); const [renamingGroupId, setRenamingGroupId] = useState(null); const [groupRenameDraft, setGroupRenameDraft] = useState(""); const { confirm: requestGroupDeleteConfirm, dialog: groupDeleteDialog } = useConfirmDialog(); - const commitNewGroup = useCallback(() => { - const name = groupDraft.trim(); - if (name) onCreateWorkspaceGroup?.(name); - setCreatingGroup(false); - setGroupDraft(""); - }, [groupDraft, onCreateWorkspaceGroup]); - - const cancelNewGroup = useCallback(() => { - setCreatingGroup(false); - setGroupDraft(""); - }, []); - const commitGroupRename = useCallback(() => { const name = groupRenameDraft.trim(); if (renamingGroupId && name) onRenameWorkspaceGroup?.(renamingGroupId, name); @@ -771,89 +872,6 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi }, [onDeleteWorkspaceGroup, requestGroupDeleteConfirm, t], ); - const sidebarSectionLayout = useMemo(() => { - const { - containerHeight, - projectsHeaderHeight, - recentHeaderHeight, - handleHeight, - projectsContentHeight, - } = sidebarSectionMetrics; - const measured = containerHeight > 0; - const available = Math.max( - 0, - containerHeight - projectsHeaderHeight - recentHeaderHeight - handleHeight, - ); - const projectMinBodyHeight = Math.min(SIDEBAR_PROJECT_MIN_BODY_HEIGHT, available); - const recentMinBodyHeight = Math.min( - SIDEBAR_RECENT_MIN_BODY_HEIGHT, - Math.max(0, available - projectMinBodyHeight), - ); - const resizeMaxHeight = Math.max(0, available - recentMinBodyHeight); - const resizeMinHeight = Math.max( - 0, - Math.min(projectsContentHeight, projectMinBodyHeight, resizeMaxHeight), - ); - const projectsBodyDefaultRatio = isMobileMenuLayout - ? SIDEBAR_MOBILE_PROJECTS_BODY_DEFAULT_RATIO - : SIDEBAR_PROJECTS_BODY_DEFAULT_RATIO; - const defaultProjectsBodyHeight = clampSidebarSectionHeight( - Math.min(projectsContentHeight, Math.floor(available * projectsBodyDefaultRatio)), - resizeMinHeight, - resizeMaxHeight, - ); - - let projectsBodyHeight = 0; - if (showProjects && !projectsCollapsed) { - if (recentCollapsed) { - projectsBodyHeight = available; - } else if (projectSectionHeight !== null) { - projectsBodyHeight = clampSidebarSectionHeight( - projectSectionHeight, - resizeMinHeight, - resizeMaxHeight, - ); - } else { - projectsBodyHeight = defaultProjectsBodyHeight; - } - } - const recentBodyHeight = recentCollapsed ? 0 : Math.max(0, available - projectsBodyHeight); - - const projectsBodyTrack = - !showProjects || projectsCollapsed - ? "0px" - : measured - ? `${projectsBodyHeight}px` - : "min-content"; - const recentBodyTrack = recentCollapsed - ? "0px" - : measured - ? `${recentBodyHeight}px` - : "minmax(0, 1fr)"; - const gridTemplateRows = showProjects - ? `auto ${projectsBodyTrack} auto auto ${recentBodyTrack}` - : `auto ${recentBodyTrack}`; - - return { projectsBodyHeight, resizeMinHeight, resizeMaxHeight, gridTemplateRows }; - }, [ - isMobileMenuLayout, - projectSectionHeight, - projectsCollapsed, - recentCollapsed, - showProjects, - sidebarSectionMetrics, - ]); - const canResizeProjectSections = - !sectionsDisabled && - showProjects && - !projectsCollapsed && - !recentCollapsed && - sidebarSectionLayout.resizeMaxHeight > sidebarSectionLayout.resizeMinHeight; - sidebarSectionLayoutRef.current = { - projectsBodyHeight: sidebarSectionLayout.projectsBodyHeight, - resizeMinHeight: sidebarSectionLayout.resizeMinHeight, - resizeMaxHeight: sidebarSectionLayout.resizeMaxHeight, - }; const handleMenuOpenChange = useStableEvent((id: string, open: boolean) => { if (open && sectionsDisabled) { return; @@ -907,12 +925,6 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi setPendingProjectAction(null); exitSelectionMode(); handleCancelRename(); - projectSectionResizeCleanupRef.current?.(); - if (projectSectionResizeFrameRef.current !== null) { - window.cancelAnimationFrame(projectSectionResizeFrameRef.current); - projectSectionResizeFrameRef.current = null; - } - setIsProjectSectionResizing(false); }, [exitSelectionMode, handleCancelRename, sectionsDisabled]); useEffect(() => { @@ -982,24 +994,24 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi // Divider slot between the pinned block and the rest: index of the first // unpinned row, only when at least one pinned row sits above it. const firstUnpinnedHistoryIndex = useMemo(() => { - if (items[0]?.isPinned !== true) { + if (unarchivedItems[0]?.isPinned !== true) { return -1; } - const index = items.findIndex((item) => item.isPinned !== true); + const index = unarchivedItems.findIndex((item) => item.isPinned !== true); return index > 0 ? index : -1; - }, [items]); - const getHistoryItemKey = useCallback((index: number) => items[index]?.id ?? index, [items]); + }, [unarchivedItems]); + const getHistoryItemKey = useCallback( + (index: number) => unarchivedItems[index]?.id ?? index, + [unarchivedItems], + ); const historyVirtualizer = useVirtualizer({ - count: items.length, + count: showProjects ? 0 : unarchivedItems.length, getScrollElement: () => historyScrollRef.current, estimateSize: () => HISTORY_ROW_ESTIMATED_HEIGHT + HISTORY_ROW_GAP, getItemKey: getHistoryItemKey, overscan: HISTORY_ROW_OVERSCAN_COUNT, }); const virtualHistoryRows = historyVirtualizer.getVirtualItems(); - const lastVirtualHistoryIndex = - virtualHistoryRows.length > 0 ? virtualHistoryRows[virtualHistoryRows.length - 1].index : -1; - // Workspace switch: land the new scope at the top; the scope-keyed content // wrapper below replays the soft enter transition at the same time. // biome-ignore lint/correctness/useExhaustiveDependencies: scope identity intentionally drives the reset @@ -1016,215 +1028,122 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi pendingSearchScrollRef.current !== currentConversationId ) return; - const index = items.findIndex((item) => item.id === revealedSearchConversationId); - if (index < 0) return; - historyVirtualizer.scrollToIndex(index, { align: "auto" }); + if (showProjects) { + if (!visibleItems.some((item) => item.id === currentConversationId) && !activeProjectId) + return; + const project = projects.find((item) => item.id === activeProjectId); + if (project && !expandedProjectIds.has(project.id)) { + setExpandedProjectIds((current) => new Set(current).add(project.id)); + return; + } + if ( + pinnedCollapsed && + (project?.isPinned || pinnedConversations.some((item) => item.id === currentConversationId)) + ) { + setPinnedCollapsed(false); + return; + } + const row = Array.from( + workspaceTreeRef.current?.querySelectorAll("[data-conversation-id]") ?? [], + ).find((element) => element.dataset.conversationId === currentConversationId); + if (!row) return; + row.scrollIntoView({ block: "nearest" }); + } else { + const index = unarchivedItems.findIndex((item) => item.id === revealedSearchConversationId); + if (index < 0) return; + historyVirtualizer.scrollToIndex(index, { align: "auto" }); + } pendingSearchScrollRef.current = null; }, [ currentConversationId, historyVirtualizer, isOpen, - items, + unarchivedItems, + showProjects, + projects, + activeProjectId, + expandedProjectIds, + pinnedCollapsed, + pinnedConversations, + visibleItems, listStatus, recentCollapsed, revealedSearchConversationId, ]); - useEffect(() => { - if ( - sectionsDisabled || - !hasMore || - listStatus === "loading" || - listStatus === "initial" || - isLoadingMore || - recentCollapsed || - items.length === 0 || - lastVirtualHistoryIndex < items.length - HISTORY_LOAD_MORE_THRESHOLD - ) { - return; - } - handleLoadMore(); - }, [ - sectionsDisabled, - hasMore, - listStatus, - isLoadingMore, - items.length, - lastVirtualHistoryIndex, - handleLoadMore, - recentCollapsed, - ]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: re-run to (re)observe section refs when sections mount/unmount or toggle - useEffect(() => { - if (!isOpen) { - return; - } - const container = sidebarSectionsRef.current; - if (!container || typeof ResizeObserver === "undefined") { - return; - } - - let frameId = 0; - const measure = () => { - frameId = 0; - setSidebarSectionMetrics((previous) => { - const next = { - containerHeight: container.clientHeight, - projectsHeaderHeight: projectsHeaderRef.current?.offsetHeight ?? 0, - recentHeaderHeight: recentHeaderRef.current?.offsetHeight ?? 0, - handleHeight: sectionResizeHandleRef.current?.offsetHeight ?? 0, - projectsContentHeight: projectsBodyRef.current?.offsetHeight ?? 0, - }; - if ( - previous.containerHeight === next.containerHeight && - previous.projectsHeaderHeight === next.projectsHeaderHeight && - previous.recentHeaderHeight === next.recentHeaderHeight && - previous.handleHeight === next.handleHeight && - previous.projectsContentHeight === next.projectsContentHeight - ) { - return previous; - } - return next; - }); - }; - const scheduleMeasure = () => { - if (frameId !== 0) { - return; - } - frameId = window.requestAnimationFrame(measure); - }; - - scheduleMeasure(); - window.addEventListener("resize", scheduleMeasure); - const resizeObserver = new ResizeObserver(scheduleMeasure); - resizeObserver.observe(container); - const observedTargets = [ - projectsHeaderRef.current, - recentHeaderRef.current, - sectionResizeHandleRef.current, - projectsBodyRef.current, - ]; - for (const target of observedTargets) { - if (target) { - resizeObserver.observe(target); - } - } - - return () => { - window.removeEventListener("resize", scheduleMeasure); - if (frameId !== 0) { - window.cancelAnimationFrame(frameId); - } - resizeObserver.disconnect(); - }; - }, [isOpen, projectsCollapsed, recentCollapsed, showProjects]); - - useEffect(() => { - return () => { - projectSectionResizeCleanupRef.current?.(); - if (projectSectionResizeFrameRef.current !== null) { - window.cancelAnimationFrame(projectSectionResizeFrameRef.current); + const projectGroupKey = (id: string) => + projectSections.grouped.find((section) => section.projects.some((project) => project.id === id)) + ?.group.id ?? ""; + const reorder = useSidebarReorderDrag({ + containerRef: workspaceTreeRef, + disabled: sectionsDisabled || selectionMode || !isOpen, + scopeKey, + canDrop: (source, target) => { + if (source === target) return false; + const pinnedKeys = new Set(pinnedEntries.map((entry) => entry.key)); + if (pinnedKeys.has(source) || pinnedKeys.has(target)) { + return Boolean(onReorderPinned) && pinnedKeys.has(source) && pinnedKeys.has(target); } - }; - }, []); - - const handleProjectSectionResizeStart = useCallback( - (event: ReactPointerEvent) => { - if (sectionsDisabled || event.button !== 0 || !canResizeProjectSections) { + const sourceProject = activeProjects.find( + (project) => sidebarWorkspaceOrderKey(project.path) === source, + ); + const targetProject = activeProjects.find( + (project) => sidebarWorkspaceOrderKey(project.path) === target, + ); + return Boolean( + onReorderProjects && + sourceProject && + targetProject && + projectGroupKey(sourceProject.id) === projectGroupKey(targetProject.id), + ); + }, + onDrop: (source, target, position) => { + if (pinnedEntries.some((entry) => entry.key === source)) { + const order = reorderSidebarPinnedEntries(pinnedEntries, source, target, position); + if (order) onReorderPinned?.(order); return; } - - event.preventDefault(); - projectSectionResizeCleanupRef.current?.(); - - const pointerId = event.pointerId; - const resizeTarget = event.currentTarget; - const startY = event.clientY; - const layout = sidebarSectionLayoutRef.current; - const startHeight = clampSidebarSectionHeight( - layout.projectsBodyHeight, - layout.resizeMinHeight, - layout.resizeMaxHeight, + const sourceProject = activeProjects.find( + (project) => sidebarWorkspaceOrderKey(project.path) === source, ); - const previousCursor = document.body.style.cursor; - const previousUserSelect = document.body.style.userSelect; - - setIsProjectSectionResizing(true); - document.body.style.cursor = "row-resize"; - document.body.style.userSelect = "none"; - resizeTarget.setPointerCapture(pointerId); - - const scheduleProjectSectionHeight = (nextHeight: number) => { - if (projectSectionResizeFrameRef.current !== null) { - return; - } - projectSectionResizeFrameRef.current = window.requestAnimationFrame(() => { - projectSectionResizeFrameRef.current = null; - setProjectSectionHeight(nextHeight); - }); - }; - - const cleanupResize = () => { - window.removeEventListener("pointermove", handleMove); - window.removeEventListener("pointerup", handleUp); - window.removeEventListener("pointercancel", handleUp); - window.removeEventListener("blur", handleBlur); - if (resizeTarget.hasPointerCapture(pointerId)) { - resizeTarget.releasePointerCapture(pointerId); - } - document.body.style.cursor = previousCursor; - document.body.style.userSelect = previousUserSelect; - projectSectionResizeCleanupRef.current = null; - }; - - const finishResize = () => { - cleanupResize(); - if (projectSectionResizeFrameRef.current !== null) { - window.cancelAnimationFrame(projectSectionResizeFrameRef.current); - projectSectionResizeFrameRef.current = null; - } - setIsProjectSectionResizing(false); - }; - - const handleMove = (moveEvent: globalThis.PointerEvent) => { - if (moveEvent.pointerId !== pointerId) { - return; - } - moveEvent.preventDefault(); - const liveLayout = sidebarSectionLayoutRef.current; - scheduleProjectSectionHeight( - clampSidebarSectionHeight( - startHeight + moveEvent.clientY - startY, - liveLayout.resizeMinHeight, - liveLayout.resizeMaxHeight, - ), - ); - }; - - const handleUp = (upEvent: globalThis.PointerEvent) => { - if (upEvent.pointerId !== pointerId) { - return; - } - finishResize(); - }; - - const handleBlur = () => { - finishResize(); - }; - - projectSectionResizeCleanupRef.current = cleanupResize; - window.addEventListener("pointermove", handleMove); - window.addEventListener("pointerup", handleUp); - window.addEventListener("pointercancel", handleUp); - window.addEventListener("blur", handleBlur); + const targetProject = activeProjects.find( + (project) => sidebarWorkspaceOrderKey(project.path) === target, + ); + if (!sourceProject || !targetProject) return; + const order = reorderSidebarProjects(projects, sourceProject.id, targetProject.id, position); + if (order) onReorderProjects?.(order); }, - [canResizeProjectSections, sectionsDisabled], + }); + const draggedPinnedEntry = pinnedEntries.find((entry) => entry.key === reorder.draggingKey); + const draggedWorkspace = activeProjects.find( + (project) => sidebarWorkspaceOrderKey(project.path) === reorder.draggingKey, ); + const draggedTitle = + draggedPinnedEntry?.kind === "conversation" + ? draggedPinnedEntry.item.title + : draggedWorkspace?.name; + const showWorkspaceFolderDrop = workspaceFolderDropActive && !reorder.draggingKey; const renderHistoryRow = useCallback( - (item: SidebarConversation) => ( + (item: SidebarConversation, showIcon = false, archived = false) => ( ), [ + showProjects, + onReorderPinned, + reorder.onPointerDown, + reorder.dropTarget, + reorder.draggingKey, currentConversationId, + handleSetConversationArchived, + onSetConversationArchived, handleCancelRename, handleCommitRename, handleDeleteConversation, @@ -1302,6 +1228,131 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi ], ); + const renderProjectConversations = (project: WorkspaceProject, indented = false) => { + if (!expandedProjectIds.has(project.id)) return null; + const key = workspaceProjectPathKey(project.path); + const state = workspaceHistory?.get(key); + const conversations = projectConversations.get(key) ?? []; + const visible = visibleProjectConversations(project); + const canLoadMore = conversations.length > visible.length || state?.hasMore === true; + return ( +
+ {visible.map((item) => renderHistoryRow(item))} + {!state || (state.loading && visible.length === 0) ? ( +
+ {t("sidebar.readingHistory")} +
+ ) : visible.length === 0 && !state.error ? ( +
+ {t("chat.emptyChatHistory")} +
+ ) : null} + {state?.error ? ( +
+ {state.error} +
+ ) : null} + {canLoadMore || state?.error ? ( + + ) : null} +
+ ); + }; + + const hasVisibleActiveConversation = + visibleItems.some((item) => item.id === currentConversationId) || + (archivedConversationsOpen && archivedItems.some((item) => item.id === currentConversationId)); + const renderWorkspaceProject = ( + project: WorkspaceProject, + indented = false, + archived = false, + ) => { + const pathKey = workspaceProjectPathKey(project.path); + return ( + + + {!archived && renderProjectConversations(project, indented)} + + ); + }; + const renderArchivedConversations = () => + archivedItems.length > 0 ? ( +
+ + {archivedConversationsOpen && + archivedItems.map((item) => renderHistoryRow(item, false, true))} +
+ ) : null; + return (