diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx index e148277b..3e423f59 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.performance.test.tsx @@ -284,7 +284,7 @@ const initializePerformanceDom = async (): Promise => { mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null })); mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) })); mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) })); - mock.module('@/lib/url', () => ({ isExternalHttpUrl: () => false, openExternalUrl: async () => undefined, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => false })); + mock.module('@/lib/url', () => ({ getUrlScheme: () => null, isAppLinkUrl: () => false, isExternalHttpUrl: () => false, openConfirmedAppLinkUrl: async () => false, openExternalUrl: async () => undefined, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => false })); mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false })); mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false })); mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined })); diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts index 06911b4e..d4ce252f 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts @@ -48,6 +48,7 @@ let hookStates: Array<{ current: null } | undefined> = []; let activeFakeDocument: FakeDocument | null = null; const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => { + void ownerDocument; let html = ''; const element: FakeElement = { childNodes: [], @@ -184,6 +185,7 @@ const fakeReact = { return factory(); }, useRef: (current: T) => { + void current; const index = hookCursor; hookCursor += 1; if (!hookStates[index]) hookStates[index] = { current: null }; diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 311eb2c1..545ccdff 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -1080,8 +1080,9 @@ const MarkdownRendererImpl: React.FC = ({ scope: `${runtimeKey}\0${settledPart.sessionID}`, id: `${settledPart.messageID}\0${settledPart.id}\0${imageMode}`, locale, + directory: effectiveDirectory, }; - }, [content.length, imageMode, isStreaming, locale, runtimeKey, settledPart]); + }, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledPart]); // Identity for the fade-in wrapper: a new part/message restarts the animation. const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; diff --git a/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.test.ts b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.test.ts index 128a5026..71a4eccf 100644 --- a/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.test.ts +++ b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.test.ts @@ -5,7 +5,7 @@ import { DetachedMarkdownDomCache, type DetachedMarkdownDom } from './detachedMa Object.assign(globalThis, { document: new Window().document }); -const keyFor = ({ scope, id, locale }: DetachedMarkdownDom) => ({ scope, id, locale }); +const keyFor = ({ scope, id, locale, directory }: DetachedMarkdownDom) => ({ scope, id, locale, directory }); const createEntry = ( document: Document, @@ -21,6 +21,7 @@ const createEntry = ( scope: `runtime:${sessionId}`, id: `${messageId}:${partId}`, locale: 'en', + directory: '/repo-a', fragment, }; }; @@ -51,11 +52,13 @@ describe('DetachedMarkdownDomCache', () => { scope: 'runtime:session-a', id: 'message-2:part', locale: 'en', + directory: '/repo-a', })).toBeNull(); expect(cache.take({ scope: 'runtime:session-c', id: 'message-5:part', locale: 'en', + directory: '/repo-a', })).not.toBeNull(); }); @@ -75,6 +78,15 @@ describe('DetachedMarkdownDomCache', () => { expect(cache.take(keyFor(replacement))?.firstChild).toBe(replacementNode); }); + test('does not restore file-link DOM under another directory', () => { + const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 }); + const entry = createEntry(document, 'session', 'message', 'part'); + + cache.store(entry); + + expect(cache.take({ ...keyFor(entry), directory: '/repo-b' })).toBeNull(); + }); + test('refreshes session LRU and clears all entries', () => { const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 }); const sessionA = createEntry(document, 'session-a', 'message-a', 'part'); diff --git a/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts index 8ec194ea..58ae0258 100644 --- a/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts +++ b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts @@ -2,6 +2,7 @@ export type DetachedMarkdownDomKey = { scope: string; id: string; locale: string; + directory: string; }; export type DetachedMarkdownDom = DetachedMarkdownDomKey & { @@ -79,7 +80,7 @@ export class DetachedMarkdownDomCache { // A fragment is a move-only resource; taking it removes cache ownership. session.delete(entryKey); if (session.size === 0) this.sessions.delete(sessionKey); - if (entry.locale !== key.locale) return null; + if (entry.locale !== key.locale || entry.directory !== key.directory) return null; return entry.fragment; } diff --git a/packages/ui/src/components/session/SessionFolderItem.tsx b/packages/ui/src/components/session/SessionFolderItem.tsx index 483d6efd..0d0bcebd 100644 --- a/packages/ui/src/components/session/SessionFolderItem.tsx +++ b/packages/ui/src/components/session/SessionFolderItem.tsx @@ -4,7 +4,8 @@ import type { SessionFolder } from '@/stores/useSessionFoldersStore'; import { useI18n } from '@/lib/i18n'; import { Icon } from "@/components/icon/Icon"; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { CollapsedActivityIndicator, type CollapsedActivityState } from './sidebar/sessions/collapsedActivityIndicator'; +import { CollapsedActivityIndicator } from './sidebar/sessions/collapsedActivityIndicator'; +import type { CollapsedActivityState } from './sidebar/sessions/collapsedActivityState'; interface SessionFolderItemProps { folder: SessionFolder; @@ -56,9 +57,7 @@ const SessionFolderItemBase = ({ onToggle, onRename, onDelete, - children, - groupDirectory, - projectId, + children, mobileVariant = false, alwaysShowActions = mobileVariant, isRenaming = false, diff --git a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx index 5f0c6cef..e9098560 100644 --- a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx +++ b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { usePrefetchSessionMessages } from '@/sync/use-sync'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; @@ -24,9 +25,18 @@ import type { useSessionProjectViewState } from '../projects/useSessionProjectVi import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import type { DeleteSessionConfirmState } from '../sessions/useSessionActions'; import { useExpandedParents } from '../sessions/useExpandedParents'; +import { SessionGroupSection } from '../projects/SessionGroupSection'; +import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory } from '@/lib/chatDirectories'; +import { isCapacitorApp } from '@/lib/platform'; const PR_NO_PR_RETRY_MS = 5 * 60_000; +const isRootSession = (session: Session): boolean => { + // SAFETY: OpenCode attaches parentID to hierarchical session records, + // although the SDK's base Session type does not currently declare it. + return !(session as Session & { parentID?: string | null }).parentID; +}; + type Project = { id: string; path: string; @@ -122,8 +132,13 @@ const VisibleSessionProjects: React.FC = ({ topol }); }, []); const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection); + const projectDisplayMode = useSessionDisplayStore((state) => state.projectDisplayMode); + const singleProjectId = useSessionDisplayStore((state) => state.singleProjectId); + const setSingleProjectId = useSessionDisplayStore((state) => state.setSingleProjectId); + const supportsSingleProjectMode = !topology.isVSCode && !isCapacitorApp(); + const singleProjectMode = supportsSingleProjectMode && projectDisplayMode === 'single'; const recentSessions = useRecentSessionCollection({ - enabled: showRecentSection, + enabled: showRecentSection && !singleProjectMode, isVSCode: topology.isVSCode, pinnedSessionIds: collection.pinnedSessionIds, sessionOrderRanks: collection.sessionOrderRanks, @@ -269,6 +284,7 @@ const VisibleSessionProjects: React.FC = ({ topol editingId, editTitle, copiedSessionId, + sessionBatchSize: singleProjectMode && !view.useGroupedSections ? 20 : undefined, setEditingId, setEditTitle, toggleParent, @@ -309,6 +325,8 @@ const VisibleSessionProjects: React.FC = ({ topol view.hasSessionSearchQuery, view.mobileVariant, view.normalizedSessionSearchQuery, + view.useGroupedSections, + singleProjectMode, ]); const groupActions = React.useMemo(() => ({ showMoreGroupSessions, @@ -327,8 +345,55 @@ const VisibleSessionProjects: React.FC = ({ topol scrollerActions.setActiveProjectIdOnly, scrollerActions.setSessionSwitcherOpen, ]); + const chatGroup = React.useMemo(() => { + if (topology.isVSCode) return null; + const chatsRoot = getChatsRootForHome(view.homeDirectory) + ?? collection.chatSessions.map((session) => getChatsRootFromDirectory(session.directory)).find(Boolean) + ?? null; + if (!chatsRoot) return null; + const folderScopes = Array.from(new Set([ + chatsRoot, + ...collection.chatSessions.map((session) => normalizePath(session.directory ?? null)).filter(Boolean), + ])).filter((directory): directory is string => Boolean(directory)) + .map((directory) => ({ scopeKey: directory, directory })); + return { + id: 'managed-chats', + label: '', + branch: null, + description: null, + isMain: true, + worktree: null, + directory: chatsRoot, + folderScopeKey: chatsRoot, + folderScopes, + draftTarget: 'chat', + sessions: collection.chatSessions + .filter((session) => !session.time?.archived && isRootSession(session)) + .map((session) => ({ session, children: (collection.childrenMap.get(session.id) ?? []).filter((child) => !child.time?.archived).map((child) => ({ session: child, children: [], worktree: null })), worktree: null })), + }; + }, [collection.chatSessions, collection.childrenMap, topology.isVSCode, view.homeDirectory]); + const renderChatsSection = React.useCallback(() => { + if (!chatGroup) return null; + return ; + }, [chatGroup, groupActions, groupProps, openSidebarMenuKey]); + const handleOpenNewChat = React.useCallback(() => { + scrollerActions.setActiveMainTab('chat'); + if (view.mobileVariant) scrollerActions.setSessionSwitcherOpen(false); + scrollerActions.openNewSessionDraft({ selectedProjectId: CHAT_DRAFT_PROJECT_ID, directoryOverride: null }); + }, [scrollerActions, view.mobileVariant]); const recentSection = React.useMemo(() => ( - !topology.isVSCode && showRecentSection ? = ({ topol setDeleteSessionConfirm={setDeleteSessionConfirm} startFolderRename={startFolderRename} setCopiedSessionId={setCopiedSessionId} + chatSessions={collection.chatSessions} + renderChatsSection={renderChatsSection} + onNewChat={handleOpenNewChat} + showRecentSection={showRecentSection && !singleProjectMode} /> : null ), [ alwaysShowActions, @@ -378,12 +447,16 @@ const VisibleSessionProjects: React.FC = ({ topol recentSessions, rowActions, showRecentSection, + singleProjectMode, + handleOpenNewChat, + renderChatsSection, startFolderRename, toggleParent, topology.availableWorktreesByProject, topology.gitBranches, topology.isVSCode, topology.projects, + collection.chatSessions, view.hasSessionSearchQuery, view.homeDirectory, view.isDesktopShellRuntime, @@ -396,6 +469,14 @@ const VisibleSessionProjects: React.FC = ({ topol sectionsForRender: orderedSectionsForRender, projectSections, activeProjectId: view.activeProjectId, + singleProjectMode, + singleProjectId: singleProjectMode + ? (projectSections.some((section) => section.project.id === singleProjectId) + ? singleProjectId + : (projectSections.some((section) => section.project.id === view.activeProjectId) + ? view.activeProjectId + : projectSections[0]?.project.id ?? null)) + : null, emptyState: view.emptyState, searchEmptyState: view.searchEmptyState, projectRepoStatus: topology.projectRepoStatus, @@ -416,6 +497,8 @@ const VisibleSessionProjects: React.FC = ({ topol view.searchEmptyState, visibleSessionCountByGroup, recentSection, + singleProjectId, + singleProjectMode, ]); const scrollerView = React.useMemo(() => ({ homeDirectory: view.homeDirectory, @@ -456,6 +539,7 @@ const VisibleSessionProjects: React.FC = ({ topol reorderProjects: scrollerActions.reorderProjects, setGroupOrderByProject, renderProjectStatusIndicator: scrollerActions.renderProjectStatusIndicator, + setSingleProjectId, }), [ groupActions, scrollerActions.openNewSessionDraft, @@ -470,6 +554,7 @@ const VisibleSessionProjects: React.FC = ({ topol setGroupOrderByProject, toggleProject, scrollerActions.renderProjectStatusIndicator, + setSingleProjectId, ]); return <> { const descriptors = new Map(); @@ -157,6 +164,80 @@ describe('projectSidebarCollection', () => { expect(recentBefore).toEqual([]); expect(recentAfter.map((entry) => entry.id)).toEqual(['old-root']); }); + + test('keeps managed Chats in a dedicated projection and out of project and Recent ownership', () => { + const managed = session('managed', '/home/.config/openchamber/chats/2026-08-24/session-managed'); + const project = session('project', '/workspace/a'); + const projects = projectSidebarCollection({ + globalActiveSessions: [managed, project], + liveSessions: [], + knownDirectories: new Set(['/workspace/a']), + isVSCode: false, + }); + + expect(projects.map((entry) => entry.id)).toEqual(['project']); + expect(partitionSidebarSessions([managed, project], false).chatSessions.map((entry) => entry.id)).toEqual(['managed']); + expect(deriveRecentSessions(projects, new Set(['managed', 'project']), 200_000_000) + .map((entry) => entry.id)).toEqual(['project']); + }); + + test('keeps managed Chats out of the VS Code sidebar', () => { + const managed = session('managed', '/home/.config/openchamber/chats/2026-08-24/session-managed'); + + expect(partitionSidebarSessions([managed], true)).toEqual({ projectSessions: [], chatSessions: [] }); + expect(projectSidebarCollection({ + globalActiveSessions: [managed], + liveSessions: [], + knownDirectories: new Set(), + isVSCode: true, + })).toEqual([]); + }); + + test('excludes a /btw fork before project ownership and restores it when the marker is removed', () => { + const fork = { + ...session('fork', '/home/.config/openchamber/chats/2026-08-24/session-fork'), + metadata: { openchamber: { kind: 'btw', originalSessionID: 'parent' } }, + }; + const project = session('project', '/workspace/a'); + const input = { + liveSessions: [], + knownDirectories: new Set(['/workspace/a']), + isVSCode: false, + }; + + expect(projectSidebarCollection({ ...input, globalActiveSessions: [fork, project] }).map((entry) => entry.id)).toEqual(['project']); + expect(partitionSidebarSessions([fork], false).chatSessions).toEqual([]); + + const promoted = { + ...fork, + metadata: { openchamber: {} }, + }; + expect(partitionSidebarSessions([promoted], false).chatSessions.map((entry) => entry.id)).toEqual(['fork']); + }); + + test('keeps a ranked managed root and its active child in the Chats hierarchy', () => { + const managedRoot = { ...session('managed-root', '/home/.config/openchamber/chats/2026-08-24/session-root'), time: { created: 1, updated: 1 } }; + const managedChild = { + ...session('managed-child', '/home/.config/openchamber/chats/2026-08-24/session-root'), + parentID: 'managed-root', + time: { created: 2, updated: 2 }, + }; + const projectRoot = { ...session('project-root', '/workspace/a'), time: { created: 3, updated: 3 } }; + + const projection = buildSidebarSessionProjection({ + globalActiveSessions: [projectRoot, managedRoot, managedChild], + liveSessions: [], + knownDirectories: new Set(['/workspace/a']), + isVSCode: false, + pinnedSessionIds: new Set(), + sessionOrderRanks: new Map([['managed-root', 10]]), + }); + + expect(projection.projectSessions.map((entry) => entry.id)).toEqual(['project-root']); + expect(projection.chatSessions.map((entry) => entry.id)).toEqual(['managed-root', 'managed-child']); + expect(projection.orderedSessions.map((entry) => entry.id)).toEqual(['managed-root', 'managed-child', 'project-root']); + expect(projection.childrenMap.get('managed-root')?.map((entry) => entry.id)).toEqual(['managed-child']); + }); }); describe('useRecentSessionCollection', () => { diff --git a/packages/ui/src/components/session/sidebar/list/sessionCollection.ts b/packages/ui/src/components/session/sidebar/list/sessionCollection.ts index 4c62c9f7..c1e399c3 100644 --- a/packages/ui/src/components/session/sidebar/list/sessionCollection.ts +++ b/packages/ui/src/components/session/sidebar/list/sessionCollection.ts @@ -13,6 +13,8 @@ import { useGlobalSessionStatusStore } from '@/sync/global-session-status'; import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { deriveRecentSessions } from '../recent/activitySections'; import { normalizePath } from '../utils'; +import { isChatDirectoryPath } from '@/lib/chatDirectories'; +import { isBtwSession } from '@/lib/sessionBtwMetadata'; type ProjectSidebarActiveSessionsArgs = { globalActiveSessions: Session[]; @@ -21,6 +23,31 @@ type ProjectSidebarActiveSessionsArgs = { isVSCode: boolean; }; +type SidebarSessionPartitions = { + projectSessions: Session[]; + chatSessions: Session[]; +}; + +// This boundary owns session visibility before Recent or projects take +// ownership. Temporary /btw forks never leak into any sidebar projection. +export const partitionSidebarSessions = ( + sessions: readonly Session[], + isVSCode: boolean, +): SidebarSessionPartitions => { + const projectSessions: Session[] = []; + const chatSessions: Session[] = []; + for (const session of sessions) { + if (isBtwSession(session)) continue; + if (isChatDirectoryPath(session.directory)) { + if (isVSCode) continue; + chatSessions.push(session); + continue; + } + projectSessions.push(session); + } + return { projectSessions, chatSessions }; +}; + const EMPTY_ACTIVE_SESSION_IDS: ReadonlySet = new Set(); const isKnownActiveSessionDirectory = ( @@ -51,13 +78,28 @@ export const projectSidebarActiveSessions = ({ sessions.push(session); } - return sessions.filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode)); + return partitionSidebarSessions(sessions, isVSCode).projectSessions + .filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode)); }; export const projectSidebarCollection = (args: ProjectSidebarActiveSessionsArgs): Session[] => { return projectSidebarActiveSessions(args); }; +const mergeSidebarSessionSources = ( + globalActiveSessions: readonly Session[], + liveSessions: readonly Session[], +): Session[] => { + const sessions = [...globalActiveSessions]; + const knownIds = new Set(globalActiveSessions.map((session) => session.id)); + for (const session of liveSessions) { + if (knownIds.has(session.id)) continue; + knownIds.add(session.id); + sessions.push(session); + } + return sessions; +}; + // The collection owns hierarchy membership. Consumers receive this narrow // resolver instead of retaining the collection's mutable indexing detail. export const getDescendantIds = ( @@ -78,6 +120,49 @@ export const getDescendantIds = ( return descendants; }; +type SidebarSessionProjectionArgs = ProjectSidebarActiveSessionsArgs & { + pinnedSessionIds: Set; + sessionOrderRanks: ReadonlyMap; +}; + +export const buildSidebarSessionProjection = ({ + globalActiveSessions, + liveSessions, + knownDirectories, + isVSCode, + pinnedSessionIds, + sessionOrderRanks, +}: SidebarSessionProjectionArgs) => { + const visibleSessions = mergeSidebarSessionSources(globalActiveSessions, liveSessions); + const partition = partitionSidebarSessions(visibleSessions, isVSCode); + const projectSessions = partition.projectSessions + .filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode)); + const orderedSessions = orderSessionsByLifecycleScopes( + [...projectSessions, ...partition.chatSessions], + pinnedSessionIds, + sessionOrderRanks, + ); + const chatSessionIds = new Set(partition.chatSessions.map((session) => session.id)); + const sessionById = new Map(orderedSessions.map((session) => [session.id, session])); + const childrenMap = new Map(); + for (const session of sessionById.values()) { + // SAFETY: OpenCode's session records carry parentID for sub-session + // hierarchy; the SDK's base Session type does not currently expose it. + const parentID = (session as Session & { parentID?: string | null }).parentID; + if (!parentID) continue; + const siblings = childrenMap.get(parentID) ?? []; + siblings.push(session); + childrenMap.set(parentID, siblings); + } + return { + chatSessions: orderedSessions.filter((session) => chatSessionIds.has(session.id)), + childrenMap, + orderedSessions, + projectSessions, + sessionById, + }; +}; + type UseSessionProjectCollectionArgs = { knownDirectories: Set; isVSCode: boolean; @@ -101,16 +186,15 @@ export const useSessionProjectCollection = ({ (state) => isVisible ? state.rankById : EMPTY_SESSION_ORDER_RANKS, [isVisible], )); - const sessions = React.useMemo(() => projectSidebarCollection({ + const projection = React.useMemo(() => buildSidebarSessionProjection({ globalActiveSessions, liveSessions, knownDirectories, isVSCode, - }), [globalActiveSessions, isVSCode, knownDirectories, liveSessions]); - const orderedSessions = React.useMemo( - () => orderSessionsByLifecycleScopes(sessions, pinnedSessionIds, sessionOrderRanks), - [pinnedSessionIds, sessionOrderRanks, sessions], - ); + pinnedSessionIds, + sessionOrderRanks, + }), [globalActiveSessions, isVSCode, knownDirectories, liveSessions, pinnedSessionIds, sessionOrderRanks]); + const { chatSessions, orderedSessions, projectSessions: sessions } = projection; const sessionById = React.useMemo(() => new Map( [...orderedSessions, ...archivedSessions].map((session) => [session.id, session]), ), [archivedSessions, orderedSessions]); @@ -129,13 +213,14 @@ export const useSessionProjectCollection = ({ }, [sessionById]); const getDescendantIdsForAction = React.useCallback( (sessionId: string, options: { includeArchived: boolean }) => getDescendantIds(childrenMap, sessionId) - .filter((id) => options.includeArchived || !Boolean(sessionById.get(id)?.time?.archived)), + .filter((id) => options.includeArchived || !sessionById.get(id)?.time?.archived), [childrenMap, sessionById], ); return { archivedSessions, childrenMap, + chatSessions, getDescendantIds: getDescendantIdsForAction, globalActiveSessions, hasAuthoritativeGlobalSessions, diff --git a/packages/ui/src/components/session/sidebar/list/useSessionListSync.test.tsx b/packages/ui/src/components/session/sidebar/list/useSessionListSync.test.tsx index 20d0622b..c57b312e 100644 --- a/packages/ui/src/components/session/sidebar/list/useSessionListSync.test.tsx +++ b/packages/ui/src/components/session/sidebar/list/useSessionListSync.test.tsx @@ -74,7 +74,6 @@ mock.module('./useAuthoritativeSessionCleanup', () => ({ const { useSessionListSync } = await import('./useSessionListSync'); const projects = [{ id: 'project', path: '/project' }]; -const projectDirectories = new Set(['/project']); const worktree: WorktreeMetadata = { path: '/worktree', projectDirectory: '/project', branch: 'feature', label: 'feature' }; const LifecycleProbe: React.FC<{ isVSCode: boolean }> = ({ isVSCode }) => { diff --git a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx index 03f2b031..bb14a6e1 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx @@ -7,9 +7,6 @@ import { useUIStore } from '@/stores/useUIStore'; import type { SessionFolder } from '@/stores/useSessionFoldersStore'; import type { Session } from '@opencode-ai/sdk/v2'; import type { SessionGroupSectionProps } from './SessionGroupSection'; -import { SessionProjectScroller } from './SessionProjectScroller'; -import { RecentSessionSection } from '../recent/RecentSessionSection'; -import { SidebarActivitySections } from '../recent/SidebarActivitySections'; import { installHookTestDom } from '../test-utils/testDom'; type FolderCallbacks = { @@ -27,25 +24,6 @@ type RowPropsCapture = Pick; -type ExpectNever = T; -type RowDomainCallback = - | 'handleSaveEdit' - | 'handleCancelEdit' - | 'handleSessionSelect' - | 'handleSessionDoubleClick' - | 'handleShareSession' - | 'handleCopyShareUrl' - | 'handleCopySessionId' - | 'handleUnshareSession' - | 'handleDeleteSession' - | 'handleRestoreSession'; - -// Structural group contracts must not expose the row domain action surface. -type _SessionGroupSectionHasNoRowDomainCallbacks = ExpectNever>; -type _SessionProjectScrollerHasNoRowDomainCallbacks = ExpectNever, RowDomainCallback>>; -type _RecentSessionSectionHasNoRowDomainCallbacks = ExpectNever, RowDomainCallback>>; -type _SidebarActivitySectionsHasNoRowDomainCallbacks = ExpectNever, RowDomainCallback>>; - let folderCallbacks: FolderCallbacks | null = null; let rowPropsCapture: RowPropsCapture | null = null; @@ -80,6 +58,9 @@ mock.module('@/sync/sync-context', () => ({ mock.module('../sessions/collapsedActivityIndicator', () => ({ CollapsedSessionActivityIndicator: () => null, +})); + +mock.module('../sessions/collapsedActivityState', () => ({ useCollapsedSessionActivityState: () => null, })); diff --git a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx index d1fec86b..f1cb3711 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx @@ -32,7 +32,6 @@ import { selectFolderIdsForProjection, selectFolderRootNodes, } from '../sessions/sessionNodeItemUtils'; -import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; type FolderScope = { scopeKey: string; directory: string | null }; @@ -40,7 +39,8 @@ import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrSt import { useI18n } from '@/lib/i18n'; import { useChildStoreManager } from '@/sync/sync-context'; import { canRequestNativeDirectoryAccess, requestDirectoryAccess } from '@/lib/desktop'; -import { CollapsedSessionActivityIndicator, useCollapsedSessionActivityState } from '../sessions/collapsedActivityIndicator'; +import { CollapsedSessionActivityIndicator } from '../sessions/collapsedActivityIndicator'; +import { useCollapsedSessionActivityState } from '../sessions/collapsedActivityState'; import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem'; import { FolderDeleteConfirmDialog } from '../shell/ConfirmDialogs'; @@ -445,18 +445,6 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo return allFoldersForGroupBase.filter(({ folder }) => visibleFolderIds.has(folder.id)); }, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]); - const groupSessionIds = React.useMemo(() => { - const ids = new Set(); - const visit = (nodes: SessionNode[]) => nodes.forEach((node) => { - ids.add(node.session.id); - visit(node.children); - }); - visit(sourceGroupNodes); - return ids; - }, [sourceGroupNodes]); - const groupExpansionKeys = React.useMemo(() => new Set( - [...groupSessionIds].map((id) => `project:${group.isArchivedBucket ? 'archived' : 'active'}:${id}`), - ), [group.isArchivedBucket, groupSessionIds]); const effectiveEditingId = editingId; const effectiveOpenMenuKey = openSidebarMenuKey; const effectiveExpandedParents = expandedParents; diff --git a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.test.ts b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.test.ts index e7cbc684..11fc263f 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.test.ts +++ b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { buildGroupRenderDescriptors } from './sessionProjectRender'; +import { buildGroupRenderDescriptors, selectRenderedProjectSections } from './sessionProjectRender'; import type { SessionGroup } from '../types'; +import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; const makeGroup = (id: string, overrides: Partial = {}): SessionGroup => ({ id, @@ -68,3 +69,23 @@ describe('buildGroupRenderDescriptors', () => { expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false }).map((descriptor) => descriptor.hideGroupLabel)).toEqual([false, false]); }); }); + +describe('single-project scroller projection', () => { + test('renders only the selected project from persisted display state', () => { + const previous = useSessionDisplayStore.getState(); + const sections = [ + { project: { id: 'project-a', normalizedPath: '/workspace/a' }, groups: [] }, + { project: { id: 'project-b', normalizedPath: '/workspace/b' }, groups: [] }, + ]; + + try { + useSessionDisplayStore.setState({ projectDisplayMode: 'single', singleProjectId: 'project-b' }); + const state = useSessionDisplayStore.getState(); + + expect(selectRenderedProjectSections(sections, state.projectDisplayMode === 'single', state.singleProjectId) + .map((section) => section.project.id)).toEqual(['project-b']); + } finally { + useSessionDisplayStore.setState(previous, true); + } + }); +}); diff --git a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx index 688b5bf6..30a31ac3 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx @@ -14,7 +14,7 @@ import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils'; import type { SessionGroup } from '../types'; import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems'; import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection'; -import { buildGroupRenderDescriptors, type ProjectSection } from './sessionProjectRender'; +import { buildGroupRenderDescriptors, selectRenderedProjectSections, type ProjectSection } from './sessionProjectRender'; import { formatProjectLabel } from '../utils'; import { useI18n } from '@/lib/i18n'; import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore'; @@ -78,6 +78,8 @@ type SessionProjectScrollerModel = { sectionsForRender: ProjectSection[]; projectSections: ProjectSection[]; activeProjectId: string | null; + singleProjectMode: boolean; + singleProjectId: string | null; emptyState: React.ReactNode; searchEmptyState: React.ReactNode; projectRepoStatus: Map; @@ -114,6 +116,7 @@ type SessionProjectScrollerActions = { reorderProjects: (fromIndex: number, toIndex: number) => void; setGroupOrderByProject: React.Dispatch>>; renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode; + setSingleProjectId: (id: string) => void; }; type Props = { @@ -139,7 +142,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode { const { t } = useI18n(); const { model, view, actions } = props; const isInlineEditing = model.state.editingId !== null; - const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders; + const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders && !model.singleProjectMode; const projectSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), @@ -180,7 +183,12 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode { event.preventDefault(); event.stopPropagation(); }, []); - const hasProjectScroller = model.projectSections.length > 0 && model.sectionsForRender.length > 0; + const renderedSections = selectRenderedProjectSections( + model.sectionsForRender, + model.singleProjectMode, + model.singleProjectId, + ); + const hasProjectScroller = model.projectSections.length > 0 && renderedSections.length > 0; React.useLayoutEffect(() => { if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) { syncTopFade(scrollContainerRef.current); @@ -199,8 +207,17 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode { // ready in the same frame; the observer then corrects it. When shared sessions // lead the list, the Recent fallback below owns the top instead of a project. const leadingProject = - stuckProject ?? (model.hasSharedSessions ? null : model.sectionsForRender[0]?.project ?? null); + stuckProject ?? (model.hasSharedSessions ? null : renderedSections[0]?.project ?? null); const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null; + const projectPickerOptions = React.useMemo(() => model.projectSections.map((section) => ({ + id: section.project.id, + projectLabel: getProjectLabel(section.project, view.homeDirectory), + projectDescription: formatPathForDisplay(section.project.normalizedPath, view.homeDirectory), + projectIcon: section.project.icon, + projectColor: section.project.color, + projectIconImage: section.project.iconImage, + projectIconBackground: section.project.iconBackground, + })), [model.projectSections, view.homeDirectory]); if (model.projectSections.length === 0) { return {model.topContent}{model.emptyState}; @@ -237,7 +254,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode { {view.showOnlyMainWorkspace ? (
{(() => { - const activeSection = model.sectionsForRender.find((section) => section.project.id === model.activeProjectId) ?? model.sectionsForRender[0]; + const activeSection = renderedSections.find((section) => section.project.id === model.activeProjectId) ?? renderedSections[0]; if (!activeSection) { return view.hasSessionSearchQuery ? model.searchEmptyState : model.emptyState; } @@ -270,20 +287,20 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode { actions.reorderProjects(oldIndex, newIndex); }} > - section.project.id)} strategy={verticalListSortingStrategy}> - {model.sectionsForRender.map((section) => { + section.project.id)} strategy={verticalListSortingStrategy}> + {renderedSections.map((section) => { const project = section.project; const projectKey = project.id; const projectLabel = getProjectLabel(project, view.homeDirectory); const projectDescription = formatPathForDisplay(project.normalizedPath, view.homeDirectory); - const isCollapsed = view.collapsedProjects.has(projectKey); + const isCollapsed = model.singleProjectMode ? false : view.collapsedProjects.has(projectKey); const isRepo = model.projectRepoStatus.get(projectKey); return ( actions.toggleProject(projectKey)} + setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} + projectPickerOptions={model.singleProjectMode ? projectPickerOptions : undefined} + onProjectSelect={model.singleProjectMode ? actions.setSingleProjectId : undefined} + onToggle={() => { if (!model.singleProjectMode) actions.toggleProject(projectKey); }} onNewSession={() => { if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey); if (view.mobileVariant) actions.setSessionSwitcherOpen(false); diff --git a/packages/ui/src/components/session/sidebar/projects/sessionProjectRender.ts b/packages/ui/src/components/session/sidebar/projects/sessionProjectRender.ts index 146ff240..8f5d0ea6 100644 --- a/packages/ui/src/components/session/sidebar/projects/sessionProjectRender.ts +++ b/packages/ui/src/components/session/sidebar/projects/sessionProjectRender.ts @@ -13,6 +13,14 @@ export type ProjectSection = { groups: SessionGroup[]; }; +export const selectRenderedProjectSections = ( + sections: ProjectSection[], + singleProjectMode: boolean, + singleProjectId: string | null, +): ProjectSection[] => singleProjectMode + ? sections.filter((section) => section.project.id === singleProjectId) + : sections; + type GroupRenderDescriptor = { group: SessionGroup; groupKey: string; diff --git a/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx b/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx index a2149948..7b1c3d01 100644 --- a/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx +++ b/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx @@ -35,6 +35,8 @@ type ProjectHeaderIdentityProps = ProjectIdentityProps & { alwaysShowActions?: boolean; }; +type ProjectPickerOption = ProjectIdentityProps & { projectDescription: string }; + export const ProjectHeaderIdentity: React.FC = ({ id, projectLabel, @@ -121,6 +123,8 @@ export interface SortableProjectItemProps extends ProjectIdentityProps { statusIndicator?: React.ReactNode; openSidebarMenuKey: string | null; setOpenSidebarMenuKey: (key: string | null) => void; + projectPickerOptions?: ProjectPickerOption[]; + onProjectSelect?: (projectId: string) => void; } export const SortableProjectItem: React.FC = ({ @@ -150,6 +154,8 @@ export const SortableProjectItem: React.FC = ({ statusIndicator = null, openSidebarMenuKey, setOpenSidebarMenuKey, + projectPickerOptions, + onProjectSelect, }) => { const { t } = useI18n(); const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders); @@ -166,6 +172,7 @@ export const SortableProjectItem: React.FC = ({ const menuInstanceKey = `project:${id}`; const isMenuOpen = openSidebarMenuKey === menuInstanceKey; const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false); + const isProjectPicker = Boolean(projectPickerOptions && onProjectSelect); const handleMenuOpenChange = React.useCallback((open: boolean) => { if (open) setIsContextMenuOpen(false); @@ -273,7 +280,28 @@ export const SortableProjectItem: React.FC = ({ className="relative flex items-center gap-1 py-1 pl-4 pr-3.5" {...attributes} > - + {isProjectPicker ? ( + + + + + + {projectPickerOptions?.map((option) => ( + onProjectSelect?.(option.id)} className="flex items-center justify-between gap-3" title={option.projectDescription}> + + {option.id === id ? : null} + + ))} + + + ) : + {section.key === 'chats' && props.onNewChat ? ( + + ) : null}
{!isCollapsed ? (
- {visibleItems.map(renderItem)} - {remainingCount > 0 ? ( + {usesCustomRenderer ? props.renderChatsSection?.(section.items) : visibleItems.map(renderItem)} + {!usesCustomRenderer && remainingCount > 0 ? (