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.