('[data-markdown-content]') ?? container;
if (!target) return;
+ const decorationId = getMarkdownDecorationId(ctx);
if (text && target.childNodes.length === 0) {
- const block = document.createElement('div');
- block.setAttribute('data-md-block', '');
- // `display:contents` keeps margin-collapsing/spacing identical to a flat
- // HTML body — the wrapper exists only for per-block reconciliation.
- block.style.display = 'contents';
- block.innerHTML = renderMarkdownSync(text, imageMode);
- // Decorate synchronously too: wrap code blocks in their framed card,
- // mark inline code, build table controls, etc. The async pass re-decorates
- // its own DOM before morphing, so without this the first paint shows bare
- // /tables that "snap" into their decorated form a tick later. Matching
- // the structure here keeps the async morph to syntax colors only.
- decorateMarkdown(block, ctx);
- target.appendChild(block);
- if (shouldRefreshMermaidViewers(block)) {
- refreshMermaidViewers();
+ const cachedBlocks = !streaming ? getCachedMarkdownBlocks(text, imageMode) : null;
+ if (cachedBlocks) {
+ let hasMermaidBlock = false;
+ for (const cachedBlock of cachedBlocks) {
+ const block = document.createElement('div');
+ block.setAttribute('data-md-block', '');
+ block.style.display = 'contents';
+ block.innerHTML = cachedBlock.html;
+ decorateMarkdown(block, ctx);
+ block.setAttribute('data-md-id', cachedBlock.id);
+ block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
+ hasMermaidBlock ||= shouldRefreshMermaidViewers(block);
+ target.appendChild(block);
+ }
+ if (hasMermaidBlock) refreshMermaidViewers();
+ } else {
+ const block = document.createElement('div');
+ block.setAttribute('data-md-block', '');
+ block.style.display = 'contents';
+ block.innerHTML = renderMarkdownSync(text, imageMode);
+ decorateMarkdown(block, ctx);
+ block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
+ target.appendChild(block);
+ if (shouldRefreshMermaidViewers(block)) refreshMermaidViewers();
}
+ } else if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(target)) {
+ // StrictMode re-runs this setup after the cleanup probe. The DOM remains,
+ // but the viewer registry does not, so recreate it without reinstalling
+ // or re-decorating ordinary blocks.
+ refreshMermaidViewers();
}
- }, [containerRef, text, imageMode, ctx, refreshMermaidViewers]);
+ }, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup();
@@ -835,9 +936,11 @@ const useMorphdomMarkdown = ({
if (!container) return;
const target = container.querySelector('[data-markdown-content]') ?? container;
let active = true;
+ const renderRevision = renderRevisionRef.current;
+ const decorationId = getMarkdownDecorationId(ctx);
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
- if (!active) return;
+ if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[];
// Reconcile per block: only re-morph blocks whose content changed, leaving
@@ -854,7 +957,28 @@ const useMorphdomMarkdown = ({
target.appendChild(el);
isNewBlock = true;
}
- if (el.getAttribute('data-md-id') === block.id) return;
+ if (el.getAttribute('data-md-id') === block.id) {
+ if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
+ const hasMermaidBlock = shouldRefreshMermaidViewers(el);
+ if (hasMermaidBlock) {
+ mermaidViewerRef.current?.cleanup();
+ mermaidViewerRef.current = null;
+ }
+ const replacement = document.createElement('div');
+ replacement.setAttribute('data-md-block', '');
+ replacement.style.display = 'contents';
+ replacement.innerHTML = block.html;
+ decorateMarkdown(replacement, ctx);
+ replacement.setAttribute('data-md-id', block.id);
+ replacement.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
+ el.replaceWith(replacement);
+ if (hasMermaidBlock || shouldRefreshMermaidViewers(replacement)) refreshMermaidViewers();
+ }
+ if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(el)) {
+ refreshMermaidViewers();
+ }
+ return;
+ }
const temp = document.createElement('div');
temp.innerHTML = block.html;
@@ -883,12 +1007,12 @@ const useMorphdomMarkdown = ({
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
});
el.setAttribute('data-md-id', block.id);
+ el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
refreshMermaidViewers();
}
});
- // Remove any trailing block elements no longer present.
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
let removedMermaidBlock = false;
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
@@ -901,13 +1025,15 @@ const useMorphdomMarkdown = ({
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
refreshMermaidViewers();
}
-
+ mountedDomRef.current = domCacheKey
+ ? { key: domCacheKey, copiedLabel: ctx.labels.copied }
+ : null;
});
return () => {
active = false;
};
- }, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
+ }, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
React.useEffect(() => {
const container = containerRef.current;
@@ -988,6 +1114,33 @@ const MarkdownRendererImpl: React.FC = ({
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
+ const { locale } = useI18n();
+ const imageMode: MarkdownImageMode = variant === 'assistant' ? 'label' : 'inline';
+ const settledPart = part
+ && (part.type === 'text' || part.type === 'reasoning')
+ && part.time?.end !== undefined
+ ? part
+ : null;
+ const runtimeKey = getRuntimeKey();
+ // Memoized on scalar identities, not the part object: sync-store reducers
+ // recreate part objects on unrelated updates, and an object-identity dep
+ // re-ran the async render pipeline for identical content.
+ const settledSessionID = settledPart?.sessionID;
+ const settledMessageID = settledPart?.messageID;
+ const settledPartID = settledPart?.id;
+ const domCacheKey = React.useMemo(() => {
+ // Streaming, unfinished, oversized, and identity-less Markdown continues
+ // through the normal rendering pipeline and never retains detached DOM.
+ if (isStreaming || !settledSessionID || !settledMessageID || !settledPartID || content.length === 0 || content.length > MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS) return null;
+ // content.length is a cheap fingerprint: an edited or reverted part that
+ // re-materializes under the same id must not restore the old DOM.
+ return {
+ scope: `${runtimeKey}\0${settledSessionID}`,
+ id: `${settledMessageID}\0${settledPartID}\0${imageMode}\0${content.length}`,
+ locale,
+ directory: effectiveDirectory,
+ };
+ }, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledSessionID, settledMessageID, settledPartID]);
// Identity for the fade-in wrapper: a new part/message restarts the animation.
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
@@ -995,9 +1148,10 @@ const MarkdownRendererImpl: React.FC = ({
containerRef,
text: content,
streaming: live,
- imageMode: variant === 'assistant' ? 'label' : 'inline',
+ imageMode,
syntaxVars,
ctx,
+ domCacheKey,
});
const markdownContent = (
diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts
index 65d5b6ed..8981ebce 100644
--- a/packages/ui/src/components/chat/markdown/decorate.ts
+++ b/packages/ui/src/components/chat/markdown/decorate.ts
@@ -43,28 +43,30 @@ export type DecorateContext = {
onPreviewLoopback?: (url: string) => void;
};
-// Reference the app's icon sprite (injected into by the shared Icon
-// component) so DOM-built controls use the same themed icons as the rest of
-// the app. Sprite symbols are registered under `#oc-`.
-const spriteIcon = (name: IconName): string =>
- ``;
-
const ICONS = {
- copy: spriteIcon('file-copy'),
- check: spriteIcon('check'),
- download: spriteIcon('download'),
- zoomIn: spriteIcon('add'),
- zoomOut: spriteIcon('subtract'),
- fit: spriteIcon('refresh'),
- textWrap: spriteIcon('text-wrap'),
- image: spriteIcon('file-image'),
-} as const;
+ copy: 'file-copy',
+ check: 'check',
+ download: 'download',
+ zoomIn: 'add',
+ zoomOut: 'subtract',
+ fit: 'refresh',
+ textWrap: 'text-wrap',
+ image: 'file-image',
+} as const satisfies Record;
const ICON_BTN_CLASS =
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--interactive-focus-ring)]';
-const setIconHtml = (el: Element, html: string): void => {
- el.innerHTML = html;
+const setIcon = (el: Element, icon: keyof typeof ICONS): void => {
+ const iconName = ICONS[icon];
+ const svg = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('class', 'remixicon size-3.5');
+ svg.setAttribute('viewBox', '0 0 24 24');
+ svg.setAttribute('aria-hidden', 'true');
+ const use = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'use');
+ use.setAttribute('href', `#oc-${iconName}`);
+ svg.appendChild(use);
+ el.replaceChildren(svg);
};
const decorateImageLabels = (root: HTMLElement): void => {
@@ -74,7 +76,7 @@ const decorateImageLabels = (root: HTMLElement): void => {
icon.className = 'inline-flex shrink-0';
icon.setAttribute('aria-hidden', 'true');
icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true');
- setIconHtml(icon, ICONS.image);
+ setIcon(icon, 'image');
label.prepend(icon);
}
};
@@ -86,7 +88,7 @@ const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string):
button.setAttribute('data-md-action', slot);
button.setAttribute('title', title);
button.setAttribute('aria-label', title);
- setIconHtml(button, ICONS[icon]);
+ setIcon(button, icon);
return button;
};
@@ -199,11 +201,11 @@ export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: bool
};
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
- setIconHtml(button, ICONS.check);
+ setIcon(button, 'check');
button.setAttribute('title', copiedTitle);
button.setAttribute('aria-label', copiedTitle);
window.setTimeout(() => {
- setIconHtml(button, ICONS[restore]);
+ setIcon(button, restore);
button.setAttribute('title', restoreTitle);
button.setAttribute('aria-label', restoreTitle);
}, 2000);
@@ -503,7 +505,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
preview.setAttribute('data-md-url', href);
preview.setAttribute('title', ctx.labels.previewTitle);
preview.setAttribute('aria-label', ctx.labels.previewLabel);
- setIconHtml(preview, ICONS.download);
+ setIcon(preview, 'download');
anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
}
}
@@ -565,7 +567,12 @@ export const attachMarkdownInteractions = (
if (action === 'copy-code') {
const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code');
const text = code ? getMarkdownCodeText(code) : '';
- if (text) void copyTextToClipboard(text).then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy));
+ if (text) {
+ actionEl.setAttribute('data-md-copy-pending', '');
+ void copyTextToClipboard(text)
+ .then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy))
+ .finally(() => actionEl.removeAttribute('data-md-copy-pending'));
+ }
return;
}
diff --git a/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.test.ts b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.test.ts
new file mode 100644
index 00000000..71a4eccf
--- /dev/null
+++ b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, test } from 'bun:test';
+import { Window } from 'happy-dom';
+
+import { DetachedMarkdownDomCache, type DetachedMarkdownDom } from './detachedMarkdownDomCache';
+
+Object.assign(globalThis, { document: new Window().document });
+
+const keyFor = ({ scope, id, locale, directory }: DetachedMarkdownDom) => ({ scope, id, locale, directory });
+
+const createEntry = (
+ document: Document,
+ sessionId: string,
+ messageId: string,
+ partId: string,
+): DetachedMarkdownDom => {
+ const fragment = document.createDocumentFragment();
+ const node = document.createElement('p');
+ node.textContent = `${messageId}:${partId}`;
+ fragment.appendChild(node);
+ return {
+ scope: `runtime:${sessionId}`,
+ id: `${messageId}:${partId}`,
+ locale: 'en',
+ directory: '/repo-a',
+ fragment,
+ };
+};
+
+describe('DetachedMarkdownDomCache', () => {
+ test('consumes the original DOM fragment once and rejects another locale', () => {
+ const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
+ const entry = createEntry(document, 'session-a', 'message-a', 'part-a');
+ const originalNode = entry.fragment.firstChild;
+
+ cache.store(entry);
+ expect(cache.take({ ...keyFor(entry), locale: 'zh' })).toBeNull();
+ cache.store(entry);
+ const restored = cache.take(keyFor(entry));
+ expect(restored?.firstChild).toBe(originalNode);
+ expect(cache.take(keyFor(entry))).toBeNull();
+ });
+
+ test('bounds entries per session and evicts the least recently used session', () => {
+ const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
+ cache.store(createEntry(document, 'session-a', 'message-1', 'part'));
+ cache.store(createEntry(document, 'session-a', 'message-2', 'part'));
+ cache.store(createEntry(document, 'session-a', 'message-3', 'part'));
+ cache.store(createEntry(document, 'session-b', 'message-4', 'part'));
+ cache.store(createEntry(document, 'session-c', 'message-5', 'part'));
+ expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
+ expect(cache.take({
+ scope: 'runtime:session-a',
+ id: 'message-2:part',
+ locale: 'en',
+ directory: '/repo-a',
+ })).toBeNull();
+ expect(cache.take({
+ scope: 'runtime:session-c',
+ id: 'message-5:part',
+ locale: 'en',
+ directory: '/repo-a',
+ })).not.toBeNull();
+ });
+
+ test('isolates identities by runtime and replaces an identity without growing stats', () => {
+ const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
+ const first = createEntry(document, 'session', 'message', 'part');
+ const replacement = createEntry(document, 'session', 'message', 'part');
+ const replacementNode = replacement.fragment.firstChild;
+ const otherRuntime = createEntry(document, 'other-runtime-session', 'message', 'part');
+
+ cache.store(first);
+ cache.store(replacement);
+ cache.store(otherRuntime);
+
+ expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
+ expect(cache.take(keyFor(otherRuntime))).not.toBeNull();
+ expect(cache.take(keyFor(replacement))?.firstChild).toBe(replacementNode);
+ });
+
+ test('does not restore file-link DOM under another directory', () => {
+ const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
+ const entry = createEntry(document, 'session', 'message', 'part');
+
+ cache.store(entry);
+
+ expect(cache.take({ ...keyFor(entry), directory: '/repo-b' })).toBeNull();
+ });
+
+ test('refreshes session LRU and clears all entries', () => {
+ const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
+ const sessionA = createEntry(document, 'session-a', 'message-a', 'part');
+ const sessionB = createEntry(document, 'session-b', 'message-b', 'part');
+ const sessionC = createEntry(document, 'session-c', 'message-c', 'part');
+ cache.store(sessionA);
+ cache.store(sessionB);
+ cache.store(sessionA);
+ cache.store(sessionC);
+ expect(cache.take(keyFor(sessionB))).toBeNull();
+
+ cache.clear();
+ expect(cache.stats()).toEqual({ sessions: 0, entries: 0 });
+ });
+});
diff --git a/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts
new file mode 100644
index 00000000..faa3f37f
--- /dev/null
+++ b/packages/ui/src/components/chat/markdown/detachedMarkdownDomCache.ts
@@ -0,0 +1,124 @@
+export type DetachedMarkdownDomKey = {
+ scope: string;
+ id: string;
+ locale: string;
+ directory: string;
+};
+
+export type DetachedMarkdownDom = DetachedMarkdownDomKey & {
+ // The fragment owns the original nodes. take() consumes it once by moving
+ // those nodes back into a renderer; nothing is cloned or serialized.
+ fragment: DocumentFragment;
+};
+
+export type DetachedMarkdownDomCacheStats = {
+ sessions: number;
+ entries: number;
+};
+
+// Holds detached, fully decorated Markdown DOM. The cache is intentionally
+// small: it accelerates recent-session and reverse-scroll remounts without
+// retaining whole session trees or depending on browser-specific byte guesses.
+type DetachedMarkdownDomCacheLimits = {
+ maxSessions: number;
+ maxEntriesPerSession: number;
+};
+
+type SessionCache = Map;
+
+const DEFAULT_LIMITS: DetachedMarkdownDomCacheLimits = {
+ // Eight buckets cover a broader recent-session working set without
+ // coupling eviction to React commit or microtask timing.
+ maxSessions: 8,
+ maxEntriesPerSession: 4,
+};
+
+export class DetachedMarkdownDomCache {
+ private readonly maxSessions: number;
+ private readonly maxEntriesPerSession: number;
+ private readonly sessions = new Map();
+
+ constructor(limits: DetachedMarkdownDomCacheLimits = DEFAULT_LIMITS) {
+ this.maxSessions = Math.max(1, limits.maxSessions);
+ this.maxEntriesPerSession = Math.max(1, limits.maxEntriesPerSession);
+ }
+
+ store(entry: DetachedMarkdownDom): void {
+ const sessionKey = entry.scope;
+ const entryKey = entry.id;
+
+ let session = this.sessions.get(sessionKey);
+ if (session === undefined) {
+ session = new Map();
+ this.sessions.set(sessionKey, session);
+ } else {
+ this.refreshSession(sessionKey, session);
+ }
+
+ // A part has one DOM version inside its authoritative runtime/session.
+ session.delete(entryKey);
+ session.set(entryKey, entry);
+
+ while (session.size > this.maxEntriesPerSession) {
+ this.removeOldestEntry(session);
+ }
+ while (this.sessions.size > this.maxSessions) {
+ this.removeOldestSession();
+ }
+ }
+
+ take(key: DetachedMarkdownDomKey): DocumentFragment | null {
+ const sessionKey = key.scope;
+ const session = this.sessions.get(sessionKey);
+ if (!session) return null;
+ const entryKey = key.id;
+
+ this.refreshSession(sessionKey, session);
+ const entry = session.get(entryKey);
+ if (entry === undefined) return null;
+
+ // A mismatched probe (different locale or directory for the same part)
+ // must not destroy the entry — the matching renderer may still come for
+ // it. Only a real hit transfers ownership out of the cache.
+ if (entry.locale !== key.locale || entry.directory !== key.directory) return null;
+
+ // A fragment is a move-only resource; taking it removes cache ownership.
+ session.delete(entryKey);
+ if (session.size === 0) this.sessions.delete(sessionKey);
+ return entry.fragment;
+ }
+
+ clear(): void {
+ this.sessions.clear();
+ }
+
+ stats(): DetachedMarkdownDomCacheStats {
+ let entries = 0;
+ for (const session of this.sessions.values()) {
+ entries += session.size;
+ }
+ return {
+ sessions: this.sessions.size,
+ entries,
+ };
+ }
+
+ private refreshSession(sessionKey: string, session: SessionCache): void {
+ this.sessions.delete(sessionKey);
+ this.sessions.set(sessionKey, session);
+ }
+
+ private removeOldestEntry(session: SessionCache): void {
+ const oldestKey = session.keys().next().value;
+ if (oldestKey === undefined) return;
+ session.delete(oldestKey);
+ }
+
+ private removeOldestSession(): void {
+ const oldestKey = this.sessions.keys().next().value;
+ if (oldestKey === undefined) return;
+ this.sessions.delete(oldestKey);
+ }
+}
+
+export const detachedMarkdownDomCache = new DetachedMarkdownDomCache();
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts
index 9153250d..e1321496 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.test.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts
@@ -49,7 +49,10 @@ import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from '
const {
__markdownImageCandidateCacheForTests,
extractMarkdownImageCandidates,
+ getCachedMarkdownBlocks,
+ renderMarkdownBlocks,
renderMarkdownSync,
+ resetMarkdownHtmlCacheForTests,
} = await import('./markdownCore');
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
@@ -90,6 +93,47 @@ describe('markdown sanitization', () => {
});
+describe('Markdown block cache reads', () => {
+ test('returns all settled blocks synchronously after a full cache hit', async () => {
+ resetMarkdownHtmlCacheForTests();
+ const text = '**cached** settled markdown';
+
+ expect(getCachedMarkdownBlocks(text)).toBeNull();
+ const rendered = await renderMarkdownBlocks(text, false);
+
+ expect(getCachedMarkdownBlocks(text)).toEqual(rendered);
+ });
+
+ test('returns null for a cold or partial settled miss', async () => {
+ resetMarkdownHtmlCacheForTests();
+ const first = 'first settled block';
+ const changed = 'first settled block\n\nsecond settled block';
+
+ await renderMarkdownBlocks(first, false);
+
+ expect(getCachedMarkdownBlocks(changed)).toBeNull();
+ });
+
+ test('keeps image mode identity out of the settled full hit', async () => {
+ resetMarkdownHtmlCacheForTests();
+ const text = '';
+
+ await renderMarkdownBlocks(text, false, 'inline');
+
+ expect(getCachedMarkdownBlocks(text, 'label')).toBeNull();
+ expect(getCachedMarkdownBlocks(text, 'inline')).not.toBeNull();
+ });
+
+ test('does not treat streaming live-cache entries as settled full hits', async () => {
+ resetMarkdownHtmlCacheForTests();
+ const text = 'streaming markdown';
+
+ await renderMarkdownBlocks(text, true);
+
+ expect(getCachedMarkdownBlocks(text)).toBeNull();
+ });
+});
+
describe('Markdown images', () => {
test('renders assistant images as icon-ready text without loading the source', () => {
const html = renderMarkdownSync([
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts
index 5d43f798..31c9af15 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.ts
@@ -557,10 +557,30 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
live: liveBlockCache.size,
});
-const parseBlock = async (
- block: MarkdownBlock,
- imageMode: MarkdownImageMode,
-): Promise => {
+/**
+ * Read a settled render synchronously when every block is already in the full
+ * cache. Cache reads retain the existing LRU `get` semantics and do not insert
+ * or expand either cache.
+ */
+export const getCachedMarkdownBlocks = (
+ text: string,
+ imageMode: MarkdownImageMode = 'inline',
+): RenderedBlock[] | null => {
+ if (!text) return [];
+
+ const blocks = streamBlocks(text, false);
+ const rendered: RenderedBlock[] = [];
+ for (const block of blocks) {
+ const contentHash = contentFingerprint(block.raw);
+ const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
+ const html = fullBlockCache.get(id);
+ if (html === undefined) return null;
+ rendered.push({ id, html });
+ }
+ return rendered;
+};
+
+const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise => {
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = await Promise.resolve(parser.parse(block.src));
const withMath = renderMathExpressions(parsed);
diff --git a/packages/ui/src/components/chat/markdown/mermaidViewer.ts b/packages/ui/src/components/chat/markdown/mermaidViewer.ts
index d4532546..2dd89307 100644
--- a/packages/ui/src/components/chat/markdown/mermaidViewer.ts
+++ b/packages/ui/src/components/chat/markdown/mermaidViewer.ts
@@ -22,6 +22,18 @@ type MermaidViewerController = {
cleanup: () => void;
};
+type InternalMermaidViewerController = MermaidViewerController & {
+ viewport: HTMLElement;
+ fitToViewport: (viewport: MermaidViewport) => void;
+};
+
+type MermaidViewerRegistryState = {
+ container: HTMLElement;
+ controllers: Map;
+ signatures: Map;
+ disposed: boolean;
+};
+
type MermaidSvgBoundsSource = {
viewBox?: string | null;
width?: string | number | null;
@@ -36,13 +48,8 @@ type MermaidViewerSignatureSource = MermaidSvgBoundsSource & {
const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0;
const parseSvgNumber = (value: string | number | null | undefined): number | null => {
- if (typeof value === 'number') {
- return isPositiveFinite(value) ? value : null;
- }
- if (typeof value !== 'string') {
- return null;
- }
- const match = value.trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
+ if (value === null || value === undefined) return null;
+ const match = String(value).trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
if (!match) {
return null;
}
@@ -233,10 +240,14 @@ export const zoomMermaidViewBoxAtPoint = ({
};
const controllerByBlock = new WeakMap();
-
-export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => (
- block instanceof HTMLElement ? controllerByBlock.get(block) ?? null : null
-);
+const controllerByViewport = new WeakMap();
+const activeControllers = new Set();
+const pendingRegistries = new Set();
+// Controllers are non-essential for the static SVG. Initialize all renderers
+// from one post-presentation batch so geometry reads precede every SVG write.
+let sharedResizeObserver: ResizeObserver | null = null;
+let pendingRegistryFlushFrame: number | null = null;
+let pendingResizeFrame: number | null = null;
const getSvgViewport = (block: HTMLElement): HTMLElement | null => (
block.querySelector('[data-markdown="mermaid-viewport"]')
@@ -272,7 +283,62 @@ const isPanExcludedTarget = (target: EventTarget | null): boolean => (
target instanceof Element && Boolean(target.closest('button, a, [role="button"]'))
);
-const createMermaidViewerController = (block: HTMLElement): MermaidViewerController | null => {
+const fitControllers = (controllers: readonly InternalMermaidViewerController[]): void => {
+ const viewportSizes = controllers.map((controller) => getViewportSize(controller.viewport));
+ controllers.forEach((controller, index) => {
+ const viewport = viewportSizes[index];
+ if (viewport) controller.fitToViewport(viewport);
+ });
+};
+
+const scheduleActiveControllerFit = (): void => {
+ if (pendingResizeFrame !== null || activeControllers.size === 0) return;
+ pendingResizeFrame = window.requestAnimationFrame(() => {
+ pendingResizeFrame = null;
+ fitControllers(Array.from(activeControllers));
+ });
+};
+
+const ensureSharedResizeObserver = (): ResizeObserver | null => {
+ if (sharedResizeObserver) return sharedResizeObserver;
+ const ResizeObserverConstructor = globalThis.ResizeObserver;
+ if (!ResizeObserverConstructor) return null;
+ sharedResizeObserver = new ResizeObserverConstructor((entries) => {
+ for (const entry of entries) {
+ if (!(entry.target instanceof HTMLElement)) continue;
+ controllerByViewport.get(entry.target)?.fitToViewport({
+ width: entry.contentRect.width,
+ height: entry.contentRect.height,
+ });
+ }
+ });
+ return sharedResizeObserver;
+};
+
+const registerController = (controller: InternalMermaidViewerController): void => {
+ if (activeControllers.has(controller)) return;
+ const wasEmpty = activeControllers.size === 0;
+ activeControllers.add(controller);
+ controllerByViewport.set(controller.viewport, controller);
+ ensureSharedResizeObserver()?.observe(controller.viewport);
+ if (wasEmpty) window.addEventListener('resize', scheduleActiveControllerFit);
+};
+
+const unregisterController = (controller: InternalMermaidViewerController): void => {
+ if (!activeControllers.delete(controller)) return;
+ sharedResizeObserver?.unobserve(controller.viewport);
+ controllerByViewport.delete(controller.viewport);
+ if (activeControllers.size > 0) return;
+ sharedResizeObserver?.disconnect();
+ sharedResizeObserver = null;
+ window.removeEventListener('resize', scheduleActiveControllerFit);
+ if (pendingResizeFrame !== null) {
+ window.cancelAnimationFrame(pendingResizeFrame);
+ pendingResizeFrame = null;
+ }
+};
+
+const createMermaidViewerController = (block: HTMLElement): InternalMermaidViewerController | null => {
const viewport = getSvgViewport(block);
const svg = block.querySelector('[data-markdown="mermaid"] svg');
if (!viewport || !svg) {
@@ -301,8 +367,12 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
svg.removeAttribute('height');
};
+ const fitToViewport = (size: MermaidViewport): void => {
+ applyViewBox(fitMermaidViewBox(contentBox, size));
+ };
+
const fit = (): void => {
- applyViewBox(fitMermaidViewBox(contentBox, getViewportSize(viewport)));
+ fitToViewport(getViewportSize(viewport));
};
const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => {
@@ -390,32 +460,24 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
}
};
- const onResize = (): void => {
- fit();
- };
-
viewport.addEventListener('wheel', onWheel, { passive: false });
viewport.addEventListener('pointerdown', onPointerDown);
viewport.addEventListener('pointermove', onPointerMove);
viewport.addEventListener('pointerup', stopPan);
viewport.addEventListener('pointercancel', stopPan);
- window.addEventListener('resize', onResize);
- const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
- observer?.observe(viewport);
- fit();
-
- return {
+ const controller: InternalMermaidViewerController = {
+ viewport,
zoomIn,
zoomOut,
fit,
+ fitToViewport,
cleanup: () => {
+ unregisterController(controller);
viewport.removeEventListener('wheel', onWheel);
viewport.removeEventListener('pointerdown', onPointerDown);
viewport.removeEventListener('pointermove', onPointerMove);
viewport.removeEventListener('pointerup', stopPan);
viewport.removeEventListener('pointercancel', stopPan);
- window.removeEventListener('resize', onResize);
- observer?.disconnect();
if (clearClickSuppressionTimer !== null) {
window.clearTimeout(clearClickSuppressionTimer);
}
@@ -424,42 +486,97 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
controllerByBlock.delete(block);
},
};
+ return controller;
};
-export const createMermaidViewerRegistry = (container: HTMLElement): { refresh: () => void; cleanup: () => void } => {
- const controllers = new Map();
- const signatures = new Map();
-
- const refresh = (): void => {
- for (const [block, controller] of Array.from(controllers.entries())) {
- const signature = getBlockViewerSignature(block);
- if (!container.contains(block) || signature !== signatures.get(block)) {
- controller.cleanup();
- controllers.delete(block);
- signatures.delete(block);
- }
+const removeStaleControllers = (state: MermaidViewerRegistryState): void => {
+ for (const [block, controller] of state.controllers) {
+ const signature = getBlockViewerSignature(block);
+ if (!state.container.contains(block) || signature !== state.signatures.get(block)) {
+ controller.cleanup();
+ state.controllers.delete(block);
+ state.signatures.delete(block);
}
+ }
+};
- for (const block of Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR))) {
- if (controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) {
- continue;
- }
- const controller = createMermaidViewerController(block);
- if (!controller) {
- continue;
- }
- controllers.set(block, controller);
- signatures.set(block, getBlockViewerSignature(block));
- controllerByBlock.set(block, controller);
- }
+const collectNewControllers = (state: MermaidViewerRegistryState): InternalMermaidViewerController[] => {
+ if (state.disposed) return [];
+ const newControllers: InternalMermaidViewerController[] = [];
+ for (const block of Array.from(state.container.querySelectorAll(MERMAID_BLOCK_SELECTOR))) {
+ if (state.controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) continue;
+ const controller = createMermaidViewerController(block);
+ if (!controller) continue;
+ state.controllers.set(block, controller);
+ state.signatures.set(block, getBlockViewerSignature(block));
+ controllerByBlock.set(block, controller);
+ newControllers.push(controller);
+ }
+ return newControllers;
+};
+
+const flushPendingRegistries = (): void => {
+ const registries = Array.from(pendingRegistries);
+ pendingRegistries.clear();
+ const newControllers: InternalMermaidViewerController[] = [];
+ for (const state of registries) {
+ if (state.disposed) continue;
+ removeStaleControllers(state);
+ newControllers.push(...collectNewControllers(state));
+ }
+ fitControllers(newControllers);
+ for (const controller of newControllers) registerController(controller);
+};
+
+const schedulePendingRegistryFlush = (): void => {
+ if (pendingRegistryFlushFrame !== null) return;
+ pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
+ pendingRegistryFlushFrame = null;
+ pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
+ pendingRegistryFlushFrame = null;
+ flushPendingRegistries();
+ });
+ });
+};
+
+const scheduleRegistryRefresh = (state: MermaidViewerRegistryState): void => {
+ if (state.disposed) return;
+ removeStaleControllers(state);
+ pendingRegistries.add(state);
+ schedulePendingRegistryFlush();
+};
+
+export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => {
+ if (!(block instanceof HTMLElement)) return null;
+ const existing = controllerByBlock.get(block);
+ if (existing) return existing;
+
+ for (const state of pendingRegistries) {
+ if (!state.container.contains(block)) continue;
+ flushPendingRegistries();
+ return controllerByBlock.get(block) ?? null;
+ }
+ return null;
+};
+
+export const createMermaidViewerRegistry = (container: HTMLElement) => {
+ const state: MermaidViewerRegistryState = {
+ container,
+ controllers: new Map(),
+ signatures: new Map(),
+ disposed: false,
};
+ const refresh = (): void => scheduleRegistryRefresh(state);
+
const cleanup = (): void => {
- for (const controller of controllers.values()) {
+ state.disposed = true;
+ pendingRegistries.delete(state);
+ for (const controller of state.controllers.values()) {
controller.cleanup();
}
- controllers.clear();
- signatures.clear();
+ state.controllers.clear();
+ state.signatures.clear();
};
refresh();
diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx
index 0f30dad5..7e392460 100644
--- a/packages/ui/src/components/layout/MainLayout.tsx
+++ b/packages/ui/src/components/layout/MainLayout.tsx
@@ -23,6 +23,7 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling';
import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
+import { useSessionListSync } from '@/components/session/sidebar/list/useSessionListSync';
import { ChatView } from '@/components/views/ChatView';
@@ -35,6 +36,7 @@ const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/Se
* crossing the threshold reloads into it (see watchHostedSurfaceViewport).
*/
export const MainLayout: React.FC = () => {
+ useSessionListSync({ isVSCode: false });
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const setIsMobile = useUIStore((state) => state.setIsMobile);
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx
index 0b81613c..59dec89c 100644
--- a/packages/ui/src/components/layout/VSCodeLayout.tsx
+++ b/packages/ui/src/components/layout/VSCodeLayout.tsx
@@ -41,6 +41,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
import type { UsageWindow } from '@/types';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
+import { useSessionListSync } from '@/components/session/sidebar/list/useSessionListSync';
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
@@ -526,8 +527,11 @@ export const VSCodeLayout: React.FC = () => {
}
}, [usesExpandedLayout, currentView, viewMode]);
+ useSessionListSync({ isVSCode: true });
+
return (
-
+ <>
+
{viewMode === 'editor' ? (
// Editor mode: just chat, no sidebar
@@ -639,7 +643,8 @@ export const VSCodeLayout: React.FC = () => {
>
)}
-
+
+ >
);
};
diff --git a/packages/ui/src/components/model-picker/ModelPickerList.tsx b/packages/ui/src/components/model-picker/ModelPickerList.tsx
index 4a277737..6d09dfc1 100644
--- a/packages/ui/src/components/model-picker/ModelPickerList.tsx
+++ b/packages/ui/src/components/model-picker/ModelPickerList.tsx
@@ -545,8 +545,9 @@ export const ModelPickerList: React.FC
= ({
? Math.min(STICKY_FADE_MIN_SIZE + scroller.scrollTop, STICKY_FADE_MAX_SIZE)
: 0;
stickyFadeSizeRef.current = fadeSize;
- scroller.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`);
- scroller.style.setProperty(
+ const fadeRoot = scroller.closest('.oc-sticky-fade-root');
+ fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${fadeSize}px`);
+ fadeRoot?.style.setProperty(
'--scroll-shadow-top-clear-size',
`${Math.min(Math.max(fadeSize - 8, 0), STICKY_FADE_CLEAR_MAX_SIZE)}px`,
);
@@ -876,24 +877,23 @@ export const ModelPickerList: React.FC = ({
-
syncStickyFade(event.currentTarget) : undefined}
- >
-
+
syncStickyFade(event.currentTarget) : undefined}
+ >
+
{includeNotSelected ? (
<>
))
)}
-
-
- {stickyHeaders && leadingSectionKey ? (
-
- {renderSectionIdentity(leadingSectionKey)}
-
- ) : null}
+
+
+ {stickyHeaders && leadingSectionKey ? (
+
+ {renderSectionIdentity(leadingSectionKey)}
+
+ ) : null}
diff --git a/packages/ui/src/components/multirun/ModelMultiSelect.tsx b/packages/ui/src/components/multirun/ModelMultiSelect.tsx
index dcc62dfb..faeb131c 100644
--- a/packages/ui/src/components/multirun/ModelMultiSelect.tsx
+++ b/packages/ui/src/components/multirun/ModelMultiSelect.tsx
@@ -152,7 +152,7 @@ export const ModelMultiSelect: React.FC
= ({
// Find the nearest dialog or overflow ancestor to constrain within
let container: HTMLElement | null = triggerRef.current.parentElement;
while (container) {
- if (container.getAttribute('role') === 'dialog' || container.hasAttribute('data-scroll-shadow')) {
+ if (container.matches('[role="dialog"], [data-scroll-shadow-scroller]')) {
break;
}
const style = getComputedStyle(container);
diff --git a/packages/ui/src/components/session/SessionFolderItem.tsx b/packages/ui/src/components/session/SessionFolderItem.tsx
index 21f5078c..0d0bcebd 100644
--- a/packages/ui/src/components/session/SessionFolderItem.tsx
+++ b/packages/ui/src/components/session/SessionFolderItem.tsx
@@ -4,9 +4,8 @@ import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
-import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
-import { CollapsedActivityIndicator } from './sidebar/collapsedActivityIndicator';
-import type { CollapsedActivityState } from './sidebar/collapsedActivityState';
+import { CollapsedActivityIndicator } from './sidebar/sessions/collapsedActivityIndicator';
+import type { CollapsedActivityState } from './sidebar/sessions/collapsedActivityState';
interface SessionFolderItemProps {
folder: SessionFolder;
@@ -24,23 +23,7 @@ interface SessionFolderItemProps {
onToggle: () => void;
onRename: (name: string) => void;
onDelete: () => void;
- renderSessionNode: (
- node: TSessionNode,
- depth?: number,
- groupDir?: string | null,
- projectId?: string | null,
- archivedBucket?: boolean,
- secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
- renderContext?: 'project' | 'recent',
- renderExtras?: SessionNodeChildRenderExtras,
- ) => React.ReactNode;
- /**
- * Returns the precomputed per-row render extras for a given node. The
- * group precomputes subtree-contains lookups once, then resolves a
- * per-node structure key here so SessionNodeItem's React.memo comparator
- * can answer with a single string compare instead of a recursive walk.
- */
- getRenderExtras?: (node: TSessionNode) => SessionNodeRenderExtras | undefined;
+ children?: React.ReactNode;
groupDirectory?: string | null;
projectId?: string | null;
mobileVariant?: boolean;
@@ -74,10 +57,7 @@ const SessionFolderItemBase = ({
onToggle,
onRename,
onDelete,
- renderSessionNode,
- getRenderExtras,
- groupDirectory,
- projectId,
+ children,
mobileVariant = false,
alwaysShowActions = mobileVariant,
isRenaming = false,
@@ -97,6 +77,7 @@ const SessionFolderItemBase = ({
const [localDraft, setLocalDraft] = React.useState('');
const inputRef = React.useRef(null);
+
const renaming = isRenaming || localRenaming;
const draft = isRenaming ? renameDraft : localDraft;
@@ -167,6 +148,7 @@ const SessionFolderItemBase = ({
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
)}
onClick={renaming ? undefined : (event) => {
+ // SAFETY: this handler is attached to the div rendered directly above.
(event.currentTarget as HTMLElement).blur();
onToggle();
}}
@@ -346,11 +328,7 @@ const SessionFolderItemBase = ({
{subFolderItems}
{/* Then sessions */}
{sessions.length > 0 ? (
-
- {sessions.map((node) =>
- renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)),
- )}
-
+ children
) : !subFolderItems ? (
{t('sessions.sidebar.folderItem.emptyFolder')}
@@ -362,6 +340,9 @@ const SessionFolderItemBase =
({
);
};
-export const SessionFolderItem = React.memo(SessionFolderItemBase) as (
+export const SessionFolderItem = (
+ /* SAFETY: React.memo preserves the generic component's props and return type. */
+ React.memo(SessionFolderItemBase) as (
props: SessionFolderItemProps,
-) => React.ReactElement;
+ ) => React.ReactElement
+);
diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx
index b59178da..9a7361bd 100644
--- a/packages/ui/src/components/session/SessionSidebar.tsx
+++ b/packages/ui/src/components/session/SessionSidebar.tsx
@@ -1,52 +1,28 @@
import React from 'react';
-import { getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath } from '@/lib/chatDirectories';
-import { isBtwSession } from '@/lib/sessionBtwMetadata';
-import { mergeSidebarSessionSources } from './sidebar/sidebarSessionSources';
-import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
-import { isDesktopShell } from '@/lib/desktop';
+import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
-import { formatDirectoryName, cn } from '@/lib/utils';
+import { cn } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
-import { useChildStoreManager } from '@/sync/sync-context';
-import { getAllSyncSessionMap } from '@/sync/sync-refs';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
-import { useSync } from '@/sync/use-sync';
-import { SessionPrefetchEffect } from './sidebar/hooks/useSessionPrefetch';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore';
-import { isVSCodeRuntime } from '@/lib/desktop';
import { TooltipProvider } from '@/components/ui/tooltip';
import { NewWorktreeDialog } from './NewWorktreeDialog';
-import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
-import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders';
-import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
-import { useSessionSidebarSections } from './sidebar/hooks/useSessionSidebarSections';
-import { ProjectSessionSelectionEffect } from './sidebar/hooks/useProjectSessionSelection';
-import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping';
-import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects';
-import { useSessionActions } from './sidebar/hooks/useSessionActions';
-import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence';
-import { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus';
-import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
-import { useAuthoritativeSessionCleanup } from './sidebar/hooks/useAuthoritativeSessionCleanup';
-import { createSessionOwnershipIndex } from './sidebar/sessionOwnership';
-import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
+import { useSessionSearchEffects } from './sidebar/shell/useSessionSearchEffects';
+import { useSessionProjectViewState } from './sidebar/projects/useSessionProjectViewState';
+import { useProjectRepoStatus } from './sidebar/projects/useProjectRepoStatus';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
-import { SessionGroupSection } from './sidebar/SessionGroupSection';
-import { SidebarHeader } from './sidebar/SidebarHeader';
-import { SidebarNav } from './sidebar/SidebarNav';
-import { SidebarActivitySections, type ActivityItem } from './sidebar/SidebarActivitySections';
-import { SidebarFooter } from './sidebar/SidebarFooter';
-import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
-import { SessionNodeItem } from './sidebar/SessionNodeItem';
-import type { SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
+import { SidebarHeader } from './sidebar/shell/SidebarHeader';
+import { SidebarNav } from './sidebar/shell/SidebarNav';
+import { SidebarFooter } from './sidebar/shell/SidebarFooter';
+import { SessionProjectCollection } from './sidebar/list/SessionProjectCollection';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useShallow } from 'zustand/react/shallow';
import {
@@ -55,110 +31,19 @@ import {
worktreeMapsEqual,
} from '@/lib/worktrees/worktreeManager';
import { checkIsGitRepository } from '@/lib/gitApi';
-import type { WorktreeMetadata } from '@/types/worktree';
-import type { SortableDragHandleProps } from './sidebar/sortableItems';
-import {
- BulkSessionDeleteConfirmDialog,
- FolderDeleteConfirmDialog,
- SessionDeleteConfirmDialog,
- type BulkDeleteSessionsConfirmState,
- type DeleteFolderConfirmState,
- type DeleteSessionConfirmState,
-} from './sidebar/ConfirmDialogs';
-import { BulkActionBar } from './sidebar/BulkActionBar';
-import { useSidebarBulkActions } from './sidebar/hooks/useSidebarBulkActions';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
-import { type SessionGroup, type SessionNode } from './sidebar/types';
-import {
- deriveRecentSessions,
-} from './sidebar/activitySections';
-import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
-import {
- formatProjectLabel,
- normalizePath,
- selectExpandedParentKeysForContext,
- toggleExpandedParentKey,
-} from './sidebar/utils';
-import {
- compareSessionsByLifecycleOrder,
- EMPTY_SESSION_ORDER_RANKS,
- orderSessionsByLifecycleScopes,
- useSessionOrderingStore,
-} from '@/sync/session-ordering';
-import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
-import {
- refreshGlobalSessions,
- refreshGlobalSessionsForDirectories,
- getSessionStructuralSignature,
- resolveGlobalSessionDirectory,
- useGlobalSessionsStore,
-} from '@/stores/useGlobalSessionsStore';
-import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
-import { useNotificationStore } from '@/sync/notification-store';
-import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
-import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
-import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
-import { buildSessionBootstrapDemands } from './sidebar/sessionBootstrapDemands';
-import { recordWorktreesSeen } from './sidebar/worktreeFirstSeen';
+import { normalizePath } from './sidebar/utils';
+import { recordWorktreesSeen } from './sidebar/projects/worktreeFirstSeen';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug';
import { runBackgroundNetworkTask } from '@/lib/background-network';
-import { isCapacitorApp } from '@/lib/platform';
+import { buildKnownSessionDirectories } from './sidebar/list/sessionListDirectories';
+import { z } from 'zod';
+import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
-const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
-const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
-const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
-// v3 holds composite "${renderContext}:${active|archived}:${sessionId}"
-// entries so the same session in different render contexts (e.g. "Recent"
-// and a project's root) has independent expand state. Older expansion state
-// mixed contexts and is intentionally not migrated.
-const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v3';
-
-const buildKnownSessionDirectories = (
- projects: Array<{ path: string }>,
- availableWorktreesByProject: Map,
- options?: { includeWorktrees?: boolean },
-): Set => {
- const directories = new Set();
- for (const project of projects) {
- const normalized = normalizePath(project.path)?.toLowerCase();
- if (normalized) directories.add(normalized);
- }
- if (options?.includeWorktrees === false) {
- return directories;
- }
- for (const worktrees of availableWorktreesByProject.values()) {
- for (const worktree of worktrees) {
- const normalized = normalizePath(worktree.path)?.toLowerCase();
- if (normalized) directories.add(normalized);
- }
- }
- return directories;
-};
-
-const isKnownActiveSessionDirectory = (
- session: Session,
- knownDirectories: Set,
- options?: { allowUnknownDirectory?: boolean; allowEmptyDirectorySet?: boolean },
-): boolean => {
- if (session.time?.archived) return true;
- const directory = normalizePath(resolveGlobalSessionDirectory(session))?.toLowerCase();
- if (!directory) return options?.allowUnknownDirectory ?? true;
- if (knownDirectories.size === 0) return options?.allowEmptyDirectorySet ?? true;
- return knownDirectories.has(directory);
-};
-
-const SIDEBAR_PR_NO_PR_RETRY_MS = 5 * 60_000;
-
-const EMPTY_SUBTREE_SET: Set = new Set();
const EMPTY_STRING_ARRAY: string[] = [];
-
-const useStableRenderCallback = (handler: (...args: Args) => Return): ((...args: Args) => Return) => {
- const handlerRef = React.useRef(handler);
- handlerRef.current = handler;
- return React.useCallback((...args: Args) => handlerRef.current(...args), []);
-};
+const activeSessionByProjectSchema = z.record(z.string(), z.string().min(1).catch(''));
interface SessionSidebarProps {
isVisible?: boolean;
@@ -169,106 +54,6 @@ interface SessionSidebarProps {
showOnlyMainWorkspace?: boolean;
}
-const SidebarBootstrapDemandEffect: React.FC<{
- owner: string;
- childStores: ReturnType;
- projectSections: Parameters[0]['projectSections'];
- activeProjectId: string | null;
- collapsedProjects: ReadonlySet;
- collapsedGroups: ReadonlySet;
- currentDirectory: string | null;
-}> = ({
- owner,
- childStores,
- projectSections,
- activeProjectId,
- collapsedProjects,
- collapsedGroups,
- currentDirectory,
-}) => {
- const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
-
- React.useEffect(() => {
- childStores.setBootstrapDemand(owner, buildSessionBootstrapDemands({
- projectSections,
- activeProjectId,
- collapsedProjects,
- collapsedGroups,
- currentDirectory,
- currentSessionDirectory,
- }));
- }, [
- activeProjectId,
- childStores,
- collapsedGroups,
- collapsedProjects,
- currentDirectory,
- currentSessionDirectory,
- owner,
- projectSections,
- ]);
-
- React.useEffect(
- () => () => childStores.clearBootstrapDemand(owner),
- [childStores, owner],
- );
-
- return null;
-};
-
-// Aggregated activity/attention dot for a collapsed project header. Only
-// mounted while the project is collapsed, so the per-status-event scans stay
-// rare and bounded by the project's directory count.
-const ProjectAggregateStatusIndicator: React.FC<{ directories: Array }> = ({ directories }) => {
- const { t } = useI18n();
- const directorySet = React.useMemo(() => {
- const set = new Set();
- directories.forEach((directory) => {
- const normalized = normalizePath(directory)?.toLowerCase();
- if (normalized) set.add(normalized);
- });
- return set;
- }, [directories]);
- const hasBusySession = useGlobalSessionStatusStore(React.useCallback((state) => {
- for (const entry of state.statusById.values()) {
- if (entry.status.type !== 'busy' && entry.status.type !== 'retry') continue;
- const directory = normalizePath(entry.directory)?.toLowerCase();
- if (directory && directorySet.has(directory)) return true;
- }
- return false;
- }, [directorySet]));
- const hasUnseenNotification = useNotificationStore(React.useCallback((state) => {
- for (const [directory, count] of Object.entries(state.index.project.unseenCount)) {
- if (!count) continue;
- const normalized = normalizePath(directory)?.toLowerCase();
- if (normalized && directorySet.has(normalized)) return true;
- }
- return false;
- }, [directorySet]));
-
- // Aggregate header: dot only. A collapsed project can hold several running
- // turns, so a single elapsed counter would have nothing to count.
- if (hasBusySession) {
- return (
-
- );
- }
- if (hasUnseenNotification) {
- return (
-
- );
- }
- return null;
-};
-
const SessionSidebarComponent: React.FC = ({
isVisible = true,
mobileVariant = false,
@@ -286,91 +71,29 @@ const SessionSidebarComponent: React.FC = ({
const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
const sessionSearchContainerRef = React.useRef(null);
const sessionSearchInputRef = React.useRef(null);
- const [editingId, setEditingId] = React.useState(null);
- const [editTitle, setEditTitle] = React.useState('');
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState(null);
- const [expandedParents, setExpandedParents] = React.useState>(new Set());
const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []);
- const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set());
-
const [projectRepoStatus, setProjectRepoStatus] = React.useState
) : (
- visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
- subtreeContainsEditing,
- menuOpenSessionId,
- nodeStructureKey: resolveNodeStructureKey(node),
- childRenderExtrasFor,
- }))
+ visibleSessions.map(renderSessionNode)
)}
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
// pl-[26px] lines the text up with the worktree sub-header label
@@ -1086,15 +1084,24 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
void compactBodyPadding;
// Folder nesting is legacy-only: existing sub-folders keep working (path
// labels), but the UI no longer offers creating new ones.
- void createFolderAndStartRename;
const groupBodyPaddingClass = 'pb-2';
+ const folderDeleteDialog = {
+ const value = deleteFolderConfirm;
+ if (!value) return;
+ deleteFolder(value.scopeKey, value.folderId);
+ setDeleteFolderConfirm(null);
+ }}
+ />;
if (hideGroupLabel) {
- return ;
+ return <>{folderDeleteDialog}>;
}
return (
-
+ <>
onToggleCollapsedGroup(groupKey)}
@@ -1243,7 +1250,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
onClick={(event) => {
event.stopPropagation();
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
- if (mobileVariant) setSessionSwitcherOpen(false);
+ if (mobileVariant) setSessionSwitcherOpen(false);
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: group.directory });
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
@@ -1258,7 +1265,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
) : null}
{!isCollapsed ?
{body}
: null}
-
+
{folderDeleteDialog}>
);
}
diff --git a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.test.ts b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.test.ts
new file mode 100644
index 00000000..11fc263f
--- /dev/null
+++ b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, test } from 'bun:test';
+import { buildGroupRenderDescriptors, selectRenderedProjectSections } from './sessionProjectRender';
+import type { SessionGroup } from '../types';
+import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
+
+const makeGroup = (id: string, overrides: Partial = {}): SessionGroup => ({
+ id,
+ label: id,
+ branch: null,
+ description: null,
+ isMain: id === 'main',
+ worktree: null,
+ directory: '/workspace',
+ sessions: [],
+ ...overrides,
+});
+
+describe('buildGroupRenderDescriptors', () => {
+ test('renders the main group and archived bucket for the main workspace', () => {
+ const section = {
+ project: { id: 'project-a', normalizedPath: '/workspace' },
+ groups: [makeGroup('main'), makeGroup('archived', { isArchivedBucket: true })],
+ };
+
+ expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: true })).toEqual([
+ {
+ group: section.groups[0],
+ groupKey: 'project-a:main',
+ projectId: 'project-a',
+ hideGroupLabel: true,
+ },
+ {
+ group: section.groups[1],
+ groupKey: 'project-a:archived',
+ projectId: 'project-a',
+ hideGroupLabel: false,
+ },
+ ]);
+ });
+
+ test('renders the primary group without a label and nested groups with labels', () => {
+ const section = {
+ project: { id: 'project-a', normalizedPath: '/workspace' },
+ groups: [makeGroup('main'), makeGroup('feature')],
+ };
+
+ expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false })).toEqual([
+ {
+ group: section.groups[0],
+ groupKey: 'project-a:main',
+ projectId: 'project-a',
+ hideGroupLabel: true,
+ },
+ {
+ group: section.groups[1],
+ groupKey: 'project-a:feature',
+ projectId: 'project-a',
+ hideGroupLabel: false,
+ },
+ ]);
+ });
+
+ test('keeps labels when a flat section has no main group', () => {
+ const section = {
+ project: { id: 'project-a', normalizedPath: '/workspace' },
+ groups: [makeGroup('feature', { isMain: false }), makeGroup('other', { isMain: false })],
+ };
+
+ expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false }).map((descriptor) => descriptor.hideGroupLabel)).toEqual([false, false]);
+ });
+});
+
+describe('single-project scroller projection', () => {
+ test('renders only the selected project from persisted display state', () => {
+ const previous = useSessionDisplayStore.getState();
+ const sections = [
+ { project: { id: 'project-a', normalizedPath: '/workspace/a' }, groups: [] },
+ { project: { id: 'project-b', normalizedPath: '/workspace/b' }, groups: [] },
+ ];
+
+ try {
+ useSessionDisplayStore.setState({ projectDisplayMode: 'single', singleProjectId: 'project-b' });
+ const state = useSessionDisplayStore.getState();
+
+ expect(selectRenderedProjectSections(sections, state.projectDisplayMode === 'single', state.singleProjectId)
+ .map((section) => section.project.id)).toEqual(['project-b']);
+ } finally {
+ useSessionDisplayStore.setState(previous, true);
+ }
+ });
+});
diff --git a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx
similarity index 50%
rename from packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx
rename to packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx
index 83dac8f6..9bfb8cca 100644
--- a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx
+++ b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx
@@ -10,39 +10,124 @@ import {
} from '@dnd-kit/core';
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
-import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
-import type { SessionGroup } from './types';
-import type { SortableDragHandleProps } from './sortableItems';
+import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
+import type { SessionGroup } from '../types';
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
-import { formatProjectLabel } from './utils';
+import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
+import { buildGroupRenderDescriptors, selectRenderedProjectSections, type ProjectSection } from './sessionProjectRender';
+import { formatProjectLabel } from '../utils';
import { useI18n } from '@/lib/i18n';
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
import { streamPerfCount } from '@/stores/utils/streamDebug';
import { Icon } from '@/components/icon/Icon';
-type ProjectSection = {
- project: {
- id: string;
- label?: string;
- normalizedPath: string;
- icon?: string;
- color?: string;
- iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
- iconBackground?: string;
- };
- groups: SessionGroup[];
+type SessionProjectScrollerState = Pick & {
+ visibleSessionCountByGroup: Map;
+};
+
+type SessionProjectScrollerGroupProps = Pick & {
+ pinnedSessionIds: Set;
+ sessionOrderIndex: Map;
+};
+
+type SessionProjectScrollerGroupActions = Pick;
+
+type SessionProjectScrollerModel = {
+ topContent?: React.ReactNode;
+ hasSharedSessions?: boolean;
+ sectionsForRender: ProjectSection[];
+ projectSections: ProjectSection[];
+ activeProjectId: string | null;
+ singleProjectMode: boolean;
+ singleProjectId: string | null;
+ emptyState: React.ReactNode;
+ searchEmptyState: React.ReactNode;
+ projectRepoStatus: Map;
+ stuckProjectHeaders: Set;
+ projectHeaderSentinelRefs: React.MutableRefObject>;
+ state: SessionProjectScrollerState;
+ groupProps: SessionProjectScrollerGroupProps;
+};
+
+type SessionProjectScrollerView = {
+ homeDirectory: string | null;
+ collapsedProjects: Set;
+ showOnlyMainWorkspace: boolean;
+ hasSessionSearchQuery: boolean;
+ normalizedSessionSearchQuery: string;
+ hideDirectoryControls: boolean;
+ isDesktopShellRuntime: boolean;
+ stickyZoneHeaders: boolean;
+ mobileVariant: boolean;
+ alwaysShowActions: boolean;
+ projectSortOrder: ProjectSortOrder;
+};
+
+type SessionProjectScrollerActions = {
+ group: SessionProjectScrollerGroupActions;
+ toggleProject: (id: string) => void;
+ setActiveProjectIdOnly: (id: string) => void;
+ setSessionSwitcherOpen: (open: boolean) => void;
+ openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
+ openNewWorktreeDialog: () => void;
+ openWorktreesPage: (id: string) => void;
+ openProjectEditDialog: (id: string) => void;
+ removeProject: (id: string) => void;
+ reorderProjects: (fromIndex: number, toIndex: number) => void;
+ setGroupOrderByProject: React.Dispatch>>;
+ renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
+ setSingleProjectId: (id: string) => void;
+};
+
+type Props = {
+ model: SessionProjectScrollerModel;
+ view: SessionProjectScrollerView;
+ actions: SessionProjectScrollerActions;
};
const TOP_FADE_MAX_SIZE = 48;
const TOP_FADE_MIN_SIZE = 32;
const TOP_FADE_CLEAR_MAX_SIZE = 24;
-type ActivitySectionKey = 'chats' | 'active-now';
-
-const readActivitySectionKey = (element: Element): ActivitySectionKey | null => {
- const key = element.getAttribute('data-sidebar-activity-sentinel');
- if (key === 'chats' || key === 'active-now') return key;
- return null;
-};
const getProjectLabel = (project: ProjectSection['project'], homeDirectory: string | null): string => (
formatProjectLabel(
@@ -52,62 +137,12 @@ const getProjectLabel = (project: ProjectSection['project'], homeDirectory: stri
)
);
-type Props = {
- topContent?: React.ReactNode;
- sharedSessionsOnly?: boolean;
- hasSharedSessions?: boolean;
- sectionsForRender: ProjectSection[];
- projectSections: ProjectSection[];
- projectPickerSections: ProjectSection[];
- activeProjectId: string | null;
- singleProjectMode: boolean;
- singleProjectId: string | null;
- setSingleProjectId: (id: string) => void;
- showOnlyMainWorkspace: boolean;
- hasSessionSearchQuery: boolean;
- emptyState: React.ReactNode;
- searchEmptyState: React.ReactNode;
- renderGroupSessions: (
- group: SessionGroup,
- groupKey: string,
- projectId?: string | null,
- hideGroupLabel?: boolean,
- dragHandleProps?: SortableDragHandleProps | null,
- compactBodyPadding?: boolean,
- scrollContainerRef?: React.RefObject,
- ) => React.ReactNode;
- getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[];
- setGroupOrderByProject: React.Dispatch>>;
- renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
- homeDirectory: string | null;
- collapsedProjects: Set;
- hideDirectoryControls: boolean;
- projectRepoStatus: Map;
- isDesktopShellRuntime: boolean;
- stickyZoneHeaders: boolean;
- stuckProjectHeaders: Set;
- mobileVariant: boolean;
- alwaysShowActions: boolean;
- toggleProject: (id: string) => void;
- setActiveProjectIdOnly: (id: string) => void;
- setSessionSwitcherOpen: (open: boolean) => void;
- openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
- openNewWorktreeDialog: () => void;
- openWorktreesPage: (id: string) => void;
- openProjectEditDialog: (id: string) => void;
- removeProject: (id: string) => void;
- projectHeaderSentinelRefs: React.MutableRefObject>;
- reorderProjects: (fromIndex: number, toIndex: number) => void;
- projectSortOrder: ProjectSortOrder;
- openSidebarMenuKey: string | null;
- setOpenSidebarMenuKey: (key: string | null) => void;
- isInlineEditing: boolean;
-};
-
-function SidebarProjectsListComponent(props: Props): React.ReactNode {
+function SessionProjectScrollerComponent(props: Props): React.ReactNode {
streamPerfCount('ui.sidebar_projects_list.render');
const { t } = useI18n();
- const enableStickyFade = props.isDesktopShellRuntime && props.stickyZoneHeaders && !props.singleProjectMode;
+ const { model, view, actions } = props;
+ const isInlineEditing = model.state.editingId !== null;
+ const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders && !model.singleProjectMode;
const projectSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -115,55 +150,15 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
const groupSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
- const selectedSingleProjectSection = props.singleProjectMode
- ? props.sectionsForRender.find((section) => section.project.id === props.singleProjectId)
- : null;
- const renderedProjectSections = props.singleProjectMode
- ? (selectedSingleProjectSection ? [selectedSingleProjectSection] : [])
- : props.sectionsForRender;
- const projectPickerOptions = React.useMemo(() => props.projectPickerSections.map((section) => ({
- id: section.project.id,
- projectLabel: getProjectLabel(section.project, props.homeDirectory),
- projectDescription: formatPathForDisplay(section.project.normalizedPath, props.homeDirectory),
- projectIcon: section.project.icon,
- projectColor: section.project.color,
- projectIconImage: section.project.iconImage,
- projectIconBackground: section.project.iconBackground,
- })), [props.homeDirectory, props.projectPickerSections]);
-
- // Memoize getOrderedGroups per project so downstream consumers see a stable
- // array reference while inputs are unchanged (avoids O(P) fresh arrays per
- // list render invalidating the memoized group subtrees).
- const orderedGroupsCacheRef = React.useRef>(new Map());
- const orderedGroupsCacheGetOrderedGroupsRef = React.useRef(props.getOrderedGroups);
- if (orderedGroupsCacheGetOrderedGroupsRef.current !== props.getOrderedGroups) {
- orderedGroupsCacheGetOrderedGroupsRef.current = props.getOrderedGroups;
- orderedGroupsCacheRef.current.clear();
- }
- const cachedGetOrderedGroups = (projectId: string, groups: SessionGroup[]): SessionGroup[] => {
- const cache = orderedGroupsCacheRef.current;
- const hit = cache.get(projectId);
- if (hit && hit.groups === groups) {
- return hit.ordered;
- }
- const ordered = props.getOrderedGroups(projectId, groups);
- cache.set(projectId, { groups, ordered });
- if (cache.size > 256) {
- const firstKey = cache.keys().next().value;
- if (firstKey !== undefined) cache.delete(firstKey);
- }
- return ordered;
- };
// Threaded into SessionGroupSection so the archived-bucket virtualizer
// can resolve the scrolling ancestor synchronously (no getComputedStyle
// walk) and skip the cost of a style recalc on every render.
const scrollContainerRef = React.useRef(null);
- const [leadingActivitySection, setLeadingActivitySection] = React.useState('chats');
// Keep per-scroll measurements out of React state so the interaction guard
// can read the current fade boundary without rerendering the sidebar.
const topFadeSizeRef = React.useRef(0);
- // Update the compositor-owned mask on every scroll, but cross the React
+ // Update the viewport-owned fade on every scroll, but cross the React
// render boundary only when the sticky identity overlay appears or hides.
const syncTopFade = React.useCallback((scroller: HTMLElement) => {
const hasTopScroll = scroller.scrollTop > 1;
@@ -171,8 +166,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
? Math.min(TOP_FADE_MIN_SIZE + scroller.scrollTop, TOP_FADE_MAX_SIZE)
: 0;
topFadeSizeRef.current = topFadeSize;
- scroller.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
- scroller.style.setProperty(
+ const fadeRoot = scroller.closest('.oc-sticky-fade-root');
+ fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
+ fadeRoot?.style.setProperty(
'--scroll-shadow-top-clear-size',
`${Math.min(Math.max(topFadeSize - 8, 0), TOP_FADE_CLEAR_MAX_SIZE)}px`,
);
@@ -180,81 +176,55 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
const blockObscuredInteraction = React.useCallback((
event: React.MouseEvent | React.PointerEvent,
) => {
+ // SAFETY: React's mouse and pointer events are dispatched from Elements.
if ((event.target as Element).closest('[data-overlay-scrollbar-thumb], [data-sidebar-sticky-header]')) return;
const eventY = event.clientY - event.currentTarget.getBoundingClientRect().top;
if (eventY >= topFadeSizeRef.current) return;
event.preventDefault();
event.stopPropagation();
}, []);
- const hasProjectScroller = props.projectSections.length > 0 && renderedProjectSections.length > 0;
+ const renderedSections = selectRenderedProjectSections(
+ model.sectionsForRender,
+ model.singleProjectMode,
+ model.singleProjectId,
+ );
+ const hasProjectScroller = model.projectSections.length > 0 && renderedSections.length > 0;
React.useLayoutEffect(() => {
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
syncTopFade(scrollContainerRef.current);
}
}, [enableStickyFade, hasProjectScroller, syncTopFade]);
- React.useEffect(() => {
- const root = scrollContainerRef.current;
- if (!enableStickyFade || !root || !props.hasSharedSessions) return;
-
- const sentinels = Array.from(root.querySelectorAll('[data-sidebar-activity-sentinel]'));
- if (sentinels.length === 0) return;
- const stuckSections = new Set();
- const syncLeadingSection = (): void => {
- let nextSection = sentinels[0] ? readActivitySectionKey(sentinels[0]) : null;
- for (const sentinel of sentinels) {
- const key = readActivitySectionKey(sentinel);
- if (key && stuckSections.has(key)) nextSection = key;
- }
- if (nextSection) setLeadingActivitySection((current) => current === nextSection ? current : nextSection);
- };
- const observer = new IntersectionObserver((entries) => {
- const rootTop = root.getBoundingClientRect().top;
- for (const entry of entries) {
- const key = readActivitySectionKey(entry.target);
- if (!key) continue;
- if (!entry.isIntersecting && entry.boundingClientRect.top < (entry.rootBounds?.top ?? rootTop)) {
- stuckSections.add(key);
- } else {
- stuckSections.delete(key);
- }
- }
- syncLeadingSection();
- }, { root, threshold: 0 });
- sentinels.forEach((sentinel) => observer.observe(sentinel));
- syncLeadingSection();
- return () => observer.disconnect();
- }, [enableStickyFade, props.hasSharedSessions, props.topContent]);
let stuckProject: ProjectSection['project'] | null = null;
- for (const section of props.projectSections) {
- if (props.stuckProjectHeaders.has(section.project.id)) {
+ for (const section of model.projectSections) {
+ if (model.stuckProjectHeaders.has(section.project.id)) {
stuckProject = section.project;
}
}
// The IntersectionObserver reports the stuck header asynchronously, a frame or
- // two after the (synchronous) mask has already hidden the real header — which
+ // two after the synchronous fade has already hidden the real header — which
// otherwise leaves a one-frame gap where the title blinks out with no crisp
// replacement. Seed the overlay with the topmost rendered project so it is
// ready in the same frame; the observer then corrects it. When shared sessions
// lead the list, the Recent fallback below owns the top instead of a project.
const leadingProject =
- stuckProject ?? (props.hasSharedSessions ? null : renderedProjectSections[0]?.project ?? null);
- const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, props.homeDirectory) : null;
+ stuckProject ?? (model.hasSharedSessions ? null : renderedSections[0]?.project ?? null);
+ const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null;
+ const projectPickerOptions = React.useMemo(() => model.projectSections.map((section) => ({
+ id: section.project.id,
+ projectLabel: getProjectLabel(section.project, view.homeDirectory),
+ projectDescription: formatPathForDisplay(section.project.normalizedPath, view.homeDirectory),
+ projectIcon: section.project.icon,
+ projectColor: section.project.color,
+ projectIconImage: section.project.iconImage,
+ projectIconBackground: section.project.iconBackground,
+ })), [model.projectSections, view.homeDirectory]);
- if (props.sharedSessionsOnly) {
- return (
-
- {props.topContent}
- {!props.hasSharedSessions ? (props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState) : null}
-
- );
+ if (model.projectSections.length === 0) {
+ return {model.topContent}{model.emptyState};
}
- if (props.projectSections.length === 0) {
- return {props.topContent}{props.emptyState};
- }
-
- if (props.sectionsForRender.length === 0) {
- return {props.searchEmptyState};
+ if (model.sectionsForRender.length === 0) {
+ return {model.searchEmptyState};
}
return (
@@ -265,48 +235,37 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
// rows appear below naturally.
-
syncTopFade(event.currentTarget) : undefined}
- >
- {props.topContent}
- {props.showOnlyMainWorkspace ? (
+ syncTopFade(event.currentTarget) : undefined}
+ >
+ {model.topContent}
+ {view.showOnlyMainWorkspace ? (
{(() => {
- const activeSection = props.sectionsForRender.find((section) => section.project.id === props.activeProjectId) ?? props.sectionsForRender[0];
+ const activeSection = renderedSections.find((section) => section.project.id === model.activeProjectId) ?? renderedSections[0];
if (!activeSection) {
- return props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState;
+ return view.hasSessionSearchQuery ? model.searchEmptyState : model.emptyState;
}
- const primaryGroup =
- activeSection.groups.find((candidate) => candidate.isMain && candidate.sessions.length > 0)
- ?? activeSection.groups.find((candidate) => candidate.sessions.length > 0)
- ?? activeSection.groups.find((candidate) => candidate.isMain)
- ?? activeSection.groups[0];
- if (!primaryGroup) {
+ const descriptors = buildGroupRenderDescriptors(activeSection, { mainWorkspaceOnly: true });
+ if (!descriptors.length) {
return
{t('sessions.sidebar.empty.noSessions.title')}
;
}
- const archivedGroup = activeSection.groups.find((candidate) => candidate.isArchivedBucket);
- const groupsToRender = [
- primaryGroup,
- ...(archivedGroup && archivedGroup.id !== primaryGroup.id ? [archivedGroup] : []),
- ];
-
- return groupsToRender.map((group) => {
- const groupKey = `${activeSection.project.id}:${group.id}`;
- const hideGroupLabel = group.id === primaryGroup.id;
+ return descriptors.map(({ group, groupKey, projectId, hideGroupLabel }) => {
return (
- {props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true, scrollContainerRef)}
+
);
});
@@ -317,31 +276,31 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
sensors={projectSensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
- if (props.isInlineEditing) return;
+ if (isInlineEditing) return;
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
- if (props.projectSortOrder !== 'manual') return;
+ if (view.projectSortOrder !== 'manual') return;
const { active, over } = event;
if (!over || active.id === over.id) return;
- const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id);
- const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id);
+ const oldIndex = model.sectionsForRender.findIndex((section) => section.project.id === active.id);
+ const newIndex = model.sectionsForRender.findIndex((section) => section.project.id === over.id);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
- props.reorderProjects(oldIndex, newIndex);
+ actions.reorderProjects(oldIndex, newIndex);
}}
>
-
section.project.id)} strategy={verticalListSortingStrategy}>
- {renderedProjectSections.map((section) => {
+ section.project.id)} strategy={verticalListSortingStrategy}>
+ {renderedSections.map((section) => {
const project = section.project;
const projectKey = project.id;
- const projectLabel = getProjectLabel(project, props.homeDirectory);
- const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
- const isCollapsed = props.singleProjectMode ? false : props.collapsedProjects.has(projectKey);
- const isRepo = props.projectRepoStatus.get(projectKey);
+ const projectLabel = getProjectLabel(project, view.homeDirectory);
+ const projectDescription = formatPathForDisplay(project.normalizedPath, view.homeDirectory);
+ const isCollapsed = model.singleProjectMode ? false : view.collapsedProjects.has(projectKey);
+ const isRepo = model.projectRepoStatus.get(projectKey);
return (
{
- if (!props.singleProjectMode) props.toggleProject(projectKey);
- }}
+ isDesktopShell={view.isDesktopShellRuntime}
+ hideDirectoryControls={view.hideDirectoryControls}
+ mobileVariant={view.mobileVariant}
+ alwaysShowActions={view.alwaysShowActions}
+ statusIndicator={isCollapsed ? actions.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
+ openSidebarMenuKey={model.state.openSidebarMenuKey}
+ setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey}
+ projectPickerOptions={model.singleProjectMode ? projectPickerOptions : undefined}
+ onProjectSelect={model.singleProjectMode ? actions.setSingleProjectId : undefined}
+ onToggle={() => { if (!model.singleProjectMode) actions.toggleProject(projectKey); }}
onNewSession={() => {
- if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
- if (props.mobileVariant) props.setSessionSwitcherOpen(false);
- props.openNewSessionDraft({
+ if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
+ if (view.mobileVariant) actions.setSessionSwitcherOpen(false);
+ actions.openNewSessionDraft({
selectedProjectId: projectKey,
directoryOverride: project.normalizedPath,
});
}}
onNewWorktreeSession={() => {
- if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
- props.openNewWorktreeDialog();
+ if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
+ actions.openNewWorktreeDialog();
}}
- onManageWorktrees={() => props.openWorktreesPage(projectKey)}
- onRenameStart={() => props.openProjectEditDialog(projectKey)}
- onClose={() => props.removeProject(projectKey)}
- sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
+ onManageWorktrees={() => actions.openWorktreesPage(projectKey)}
+ onRenameStart={() => actions.openProjectEditDialog(projectKey)}
+ onClose={() => actions.removeProject(projectKey)}
+ sentinelRef={(el) => { model.projectHeaderSentinelRefs.current.set(projectKey, el); }}
showCreateButtons
- openSidebarMenuKey={props.openSidebarMenuKey}
- setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
- projectPickerOptions={props.singleProjectMode ? projectPickerOptions : undefined}
- onProjectSelect={props.singleProjectMode ? props.setSingleProjectId : undefined}
- >
+ >
{!isCollapsed ? (
{(() => {
- const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups);
+ const orderedGroups = section.groups;
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
const nestedGroups = rootGroup
? orderedGroups.filter((group) => group.id !== rootGroup.id)
@@ -393,7 +350,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
sensors={groupSensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
- if (props.isInlineEditing) return;
+ if (isInlineEditing) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = nestedGroups.findIndex((item) => item.id === active.id);
@@ -401,7 +358,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
const nextNested = arrayMove(nestedGroups, oldIndex, newIndex).map((item) => item.id);
const next = rootGroup ? [rootGroup.id, ...nextNested] : nextNested;
- props.setGroupOrderByProject((prev) => {
+ actions.setGroupOrderByProject((prev) => {
const map = new Map(prev);
map.set(projectKey, next);
return map;
@@ -411,13 +368,13 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
{/* Root/flat sessions render directly under the
project zone header; worktree and archived
groups keep their own slim sortable sub-header. */}
- {rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true, null, undefined, scrollContainerRef) : null}
+ {rootGroup ?
: null}
group.id)} strategy={verticalListSortingStrategy}>
{nestedGroups.map((group) => {
const groupKey = `${projectKey}:${group.id}`;
return (
-
- {(dragHandleProps) => props.renderGroupSessions(group, groupKey, projectKey, false, dragHandleProps, undefined, scrollContainerRef)}
+
+ {(dragHandleProps) => }
);
})}
@@ -435,15 +392,15 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
)}
-
- {enableStickyFade && (leadingProject || props.hasSharedSessions) ? (
+
+ {enableStickyFade && (leadingProject || model.hasSharedSessions) ? (
{leadingProject && leadingProjectLabel ? (
) : (
<>
-
+
- {t(leadingActivitySection === 'chats'
- ? 'sessions.sidebar.activity.chatsTitle'
- : 'sessions.sidebar.activity.recentTitle')}
+ {t('sessions.sidebar.activity.recentTitle')}
>
)}
@@ -466,4 +421,4 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
);
}
-export const SidebarProjectsList = React.memo(SidebarProjectsListComponent);
+export const SessionProjectScroller = React.memo(SessionProjectScrollerComponent);
diff --git a/packages/ui/src/components/session/sidebar/projects/sessionProjectRender.ts b/packages/ui/src/components/session/sidebar/projects/sessionProjectRender.ts
new file mode 100644
index 00000000..8f5d0ea6
--- /dev/null
+++ b/packages/ui/src/components/session/sidebar/projects/sessionProjectRender.ts
@@ -0,0 +1,55 @@
+import type { SessionGroup } from '../types';
+
+export type ProjectSection = {
+ project: {
+ id: string;
+ label?: string;
+ normalizedPath: string;
+ icon?: string;
+ color?: string;
+ iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
+ iconBackground?: string;
+ };
+ groups: SessionGroup[];
+};
+
+export const selectRenderedProjectSections = (
+ sections: ProjectSection[],
+ singleProjectMode: boolean,
+ singleProjectId: string | null,
+): ProjectSection[] => singleProjectMode
+ ? sections.filter((section) => section.project.id === singleProjectId)
+ : sections;
+
+type GroupRenderDescriptor = {
+ group: SessionGroup;
+ groupKey: string;
+ projectId: string;
+ hideGroupLabel: boolean;
+};
+
+export const buildGroupRenderDescriptors = (
+ section: ProjectSection,
+ options: { mainWorkspaceOnly: boolean },
+): GroupRenderDescriptor[] => {
+ const primaryGroup = section.groups.find((group) => group.isMain && group.sessions.length > 0)
+ ?? section.groups.find((group) => group.sessions.length > 0)
+ ?? section.groups.find((group) => group.isMain)
+ ?? section.groups[0];
+ if (!primaryGroup) return [];
+
+ const archivedGroup = section.groups.find((group) => group.isArchivedBucket && group.id !== primaryGroup.id);
+ const groups = options.mainWorkspaceOnly
+ ? [primaryGroup, ...(archivedGroup ? [archivedGroup] : [])]
+ : [
+ ...(section.groups.find((group) => group.isMain) ? [section.groups.find((group) => group.isMain)!] : []),
+ ...section.groups.filter((group) => !group.isMain),
+ ];
+
+ return groups.map((group) => ({
+ group,
+ groupKey: `${section.project.id}:${group.id}`,
+ projectId: section.project.id,
+ hideGroupLabel: options.mainWorkspaceOnly ? group.id === primaryGroup.id : group.isMain,
+ }));
+};
diff --git a/packages/ui/src/components/session/sidebar/sortableItems.tsx b/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx
similarity index 82%
rename from packages/ui/src/components/session/sidebar/sortableItems.tsx
rename to packages/ui/src/components/session/sidebar/projects/sortableItems.tsx
index 4795f92e..7b1c3d01 100644
--- a/packages/ui/src/components/session/sidebar/sortableItems.tsx
+++ b/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx
@@ -30,15 +30,13 @@ type ProjectIdentityProps = {
projectIconBackground?: string;
};
-type ProjectPickerOption = ProjectIdentityProps & {
- projectDescription: string;
-};
-
type ProjectHeaderIdentityProps = ProjectIdentityProps & {
isCollapsed?: boolean;
alwaysShowActions?: boolean;
};
+type ProjectPickerOption = ProjectIdentityProps & { projectDescription: string };
+
export const ProjectHeaderIdentity: React.FC
= ({
id,
projectLabel,
@@ -121,10 +119,10 @@ export interface SortableProjectItemProps extends ProjectIdentityProps {
children?: React.ReactNode;
showCreateButtons?: boolean;
hideHeader?: boolean;
- openSidebarMenuKey: string | null;
- setOpenSidebarMenuKey: (key: string | null) => void;
/** Aggregated activity/attention indicator shown while the project is collapsed. */
statusIndicator?: React.ReactNode;
+ openSidebarMenuKey: string | null;
+ setOpenSidebarMenuKey: (key: string | null) => void;
projectPickerOptions?: ProjectPickerOption[];
onProjectSelect?: (projectId: string) => void;
}
@@ -153,9 +151,9 @@ export const SortableProjectItem: React.FC = ({
children,
showCreateButtons = true,
hideHeader = false,
+ statusIndicator = null,
openSidebarMenuKey,
setOpenSidebarMenuKey,
- statusIndicator = null,
projectPickerOptions,
onProjectSelect,
}) => {
@@ -174,6 +172,7 @@ export const SortableProjectItem: React.FC = ({
const menuInstanceKey = `project:${id}`;
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
+ const isProjectPicker = Boolean(projectPickerOptions && onProjectSelect);
const handleMenuOpenChange = React.useCallback((open: boolean) => {
if (open) setIsContextMenuOpen(false);
@@ -235,7 +234,6 @@ export const SortableProjectItem: React.FC = ({
}
onToggle();
}, [onToggle]);
- const isProjectPicker = Boolean(projectPickerOptions && onProjectSelect);
return (
= ({
-
+
{projectPickerOptions?.map((option) => (
- onProjectSelect?.(option.id)}
- className="flex items-center justify-between gap-3"
- title={option.projectDescription}
- >
-
-
-
+ onProjectSelect?.(option.id)} className="flex items-center justify-between gap-3" title={option.projectDescription}>
+
{option.id === id ? : null}
))}
- ) : (
-
-
-
-
-
- {projectDescription}
-
-
- )}
+ ) :
+
+
+
+
+ {projectDescription}
+
+ }
= ({
const SortableGroupItemBase: React.FC<{
id: string;
disabled?: boolean;
- children: React.ReactNode | ((dragHandleProps: SortableDragHandleProps) => React.ReactNode);
+ children: (dragHandleProps: SortableDragHandleProps) => React.ReactNode;
}> = ({ id, disabled = false, children }) => {
const {
listeners,
@@ -502,7 +468,7 @@ const SortableGroupItemBase: React.FC<{
isDragging && 'opacity-50',
)}
>
- {typeof children === 'function' ? children(dragHandleProps) : children}
+ {children(dragHandleProps)}
);
};
diff --git a/packages/ui/src/components/session/sidebar/hooks/useGroupOrdering.ts b/packages/ui/src/components/session/sidebar/projects/useGroupOrdering.ts
similarity index 100%
rename from packages/ui/src/components/session/sidebar/hooks/useGroupOrdering.ts
rename to packages/ui/src/components/session/sidebar/projects/useGroupOrdering.ts
diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts b/packages/ui/src/components/session/sidebar/projects/useProjectRepoStatus.ts
similarity index 100%
rename from packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts
rename to packages/ui/src/components/session/sidebar/projects/useProjectRepoStatus.ts
diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts b/packages/ui/src/components/session/sidebar/projects/useProjectSessionLists.ts
similarity index 89%
rename from packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts
rename to packages/ui/src/components/session/sidebar/projects/useProjectSessionLists.ts
index 9f65de00..e860a13f 100644
--- a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts
+++ b/packages/ui/src/components/session/sidebar/projects/useProjectSessionLists.ts
@@ -1,5 +1,5 @@
import React from 'react';
-import type { SessionOwnershipIndex } from '../sessionOwnership';
+import type { SessionOwnershipIndex } from '../sessions/sessionOwnership';
type Args = {
ownership: SessionOwnershipIndex;
diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts b/packages/ui/src/components/session/sidebar/projects/useProjectSessionSelection.ts
similarity index 100%
rename from packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts
rename to packages/ui/src/components/session/sidebar/projects/useProjectSessionSelection.ts
diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.test.tsx b/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.test.tsx
new file mode 100644
index 00000000..b6fe0d69
--- /dev/null
+++ b/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.test.tsx
@@ -0,0 +1,103 @@
+import { describe, expect, test } from 'bun:test';
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import type { Session } from '@opencode-ai/sdk/v2';
+import { I18nProvider } from '@/lib/i18n';
+import { useSessionActions } from '../sessions/useSessionActions';
+import { useSessionGrouping } from './useSessionGrouping';
+import type { SessionNode } from '../types';
+
+type FixtureSession = Session & { parentID?: string };
+const session = (id: string, parentID?: string): Session => {
+ const value: FixtureSession = {
+ id,
+ slug: id,
+ projectID: 'project',
+ title: id,
+ version: '1',
+ directory: '/workspace',
+ time: { created: 1, updated: 1 },
+ };
+ if (parentID) value.parentID = parentID;
+ return value;
+};
+
+const collectIds = (nodes: SessionNode[]): string[] => {
+ const ids: string[] = [];
+ const visit = (items: SessionNode[]): void => {
+ for (const node of items) {
+ ids.push(node.session.id);
+ visit(node.children);
+ }
+ };
+ visit(nodes);
+ return ids;
+};
+
+describe('useSessionGrouping malformed hierarchy fallbacks', () => {
+ test('renders a deterministic cycle/orphan fallback tree without duplicate sessions', async () => {
+ type GroupingCapture = { buildGroupedSessions?: ReturnType['buildGroupedSessions'] };
+ const state: GroupingCapture = {};
+ const Harness = () => {
+ state.buildGroupedSessions = useSessionGrouping({
+ homeDirectory: null,
+ worktreeMetadata: new Map(),
+ pinnedSessionIds: new Set(),
+ sessionOrderRanks: new Map(),
+ gitBranches: new Map(),
+ isVSCode: false,
+ }).buildGroupedSessions;
+ return null;
+ };
+
+ renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
+ const buildGroupedSessions = state.buildGroupedSessions;
+ if (!buildGroupedSessions) throw new Error('grouping callback was not mounted');
+
+ const groups = buildGroupedSessions(
+ [session('a', 'b'), session('b', 'a'), session('orphan', 'missing')],
+ '/workspace',
+ [],
+ null,
+ false,
+ );
+ const rootGroup = groups.find((group) => group.isMain);
+ const ids = collectIds(rootGroup?.sessions ?? []);
+
+ expect(ids).toEqual(['orphan', 'a', 'b']);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ test('uses the row-local descendant snapshot for archive and hard-delete actions', async () => {
+ type ActionsCapture = { handleDeleteSession?: ReturnType['handleDeleteSession'] };
+ const state: ActionsCapture = {};
+ const Harness = () => {
+ state.handleDeleteSession = useSessionActions({
+ mobileVariant: false,
+ allowReselect: false,
+ isSessionSearchOpen: false,
+ sessionSearchQuery: '',
+ setSessionSearchQuery: () => undefined,
+ setIsSessionSearchOpen: () => undefined,
+ descendantIds: ['active-child', 'archived-child'],
+ showDeletionDialog: false,
+ setDeleteSessionConfirm: () => undefined,
+ deleteSessionConfirm: null,
+ setEditingId: () => undefined,
+ setEditTitle: () => undefined,
+ editingId: null,
+ editTitle: '',
+ copiedSessionId: null,
+ setCopiedSessionId: () => undefined,
+ }).handleDeleteSession;
+ return null;
+ };
+
+ renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
+ const handleDeleteSession = state.handleDeleteSession;
+ if (!handleDeleteSession) throw new Error('session actions callback was not mounted');
+
+ handleDeleteSession(session('root'));
+ handleDeleteSession(session('root'), { hardDelete: true });
+ });
+});
diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts b/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.ts
similarity index 88%
rename from packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts
rename to packages/ui/src/components/session/sidebar/projects/useSessionGrouping.ts
index b75ea6dd..fe6be770 100644
--- a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts
+++ b/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.ts
@@ -9,11 +9,11 @@ import {
normalizeForBranchComparison,
normalizePath,
} from '../utils';
-import { compareSessionsByLifecycleOrder, getSessionLifecycleOrderValue } from '@/sync/session-ordering';
+import { getSessionLifecycleOrderValue } from '@/sync/session-ordering';
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
-import { getWorktreeFirstSeenAt } from '../worktreeFirstSeen';
+import { getWorktreeFirstSeenAt } from './worktreeFirstSeen';
type Args = {
homeDirectory: string | null;
@@ -70,8 +70,9 @@ export const useSessionGrouping = (args: Args) => {
projectIsRepo: boolean,
) => {
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
- const sortedProjectSessions = dedupeSessionsById(projectSessions)
- .sort((a, b) => compareSessionsByLifecycleOrder(a, b, args.pinnedSessionIds, args.sessionOrderRanks));
+ // `orderSessionsByLifecycleScopes` owns lifecycle ordering before project
+ // ownership buckets are built. Dedupe retains that root/sibling order.
+ const sortedProjectSessions = dedupeSessionsById(projectSessions);
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
const childrenMap = new Map();
@@ -86,7 +87,6 @@ export const useSessionGrouping = (args: Args) => {
collection.push(session);
childrenMap.set(parentID, collection);
});
- childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, args.pinnedSessionIds, args.sessionOrderRanks)));
const worktreeByPath = new Map();
availableWorktrees.forEach((meta) => {
@@ -109,12 +109,19 @@ export const useSessionGrouping = (args: Args) => {
return null;
};
+ const claimedSessionIds = new Set();
const buildProjectNode = (session: Session): SessionNode => {
+ claimedSessionIds.add(session.id);
const children = childrenMap.get(session.id) ?? [];
- return { session, children: children.map((child) => buildProjectNode(child)), worktree: getSessionWorktree(session) };
+ const childNodes: SessionNode[] = [];
+ for (const child of children) {
+ if (claimedSessionIds.has(child.id)) continue;
+ childNodes.push(buildProjectNode(child));
+ }
+ return { session, children: childNodes, worktree: getSessionWorktree(session) };
};
- const roots = sortedProjectSessions.filter((session) => {
+ const rootCandidates = sortedProjectSessions.filter((session) => {
const parentID = (session as Session & { parentID?: string | null }).parentID;
if (!parentID) return true;
const parentSession = sessionMap.get(parentID);
@@ -122,6 +129,16 @@ export const useSessionGrouping = (args: Args) => {
return isArchivedSession(parentSession) !== isArchivedSession(session);
});
+ // A malformed cycle has no structural root. Start with normal roots,
+ // then expose each still-unclaimed component from its first input row.
+ const roots: SessionNode[] = [];
+ const addRoot = (session: Session): void => {
+ if (claimedSessionIds.has(session.id)) return;
+ roots.push(buildProjectNode(session));
+ };
+ rootCandidates.forEach(addRoot);
+ sortedProjectSessions.forEach(addRoot);
+
const groupedNodes = new Map();
const archivedKey = '__archived__';
@@ -140,9 +157,8 @@ export const useSessionGrouping = (args: Args) => {
return archivedKey;
};
- roots.forEach((session) => {
- const node = buildProjectNode(session);
- const groupKey = getGroupKey(session);
+ roots.forEach((node) => {
+ const groupKey = getGroupKey(node.session);
if (!groupedNodes.has(groupKey)) groupedNodes.set(groupKey, []);
groupedNodes.get(groupKey)?.push(node);
});
@@ -258,7 +274,7 @@ export const useSessionGrouping = (args: Args) => {
return groups;
},
- [args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
+ [args.homeDirectory, args.worktreeMetadata, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
);
return {
diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionProjectViewState.test.tsx b/packages/ui/src/components/session/sidebar/projects/useSessionProjectViewState.test.tsx
new file mode 100644
index 00000000..f603fb61
--- /dev/null
+++ b/packages/ui/src/components/session/sidebar/projects/useSessionProjectViewState.test.tsx
@@ -0,0 +1,220 @@
+import { beforeEach, describe, expect, test } from 'bun:test';
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { useSessionUIStore } from '@/sync/session-ui-store';
+import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
+import type { SessionGroup } from '../types';
+import { useSessionProjectViewState } from './useSessionProjectViewState';
+
+class ElementStub implements Partial {
+ nodeType = 1;
+}
+type DocumentStub = {
+ nodeType: number;
+ defaultView: typeof globalThis;
+ activeElement: null;
+ addEventListener: () => void;
+ removeEventListener: () => void;
+ documentElement?: Element;
+ body?: Element;
+};
+type GlobalValue = typeof globalThis | typeof ElementStub | DocumentStub | boolean;
+type HookCapture = {
+ state?: ReturnType['state'];
+ actions?: ReturnType['actions'];
+ renderCount: number;
+};
+
+const installMinimalDom = () => {
+ const descriptors = new Map();
+ const setGlobal = (name: string, value: GlobalValue) => {
+ descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
+ Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
+ };
+ const documentStub: DocumentStub = {
+ nodeType: 9,
+ defaultView: globalThis,
+ activeElement: null,
+ addEventListener: () => undefined,
+ removeEventListener: () => undefined,
+ };
+ // SAFETY: React's test renderer only inspects this fixture's DOM identity fields and listeners.
+ const container = Object.create(ElementStub.prototype) as Element;
+ Object.assign(container, {
+ nodeType: 1,
+ tagName: 'DIV',
+ nodeName: 'DIV',
+ namespaceURI: 'http://www.w3.org/1999/xhtml',
+ ownerDocument: documentStub,
+ addEventListener: () => undefined,
+ removeEventListener: () => undefined,
+ });
+ documentStub.documentElement = container;
+ documentStub.body = container;
+ setGlobal('document', documentStub);
+ setGlobal('window', globalThis);
+ setGlobal('Element', ElementStub);
+ setGlobal('HTMLElement', ElementStub);
+ setGlobal('HTMLIFrameElement', ElementStub);
+ setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ return {
+ container,
+ restore: () => {
+ for (const [name, descriptor] of descriptors) {
+ if (descriptor) Object.defineProperty(globalThis, name, descriptor);
+ else Reflect.deleteProperty(globalThis, name);
+ }
+ },
+ };
+};
+
+describe('useSessionProjectViewState', () => {
+ beforeEach(() => {
+ const storage = getDeferredSafeStorage();
+ storage.removeItem('oc.sessions.projectCollapse');
+ storage.removeItem('oc.sessions.groupCollapse');
+ storage.removeItem('oc.sessions.groupOrder');
+ });
+
+ test('keeps stable state/actions and ignores selection-store updates', async () => {
+ const dom = installMinimalDom();
+ const root: Root = createRoot(dom.container);
+ const capture: HookCapture = { renderCount: 0 };
+ const projects = [{ id: 'project-a' }, { id: 'project-b' }];
+ const Harness = () => {
+ capture.renderCount += 1;
+ const viewState = useSessionProjectViewState({ isVSCode: true, projects });
+ capture.state = viewState.state;
+ capture.actions = viewState.actions;
+ return null;
+ };
+
+ try {
+ await act(async () => root.render(React.createElement(Harness)));
+ const initialState = capture.state;
+ const initialActions = capture.actions;
+ const initialRenderCount = capture.renderCount;
+ if (!initialState || !initialActions) throw new Error('hook did not mount');
+
+ await act(async () => {
+ useSessionUIStore.setState({ currentSessionId: 'selection-only' });
+ });
+ expect(capture.renderCount).toBe(initialRenderCount);
+ expect(capture.state).toBe(initialState);
+ expect(capture.actions).toBe(initialActions);
+
+ await act(async () => initialActions.toggleProject('project-a'));
+ expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
+ await act(async () => initialActions.collapseAllProjects());
+ expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a', 'project-b']));
+ await act(async () => initialActions.expandAllProjects());
+ expect(capture.state?.collapsedProjects).toEqual(new Set());
+
+ await act(async () => initialActions.toggleGroup('project-a:group-a'));
+ expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
+
+ await act(async () => {
+ initialActions.setGroupOrderByProject((previous) => {
+ const next = new Map(previous);
+ next.set('project-a', ['group-b', 'group-a']);
+ return next;
+ });
+ });
+ const group = (id: string): SessionGroup => ({
+ id,
+ label: id,
+ branch: null,
+ description: null,
+ isMain: false,
+ worktree: null,
+ directory: null,
+ sessions: [],
+ });
+ expect(capture.actions?.getOrderedGroups('project-a', [group('group-a'), group('group-b')])
+ .map((item) => item.id)).toEqual(['group-b', 'group-a']);
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ const storage = getDeferredSafeStorage();
+ expect(JSON.parse(storage.getItem('oc.sessions.projectCollapse') ?? 'null')).toEqual([]);
+ expect(JSON.parse(storage.getItem('oc.sessions.groupCollapse') ?? 'null')).toEqual(['project-a:group-a']);
+ expect(JSON.parse(storage.getItem('oc.sessions.groupOrder') ?? 'null')).toEqual({
+ 'project-a': ['group-b', 'group-a'],
+ });
+ } finally {
+ await act(async () => root.unmount());
+ dom.restore();
+ }
+ });
+
+ test('preserves malformed group storage until explicit user mutation', async () => {
+ const storage = getDeferredSafeStorage();
+ const malformedCollapse = '{malformed-collapse';
+ const malformedOrder = JSON.stringify({ 'project-a': ['group-a', 2] });
+ storage.setItem('oc.sessions.groupCollapse', malformedCollapse);
+ storage.setItem('oc.sessions.groupOrder', malformedOrder);
+ const dom = installMinimalDom();
+ const root = createRoot(dom.container);
+ const capture: HookCapture = { renderCount: 0 };
+ const Harness = () => {
+ capture.renderCount += 1;
+ const value = useSessionProjectViewState({ isVSCode: true, projects: [{ id: 'project-a' }] });
+ capture.state = value.state;
+ capture.actions = value.actions;
+ return null;
+ };
+
+ try {
+ await act(async () => root.render(React.createElement(Harness)));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(capture.state?.collapsedGroups).toEqual(new Set());
+ expect(capture.state?.groupOrderByProject).toEqual(new Map());
+ expect(storage.getItem('oc.sessions.groupCollapse')).toBe(malformedCollapse);
+ expect(storage.getItem('oc.sessions.groupOrder')).toBe(malformedOrder);
+
+ await act(async () => capture.actions!.toggleGroup('project-a:group-a'));
+ await act(async () => capture.actions!.setGroupOrderByProject(new Map([['project-a', ['group-a']]])));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(JSON.parse(storage.getItem('oc.sessions.groupCollapse') ?? 'null')).toEqual(['project-a:group-a']);
+ expect(JSON.parse(storage.getItem('oc.sessions.groupOrder') ?? 'null')).toEqual({ 'project-a': ['group-a'] });
+ } finally {
+ await act(async () => root.unmount());
+ dom.restore();
+ }
+ });
+
+ test('retains persisted project/group state while hidden and across a full remount', async () => {
+ const storage = getDeferredSafeStorage();
+ storage.setItem('oc.sessions.projectCollapse', JSON.stringify(['project-a']));
+ storage.setItem('oc.sessions.groupCollapse', JSON.stringify(['project-a:group-a']));
+ storage.setItem('oc.sessions.groupOrder', JSON.stringify({ 'project-a': ['group-b', 'group-a'] }));
+ const dom = installMinimalDom();
+ const root = createRoot(dom.container);
+ const capture: HookCapture = { renderCount: 0 };
+ const Harness = ({ hidden }: { hidden: boolean }) => {
+ void hidden;
+ capture.renderCount += 1;
+ const value = useSessionProjectViewState({ isVSCode: true, projects: [{ id: 'project-a' }] });
+ capture.state = value.state;
+ capture.actions = value.actions;
+ return null;
+ };
+
+ try {
+ await act(async () => root.render(React.createElement(Harness, { hidden: false })));
+ await act(async () => root.render(React.createElement(Harness, { hidden: true })));
+ await act(async () => root.render(React.createElement(Harness, { hidden: false })));
+ expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
+ expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
+ expect(capture.state?.groupOrderByProject).toEqual(new Map([['project-a', ['group-b', 'group-a']]]));
+
+ await act(async () => root.render(null));
+ await act(async () => root.render(React.createElement(Harness, { hidden: false })));
+ expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
+ expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
+ expect(capture.state?.groupOrderByProject).toEqual(new Map([['project-a', ['group-b', 'group-a']]]));
+ } finally {
+ await act(async () => root.unmount());
+ dom.restore();
+ }
+ });
+});
diff --git a/packages/ui/src/components/session/sidebar/projects/useSessionProjectViewState.ts b/packages/ui/src/components/session/sidebar/projects/useSessionProjectViewState.ts
new file mode 100644
index 00000000..882474b0
--- /dev/null
+++ b/packages/ui/src/components/session/sidebar/projects/useSessionProjectViewState.ts
@@ -0,0 +1,199 @@
+import React from 'react';
+import { updateDesktopSettings } from '@/lib/persistence';
+import { useProjectsStore } from '@/stores/useProjectsStore';
+import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
+import { z } from 'zod';
+import { useGroupOrdering } from './useGroupOrdering';
+
+const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
+const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
+const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
+
+type Project = { id: string };
+
+type SessionProjectViewStateArgs = {
+ isVSCode: boolean;
+ projects: readonly Project[];
+};
+
+const parseStringSet = (raw: string | null): Set => {
+ if (!raw) return new Set();
+ try {
+ const parsed = z.array(z.string()).safeParse(JSON.parse(raw));
+ return new Set(parsed.success ? parsed.data : []);
+ } catch {
+ return new Set();
+ }
+};
+
+const parseGroupOrder = (raw: string | null): Map => {
+ if (!raw) return new Map();
+ try {
+ const parsed = z.record(z.string(), z.array(z.string())).safeParse(JSON.parse(raw));
+ if (!parsed.success) return new Map();
+ const next = new Map();
+ for (const [projectId, order] of Object.entries(parsed.data)) {
+ next.set(projectId, order);
+ }
+ return next;
+ } catch {
+ return new Map();
+ }
+};
+
+export const useSessionProjectViewState = ({
+ isVSCode,
+ projects,
+}: SessionProjectViewStateArgs) => {
+ const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []);
+ const [collapsedProjects, setCollapsedProjects] = React.useState>(() => (
+ parseStringSet(safeStorage.getItem(PROJECT_COLLAPSE_STORAGE_KEY))
+ ));
+ const [collapsedGroups, setCollapsedGroups] = React.useState>(() => (
+ parseStringSet(safeStorage.getItem(GROUP_COLLAPSE_STORAGE_KEY))
+ ));
+ const [groupOrderByProject, setGroupOrderByProject] = React.useState>(() => (
+ parseGroupOrder(safeStorage.getItem(GROUP_ORDER_STORAGE_KEY))
+ ));
+ const ignoreIntersectionUntil = React.useRef(0);
+ const groupCollapseDirty = React.useRef(false);
+ const groupOrderDirty = React.useRef(false);
+ const persistCollapsedProjectsTimer = React.useRef(null);
+ const pendingCollapsedProjects = React.useRef | null>(null);
+
+ const flushCollapsedProjectsPersist = React.useCallback(() => {
+ if (isVSCode) return;
+ const collapsed = pendingCollapsedProjects.current;
+ pendingCollapsedProjects.current = null;
+ persistCollapsedProjectsTimer.current = null;
+ if (!collapsed) return;
+
+ const { projects: storedProjects } = useProjectsStore.getState();
+ const updatedProjects = storedProjects.map((project) => ({
+ ...project,
+ sidebarCollapsed: collapsed.has(project.id),
+ }));
+ void updateDesktopSettings({ projects: updatedProjects }).catch(() => {});
+ }, [isVSCode]);
+
+ const scheduleCollapsedProjectsPersist = React.useCallback((collapsed: Set) => {
+ if (!globalThis.window || isVSCode) return;
+ pendingCollapsedProjects.current = collapsed;
+ if (persistCollapsedProjectsTimer.current !== null) {
+ window.clearTimeout(persistCollapsedProjectsTimer.current);
+ }
+ persistCollapsedProjectsTimer.current = window.setTimeout(() => {
+ flushCollapsedProjectsPersist();
+ }, 700);
+ }, [flushCollapsedProjectsPersist, isVSCode]);
+
+ React.useEffect(() => {
+ return () => {
+ if (globalThis.window && persistCollapsedProjectsTimer.current !== null) {
+ window.clearTimeout(persistCollapsedProjectsTimer.current);
+ }
+ persistCollapsedProjectsTimer.current = null;
+ pendingCollapsedProjects.current = null;
+ };
+ }, []);
+
+ React.useEffect(() => {
+ if (!groupOrderDirty.current) return;
+ try {
+ safeStorage.setItem(GROUP_ORDER_STORAGE_KEY, JSON.stringify(Object.fromEntries(groupOrderByProject.entries())));
+ } catch {
+ // ignored
+ }
+ }, [groupOrderByProject, safeStorage]);
+
+ React.useEffect(() => {
+ if (!groupCollapseDirty.current) return;
+ try {
+ safeStorage.setItem(GROUP_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(collapsedGroups)));
+ } catch {
+ // ignored
+ }
+ }, [collapsedGroups, safeStorage]);
+
+ const collapseAllProjects = React.useCallback(() => {
+ ignoreIntersectionUntil.current = Date.now() + 150;
+ groupCollapseDirty.current = true;
+ setCollapsedGroups(new Set());
+ setCollapsedProjects(() => {
+ const allIds = new Set(projects.map((project) => project.id));
+ try {
+ safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(allIds)));
+ } catch {
+ // ignored
+ }
+ scheduleCollapsedProjectsPersist(allIds);
+ return allIds;
+ });
+ }, [projects, safeStorage, scheduleCollapsedProjectsPersist]);
+
+ const expandAllProjects = React.useCallback(() => {
+ ignoreIntersectionUntil.current = Date.now() + 150;
+ groupCollapseDirty.current = true;
+ setCollapsedGroups(new Set());
+ setCollapsedProjects(() => {
+ const empty = new Set();
+ try {
+ safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify([]));
+ } catch {
+ // ignored
+ }
+ scheduleCollapsedProjectsPersist(empty);
+ return empty;
+ });
+ }, [safeStorage, scheduleCollapsedProjectsPersist]);
+
+ const toggleProject = React.useCallback((projectId: string) => {
+ ignoreIntersectionUntil.current = Date.now() + 150;
+ setCollapsedProjects((previous) => {
+ const next = new Set(previous);
+ if (next.has(projectId)) next.delete(projectId);
+ else next.add(projectId);
+ try {
+ safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next)));
+ } catch {
+ // ignored
+ }
+ scheduleCollapsedProjectsPersist(next);
+ return next;
+ });
+ }, [safeStorage, scheduleCollapsedProjectsPersist]);
+
+ const toggleGroup = React.useCallback((key: string) => {
+ groupCollapseDirty.current = true;
+ setCollapsedGroups((previous) => {
+ const next = new Set(previous);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ }, []);
+ const updateGroupOrderByProject = React.useCallback>>>((update) => {
+ groupOrderDirty.current = true;
+ setGroupOrderByProject(update);
+ }, []);
+
+ const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
+ const state = React.useMemo(() => ({
+ collapsedProjects,
+ collapsedGroups,
+ groupOrderByProject,
+ }), [collapsedGroups, collapsedProjects, groupOrderByProject]);
+ const actions = React.useMemo(() => ({
+ setCollapsedProjects,
+ toggleProject,
+ collapseAllProjects,
+ expandAllProjects,
+ scheduleCollapsedProjectsPersist,
+ setCollapsedGroups,
+ toggleGroup,
+ setGroupOrderByProject: updateGroupOrderByProject,
+ getOrderedGroups,
+ }), [collapseAllProjects, expandAllProjects, getOrderedGroups, scheduleCollapsedProjectsPersist, toggleGroup, toggleProject, updateGroupOrderByProject]);
+
+ return { state, actions };
+};
diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts b/packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts
similarity index 100%
rename from packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts
rename to packages/ui/src/components/session/sidebar/projects/useSessionSidebarSections.ts
diff --git a/packages/ui/src/components/session/sidebar/hooks/useStickyProjectHeaders.ts b/packages/ui/src/components/session/sidebar/projects/useStickyProjectHeaders.ts
similarity index 100%
rename from packages/ui/src/components/session/sidebar/hooks/useStickyProjectHeaders.ts
rename to packages/ui/src/components/session/sidebar/projects/useStickyProjectHeaders.ts
diff --git a/packages/ui/src/components/session/sidebar/worktreeFirstSeen.ts b/packages/ui/src/components/session/sidebar/projects/worktreeFirstSeen.ts
similarity index 95%
rename from packages/ui/src/components/session/sidebar/worktreeFirstSeen.ts
rename to packages/ui/src/components/session/sidebar/projects/worktreeFirstSeen.ts
index ede3b8ad..731ac339 100644
--- a/packages/ui/src/components/session/sidebar/worktreeFirstSeen.ts
+++ b/packages/ui/src/components/session/sidebar/projects/worktreeFirstSeen.ts
@@ -1,4 +1,4 @@
-import { normalizePath } from './utils';
+import { normalizePath } from '../utils';
// In-memory first-seen tracker for worktree directories. Worktree metadata
// carries no creation time, so we record when a path first appears during
diff --git a/packages/ui/src/components/session/sidebar/recent/RecentSessionSection.tsx b/packages/ui/src/components/session/sidebar/recent/RecentSessionSection.tsx
new file mode 100644
index 00000000..b3abac77
--- /dev/null
+++ b/packages/ui/src/components/session/sidebar/recent/RecentSessionSection.tsx
@@ -0,0 +1,167 @@
+import React from 'react';
+import type { Session } from '@opencode-ai/sdk/v2';
+import { useI18n } from '@/lib/i18n';
+import { formatDirectoryName } from '@/lib/utils';
+import type { WorktreeMetadata } from '@/types/worktree';
+import { SidebarActivitySections } from './SidebarActivitySections';
+import { deriveRecentActivitySections, type RecentSessionLocation } from './activitySections';
+import type { ActivityItem } from './SidebarActivitySections';
+import type { SessionTreeItemProps } from '../sessions/SessionTreeItem';
+import type { SessionNode } from '../types';
+import { formatProjectLabel, normalizePath } from '../utils';
+
+type Props = {
+ projects: { id: string; label?: string; normalizedPath: string }[];
+ availableWorktreesByProject: Map;
+ gitBranches: Map;
+ homeDirectory: string | null;
+ hasSessionSearchQuery: boolean;
+ normalizedSessionSearchQuery: string;
+ isDesktopShellRuntime: boolean;
+ sessions: Session[];
+ childrenMap: ReadonlyMap;
+ pinnedSessionIds: Set;
+ recentSessions: Session[];
+ expandedParents: Set;
+ notifyOnSubtasks: boolean;
+ editingId: string | null;
+ editTitle: string;
+ copiedSessionId: string | null;
+ openSidebarMenuKey: string | null;
+ mobileVariant: boolean;
+ alwaysShowActions: boolean;
+ chatSessions: Session[];
+ renderChatsSection: (items: ActivityItem[]) => React.ReactNode;
+ onNewChat: () => void;
+ showRecentSection: boolean;
+} & Pick;
+
+export const RecentSessionSection: React.FC = (props) => {
+ const {
+ projects,
+ availableWorktreesByProject,
+ gitBranches,
+ homeDirectory,
+ hasSessionSearchQuery,
+ normalizedSessionSearchQuery,
+ isDesktopShellRuntime,
+ sessions,
+ childrenMap,
+ pinnedSessionIds,
+ recentSessions,
+ chatSessions,
+ showRecentSection,
+ } = props;
+ const { t } = useI18n();
+ const sessionLocationById = React.useMemo(() => {
+ const locations = new Map();
+ for (const session of sessions) {
+ const directory = normalizePath(session.directory ?? null);
+ if (!directory) continue;
+ let owner: Props['projects'][number] | null = null;
+ let ownerLength = -1;
+ for (const project of projects) {
+ const projectPath = normalizePath(project.normalizedPath);
+ if (projectPath && (directory === projectPath || directory.startsWith(`${projectPath}/`)) && projectPath.length > ownerLength) {
+ owner = project;
+ ownerLength = projectPath.length;
+ }
+ }
+ if (!owner) continue;
+ const worktree = availableWorktreesByProject.get(owner.normalizedPath)?.find((entry) => normalizePath(entry.path) === directory);
+ const projectLabel = formatProjectLabel(owner.label?.trim() || formatDirectoryName(owner.normalizedPath, homeDirectory) || owner.normalizedPath);
+ const branch = worktree?.branch?.trim() || gitBranches.get(directory)?.trim() || null;
+ locations.set(session.id, {
+ projectId: owner.id,
+ groupDirectory: directory,
+ projectLabel,
+ branchLabel: branch && branch !== 'HEAD' && branch !== projectLabel ? branch : null,
+ });
+ }
+ return locations;
+ }, [availableWorktreesByProject, sessions, gitBranches, homeDirectory, projects]);
+ const getSessionLocation = React.useCallback(
+ (sessionId: string) => sessionLocationById.get(sessionId) ?? null,
+ [sessionLocationById],
+ );
+ const getSessionNode = React.useCallback(
+ (session: Session): SessionNode => ({
+ session,
+ children: (childrenMap.get(session.id) ?? []).filter((child) => !child.time?.archived).map((child) => ({
+ session: child,
+ children: [],
+ worktree: null,
+ })),
+ worktree: null,
+ }),
+ [childrenMap],
+ );
+ const recentSections = React.useMemo(() => deriveRecentActivitySections({
+ sessions: recentSessions,
+ getSessionLocation,
+ getSessionNode,
+ query: hasSessionSearchQuery ? normalizedSessionSearchQuery : '',
+ }), [getSessionLocation, getSessionNode, hasSessionSearchQuery, normalizedSessionSearchQuery, recentSessions]);
+ const sections = React.useMemo(() => [
+ {
+ key: 'chats' as const,
+ title: t('sessions.sidebar.activity.chatsTitle'),
+ items: chatSessions.map((session) => ({
+ node: getSessionNode(session),
+ projectId: null,
+ groupDirectory: session.directory ?? null,
+ secondaryMeta: null,
+ })),
+ },
+ ...(showRecentSection ? recentSections.map((section) => ({ ...section, title: t('sessions.sidebar.activity.recentTitle') })) : []),
+ ], [chatSessions, getSessionNode, recentSections, showRecentSection, t]);
+ return (
+
+ );
+};
diff --git a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx b/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx
similarity index 63%
rename from packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx
rename to packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx
index 8c11daaa..1cd444d0 100644
--- a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx
+++ b/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { cn } from '@/lib/utils';
-import type { SessionNode } from './types';
+import type { SessionNode } from '../types';
import { useI18n } from '@/lib/i18n';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { Icon } from "@/components/icon/Icon";
@@ -8,9 +8,9 @@ import {
collectSubtreeContainingId,
computeNodeStructureKey,
resolveMenuOpenSessionId,
-} from './sessionNodeItemUtils';
-import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+} from '../sessions/sessionNodeItemUtils';
+import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils';
+import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem';
export type ActivityItem = {
node: SessionNode;
@@ -30,27 +30,40 @@ type ActivitySection = {
type Props = {
sections: ActivitySection[];
- renderSessionNode: (
- node: SessionNode,
- depth?: number,
- groupDirectory?: string | null,
- projectId?: string | null,
- archivedBucket?: boolean,
- secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
- renderContext?: 'project' | 'recent',
- renderExtras?: SessionNodeRenderExtras,
- ) => React.ReactNode;
- editingId: string | null;
- openSidebarMenuKey: string | null;
expansionState?: ReadonlySet;
variant?: 'section' | 'flat';
initialVisibleCount?: number;
batchSize?: number;
isDesktopShellRuntime: boolean;
+ pinnedSessionIds: Set;
+ expandedParents: Set;
+ hasSessionSearchQuery: boolean;
+ normalizedSessionSearchQuery: string;
+ notifyOnSubtasks: boolean;
+ editingId: string | null;
+ editTitle: string;
+ copiedSessionId: string | null;
+ openSidebarMenuKey: string | null;
+ mobileVariant: boolean;
+ alwaysShowActions: boolean;
onNewChat?: () => void;
- alwaysShowActions?: boolean;
renderChatsSection?: (items: ActivityItem[]) => React.ReactNode;
-};
+} & Pick;
type RenderExtras = SessionNodeRenderExtras;
@@ -59,14 +72,12 @@ const MAX_VISIBLE_RECENT_SESSIONS = 7;
export function SidebarActivitySections(props: Props): React.ReactNode {
const {
sections,
- renderSessionNode,
- editingId,
- openSidebarMenuKey,
variant = 'section',
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
} = props;
const { t } = useI18n();
+ const { pinnedSessionIds } = props;
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
const [collapsed, setCollapsed] = React.useState>(new Set());
const [visibleCountBySection, setVisibleCountBySection] = React.useState>(new Map());
@@ -109,8 +120,8 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
const subtreeContainsEditing = new Set();
- collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing);
- const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false);
+ collectSubtreeContainingId(nodes, props.editingId, subtreeContainsEditing);
+ const menuOpenSessionId = resolveMenuOpenSessionId(nodes, props.openSidebarMenuKey, 'recent', false);
const nodeStructureKeyByNode = new WeakMap();
const visit = (node: SessionNode): void => {
nodeStructureKeyByNode.set(node, computeNodeStructureKey(node));
@@ -131,11 +142,9 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
childRenderExtrasFor,
});
- }, [editingId, openSidebarMenuKey]);
+ }, [props.editingId, props.openSidebarMenuKey]);
- const visibleSections = sections.filter((section) => (
- section.items.length > 0 || (section.key === 'chats' && props.onNewChat)
- ));
+ const visibleSections = sections.filter((section) => section.items.length > 0 || section.key === 'chats');
if (visibleSections.length === 0) {
return null;
}
@@ -155,15 +164,41 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
const usesCustomRenderer = section.key === 'chats' && Boolean(props.renderChatsSection);
const canShowFewer = !usesCustomRenderer && !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node));
- const renderItem = (item: ActivityItem) => renderSessionNode(
- item.node,
- 0,
- item.groupDirectory,
- item.projectId,
- false,
- item.secondaryMeta,
- 'recent',
- getRenderExtras(item.node),
+ const renderItem = (item: ActivityItem) => (
+
);
if (flatVariant) {
@@ -185,11 +220,6 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
return (
-
toggleSection(section.key)}
- className={cn(
- 'group flex w-full items-center gap-1.5 py-1 pl-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
- section.key === 'chats' && props.onNewChat ? 'pr-10' : 'pr-3.5',
- )}
+ className={cn('group flex w-full items-center gap-1.5 py-1 pl-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50', section.key === 'chats' ? 'pr-10' : 'pr-3.5')}
aria-expanded={!isCollapsed}
>
-
+
{isCollapsed ? : }
@@ -213,38 +240,19 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
{section.title}
{section.key === 'chats' && props.onNewChat ? (
-
-
-
-
-
-
- {t('sessions.sidebar.header.actions.newSession')}
-
-
-
+
) : null}
{!isCollapsed ? (
- {section.key === 'chats' && props.renderChatsSection
- ? props.renderChatsSection(section.items)
- : visibleItems.map(renderItem)}
+ {usesCustomRenderer ? props.renderChatsSection?.(section.items) : visibleItems.map(renderItem)}
{!usesCustomRenderer && remainingCount > 0 ? (