From e92bc46c562660abe4385e1ec4db10d4d686242b Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Sat, 5 Sep 2026 14:00:05 -0700 Subject: [PATCH 01/10] Add compact question forms and single-line mobile permission prompts --- apps/app/.ladle/config.mjs | 6 +- .../PluginPendingInteractionComposer.test.tsx | 57 +++++++- .../PluginPendingInteractionComposer.tsx | 138 +++++++++++------- ...ThreadPendingInteractionBanner.stories.tsx | 15 ++ .../ThreadPendingInteractionBanner.tsx | 45 +++--- plugins/ask-user-question/app.stories.tsx | 75 ++++++++++ 6 files changed, 265 insertions(+), 71 deletions(-) create mode 100644 plugins/ask-user-question/app.stories.tsx diff --git a/apps/app/.ladle/config.mjs b/apps/app/.ladle/config.mjs index 38c1490835e..4ec7d5759ef 100644 --- a/apps/app/.ladle/config.mjs +++ b/apps/app/.ladle/config.mjs @@ -39,7 +39,11 @@ function formatNetworkUrls(serverUrl) { /** @type {import("@ladle/react").UserConfig} */ export default { - stories: ["src/**/*.stories.tsx", "../../plugins/workflows/**/*.stories.tsx"], + stories: [ + "src/**/*.stories.tsx", + "../../plugins/workflows/**/*.stories.tsx", + "../../plugins/ask-user-question/*.stories.tsx", + ], defaultStory: "", viteConfig: "./.ladle/vite.config.ts", host: "0.0.0.0", diff --git a/apps/app/src/components/plugin/PluginPendingInteractionComposer.test.tsx b/apps/app/src/components/plugin/PluginPendingInteractionComposer.test.tsx index 44cf1ec9de6..4ca22084642 100644 --- a/apps/app/src/components/plugin/PluginPendingInteractionComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginPendingInteractionComposer.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; +import { useEffect, useState } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginPendingInteraction } from "@bb/domain"; @@ -56,6 +57,60 @@ afterEach(() => { }); describe("PluginPendingInteractionComposer", () => { + it("preserves drafts and pauses keyboard listeners while collapsed", () => { + const onShortcut = vi.fn(); + function QuestionRenderer() { + const [answer, setAnswer] = useState(""); + useEffect(() => { + window.addEventListener("keydown", onShortcut); + return () => window.removeEventListener("keydown", onShortcut); + }, []); + return ( + setAnswer(event.target.value)} + /> + ); + } + setPluginSlotRegistrations( + "secrets", + registrations([{ id: "secret-request", component: QuestionRenderer }]), + ); + renderComposer( + , + ); + fireEvent.change(screen.getByRole("textbox", { name: "Answer" }), { + target: { value: "Keep my draft" }, + }); + fireEvent.keyDown(window, { key: "1" }); + expect(onShortcut).toHaveBeenCalledTimes(1); + const toggle = screen.getByRole("button", { name: "Collapse form" }); + toggle.focus(); + fireEvent.click(toggle); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Expand form" }), + ); + fireEvent.keyDown(window, { key: "2" }); + expect(onShortcut).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Expand form" })); + expect(screen.getByRole("textbox").getAttribute("value")).toBe( + "Keep my draft", + ); + fireEvent.keyDown(window, { key: "3" }); + expect(onShortcut).toHaveBeenCalledTimes(2); + }); + it("mounts only the renderer registered by the interaction's plugin", () => { function WrongRenderer() { return
wrong plugin renderer
; diff --git a/apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx b/apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx index 39fea4c19e9..d1a8b8740ac 100644 --- a/apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx +++ b/apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx @@ -1,4 +1,5 @@ -import { useCallback, useMemo, useState } from "react"; +import { Activity, useCallback, useId, useMemo, useState } from "react"; +import { Icon } from "@bb/shared-ui/icon"; import { Button } from "@bb/shared-ui/button"; import type { JsonValue, PendingInteraction } from "@bb/domain"; import { PluginSlotMount } from "./PluginSlotMount"; @@ -32,6 +33,11 @@ export function PluginPendingInteractionComposer({ const stopThread = useStopThread(); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + const [collapsedInteractionId, setCollapsedInteractionId] = useState< + string | null + >(null); + const isCollapsed = collapsedInteractionId === interaction.id; + const contentId = useId(); const slot = useMemo( () => resolvePendingInteraction( @@ -83,25 +89,88 @@ export function PluginPendingInteractionComposer({ const dismissLabel = dismissal === "cancel" ? "Cancel" : "Stop turn"; return ( -
-
-

- {request.title} -

-

- {dismissal === "cancel" ? "Requested by " : "The agent asks through "} - {request.pluginId} -

+
+
+ +
- {slot ? ( - +
+

+ {dismissal === "cancel" + ? "Requested by " + : "The agent asks through "} + {request.pluginId} +

+ {slot ? ( + +

+ The plugin form crashed. {dismissLabel} to continue. +

+ +
+ } + > +
+ +
+
+ ) : (

- The plugin form crashed. {dismissLabel} to continue. + The plugin form is unavailable. {dismissLabel} to continue.

- } - > -
- -
- - ) : ( -
-

- The plugin form is unavailable. {dismissLabel} to continue. -

- + )}
- )} + {error ? (

{error} diff --git a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.stories.tsx b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.stories.tsx index 547f846f4f8..4274949a140 100644 --- a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.stories.tsx +++ b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.stories.tsx @@ -310,3 +310,18 @@ export function Overview() { ); } + +export function CompactPermissions() { + return ( +

+ + +
+ ); +} diff --git a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx index bef72a41fd5..d2c857ec9e2 100644 --- a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx +++ b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx @@ -352,30 +352,35 @@ function BannerShell({ {toggle} ) : ( -
- - - {title ?? label} - - {summary ? ( - - {summary} - - ) : null} - {sourceThreadLink} - +
+
+ +
+ + {title ?? label} + + {summary ? ( + + {summary} + + ) : null} +
+ {sourceThreadLink} +
+
+
+
{toggle}
{footer ? ( -
+
{footer("strip")}
) : null} - {toggle}
)} - - {error ? ( -

- {error} -

- ) : null} -
+ + )} + ); } diff --git a/apps/app/src/components/thread/pending-interactions/PendingInteractionShell.tsx b/apps/app/src/components/thread/pending-interactions/PendingInteractionShell.tsx new file mode 100644 index 00000000000..eaf7e23754c --- /dev/null +++ b/apps/app/src/components/thread/pending-interactions/PendingInteractionShell.tsx @@ -0,0 +1,189 @@ +import { + Activity, + useId, + useLayoutEffect, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { NavLink } from "react-router-dom"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { ExpandableLine } from "@/components/ui/expandable-line.js"; + +export interface PendingInteractionSourceThread { + href: string; + title: string; +} + +interface PendingInteractionShellProps { + label: string; + title?: string; + summary?: string | null; + initiallyExpanded: boolean; + errorMessage?: string | null; + footer?: (layout: PendingInteractionLayout) => ReactNode; + children?: (isExpanded: boolean) => ReactNode; + sourceThread?: PendingInteractionSourceThread; + testId: string; +} + +export type PendingInteractionLayout = "strip" | "card"; + +export function PendingInteractionShell({ + label, + title, + summary, + initiallyExpanded, + errorMessage, + footer, + children, + sourceThread, + testId, +}: PendingInteractionShellProps) { + const [isExpanded, setIsExpanded] = useState(initiallyExpanded); + const toggleRef = useRef(null); + const shouldRestoreToggleFocusRef = useRef(false); + const contentId = useId(); + useLayoutEffect(() => { + if (!shouldRestoreToggleFocusRef.current) return; + shouldRestoreToggleFocusRef.current = false; + toggleRef.current?.focus(); + }, [isExpanded]); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && isExpanded && !event.defaultPrevented) { + event.preventDefault(); + event.stopPropagation(); + shouldRestoreToggleFocusRef.current = true; + setIsExpanded(false); + } + }; + const toggle = ( + + ); + const errorNode = errorMessage ? ( +
+ {errorMessage} +
+ ) : null; + const sourceThreadLink = sourceThread ? ( + + From {sourceThread.title} + + ) : null; + + return ( +
+ {isExpanded ? ( +
+ + + {label} + + {sourceThreadLink} + + {toggle} +
+ ) : ( +
+
+ +
+ + {title ?? label} + + {summary ? ( + + {summary} + + ) : null} +
+ {sourceThreadLink} +
+
+
+
{toggle}
+ {footer ? ( +
+ {footer("strip")} +
+ ) : null} +
+ )} + + + + {errorNode} +
+ ); +} + +function AttentionDot() { + return ( +
diff --git a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.test.tsx b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.test.tsx index 2da5accbf4d..1c338a0c94e 100644 --- a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.test.tsx +++ b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.test.tsx @@ -539,5 +539,16 @@ describe("ThreadPendingInteractionBanner collapsed strip", () => { .getByRole("button", { name: "AOption A" }) .getAttribute("aria-pressed"), ).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: "Submit answer" })); + expect(mocks.resolveMutateAsync).toHaveBeenCalledWith({ + threadId: "thr_1", + interactionId: "pint_question", + resolution: { + kind: "user_answer", + answers: { path: { selected: ["a"] } }, + }, + }); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(mocks.stopMutateAsync).toHaveBeenCalledWith("thr_1"); }); }); diff --git a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx index fba4b7e4a23..9434ab2d2b4 100644 --- a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx +++ b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.tsx @@ -333,12 +333,11 @@ function ThreadUserQuestionPendingInteractionBanner({ sourceThread={sourceThread} testId="user-question-banner" > - {(isExpanded) => ( + {() => ( )} diff --git a/apps/app/src/components/thread/user-questions/QuestionForm.test.tsx b/apps/app/src/components/thread/user-questions/QuestionForm.test.tsx new file mode 100644 index 00000000000..b4dc488a383 --- /dev/null +++ b/apps/app/src/components/thread/user-questions/QuestionForm.test.tsx @@ -0,0 +1,278 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render as renderReact, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QuestionForm } from "@bb/shared-ui/question-form"; +import type { + Question, + QuestionAnswer, +} from "@bb/shared-ui/question-form-state"; +import { ThreadQuestionFormHost } from "./ThreadQuestionFormHost"; +import { AppCommandProvider } from "@/components/commands/AppCommandProvider"; +import { defaultAppSettings } from "@bb/domain"; +type InteractionPayload = { questions: Question[] }; +type InteractionResponse = { answers: Record }; + +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemConfig: () => ({ + data: { + generalSettings: { ...defaultAppSettings }, + keybindings: [1, 2, 3].map((digit) => ({ + command: `question.select.${digit}`, + desktopOnly: false, + shortcut: { + key: String(digit), + mod: false, + meta: false, + control: false, + alt: false, + shift: false, + }, + when: { all: ["questionOpen"], none: [] }, + })), + }, + }), +})); +vi.mock("@/lib/bb-desktop", () => ({ getBbDesktopInfo: () => null })); +const pane = vi.hoisted(() => ({ isFocused: true })); +vi.mock("@/views/thread-detail/PaneContext", () => ({ + useOptionalPaneContext: () => pane, +})); + +beforeEach(() => { + pane.isFocused = true; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +afterEach(cleanup); + +const singleSelect: InteractionPayload = { + questions: [ + { + id: "q0", + prompt: "Which database should we use?", + shortLabel: "Database", + multiSelect: false, + allowFreeText: true, + options: [ + { + value: "q0o0", + label: "Postgres", + description: "Relational, needs a server.", + preview: "CREATE TABLE users (id uuid primary key);", + }, + { + value: "q0o1", + label: "SQLite", + description: "Embedded, zero setup.", + }, + ], + }, + ], +}; + +function render( + payload: InteractionPayload, + handlers: { + submit?: (value: InteractionResponse) => Promise; + cancel?: () => Promise; + } = {}, +) { + return renderReact( + + + { + void handlers.submit?.({ answers }); + }} + onCancel={() => { + void handlers.cancel?.(); + }} + /> + + , + ); +} + +function getButtonByText( + slot: ReturnType, + text: string, +): HTMLButtonElement { + const button = slot.getByText(text).closest("button"); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`${text} is not rendered inside a button`); + } + return button; +} + +describe("answering a single-select question", () => { + it("submits the selected option value", () => { + const submit = vi.fn<(value: InteractionResponse) => Promise>( + async () => undefined, + ); + const slot = render(singleSelect, { submit }); + + expect(slot.getAllByText("Which database should we use?")).toHaveLength(2); + fireEvent.click(getButtonByText(slot, "SQLite")); + fireEvent.click(getButtonByText(slot, "Submit answer")); + + expect(submit).toHaveBeenCalledTimes(1); + expect(submit.mock.calls[0]?.[0]).toEqual({ + answers: { q0: { selected: ["q0o1"] } }, + } satisfies InteractionResponse); + }); + + it("ignores answer shortcuts in an unfocused pane", () => { + pane.isFocused = false; + const slot = render(singleSelect); + fireEvent.keyDown(window, { key: "1" }); + expect(getButtonByText(slot, "Postgres").getAttribute("aria-pressed")).toBe( + "false", + ); + }); + + it("blocks submission until something is chosen", () => { + const slot = render(singleSelect); + const submitButton = getButtonByText(slot, "Submit answer"); + + expect(submitButton.disabled).toBe(true); + fireEvent.click(getButtonByText(slot, "Postgres")); + expect(submitButton.disabled).toBe(false); + }); + + it("reveals an option preview only while that option is selected", () => { + const slot = render(singleSelect); + const preview = "CREATE TABLE users (id uuid primary key);"; + + expect(slot.queryByText(preview)).toBeNull(); + fireEvent.click(getButtonByText(slot, "Postgres")); + expect(slot.getByText(preview)).toBeTruthy(); + + fireEvent.click(getButtonByText(slot, "SQLite")); + expect(slot.queryByText(preview)).toBeNull(); + }); + + it("makes 'Other' and a real option mutually exclusive", () => { + const submit = vi.fn<(value: InteractionResponse) => Promise>( + async () => undefined, + ); + const slot = render(singleSelect, { submit }); + + fireEvent.click(getButtonByText(slot, "Postgres")); + fireEvent.click(getButtonByText(slot, "Other…")); + const textarea = slot.getByLabelText("Database answer"); + fireEvent.change(textarea, { target: { value: "DuckDB" } }); + fireEvent.click(getButtonByText(slot, "Submit answer")); + + expect(submit).toHaveBeenCalledTimes(1); + expect(submit.mock.calls[0]?.[0]).toEqual({ + answers: { q0: { selected: [], freeText: "DuckDB" } }, + } satisfies InteractionResponse); + }); + + it("selects an option with its number-key shortcut", () => { + const submit = vi.fn<(value: InteractionResponse) => Promise>( + async () => undefined, + ); + const slot = render(singleSelect, { submit }); + + fireEvent.keyDown(window, { key: "2" }); + fireEvent.click(getButtonByText(slot, "Submit answer")); + + expect(submit).toHaveBeenCalledTimes(1); + expect(submit.mock.calls[0]?.[0]).toEqual({ + answers: { q0: { selected: ["q0o1"] } }, + } satisfies InteractionResponse); + }); + + it("ignores number keys typed into the free-text box", () => { + const slot = render(singleSelect); + fireEvent.click(getButtonByText(slot, "Other…")); + const textarea = slot.getByLabelText("Database answer"); + + fireEvent.keyDown(textarea, { key: "1" }); + + expect(getButtonByText(slot, "Postgres").getAttribute("aria-pressed")).toBe( + "false", + ); + }); +}); + +describe("multi-select and multi-question flows", () => { + const multi: InteractionPayload = { + questions: [ + { + id: "q0", + prompt: "Which extras?", + shortLabel: "Extras", + multiSelect: true, + allowFreeText: true, + options: [ + { value: "q0o0", label: "Metrics", description: "Prometheus." }, + { value: "q0o1", label: "Tracing", description: "OTel." }, + ], + }, + { + id: "q1", + prompt: "Which database?", + shortLabel: "Database", + multiSelect: false, + allowFreeText: true, + options: [ + { value: "q1o0", label: "Postgres", description: "Server." }, + { value: "q1o1", label: "SQLite", description: "Embedded." }, + ], + }, + ], + }; + + it("keeps several options selected and walks both questions before submitting", () => { + const submit = vi.fn<(value: InteractionResponse) => Promise>( + async () => undefined, + ); + const slot = render(multi, { submit }); + + expect(slot.getByText("1 of 2")).toBeTruthy(); + fireEvent.click(getButtonByText(slot, "Metrics")); + fireEvent.click(getButtonByText(slot, "Tracing")); + fireEvent.click(getButtonByText(slot, "Next")); + + expect(slot.getByText("2 of 2")).toBeTruthy(); + fireEvent.click(getButtonByText(slot, "Postgres")); + fireEvent.click(getButtonByText(slot, "Submit answer")); + + expect(submit).toHaveBeenCalledTimes(1); + expect(submit.mock.calls[0]?.[0]).toEqual({ + answers: { + q0: { selected: ["q0o0", "q0o1"] }, + q1: { selected: ["q1o0"] }, + }, + } satisfies InteractionResponse); + }); + + it("cancels the request instead of submitting", () => { + const cancel = vi.fn(async () => undefined); + const slot = render(multi, { cancel }); + + fireEvent.click(getButtonByText(slot, "Cancel")); + expect(cancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/app/src/components/thread/user-questions/ThreadQuestionFormHost.tsx b/apps/app/src/components/thread/user-questions/ThreadQuestionFormHost.tsx new file mode 100644 index 00000000000..315e0830924 --- /dev/null +++ b/apps/app/src/components/thread/user-questions/ThreadQuestionFormHost.tsx @@ -0,0 +1,56 @@ +import { isEditableKeyboardTarget } from "@/lib/app-keybindings"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { QUESTION_SELECT_APP_COMMAND_IDS } from "@bb/domain"; +import { QuestionFormHostProvider } from "@bb/shared-ui/question-form-host"; +import { + useAppCommandContext, + useAppCommandShortcuts, + useIndexedAppCommandHandlers, +} from "@/components/commands/AppCommandProvider"; +import { useOptionalPaneContext } from "@/views/thread-detail/PaneContext"; + +export function ThreadQuestionFormHost({ children }: { children: ReactNode }) { + const handlerRef = useRef<((index: number) => boolean) | null>(null); + const [hasHandler, setHasHandler] = useState(false); + const isFocusedPane = useOptionalPaneContext()?.isFocused ?? true; + const bindings = useAppCommandShortcuts(QUESTION_SELECT_APP_COMMAND_IDS); + const registerChoiceHandler = useCallback( + (handler: (index: number) => boolean) => { + handlerRef.current = handler; + setHasHandler(true); + return () => { + handlerRef.current = null; + setHasHandler(false); + }; + }, + [], + ); + const value = useMemo( + () => ({ + shortcuts: new Map( + QUESTION_SELECT_APP_COMMAND_IDS.flatMap((command, index) => { + const binding = bindings.get(command); + return binding ? [[String(index), binding] as const] : []; + }), + ), + registerChoiceHandler, + }), + [bindings, registerChoiceHandler], + ); + const enabled = isFocusedPane && hasHandler; + useAppCommandContext("questionOpen", enabled); + useIndexedAppCommandHandlers( + QUESTION_SELECT_APP_COMMAND_IDS, + (index, invocation) => { + if (isEditableKeyboardTarget(invocation.target)) return false; + return enabled ? (handlerRef.current?.(index) ?? false) : false; + }, + 100, + enabled, + ); + return ( + + {children} + + ); +} diff --git a/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx b/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx index 78710d41697..e1fca22d11e 100644 --- a/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx +++ b/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx @@ -1,514 +1,72 @@ -import { - useLayoutEffect, - useMemo, - useRef, - useState, - type KeyboardEvent, -} from "react"; -import { QUESTION_SELECT_APP_COMMAND_IDS } from "@bb/domain"; -import type { - PendingInteractionUserQuestionOption, - PendingInteractionUserQuestionQuestion, -} from "@bb/domain"; -import { Button } from "@bb/shared-ui/button"; -import { Icon } from "@bb/shared-ui/icon"; -import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; -import { TabPill } from "@/components/ui/tab-pill.js"; -import { useAutoGrow } from "@/hooks/useAutoGrow"; +import { useMemo, useRef } from "react"; +import type { PendingInteractionUserQuestionQuestion } from "@bb/domain"; +import { QuestionForm } from "@bb/shared-ui/question-form"; import { useResolveThreadPendingInteraction } from "@/hooks/mutations/thread-interaction-mutations"; import { useStopThread } from "@/hooks/mutations/thread-runtime-mutations"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; -import { cn } from "@bb/shared-ui/lib/utils"; -import { - answerStateFor, - buildUserAnswerResolution, - createInitialFormState, - isQuestionAnswered, - type QuestionAnswerState, - type QuestionFormState, -} from "./user-question-form-state.js"; -import { - useAppCommandContext, - useAppCommandShortcuts, - useIndexedAppCommandHandlers, -} from "@/components/commands/AppCommandProvider"; -import type { AppShortcutPresentation } from "@/lib/app-keybindings"; -import { useOptionalPaneContext } from "@/views/thread-detail/PaneContext"; import { useStickyFooterAvailableHeight } from "./useStickyFooterAvailableHeight.js"; interface UserQuestionAnswerFormProps { - className?: string; interactionId: string; - isResolving?: boolean; + isResolving: boolean; questions: readonly PendingInteractionUserQuestionQuestion[]; - shortcutsEnabled: boolean; threadId: string; } -interface QuestionOptionRowProps { - checked: boolean; - label: string; - description?: string; - multiSelect: boolean; - onSelect: () => void; - shortcut?: AppShortcutPresentation; -} - -interface QuestionTabsProps { - currentIndex: number; - formState: QuestionFormState; - onSelect: (index: number) => void; - questions: readonly PendingInteractionUserQuestionQuestion[]; -} - -interface QuestionInputBlockProps { - disabled: boolean; - question: PendingInteractionUserQuestionQuestion; - state: QuestionAnswerState; - onToggleOption: (optionValue: string) => void; - onSelectOther: () => void; - onFreeTextChange: (value: string) => void; - onShortcutSubmit: () => void; - shortcuts: ReadonlyMap; -} - -const OTHER_OPTION_LABEL = "Other…"; -const USER_QUESTION_FREE_TEXT_MIN_HEIGHT = 84; -const USER_QUESTION_FREE_TEXT_MAX_HEIGHT = 158; - -type QuestionShortcutChoice = - | { kind: "option"; value: string } - | { kind: "other" } - | null; - -export function resolveQuestionShortcutChoice( - question: PendingInteractionUserQuestionQuestion, - index: number, -): QuestionShortcutChoice { - const options = question.options ?? []; - const option = options[index]; - if (option) return { kind: "option", value: option.value }; - if ( - index === options.length && - options.length > 0 && - question.allowFreeText - ) { - return { kind: "other" }; - } - return null; -} - -function QuestionOptionRow({ - checked, - label, - description, - multiSelect, - onSelect, - shortcut, -}: QuestionOptionRowProps) { - return ( - - ); -} - -function QuestionTabs({ - currentIndex, - formState, - onSelect, - questions, -}: QuestionTabsProps) { - return ( -
- {} -
- {questions.map((question, index) => { - const answered = isQuestionAnswered( - question, - answerStateFor(formState, question), - ); - return ( - onSelect(index)} - closeAction={null} - /> - ); - })} -
- - {currentIndex + 1} of {questions.length} - -
- ); -} - -function QuestionInputBlock({ - disabled, - question, - state, - onToggleOption, - onSelectOther, - onFreeTextChange, - onShortcutSubmit, - shortcuts, -}: QuestionInputBlockProps) { - const freeTextRef = useRef(null); - const isPointerCoarse = usePointerCoarse(); - const resizeFreeTextArea = useAutoGrow(freeTextRef, { - minHeight: USER_QUESTION_FREE_TEXT_MIN_HEIGHT, - maxHeight: USER_QUESTION_FREE_TEXT_MAX_HEIGHT, - }); - const options = question.options ?? []; - const freeTextLabel = `${question.shortLabel ?? question.prompt} answer`; - - useLayoutEffect(() => { - if (!state.otherSelected) return; - resizeFreeTextArea(); - }, [question.id, resizeFreeTextArea, state.otherSelected, state.otherText]); - - const handleFreeTextKeyDown = ( - event: KeyboardEvent, - ): void => { - if ( - event.nativeEvent.isComposing || - event.key !== "Enter" || - (!event.metaKey && !event.ctrlKey) - ) { - return; - } - event.preventDefault(); - onShortcutSubmit(); - }; - return ( -
- {question.prompt} -
- {question.prompt} -
-
- {options.map((option: PendingInteractionUserQuestionOption, index) => ( - onToggleOption(option.value)} - shortcut={shortcuts.get(String(index))} - /> - ))} - {question.allowFreeText && options.length > 0 ? ( - - ) : null} -
- {state.otherSelected ? ( -