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 { isVSCodeRuntime } from '@/stores/utils/vscodeRuntime';
import {
contentFingerprint,
estimateTokenRunsBytes,
@@ -46,6 +47,8 @@ const resultCache = new HighlightResultCache<CachedHighlight>({
const inflight = new Map<string, Promise<CachedHighlight | null>>();
let worker: Worker | undefined;
let workerCreation: Promise<Worker | undefined> | undefined;
let workerObjectUrl: string | undefined;
let nextId = 0;
const pending = new Map<number, PendingResolver>();
// 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<Worker | undefined> => {
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<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) {
if (workerObjectUrl) {
URL.revokeObjectURL(workerObjectUrl);
workerObjectUrl = undefined;
}
console.error('Failed to create Shiki worker:', err);
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 instance = getWorker();
const getWorker = async (): Promise<Worker | undefined> => {
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);
const id = ++nextId;
return new Promise<MarkdownWorkerResponse | null>((resolve) => {