feat(vscode): startup parity + workspace-grouped session list (#1658)

* perf(vscode): gate API readiness and coalesce duplicate startup reads

Bring the VS Code bridge runtime to parity with the web startup
optimizations (PR #1650), which were web/desktop-only.

waitForApiUrl now hands out the OpenCode API URL only once the manager
reports 'connected', instead of as soon as getApiUrl() exposes
server.url. The URL is available the moment the process is spawned —
before waitForReady confirms it can serve and during a workspace-switch
restart (stale port) — so URL-presence alone let the bridge forward to a
not-yet-ready OpenCode and surface 502s. Gating on connected status
mirrors the web proxy's isOpenCodeReady hold. Also fail fast on 'error'
status so a missing CLI doesn't burn the full 30s timeout.

Coalesce concurrent identical GET reads (config/path/agents/agent/
project/command) at the bridge proxy so the single OpenCode process
serves them once. On cold start the webview's sync bootstrap and config
store fire these reads in parallel with no shared dedup; this is the
extension-host analog of the runtimeFetch coalescer. Shared reads carry
no AbortController so one caller's abort can't strand the others, and the
entry clears as soon as it settles (never serves stale).

* perf(vscode): fade the startup splash once mounted + connected, not on live fetch

The webview's initial-loading overlay held until a successful live
/api/config/providers AND /api/agent fetch completed. After the cache
hydration work those live reads are the slowest cold-start tail — the UI
underneath already paints pickers and the sidebar from cache and refreshes
in the background — so gating the splash on them kept it spinning long
after the app was usable.

Fade the overlay as soon as the UI is mounted and OpenCode is connected.
Per-widget loaders convey any remaining background refresh, matching how
web/desktop (which have no such splash) already behave. Removes the now
-obsolete bootstrapProvidersReady/AgentsReady/Failed tracking and
recordBootstrapFetch. Connection error/disconnected splash messages are
unchanged.

* fix(vscode): include captured OpenCode output in spawn-timeout error

When the managed OpenCode server fails to emit its 'listening' line within
the start timeout, the error discarded everything the process printed to
stdout/stderr — so the status report showed a bare 'Timeout waiting for
server to start' with no clue whether the process hung, crashed silently,
or printed a config/auth error. The exit path already includes the output;
the timeout path now does too (or notes that nothing was printed).

* feat(vscode): workspace-grouped session list with working folders, pinning, and archived toggle

Replace the flat multi-workspace session list with the grouped project view,
using each open VS Code workspace folder as a header (no per-worktree
subgroups). This restores native folder and pin support, which the flat list
silently dropped, and fixes the clipped left padding on session rows.

- Group sessions strictly by open workspace; funnel all non-archived sessions
  into the workspace's group so they no longer fall into the archived bucket.
- Keep the project/group/folder + buttons but make them open a draft in the
  correct workspace and navigate to chat; hide the project actions (...) menu,
  which isn't relevant in VS Code.
- Force the minimal single-line row layout (the second metadata row is
  redundant under workspace headers) and drop the per-row tooltip.
- Add a show/hide archived toggle next to the archive-all control, since the
  VS Code header has no display-mode menu.
- Size the hover action reveal so the timestamp clears the row buttons.
This commit is contained in:
Bohdan Triapitsyn
2026-06-15 13:02:26 +03:00
committed by GitHub
parent 8919d33636
commit c73ab9cbd5
14 changed files with 364 additions and 135 deletions
@@ -14,6 +14,7 @@ import { ArchiveAllDropdown } from '@/components/session/ArchiveAllDropdown';
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
import { SessionsTabTitle } from '@/components/session/SessionsTabTitle';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { cn } from '@/lib/utils';
import {
DropdownMenu,
@@ -546,7 +547,6 @@ export const VSCodeLayout: React.FC = () => {
mobileVariant
allowReselect
hideDirectoryControls
showOnlyMainWorkspace
/>
<div
className={cn(
@@ -594,7 +594,6 @@ export const VSCodeLayout: React.FC = () => {
allowReselect
onSessionSelected={() => setCurrentView('chat')}
hideDirectoryControls
showOnlyMainWorkspace
/>
</div>
</div>
@@ -640,6 +639,8 @@ interface VSCodeHeaderProps {
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onArchiveAll, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits, enableSessionSwitcher }) => {
const { t } = useI18n();
const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions);
const toggleArchivedSessions = useSessionDisplayStore((state) => state.toggleArchivedSessions);
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
const providers = useConfigStore((state) => state.providers);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
@@ -817,6 +818,21 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
<SessionsTabTitle title={title} />
)}
<div className="min-w-0 flex-1" />
{onArchiveAll && (
<button
type="button"
onClick={toggleArchivedSessions}
className={cn(
'inline-flex h-8 w-8 items-center justify-center p-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
showArchivedSessions ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
)}
aria-label={t('sessions.sidebar.header.displayMode.showArchived')}
aria-pressed={showArchivedSessions}
title={t('sessions.sidebar.header.displayMode.showArchived')}
>
<Icon name="archive-stack" className="h-5 w-5" />
</button>
)}
{onArchiveAll && <ArchiveAllDropdown onArchiveAll={onArchiveAll} />}
{onNewSession && (
<button
@@ -60,7 +60,6 @@ import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { type SessionGroup, type SessionNode } from './sidebar/types';
import {
deriveRecentSessions,
getSessionUpdatedAtMs,
} from './sidebar/activitySections';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import {
@@ -83,8 +82,6 @@ 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
@@ -325,6 +322,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
// The sidebar tree's +-buttons (project / group / folder) open a draft but,
// unlike selecting an existing session, don't navigate. VS Code's compact view
// is driven by the openchamber:navigate event, so switch to chat explicitly
// (a no-op in the expanded side-by-side layout, which is always showing chat).
const openNewSessionDraftFromTree = React.useCallback<typeof openNewSessionDraft>((options) => {
openNewSessionDraft(options);
if (isVSCode) {
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } }));
}
}, [isVSCode, openNewSessionDraft]);
const updateStore = useUpdateStore(useShallow((s) => ({
checkForUpdates: s.checkForUpdates,
available: s.available,
@@ -1024,29 +1031,17 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [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]);
// Prefetch is wired below, after recentSessionIds is computed.
const activitySections = React.useMemo(() => {
if (!isVSCode && !showRecentSection) {
// VS Code renders the full grouped project view (one group per open
// workspace, folders + pinned native); the flat "recent" activity list is
// web/desktop-only.
if (isVSCode || !showRecentSection) {
return [];
}
const recentSessions = isVSCode ? vscodeSharedSessions : activeNowSessions;
const recentSessions = activeNowSessions;
const toItem = (session: Session) => {
const existing = sessionSidebarMetaById.get(session.id);
@@ -1080,7 +1075,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return [
{ key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items },
];
}, [activeNowSessions, filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, sessionSidebarMetaById, showRecentSection, t, vscodeSharedSessions]);
}, [activeNowSessions, filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, sessionSidebarMetaById, showRecentSection, t]);
const hasActivitySectionItems = React.useMemo(
() => activitySections.some((section) => section.items.length > 0),
@@ -1376,7 +1371,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setActiveProjectIdOnly={setActiveProjectIdOnly}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraft}
openNewSessionDraft={openNewSessionDraftFromTree}
addSessionToFolder={addSessionToFolder}
createFolderAndStartRename={createFolderAndStartRename}
renamingFolderId={renamingFolderId}
@@ -1413,7 +1408,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setActiveProjectIdOnly,
setActiveMainTab,
setSessionSwitcherOpen,
openNewSessionDraft,
openNewSessionDraftFromTree,
addSessionToFolder,
createFolderAndStartRename,
renamingFolderId,
@@ -1425,13 +1420,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
],
);
const topContent = (isVSCode || (showRecentSection && !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}
variant="section"
/>
) : null;
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
@@ -1648,7 +1641,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<SidebarProjectsList
topContent={topContent}
sharedSessionsOnly={isVSCode}
hasSharedSessions={hasActivitySectionItems}
sectionsForRender={sectionsForSidebarRender}
projectSections={projectSections}
@@ -1670,7 +1662,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setActiveProjectIdOnly={setActiveProjectIdOnly}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraft}
openNewSessionDraft={openNewSessionDraftFromTree}
openNewWorktreeDialog={openNewWorktreeDialog}
openProjectEditDialog={setEditingProjectDialogId}
removeProject={removeProject}
@@ -14,6 +14,12 @@
- folder cleanup sync
- sticky project header observer
## VS Code grouping
- VS Code uses the **same grouped project tree** as web/desktop (project headers + folders + pinned-first ordering), not a separate flat list. Each open VS Code workspace folder is a project header.
- VS Code groups strictly **by open workspace**: `useSessionGrouping` funnels every non-archived session into the project's root group and emits **no per-worktree subgroups** (worktrees aren't registered in VS Code). `getSessionsForProject` buckets sessions to a workspace by exact directory match, so only sessions whose directory is an open workspace folder appear.
- VS Code passes `hideDirectoryControls` (clean workspace headers, no worktree/close chrome) and no longer passes `showOnlyMainWorkspace`/`sharedSessionsOnly`. Folders and pinning therefore work natively, scoped to the workspace root.
## File summaries
### Components
@@ -264,10 +264,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const displayMode = useSessionDisplayStore((state) => state.displayMode);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
// VS Code keeps the expanded "default" layout regardless of the stored mode:
// multi-workspace lists rely on inline project/branch, and hover tooltips
// across the whole list would be impractical there.
const isMinimalMode = displayMode === 'minimal' && !isVSCode;
// VS Code always uses the minimal (single-line) layout: sessions are grouped
// under workspace project headers, so the second metadata row (project/branch)
// is redundant. The display-mode toggle is hidden there, so force it on.
const isMinimalMode = displayMode === 'minimal' || isVSCode;
const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []);
const runtimeApis = React.useContext(RuntimeAPIContext);
const revealOnHoverClass = isVSCode
@@ -280,7 +280,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const showQuickArchiveAction = !archivedBucket && !mobileVariant;
const revealPaddingClass = isMinimalMode
? (isVSCode
? 'group-hover:pr-2'
// VS Code minimal rows reveal up to three actions on hover
// (open-in-editor + quick-archive + menu, each h-4). The date sits in the
// row flow, so the title must shrink enough to clear the actions or they
// overlap the timestamp. Open-in-editor is always present in VS Code.
? (showQuickArchiveAction && showOpenInEditorAction
? 'group-hover:pr-18'
: showQuickArchiveAction || showOpenInEditorAction
? 'group-hover:pr-14'
: 'group-hover:pr-8')
: 'group-hover:pr-2 group-focus-within:pr-2')
: (isVSCode
? (showQuickArchiveAction && showOpenInEditorAction
@@ -1006,6 +1014,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
</div>
</button>
</TooltipTrigger>
{/* VS Code already shows project context via workspace headers, so
the per-row metadata tooltip is redundant noise there. */}
{!isVSCode ? (
<TooltipContent side="right" sideOffset={8} className="max-w-xs text-left">
<div className="flex flex-col gap-1 text-left text-xs">
<div className={cn('flex items-center gap-3 text-left text-muted-foreground', secondaryMeta?.projectLabel ? 'justify-between' : 'justify-start')}>
@@ -1021,6 +1032,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
) : null}
</div>
</TooltipContent>
) : null}
</Tooltip>
) : (
<button
@@ -124,6 +124,11 @@ export const useSessionGrouping = (args: Args) => {
const getGroupKey = (session: Session) => {
if (session.time?.archived) return archivedKey;
// VS Code groups by open workspace, not by worktree: every non-archived
// session in a project belongs to that project's single (root) group.
// Worktrees aren't registered in VS Code, so the desktop directory-match
// below would otherwise dump these sessions into the archived bucket.
if (args.isVSCode) return normalizedProjectRoot ?? '__project_root__';
const metadataPath = normalizePath(args.worktreeMetadata.get(session.id)?.path ?? null);
const normalizedDir = metadataPath ?? resolveGlobalSessionDirectory(session);
if (!normalizedDir) return archivedKey;
@@ -196,7 +201,9 @@ export const useSessionGrouping = (args: Args) => {
return aLabel.localeCompare(bLabel);
});
sortedWorktrees.forEach((meta) => {
// VS Code groups strictly by open workspace — no per-worktree subgroups.
const worktreeGroups = args.isVSCode ? [] : sortedWorktrees;
worktreeGroups.forEach((meta) => {
const directory = normalizePath(meta.path) ?? meta.path;
const currentBranch = args.gitBranches.get(directory)?.trim() || null;
const metadataBranch = meta.branch?.trim() || null;
@@ -169,6 +169,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
className={cn('w-full text-left group/project select-none')}
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
onContextMenu={(event) => {
// VS Code hides project actions entirely (hideDirectoryControls).
if (hideDirectoryControls) return;
event.preventDefault();
setIsContextMenuOpen(true);
}}
@@ -266,6 +268,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
</Tooltip>
) : null}
{!hideDirectoryControls ? (
<DropdownMenu
open={isMenuOpen}
onOpenChange={handleMenuOpenChange}
@@ -293,6 +296,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
{renderProjectMenuItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
{showCreateButtons && onNewSession ? (