feat: improve VS Code dev flow and stabilize sidebar/chat behavior (#754)

* fix: improve session sidebar tooltip and truncation behavior

- Keep new-draft tooltip anchored to its trigger button
- Fix minimal-mode worktree/group header text truncation
- Tune minimal-mode right padding to reduce early label clipping

* fix: render reasoning through markdown pipeline

- Use Streamdown rendering for reasoning in live chat mode
- Remove italic styling from reasoning text
- Render expanded reasoning content with MarkdownRenderer

* chore: remove legacy electron dependencies

- Removed unused Electron packages from root and UI manifests
- Deleted obsolete Electron context menu type declaration
- Regenerated lockfile after dependency cleanup

* fix: handle non-repository folders in git status API

- Prevent 500 errors when status is requested outside a valid Git repo
- Improve repository detection using `git rev-parse --git-dir`
- Reduce noisy server logs for expected non-repo status checks

* fix unloaded session chat layout flicker

* fix: reduce noisy TTS status polling

Cache and dedupe TTS status requests, and only check provider availability when the related voice features are enabled so disabled voice setups stay quiet.

* perf: throttle background PR git status refreshes

* fix: improve VS Code Explorer file drop mentions in chat

- Add Explorer context action to insert selected files as @mentions.
- Handle Explorer drag-and-drop to prefill @file mentions instead of attachments.
- Prevent duplicate plain-path text when dropping multiple files.

* fix: deduplicate recent sessions in VS Code sidebar

- Hide sessions from main list when already shown in recent
- Apply dedup only in VS Code runtime
- Keep session search behavior unchanged

* feat: add true HMR dev flow for VS Code extension

- Load VS Code webview from Vite dev server with React refresh preamble
- Add `vscode:dev` runner that starts watchers and opens Extension Development Host
- Update VS Code dev docs and scripts to use the new HMR startup flow

* feat: polish VS Code session sidebar and attachment UX

- Add resizable sessions sidebar in VS Code layout
- Tighten session list spacing and hover behavior in VS Code
- Remove bulk file/image attach success toasts while keeping error toasts
This commit is contained in:
Bohdan Triapitsyn
2026-03-23 23:51:55 +02:00
committed by GitHub
parent ea6d4c4d43
commit 1231fd773e
39 changed files with 1441 additions and 791 deletions
@@ -1032,6 +1032,44 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
];
}, [activeNowSessions, sessionSidebarMetaById]);
const recentSessionIds = React.useMemo(() => {
return new Set(activitySections.flatMap((section) => section.items.map((item) => item.node.session.id)));
}, [activitySections]);
const sectionsForSidebarRender = React.useMemo(() => {
if (!isVSCode || hasSessionSearchQuery || recentSessionIds.size === 0) {
return sectionsForRender;
}
const filterNodes = (nodes: SessionNode[]): SessionNode[] => {
return nodes.reduce<SessionNode[]>((acc, node) => {
if (recentSessionIds.has(node.session.id)) {
return acc;
}
const filteredChildren = filterNodes(node.children);
if (filteredChildren.length === node.children.length) {
acc.push(node);
return acc;
}
acc.push({
...node,
children: filteredChildren,
});
return acc;
}, []);
};
return sectionsForRender.map((section) => ({
...section,
groups: section.groups.map((group) => ({
...group,
sessions: filterNodes(group.sessions),
})),
}));
}, [isVSCode, hasSessionSearchQuery, recentSessionIds, sectionsForRender]);
const desktopHeaderActionButtonClass =
'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed';
const mobileHeaderActionButtonClass =
@@ -1203,12 +1241,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}, [prStatusEntries]);
const renderGroupSessions = React.useCallback(
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null) => (
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => (
<SessionGroupSection
group={group}
groupKey={groupKey}
projectId={projectId}
hideGroupLabel={hideGroupLabel}
compactBodyPadding={compactBodyPadding}
hasSessionSearchQuery={hasSessionSearchQuery}
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
groupSearchDataByGroup={groupSearchDataByGroup}
@@ -1363,7 +1402,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<SidebarProjectsList
topContent={topContent}
sectionsForRender={sectionsForRender}
sectionsForRender={sectionsForSidebarRender}
projectSections={projectSections}
activeProjectId={activeProjectId}
showOnlyMainWorkspace={showOnlyMainWorkspace}
@@ -91,6 +91,7 @@ type Props = {
}>;
onToggleCollapsedGroup: (groupKey: string) => void;
dragHandleProps?: SortableDragHandleProps | null;
compactBodyPadding?: boolean;
};
export function SessionGroupSection(props: Props): React.ReactNode {
@@ -132,6 +133,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
prVisualStateByDirectoryBranch,
onToggleCollapsedGroup,
dragHandleProps,
compactBodyPadding = false,
} = props;
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
@@ -373,8 +375,8 @@ export function SessionGroupSection(props: Props): React.ReactNode {
? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
: isMinimalMode
? (hasWorktreeDeleteAction
? 'pr-10 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-10')
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-2')
: (hasWorktreeDeleteAction
? 'pr-5 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-5');
@@ -415,8 +417,10 @@ export function SessionGroupSection(props: Props): React.ReactNode {
</SessionFolderDndScope>
);
const groupBodyPaddingClass = compactBodyPadding ? 'pb-2 pl-1' : 'pb-3 pl-4';
if (hideGroupLabel) {
return <div className="oc-group"><div className="oc-group-body pb-3 pl-4">{body}</div></div>;
return <div className="oc-group"><div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div></div>;
}
return (
@@ -438,12 +442,12 @@ export function SessionGroupSection(props: Props): React.ReactNode {
<div
ref={dragHandleProps?.setActivatorNodeRef}
className={cn(
'min-w-0 flex items-start gap-1 pl-0.5 transition-[padding] cursor-grab active:cursor-grabbing',
'min-w-0 flex flex-1 items-start gap-1 overflow-hidden pl-0.5 transition-[padding] cursor-grab active:cursor-grabbing',
groupHeaderRightPadding,
)}
{...(dragHandleProps?.listeners ?? {})}
>
<div className="min-w-0 flex flex-col justify-center gap-0.5">
<div className="min-w-0 flex flex-1 flex-col justify-center gap-0.5 overflow-hidden">
<p className="text-[14px] font-normal truncate text-foreground/92">
{showInlinePrTitle && prIndicator ? (
<span className="inline-flex min-w-0 max-w-full items-center">
@@ -503,17 +507,17 @@ export function SessionGroupSection(props: Props): React.ReactNode {
<span className="ml-1 min-w-0 flex-1 truncate leading-none align-middle">{group.branch}</span>
</span>
) : group.isArchivedBucket ? (
<span className="inline-flex min-w-0 items-center gap-1">
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<RiArchiveLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground group-hover/gh:hidden" />
<span className="hidden text-muted-foreground group-hover/gh:inline-flex h-3.5 w-3.5 items-center justify-center">
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
</span>
</span>
<span className="truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
</span>
) : (!group.isMain || group.worktree) ? (
<span className="inline-flex min-w-0 items-center gap-1">
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<RiGitBranchLine
className="h-3.5 w-3.5 shrink-0 text-muted-foreground group-hover/gh:hidden"
@@ -523,7 +527,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
</span>
</span>
<span className="truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
</span>
) : (
renderHighlightedText(group.label, normalizedSessionSearchQuery)
@@ -659,7 +663,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
</div>
) : null}
</div>
{!isCollapsed ? <div className="oc-group-body pb-3 pl-4">{body}</div> : null}
{!isCollapsed ? <div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div> : null}
</div>
);
}
@@ -152,6 +152,15 @@ export function SessionNodeItem(props: Props): React.ReactNode {
const displayMode = useSessionDisplayStore((state) => state.displayMode);
const isMinimalMode = displayMode === 'minimal';
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const revealOnHoverClass = isVSCode
? 'group-hover:opacity-100 group-hover:pointer-events-auto'
: 'group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto';
const hideOnHoverClass = isVSCode
? 'group-hover:opacity-0'
: 'group-hover:opacity-0 group-focus-within:opacity-0';
const revealPaddingClass = isVSCode
? 'group-hover:pr-5'
: 'group-hover:pr-5 group-focus-within:pr-5';
const suppressNextSelectRef = React.useRef(false);
const session = node.session;
@@ -433,7 +442,9 @@ export function SessionNodeItem(props: Props): React.ReactNode {
}}
className={cn(
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
mobileVariant ? 'pr-7' : '',
mobileVariant
? (isVSCode ? revealPaddingClass : 'pr-7')
: '',
)}
>
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
@@ -446,7 +457,7 @@ export function SessionNodeItem(props: Props): React.ReactNode {
'whitespace-nowrap text-right text-[0.72rem] text-muted-foreground/75 transition-opacity duration-150',
isMenuOpen
? 'opacity-0'
: 'group-hover:opacity-0 group-focus-within:opacity-0',
: hideOnHoverClass,
)}>
{sessionCompactUpdatedLabel}
</span>
@@ -458,7 +469,7 @@ export function SessionNodeItem(props: Props): React.ReactNode {
'absolute inset-y-0 right-0 inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
isMenuOpen
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto',
: cn('opacity-0 pointer-events-none', revealOnHoverClass),
)}
aria-label="Session menu"
onClick={handleMenuTriggerClick}
@@ -509,7 +520,12 @@ export function SessionNodeItem(props: Props): React.ReactNode {
e.stopPropagation();
handleSessionDoubleClick();
}}
className={cn('flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]', mobileVariant ? 'pr-7' : 'group-hover:pr-5 group-focus-within:pr-5')}
className={cn(
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
mobileVariant
? (isVSCode ? revealPaddingClass : 'pr-7')
: revealPaddingClass
)}
>
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
{inlineStatusMarker}
@@ -550,9 +566,9 @@ export function SessionNodeItem(props: Props): React.ReactNode {
'transition-opacity',
isMenuOpen
? 'opacity-100 pointer-events-auto'
: mobileVariant
: (mobileVariant && !isVSCode)
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto',
: cn('opacity-0 pointer-events-none', revealOnHoverClass),
),
)}>
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
@@ -564,7 +580,7 @@ export function SessionNodeItem(props: Props): React.ReactNode {
isMinimalMode && !mobileVariant
? (isMenuOpen
? 'h-4 w-4 opacity-100 pointer-events-auto'
: 'h-4 w-4 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto')
: cn('h-4 w-4 opacity-0 pointer-events-none', revealOnHoverClass))
: 'h-6 w-6 opacity-100',
)}
aria-label="Session menu"
@@ -38,7 +38,7 @@ type Props = {
hasSessionSearchQuery: boolean;
emptyState: React.ReactNode;
searchEmptyState: React.ReactNode;
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null) => React.ReactNode;
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => React.ReactNode;
homeDirectory: string | null;
collapsedProjects: Set<string>;
hideDirectoryControls: boolean;
@@ -111,7 +111,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
const hideGroupLabel = group.id === primaryGroup.id;
return (
<React.Fragment key={groupKey}>
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel)}
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true)}
</React.Fragment>
);
});
@@ -290,8 +290,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
onNewSession();
}}
className={cn(
'h-6 w-6 rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
mobileVariant ? 'inline-flex items-center justify-center' : isHovered ? 'inline-flex items-center justify-center' : 'hidden',
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
aria-label={isRepo ? 'New draft session' : 'New session'}
>