diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 2c79addd..5008fd5c 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -61,6 +61,7 @@ import { type SessionGroup, type SessionNode } from './sidebar/types'; import { deriveActiveNowSessions, deriveLiveActiveNowSessions, + getSessionUpdatedAtMs, } from './sidebar/activitySections'; import { useActiveNowStore } from '@/stores/useActiveNowStore'; import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; @@ -84,6 +85,8 @@ const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse'; const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject'; +const VSCODE_RECENT_INITIAL_SESSION_COUNT = 20; +const VSCODE_RECENT_SESSION_BATCH_SIZE = 20; // v2 key holds composite "${renderContext}:${active|archived}:${sessionId}" // entries so the same session in different render contexts (e.g. "Recent" // and a project's root) has independent expand state. v1 held bare session @@ -122,12 +125,16 @@ type PrIndicator = { const buildKnownSessionDirectories = ( projects: Array<{ path: string }>, availableWorktreesByProject: Map, + options?: { includeWorktrees?: boolean }, ): Set => { const directories = new Set(); for (const project of projects) { const normalized = normalizePath(project.path)?.toLowerCase(); if (normalized) directories.add(normalized); } + if (options?.includeWorktrees === false) { + return directories; + } for (const worktrees of availableWorktreesByProject.values()) { for (const worktree of worktrees) { const normalized = normalizePath(worktree.path)?.toLowerCase(); @@ -137,11 +144,15 @@ const buildKnownSessionDirectories = ( return directories; }; -const isKnownActiveSessionDirectory = (session: Session, knownDirectories: Set): boolean => { +const isKnownActiveSessionDirectory = ( + session: Session, + knownDirectories: Set, + options?: { allowUnknownDirectory?: boolean; allowEmptyDirectorySet?: boolean }, +): boolean => { if (session.time?.archived) return true; const directory = normalizePath(resolveGlobalSessionDirectory(session))?.toLowerCase(); - if (!directory) return true; - if (knownDirectories.size === 0) return true; + if (!directory) return options?.allowUnknownDirectory ?? true; + if (knownDirectories.size === 0) return options?.allowEmptyDirectorySet ?? true; return knownDirectories.has(directory); }; @@ -305,6 +316,7 @@ export const SessionSidebar: React.FC = ({ const sync = useSync(); const liveSessions = useAllLiveSessions(); const liveSessionStatuses = useAllSessionStatuses(); + const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded); const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions); @@ -332,8 +344,8 @@ export const SessionSidebar: React.FC = ({ }))); const knownSessionDirectories = React.useMemo( - () => buildKnownSessionDirectories(projects, availableWorktreesByProject), - [availableWorktreesByProject, projects], + () => buildKnownSessionDirectories(projects, availableWorktreesByProject, { includeWorktrees: !isVSCode }), + [availableWorktreesByProject, isVSCode, projects], ); const sessions = React.useMemo(() => { @@ -351,8 +363,11 @@ export const SessionSidebar: React.FC = ({ merged.push(session); }); - return merged.filter((session) => isKnownActiveSessionDirectory(session, knownSessionDirectories)); - }, [globalActiveSessions, knownSessionDirectories, liveSessions]); + return merged.filter((session) => isKnownActiveSessionDirectory(session, knownSessionDirectories, { + allowUnknownDirectory: !isVSCode, + allowEmptyDirectorySet: !isVSCode, + })); + }, [globalActiveSessions, isVSCode, knownSessionDirectories, liveSessions]); const syncSessionStructureSignature = React.useMemo( () => liveSessions @@ -452,7 +467,6 @@ export const SessionSidebar: React.FC = ({ const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); - const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const { isTablet } = useDeviceInfo(); const alwaysShowSidebarActions = mobileVariant || isTablet; @@ -804,6 +818,9 @@ export const SessionSidebar: React.FC = ({ const directories = new Set(); normalizedProjects.forEach((project) => { if (project.normalizedPath) directories.add(project.normalizedPath); + if (isVSCode) { + return; + } const worktrees = availableWorktreesByProject.get(project.normalizedPath) ?? []; worktrees.forEach((worktree) => { const directory = normalizePath(worktree.path); @@ -811,7 +828,7 @@ export const SessionSidebar: React.FC = ({ }); }); return [...directories].sort(); - }, [availableWorktreesByProject, normalizedProjects]); + }, [availableWorktreesByProject, isVSCode, normalizedProjects]); const knownProjectSessionDirectoriesRef = React.useRef | null>(null); React.useEffect(() => { @@ -819,6 +836,9 @@ export const SessionSidebar: React.FC = ({ const previousDirectories = knownProjectSessionDirectoriesRef.current; knownProjectSessionDirectoriesRef.current = nextDirectories; if (!previousDirectories) { + if (isVSCode && projectSessionDirectories.length > 0) { + void refreshGlobalSessionsForDirectories(projectSessionDirectories, syncSessionsSnapshotRef.current); + } return; } @@ -828,7 +848,7 @@ export const SessionSidebar: React.FC = ({ } void refreshGlobalSessionsForDirectories(addedDirectories, syncSessionsSnapshotRef.current); - }, [projectSessionDirectories]); + }, [isVSCode, projectSessionDirectories]); const { github } = useRuntimeAPIs(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); @@ -1001,13 +1021,28 @@ export const SessionSidebar: React.FC = ({ const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions); const activeNowSessions = React.useMemo(() => { - if (!showRecentSection) { + if (!showRecentSection || isVSCode) { return []; } return deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session]))) .sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)); - }, [activeNowEntries, pinnedSessionIds, sessions, showRecentSection]); + }, [activeNowEntries, isVSCode, pinnedSessionIds, sessions, showRecentSection]); + + const vscodeSharedSessions = React.useMemo(() => { + if (!isVSCode) { + return []; + } + + return sessions + .filter((session) => !session.time?.archived) + .filter((session) => !(session as Session & { parentID?: string | null }).parentID) + .sort((left, right) => { + const timeDelta = getSessionUpdatedAtMs(right) - getSessionUpdatedAtMs(left); + if (timeDelta !== 0) return timeDelta; + return right.id.localeCompare(left.id); + }); + }, [isVSCode, sessions]); const liveActiveSessions = React.useMemo(() => { if (!showRecentSection) { @@ -1041,25 +1076,50 @@ export const SessionSidebar: React.FC = ({ // Prefetch is wired below, after recentSessionIds is computed. const activitySections = React.useMemo(() => { - if (!showRecentSection) { + if (!isVSCode && !showRecentSection) { return []; } + const recentSessions = isVSCode ? vscodeSharedSessions : activeNowSessions; + const toItem = (session: Session) => { const existing = sessionSidebarMetaById.get(session.id); const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + const node = existing?.node ?? { session, children: [], worktree: null }; + const filteredNodes = hasSessionSearchQuery + ? filterSessionNodesForSearch([node], normalizedSessionSearchQuery) + : [node]; + const filteredNode = filteredNodes[0]; + if (!filteredNode) { + return null; + } + const secondaryMeta = existing?.secondaryMeta + ? { + projectLabel: existing.secondaryMeta.projectLabel, + branchLabel: isVSCode ? null : existing.secondaryMeta.branchLabel, + } + : null; return { - node: existing?.node ?? { session, children: [], worktree: null }, + node: filteredNode, projectId: existing?.projectId ?? null, groupDirectory: existing?.groupDirectory ?? sessionDirectory, - secondaryMeta: existing?.secondaryMeta ?? null, + secondaryMeta, }; }; + const items = recentSessions + .map(toItem) + .filter((item): item is NonNullable> => item !== null); + return [ - { key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items: activeNowSessions.map(toItem) }, + { key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items }, ]; - }, [activeNowSessions, sessionSidebarMetaById, showRecentSection, t]); + }, [activeNowSessions, filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, sessionSidebarMetaById, showRecentSection, t, vscodeSharedSessions]); + + const hasActivitySectionItems = React.useMemo( + () => activitySections.some((section) => section.items.length > 0), + [activitySections], + ); const recentSessionIds = React.useMemo(() => { @@ -1399,10 +1459,13 @@ export const SessionSidebar: React.FC = ({ ], ); - const topContent = showRecentSection && !isVSCode && !hasSessionSearchQuery ? ( + const topContent = (isVSCode || (showRecentSection && !hasSessionSearchQuery)) ? ( ) : null; const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId); @@ -1619,6 +1682,8 @@ export const SessionSidebar: React.FC = ({ React.ReactNode; + variant?: 'section' | 'flat'; + initialVisibleCount?: number; + batchSize?: number; }; const MAX_VISIBLE_RECENT_SESSIONS = 7; -export function SidebarActivitySections({ sections, renderSessionNode }: Props): React.ReactNode { +export function SidebarActivitySections({ + sections, + renderSessionNode, + variant = 'section', + initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS, + batchSize = MAX_VISIBLE_RECENT_SESSIONS, +}: Props): React.ReactNode { const { t } = useI18n(); const [collapsed, setCollapsed] = React.useState>(new Set()); - const [expandedSections, setExpandedSections] = React.useState>(new Set()); + const [visibleCountBySection, setVisibleCountBySection] = React.useState>(new Map()); + const flatVariant = variant === 'flat'; const toggleSection = React.useCallback((key: string) => { setCollapsed((prev) => { @@ -44,14 +54,24 @@ export function SidebarActivitySections({ sections, renderSessionNode }: Props): }); }, []); - const toggleSectionLimit = React.useCallback((key: string) => { - setExpandedSections((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); + const showMoreSessions = React.useCallback((key: string, currentVisibleCount: number, totalCount: number) => { + setVisibleCountBySection((prev) => { + const nextVisibleCount = flatVariant + ? Math.min(totalCount, currentVisibleCount + batchSize) + : totalCount; + const next = new Map(prev); + next.set(key, nextVisibleCount); + return next; + }); + }, [batchSize, flatVariant]); + + const resetSectionLimit = React.useCallback((key: string) => { + setVisibleCountBySection((prev) => { + if (!prev.has(key)) { + return prev; } + const next = new Map(prev); + next.delete(key); return next; }); }, []); @@ -62,12 +82,34 @@ export function SidebarActivitySections({ sections, renderSessionNode }: Props): } return ( -
+
{visibleSections.map((section) => { const isCollapsed = collapsed.has(section.key); - const isExpanded = expandedSections.has(section.key); - const visibleItems = isExpanded ? section.items : section.items.slice(0, MAX_VISIBLE_RECENT_SESSIONS); + const visibleLimit = Math.max( + initialVisibleCount, + visibleCountBySection.get(section.key) ?? initialVisibleCount, + ); + const visibleItems = section.items.slice(0, visibleLimit); const remainingCount = section.items.length - visibleItems.length; + const canShowFewer = !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0; + + if (flatVariant) { + return ( +
+ {visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))} + {remainingCount > 0 ? ( + + ) : null} +
+ ); + } + return (
) : null} - {isExpanded && section.items.length > MAX_VISIBLE_RECENT_SESSIONS ? ( + {canShowFewer ? ( ) : null} diff --git a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx index 6d7bbdf3..1b0d9e67 100644 --- a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx @@ -33,6 +33,8 @@ type ProjectSection = { type Props = { topContent?: React.ReactNode; + sharedSessionsOnly?: boolean; + hasSharedSessions?: boolean; sectionsForRender: ProjectSection[]; projectSections: ProjectSection[]; activeProjectId: string | null; @@ -76,6 +78,15 @@ export function SidebarProjectsList(props: Props): React.ReactNode { useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), ); + if (props.sharedSessionsOnly) { + return ( + + {props.topContent} + {!props.hasSharedSessions ? (props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState) : null} + + ); + } + if (props.projectSections.length === 0) { return {props.topContent}{props.emptyState}; } diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 97f72f5a..d5460f51 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -38,6 +38,11 @@ interface ProjectPathValidationResult { reason?: string; } +interface VSCodeWorkspaceFolderConfig { + name?: string; + path: string; +} + interface ProjectsStore { projects: ProjectEntry[]; activeProjectId: string | null; @@ -55,6 +60,7 @@ interface ProjectsStore { resetForRuntimeSwitch: () => void; validateProjectPath: (path: string) => ProjectPathValidationResult; synchronizeFromSettings: (settings: DesktopSettings) => void; + syncVSCodeWorkspaceFolders: (folders: VSCodeWorkspaceFolderConfig[], activePath?: string | null) => ProjectEntry | null; getActiveProject: () => ProjectEntry | null; } @@ -322,7 +328,46 @@ const persistProjects = (projects: ProjectEntry[], activeProjectId: string | nul }; const initialProjects = readPersistedProjects(); -const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectId: string | null } | null => { +const normalizeVSCodeWorkspaceFolders = (folders: VSCodeWorkspaceFolderConfig[]): VSCodeWorkspaceFolderConfig[] => { + const result: VSCodeWorkspaceFolderConfig[] = []; + const seen = new Set(); + for (const folder of folders) { + const normalizedPath = normalizeProjectPath(folder.path); + if (!normalizedPath || seen.has(normalizedPath)) { + continue; + } + seen.add(normalizedPath); + result.push({ + name: folder.name?.trim(), + path: normalizedPath, + }); + } + return result; +}; + +const createVSCodeWorkspaceProject = ( + folder: VSCodeWorkspaceFolderConfig, + existing: ProjectEntry | null, + now: number, + activePath: string | null, +): ProjectEntry | null => { + const normalizedPath = normalizeProjectPath(folder.path); + if (!normalizedPath) { + return null; + } + const id = createProjectIdFromPath(normalizedPath); + const isActive = activePath === normalizedPath; + return { + ...existing, + id, + path: normalizedPath, + label: deriveProjectLabel(normalizedPath), + addedAt: existing?.addedAt ?? now, + lastOpenedAt: isActive ? now : existing?.lastOpenedAt ?? now, + }; +}; + +const getVSCodeWorkspaceFolders = (): VSCodeWorkspaceFolderConfig[] | null => { if (typeof window === 'undefined') { return null; } @@ -332,37 +377,127 @@ const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectI return null; } - const workspaceFolder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder; + const config = (window as unknown as { + __VSCODE_CONFIG__?: { + workspaceFolder?: unknown; + workspaceFolders?: unknown; + }; + }).__VSCODE_CONFIG__; + const folders = Array.isArray(config?.workspaceFolders) + ? config.workspaceFolders + .map((entry) => { + const candidate = entry as { name?: unknown; path?: unknown }; + const path = typeof candidate.path === 'string' ? candidate.path.trim() : ''; + if (!path) return null; + const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''; + return { name, path }; + }) + .filter((entry): entry is { name: string; path: string } => entry !== null) + : []; + + if (folders.length > 0) { + return normalizeVSCodeWorkspaceFolders(folders); + } + + const workspaceFolder = config?.workspaceFolder; if (typeof workspaceFolder !== 'string' || workspaceFolder.trim().length === 0) { return null; } - const normalizedPath = normalizeProjectPath(workspaceFolder); - if (!normalizedPath) { + return normalizeVSCodeWorkspaceFolders([{ path: workspaceFolder }]); +}; + +const createVSCodeWorkspaceProjects = ( + folders: VSCodeWorkspaceFolderConfig[], + existingProjects: ProjectEntry[], + activePath?: string | null, +): { projects: ProjectEntry[]; activeProjectId: string | null; activeProject: ProjectEntry | null } | null => { + const normalizedFolders = normalizeVSCodeWorkspaceFolders(folders); + const normalizedActivePath = activePath ? normalizeProjectPath(activePath) : null; + const effectiveFolders = normalizedFolders.length === 0 && normalizedActivePath + ? [{ path: normalizedActivePath }] + : normalizedActivePath && !normalizedFolders.some((folder) => folder.path === normalizedActivePath) + ? [...normalizedFolders, { path: normalizedActivePath }] + : normalizedFolders; + if (effectiveFolders.length === 0) { + return null; + } + const now = Date.now(); + const projects = effectiveFolders + .map((folder) => createVSCodeWorkspaceProject( + folder, + existingProjects.find((project) => project.path === folder.path) ?? null, + now, + normalizedActivePath, + )) + .filter((project): project is ProjectEntry => project !== null); + + if (projects.length === 0) { return null; } - const id = createProjectIdFromPath(normalizedPath); - const entry: ProjectEntry = { - id, - path: normalizedPath, - label: deriveProjectLabel(normalizedPath), - addedAt: Date.now(), - lastOpenedAt: Date.now(), - }; + const activeProject = normalizedActivePath + ? projects.find((project) => project.path === normalizedActivePath) ?? null + : projects[0] ?? null; + const activeProjectId = activeProject?.id ?? projects[0]?.id ?? null; if (streamDebugEnabled()) { - console.log('[OpenChamber][VSCode][projects] Using workspace fallback project', entry); + console.log('[OpenChamber][VSCode][projects] Using workspace projects', projects); } - return { projects: [entry], activeProjectId: id }; + return { projects, activeProjectId, activeProject: activeProject ?? projects[0] ?? null }; }; -// VS Code runtime should behave as a single-project environment scoped to the workspace folder. -// Always prefer the workspace project over any persisted multi-project registry. +const projectIconImagesEqual = ( + left: ProjectEntry['iconImage'], + right: ProjectEntry['iconImage'], +): boolean => { + if (left === right) return true; + if (!left || !right) return left === right; + return left.mime === right.mime + && left.updatedAt === right.updatedAt + && left.source === right.source; +}; + +const vscodeWorkspaceProjectsEqual = (left: ProjectEntry[], right: ProjectEntry[]): boolean => { + if (left.length !== right.length) return false; + return left.every((leftProject, index) => { + const rightProject = right[index]; + if (!rightProject) return false; + return leftProject.id === rightProject.id + && leftProject.path === rightProject.path + && leftProject.label === rightProject.label + && leftProject.icon === rightProject.icon + && leftProject.color === rightProject.color + && leftProject.iconBackground === rightProject.iconBackground + && leftProject.addedAt === rightProject.addedAt + && leftProject.lastOpenedAt === rightProject.lastOpenedAt + && leftProject.sidebarCollapsed === rightProject.sidebarCollapsed + && projectIconImagesEqual(leftProject.iconImage, rightProject.iconImage); + }); +}; + +const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectId: string | null } | null => { + const folders = getVSCodeWorkspaceFolders(); + if (!folders) { + return null; + } + const result = createVSCodeWorkspaceProjects(folders, []); + if (!result) { + return null; + } + return { projects: result.projects, activeProjectId: result.activeProjectId }; +}; + +// VS Code runtime is scoped to the workspace folders opened in VS Code. +// Always prefer the VS Code workspace projects over any persisted multi-project registry. const vscodeWorkspace = getVSCodeWorkspaceProject(); -const effectiveInitialProjects = vscodeWorkspace?.projects ?? initialProjects; -const persistedInitialActiveProjectId = vscodeWorkspace?.activeProjectId ?? readPersistedActiveProjectId(); +const isVSCodeProjectsRuntime = (() => { + if (typeof window === 'undefined') return false; + return Boolean(getRegisteredRuntimeAPIs()?.runtime?.isVSCode); +})(); +const effectiveInitialProjects = vscodeWorkspace?.projects ?? (isVSCodeProjectsRuntime ? [] : initialProjects); +const persistedInitialActiveProjectId = vscodeWorkspace?.activeProjectId ?? (isVSCodeProjectsRuntime ? null : readPersistedActiveProjectId()); const initialActiveProjectId = effectiveInitialProjects.some((project) => project.id === persistedInitialActiveProjectId) ? persistedInitialActiveProjectId : effectiveInitialProjects[0]?.id ?? null; @@ -390,7 +525,7 @@ export const useProjectsStore = create()( }, addProject: (path: string, options?: { label?: string; id?: string }) => { - if (vscodeWorkspace) { + if (isVSCodeProjectsRuntime) { return null; } const { validateProjectPath } = get(); @@ -431,7 +566,7 @@ export const useProjectsStore = create()( }, removeProject: (id: string) => { - if (vscodeWorkspace) { + if (isVSCodeProjectsRuntime) { return; } const current = get(); @@ -468,7 +603,7 @@ export const useProjectsStore = create()( }, setActiveProject: (id: string) => { - if (vscodeWorkspace) { + if (isVSCodeProjectsRuntime) { return; } const { projects, activeProjectId } = get(); @@ -493,7 +628,7 @@ export const useProjectsStore = create()( }, setActiveProjectIdOnly: (id: string) => { - if (vscodeWorkspace) { + if (isVSCodeProjectsRuntime) { return; } const { projects, activeProjectId } = get(); @@ -515,7 +650,7 @@ export const useProjectsStore = create()( }, renameProject: (id: string, label: string) => { - if (vscodeWorkspace) { + if (isVSCodeProjectsRuntime) { return; } const trimmed = label.trim(); @@ -532,7 +667,7 @@ export const useProjectsStore = create()( }, updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null; iconBackground?: string | null }) => { - if (vscodeWorkspace) { + if (isVSCodeProjectsRuntime) { return; } const { projects, activeProjectId } = get(); @@ -555,7 +690,7 @@ export const useProjectsStore = create()( }, uploadProjectIcon: async (id: string, file: File) => { - if (vscodeWorkspace) { + if (isVSCodeProjectsRuntime) { return { ok: false, error: 'Custom icons are not supported in this runtime' }; } @@ -600,7 +735,7 @@ export const useProjectsStore = create()( }, removeProjectIcon: async (id: string) => { - if (vscodeWorkspace) { + if (isVSCodeProjectsRuntime) { return { ok: false, error: 'Custom icons are not supported in this runtime' }; } @@ -629,7 +764,7 @@ export const useProjectsStore = create()( }, discoverProjectIcon: async (id: string, options?: { force?: boolean }) => { - if (vscodeWorkspace) { + if (isVSCodeProjectsRuntime) { return { ok: false, error: 'Custom icons are not supported in this runtime' }; } @@ -670,7 +805,7 @@ export const useProjectsStore = create()( }, reorderProjects: (fromIndex: number, toIndex: number) => { - if (vscodeWorkspace) { + if (isVSCodeProjectsRuntime) { return; } const { projects, activeProjectId } = get(); @@ -693,7 +828,7 @@ export const useProjectsStore = create()( }, resetForRuntimeSwitch: () => { - if (vscodeWorkspace) { + if (isVSCodeProjectsRuntime) { return; } const projects = readPersistedProjects(); @@ -705,7 +840,7 @@ export const useProjectsStore = create()( }, synchronizeFromSettings: (settings: DesktopSettings) => { - if (vscodeWorkspace) { + if (isVSCodeProjectsRuntime) { return; } const incomingProjects = sanitizeProjects(settings.projects ?? []); @@ -752,6 +887,41 @@ export const useProjectsStore = create()( } }, + syncVSCodeWorkspaceFolders: (folders, activePath) => { + if (!isVSCodeProjectsRuntime) { + return null; + } + + const current = get(); + const currentActiveProject = current.activeProjectId + ? current.projects.find((project) => project.id === current.activeProjectId) ?? null + : null; + const targetActivePath = activePath ?? currentActiveProject?.path ?? null; + const result = createVSCodeWorkspaceProjects(folders, current.projects, targetActivePath); + if (!result) { + if (folders.length === 0 && !activePath && current.projects.length > 0) { + set({ projects: [], activeProjectId: null }); + cacheProjects([], null); + } + return null; + } + + const projectsChanged = !vscodeWorkspaceProjectsEqual(current.projects, result.projects); + const activeChanged = current.activeProjectId !== result.activeProjectId; + + if (projectsChanged || activeChanged) { + set({ projects: result.projects, activeProjectId: result.activeProjectId }); + cacheProjects(result.projects, result.activeProjectId); + } + + if (result.activeProject) { + opencodeClient.setDirectory(result.activeProject.path); + useDirectoryStore.getState().setDirectory(result.activeProject.path, { showOverlay: false }); + } + + return result.activeProject; + }, + getActiveProject: () => { const { projects, activeProjectId } = get(); if (!activeProjectId) { diff --git a/packages/vscode/src/AgentManagerPanelProvider.ts b/packages/vscode/src/AgentManagerPanelProvider.ts index fcbaaa5e..1da28c2b 100644 --- a/packages/vscode/src/AgentManagerPanelProvider.ts +++ b/packages/vscode/src/AgentManagerPanelProvider.ts @@ -7,6 +7,7 @@ import { getWebviewHtml } from './webviewHtml'; import { openSseProxy } from './sseProxy'; import { resolveWebviewDevServerUrl } from './webviewDevServer'; import { normalizeWindowsDriveLetter } from './pathUtils'; +import { resolveWorkspaceFolders } from './workspaceResolver'; const t = vscode.l10n.t; @@ -256,12 +257,14 @@ export class AgentManagerPanelProvider { const workspaceFolder = normalizeWindowsDriveLetter( vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '' ); + const workspaceFolders = resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []); const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false; return getWebviewHtml({ webview, extensionUri: this._extensionUri, workspaceFolder, + workspaceFolders, initialStatus: this._cachedStatus, cliAvailable, panelType: 'agentManager', diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index 9e99d2e7..f300f0c2 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -7,6 +7,7 @@ import { getWebviewHtml } from './webviewHtml'; import { openSseProxy } from './sseProxy'; import { resolveWebviewDevServerUrl } from './webviewDevServer'; import { normalizeWindowsDriveLetter } from './pathUtils'; +import { resolveWorkspaceFolders, type WorkspaceFolderCandidate } from './workspaceResolver'; type ActiveEditorFilePayload = { filePath: string; @@ -264,18 +265,29 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } } - public createNewSession() { + public createNewSession(options?: { directory?: string; workspaceFolders?: WorkspaceFolderCandidate[] }) { if (this._view) { // Reveal the webview panel this._view.show(true); - + this._view.webview.postMessage({ type: 'command', - command: 'newSession' + command: 'newSession', + ...((options?.directory || options?.workspaceFolders?.length) && { + payload: { directory: options?.directory, workspaceFolders: options?.workspaceFolders ?? [] }, + }), }); } } + public syncWorkspaceFolders(workspaceFolders: WorkspaceFolderCandidate[]) { + this._view?.webview.postMessage({ + type: 'command', + command: 'workspaceFoldersChanged', + payload: { workspaceFolders }, + }); + } + public showSettings() { if (this._view) { // Reveal the webview panel @@ -596,6 +608,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const workspaceFolder = normalizeWindowsDriveLetter( vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '' ); + const workspaceFolders = resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []); // Use cached values which are updated by onStatusChange callback const initialStatus = this._cachedStatus; const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false; @@ -604,6 +617,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { webview, extensionUri: this._extensionUri, workspaceFolder, + workspaceFolders, initialStatus, cliAvailable, extensionVersion: String(this._context.extension?.packageJSON?.version || ''), diff --git a/packages/vscode/src/SessionEditorPanelProvider.ts b/packages/vscode/src/SessionEditorPanelProvider.ts index 3d084b2a..8686ad71 100644 --- a/packages/vscode/src/SessionEditorPanelProvider.ts +++ b/packages/vscode/src/SessionEditorPanelProvider.ts @@ -7,6 +7,7 @@ import { getWebviewHtml } from './webviewHtml'; import { openSseProxy } from './sseProxy'; import { resolveWebviewDevServerUrl } from './webviewDevServer'; import { normalizeWindowsDriveLetter } from './pathUtils'; +import { resolveWorkspaceFolders } from './workspaceResolver'; const t = vscode.l10n.t; @@ -484,6 +485,7 @@ export class SessionEditorPanelProvider { const workspaceFolder = normalizeWindowsDriveLetter( vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '' ); + const workspaceFolders = resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []); const initialStatus = this._cachedStatus; const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false; @@ -491,6 +493,7 @@ export class SessionEditorPanelProvider { webview, extensionUri: this._extensionUri, workspaceFolder, + workspaceFolders, initialStatus, cliAvailable, panelType: 'chat', diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 53592705..1fd80b94 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -4,6 +4,7 @@ import { AgentManagerPanelProvider } from './AgentManagerPanelProvider'; import { SessionEditorPanelProvider } from './SessionEditorPanelProvider'; import { createOpenCodeManager, type OpenCodeManager } from './opencode'; import { startGlobalEventWatcher, stopGlobalEventWatcher, setChatViewProvider } from './sessionActivityWatcher'; +import { resolveWorkspaceFolders } from './workspaceResolver'; let chatViewProvider: ChatViewProvider | undefined; let agentManagerProvider: AgentManagerPanelProvider | undefined; @@ -458,8 +459,51 @@ export async function activate(context: vscode.ExtensionContext) { ); context.subscriptions.push( - vscode.commands.registerCommand('openchamber.newSession', () => { - chatViewProvider?.createNewSession(); + vscode.commands.registerCommand('openchamber.newSession', async (directory?: unknown) => { + const candidates = resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []); + let folderPath: string | undefined = typeof directory === 'string' ? directory : undefined; + + if (!folderPath && candidates.length === 0) { + vscode.window.showInformationMessage('OpenChamber: No folder is open. Open a folder to start a new session.'); + return; + } + + if (!folderPath) { + folderPath = candidates.length === 1 + ? candidates[0].path + : (await vscode.window.showQuickPick( + candidates.map((folder) => ({ label: folder.name, description: folder.path, path: folder.path })), + { placeHolder: 'Select a workspace folder for this session', matchOnDescription: true } + ))?.path; + } + + if (!folderPath) { + return; + } + + if (openCodeManager) { + const result = await openCodeManager.setWorkingDirectory(folderPath); + if (!result.success) { + vscode.window.showErrorMessage(`OpenChamber: ${result.error}`); + return; + } + } + const workspaceFolders = candidates.some((folder) => folder.path === folderPath) + ? candidates + : [ + ...candidates, + { + name: folderPath.split(/[\\/]/).filter(Boolean).pop() ?? folderPath, + path: folderPath, + }, + ]; + chatViewProvider?.createNewSession({ directory: folderPath, workspaceFolders }); + }) + ); + + context.subscriptions.push( + vscode.workspace.onDidChangeWorkspaceFolders(() => { + chatViewProvider?.syncWorkspaceFolders(resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? [])); }) ); diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 593f3743..08d90e19 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -8,6 +8,7 @@ import { spawnSync } from 'child_process'; import { spawn } from 'child_process'; import { randomBytes } from 'crypto'; import { normalizeWindowsDriveLetter } from './pathUtils'; +import { resolveWorkingDirectoryChange } from './workingDirectoryChange'; const t = vscode.l10n.t; @@ -45,11 +46,15 @@ export type OpenCodeDebugInfo = { authSource: 'user-env' | 'generated' | 'rotated' | null; }; +export type SetWorkingDirectoryResult = + | { success: true; path: string } + | { success: false; error: string }; + export interface OpenCodeManager { start(workdir?: string): Promise; stop(): Promise; restart(): Promise; - setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>; + setWorkingDirectory(path: string): Promise; getStatus(): ConnectionStatus; getApiUrl(): string | null; getOpenCodeAuthHeaders(): Record; @@ -728,6 +733,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo const listeners = new Set<(status: ConnectionStatus, error?: string) => void>(); const workspaceDirectory = (): string => normalizeWindowsDriveLetter(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir()); + const serverWorkingDirectory = (): string => normalizeWindowsDriveLetter(os.homedir()); let workingDirectory: string = workspaceDirectory(); let startCount = 0; let restartCount = 0; @@ -886,14 +892,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo }); process.env.OPENCODE_SERVER_PASSWORD = password; - // SDK spawns `opencode serve` in current process cwd. - // Some OpenCode endpoints behave differently based on server process cwd, - // so ensure we start it from the workspace directory. + // Match the web runtime: keep the server process in a neutral cwd and pass + // the selected workspace through explicit `directory` API parameters. + const serverCwd = serverWorkingDirectory(); const originalCwd = process.cwd(); try { - process.chdir(workingDirectory); + process.chdir(serverCwd); const port = await allocateManagedOpenCodePort(); - server = await spawnManagedOpenCodeServer(workingDirectory, port, READY_CHECK_TIMEOUT_MS); + server = await spawnManagedOpenCodeServer(serverCwd, port, READY_CHECK_TIMEOUT_MS); } finally { try { process.chdir(originalCwd); @@ -992,9 +998,10 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo async function restartInternal(): Promise { restartCount += 1; + const restartDirectory = workingDirectory; await stopInternal(); await new Promise(r => setTimeout(r, 250)); - await startInternal(undefined, { rotateManaged: true }); + await startInternal(restartDirectory, { rotateManaged: true }); } async function start(workdir?: string): Promise { @@ -1042,22 +1049,29 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo } } - async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> { - void newPath; - const workspacePath = workspaceDirectory(); - const nextDirectory = workspacePath; - - if (workingDirectory === nextDirectory) { - return { success: true, restarted: false, path: nextDirectory }; + async function setWorkingDirectory(newPath: string): Promise { + const trimmed = newPath.trim(); + if (!trimmed) { + return { success: false, error: 'path not found' }; } - workingDirectory = nextDirectory; - - if (useConfiguredUrl && configuredApiUrl) { - return { success: true, restarted: false, path: nextDirectory }; + let stat; + try { + stat = await fs.promises.stat(trimmed); + } catch { + return { success: false, error: 'path not found' }; + } + if (!stat.isDirectory()) { + return { success: false, error: 'path not found' }; } - return { success: true, restarted: false, path: nextDirectory }; + const change = resolveWorkingDirectoryChange(workingDirectory, trimmed); + if (!change.changed) { + return { success: true, path: change.path }; + } + + workingDirectory = change.path; + return { success: true, path: change.path }; } return { diff --git a/packages/vscode/src/webviewHtml.ts b/packages/vscode/src/webviewHtml.ts index 1fa732c5..e383333a 100644 --- a/packages/vscode/src/webviewHtml.ts +++ b/packages/vscode/src/webviewHtml.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import * as os from 'os'; import { getThemeKindName } from './theme'; import type { ConnectionStatus } from './opencode'; +import type { WorkspaceFolderCandidate } from './workspaceResolver'; export type PanelType = 'chat' | 'agentManager'; @@ -9,6 +10,7 @@ export interface WebviewHtmlOptions { webview: vscode.Webview; extensionUri: vscode.Uri; workspaceFolder: string; + workspaceFolders?: WorkspaceFolderCandidate[]; initialStatus: ConnectionStatus; cliAvailable: boolean; panelType?: PanelType; @@ -46,6 +48,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string { webview, extensionUri, workspaceFolder, + workspaceFolders = [], initialStatus, cliAvailable, panelType = 'chat', @@ -54,6 +57,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string { devServerUrl, extensionVersion = '', } = options; + const workspaceFoldersJson = JSON.stringify(workspaceFolders).replace(/ { + test('returns unchanged when the selected directory already matches', () => { + expect(resolveWorkingDirectoryChange('/work/alpha', '/work/alpha')).toEqual({ + changed: false, + path: '/work/alpha', + }); + }); + + test('updates the directory without requiring an OpenCode server restart', () => { + expect(resolveWorkingDirectoryChange('/work/alpha', '/work/bravo')).toEqual({ + changed: true, + path: '/work/bravo', + }); + }); + + test('trims the selected directory before comparing', () => { + expect(resolveWorkingDirectoryChange('/work/alpha', ' /work/bravo ')).toEqual({ + changed: true, + path: '/work/bravo', + }); + }); +}); diff --git a/packages/vscode/src/workingDirectoryChange.ts b/packages/vscode/src/workingDirectoryChange.ts new file mode 100644 index 00000000..643580d9 --- /dev/null +++ b/packages/vscode/src/workingDirectoryChange.ts @@ -0,0 +1,16 @@ +import { normalizeWindowsDriveLetter } from './pathUtils'; + +export type WorkingDirectoryChange = + | { changed: false; path: string } + | { changed: true; path: string }; + +export function resolveWorkingDirectoryChange( + currentDirectory: string, + nextDirectory: string +): WorkingDirectoryChange { + const normalized = normalizeWindowsDriveLetter(nextDirectory.trim()); + if (currentDirectory === normalized) { + return { changed: false, path: normalized }; + } + return { changed: true, path: normalized }; +} diff --git a/packages/vscode/src/workspaceResolver.test.js b/packages/vscode/src/workspaceResolver.test.js new file mode 100644 index 00000000..b92f9382 --- /dev/null +++ b/packages/vscode/src/workspaceResolver.test.js @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; +import { resolveWorkspaceFolders } from './workspaceResolver.ts'; + +const ALPHA = { name: 'alpha', uri: { fsPath: '/work/alpha' } }; +const BRAVO = { name: 'Bravo', uri: { fsPath: '/work/bravo' } }; +const CHARLIE = { name: 'Charlie', uri: { fsPath: '/work/charlie' } }; +const ALPHA_DUP = { name: 'alpha-dup', uri: { fsPath: '/work/alpha' } }; +const ALPHA_WITH_TRAILING = { name: 'alpha', uri: { fsPath: '/work/alpha///' } }; +const BRAVO_WITH_TRAILING = { name: 'bravo', uri: { fsPath: '/work/bravo//' } }; + +describe('resolveWorkspaceFolders', () => { + describe('when the input is empty', () => { + test('returns an empty list without throwing', () => { + expect(resolveWorkspaceFolders([])).toEqual([]); + }); + }); + + describe('when a single folder is provided', () => { + test('preserves its name and path', () => { + expect(resolveWorkspaceFolders([ALPHA])).toEqual([ + { name: 'alpha', path: '/work/alpha' }, + ]); + }); + }); + + describe('when multiple folders are provided', () => { + test('returns them sorted alphabetically by name, case-insensitive', () => { + const result = resolveWorkspaceFolders([CHARLIE, ALPHA, BRAVO]); + + expect(result.map((entry) => entry.name)).toEqual([ + 'alpha', + 'Bravo', + 'Charlie', + ]); + }); + }); + + describe('when folders share the same path', () => { + test('keeps only the first occurrence and discards duplicates by path', () => { + const result = resolveWorkspaceFolders([ALPHA, ALPHA_DUP, BRAVO]); + + expect(result).toEqual([ + { name: 'alpha', path: '/work/alpha' }, + { name: 'Bravo', path: '/work/bravo' }, + ]); + }); + }); + + describe('when paths contain trailing separators', () => { + test('strips them from every returned path', () => { + const result = resolveWorkspaceFolders([ + ALPHA_WITH_TRAILING, + BRAVO_WITH_TRAILING, + ]); + + expect(result).toEqual([ + { name: 'alpha', path: '/work/alpha' }, + { name: 'bravo', path: '/work/bravo' }, + ]); + }); + + test('treats paths that differ only by trailing separators as the same folder', () => { + const result = resolveWorkspaceFolders([ALPHA_WITH_TRAILING, ALPHA_DUP]); + + expect(result).toEqual([{ name: 'alpha', path: '/work/alpha' }]); + }); + }); +}); diff --git a/packages/vscode/src/workspaceResolver.ts b/packages/vscode/src/workspaceResolver.ts new file mode 100644 index 00000000..54bea98d --- /dev/null +++ b/packages/vscode/src/workspaceResolver.ts @@ -0,0 +1,26 @@ +import { normalizeWindowsDriveLetter } from './pathUtils'; + +export interface WorkspaceFolderInput { + name: string; + uri: { fsPath: string }; +} + +export interface WorkspaceFolderCandidate { + name: string; + path: string; +} + +export function resolveWorkspaceFolders( + folders: ReadonlyArray +): WorkspaceFolderCandidate[] { + const seen = new Map(); + for (const folder of folders) { + const path = normalizeWindowsDriveLetter(folder.uri.fsPath).replace(/[\\/]+$/, ''); + if (!seen.has(path)) { + seen.set(path, { name: folder.name, path }); + } + } + return [...seen.values()].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ); +} diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 9d784b35..9bfd26ab 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -24,6 +24,7 @@ declare global { __VSCODE_CONFIG__?: { apiUrl?: string; workspaceFolder: string; + workspaceFolders?: Array<{ name: string; path: string }>; theme: string; connectionStatus: string; cliAvailable?: boolean; @@ -1338,12 +1339,55 @@ onCommand('createSessionWithPrompt', (payload) => { }); }); +const normalizeWorkspaceFoldersPayload = (value: unknown): Array<{ name: string; path: string }> => { + if (!Array.isArray(value)) { + return []; + } + return value + .map((entry) => { + const candidate = entry as { name?: unknown; path?: unknown }; + const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''; + const path = typeof candidate.path === 'string' ? candidate.path.trim() : ''; + return path ? { name, path } : null; + }) + .filter((entry): entry is { name: string; path: string } => entry !== null); +}; + +const syncVSCodeWorkspaceProjects = async ( + workspaceFolders: Array<{ name: string; path: string }>, + activePath?: string, +) => { + if (window.__VSCODE_CONFIG__) { + window.__VSCODE_CONFIG__.workspaceFolders = workspaceFolders; + } + const { useProjectsStore } = await import('@/stores/useProjectsStore'); + return useProjectsStore.getState().syncVSCodeWorkspaceFolders(workspaceFolders, activePath); +}; + +onCommand('workspaceFoldersChanged', (payload) => { + const record = payload as { workspaceFolders?: unknown } | undefined; + const workspaceFolders = normalizeWorkspaceFoldersPayload(record?.workspaceFolders); + void syncVSCodeWorkspaceProjects(workspaceFolders); +}); + // Listen for newSession command from extension title bar button -onCommand('newSession', () => { - import('@/sync/session-ui-store').then(({ useSessionUIStore }) => { - useSessionUIStore.getState().openNewSessionDraft(); +onCommand('newSession', (payload) => { + const record = payload as { directory?: unknown; workspaceFolders?: unknown } | undefined; + const directory = record?.directory; + const directoryOverride = typeof directory === 'string' && directory.trim().length > 0 ? directory.trim() : undefined; + const workspaceFolders = normalizeWorkspaceFoldersPayload(record?.workspaceFolders); + + Promise.all([ + import('@/sync/session-ui-store'), + syncVSCodeWorkspaceProjects(workspaceFolders, directoryOverride), + ]).then(([{ useSessionUIStore }, selectedProject]) => { + useSessionUIStore.getState().openNewSessionDraft( + directoryOverride + ? { directoryOverride, selectedProjectId: selectedProject?.id ?? undefined } + : undefined + ); }); - + // Also dispatch event to navigate to chat view in VSCodeLayout window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } })); });