Skip to content
Open
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
85 changes: 79 additions & 6 deletions apps/website/content/api/react/chat/MessageAction-ui79k6.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,80 @@
"members": [],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 8
"line": 10
},
"isReadonly": true,
"isOptional": true
},
{
"name": "icon",
"kind": "PropertySignature",
"canonicalReference": "@threadplane/react!MessageAction#icon:member",
"excerptTokens": [
{
"kind": "Content",
"text": "readonly icon?: "
},
{
"kind": "Reference",
"text": "ReactNode",
"canonicalReference": "@types/react!React.ReactNode:type"
},
{
"kind": "Content",
"text": ";"
}
],
"docs": {
"summary": "App-authored noninteractive decorative content; nullish and boolean values omit the icon.",
"remarks": "",
"deprecated": "",
"returns": "",
"examples": [],
"see": [],
"params": [],
"typeParams": []
},
"members": [],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 12
},
"isReadonly": true,
"isOptional": true
},
{
"name": "iconOnly",
"kind": "PropertySignature",
"canonicalReference": "@threadplane/react!MessageAction#iconOnly:member",
"excerptTokens": [
{
"kind": "Content",
"text": "readonly iconOnly?: "
},
{
"kind": "Content",
"text": "boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"docs": {
"summary": "Visually hides the label with opt-in styles when true and an icon is supplied.",
"remarks": "",
"deprecated": "",
"returns": "",
"examples": [],
"see": [],
"params": [],
"typeParams": []
},
"members": [],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 14
},
"isReadonly": true,
"isOptional": true
Expand Down Expand Up @@ -96,7 +169,7 @@
"members": [],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 5
"line": 7
},
"isReadonly": true,
"isOptional": false
Expand Down Expand Up @@ -132,7 +205,7 @@
"members": [],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 6
"line": 8
},
"isReadonly": true,
"isOptional": false
Expand Down Expand Up @@ -168,20 +241,20 @@
"members": [],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 7
"line": 9
},
"isReadonly": true,
"isOptional": false
}
],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 4
"line": 6
}
}
],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 4
"line": 6
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"members": [],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 20
"line": 26
},
"overloadIndex": 1,
"parameters": [
Expand Down Expand Up @@ -87,6 +87,6 @@
],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 20
"line": 26
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
"members": [],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 13
"line": 19
},
"isReadonly": true,
"isOptional": false
Expand Down Expand Up @@ -106,7 +106,7 @@
"members": [],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 16
"line": 22
},
"isReadonly": true,
"isOptional": true
Expand Down Expand Up @@ -142,7 +142,7 @@
"members": [],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 14
"line": 20
},
"isReadonly": true,
"isOptional": true
Expand Down Expand Up @@ -178,20 +178,20 @@
"members": [],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 15
"line": 21
},
"isReadonly": true,
"isOptional": true
}
],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 12
"line": 18
}
}
],
"source": {
"path": "libs/react/src/chat/message-actions.tsx",
"line": 12
"line": 18
}
}
33 changes: 29 additions & 4 deletions apps/website/content/docs/react/chat/components/chat-message.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This experimental, private React source-build component presents one message. It
import type { MessageRow } from '@threadplane/content/messages';
import {
ChatMessage,
MessageActions,
MessageList,
Reasoning,
Citations,
Expand All @@ -19,7 +20,10 @@ import {
import { Markdown } from '@threadplane/react/markdown';
import '@threadplane/react/chat/styles.css';

export function StandaloneMessage({ onCopy }: { readonly onCopy: () => void }) {
export function StandaloneMessage({ onCopy, copyPending = false }: {
readonly onCopy: () => void;
readonly copyPending?: boolean;
}) {
return (
<ChatMessage
role="assistant"
Expand All @@ -28,9 +32,22 @@ export function StandaloneMessage({ onCopy }: { readonly onCopy: () => void }) {
beforeContent={<p>Message context</p>}
afterContent={<aside aria-label="Sources">Source details</aside>}
controls={
<button type="button" onClick={onCopy}>
Copy
</button>
<MessageActions
disabled={copyPending}
actions={[{
id: 'copy',
label: 'Copy answer',
onSelect: onCopy,
iconOnly: true,
icon: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"
stroke="currentColor" strokeWidth="2" aria-hidden="true" focusable="false">
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
</svg>
),
}]}
/>
}
>
<p>שלום עולם — content chooses its own direction.</p>
Expand Down Expand Up @@ -97,3 +114,11 @@ Children render once in `.tp-chat-message__body` with `dir="auto"`, independentl
This wrapper does not suppress streaming controls: current streaming assistant controls stay usable, and historical streaming controls follow the same reveal conditions. Non-assistant controls, standalone `MessageActions` and nested rich-content controls retain their existing behavior. The consumer owns each handler, status, disabled state and clipboard operation. Rendering, updating and unmounting do not activate controls, move focus or announce a live status.

The component exposes no session, message snapshot, render callback or raw element props. MessageList keeps its existing custom renderer bypass and scroll policy. Its readonly `MessageListRowContext.current` identifies the final supplied row, even when that row is user, system or tool; optional readonly `prevRole` comes from `rows[index - 1]?.role`, rather than the message payload or DOM. The ten positional combinations reuse frozen context objects. Replacing a preceding immutable row with a different projected role updates the following row even if its own reference is unchanged; unrelated historical rows remain memoized. Appending also rerenders the former final row because `current` changes. Stable one-argument renderers remain compatible, and Chat forwards the same two-argument contract. Full ChatMessage/T23 parity remains deferred.

`MessageActions` can supply the controls, as in the local copy SVG above. `MessageAction` has exactly six readonly fields: required unique `id`, literal string `label` and argumentless `onSelect`, plus optional `disabled`, `icon` and `iconOnly`. `MessageActionsProps` keeps four readonly fields: required `actions` and optional `disabled`, `label` and `className`. The default group label is **Message actions**; empty actions render nothing. Both group and per-action disabled states default to false; either disables the native button.

`icon` accepts app-authored decorative, noninteractive ReactNode content inside an `aria-hidden` wrapper. `iconOnly` defaults to false; only `true` with a supplied icon enables compact presentation. Exactly `null`, `undefined` and booleans omit the icon and keep a visible label even with `iconOnly: true`. Zero, strings (including empty strings), arrays (including empty arrays), fragments and components that render nothing follow normal ReactNode semantics. There is no child inspection; compact callers must supply a meaningful visible, noninteractive glyph.

The literal string label always provides the button's accessible name. The opt-in chat stylesheet visually hides only the effective compact label, sets a minimum 36px square target and sizes direct SVG glyphs to 16px. Compact markup supplies the same literal label as a native `title` tooltip independently of CSS. Without the stylesheet, both icon and label remain visible in normal DOM presentation. Omit `iconOnly` for an icon beside a visible label; omit `icon` for a text button.

Same-ID compatible updates and reordering preserve the native button and its focus where native disabled behavior permits. Pointer, Enter and Space activation call the current argumentless callback. Rendering, updating and unmounting do not activate actions. The application owns clipboard admission and writes, pending state, feedback, async completion and errors; it must handle promise rejections itself. No automatic ratings or regeneration commands are provided. This bounded private presentation feature does not complete T28 or establish public release parity.
12 changes: 12 additions & 0 deletions examples/chat/native/e2e/inline-source-previews.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ test('installed inline previews bridge keyboard focus, transfer hover and retire
await page.keyboard.press('Escape');
await expect(trigger).toBeFocused();
await expect(dialog).toHaveCount(0);
// Observe beyond the 120ms hover timer with the pointer stationary: layout
// boundary events after Escape must not reopen the dismissed portal.
await page.waitForTimeout(150);
await expect(dialog).toHaveCount(0);
await expect(trigger).toBeFocused();
await page.keyboard.press('Control+Enter');
await expect(dialog).toHaveCount(0);
await page.keyboard.press('Enter');
Expand Down Expand Up @@ -353,6 +358,13 @@ test('installed inline previews bridge keyboard focus, transfer hover and retire
await sheets.dispose();
}
await page.keyboard.press('Escape');
await expect(trigger).toBeFocused();
await expect(dialog).toHaveCount(0);
// Restoring styles can expose the stationary pointer over the trigger.
// Observe beyond its original 120ms hover delay before the next width.
await page.waitForTimeout(150);
await expect(dialog).toHaveCount(0);
await expect(trigger).toBeFocused();
await preserved();
}
// The literal long-type fixture needs room above both anchor positions.
Expand Down
39 changes: 39 additions & 0 deletions examples/chat/native/e2e/message-actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,37 @@ test('installed React message actions copy only explicit answer text without con
});
const copy = list.getByRole('button', { name: 'Copy answer', exact: true });
await expect(copy).toHaveCount(1);
const compactCopy = async () => {
await expect(copy).toHaveAccessibleName('Copy answer');
await expect(copy).toHaveAttribute('title', 'Copy answer');
await expect(copy).toHaveClass(/tp-message-actions__action--icon-only/);
await expect(copy.locator('.tp-message-actions__label')).toHaveText(
'Copy answer'
);
const icon = copy.locator('.tp-message-actions__icon');
await expect(icon).toHaveAttribute('aria-hidden', 'true');
const svg = icon.locator('svg');
await expect(svg).toHaveAttribute('aria-hidden', 'true');
await expect(svg).toHaveAttribute('focusable', 'false');
await expect(svg).toHaveAttribute('viewBox', '0 0 24 24');
await expect(svg).toHaveAttribute('stroke', 'currentColor');
await expect(svg.locator('rect')).toHaveAttribute('width', '11');
await expect(svg.locator('path')).toHaveAttribute(
'd',
'M5 15V5a2 2 0 0 1 2-2h10'
);
const bounds = await copy.boundingBox();
expect(bounds!.width).toBeGreaterThanOrEqual(36);
expect(bounds!.height).toBeGreaterThanOrEqual(36);
const glyph = await svg.boundingBox();
expect(glyph!.width).toBe(16);
expect(glyph!.height).toBe(16);
await expect(copy.locator('.tp-message-actions__label')).toHaveCSS(
'clip-path',
'inset(50%)'
);
};
await compactCopy();
await expect(
list.getByRole('group', { name: 'Message actions' })
).toHaveClass(/tp-message-actions/);
Expand Down Expand Up @@ -176,11 +207,19 @@ test('installed React message actions copy only explicit answer text without con
await expect(controls).toHaveCSS('opacity', '0');
await copy.focus();
await expect(copy).toBeFocused();
// Real keyboard traversal selects the browser's focus-visible modality.
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
await expect(copy).toBeFocused();
await expect(copy).toHaveCSS('outline-style', 'solid');
await expect(copy).toHaveCSS('outline-width', '2px');
await expect(copy).toHaveCSS('outline-offset', '3px');
await expect(controls).toHaveCSS('opacity', '1');
await expect(controls).toHaveCSS('pointer-events', 'auto');
expect(await messageBounds(copy)).toEqual(copyBounds);
expect(await writes()).toEqual([]);
const stableControls = async () => {
await compactCopy();
expect(
await controls.evaluate(
(el, original) => el === original,
Expand Down
39 changes: 39 additions & 0 deletions examples/chat/native/e2e/message-presentation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,45 @@ test('installed React ChatMessage preserves roles, direction, controls and readi
const copy = answer
.locator('.tp-chat-message__controls')
.getByRole('button', { name: 'Copy answer', exact: true });
const compactCopy = async (button: Locator) => {
await expect(button).toHaveAccessibleName('Copy answer');
await expect(button).toHaveAttribute('title', 'Copy answer');
await expect(button).toHaveClass(/tp-message-actions__action--icon-only/);
await expect(button.locator('.tp-message-actions__label')).toHaveText(
'Copy answer'
);
await expect(button.locator('.tp-message-actions__label')).toHaveCSS(
'clip-path',
'inset(50%)'
);
const icon = button.locator('.tp-message-actions__icon');
await expect(icon).toHaveAttribute('aria-hidden', 'true');
const svg = icon.locator('svg');
await expect(svg).toHaveAttribute('aria-hidden', 'true');
await expect(svg).toHaveAttribute('focusable', 'false');
await expect(svg).toHaveAttribute('viewBox', '0 0 24 24');
await expect(svg).toHaveAttribute('stroke', 'currentColor');
await expect(svg.locator('path')).toHaveAttribute(
'd',
'M5 15V5a2 2 0 0 1 2-2h10'
);
const bounds = await button.boundingBox();
expect(bounds!.width).toBeGreaterThanOrEqual(36);
expect(bounds!.height).toBeGreaterThanOrEqual(36);
const glyph = await svg.boundingBox();
expect(glyph!.width).toBe(16);
expect(glyph!.height).toBe(16);
};
await compactCopy(copy);
await copy.focus();
await expect(copy).toBeFocused();
// Real keyboard traversal selects the browser's focus-visible modality.
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
await expect(copy).toBeFocused();
await expect(copy).toHaveCSS('outline-style', 'solid');
await expect(copy).toHaveCSS('outline-width', '2px');
await expect(copy).toHaveCSS('outline-offset', '3px');
await copy.press('Enter');
await expect(answer.getByRole('status')).toHaveText('Answer copied.');
expect(await writes()).toEqual([roleMessages[2].content]);
Expand Down Expand Up @@ -467,6 +505,7 @@ test('installed React ChatMessage preserves roles, direction, controls and readi
await expect(currentCopy).toBeEnabled();
const mountedCopy = await currentCopy.elementHandle();
const stableCurrentCopy = async () => {
await compactCopy(currentCopy);
expect(
await currentCopy.evaluate(
(el, original) => el === original,
Expand Down
Loading
Loading