From ad543e9eef1bcfb51f78acd64c8e41640ecb3a6a Mon Sep 17 00:00:00 2001 From: Seungmin Date: Thu, 26 Feb 2026 13:29:06 +0900 Subject: [PATCH 1/3] Extend long-message collapse to assistant responses --- extension/src/content/user-collapse.ts | 57 +++++++++++++++----------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/extension/src/content/user-collapse.ts b/extension/src/content/user-collapse.ts index df5e5e9..06b405d 100644 --- a/extension/src/content/user-collapse.ts +++ b/extension/src/content/user-collapse.ts @@ -1,5 +1,5 @@ /** - * LightSession for ChatGPT - Collapse long user messages (presentation-only) + * LightSession for ChatGPT - Collapse long chat messages (presentation-only) * * Constraints: * - Do not truncate or rewrite message text content (no innerHTML rewriting). @@ -13,7 +13,8 @@ const STYLE_ID = 'lightsession-user-collapse-styles'; const PROCESSED_ATTR = 'data-ls-uc-processed'; const STATE_ATTR = 'data-ls-uc-state'; // "collapsed" | "expanded" -const USER_ROOT_SELECTOR = '[data-message-author-role="user"][data-message-id]'; +const COLLAPSIBLE_ROOT_SELECTOR = + '[data-message-author-role="user"][data-message-id], [data-message-author-role="assistant"][data-message-id]'; const ANY_ROLE_ROOT_SELECTOR = '[data-message-author-role][data-message-id]'; const BUBBLE_SELECTOR = '.user-message-bubble-color'; const TEXT_SELECTORS = ['.whitespace-pre-wrap', '.markdown.prose', '.markdown', '.prose'] as const; @@ -34,7 +35,7 @@ function ensureStyles(): void { const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = ` -/* LightSession: user message collapse */ +/* LightSession: chat message collapse */ .ls-uc-bubble { position: relative; } .ls-uc-text { position: relative; } @@ -240,16 +241,24 @@ function ensureCollapseUi(root: HTMLElement, bubble: HTMLElement, text: HTMLElem const btn = ensureButton(bubble, textId); updateButtonUi(btn, bubble.getAttribute(STATE_ATTR) === 'expanded'); - logDebug('User collapse applied for message:', messageId); + logDebug('Collapse applied for message:', messageId); } -function processUserMessageRoot(root: HTMLElement): void { - const bubble = root.querySelector(BUBBLE_SELECTOR); - if (!bubble) return; +function deriveBubbleContainer(root: HTMLElement, text: HTMLElement): HTMLElement { + const knownBubble = root.querySelector(BUBBLE_SELECTOR); + if (knownBubble) return knownBubble; - const text = findTextContainer(bubble); + // Assistant turns typically don't use the user bubble class. + // Use the text container's parent so button positioning is localized to the message content. + return text.parentElement instanceof HTMLElement ? text.parentElement : root; +} + +function processCollapsibleMessageRoot(root: HTMLElement): void { + const text = findTextContainer(root); if (!text) return; + const bubble = deriveBubbleContainer(root, text); + // Measure for "long" before clamping. Caller batches this in rAF. const fullHeight = text.scrollHeight; const isLong = fullHeight > COLLAPSED_MAX_HEIGHT_PX + 24; @@ -263,18 +272,18 @@ function processUserMessageRoot(root: HTMLElement): void { ensureCollapseUi(root, bubble, text); } -function collectUserRootsFromAddedNode(node: unknown): HTMLElement[] { +function collectCollapsibleRootsFromAddedNode(node: unknown): HTMLElement[] { if (!(node instanceof HTMLElement)) return []; // Avoid duplicate work; pendingRoots is a Set but array creation can still be expensive on large mutation batches. - if (node.matches(USER_ROOT_SELECTOR)) return [node]; + if (node.matches(COLLAPSIBLE_ROOT_SELECTOR)) return [node]; const out = new Set(); - const closest = node.closest(USER_ROOT_SELECTOR); + const closest = node.closest(COLLAPSIBLE_ROOT_SELECTOR); if (closest) out.add(closest); - for (const r of Array.from(node.querySelectorAll(USER_ROOT_SELECTOR))) out.add(r); + for (const r of Array.from(node.querySelectorAll(COLLAPSIBLE_ROOT_SELECTOR))) out.add(r); return Array.from(out); } @@ -305,7 +314,7 @@ export function installUserCollapse(): UserCollapseController { const wasPinned = scroller ? isPinnedToBottom(scroller) : false; for (const root of pendingRoots) { - processUserMessageRoot(root); + processCollapsibleMessageRoot(root); } pendingRoots.clear(); @@ -325,7 +334,7 @@ export function installUserCollapse(): UserCollapseController { try { ensureAttached(); } catch (e) { - logWarn('User collapse failed to re-attach:', e); + logWarn('Message collapse failed to re-attach:', e); } }); }; @@ -344,7 +353,7 @@ export function installUserCollapse(): UserCollapseController { // Avoid for..of over NodeList (requires DOM iterable lib typings). for (let i = 0; i < m.addedNodes.length; i++) { const n = m.addedNodes[i]; - const roots = collectUserRootsFromAddedNode(n); + const roots = collectCollapsibleRootsFromAddedNode(n); for (const r of roots) pendingRoots.add(r); } continue; @@ -354,10 +363,10 @@ export function installUserCollapse(): UserCollapseController { if (m.target instanceof HTMLElement) { // Attribute changes are usually on the root node, but can also happen on wrappers. // Include descendants to handle node recycling across chats. - if (m.target.matches(USER_ROOT_SELECTOR)) { + if (m.target.matches(COLLAPSIBLE_ROOT_SELECTOR)) { pendingRoots.add(m.target); } else { - const roots = collectUserRootsFromAddedNode(m.target); + const roots = collectCollapsibleRootsFromAddedNode(m.target); for (const r of roots) pendingRoots.add(r); } } @@ -375,7 +384,7 @@ export function installUserCollapse(): UserCollapseController { }); // Initial scan. - const initial = Array.from(container.querySelectorAll(USER_ROOT_SELECTOR)); + const initial = Array.from(container.querySelectorAll(COLLAPSIBLE_ROOT_SELECTOR)); for (const r of initial) pendingRoots.add(r); if (pendingRoots.size > 0) scheduleProcess(); }; @@ -464,7 +473,7 @@ export function installUserCollapse(): UserCollapseController { try { ensureAttached(); } catch (e) { - logWarn('User collapse failed to attach:', e); + logWarn('Message collapse failed to attach:', e); } }; @@ -497,11 +506,11 @@ export function installUserCollapse(): UserCollapseController { // Remove UI affordances/classes. const main = getMain(); const scope = main || document; - for (const root of Array.from(scope.querySelectorAll(USER_ROOT_SELECTOR))) { - const bubble = root.querySelector(BUBBLE_SELECTOR); - if (!bubble) continue; - const text = findTextContainer(bubble); - if (text) removeCollapseUi(root, bubble, text); + for (const root of Array.from(scope.querySelectorAll(COLLAPSIBLE_ROOT_SELECTOR))) { + const text = findTextContainer(root); + if (!text) continue; + const bubble = deriveBubbleContainer(root, text); + removeCollapseUi(root, bubble, text); } container = null; From a8a96e70a75510c72961d1913b116def6e3adf39 Mon Sep 17 00:00:00 2001 From: Seungmin Date: Thu, 26 Feb 2026 15:02:16 +0900 Subject: [PATCH 2/3] Keep assistant replies expanded on first render --- extension/src/content/user-collapse.ts | 64 ++++++++++++++++---------- tests/unit/user-collapse.test.ts | 32 +++++++++++++ 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/extension/src/content/user-collapse.ts b/extension/src/content/user-collapse.ts index df5e5e9..7bc3320 100644 --- a/extension/src/content/user-collapse.ts +++ b/extension/src/content/user-collapse.ts @@ -1,5 +1,5 @@ /** - * LightSession for ChatGPT - Collapse long user messages (presentation-only) + * LightSession for ChatGPT - Collapse long chat messages (presentation-only) * * Constraints: * - Do not truncate or rewrite message text content (no innerHTML rewriting). @@ -13,7 +13,8 @@ const STYLE_ID = 'lightsession-user-collapse-styles'; const PROCESSED_ATTR = 'data-ls-uc-processed'; const STATE_ATTR = 'data-ls-uc-state'; // "collapsed" | "expanded" -const USER_ROOT_SELECTOR = '[data-message-author-role="user"][data-message-id]'; +const COLLAPSIBLE_ROOT_SELECTOR = + '[data-message-author-role="user"][data-message-id], [data-message-author-role="assistant"][data-message-id]'; const ANY_ROLE_ROOT_SELECTOR = '[data-message-author-role][data-message-id]'; const BUBBLE_SELECTOR = '.user-message-bubble-color'; const TEXT_SELECTORS = ['.whitespace-pre-wrap', '.markdown.prose', '.markdown', '.prose'] as const; @@ -34,7 +35,7 @@ function ensureStyles(): void { const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = ` -/* LightSession: user message collapse */ +/* LightSession: chat message collapse */ .ls-uc-bubble { position: relative; } .ls-uc-text { position: relative; } @@ -213,6 +214,11 @@ function applyFadeColor(bubble: HTMLElement, text: HTMLElement): void { } } + +function shouldStartExpanded(root: HTMLElement): boolean { + return root.getAttribute('data-message-author-role') === 'assistant'; +} + function removeCollapseUi(root: HTMLElement, bubble: HTMLElement, text: HTMLElement): void { bubble.removeAttribute(STATE_ATTR); bubble.classList.remove('ls-uc-bubble'); @@ -234,22 +240,30 @@ function ensureCollapseUi(root: HTMLElement, bubble: HTMLElement, text: HTMLElem text.id = textId; if (!bubble.getAttribute(STATE_ATTR)) { - bubble.setAttribute(STATE_ATTR, 'collapsed'); + bubble.setAttribute(STATE_ATTR, shouldStartExpanded(root) ? 'expanded' : 'collapsed'); } const btn = ensureButton(bubble, textId); updateButtonUi(btn, bubble.getAttribute(STATE_ATTR) === 'expanded'); - logDebug('User collapse applied for message:', messageId); + logDebug('Collapse applied for message:', messageId); } -function processUserMessageRoot(root: HTMLElement): void { - const bubble = root.querySelector(BUBBLE_SELECTOR); - if (!bubble) return; +function deriveBubbleContainer(root: HTMLElement, text: HTMLElement): HTMLElement { + const knownBubble = root.querySelector(BUBBLE_SELECTOR); + if (knownBubble) return knownBubble; - const text = findTextContainer(bubble); + // Assistant turns typically don't use the user bubble class. + // Use the text container's parent so button positioning is localized to the message content. + return text.parentElement instanceof HTMLElement ? text.parentElement : root; +} + +function processCollapsibleMessageRoot(root: HTMLElement): void { + const text = findTextContainer(root); if (!text) return; + const bubble = deriveBubbleContainer(root, text); + // Measure for "long" before clamping. Caller batches this in rAF. const fullHeight = text.scrollHeight; const isLong = fullHeight > COLLAPSED_MAX_HEIGHT_PX + 24; @@ -263,18 +277,18 @@ function processUserMessageRoot(root: HTMLElement): void { ensureCollapseUi(root, bubble, text); } -function collectUserRootsFromAddedNode(node: unknown): HTMLElement[] { +function collectCollapsibleRootsFromAddedNode(node: unknown): HTMLElement[] { if (!(node instanceof HTMLElement)) return []; // Avoid duplicate work; pendingRoots is a Set but array creation can still be expensive on large mutation batches. - if (node.matches(USER_ROOT_SELECTOR)) return [node]; + if (node.matches(COLLAPSIBLE_ROOT_SELECTOR)) return [node]; const out = new Set(); - const closest = node.closest(USER_ROOT_SELECTOR); + const closest = node.closest(COLLAPSIBLE_ROOT_SELECTOR); if (closest) out.add(closest); - for (const r of Array.from(node.querySelectorAll(USER_ROOT_SELECTOR))) out.add(r); + for (const r of Array.from(node.querySelectorAll(COLLAPSIBLE_ROOT_SELECTOR))) out.add(r); return Array.from(out); } @@ -305,7 +319,7 @@ export function installUserCollapse(): UserCollapseController { const wasPinned = scroller ? isPinnedToBottom(scroller) : false; for (const root of pendingRoots) { - processUserMessageRoot(root); + processCollapsibleMessageRoot(root); } pendingRoots.clear(); @@ -325,7 +339,7 @@ export function installUserCollapse(): UserCollapseController { try { ensureAttached(); } catch (e) { - logWarn('User collapse failed to re-attach:', e); + logWarn('Message collapse failed to re-attach:', e); } }); }; @@ -344,7 +358,7 @@ export function installUserCollapse(): UserCollapseController { // Avoid for..of over NodeList (requires DOM iterable lib typings). for (let i = 0; i < m.addedNodes.length; i++) { const n = m.addedNodes[i]; - const roots = collectUserRootsFromAddedNode(n); + const roots = collectCollapsibleRootsFromAddedNode(n); for (const r of roots) pendingRoots.add(r); } continue; @@ -354,10 +368,10 @@ export function installUserCollapse(): UserCollapseController { if (m.target instanceof HTMLElement) { // Attribute changes are usually on the root node, but can also happen on wrappers. // Include descendants to handle node recycling across chats. - if (m.target.matches(USER_ROOT_SELECTOR)) { + if (m.target.matches(COLLAPSIBLE_ROOT_SELECTOR)) { pendingRoots.add(m.target); } else { - const roots = collectUserRootsFromAddedNode(m.target); + const roots = collectCollapsibleRootsFromAddedNode(m.target); for (const r of roots) pendingRoots.add(r); } } @@ -375,7 +389,7 @@ export function installUserCollapse(): UserCollapseController { }); // Initial scan. - const initial = Array.from(container.querySelectorAll(USER_ROOT_SELECTOR)); + const initial = Array.from(container.querySelectorAll(COLLAPSIBLE_ROOT_SELECTOR)); for (const r of initial) pendingRoots.add(r); if (pendingRoots.size > 0) scheduleProcess(); }; @@ -464,7 +478,7 @@ export function installUserCollapse(): UserCollapseController { try { ensureAttached(); } catch (e) { - logWarn('User collapse failed to attach:', e); + logWarn('Message collapse failed to attach:', e); } }; @@ -497,11 +511,11 @@ export function installUserCollapse(): UserCollapseController { // Remove UI affordances/classes. const main = getMain(); const scope = main || document; - for (const root of Array.from(scope.querySelectorAll(USER_ROOT_SELECTOR))) { - const bubble = root.querySelector(BUBBLE_SELECTOR); - if (!bubble) continue; - const text = findTextContainer(bubble); - if (text) removeCollapseUi(root, bubble, text); + for (const root of Array.from(scope.querySelectorAll(COLLAPSIBLE_ROOT_SELECTOR))) { + const text = findTextContainer(root); + if (!text) continue; + const bubble = deriveBubbleContainer(root, text); + removeCollapseUi(root, bubble, text); } container = null; diff --git a/tests/unit/user-collapse.test.ts b/tests/unit/user-collapse.test.ts index 801c6bf..1150fb0 100644 --- a/tests/unit/user-collapse.test.ts +++ b/tests/unit/user-collapse.test.ts @@ -199,6 +199,38 @@ describe('user-collapse', () => { expect(bubble.getAttribute('data-ls-uc-state')).toBe('collapsed'); }); + + it('starts long assistant messages expanded by default', () => { + document.body.innerHTML = ` +
+
+
+
+
Long assistant response
+
+
+
+
+ `; + + const main = document.querySelector('main') as HTMLElement; + mockLayout(main, { scrollHeight: 2000, clientHeight: 800 }); + + const text = document.querySelector('.markdown.prose') as HTMLElement; + mockLayout(text, { scrollHeight: 1200, clientHeight: 120, rectHeight: 1200 }); + + const ctrl = installUserCollapse(); + ctrl.enable(); + lastCtrl = ctrl; + + const bubble = text.parentElement as HTMLElement; + const btn = bubble.querySelector('button.ls-uc-toggle') as HTMLButtonElement; + + expect(btn).not.toBeNull(); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + expect(bubble.getAttribute('data-ls-uc-state')).toBe('expanded'); + }); + it('does not duplicate toggles when processing the same message again', () => { document.body.innerHTML = `
From 19bc5d58ccb1a85d4a401f669fafdb6058da61c0 Mon Sep 17 00:00:00 2001 From: Seungmin Date: Thu, 26 Feb 2026 15:18:03 +0900 Subject: [PATCH 3/3] Expand only latest assistant response by default --- extension/src/content/user-collapse.ts | 36 ++++++++++- tests/unit/user-collapse.test.ts | 86 ++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/extension/src/content/user-collapse.ts b/extension/src/content/user-collapse.ts index 7bc3320..8ee4e24 100644 --- a/extension/src/content/user-collapse.ts +++ b/extension/src/content/user-collapse.ts @@ -215,10 +215,22 @@ function applyFadeColor(bubble: HTMLElement, text: HTMLElement): void { } -function shouldStartExpanded(root: HTMLElement): boolean { +function isAssistantRoot(root: HTMLElement): boolean { return root.getAttribute('data-message-author-role') === 'assistant'; } +function isLatestAssistantRoot(root: HTMLElement): boolean { + const allAssistantRoots = Array.from( + document.querySelectorAll('[data-message-author-role="assistant"][data-message-id]') + ); + if (allAssistantRoots.length === 0) return false; + return allAssistantRoots[allAssistantRoots.length - 1] === root; +} + +function shouldStartExpanded(root: HTMLElement): boolean { + return isAssistantRoot(root) && isLatestAssistantRoot(root); +} + function removeCollapseUi(root: HTMLElement, bubble: HTMLElement, text: HTMLElement): void { bubble.removeAttribute(STATE_ATTR); bubble.classList.remove('ls-uc-bubble'); @@ -258,6 +270,27 @@ function deriveBubbleContainer(root: HTMLElement, text: HTMLElement): HTMLElemen return text.parentElement instanceof HTMLElement ? text.parentElement : root; } + +function normalizeAssistantExpansion(): void { + const allAssistantRoots = Array.from( + document.querySelectorAll('[data-message-author-role="assistant"][data-message-id]') + ); + if (allAssistantRoots.length === 0) return; + + const latestAssistant = allAssistantRoots[allAssistantRoots.length - 1]; + for (const root of allAssistantRoots) { + const text = findTextContainer(root); + if (!text) continue; + const bubble = deriveBubbleContainer(root, text); + const btn = bubble.querySelector('button.ls-uc-toggle'); + if (!btn) continue; + + const expanded = root === latestAssistant; + bubble.setAttribute(STATE_ATTR, expanded ? 'expanded' : 'collapsed'); + updateButtonUi(btn, expanded); + } +} + function processCollapsibleMessageRoot(root: HTMLElement): void { const text = findTextContainer(root); if (!text) return; @@ -322,6 +355,7 @@ export function installUserCollapse(): UserCollapseController { processCollapsibleMessageRoot(root); } pendingRoots.clear(); + normalizeAssistantExpansion(); if (scroller && wasPinned) { // Keep user pinned to bottom if they were pinned before we changed layout. diff --git a/tests/unit/user-collapse.test.ts b/tests/unit/user-collapse.test.ts index 1150fb0..18b170c 100644 --- a/tests/unit/user-collapse.test.ts +++ b/tests/unit/user-collapse.test.ts @@ -231,6 +231,92 @@ describe('user-collapse', () => { expect(bubble.getAttribute('data-ls-uc-state')).toBe('expanded'); }); + + it('keeps only the latest long assistant message expanded', () => { + document.body.innerHTML = ` +
+
+
+
+
Long assistant response 1
+
+
+
+
+
Long assistant response 2
+
+
+
+
+ `; + + const main = document.querySelector('main') as HTMLElement; + mockLayout(main, { scrollHeight: 2000, clientHeight: 800 }); + + const text1 = document.getElementById('a1-text') as HTMLElement; + const text2 = document.getElementById('a2-text') as HTMLElement; + mockLayout(text1, { scrollHeight: 1200, clientHeight: 120, rectHeight: 1200 }); + mockLayout(text2, { scrollHeight: 1300, clientHeight: 130, rectHeight: 1300 }); + + const ctrl = installUserCollapse(); + ctrl.enable(); + lastCtrl = ctrl; + + const bubble1 = text1.parentElement as HTMLElement; + const bubble2 = text2.parentElement as HTMLElement; + + expect(bubble1.getAttribute('data-ls-uc-state')).toBe('collapsed'); + expect(bubble2.getAttribute('data-ls-uc-state')).toBe('expanded'); + }); + + it('collapses previous assistant message when a newer assistant response arrives', () => { + document.body.innerHTML = ` +
+
+
+
+
Long assistant response 1
+
+
+
+
+ `; + + const main = document.querySelector('main') as HTMLElement; + mockLayout(main, { scrollHeight: 2000, clientHeight: 800 }); + + const text1 = document.getElementById('a1-text') as HTMLElement; + mockLayout(text1, { scrollHeight: 1200, clientHeight: 120, rectHeight: 1200 }); + + const ctrl = installUserCollapse(); + ctrl.enable(); + lastCtrl = ctrl; + + const turns = document.getElementById('turns') as HTMLElement; + const mo = getObserverForContainer(turns); + + const newRoot = document.createElement('div'); + newRoot.setAttribute('data-message-author-role', 'assistant'); + newRoot.setAttribute('data-message-id', 'a2'); + const wrap = document.createElement('div'); + const text2 = document.createElement('div'); + text2.className = 'markdown prose'; + text2.id = 'a2-text'; + text2.textContent = 'Long assistant response 2'; + wrap.appendChild(text2); + newRoot.appendChild(wrap); + turns.appendChild(newRoot); + + mockLayout(text2, { scrollHeight: 1300, clientHeight: 130, rectHeight: 1300 }); + + mo.trigger([{ type: 'childList', addedNodes: [newRoot] }]); + + const bubble1 = text1.parentElement as HTMLElement; + const bubble2 = text2.parentElement as HTMLElement; + expect(bubble1.getAttribute('data-ls-uc-state')).toBe('collapsed'); + expect(bubble2.getAttribute('data-ls-uc-state')).toBe('expanded'); + }); + it('does not duplicate toggles when processing the same message again', () => { document.body.innerHTML = `