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 @@ -10,6 +10,7 @@ export const askUserMeta = {
{ defaultValue: "{}", name: "answers", type: "AskUserAnswers" },
{ defaultValue: "asking", name: "state", type: "AskUserState" },
{ name: "navigation", type: "AskUserNavigation" },
{ name: "waitTiming", type: "AskUserWaitTiming" },
{ name: "disclosure", type: "{ label: ReactNode; collapseLabel: string; skipped?: boolean }" },
{ name: "summaryLabel", type: "ReactNode" },
{ name: "summaryDetail", type: "ReactNode" },
Expand Down Expand Up @@ -65,7 +66,6 @@ export const askUserMeta = {
"type.flow.meta.fontWeight",
"type.flow.meta.lineHeight",
"type.body.sm.lineHeight",
"control.askUser.descriptionMaxWidth",
"control.icon.sizeMd",
"control.askUser.optionContentGap",
"control.askUser.optionGap",
Expand Down
64 changes: 38 additions & 26 deletions design-system/packages/ui/src/flow-chat/ask-user/AskUser.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,26 @@
display: none;
}

.waitTiming {
display: inline-flex;
min-inline-size: 0;
flex: 0 0 auto;
align-items: center;
gap: var(--openbitfun-space-1);
}

.waitSettled {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
color: var(--openbitfun-color-content-muted);
font-family: var(--openbitfun-type-flow-meta-font-family);
font-size: var(--openbitfun-type-flow-meta-font-size);
font-weight: var(--openbitfun-type-flow-meta-font-weight);
line-height: var(--openbitfun-type-flow-meta-line-height);
white-space: nowrap;
}

.navigation {
position: relative;
}
Expand Down Expand Up @@ -314,20 +334,28 @@
.details {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard);
}

.details[hidden] {
display: none;
}

.detailsInner {
overflow: hidden;
}

/*
* Only the collapsing summary transitions its row. Keeping the resting row
* free of a transition, and keeping the shrinking min-size collapsed-only,
* stops the 1fr row from inflating past its content while the card is open.
*/
.root[data-openbitfun-has-summary="true"][data-openbitfun-expanded="false"] .details {
grid-template-rows: 0fr;
transition: grid-template-rows var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-standard);
}

.detailsInner {
.root[data-openbitfun-has-summary="true"][data-openbitfun-expanded="false"] .detailsInner {
min-block-size: 0;
overflow: hidden;
}

.body {
Expand Down Expand Up @@ -409,10 +437,10 @@
display: flex;
flex: 1 1 auto;
min-inline-size: 0;
min-block-size: var(--openbitfun-control-height-lg);
min-block-size: var(--openbitfun-control-height-sm);
align-items: flex-start;
gap: var(--openbitfun-space-2);
padding-block: max(var(--openbitfun-control-ask-user-option-padding-block), calc((var(--openbitfun-control-height-lg) - 1lh) / 2));
padding-block: var(--openbitfun-control-ask-user-option-padding-block);
padding-inline: var(--openbitfun-control-ask-user-option-padding-inline);
font: inherit;
}
Expand All @@ -429,9 +457,9 @@
.optionCopy {
display: flex;
min-inline-size: 0;
align-items: baseline;
justify-content: space-between;
gap: var(--openbitfun-space-3);
flex-direction: column;
align-items: stretch;
gap: var(--openbitfun-control-ask-user-option-content-gap);
}

.optionLabel,
Expand All @@ -441,7 +469,6 @@

.optionLabel {
display: block;
flex: 0 1 auto;
min-inline-size: 0;
color: var(--openbitfun-color-content-primary);
font-weight: var(--openbitfun-type-body-sm-font-weight);
Expand All @@ -451,14 +478,11 @@

.optionDescription {
display: block;
overflow: hidden;
flex: 1 1 auto;
min-inline-size: 0;
max-inline-size: var(--openbitfun-control-ask-user-description-max-width);
color: var(--openbitfun-color-content-muted);
font-weight: var(--openbitfun-type-body-sm-font-weight);
line-height: var(--openbitfun-type-body-sm-line-height);
text-align: end;
text-align: start;
white-space: normal;
overflow-wrap: anywhere;
}
Expand Down Expand Up @@ -567,19 +591,6 @@
min-block-size: var(--openbitfun-control-hit-target);
}

@container ask-user (max-width: 36rem) {
.optionCopy {
flex-direction: column;
align-items: stretch;
gap: var(--openbitfun-space-1);
}

.optionDescription {
max-inline-size: none;
text-align: start;
}
}

@container ask-user (max-width: 24rem) {
.footer .status {
flex-basis: 100%;
Expand All @@ -589,6 +600,7 @@

@media (prefers-reduced-motion: reduce) {
.details,
.root[data-openbitfun-has-summary="true"][data-openbitfun-expanded="false"] .details,
.option {
transition: none;
}
Expand Down
54 changes: 53 additions & 1 deletion design-system/packages/ui/src/flow-chat/ask-user/AskUser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Checkbox } from "../../components/Checkbox";
import { Icon } from "../../components/Icon";
import { Input } from "../../components/Input";
import { Radio } from "../../components/Radio";
import { Tooltip } from "../../components/Tooltip";
import { classNames } from "../../internal/classNames";
import styles from "./AskUser.module.css";

Expand Down Expand Up @@ -65,6 +66,20 @@ export interface AskUserNavigation {
selectionLabel: (selected: number, total: number) => ReactNode;
}

/**
* Header-right wait timing. A running host deadline renders a dismissible
* countdown; an unbounded wait renders the settled label instead so the header
* never loses its timing affordance.
*/
export interface AskUserWaitTiming {
cancelLabel?: string;
cancelDisabled?: boolean;
hint?: ReactNode;
onCancel?: () => void;
remainingLabel?: ReactNode;
settledLabel?: ReactNode;
}

export interface AskUserProps
extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "onChange"> {
answers?: AskUserAnswers;
Expand Down Expand Up @@ -95,6 +110,7 @@ export interface AskUserProps
submittingLabel?: ReactNode;
summaryDetail?: ReactNode;
summaryLabel?: ReactNode;
waitTiming?: AskUserWaitTiming;
}

function hasAnswer(
Expand Down Expand Up @@ -146,6 +162,7 @@ export const AskUser = forwardRef<HTMLDivElement, AskUserProps>(function AskUser
submittingLabel,
summaryDetail,
summaryLabel,
waitTiming,
...props
}, ref) {
const instanceId = useId();
Expand Down Expand Up @@ -281,6 +298,38 @@ export const AskUser = forwardRef<HTMLDivElement, AskUserProps>(function AskUser
size="md"
/>
);
const hasWaitCountdown = waitTiming?.onCancel !== undefined
&& waitTiming.remainingLabel !== undefined
&& waitTiming.remainingLabel !== null;
const hasSettledWait = waitTiming?.settledLabel !== undefined
&& waitTiming.settledLabel !== null;
const waitNode = hasWaitCountdown && waitTiming
? (
<span className={styles.waitTiming} data-counting="true" data-openbitfun-part="wait-timing">
<Tooltip content={waitTiming.hint} disabled={!waitTiming.hint} placement="top">
<Button
aria-label={waitTiming.cancelLabel}
disabled={waitTiming.cancelDisabled}
labelBehavior="static"
onClick={waitTiming.onCancel}
size="xs"
trailingIcon={<Icon name="xmark" size="xs" />}
variant="text"
>
<span role="timer">{waitTiming.remainingLabel}</span>
</Button>
</Tooltip>
</span>
)
: hasSettledWait && waitTiming
? (
<span className={styles.waitTiming} data-counting="false" data-openbitfun-part="wait-timing">
<span className={styles.waitSettled} data-openbitfun-part="wait-settled">
{waitTiming.settledLabel}
</span>
</span>
)
: null;

return (
<div
Expand Down Expand Up @@ -371,6 +420,7 @@ export const AskUser = forwardRef<HTMLDivElement, AskUserProps>(function AskUser
)}
</span>
<span className={styles.headerTrailing}>
{waitNode}
{headerTrailing}
{activeQuestionIndex > 0 && (
<span data-openbitfun-part="back">
Expand Down Expand Up @@ -417,7 +467,9 @@ export const AskUser = forwardRef<HTMLDivElement, AskUserProps>(function AskUser
{headerIcon}
<OverflowText>{header ?? disclosure?.label}</OverflowText>
</span>
{headerTrailing !== undefined && <span className={styles.headerTrailing}>{headerTrailing}</span>}
{(waitNode || (headerTrailing !== undefined && headerTrailing !== null)) && (
<span className={styles.headerTrailing}>{waitNode}{headerTrailing}</span>
)}
{disclosure && (
<span className={styles.summaryAction} data-openbitfun-icon-slot="true">
<Icon name="chevron-up" size="md" />
Expand Down
1 change: 1 addition & 0 deletions design-system/packages/ui/src/flow-chat/ask-user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export {
type AskUserProps,
type AskUserQuestion,
type AskUserState,
type AskUserWaitTiming,
} from "./AskUser";
35 changes: 35 additions & 0 deletions design-system/packages/ui/tests/ask-user.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,41 @@ test("AskUser disclosure hides editable controls until expanded and retains the
assert.match(expanded, /data-openbitfun-part="footer"/);
});

test("AskUser keeps a wait affordance in the header for running and unbounded waits", () => {
const base = {
navigation: {
backLabel: "Back",
nextLabel: "Next",
progressLabel: (current, total) => `Question ${current} of ${total}`,
selectionLabel: (selected, total) => `${selected} of ${total} selected`,
},
questions,
state: "asking",
submitLabel: "Submit",
};

const counting = renderToStaticMarkup(createElement(AskUser, {
...base,
waitTiming: {
cancelLabel: "Cancel countdown",
onCancel: () => undefined,
remainingLabel: "2:31",
},
}));
assert.match(counting, /data-counting="true"[^>]*data-openbitfun-part="wait-timing"/);
assert.match(counting, /<button[^>]*aria-label="Cancel countdown"[\s\S]*role="timer">2:31/);
assert.match(counting, /aria-label="Cancel countdown"[\s\S]*data-openbitfun-name="xmark"/);
assert.doesNotMatch(counting, /data-openbitfun-part="wait-settled"/);

const settled = renderToStaticMarkup(createElement(AskUser, {
...base,
waitTiming: { settledLabel: "No time limit" },
}));
assert.match(settled, /data-counting="false"[^>]*data-openbitfun-part="wait-timing"/);
assert.match(settled, /data-openbitfun-part="wait-settled"[^>]*>No time limit/);
assert.doesNotMatch(settled, /role="timer"|aria-label="Cancel countdown"/);
});

test("AskUser styles use public semantic and component geometry tokens", async () => {
const styles = await readFile(new URL("../dist/styles.css", import.meta.url), "utf8");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,13 +265,18 @@ describe('AskUserQuestionCard', () => {
render();
expect(container.querySelector('[role="timer"]')).toBeNull();
expect(container.querySelector('button[aria-label="toolCards.askUser.cancelCountdown"]')).toBeNull();
expect(container.querySelector('[data-openbitfun-part="wait-settled"]')?.textContent)
.toBe('toolCards.askUser.unlimitedWait');
tool.userQuestionWait.interactionStarted = true;
render();
expect(container.querySelector('[role="timer"]')).toBeNull();
expect(container.querySelector('[data-openbitfun-part="wait-settled"]')).toBeNull();
tool.userQuestionWait.interactionStarted = false;
delete tool.userQuestionWait.deadlineMs;
render();
expect(container.querySelector('[role="timer"]')).toBeNull();
expect(container.querySelector('[data-openbitfun-part="wait-settled"]')?.textContent)
.toBe('toolCards.askUser.unlimitedWait');
} finally {
vi.useRealTimers();
}
Expand Down
41 changes: 25 additions & 16 deletions src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type AskUserAnswers,
type AskUserQuestion,
type AskUserState,
type AskUserWaitTiming,
} from '@openbitfun/ui/flow-chat';
import React, {
useCallback,
Expand All @@ -22,7 +23,6 @@ import React, {
useSyncExternalStore,
} from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Icon, Tooltip } from '@openbitfun/ui';
import { i18nService } from '@/infrastructure/i18n';
import { toolAPI } from '@/infrastructure/api/service-api/ToolAPI';
import {
Expand Down Expand Up @@ -583,6 +583,29 @@ export const AskUserQuestionCard: React.FC<ToolCardProps> = ({
? t('toolCards.askUser.submitFailed')
: t('toolCards.askUser.waitingAnswer');

// The header always keeps a timing affordance while the question can be
// answered: a dismissible host countdown, or the settled unbounded label when
// the host reports no deadline (timeout disabled) instead of an empty slot.
// A settled interaction still clears the slot, matching the countdown's own
// dismissal rule.
const waitSettled = canAnswer
&& typeof deadline !== 'number'
&& !interactionAcknowledged
&& !toolItem.userQuestionWait?.interactionStarted;
const waitTiming: AskUserWaitTiming | undefined = isSubmitted
? undefined
: showTimer
? {
cancelDisabled: !sessionId,
cancelLabel: t('toolCards.askUser.cancelCountdown'),
hint: t('toolCards.askUser.timeoutSettingsHint'),
onCancel: startInteraction,
remainingLabel: countdown,
}
: waitSettled
? { settledLabel: t('toolCards.askUser.unlimitedWait') }
: undefined;

if (endedWithoutAnswer && !canFollowUp) {
const canReviewQuestions = cancelled && designQuestions.length > 0;
const terminalLabel = timedOut
Expand Down Expand Up @@ -667,21 +690,6 @@ export const AskUserQuestionCard: React.FC<ToolCardProps> = ({
}),
}}
onClickCapture={handleInteraction}
headerTrailing={showTimer && !isSubmitted ? (
<Tooltip content={t('toolCards.askUser.timeoutSettingsHint')} placement="top">
<Button
variant="text"
size="xs"
labelBehavior="static"
aria-label={t('toolCards.askUser.cancelCountdown')}
disabled={!sessionId}
trailingIcon={<Icon name="xmark" size="xs" />}
onClick={startInteraction}
>
<span role="timer">{countdown}</span>
</Button>
</Tooltip>
) : undefined}
onFocusCapture={handleInteraction}
onAnswersChange={handleAnswersChange}
onCustomAnswerChange={(questionId, value) => {
Expand All @@ -707,6 +715,7 @@ export const AskUserQuestionCard: React.FC<ToolCardProps> = ({
submitTitle={!isAllAnswered()
? t('toolCards.askUser.answerAllBeforeSubmit')
: undefined}
waitTiming={waitTiming}
/>
);
};
Loading
Loading