From c47c7190f45b5ab2a802d15ee00c2f589888ef69 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 20:02:59 +0300 Subject: [PATCH] feat(header): session tab tooltips, status dots, cursor context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../chat/markdown/markdown-worker.ts | 58 ++- packages/ui/src/components/layout/Header.tsx | 50 +-- .../components/layout/SessionTabsStrip.tsx | 379 +++++++++++++----- packages/ui/src/index.css | 16 + packages/vscode/src/DOCUMENTATION.md | 2 +- 5 files changed, 366 insertions(+), 139 deletions(-) diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index 2feb270a..b93eb6dc 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -1,4 +1,5 @@ import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url'; +import { isVSCodeRuntime } from '@/stores/utils/vscodeRuntime'; import { contentFingerprint, estimateTokenRunsBytes, @@ -46,6 +47,8 @@ const resultCache = new HighlightResultCache({ const inflight = new Map>(); let worker: Worker | undefined; +let workerCreation: Promise | undefined; +let workerObjectUrl: string | undefined; let nextId = 0; const pending = new Map(); // Theme names whose full definition we've already shipped to the live worker, so @@ -71,31 +74,56 @@ const failAll = (): void => { inflight.clear(); worker?.terminate(); worker = undefined; + workerCreation = undefined; + if (workerObjectUrl) { + URL.revokeObjectURL(workerObjectUrl); + workerObjectUrl = undefined; + } }; -const getWorker = (): Worker | undefined => { - if (worker) return worker; +const createWorker = async (): Promise => { if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined; 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) => { + 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) { + if (workerObjectUrl) { + URL.revokeObjectURL(workerObjectUrl); + workerObjectUrl = undefined; + } console.error('Failed to create Shiki worker:', err); return undefined; } - worker.onmessage = (event: MessageEvent) => { - 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 => { - const instance = getWorker(); +const getWorker = async (): Promise => { + if (worker) return worker; + workerCreation ??= createWorker().finally(() => { + workerCreation = undefined; + }); + return workerCreation; +}; + +const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise => { + const instance = await getWorker(); if (!instance) return Promise.resolve(null); const id = ++nextId; return new Promise((resolve) => { diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 97259ea1..8ab2bc69 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -1530,49 +1530,50 @@ export const Header: React.FC = () => { 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 canMoveToWorktree = isActive && !isVSCode && !isChatContext && currentSession && !currentSession.parentId; return ( <> - { if (!isActive) select(); pendingHeaderRenameRef.current = true; }}> + { if (!isActive) select(); pendingHeaderRenameRef.current = true; }}> {t('sessions.sidebar.session.menu.rename')} - - copySessionIdFor(session.id)}> + + copySessionIdFor(session.id)}> {t('sessions.sidebar.session.menu.copyId')} - - + + {shareUrl ? ( <> - copySessionShareUrl(shareUrl)}> + copySessionShareUrl(shareUrl)}> {t('sessions.sidebar.session.menu.copyLink')} - - void unshareSessionFor(session.id)}> + + void unshareSessionFor(session.id)}> {t('sessions.sidebar.session.menu.unshare')} - + ) : ( - void shareSessionFor(session.id)}> + void shareSessionFor(session.id)}> {t('sessions.sidebar.session.menu.share')} - + )} {isActive ? ( - void exportCurrentSession()}> + void exportCurrentSession()}> {t('sessions.sidebar.session.menu.exportMarkdown')} - + ) : null} {canMoveToWorktree ? ( - {t('sessions.sidebar.session.menu.moveToWorktree')} - + @@ -1584,17 +1585,17 @@ export const Header: React.FC = () => { ) : null} - - + + {t('header.sessionTabs.closeOtherTabs')} - - - setPendingHeaderRetentionAction({ action: 'archive', sessionId: session.id })}> + + + setPendingHeaderRetentionAction({ action: 'archive', sessionId: session.id })}> {t('sessions.sidebar.bulkActions.archive')} - - setPendingHeaderRetentionAction({ action: 'delete', sessionId: session.id })}> + + setPendingHeaderRetentionAction({ action: 'delete', sessionId: session.id })}> {t('sessions.sidebar.bulkActions.delete')} - + ); }, [copySessionIdFor, copySessionShareUrl, currentSession, exportCurrentSession, isChatContext, isCurrentSessionActive, isCurrentSessionMovingToWorktree, isVSCode, moveCurrentSessionToWorktree, sessionDirectory, shareSessionFor, t, unshareSessionFor]); @@ -1803,6 +1804,7 @@ export const Header: React.FC = () => { ) : null} { if (!open && pendingHeaderRenameRef.current) { pendingHeaderRenameRef.current = false; diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index e75f0dc4..a701774c 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -15,63 +15,172 @@ import { useSortable, } from '@dnd-kit/sortable'; 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 { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } 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 { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { useSessionTabsStore } from '@/stores/useSessionTabsStore'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; 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 }); 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 = { session: Session; isActive: boolean; select: () => 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 }) => ( + + ), + Separator: ({ 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 * 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 - * `renderMenu`, bound to this tab's session; right-click opens it without - * changing which tab is active. The overlay stays visible until the menu's - * close animation completes, so the popup never loses its anchor mid-flight - * (that was the top-left corner flash). + * close after it). One session menu — supplied by the header via + * `renderMenu` — backs both the "..." dropdown and the right-click context + * menu, which opens under the cursor without changing the active tab. The + * dropdown's anchor overlay stays mounted through the close animation so the + * popup never flashes detached. While the active tab is renaming, the + * overlay is suppressed entirely — only the rename controls show. */ const SessionTabItem: React.FC<{ tab: SessionTab; isActive: boolean; + suppressControls: boolean; onSelect: (tab: SessionTab) => void; onClose: (id: string) => void; renderMenu: (args: SessionTabMenuArgs) => React.ReactNode; closeOtherTabs: (id: string) => void; onMenuOpenChangeComplete?: (open: boolean) => void; children?: React.ReactNode; -}> = ({ tab, isActive, onSelect, onClose, renderMenu, closeOtherTabs, onMenuOpenChangeComplete, children }) => { +}> = ({ tab, isActive, suppressControls, onSelect, onClose, renderMenu, closeOtherTabs, onMenuOpenChangeComplete, children }) => { const { t } = useI18n(); 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 [contextMenuOpen, setContextMenuOpen] = React.useState(false); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id }); 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(() => { - setMenuVisible(true); - setMenuOpen(true); - }, []); + // Session state for the dot and the hover tooltip. + const sessionStatus = useGlobalSessionStatus(tab.id); + 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 (
-
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} + onMenuOpenChangeComplete?.(open)} > -
- {isActive ? children : ( - {title} - )} -
-
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', - )} - > - { - setMenuOpen(open); - if (open) setMenuVisible(true); - }} - onOpenChangeComplete={(open) => { - if (!open) setMenuVisible(false); - onMenuOpenChangeComplete?.(open); - }} - > - - - - - {renderMenu({ - session: tab.session, - isActive, - select: () => onSelect(tab), - closeOtherTabs: () => closeOtherTabs(tab.id), - })} - - - -
-
+ + + ( +
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); + } + }} + 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', + ), + )} + > +
+
+ {isActive ? children : ( + {title} + )} +
+ {showDot ? ( + + ) : null} +
+ {!suppressControls ? ( +
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', + )} + > + { + setMenuOpen(open); + if (open) setMenuVisible(true); + }} + onOpenChangeComplete={(open) => { + if (!open) setMenuVisible(false); + onMenuOpenChangeComplete?.(open); + }} + > + + + + + {renderMenu(menuArgsFor(dropdownComponents))} + + + +
+ ) : null} +
+ )} + /> +
+ {!anyMenuOpen && !isDragging ? ( + +
+
+ {title} + {sessionTimestamp ? ( + + {formatSessionCompactDateLabel(sessionTimestamp)} + + ) : null} +
+ {projectLabel ? ( +
+ + {projectLabel} +
+ ) : null} + {branchLabel ? ( +
+ + {branchLabel} +
+ ) : null} + {prSummary && prStatusLabel ? ( +
+ + + #{prSummary.number} · {prStatusLabel} + +
+ ) : null} +
+
+ ) : null} +
+ + + + {renderMenu(menuArgsFor(contextComponents))} + + + +
); }; @@ -189,8 +367,10 @@ export const SessionTabsStrip: React.FC<{ renderMenu: (args: SessionTabMenuArgs) => React.ReactNode; /** Fires when a tab menu finishes opening/closing (deferred rename hook). */ onMenuOpenChangeComplete?: (open: boolean) => void; + /** While the active tab renames, its hover controls stay hidden. */ + suppressActiveTabControls?: boolean; children: React.ReactNode; -}> = ({ renderMenu, onMenuOpenChangeComplete, children }) => { +}> = ({ renderMenu, onMenuOpenChangeComplete, suppressActiveTabControls = false, children }) => { const { t } = useI18n(); const tabIds = useSessionTabsStore((state) => state.tabIds); const ensureTab = useSessionTabsStore((state) => state.ensureTab); @@ -323,6 +503,7 @@ export const SessionTabsStrip: React.FC<{ key={tab.id} tab={tab} isActive={tab.id === currentSessionId} + suppressControls={tab.id === currentSessionId && suppressActiveTabControls} onSelect={handleSelect} onClose={handleClose} renderMenu={renderMenu} diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 1cec5c79..67f694ec 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1336,6 +1336,14 @@ html:not(.dark) .chat-scroll { 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] { display: block; min-width: 100%; @@ -1815,3 +1823,11 @@ html.desktop-runtime .markdown-content [data-openchamber-file-link="true"] { width: 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); +} diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index e0de1b91..ccf9e6bc 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -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 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` - Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers.