Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 73 additions & 25 deletions extension/src/content/user-collapse.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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;
Expand All @@ -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; }

Expand Down Expand Up @@ -213,6 +214,23 @@ function applyFadeColor(bubble: HTMLElement, text: HTMLElement): void {
}
}


function isAssistantRoot(root: HTMLElement): boolean {
return root.getAttribute('data-message-author-role') === 'assistant';
}

function isLatestAssistantRoot(root: HTMLElement): boolean {
const allAssistantRoots = Array.from(
document.querySelectorAll<HTMLElement>('[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');
Expand All @@ -234,22 +252,51 @@ 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 deriveBubbleContainer(root: HTMLElement, text: HTMLElement): HTMLElement {
const knownBubble = root.querySelector<HTMLElement>(BUBBLE_SELECTOR);
if (knownBubble) return knownBubble;

// 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 processUserMessageRoot(root: HTMLElement): void {
const bubble = root.querySelector<HTMLElement>(BUBBLE_SELECTOR);
if (!bubble) return;

const text = findTextContainer(bubble);
function normalizeAssistantExpansion(): void {
const allAssistantRoots = Array.from(
document.querySelectorAll<HTMLElement>('[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<HTMLButtonElement>('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;

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;
Expand All @@ -263,18 +310,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<HTMLElement>();

const closest = node.closest<HTMLElement>(USER_ROOT_SELECTOR);
const closest = node.closest<HTMLElement>(COLLAPSIBLE_ROOT_SELECTOR);
if (closest) out.add(closest);

for (const r of Array.from(node.querySelectorAll<HTMLElement>(USER_ROOT_SELECTOR))) out.add(r);
for (const r of Array.from(node.querySelectorAll<HTMLElement>(COLLAPSIBLE_ROOT_SELECTOR))) out.add(r);

return Array.from(out);
}
Expand Down Expand Up @@ -305,9 +352,10 @@ export function installUserCollapse(): UserCollapseController {
const wasPinned = scroller ? isPinnedToBottom(scroller) : false;

for (const root of pendingRoots) {
processUserMessageRoot(root);
processCollapsibleMessageRoot(root);
}
pendingRoots.clear();
normalizeAssistantExpansion();

if (scroller && wasPinned) {
// Keep user pinned to bottom if they were pinned before we changed layout.
Expand All @@ -325,7 +373,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);
}
});
};
Expand All @@ -344,7 +392,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;
Expand All @@ -354,10 +402,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);
}
}
Expand All @@ -375,7 +423,7 @@ export function installUserCollapse(): UserCollapseController {
});

// Initial scan.
const initial = Array.from(container.querySelectorAll<HTMLElement>(USER_ROOT_SELECTOR));
const initial = Array.from(container.querySelectorAll<HTMLElement>(COLLAPSIBLE_ROOT_SELECTOR));
for (const r of initial) pendingRoots.add(r);
if (pendingRoots.size > 0) scheduleProcess();
};
Expand Down Expand Up @@ -464,7 +512,7 @@ export function installUserCollapse(): UserCollapseController {
try {
ensureAttached();
} catch (e) {
logWarn('User collapse failed to attach:', e);
logWarn('Message collapse failed to attach:', e);
}
};

Expand Down Expand Up @@ -497,11 +545,11 @@ export function installUserCollapse(): UserCollapseController {
// Remove UI affordances/classes.
const main = getMain();
const scope = main || document;
for (const root of Array.from(scope.querySelectorAll<HTMLElement>(USER_ROOT_SELECTOR))) {
const bubble = root.querySelector<HTMLElement>(BUBBLE_SELECTOR);
if (!bubble) continue;
const text = findTextContainer(bubble);
if (text) removeCollapseUi(root, bubble, text);
for (const root of Array.from(scope.querySelectorAll<HTMLElement>(COLLAPSIBLE_ROOT_SELECTOR))) {
const text = findTextContainer(root);
if (!text) continue;
const bubble = deriveBubbleContainer(root, text);
removeCollapseUi(root, bubble, text);
}

container = null;
Expand Down
118 changes: 118 additions & 0 deletions tests/unit/user-collapse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,124 @@ describe('user-collapse', () => {
expect(bubble.getAttribute('data-ls-uc-state')).toBe('collapsed');
});


it('starts long assistant messages expanded by default', () => {
document.body.innerHTML = `
<main style="overflow-y:auto">
<div data-testid="conversation-turns">
<div data-message-author-role="assistant" data-message-id="a1">
<div>
<div class="markdown prose">Long assistant response</div>
</div>
</div>
</div>
</main>
`;

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('keeps only the latest long assistant message expanded', () => {
document.body.innerHTML = `
<main style="overflow-y:auto">
<div data-testid="conversation-turns">
<div data-message-author-role="assistant" data-message-id="a1">
<div>
<div class="markdown prose" id="a1-text">Long assistant response 1</div>
</div>
</div>
<div data-message-author-role="assistant" data-message-id="a2">
<div>
<div class="markdown prose" id="a2-text">Long assistant response 2</div>
</div>
</div>
</div>
</main>
`;

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 = `
<main style="overflow-y:auto">
<div data-testid="conversation-turns" id="turns">
<div data-message-author-role="assistant" data-message-id="a1" id="a1-root">
<div>
<div class="markdown prose" id="a1-text">Long assistant response 1</div>
</div>
</div>
</div>
</main>
`;

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 = `
<main style="overflow-y:auto">
Expand Down