From 827dd9f2013a4312d1fc7840fccce5b70ea19ebf Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 18 Oct 2026 18:10:01 +0800 Subject: [PATCH 1/3] fix(ask-user): keep a wait timing affordance in the question header The question header only rendered the countdown while a host deadline was present and untouched, so two ordinary states left the slot completely empty: a host that reports no deadline (question timeout disabled) and any card that had already acknowledged an interaction. Move the wait timing into the design-system AskUser contract so the header always states the current wait rule: the dismissible host countdown with its xmark control while a deadline runs, and a settled "No time limit" label when no deadline governs the answer. An acknowledged interaction still clears the slot, matching the countdown's own dismissal rule. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../src/flow-chat/ask-user/AskUser.module.css | 20 +++++++ .../ui/src/flow-chat/ask-user/AskUser.tsx | 54 ++++++++++++++++++- .../ui/src/flow-chat/ask-user/index.ts | 1 + .../packages/ui/tests/ask-user.test.mjs | 35 ++++++++++++ .../tool-cards/AskUserQuestionCard.test.tsx | 5 ++ .../tool-cards/AskUserQuestionCard.tsx | 41 ++++++++------ src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + 9 files changed, 142 insertions(+), 17 deletions(-) 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..fb891ade45 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; } 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": "提交", From 927a0eb943da8c0e8186f2b860372d42d035be84 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 18 Oct 2026 18:10:06 +0800 Subject: [PATCH 2/3] fix(ask-user): stack option copy and drop the inflated details row Long option descriptions were right-aligned into a capped column, which left a wide hole between each label and its description and a ragged right edge in the FlowChat card. Stack the label above its description instead, left aligned, and trim the option row's vertical padding to the content. Also stop the details grid from reserving space past its content. A 1fr row in an auto-height grid inflated the expanded card by roughly 76px, so the last option sat well above the footer. Only the collapsing summary now transitions its row and carries the shrinking min-size, which keeps the collapse animation while the expanded card measures its content. Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../ui/src/flow-chat/ask-user/AskUser.meta.ts | 1 - .../src/flow-chat/ask-user/AskUser.module.css | 44 ++++++++----------- 2 files changed, 18 insertions(+), 27 deletions(-) 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..9f71f2c211 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 @@ -65,7 +65,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 fb891ade45..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 @@ -334,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 { @@ -429,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; } @@ -449,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, @@ -461,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); @@ -471,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; } @@ -587,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%; @@ -609,6 +600,7 @@ @media (prefers-reduced-motion: reduce) { .details, + .root[data-openbitfun-has-summary="true"][data-openbitfun-expanded="false"] .details, .option { transition: none; } From 6764fb4d5aa322b05f8d7e66070d7bbb5ac6e969 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 18 Oct 2026 18:14:03 +0800 Subject: [PATCH 3/3] docs(ask-user): document the waitTiming prop in the component metadata Co-authored-by: OpenBitFun <318544290+bitfun-ai@users.noreply.github.com> --- design-system/packages/ui/src/flow-chat/ask-user/AskUser.meta.ts | 1 + 1 file changed, 1 insertion(+) 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 9f71f2c211..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" },