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
2 changes: 2 additions & 0 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,8 @@ KeyHint uses 10px text on a 10px line and 2px block padding. StatusPill uses a 1

Composer and ChatComposer share the 16px surface radius, 8px padding, 12px content gap, composer border, context tint, and composer shadow. Compact ChatComposer uses a 24px action track plus 8px padding on each side and two 1px borders (42px border-box height); expanded height follows editor content. The generic context shell has no duplicate outer border or surface inset. Queue and editor state remain owned by their existing slots and product adapters.

Place a queued message preview and its `ChatComposerQueueAttachmentBadge` inside `ChatComposerQueueItemMessage`, within `ChatComposerQueueItemContent`. This keeps short text and attachments together while long previews shrink. Status descriptions remain siblings below the message line. `ChatComposerQueueItemActions` reserves consistent action widths and aligns controls to that first line; narrow trays keep icon actions accessible.

ActivityItem `surface` uses a 40px minimum row with a centered outline, 12px
radius, 9px block padding, 12px leading inset and 9px trailing inset. The 22px
sibling actions fit inside that row; optional detail grows beneath it. Inline
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
@layer openbitfun.components {
.root {
--_queue-action-size: var(--openbitfun-control-chat-composer-control-height);
--_queue-primary-action-size: 8em;
box-sizing: border-box;
display: flex;
min-inline-size: 0;
Expand Down Expand Up @@ -94,13 +96,18 @@
display: none;
}

/* Recovery descriptions need room below the message baseline. */
.list:has(.status) {
max-block-size: calc(var(--openbitfun-control-height-sm) * 6);
}

.item {
box-sizing: border-box;
display: flex;
flex: 0 0 auto;
min-inline-size: 0;
min-block-size: var(--openbitfun-control-height-sm);
align-items: center;
align-items: flex-start;
gap: var(--openbitfun-space-2);
padding-inline: var(--openbitfun-space-2) var(--openbitfun-space-1);
border-radius: var(--openbitfun-radius-md);
Expand All @@ -115,6 +122,7 @@
.leadingIcon {
display: inline-flex;
flex: 0 0 var(--openbitfun-space-5);
min-block-size: var(--openbitfun-control-height-sm);
align-items: center;
justify-content: center;
color: var(--openbitfun-color-content-muted);
Expand All @@ -124,25 +132,34 @@
display: flex;
flex: 1 1 0;
min-inline-size: 0;
min-block-size: var(--openbitfun-control-height-sm);
flex-flow: row wrap;
align-items: baseline;
gap: 0 var(--openbitfun-space-2);
color: var(--openbitfun-color-content-primary);
font-size: var(--openbitfun-type-body-md-font-size);
font-weight: var(--openbitfun-type-body-md-font-weight);
line-height: var(--openbitfun-type-body-sm-line-height);
line-height: var(--openbitfun-type-body-md-line-height);
}

/* Message text takes the line; status facts follow it and wrap below only
when the row is too narrow to hold both. */
:where(.content) > * {
flex: 1 1 8em;
.message {
display: flex;
flex: 1 0 100%;
min-inline-size: 0;
max-inline-size: 100%;
min-block-size: var(--openbitfun-control-height-sm);
align-items: center;
gap: var(--openbitfun-space-2);
}

/* Shrink long text, but never stretch a short preview away from its badge. */
.message > :global([data-overflow-behavior]) {
flex: 0 1 auto;
}

/* A truncated preview may be a tab stop so keyboard users can read its
full-text tooltip; it stays display-only. */
:where(.content) > :focus-visible {
:where(.content, .message) > :focus-visible {
border-radius: var(--openbitfun-radius-xs);
outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring);
outline-offset: var(--openbitfun-focus-offset);
Expand All @@ -160,6 +177,7 @@
font-size: var(--openbitfun-type-meta-font-size);
line-height: var(--openbitfun-type-body-sm-line-height);
overflow-wrap: anywhere;
padding-block-end: var(--openbitfun-space-1);
}

.status[data-openbitfun-tone="warning"] {
Expand All @@ -185,12 +203,26 @@
display: inline-flex;
flex: 0 0 auto;
min-inline-size: 0;
inline-size: calc(var(--_queue-primary-action-size) + var(--_queue-action-size) * 2 + var(--openbitfun-space-1) * 2);
min-block-size: var(--openbitfun-control-height-sm);
align-items: center;
justify-content: flex-end;
gap: var(--openbitfun-space-1);
font-size: var(--openbitfun-type-body-sm-font-size);
}

.actions [data-openbitfun-component="icon-button"] {
flex: 0 0 var(--_queue-action-size);
inline-size: var(--_queue-action-size);
block-size: var(--_queue-action-size);
min-inline-size: var(--_queue-action-size);
min-block-size: var(--_queue-action-size);
}

/* The tray stays quiet without making its controls disappear. */
.actions [data-openbitfun-component="button"][data-openbitfun-variant="text"] {
flex: 1 1 0;
min-inline-size: 0;
color: var(--openbitfun-color-content-secondary);
padding-inline: var(--openbitfun-space-2);
}
Expand All @@ -202,20 +234,19 @@
}

@container composer-queue (max-width: 22rem) {
.actions {
--_queue-primary-action-size: var(--_queue-action-size);
}
.actions [data-openbitfun-component="button"] [data-openbitfun-part="label"] {
display: none;
}
}

@media (pointer: coarse) {
.actions [data-openbitfun-component="button"] {
min-block-size: var(--openbitfun-control-height-sm);
.root {
--_queue-action-size: var(--openbitfun-control-height-sm);
}

.actions [data-openbitfun-component="icon-button"] {
inline-size: var(--openbitfun-control-height-sm);
block-size: var(--openbitfun-control-height-sm);
min-inline-size: var(--openbitfun-control-height-sm);
.actions [data-openbitfun-component="button"] {
min-block-size: var(--openbitfun-control-height-sm);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export interface ChatComposerQueueItemProps
export interface ChatComposerQueueItemContentProps
extends HTMLAttributes<HTMLDivElement> {}

export interface ChatComposerQueueItemMessageProps
extends HTMLAttributes<HTMLDivElement> {}

export type ChatComposerQueueItemStatusTone = "neutral" | "warning" | "danger";

export interface ChatComposerQueueItemStatusProps
Expand Down Expand Up @@ -187,6 +190,22 @@ export const ChatComposerQueueItemContent = forwardRef<
);
});

/** Natural-width preview and its attachments; status belongs below this line. */
export const ChatComposerQueueItemMessage = forwardRef<
HTMLDivElement,
ChatComposerQueueItemMessageProps
>(function ChatComposerQueueItemMessage({ className, ...props }, ref) {
return (
<div
{...props}
className={classNames(styles.message, className)}
data-openbitfun-component="chat-composer-queue"
data-openbitfun-part="message"
ref={ref}
/>
);
});

/** A fact about one message, such as its delivery state or a failure reason. */
export const ChatComposerQueueItemStatus = forwardRef<
HTMLSpanElement,
Expand Down
2 changes: 2 additions & 0 deletions design-system/packages/ui/src/flow-chat/composer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
ChatComposerQueueItem,
ChatComposerQueueItemActions,
ChatComposerQueueItemContent,
ChatComposerQueueItemMessage,
ChatComposerQueueItemStatus,
ChatComposerQueueList,
ChatComposerQueueTitle,
Expand All @@ -24,6 +25,7 @@ export {
type ChatComposerQueueHeaderProps,
type ChatComposerQueueItemActionsProps,
type ChatComposerQueueItemContentProps,
type ChatComposerQueueItemMessageProps,
type ChatComposerQueueItemProps,
type ChatComposerQueueItemState,
type ChatComposerQueueItemStatusProps,
Expand Down
29 changes: 24 additions & 5 deletions design-system/packages/ui/tests/chat-composer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ChatComposerQueueItem,
ChatComposerQueueItemActions,
ChatComposerQueueItemContent,
ChatComposerQueueItemMessage,
ChatComposerQueueItemStatus,
ChatComposerQueueList,
ChatComposerQueueTitle,
Expand Down Expand Up @@ -106,12 +107,16 @@ test("ChatComposer exposes a neutral pending-message queue with per-message atta
createElement(
ChatComposerQueueItemContent,
null,
"Help me turn these photos into a painted scene.",
createElement(
ChatComposerQueueItemMessage,
null,
createElement("span", { "data-overflow-behavior": "fade" }, "Help me turn these photos into a painted scene."),
createElement(ChatComposerQueueAttachmentBadge, {
count: 3,
label: "3 image attachments",
}),
),
),
createElement(ChatComposerQueueAttachmentBadge, {
count: 3,
label: "3 image attachments",
}),
createElement(
ChatComposerQueueItemActions,
null,
Expand Down Expand Up @@ -216,6 +221,20 @@ test("ChatComposer queue keeps actions visible and message ink primary", async (
assert.match(styles, /\[data-openbitfun-part="label"\]\s*\{\s*display: none;/);
});

test("ChatComposer queue groups attachments with natural-width text and anchors actions to the first line", async () => {
const styles = await readFile(
new URL("../src/flow-chat/composer/ChatComposerQueue.module.css", import.meta.url),
"utf8",
);
assert.match(styles, /\.message\s*\{[^}]*display:\s*flex;[^}]*min-inline-size:\s*0;[^}]*align-items:\s*center;/s);
assert.match(styles, /\.message > :global\(\[data-overflow-behavior\]\)\s*\{[^}]*flex:\s*0 1 auto;/s);
assert.match(styles, /\.attachmentBadge\s*\{[^}]*flex:\s*0 0 auto;/s);
assert.match(styles, /\.item\s*\{[^}]*align-items:\s*flex-start;/s);
assert.match(styles, /\.actions\s*\{[^}]*inline-size:\s*calc\(var\(--_queue-primary-action-size\)/s);
assert.match(styles, /\.actions\s*\{[^}]*min-block-size:\s*var\(--openbitfun-control-height-sm\)/s);
assert.match(styles, /\.list:has\(\.status\)\s*\{[^}]*max-block-size:/s);
});

test("ChatComposer queue never changes the input surface geometry", async () => {
const styles = await readFile(
new URL("../src/flow-chat/composer/ChatComposer.module.css", import.meta.url),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ it.each([false, true])('keeps a normal send hidden and shows recovery only on fa
it('keeps four queued messages compact, exposes actions, and toggles help independently', async () => {
const requests: string[] = [];
let items = Array.from({ length: 4 }, (_, index) => ({ turnId: String(index), displayContent: String(index + 1),
status: 'queued' as const, previewTruncated: false, attachmentCount: 0, agentType: 'Standard',
status: 'queued' as const, previewTruncated: false, attachmentCount: index === 0 ? 2 : 0, agentType: 'Standard',
createdAtMs: 1, reason: null, targetTurnId: null, steeringId: null }));
const queue = new HostDialogQueue('scope', 'session', async request => {
requests.push(request.action);
Expand All @@ -81,6 +81,9 @@ it('keeps four queued messages compact, exposes actions, and toggles help indepe
try {
await act(async () => root.render(<HostPendingQueuePanel queue={queue} onRestore={() => true} />));
expect(container.querySelectorAll('li')).toHaveLength(4);
const badge = container.querySelector('[data-openbitfun-part="attachmentCount"]')!;
expect(badge.closest('[data-openbitfun-part="message"]')?.querySelector('[data-openbitfun-product-part="preview"]')?.textContent).toBe('1');
expect(badge.previousElementSibling?.getAttribute('data-openbitfun-product-part')).toBe('preview');
expect(container.textContent).not.toContain('hostQueue.memoryNotice');
expect(container.textContent).not.toContain('hostQueue.queued');
expect(container.querySelectorAll('[aria-label="hostQueue.sendNow"]')).toHaveLength(4);
Expand Down Expand Up @@ -177,7 +180,8 @@ it('collapses the controlled list and keeps blocked and unconfirmed rows in a re
expect.stringContaining('hostQueue.blocked'), expect.stringContaining('hostQueue.unknown'),
]);
expect(rows[0].querySelector('[aria-label="hostQueue.cancel"]')).not.toBeNull();
expect(rows[1].querySelector('[aria-label="hostQueue.checkRetry"]')).not.toBeNull();
expect(rows[1].querySelector('[aria-label="hostQueue.checkRetry"]')?.textContent).toContain('hostQueue.retry');
expect(rows[1].querySelector('[data-openbitfun-part="message"] [data-openbitfun-part="status"]')).toBeNull();
expect(container.textContent).not.toContain('message a');
await act(async () => rows[0].querySelector<HTMLButtonElement>('[aria-label="pendingQueue.actions.more"]')!.click());
expect(document.querySelector('[role="menu"]:not([aria-hidden="true"]) [data-menu-id="collapse"]')).toBeNull();
Expand Down
30 changes: 19 additions & 11 deletions src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import { hostQueuePresentation, observePresentedHostQueue } from '../services/ho
import { HostDialogQueue, type QueueOutboxRecord, type QueueItem } from '../../../../shared/dialog-queue/HostDialogQueue';
import {
ChatComposerQueue, ChatComposerQueueHeader, ChatComposerQueueTitle, ChatComposerQueueToggle,
ChatComposerQueueList, ChatComposerQueueItem, ChatComposerQueueItemContent, ChatComposerQueueItemActions,
ChatComposerQueueList, ChatComposerQueueItem, ChatComposerQueueItemContent, ChatComposerQueueItemMessage, ChatComposerQueueItemActions,
ChatComposerQueueItemStatus, ChatComposerQueueAttachmentBadge,
} from '@openbitfun/ui/flow-chat';

function QueueAction({ label, icon, disabled, onClick }: { label: string; icon: ReactNode; disabled: boolean; onClick: () => void }) {
return <Tooltip content={label}>
<IconButton data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="action" size="xs" aria-label={label} icon={icon} disabled={disabled} onClick={onClick} />
<IconButton data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="action" size="xs" variant="annotation" aria-label={label} icon={icon} disabled={disabled} onClick={onClick} />
</Tooltip>;
}

Expand Down Expand Up @@ -73,14 +73,16 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ
return <ChatComposerQueueItem data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="item" key={item.turnId}
leadingIcon={<Icon glyph={ListEnd} size="sm" />} state={blocked ? 'failed' : steering ? 'sending' : 'default'}>
<ChatComposerQueueItemContent data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="content">
{renderPreview(item.displayContent || t('pendingQueue.emptyPlaceholder'))}
<ChatComposerQueueItemMessage>
{renderPreview(item.displayContent || t('pendingQueue.emptyPlaceholder'))}
<ChatComposerQueueAttachmentBadge count={item.attachmentCount} icon={<Icon glyph={Paperclip} size="sm" />}
label={t('hostQueue.attachments', { count: item.attachmentCount })} />
</ChatComposerQueueItemMessage>
{(blocked || steering) && <ChatComposerQueueItemStatus data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="status" tone={blocked ? 'warning' : 'neutral'}>
{blocked ? t('hostQueue.blocked') : t('hostQueue.steeringPending')}
</ChatComposerQueueItemStatus>}
{item.reason && <ChatComposerQueueItemStatus data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="status" tone={blocked ? 'warning' : 'neutral'}>{item.reason}</ChatComposerQueueItemStatus>}
</ChatComposerQueueItemContent>
<ChatComposerQueueAttachmentBadge count={item.attachmentCount} icon={<Icon glyph={Paperclip} size="xs" />}
label={t('hostQueue.attachments', { count: item.attachmentCount })} />
<ChatComposerQueueItemActions data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="actions">
<PendingQueueMessageActions sendLabel={t('hostQueue.sendNow')} sendDisabled={locked || steering}
onSend={() => void run(() => queue.act(item, 'promote'))}
Expand All @@ -99,14 +101,20 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ
const message = record.request.action === 'submit' ? record.request.message : null;
return <ChatComposerQueueItem data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="item" key={record.key} leadingIcon={<Icon glyph={ListEnd} size="sm" />} state="failed">
<ChatComposerQueueItemContent data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="content">
{message && renderPreview(message.displayContent ?? message.content)}
{message && <ChatComposerQueueItemMessage>{renderPreview(message.displayContent ?? message.content)}</ChatComposerQueueItemMessage>}
<ChatComposerQueueItemStatus data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="status" tone="warning">{t('hostQueue.unknown')}</ChatComposerQueueItemStatus>
</ChatComposerQueueItemContent>
<ChatComposerQueueItemActions data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="actions">
{record.restoreIntent && message
? <QueueAction label={t('hostQueue.edit')} icon={<Icon name="edit" />} disabled={busy} onClick={() => void run(() => restoreDraft(record))} />
: <QueueAction label={t('hostQueue.checkRetry')} icon={<Icon glyph={RotateCw} />} disabled={busy} onClick={() => void run(() => queue.retry(record))} />}
{message && <QueueAction label={t('hostQueue.copyDraft')} icon={<Icon name="duplicate" />} disabled={busy} onClick={() => {
<Tooltip content={t(record.restoreIntent && message ? 'hostQueue.edit' : 'hostQueue.checkRetry')}>
<Button data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="action"
variant="text" size="xs" labelBehavior="static" disabled={busy}
aria-label={t(record.restoreIntent && message ? 'hostQueue.edit' : 'hostQueue.checkRetry')}
leadingIcon={record.restoreIntent && message ? <Icon name="edit" size="sm" /> : <Icon glyph={RotateCw} size="sm" />}
onClick={() => void run(() => record.restoreIntent && message ? restoreDraft(record) : queue.retry(record))}>
{t(record.restoreIntent && message ? 'hostQueue.edit' : 'hostQueue.retry')}
</Button>
</Tooltip>
{message && <QueueAction label={t('hostQueue.copyDraft')} icon={<Icon name="duplicate" size="sm" />} disabled={busy} onClick={() => {
const cache = record.draft as Partial<QueuedMessage> | undefined;
// Copy locally even after an owner restart. Keep the unresolved
// receipt: copying is neither a cancellation nor a resend.
Expand All @@ -116,7 +124,7 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ
status: 'queued', retryCount: cache?.retryCount ?? 0,
userMessageMetadata: message.metadata });
}} />}
<QueueAction label={t('hostQueue.dismiss')} icon={<Icon name="xmark" />} disabled={busy} onClick={() => void run(() => queue.dismiss(record))} />
<QueueAction label={t('hostQueue.dismiss')} icon={<Icon name="xmark" size="sm" />} disabled={busy} onClick={() => void run(() => queue.dismiss(record))} />
</ChatComposerQueueItemActions>
</ChatComposerQueueItem>;
};
Expand Down
Loading
Loading