Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ each missing what the other had.
| `FlowChatTurnRail.test.tsx` | single-marker emphasis, neighboring hover fan, independent keyboard focus, reduced motion, and rail navigation |
| `useFlowChatSearch.test.ts` | exact matching-block decoration, occurrence counting, and search navigation state |
| `flowChatSearchDom.test.ts` | concrete text ranges and independent highlight ownership across rows and panes |
| `../../selection/flowChatHighlights.test.ts` | exact text-parent scoping across Markdown nodes, shared markers, cleanup and document isolation |
| `../../../infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts` | legacy accent projection, alpha preservation, theme changes and paint cleanup |
| `../../selection/flowChatSelection.test.ts` | Markdown selection boundaries, source isolation, repeated text anchors, and changed sources |
| `../../selection/FlowChatSelectionBar.test.tsx` | annotation Dialog focus containment and return, frozen excerpts during scroll/resize, and comment submission |
| `../../selection/useExcerptComposerActions.test.tsx` | main/side draft routing, focus after activation, ordinary child ownership, and stale surface rejection |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,32 @@ bounded Explore retains 8px bottom padding for its scroll fade. There is no
negative adjacent-region margin. The resident runtime slot stays 24px high and
continues to participate in the existing footer/reservation contract.

## Selection and custom highlight paint

Native transcript selections use the application selection style. Do not add
descendant `::selection` overrides to the chat root: WebKitGTK reports show
uncached highlight pseudo-style resolution during long-transcript repaint.

Search, temporary excerpts and persistent annotations use `flowChatHighlights`
to own both their CSS Highlight ranges and attributes on every intersecting text
parent. A range can span Markdown links/emphasis; marking only its first parent
loses paint. Each owner updates only its own parent set, shared parents are
reference-counted per document and highlight kind, and disposal cannot clear
another mounted row or pane. Attributes stay stable across unchanged updates
and are outside the annotation geometry observer's attribute filter.

The Appearance theme-token adapter projects the annotation accent's 30% tint
and indirect mixes in search/native-selection color tokens to concrete colors
when a theme is applied, including the chrome theme scope. Its renderer stylesheet supplies
existing semantic colors for sparse/legacy themes and system colors in forced
color mode; derived paint is not a persisted setting or a new theme token.
Scoped highlight rules remain in place without an active range, as with streaming
reveal. Moving color projection to its own stylesheet is an ownership decision,
not evidence of WebKit stylesheet-matching isolation or a measured speedup.

Linux/WebKitGTK long-session CPU and pseudo-style stacks still require runtime
verification. DOM tests establish range/marker lifecycle, not renderer performance.

## Streaming glyph presentation

The shared Markdown renderer paints newly appended text with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,12 @@
}
}

::highlight(openbitfun-flowchat-search-current) {
[data-flowchat-highlight-search-current]::highlight(openbitfun-flowchat-search-current) {
color: inherit;
background: var(--openbitfun-color-accent-border-subtle);
}

::highlight(openbitfun-flowchat-search-match) {
[data-flowchat-highlight-search-match]::highlight(openbitfun-flowchat-search-match) {
color: inherit;
background: var(--openbitfun-color-selection-surface);
}
Expand All @@ -145,12 +145,12 @@
}
}

::highlight(openbitfun-flowchat-search-current) {
[data-flowchat-highlight-search-current]::highlight(openbitfun-flowchat-search-current) {
color: HighlightText;
background: Highlight;
}

::highlight(openbitfun-flowchat-search-match) {
[data-flowchat-highlight-search-match]::highlight(openbitfun-flowchat-search-match) {
color: MarkText;
background: Mark;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe('FlowChat search highlight ownership', () => {
secondOwner.update(null, [second]);
expect([...registry.get('openbitfun-flowchat-search-current')!]).toEqual([first]);
expect([...registry.get('openbitfun-flowchat-search-match')!]).toEqual([second]);
expect([...registry.keys()].at(-1)).toBe('openbitfun-flowchat-search-current');

firstOwner.dispose();
expect(registry.has('openbitfun-flowchat-search-current')).toBe(false);
Expand Down
73 changes: 7 additions & 66 deletions src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,4 @@
const SEARCH_HIGHLIGHT_CURRENT_NAME = 'openbitfun-flowchat-search-current';
const SEARCH_HIGHLIGHT_MATCH_NAME = 'openbitfun-flowchat-search-match';

type HighlightRegistryLike = {
set: (name: string, highlight: unknown) => void;
delete: (name: string) => void;
};

type HighlightConstructorLike = new (...ranges: Range[]) => unknown;

interface SearchHighlightRanges {
current: Range | null;
matches: readonly Range[];
}

const documentHighlights = new WeakMap<Document, Map<object, SearchHighlightRanges>>();
import { createFlowChatHighlightOwner } from '../../selection/flowChatHighlights';

interface FoldedTextOffset {
start: number;
Expand Down Expand Up @@ -134,62 +119,18 @@ export function findFlowChatSearchTextRange(root: HTMLElement, query: string): R
return findFlowChatSearchTextRanges(root, query)[0] ?? null;
}

function publishSearchHighlights(ownerDocument: Document): void {
const view = ownerDocument.defaultView;
const cssWithHighlights = view?.CSS as (typeof CSS & {
highlights?: HighlightRegistryLike;
}) | undefined;
const HighlightConstructor = (view as (Window & {
Highlight?: HighlightConstructorLike;
}) | null)?.Highlight;

if (!cssWithHighlights?.highlights) {
return;
}

cssWithHighlights.highlights.delete(SEARCH_HIGHLIGHT_CURRENT_NAME);
cssWithHighlights.highlights.delete(SEARCH_HIGHLIGHT_MATCH_NAME);
if (!HighlightConstructor) {
return;
}
const owners = documentHighlights.get(ownerDocument)?.values() ?? [];
const current: Range[] = [];
const matches: Range[] = [];
for (const ranges of owners) {
if (ranges.current?.startContainer.isConnected) current.push(ranges.current);
matches.push(...ranges.matches.filter(range => range.startContainer.isConnected));
}
if (matches.length > 0) {
cssWithHighlights.highlights.set(
SEARCH_HIGHLIGHT_MATCH_NAME,
new HighlightConstructor(...matches),
);
}
// Register current last so it wins if two presentations share a text range.
if (current.length > 0) {
cssWithHighlights.highlights.set(SEARCH_HIGHLIGHT_CURRENT_NAME, new HighlightConstructor(...current));
}
}

/** Each mounted row releases only its own ranges, including across chat panes. */
export function createFlowChatSearchHighlightOwner(ownerDocument: Document) {
const owner = {};
let disposed = false;
const currentOwner = createFlowChatHighlightOwner(ownerDocument, 'search-current');
const matchOwner = createFlowChatHighlightOwner(ownerDocument, 'search-match');
return {
update(current: Range | null, matches: readonly Range[]) {
if (disposed) return;
let owners = documentHighlights.get(ownerDocument);
if (!owners) {
owners = new Map();
documentHighlights.set(ownerDocument, owners);
}
owners.set(owner, { current, matches });
publishSearchHighlights(ownerDocument);
matchOwner.update(matches);
currentOwner.update(current ? [current] : []);
},
dispose() {
disposed = true;
documentHighlights.get(ownerDocument)?.delete(owner);
publishSearchHighlights(ownerDocument);
currentOwner.dispose();
matchOwner.dispose();
},
};
}
Expand Down
31 changes: 0 additions & 31 deletions src/web-ui/src/flow_chat/selection/ConversationExcerpt.scss
Original file line number Diff line number Diff line change
Expand Up @@ -229,34 +229,3 @@
&:active::after { background: var(--openbitfun-component-conversation-excerpt-accent); }
}
}

[data-flowchat-selection-root]::selection,
[data-flowchat-selection-root] ::selection,
[data-openbitfun-product-component='conversation-excerpt']::selection,
[data-openbitfun-product-component='conversation-excerpt'] ::selection {
background-color: color-mix(in srgb, var(--openbitfun-component-conversation-excerpt-accent) 30%, transparent);
color: var(--openbitfun-component-conversation-excerpt-accent);
}

::highlight(openbitfun-flowchat-excerpt) {
background-color: color-mix(in srgb, var(--openbitfun-component-conversation-excerpt-accent) 30%, transparent);
color: var(--openbitfun-component-conversation-excerpt-accent);
}

::highlight(openbitfun-flowchat-annotations) {
background-color: color-mix(in srgb, var(--openbitfun-component-conversation-excerpt-accent) 30%, transparent);
color: var(--openbitfun-component-conversation-excerpt-accent);
}

@media (forced-colors: active) {
[data-flowchat-selection-root]::selection,
[data-flowchat-selection-root] ::selection,
[data-openbitfun-product-component='conversation-excerpt']::selection,
[data-openbitfun-product-component='conversation-excerpt'] ::selection {
background-color: Highlight;
color: HighlightText;
}

::highlight(openbitfun-flowchat-excerpt) { background-color: Highlight; color: HighlightText; }
::highlight(openbitfun-flowchat-annotations) { background-color: Highlight; color: HighlightText; }
}
18 changes: 18 additions & 0 deletions src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { contextMenuRegistry } from '@/shared/context-menu-system/core/ContextMe
import { ContextType, type SelectionContext } from '@/shared/context-menu-system/types/context.types';
import { FlowChatSelectionBar } from './FlowChatSelectionBar';
import { FLOWCHAT_EXCERPT_ACTION, type ExcerptActionRequest } from './excerptActions';
import { highlightExcerptRange } from './locateConversationExcerpt';

const state = vi.hoisted(() => ({
sessions: new Map([['main', { sessionId: 'main', title: 'Source session', workspacePath: '/workspace' }]]),
Expand Down Expand Up @@ -97,6 +98,23 @@ describe('selection annotation dialog lifecycle', () => {
return textarea;
}

it('releases temporary paint without allowing an old locate timer to clear the next excerpt', () => {
const highlights = new Map<string, Set<Range>>();
vi.stubGlobal('CSS', { highlights });
vi.stubGlobal('Highlight', class extends Set<Range> { constructor(...ranges: Range[]) { super(ranges); } });
const text = container.querySelector('[data-flow-item-id]')!;
const first = document.createRange(); first.selectNodeContents(text);
const releaseFirst = highlightExcerptRange(first);
const second = first.cloneRange(); second.setStart(text.firstChild!, 1);
const releaseSecond = highlightExcerptRange(second);
releaseFirst();
expect([...highlights.get('openbitfun-flowchat-excerpt')!]).toEqual([second]);
expect(text.hasAttribute('data-flowchat-highlight-excerpt')).toBe(true);
releaseSecond();
expect(highlights.size).toBe(0);
expect(text.hasAttribute('data-flowchat-highlight-excerpt')).toBe(false);
});

it('keeps the comment while the editor scrolls, resizes, or the source unmounts', async () => {
const surface = await openAnnotation();
const textarea = enterComment('Keep this note');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,6 @@
type ExcerptHighlight = Set<Range>;
interface HighlightView {
CSS?: { highlights?: Map<string, ExcerptHighlight> };
Highlight?: new (...ranges: Range[]) => ExcerptHighlight;
}

const documents = new WeakMap<Document, Map<symbol, readonly Range[]>>();
const name = 'openbitfun-flowchat-annotations';
import { createFlowChatHighlightOwner } from './flowChatHighlights';

/** Each mounted transcript row owns only its ranges, including in sibling panes. */
export function createExcerptHighlights(document: Document) {
const owner = Symbol();
let owners = documents.get(document);
if (!owners) { owners = new Map(); documents.set(document, owners); }
const publish = () => {
const view = document.defaultView as unknown as HighlightView | null;
if (!view?.CSS?.highlights || !view.Highlight) return;
const ranges = [...owners.values()].flat();
if (ranges.length) view.CSS.highlights.set(name, new view.Highlight(...ranges));
else view.CSS.highlights.delete(name);
};
return {
update(ranges: readonly Range[]) { owners.set(owner, ranges); publish(); },
dispose() { owners.delete(owner); publish(); },
};
return createFlowChatHighlightOwner(document, 'annotations');
}
117 changes: 117 additions & 0 deletions src/web-ui/src/flow_chat/selection/flowChatHighlights.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFlowChatHighlightOwner } from './flowChatHighlights';

const attribute = 'data-flowchat-highlight-annotations';
const name = 'openbitfun-flowchat-annotations';
class Highlight extends Set<Range> { constructor(...ranges: Range[]) { super(ranges); } }

describe('FlowChat highlight text scopes', () => {
let registry: Map<string, Highlight>;
const owners: ReturnType<typeof createFlowChatHighlightOwner>[] = [];
const owner = (kind: Parameters<typeof createFlowChatHighlightOwner>[1] = 'annotations', doc = document) => {
const result = createFlowChatHighlightOwner(doc, kind);
owners.push(result);
return result;
};
const content = () => {
const root = document.createElement('div');
root.innerHTML = '<p>before</p><p>plain <a>link</a><strong>bold</strong> tail</p><p>after</p>';
document.body.append(root);
return root;
};
beforeEach(() => {
registry = new Map();
vi.stubGlobal('CSS', { highlights: registry });
vi.stubGlobal('Highlight', Highlight);
});
afterEach(() => {
owners.splice(0).forEach(item => item.dispose());
document.body.replaceChildren();
vi.unstubAllGlobals();
});

it('scopes every intersecting text parent across Markdown nodes without touching surrounding text', () => {
const root = content();
const paragraph = root.children[1];
const range = document.createRange();
range.setStart(paragraph.firstChild!, 2);
range.setEnd(paragraph.querySelector('strong')!.firstChild!, 2);
owner().update([range]);
expect([...root.querySelectorAll(`[${attribute}]`)]).toEqual([
paragraph, paragraph.querySelector('a'), paragraph.querySelector('strong'),
]);
expect([...registry.get(name)!]).toEqual([range]);
expect(root.textContent).toBe('beforeplain linkbold tailafter');
});

it('handles element boundaries and excludes text touched only at an empty endpoint', () => {
const root = content();
const paragraph = root.children[1];
const range = document.createRange();
range.setStart(paragraph, 1); range.setEnd(paragraph, 2);
const paint = owner(); paint.update([range]);
expect([...root.querySelectorAll(`[${attribute}]`)]).toEqual([paragraph.querySelector('a')]);
range.setStart(paragraph.firstChild!, paragraph.firstChild!.textContent!.length);
range.setEnd(paragraph.querySelector('strong')!.firstChild!, 0);
paint.update([range]);
expect([...root.querySelectorAll(`[${attribute}]`)]).toEqual([paragraph.querySelector('a')]);
range.collapse(true); paint.update([range]);
expect(root.querySelector(`[${attribute}]`)).toBeNull();
expect(registry.has(name)).toBe(false);
});

it('retains shared parent markers across updates until the last owner releases them', () => {
const root = content(); const link = root.querySelector('a')!;
const range = document.createRange(); range.selectNodeContents(link);
const set = vi.spyOn(link, 'setAttribute'); const remove = vi.spyOn(link, 'removeAttribute');
const first = owner(); const second = owner();
first.update([range]); second.update([range]); first.update([range.cloneRange()]);
first.dispose(); first.dispose(); first.update([range]);
expect(set).toHaveBeenCalledTimes(1); expect(remove).not.toHaveBeenCalled();
expect(registry.get(name)?.size).toBe(1);
second.dispose();
expect(remove).toHaveBeenCalledTimes(1); expect(registry.has(name)).toBe(false);
});

it('cleans replaced parents and preserves independent highlight types', () => {
const root = content(); const link = root.querySelector('a')!;
const range = document.createRange(); range.selectNodeContents(link);
const annotations = owner(); const excerpt = owner('excerpt');
annotations.update([range]); excerpt.update([range]); annotations.update([]);
expect(link.hasAttribute(attribute)).toBe(false);
expect(link.hasAttribute('data-flowchat-highlight-excerpt')).toBe(true);
expect(registry.has('openbitfun-flowchat-excerpt')).toBe(true);
annotations.update([range]); root.replaceChildren();
annotations.update([range]); excerpt.update([range]);
expect(link.hasAttribute(attribute)).toBe(false);
expect(link.hasAttribute('data-flowchat-highlight-excerpt')).toBe(false);
expect(registry.size).toBe(0);
});

it('keeps documents independent and uses their own Highlight API', () => {
const frame = document.createElement('iframe'); document.body.append(frame);
const doc = frame.contentDocument!;
const remoteRegistry = new Map<string, Highlight>();
Object.defineProperty(doc.defaultView, 'CSS', { value: { highlights: remoteRegistry }, configurable: true });
Object.defineProperty(doc.defaultView, 'Highlight', { value: Highlight, configurable: true });
doc.body.innerHTML = '<p>other document</p>';
const range = doc.createRange(); range.selectNodeContents(doc.body.firstChild!);
const remote = owner('annotations', doc); remote.update([range]); owner().update([range]);
expect(registry.size).toBe(0); expect(remoteRegistry.get(name)?.size).toBe(1);
expect(doc.querySelector(`[${attribute}]`)).toBe(doc.body.firstChild);
remote.dispose();
expect(remoteRegistry.size).toBe(0); expect(doc.querySelector(`[${attribute}]`)).toBeNull();
});

it('does not mark unsupported highlights or remove a foreign registry entry', () => {
const root = content();
const range = document.createRange(); range.selectNodeContents(root.querySelector('a')!);
vi.stubGlobal('Highlight', undefined);
const paint = owner(); paint.update([range]);
expect(root.querySelector(`[${attribute}]`)).toBeNull();
vi.stubGlobal('Highlight', Highlight); paint.update([range]);
const foreign = new Highlight(range); registry.set(name, foreign); paint.dispose();
expect(registry.get(name)).toBe(foreign);
});
});
Loading
Loading