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
5 changes: 5 additions & 0 deletions docs/architecture/theme-token-optimization.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ Primitive/reference 色值只存在于主题 authoring、明确的主题 preset
数值接近不是唯一判断标准;相邻背景/边框、文本层级、状态色、diff、syntax 和数据系列必须结合同时
出现时的区分度审查。反过来,也不能以“可能有视觉差异”为理由给每个组件建立近似私有颜色。

对话注释由 Web UI Appearance 的 `--openbitfun-component-conversation-excerpt-accent` 承载 UIKit 青色,
引用公开浅色主题的 accent(`#059cb0`),保持各内置主题的注释标识一致。全局 selection 是中性选中语义,
全局 accent 则随主题变化,均不满足这项局部设计约束。正文拖选、编辑与定位高亮使用此色的 30% 底色和
同色文字;注释编号和工具栏交互复用同一 Token。系统强制色模式下正文选区使用系统 Highlight/HighlightText。

## 普通 UI 的硬约束

普通应用组件和页面必须满足:
Expand Down
46 changes: 36 additions & 10 deletions src/web-ui/src/flow_chat/components/ChatInputAttachments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ describe('numbered composer attachments', () => {
});
const dialog = () => document.querySelector<HTMLDivElement>('[role="dialog"][data-state="open"]');
const click = (node: HTMLElement) => { act(() => node.click()); flushFrames(); };
const trigger = (number: number) => container.querySelector<HTMLButtonElement>(`button[aria-label="Annotation ${number}"]`)!;
const chip = () => container.querySelector<HTMLButtonElement>('[data-openbitfun-product-part="attachment"]')!;
const trigger = (number: number) => {
if (!document.querySelector('[data-openbitfun-product-part="details"]')) click(chip());
return document.querySelector<HTMLButtonElement>(`[data-openbitfun-product-part="details"] button[aria-label="Annotation ${number}"]`)!;
};
const action = (key: string) => [...dialog()!.querySelectorAll('button')].find(button => button.textContent === key)!;
const typeComment = (value: string) => act(() => {
const textarea = dialog()!.querySelector('textarea')!;
Expand All @@ -92,18 +96,22 @@ describe('numbered composer attachments', () => {
vi.unstubAllGlobals();
});

it('shows images and individual annotations in one strip and removes only the chosen attachment', () => {
it('groups annotations in a capsule, previews on hover, and removes only the chosen detail', () => {
act(() => root.render(<Composer />));
const strip = container.querySelector('[data-openbitfun-part="imageStrip"]')!;
expect(strip.querySelector('img')?.alt).toBe('Photo.png');
expect(strip.contains(trigger(1))).toBe(true);
expect(strip.contains(trigger(2))).toBe(true);
click(container.querySelector<HTMLButtonElement>('[aria-label="Remove Annotation 1"]')!);
expect(strip.contains(chip())).toBe(true);
expect(document.querySelector('[data-openbitfun-product-part="details"]')).toBeNull();
act(() => chip().dispatchEvent(new MouseEvent('mouseover', { bubbles: true })));
flushFrames();
expect(document.querySelector('[data-openbitfun-product-part="details"]')?.textContent).toContain(excerpt.fragments[0].text);
expect(document.querySelector('[data-openbitfun-product-part="details"]')?.textContent).toContain('First comment');
click(document.querySelector<HTMLButtonElement>('[aria-label="Remove Annotation 1"]')!);
expect(callbacks.remove).toHaveBeenCalledWith('annotation-1');
expect(trigger(1)).toBeNull();
expect(trigger(2).textContent).toBe('#2');
expect(trigger(2)).not.toBeNull();
expect(strip.querySelector('img')).not.toBeNull();
expect(dialog()).toBeNull();
expect(document.querySelector('textarea')).toBeNull();
});

it('edits only the selected comment, keeps the number, and contains composer keyboard shortcuts', () => {
Expand Down Expand Up @@ -131,13 +139,29 @@ describe('numbered composer attachments', () => {
act(() => textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true })));
expect(callbacks.update).toHaveBeenCalledExactlyOnceWith(excerpt.id, 'Revised');
expect(dialog()).toBeNull();
expect(trigger(1).textContent).toBe('#1');
expect(trigger(1)).not.toBeNull();
click(trigger(2));
expect(dialog()!.querySelector('textarea')!.value).toBe('Second comment');
click([...dialog()!.querySelectorAll('button')].find(button => button.textContent === 'selection.cancel')!);
expect(callbacks.update).toHaveBeenCalledOnce();
});

it('opens details from keyboard focus, dismisses with Escape, and clears annotations without removing images', () => {
act(() => root.render(<Composer />));
act(() => chip().focus());
flushFrames();
expect(chip().getAttribute('aria-expanded')).toBe('true');
act(() => chip().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })));
expect(document.activeElement?.getAttribute('aria-label')).toBe('Annotation 1');
act(() => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
expect(document.activeElement).toBe(chip());
expect(document.querySelector('[data-openbitfun-product-part="details"]')).toBeNull();
click(container.querySelector<HTMLButtonElement>('[aria-label="selection.remove"]')!);
expect(callbacks.remove.mock.calls).toEqual([['annotation-1'], ['annotation-2']]);
expect(chip()).toBeNull();
expect(container.querySelector('img')?.alt).toBe('Photo.png');
});

it('views sent source markers and message annotations without editor controls or write shortcuts', () => {
act(() => root.render(<div onKeyDown={callbacks.outerKey}>
<ConversationExcerptPreview excerpt={excerpt} superscript origin="source" />
Expand Down Expand Up @@ -172,13 +196,14 @@ describe('numbered composer attachments', () => {
<ChatInputAttachments contexts={[draft]} surfaceEpoch={getActiveSurfaceScope().epoch}
onUpdate={callbacks.update} onRemove={callbacks.remove} />
</>));
click(trigger(1));
click(container.querySelector<HTMLButtonElement>('button[aria-label="Annotation 1"]')!);
expect(dialog()!.querySelector('[data-openbitfun-product-part="comment"]')!.textContent).toBe('First comment');
expect(dialog()!.querySelector('textarea')).toBeNull();
click(action('selection.locate'));
expect(callbacks.locate).toHaveBeenCalledExactlyOnceWith(excerpt, expect.any(Function));
expect(useContextStore.getState().contexts).toEqual([draft]);
click(container.querySelector<HTMLButtonElement>('[data-openbitfun-product-part="attachment"]')!);
click(trigger(1));
expect(dialog()!.querySelector('textarea')!.value).toBe('Unsent draft revision');
typeComment('Continue editing draft');
click(action('selection.save'));
Expand All @@ -187,7 +212,7 @@ describe('numbered composer attachments', () => {

it('shows only the source quote and locate action for a sent annotation without a comment', () => {
act(() => root.render(<ConversationExcerptPreview excerpt={{ ...excerpt, comment: undefined }} />));
click(trigger(1));
click(container.querySelector<HTMLButtonElement>('button[aria-label="Annotation 1"]')!);
expect(dialog()!.querySelector('textarea, input, [data-openbitfun-product-part="comment"]')).toBeNull();
expect(dialog()!.querySelector('[data-openbitfun-product-part="quote"]')!.textContent).toBe(excerpt.fragments[0].text);
expect(action('selection.locate')).toBeDefined();
Expand All @@ -207,6 +232,7 @@ describe('numbered composer attachments', () => {
expect(useContextStore.getState().contexts).toEqual([{ ...excerpt, comment: 'From the source' }]);
expect(sessionComposerStore.getState().getDraft('main').contexts).toEqual([{ ...excerpt, comment: 'From the source' }]);
click(attachment());
click(trigger(1));
expect(dialog()!.querySelector('textarea')!.value).toBe('From the source');
expect([...dialog()!.querySelectorAll('button')].map(button => button.textContent)).toEqual(actions);
typeComment('From the attachment');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ each missing what the other had.
|---|---|
| `../../hooks/useSessionReadOnOpen.test.tsx` | opening and foreground results mark read; inactive scenes, background windows, unmounts, and device switches cannot acknowledge from stale views |
| `../../selection/conversationExcerptInventory.test.ts` | source session/device isolation, draft/queue removal, sent-number retention, stable snapshots during streaming |
| `../../selection/conversationExcerptMarkerPosition.test.ts` | text endpoint coordinates, wrapping and nested clipping; geometry contracts only, not visual acceptance |
| `../../selection/conversationExcerptMarkerPosition.test.ts` | full selection bounds, measured badge groups, persistent upper-right placement over occupied text, and clipping; geometry contracts only, not visual acceptance |
| `../ChatInputAttachments.test.tsx` | shared image/annotation strip, pending editing/removal, read-only sent snapshots with no write controls/shortcuts, one-line source quote, locate, keyboard containment and stale-device rejection |
| `../../selection/conversationExcerptEditing.test.ts` | draft/visible/queue ownership, preserved prompt context and attachments, legacy payload handling, sending guards and sent snapshots with no edit capability |
| `../../services/flow-chat-manager/PendingQueueModule.test.ts` | persisted payload edits preserve queue order/identity, images and device isolation; sending entries reject edits |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,14 +271,22 @@ numbers. The number is additive presentation metadata, never annotation identity
legacy excerpts without it remain readable. New numbers follow the loaded session
family's saved, queued and pending annotations, not a cross-controller global counter.

`ConversationExcerptMarkers` paints numbered superscripts at each selected text
fragment's trailing caret in a row-local overlay. Selections in the same block keep
separate anchors, and wrapping follows the caret's current line. It validates the
frozen text anchor, omits hidden/clipped endpoints, observes only
`ConversationExcerptMarkers` paints numbered superscripts above the top-right
of each selected fragment's full text bounds in a row-local overlay. It measures
the badge group and overlaps the selection's upper-right corner by 6px on both axes,
leaving the marker body above the selected text. The marker may cover surrounding
text or another marker so a dense layout never hides the annotation.
Selection bounds use selected text runs, excluding full-width block rectangles.
Selections with different starts keep separate anchors even when
their ends match. It validates the frozen text anchor, clips partially visible anchors
to the row's visible bounds, omits fully hidden anchors, observes only
mounted rows and releases observers on unmount. It changes neither transcript text
nor row keys, row height, or scroll position. Images and individual annotation tiles
share the composer attachment strip. Source superscripts and composer attachments
open `ConversationExcerptDialog`; creation shares its compact editor content.
nor row keys, row height, or scroll position. Persistent CSS highlights follow the
draft, queued and sent annotation inventory and release their row-owned ranges on
unmount. Images and an annotation-count capsule share the composer attachment strip.
The capsule opens a hover/focus/click detail list with individual edit/remove actions.
Source superscripts and detail edit actions open `ConversationExcerptDialog`;
creation shares its compact editor content.
Both modes show one ellipsized, quoted source line, with both quote marks outside
the clipped text so the closing quote remains visible. Pending annotations use an
unlabelled textarea with an accessible name. Sent-message entries always view
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ export const conversationExcerptAppearanceDescriptor: AppearanceSurfaceDescripto
id: 'conversation-excerpt',
componentAttribute: 'data-openbitfun-product-component',
parts: [
{ id: 'root' }, { id: 'toolbar' }, { id: 'editor' }, { id: 'attachments' },
{ id: 'root' }, { id: 'toolbar' }, { id: 'action' }, { id: 'editor' }, { id: 'attachments' },
{ id: 'attachment' }, { id: 'dialog' }, { id: 'quote' }, { id: 'quoteText' },
{ id: 'comment' }, { id: 'locate' }, { id: 'preview' }, { id: 'markers' }, { id: 'superscript' },
{ id: 'chip' }, { id: 'details' }, { id: 'detail' }, { id: 'detailNumber' }, { id: 'detailText' }, { id: 'detailLabel' },
],
states: [],
};
139 changes: 128 additions & 11 deletions src/web-ui/src/flow_chat/selection/ConversationExcerpt.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,44 @@
z-index: var(--openbitfun-layer-popover);
inline-size: max-content;
max-inline-size: calc(100vw - var(--openbitfun-space-4));
padding: var(--openbitfun-space-1);
padding: 0;
border-radius: var(--openbitfun-radius-pill);
border: 0.5px solid var(--openbitfun-color-border-subtle);
background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 80%, transparent);
box-shadow: var(--openbitfun-shadow-sm);
backdrop-filter: var(--openbitfun-effect-blur-base);
}

&__toolbar { flex: 0 1 auto; }
&__toolbar {
flex: 0 1 auto;
gap: 0;

> [data-openbitfun-part='separator'] { block-size: var(--openbitfun-space-4); }
}

&__action {
block-size: 30px;
padding: 7px var(--openbitfun-space-3);
border: 0;
border-radius: var(--openbitfun-radius-pill);
color: var(--openbitfun-color-content-primary);
font-size: var(--openbitfun-type-body-sm-font-size);

[data-openbitfun-part='leading-icon'] { inline-size: 14px; block-size: 14px; }

&:hover:not(:disabled), &:active:not(:disabled) {
color: var(--openbitfun-component-conversation-excerpt-accent);
text-decoration: none;
}

&:hover:not(:disabled)::before {
background: color-mix(in srgb, var(--openbitfun-component-conversation-excerpt-accent) 10%, transparent);
}

&:active:not(:disabled)::before {
background: color-mix(in srgb, var(--openbitfun-component-conversation-excerpt-accent) 20%, transparent);
}
}

&__dialog {
> [data-openbitfun-part='header'] {
Expand Down Expand Up @@ -82,12 +116,74 @@
}

&__attachment {
inline-size: 100%;
block-size: 100%;
block-size: 28px;
min-inline-size: 0;
padding: var(--openbitfun-space-1);
max-inline-size: 200px;
padding: 0 var(--openbitfun-space-2);
border-radius: inherit;
font-variant-numeric: tabular-nums;
color: var(--openbitfun-color-content-secondary);

&:hover:not(:disabled), &:active:not(:disabled) {
color: var(--openbitfun-component-conversation-excerpt-accent);
text-decoration: none;
}
}

&__chip {
display: inline-flex;
align-items: center;
max-inline-size: 100%;
padding: var(--openbitfun-space-1);
border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle);
border-radius: var(--openbitfun-radius-pill);
background: var(--openbitfun-color-surface-raised);
gap: var(--openbitfun-space-1);
}

&__details {
position: fixed;
z-index: var(--openbitfun-layer-popover);
inline-size: 560px;
max-inline-size: calc(100vw - var(--openbitfun-space-4));
max-block-size: min(400px, calc(100vh - var(--openbitfun-space-6)));
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--openbitfun-space-4);
border-radius: var(--openbitfun-radius-lg);
box-shadow: var(--openbitfun-shadow-sm);
}

&__detail {
display: flex;
align-items: flex-start;
gap: var(--openbitfun-space-2);
color: var(--openbitfun-color-content-primary);
font-family: var(--openbitfun-type-body-sm-font-family);
font-size: var(--openbitfun-type-body-sm-font-size);
font-weight: var(--openbitfun-type-body-sm-font-weight);
line-height: var(--openbitfun-type-body-sm-line-height);
letter-spacing: var(--openbitfun-type-body-sm-letter-spacing);

& + & {
margin-block-start: var(--openbitfun-space-3);
padding-block-start: var(--openbitfun-space-3);
border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle);
}
}

&__detail-number { color: var(--openbitfun-color-content-muted); font-variant-numeric: tabular-nums; }
&__detail-text { flex: 1; min-inline-size: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
&__detail-label { display: block; color: var(--openbitfun-color-content-muted); }

&__superscript {
color: var(--openbitfun-color-content-inverse);

&::before,
&:hover:not(:disabled)::before,
&:active:not(:disabled)::before {
background: var(--openbitfun-component-conversation-excerpt-accent);
}
}

&__preview { display: inline-flex; max-inline-size: 100%; vertical-align: baseline; }
Expand All @@ -97,7 +193,6 @@
z-index: 1;
display: flex;
gap: var(--openbitfun-space-1);
transform: translate(-100%, -75%);
max-inline-size: 100%;
font-size: inherit;
line-height: inherit;
Expand Down Expand Up @@ -126,19 +221,41 @@
inline-size: var(--openbitfun-space-1);
block-size: var(--openbitfun-space-1);
clip-path: polygon(0 0, 100% 0, 0 100%);
background: var(--openbitfun-component-button-primary-background);
background: var(--openbitfun-component-conversation-excerpt-accent);
}

&:hover::after { background: var(--openbitfun-component-button-primary-background-hover); }
&:active::after { background: var(--openbitfun-component-button-primary-background-pressed); }
&:hover::after,
&: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: var(--openbitfun-color-selection-surface);
color: var(--openbitfun-color-content-primary);
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; }
}
Loading
Loading