feat(vscode): support multi-root workspaces (#1493)

Add proper VS Code multi-root workspace support. New sessions now start in the workspace folder the user chooses instead of always using the first folder.

The VS Code sidebar now shows one shared flat session list for the currently opened workspace folders, keeps that list synced when folders are added or removed, and excludes sessions from worktrees unless that worktree is opened as a workspace folder.

Also keep the OpenCode server process independent from a specific workspace folder so changing the selected folder does not restart or interrupt existing sessions.
This commit is contained in:
Maksym Mospanenko
2026-06-10 23:58:40 +03:00
committed by GitHub
parent 49a1424e5f
commit 7b33805ea0
15 changed files with 646 additions and 96 deletions
@@ -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<string, WorktreeMetadata[]>,
options?: { includeWorktrees?: boolean },
): Set<string> => {
const directories = new Set<string>();
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<string>): boolean => {
const isKnownActiveSessionDirectory = (
session: Session,
knownDirectories: Set<string>,
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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
})));
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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
const directories = new Set<string>();
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<SessionSidebarProps> = ({
});
});
return [...directories].sort();
}, [availableWorktreesByProject, normalizedProjects]);
}, [availableWorktreesByProject, isVSCode, normalizedProjects]);
const knownProjectSessionDirectoriesRef = React.useRef<Set<string> | null>(null);
React.useEffect(() => {
@@ -819,6 +836,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
}
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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
// 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<ReturnType<typeof toItem>> => 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<SessionSidebarProps> = ({
],
);
const topContent = showRecentSection && !isVSCode && !hasSessionSearchQuery ? (
const topContent = (isVSCode || (showRecentSection && !hasSessionSearchQuery)) ? (
<SidebarActivitySections
sections={activitySections}
renderSessionNode={renderSessionNode}
variant={isVSCode ? 'flat' : 'section'}
initialVisibleCount={isVSCode ? VSCODE_RECENT_INITIAL_SESSION_COUNT : undefined}
batchSize={isVSCode ? VSCODE_RECENT_SESSION_BATCH_SIZE : undefined}
/>
) : null;
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
@@ -1619,6 +1682,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<SidebarProjectsList
topContent={topContent}
sharedSessionsOnly={isVSCode}
hasSharedSessions={hasActivitySectionItems}
sectionsForRender={sectionsForSidebarRender}
projectSections={projectSections}
activeProjectId={activeProjectId}
@@ -23,14 +23,24 @@ type ActivitySection = {
type Props = {
sections: ActivitySection[];
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null, renderContext?: 'project' | 'recent') => 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<Set<string>>(new Set());
const [expandedSections, setExpandedSections] = React.useState<Set<string>>(new Set());
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(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 (
<div className="space-y-2 pb-2 pt-1">
<div className={cn(flatVariant ? 'space-y-0.5 pb-2' : 'space-y-2 pb-2 pt-1')}>
{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 (
<div key={section.key} className="space-y-0.5">
{visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))}
{remainingCount > 0 ? (
<button
type="button"
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
{t('sessions.sidebar.group.showMore')}
</button>
) : null}
</div>
);
}
return (
<div key={section.key} className="space-y-1">
<button
@@ -84,23 +126,23 @@ export function SidebarActivitySections({ sections, renderSessionNode }: Props):
{!isCollapsed ? (
<div className={cn('space-y-0.5 pl-7')}>
{visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))}
{remainingCount > 0 && !isExpanded ? (
{remainingCount > 0 ? (
<button
type="button"
onClick={() => toggleSectionLimit(section.key)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
{remainingCount === 1
? t('sessions.sidebar.group.showMoreSingle', { count: remainingCount })
: t('sessions.sidebar.group.showMorePlural', { count: remainingCount })}
</button>
) : null}
{isExpanded && section.items.length > MAX_VISIBLE_RECENT_SESSIONS ? (
{canShowFewer ? (
<button
type="button"
onClick={() => toggleSectionLimit(section.key)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
onClick={() => resetSectionLimit(section.key)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
{t('sessions.sidebar.group.showFewer')}
</button>
) : null}
@@ -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 (
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pr-2', props.mobileVariant ? '' : '')}>
{props.topContent}
{!props.hasSharedSessions ? (props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState) : null}
</ScrollableOverlay>
);
}
if (props.projectSections.length === 0) {
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>{props.topContent}{props.emptyState}</ScrollableOverlay>;
}
+200 -30
View File
@@ -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<string>();
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<ProjectsStore>()(
},
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<ProjectsStore>()(
},
removeProject: (id: string) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const current = get();
@@ -468,7 +603,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
setActiveProject: (id: string) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const { projects, activeProjectId } = get();
@@ -493,7 +628,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
setActiveProjectIdOnly: (id: string) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const { projects, activeProjectId } = get();
@@ -515,7 +650,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
renameProject: (id: string, label: string) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const trimmed = label.trim();
@@ -532,7 +667,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
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<ProjectsStore>()(
},
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<ProjectsStore>()(
},
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<ProjectsStore>()(
},
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<ProjectsStore>()(
},
reorderProjects: (fromIndex: number, toIndex: number) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const { projects, activeProjectId } = get();
@@ -693,7 +828,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
resetForRuntimeSwitch: () => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const projects = readPersistedProjects();
@@ -705,7 +840,7 @@ export const useProjectsStore = create<ProjectsStore>()(
},
synchronizeFromSettings: (settings: DesktopSettings) => {
if (vscodeWorkspace) {
if (isVSCodeProjectsRuntime) {
return;
}
const incomingProjects = sanitizeProjects(settings.projects ?? []);
@@ -752,6 +887,41 @@ export const useProjectsStore = create<ProjectsStore>()(
}
},
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) {
@@ -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',
+17 -3
View File
@@ -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 || ''),
@@ -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',
+46 -2
View File
@@ -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 ?? []));
})
);
+33 -19
View File
@@ -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<void>;
stop(): Promise<void>;
restart(): Promise<void>;
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
setWorkingDirectory(path: string): Promise<SetWorkingDirectoryResult>;
getStatus(): ConnectionStatus;
getApiUrl(): string | null;
getOpenCodeAuthHeaders(): Record<string, string>;
@@ -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<void> {
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<void> {
@@ -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<SetWorkingDirectoryResult> {
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 {
+5
View File
@@ -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(/</g, '\\u003c');
const scriptPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'assets', 'index.js');
const scriptUri = webview.asWebviewUri(scriptPath);
@@ -176,6 +180,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
window.__VSCODE_CONFIG__ = {
workspaceFolder: "${workspaceFolder.replace(/\\/g, '\\\\')}",
workspaceFolders: ${workspaceFoldersJson},
theme: "${themeKind}",
connectionStatus: "${initialStatus}",
cliAvailable: ${cliAvailable},
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'bun:test';
import { resolveWorkingDirectoryChange } from './workingDirectoryChange.ts';
describe('resolveWorkingDirectoryChange', () => {
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',
});
});
});
@@ -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 };
}
@@ -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' }]);
});
});
});
+26
View File
@@ -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<WorkspaceFolderInput>
): WorkspaceFolderCandidate[] {
const seen = new Map<string, WorkspaceFolderCandidate>();
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' })
);
}
+48 -4
View File
@@ -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' } }));
});