feat(header): session tab tooltips, status dots, cursor context menu

Right-click now opens the session menu under the cursor (the sidebar's
context-menu pattern; the same header-supplied items back both the
"..." dropdown and the context menu via injected menu primitives) and
still never changes the active tab. Hovering a tab shows the sidebar's
session tooltip — title, last-activity time, project, branch and PR
status — after a delay, suppressed while a menu is open or a drag is in
flight. Each tab carries the sidebar's status dot at its end (accent
while the session runs, info-blue for unread), hidden while the hover
controls overlay it. Tab titles fade out instead of ending in "...",
and while the active tab is renaming its hover controls stay hidden so
only the rename controls show.
This commit is contained in:
Bohdan Triapitsyn
2026-08-24 20:02:59 +03:00
parent 338dc73e96
commit c47c7190f4
5 changed files with 366 additions and 139 deletions
@@ -1,4 +1,5 @@
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url'; import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
import { isVSCodeRuntime } from '@/stores/utils/vscodeRuntime';
import { import {
contentFingerprint, contentFingerprint,
estimateTokenRunsBytes, estimateTokenRunsBytes,
@@ -46,6 +47,8 @@ const resultCache = new HighlightResultCache<CachedHighlight>({
const inflight = new Map<string, Promise<CachedHighlight | null>>(); const inflight = new Map<string, Promise<CachedHighlight | null>>();
let worker: Worker | undefined; let worker: Worker | undefined;
let workerCreation: Promise<Worker | undefined> | undefined;
let workerObjectUrl: string | undefined;
let nextId = 0; let nextId = 0;
const pending = new Map<number, PendingResolver>(); const pending = new Map<number, PendingResolver>();
// Theme names whose full definition we've already shipped to the live worker, so // Theme names whose full definition we've already shipped to the live worker, so
@@ -71,31 +74,56 @@ const failAll = (): void => {
inflight.clear(); inflight.clear();
worker?.terminate(); worker?.terminate();
worker = undefined; worker = undefined;
workerCreation = undefined;
if (workerObjectUrl) {
URL.revokeObjectURL(workerObjectUrl);
workerObjectUrl = undefined;
}
}; };
const getWorker = (): Worker | undefined => { const createWorker = async (): Promise<Worker | undefined> => {
if (worker) return worker;
if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined; if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined;
try { try {
worker = new Worker(MarkdownShikiWorkerUrl, { type: 'module' }); let workerUrl = MarkdownShikiWorkerUrl;
if (isVSCodeRuntime(null)) {
const response = await fetch(workerUrl);
if (!response.ok) throw new Error(`Shiki worker request failed with ${response.status}`);
workerObjectUrl = URL.createObjectURL(await response.blob());
workerUrl = workerObjectUrl;
}
const instance = new Worker(workerUrl, { type: 'module' });
worker = instance;
instance.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
const resolve = pending.get(event.data.id);
if (!resolve) return;
pending.delete(event.data.id);
resolve(event.data);
};
instance.onerror = failAll;
instance.onmessageerror = failAll;
instance.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
return instance;
} catch (err) { } catch (err) {
if (workerObjectUrl) {
URL.revokeObjectURL(workerObjectUrl);
workerObjectUrl = undefined;
}
console.error('Failed to create Shiki worker:', err); console.error('Failed to create Shiki worker:', err);
return undefined; return undefined;
} }
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
const resolve = pending.get(event.data.id);
if (!resolve) return;
pending.delete(event.data.id);
resolve(event.data);
};
worker.onerror = failAll;
worker.onmessageerror = failAll;
worker.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
return worker;
}; };
const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => { const getWorker = async (): Promise<Worker | undefined> => {
const instance = getWorker(); if (worker) return worker;
workerCreation ??= createWorker().finally(() => {
workerCreation = undefined;
});
return workerCreation;
};
const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
const instance = await getWorker();
if (!instance) return Promise.resolve(null); if (!instance) return Promise.resolve(null);
const id = ++nextId; const id = ++nextId;
return new Promise<MarkdownWorkerResponse | null>((resolve) => { return new Promise<MarkdownWorkerResponse | null>((resolve) => {
+26 -24
View File
@@ -1530,49 +1530,50 @@ export const Header: React.FC = () => {
const showMiniChatHeaderAction = hasElectronDesktopIPC && (isNewSessionDraftOpen || Boolean(currentSessionId)); const showMiniChatHeaderAction = hasElectronDesktopIPC && (isNewSessionDraftOpen || Boolean(currentSessionId));
const renderSessionTabMenu = React.useCallback(({ session, isActive, select, closeOtherTabs }: SessionTabMenuArgs) => { const renderSessionTabMenu = React.useCallback(({ session, isActive, select, closeOtherTabs, components }: SessionTabMenuArgs) => {
const { Item, Separator } = components;
const shareUrl = session.share?.url ?? null; const shareUrl = session.share?.url ?? null;
const canMoveToWorktree = isActive && !isVSCode && !isChatContext && currentSession && !currentSession.parentId; const canMoveToWorktree = isActive && !isVSCode && !isChatContext && currentSession && !currentSession.parentId;
return ( return (
<> <>
<DropdownMenuItem onClick={() => { if (!isActive) select(); pendingHeaderRenameRef.current = true; }}> <Item onClick={() => { if (!isActive) select(); pendingHeaderRenameRef.current = true; }}>
<Icon name="pencil-ai" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.rename')} <Icon name="pencil-ai" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.rename')}
</DropdownMenuItem> </Item>
<DropdownMenuItem onClick={() => copySessionIdFor(session.id)}> <Item onClick={() => copySessionIdFor(session.id)}>
<Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyId')} <Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyId')}
</DropdownMenuItem> </Item>
<DropdownMenuSeparator /> <Separator />
{shareUrl ? ( {shareUrl ? (
<> <>
<DropdownMenuItem onClick={() => copySessionShareUrl(shareUrl)}> <Item onClick={() => copySessionShareUrl(shareUrl)}>
<Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyLink')} <Icon name="file-copy" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.copyLink')}
</DropdownMenuItem> </Item>
<DropdownMenuItem onClick={() => void unshareSessionFor(session.id)}> <Item onClick={() => void unshareSessionFor(session.id)}>
<Icon name="link-unlink-m" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.unshare')} <Icon name="link-unlink-m" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.unshare')}
</DropdownMenuItem> </Item>
</> </>
) : ( ) : (
<DropdownMenuItem onClick={() => void shareSessionFor(session.id)}> <Item onClick={() => void shareSessionFor(session.id)}>
<Icon name="share-2" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.share')} <Icon name="share-2" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.share')}
</DropdownMenuItem> </Item>
)} )}
{isActive ? ( {isActive ? (
<DropdownMenuItem onClick={() => void exportCurrentSession()}> <Item onClick={() => void exportCurrentSession()}>
<Icon name="download" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.exportMarkdown')} <Icon name="download" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.exportMarkdown')}
</DropdownMenuItem> </Item>
) : null} ) : null}
{canMoveToWorktree ? ( {canMoveToWorktree ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="block"> <span className="block">
<DropdownMenuItem <Item
disabled={!sessionDirectory || isCurrentSessionActive || isCurrentSessionMovingToWorktree} disabled={!sessionDirectory || isCurrentSessionActive || isCurrentSessionMovingToWorktree}
onClick={moveCurrentSessionToWorktree} onClick={moveCurrentSessionToWorktree}
className="w-full" className="w-full"
> >
<Icon name="folder-shared" className="mr-2 size-4" /> <Icon name="folder-shared" className="mr-2 size-4" />
{t('sessions.sidebar.session.menu.moveToWorktree')} {t('sessions.sidebar.session.menu.moveToWorktree')}
</DropdownMenuItem> </Item>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="max-w-72"> <TooltipContent side="right" className="max-w-72">
@@ -1584,17 +1585,17 @@ export const Header: React.FC = () => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
<DropdownMenuSeparator /> <Separator />
<DropdownMenuItem onClick={closeOtherTabs}> <Item onClick={closeOtherTabs}>
<Icon name="close-circle" className="mr-2 size-4" />{t('header.sessionTabs.closeOtherTabs')} <Icon name="close-circle" className="mr-2 size-4" />{t('header.sessionTabs.closeOtherTabs')}
</DropdownMenuItem> </Item>
<DropdownMenuSeparator /> <Separator />
<DropdownMenuItem onClick={() => setPendingHeaderRetentionAction({ action: 'archive', sessionId: session.id })}> <Item onClick={() => setPendingHeaderRetentionAction({ action: 'archive', sessionId: session.id })}>
<Icon name="inbox-archive" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.archive')} <Icon name="inbox-archive" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.archive')}
</DropdownMenuItem> </Item>
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => setPendingHeaderRetentionAction({ action: 'delete', sessionId: session.id })}> <Item className="text-destructive focus:text-destructive" onClick={() => setPendingHeaderRetentionAction({ action: 'delete', sessionId: session.id })}>
<Icon name="delete-bin" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.delete')} <Icon name="delete-bin" className="mr-2 size-4" />{t('sessions.sidebar.bulkActions.delete')}
</DropdownMenuItem> </Item>
</> </>
); );
}, [copySessionIdFor, copySessionShareUrl, currentSession, exportCurrentSession, isChatContext, isCurrentSessionActive, isCurrentSessionMovingToWorktree, isVSCode, moveCurrentSessionToWorktree, sessionDirectory, shareSessionFor, t, unshareSessionFor]); }, [copySessionIdFor, copySessionShareUrl, currentSession, exportCurrentSession, isChatContext, isCurrentSessionActive, isCurrentSessionMovingToWorktree, isVSCode, moveCurrentSessionToWorktree, sessionDirectory, shareSessionFor, t, unshareSessionFor]);
@@ -1803,6 +1804,7 @@ export const Header: React.FC = () => {
) : null} ) : null}
<SessionTabsStrip <SessionTabsStrip
renderMenu={renderSessionTabMenu} renderMenu={renderSessionTabMenu}
suppressActiveTabControls={isRenamingHeaderSession}
onMenuOpenChangeComplete={(open) => { onMenuOpenChangeComplete={(open) => {
if (!open && pendingHeaderRenameRef.current) { if (!open && pendingHeaderRenameRef.current) {
pendingHeaderRenameRef.current = false; pendingHeaderRenameRef.current = false;
@@ -15,63 +15,172 @@ import {
useSortable, useSortable,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS as DndCSS } from '@dnd-kit/utilities'; import { CSS as DndCSS } from '@dnd-kit/utilities';
import { ContextMenu } from '@base-ui/react/context-menu';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass } from '@/components/ui/dropdown-menu.styles';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { useSessionTabsStore } from '@/stores/useSessionTabsStore'; import { useSessionTabsStore } from '@/stores/useSessionTabsStore';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionStatus } from '@/sync/sync-context';
import { useSessionUnseenCount } from '@/sync/notification-store';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useGitAllBranches } from '@/stores/useGitStore';
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
import {
formatProjectLabel,
formatSessionCompactDateLabel,
formatSessionDateLabel,
normalizePath,
} from '@/components/session/sidebar/utils';
const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 }); const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 });
type SessionTab = { id: string; session: Session }; type SessionTab = { id: string; session: Session };
export type SessionTabMenuComponents = {
Item: React.ComponentType<{
className?: string;
disabled?: boolean;
onClick?: React.MouseEventHandler;
children?: React.ReactNode;
}>;
Separator: React.ComponentType<{ className?: string }>;
};
export type SessionTabMenuArgs = { export type SessionTabMenuArgs = {
session: Session; session: Session;
isActive: boolean; isActive: boolean;
select: () => void; select: () => void;
closeOtherTabs: () => void; closeOtherTabs: () => void;
/** Menu primitives for the surface the menu opens in (dropdown or context menu). */
components: SessionTabMenuComponents;
}; };
const dropdownComponents: SessionTabMenuComponents = {
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
};
const contextComponents: SessionTabMenuComponents = {
Item: ({ className, ...props }) => (
<ContextMenu.Item className={cn(dropdownMenuItemClass, className)} {...props} />
),
Separator: ({ className, ...props }) => (
<ContextMenu.Separator className={cn(dropdownMenuSeparatorClass, className)} {...props} />
),
};
/** Resolve the project a session directory belongs to, for the hover tooltip. */
const useTabProjectLabel = (directory: string | null): string | null =>
useProjectsStore(React.useCallback((state) => {
if (!directory) return null;
const dir = normalizePath(directory);
if (!dir) return null;
for (const project of state.projects) {
const path = normalizePath(project.path);
if (path && (dir === path || dir.startsWith(`${path}/`))) {
return formatProjectLabel(project.label?.trim() || path.split('/').pop() || path);
}
}
return null;
}, [directory]));
/** /**
* One tab, active or not. The tab drags to reorder; the menu and close * One tab, active or not. The tab drags to reorder; the menu and close
* controls sit in a hover-revealed overlay at the tab's end (menu first, * controls sit in a hover-revealed overlay at the tab's end (menu first,
* close after it). The single session menu is supplied by the header via * close after it). One session menu — supplied by the header via
* `renderMenu`, bound to this tab's session; right-click opens it without * `renderMenu` — backs both the "..." dropdown and the right-click context
* changing which tab is active. The overlay stays visible until the menu's * menu, which opens under the cursor without changing the active tab. The
* close animation completes, so the popup never loses its anchor mid-flight * dropdown's anchor overlay stays mounted through the close animation so the
* (that was the top-left corner flash). * popup never flashes detached. While the active tab is renaming, the
* overlay is suppressed entirely — only the rename controls show.
*/ */
const SessionTabItem: React.FC<{ const SessionTabItem: React.FC<{
tab: SessionTab; tab: SessionTab;
isActive: boolean; isActive: boolean;
suppressControls: boolean;
onSelect: (tab: SessionTab) => void; onSelect: (tab: SessionTab) => void;
onClose: (id: string) => void; onClose: (id: string) => void;
renderMenu: (args: SessionTabMenuArgs) => React.ReactNode; renderMenu: (args: SessionTabMenuArgs) => React.ReactNode;
closeOtherTabs: (id: string) => void; closeOtherTabs: (id: string) => void;
onMenuOpenChangeComplete?: (open: boolean) => void; onMenuOpenChangeComplete?: (open: boolean) => void;
children?: React.ReactNode; children?: React.ReactNode;
}> = ({ tab, isActive, onSelect, onClose, renderMenu, closeOtherTabs, onMenuOpenChangeComplete, children }) => { }> = ({ tab, isActive, suppressControls, onSelect, onClose, renderMenu, closeOtherTabs, onMenuOpenChangeComplete, children }) => {
const { t } = useI18n(); const { t } = useI18n();
const [menuOpen, setMenuOpen] = React.useState(false); const [menuOpen, setMenuOpen] = React.useState(false);
// Keeps the overlay (the menu's anchor) mounted through the close animation. // Keeps the overlay (the dropdown's anchor) mounted through the close animation.
const [menuVisible, setMenuVisible] = React.useState(false); const [menuVisible, setMenuVisible] = React.useState(false);
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id }); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id });
const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled'); const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled');
const overlayVisible = menuOpen || menuVisible; const overlayVisible = !suppressControls && (menuOpen || menuVisible);
const anyMenuOpen = menuOpen || contextMenuOpen;
const openMenu = React.useCallback(() => { // Session state for the dot and the hover tooltip.
setMenuVisible(true); const sessionStatus = useGlobalSessionStatus(tab.id);
setMenuOpen(true); const isStreaming = sessionStatus?.type === 'busy' || sessionStatus?.type === 'retry';
}, []); const unseenCount = useSessionUnseenCount(tab.id);
const showUnread = unseenCount > 0 && !isActive && !isStreaming;
const showDot = isStreaming || showUnread;
const dotLabel = isStreaming
? t('sessions.sidebar.session.status.active')
: t('sessions.sidebar.session.status.unread');
const directory = normalizePath(resolveGlobalSessionDirectory(tab.session) ?? null);
const projectLabel = useTabProjectLabel(directory);
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
const allBranches = useGitAllBranches();
const branchLabel = React.useMemo(() => {
const meta = worktreeMetadata.get(tab.id);
if (meta?.branch?.trim()) return meta.branch.trim();
if (directory) return allBranches.get(directory)?.trim() || null;
return null;
}, [worktreeMetadata, allBranches, tab.id, directory]);
const prSummary = usePrVisualSummary(directory && branchLabel ? getGitHubPrStatusKey(directory, branchLabel) : null);
const prIconColor = prSummary ? `var(--pr-${prSummary.visualState})` : undefined;
const prStatusLabel = React.useMemo(() => {
if (!prSummary) return null;
switch (prSummary.visualState) {
case 'merged':
return t('sessions.sidebar.group.pr.status.merged');
case 'open':
return (prSummary.canMerge === true || prSummary.mergeableState === 'clean' || prSummary.checks?.state === 'success')
? t('sessions.sidebar.group.pr.status.readyToMerge')
: t('sessions.sidebar.group.pr.status.open');
case 'blocked':
return prSummary.mergeableState === 'dirty'
? t('sessions.sidebar.group.pr.status.mergeConflicts')
: t('sessions.sidebar.group.pr.status.mergeBlocked');
case 'draft':
return t('sessions.sidebar.group.pr.status.draft');
case 'closed':
return t('sessions.sidebar.group.pr.status.closed');
default:
return null;
}
}, [prSummary, t]);
const sessionTimestamp = tab.session.time?.updated || tab.session.time?.created || 0;
const menuArgsFor = (components: SessionTabMenuComponents): SessionTabMenuArgs => ({
session: tab.session,
isActive,
select: () => onSelect(tab),
closeOtherTabs: () => closeOtherTabs(tab.id),
components,
});
return ( return (
<div <div
@@ -83,93 +192,162 @@ const SessionTabItem: React.FC<{
{...attributes} {...attributes}
{...listeners} {...listeners}
> >
<div <ContextMenu.Root
role="tab" open={contextMenuOpen}
aria-selected={isActive} onOpenChange={setContextMenuOpen}
tabIndex={isActive ? undefined : 0} onOpenChangeComplete={(open) => onMenuOpenChangeComplete?.(open)}
onClick={isActive ? undefined : () => onSelect(tab)}
onKeyDown={isActive ? undefined : (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(tab);
}
}}
onAuxClick={(event) => {
if (event.button === 1) {
event.preventDefault();
onClose(tab.id);
}
}}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
openMenu();
}}
className={cn(
'group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2',
isActive
? 'bg-interactive-selection'
: cn(
'cursor-pointer text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover hover:text-foreground',
overlayVisible && 'bg-interactive-hover text-foreground',
),
)}
title={isActive ? undefined : title}
> >
<div className={cn('min-w-0 flex-1', 'group-hover/session-tab:pr-10', overlayVisible && 'pr-10')}> <Tooltip delayDuration={700}>
{isActive ? children : ( <TooltipTrigger asChild>
<span className="block min-w-0 truncate text-[13px] font-medium leading-4">{title}</span> <ContextMenu.Trigger
)} render={(triggerProps) => (
</div> <div
<div {...triggerProps}
onClick={(event) => event.stopPropagation()} role="tab"
onPointerDown={(event) => event.stopPropagation()} aria-selected={isActive}
className={cn( tabIndex={isActive ? undefined : 0}
'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5', onClick={isActive ? undefined : () => onSelect(tab)}
'opacity-0 transition-opacity duration-150', onKeyDown={isActive ? undefined : (event) => {
'group-hover/session-tab:flex group-hover/session-tab:opacity-100', if (event.key === 'Enter' || event.key === ' ') {
overlayVisible && 'flex opacity-100', event.preventDefault();
)} onSelect(tab);
> }
<DropdownMenu }}
open={menuOpen} onAuxClick={(event) => {
onOpenChange={(open) => { if (event.button === 1) {
setMenuOpen(open); event.preventDefault();
if (open) setMenuVisible(true); onClose(tab.id);
}} }
onOpenChangeComplete={(open) => { }}
if (!open) setMenuVisible(false); className={cn(
onMenuOpenChangeComplete?.(open); 'group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2',
}} isActive
> ? 'bg-interactive-selection'
<DropdownMenuTrigger asChild> : cn(
<button 'cursor-pointer text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover hover:text-foreground',
type="button" overlayVisible && 'bg-interactive-hover text-foreground',
aria-label={t('header.sessionTabs.tabMenuAria')} ),
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground" )}
> >
<Icon name="more" className="size-4" /> <div className={cn(
</button> 'flex min-w-0 flex-1 items-center',
</DropdownMenuTrigger> !suppressControls && 'group-hover/session-tab:pr-10',
<DropdownMenuContent align="start" className="min-w-[190px]"> overlayVisible && 'pr-10',
{renderMenu({ )}
session: tab.session, >
isActive, <div className="session-tab-title min-w-0 flex-1 overflow-hidden whitespace-nowrap">
select: () => onSelect(tab), {isActive ? children : (
closeOtherTabs: () => closeOtherTabs(tab.id), <span className="text-[13px] font-medium leading-4">{title}</span>
})} )}
</DropdownMenuContent> </div>
</DropdownMenu> {showDot ? (
<button <span
type="button" className={cn(
aria-label={t('header.sessionTabs.closeTab')} 'ml-1.5 h-1.5 w-1.5 shrink-0 rounded-full',
onClick={() => onClose(tab.id)} isStreaming ? 'bg-primary' : 'bg-[var(--status-info)]',
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground" !suppressControls && 'group-hover/session-tab:opacity-0',
> overlayVisible && 'opacity-0',
<Icon name="close" className="size-4" /> )}
</button> aria-label={dotLabel}
</div> />
</div> ) : null}
</div>
{!suppressControls ? (
<div
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
className={cn(
'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5',
'opacity-0 transition-opacity duration-150',
'group-hover/session-tab:flex group-hover/session-tab:opacity-100',
overlayVisible && 'flex opacity-100',
)}
>
<DropdownMenu
open={menuOpen}
onOpenChange={(open) => {
setMenuOpen(open);
if (open) setMenuVisible(true);
}}
onOpenChangeComplete={(open) => {
if (!open) setMenuVisible(false);
onMenuOpenChangeComplete?.(open);
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('header.sessionTabs.tabMenuAria')}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
<Icon name="more" className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[190px]">
{renderMenu(menuArgsFor(dropdownComponents))}
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
aria-label={t('header.sessionTabs.closeTab')}
onClick={() => onClose(tab.id)}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
<Icon name="close" className="size-4" />
</button>
</div>
) : null}
</div>
)}
/>
</TooltipTrigger>
{!anyMenuOpen && !isDragging ? (
<TooltipContent side="bottom" sideOffset={8} className="max-w-xs text-left">
<div className="flex min-w-44 flex-col gap-1.5 text-left text-xs">
<div className="flex items-center justify-between gap-3">
<span className="min-w-0 truncate font-medium text-foreground">{title}</span>
{sessionTimestamp ? (
<span className="flex-shrink-0 text-muted-foreground" title={formatSessionDateLabel(sessionTimestamp)}>
{formatSessionCompactDateLabel(sessionTimestamp)}
</span>
) : null}
</div>
{projectLabel ? (
<div className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
<Icon name="folder" className="h-3 w-3 flex-shrink-0" />
<span className="min-w-0 truncate">{projectLabel}</span>
</div>
) : null}
{branchLabel ? (
<div className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
<Icon name="git-branch" className="h-3 w-3 flex-shrink-0" style={prIconColor ? { color: prIconColor } : undefined} />
<span className="min-w-0 truncate">{branchLabel}</span>
</div>
) : null}
{prSummary && prStatusLabel ? (
<div className="flex min-w-0 items-center gap-1.5">
<Icon name="git-pull-request" className="h-3 w-3 flex-shrink-0" style={prIconColor ? { color: prIconColor } : undefined} />
<span className="min-w-0 truncate" style={prIconColor ? { color: prIconColor } : undefined}>
#{prSummary.number} · {prStatusLabel}
</span>
</div>
) : null}
</div>
</TooltipContent>
) : null}
</Tooltip>
<ContextMenu.Portal>
<ContextMenu.Positioner className="app-region-no-drag z-50">
<ContextMenu.Popup
data-slot="dropdown-menu-content"
style={{ color: 'var(--surface-elevated-foreground)' }}
className={cn(dropdownMenuPopupClass, 'min-w-[190px]')}
>
{renderMenu(menuArgsFor(contextComponents))}
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
</ContextMenu.Root>
</div> </div>
); );
}; };
@@ -189,8 +367,10 @@ export const SessionTabsStrip: React.FC<{
renderMenu: (args: SessionTabMenuArgs) => React.ReactNode; renderMenu: (args: SessionTabMenuArgs) => React.ReactNode;
/** Fires when a tab menu finishes opening/closing (deferred rename hook). */ /** Fires when a tab menu finishes opening/closing (deferred rename hook). */
onMenuOpenChangeComplete?: (open: boolean) => void; onMenuOpenChangeComplete?: (open: boolean) => void;
/** While the active tab renames, its hover controls stay hidden. */
suppressActiveTabControls?: boolean;
children: React.ReactNode; children: React.ReactNode;
}> = ({ renderMenu, onMenuOpenChangeComplete, children }) => { }> = ({ renderMenu, onMenuOpenChangeComplete, suppressActiveTabControls = false, children }) => {
const { t } = useI18n(); const { t } = useI18n();
const tabIds = useSessionTabsStore((state) => state.tabIds); const tabIds = useSessionTabsStore((state) => state.tabIds);
const ensureTab = useSessionTabsStore((state) => state.ensureTab); const ensureTab = useSessionTabsStore((state) => state.ensureTab);
@@ -323,6 +503,7 @@ export const SessionTabsStrip: React.FC<{
key={tab.id} key={tab.id}
tab={tab} tab={tab}
isActive={tab.id === currentSessionId} isActive={tab.id === currentSessionId}
suppressControls={tab.id === currentSessionId && suppressActiveTabControls}
onSelect={handleSelect} onSelect={handleSelect}
onClose={handleClose} onClose={handleClose}
renderMenu={renderMenu} renderMenu={renderMenu}
+16
View File
@@ -1336,6 +1336,14 @@ html:not(.dark) .chat-scroll {
background: transparent !important; background: transparent !important;
} }
/* The marked renderer uses its own code-body wrapper instead of Streamdown's. */
.markdown-content [data-md-code-body],
.markdown-content [data-md-code-body] pre,
.markdown-content [data-md-code-body] code,
.markdown-content [data-md-code-body] [data-md-code-lines] {
background: transparent !important;
}
.markdown-content [data-md-code-lines] { .markdown-content [data-md-code-lines] {
display: block; display: block;
min-width: 100%; min-width: 100%;
@@ -1815,3 +1823,11 @@ html.desktop-runtime .markdown-content [data-openchamber-file-link="true"] {
width: 0; width: 0;
height: 0; height: 0;
} }
/* Session tab titles: fade out instead of "..." — the ellipsis reads as
clutter next to the tab's status dot and hover controls. Short titles
never reach the fade zone (the span spans the tab, not the text). */
.session-tab-title {
-webkit-mask-image: linear-gradient(to right, black calc(100% - 14px), transparent);
mask-image: linear-gradient(to right, black calc(100% - 14px), transparent);
}
+1 -1
View File
@@ -44,7 +44,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can run bounded local decompression off the main thread. Blob scripts remain disallowed by `script-src`. The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can run bounded local decompression off the main thread. Blob scripts remain disallowed by `script-src`.
The webview build emits each worker as one self-contained file. VS Code webviews cannot load module imports from inside a worker, so allowing worker URLs in the CSP is not enough when Rollup splits Shiki grammars into separate chunks. The webview build emits each worker as one self-contained file. VS Code webviews cannot load workers directly from extension resource URLs or load module imports from inside a worker. The shared Shiki client therefore fetches the built worker, starts it from a `blob:` URL, and relies on the worker CSP allowance above.
- `bridge-localfs-proxy-runtime.ts` - `bridge-localfs-proxy-runtime.ts`
- Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers. - Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers.