diff --git a/design-system/packages/ui/src/flow-chat/ask-user/AskUser.meta.ts b/design-system/packages/ui/src/flow-chat/ask-user/AskUser.meta.ts index 2eea782646..496961f516 100644 --- a/design-system/packages/ui/src/flow-chat/ask-user/AskUser.meta.ts +++ b/design-system/packages/ui/src/flow-chat/ask-user/AskUser.meta.ts @@ -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" }, @@ -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", diff --git a/design-system/packages/ui/src/flow-chat/ask-user/AskUser.module.css b/design-system/packages/ui/src/flow-chat/ask-user/AskUser.module.css index ea14a55cdb..83b6e33692 100644 --- a/design-system/packages/ui/src/flow-chat/ask-user/AskUser.module.css +++ b/design-system/packages/ui/src/flow-chat/ask-user/AskUser.module.css @@ -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; } @@ -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 { @@ -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; } @@ -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, @@ -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); @@ -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; } @@ -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%; @@ -589,6 +600,7 @@ @media (prefers-reduced-motion: reduce) { .details, + .root[data-openbitfun-has-summary="true"][data-openbitfun-expanded="false"] .details, .option { transition: none; } diff --git a/design-system/packages/ui/src/flow-chat/ask-user/AskUser.tsx b/design-system/packages/ui/src/flow-chat/ask-user/AskUser.tsx index 774183caf4..7b693db46a 100644 --- a/design-system/packages/ui/src/flow-chat/ask-user/AskUser.tsx +++ b/design-system/packages/ui/src/flow-chat/ask-user/AskUser.tsx @@ -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"; @@ -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, "children" | "onChange"> { answers?: AskUserAnswers; @@ -95,6 +110,7 @@ export interface AskUserProps submittingLabel?: ReactNode; summaryDetail?: ReactNode; summaryLabel?: ReactNode; + waitTiming?: AskUserWaitTiming; } function hasAnswer( @@ -146,6 +162,7 @@ export const AskUser = forwardRef(function AskUser submittingLabel, summaryDetail, summaryLabel, + waitTiming, ...props }, ref) { const instanceId = useId(); @@ -281,6 +298,38 @@ export const AskUser = forwardRef(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 + ? ( + + + + + + ) + : hasSettledWait && waitTiming + ? ( + + + {waitTiming.settledLabel} + + + ) + : null; return (
(function AskUser )} + {waitNode} {headerTrailing} {activeQuestionIndex > 0 && ( @@ -417,7 +467,9 @@ export const AskUser = forwardRef(function AskUser {headerIcon} {header ?? disclosure?.label} - {headerTrailing !== undefined && {headerTrailing}} + {(waitNode || (headerTrailing !== undefined && headerTrailing !== null)) && ( + {waitNode}{headerTrailing} + )} {disclosure && ( diff --git a/design-system/packages/ui/src/flow-chat/ask-user/index.ts b/design-system/packages/ui/src/flow-chat/ask-user/index.ts index acb8eb9ec1..bcdf95e0d8 100644 --- a/design-system/packages/ui/src/flow-chat/ask-user/index.ts +++ b/design-system/packages/ui/src/flow-chat/ask-user/index.ts @@ -8,4 +8,5 @@ export { type AskUserProps, type AskUserQuestion, type AskUserState, + type AskUserWaitTiming, } from "./AskUser"; diff --git a/design-system/packages/ui/tests/ask-user.test.mjs b/design-system/packages/ui/tests/ask-user.test.mjs index fe4697ba6b..b8f2b5a3fe 100644 --- a/design-system/packages/ui/tests/ask-user.test.mjs +++ b/design-system/packages/ui/tests/ask-user.test.mjs @@ -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, /]*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"); diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.test.tsx index 993fe9cc08..a90eb7b692 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.test.tsx @@ -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(); } diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx index 3c011d0ddc..8979227250 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx @@ -11,6 +11,7 @@ import { type AskUserAnswers, type AskUserQuestion, type AskUserState, + type AskUserWaitTiming, } from '@openbitfun/ui/flow-chat'; import React, { useCallback, @@ -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 { @@ -583,6 +583,29 @@ export const AskUserQuestionCard: React.FC = ({ ? 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 @@ -667,21 +690,6 @@ export const AskUserQuestionCard: React.FC = ({ }), }} onClickCapture={handleInteraction} - headerTrailing={showTimer && !isSubmitted ? ( - - - - ) : undefined} onFocusCapture={handleInteraction} onAnswersChange={handleAnswersChange} onCustomAnswerChange={(questionId, value) => { @@ -707,6 +715,7 @@ export const AskUserQuestionCard: React.FC = ({ submitTitle={!isAllAnswered() ? t('toolCards.askUser.answerAllBeforeSubmit') : undefined} + waitTiming={waitTiming} /> ); }; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index d6db059fa2..ef165dfbbe 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -2216,6 +2216,7 @@ "awaitingTimeoutConfirmation": "Waiting for timeout confirmation", "cancelCountdown": "Cancel countdown", "timeoutSettingsHint": "Click to cancel the countdown. Change the timeout in Settings → Execution & Permissions → Tool execution → Question timeout.", + "unlimitedWait": "No time limit", "questionsCount": "{{count}} questions", "answerAllBeforeSubmit": "Please answer all questions before submitting", "submit": "Submit", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index ea5e6a4471..300eb0db80 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -2216,6 +2216,7 @@ "awaitingTimeoutConfirmation": "等待后端确认超时", "cancelCountdown": "取消计时", "timeoutSettingsHint": "点击可取消计时。可在设置 → 执行与权限 → 工具运行 → 提问超时中调整超时时间。", + "unlimitedWait": "不限时", "questionsCount": "{{count}} 个问题", "answerAllBeforeSubmit": "请回答所有问题后再提交", "submit": "提交", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 6eb4f23aa4..d10d262bdf 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -2216,6 +2216,7 @@ "awaitingTimeoutConfirmation": "等待後端確認逾時", "cancelCountdown": "取消計時", "timeoutSettingsHint": "點擊可取消計時。可在設定 → 執行與權限 → 工具執行 → 提問逾時中調整逾時時間。", + "unlimitedWait": "不限時", "questionsCount": "{{count}} 個問題", "answerAllBeforeSubmit": "請回答所有問題後再提交", "submit": "提交",