diff --git a/packages/junior-dashboard/e2e/review-desk.spec.ts b/packages/junior-dashboard/e2e/review-desk.spec.ts index d52e4bfe58..250125306d 100644 --- a/packages/junior-dashboard/e2e/review-desk.spec.ts +++ b/packages/junior-dashboard/e2e/review-desk.spec.ts @@ -160,10 +160,16 @@ test("each review item gets a fresh answer form", async ({ detail.getByRole("heading", { name: license.title }), ).toBeVisible(); await detail.getByRole("radio", { name: "Reject" }).click(); + await expect(detail.getByText("1 of 2 answered")).toBeVisible(); + // The note goes with the picked answer. It must not replace it. + await detail.getByLabel("Anything else?").fill("Ask legal first."); await detail.getByRole("button", { name: "Send" }).click(); await expect.poll(() => actions.length).toBe(2); expect(actions[1]).toMatchObject({ - body: { action: "submit", answer: { values: { decision: ["Reject"] } } }, + body: { + action: "submit", + answer: { other: "Ask legal first.", values: { decision: ["Reject"] } }, + }, id: license.id, }); }); diff --git a/packages/junior-dashboard/src/client/components/UserInputForm.tsx b/packages/junior-dashboard/src/client/components/UserInputForm.tsx index 18b9cfc380..0c60998dae 100644 --- a/packages/junior-dashboard/src/client/components/UserInputForm.tsx +++ b/packages/junior-dashboard/src/client/components/UserInputForm.tsx @@ -3,6 +3,7 @@ import { buttonUserInputField, isEmptyUserInputAnswer, normalizeUserInputAnswer, + USER_INPUT_NOTE, type UserInputAnswer, type UserInputField, type UserInputRequest, @@ -41,9 +42,13 @@ function ChoiceField(props: { }) { const id = useId(); const { field } = props; + // Say how many choices count, so a reader knows when a field is done. const legend = ( {field.label} + + {field.kind === "choice" ? "Pick one" : "Pick any"} + ); const description = field.description ? ( @@ -179,11 +184,12 @@ export function UserInputForm(props: { requestId: props.requestId, values, }; + const normalized = normalizeUserInputAnswer(props.request, answer); + const answeredCount = Object.keys(normalized.values).length; + const fieldCount = props.request.fields.length; function submit(event: FormEvent) { event.preventDefault(); - if ( - !isEmptyUserInputAnswer(normalizeUserInputAnswer(props.request, answer)) - ) { + if (!isEmptyUserInputAnswer(normalized)) { void send(answer); } } @@ -259,9 +265,9 @@ export function UserInputForm(props: { )} @@ -269,6 +275,14 @@ export function UserInputForm(props: { + {fieldCount > 1 ? ( + + {answeredCount} of {fieldCount} answered + + ) : null} {secondary} diff --git a/packages/junior-dashboard/src/client/review-desk/ConversationReviewBar.tsx b/packages/junior-dashboard/src/client/review-desk/ConversationReviewBar.tsx index 3a72a5007d..612d78b2e1 100644 --- a/packages/junior-dashboard/src/client/review-desk/ConversationReviewBar.tsx +++ b/packages/junior-dashboard/src/client/review-desk/ConversationReviewBar.tsx @@ -45,7 +45,6 @@ export function ConversationReviewBar(props: { conversationId: string }) { label="Review items" onClose={() => setOpen(false)} openKey={props.conversationId} - width="narrow" >
{items.map((item) => ( diff --git a/packages/junior/src/api/schema.ts b/packages/junior/src/api/schema.ts index 8512db7421..2568e4bb19 100644 --- a/packages/junior/src/api/schema.ts +++ b/packages/junior/src/api/schema.ts @@ -9,6 +9,7 @@ export { buttonUserInputField, isEmptyUserInputAnswer, normalizeUserInputAnswer, + USER_INPUT_NOTE, } from "../chat/tools/runtime/user-input-request"; export type { UserInputAnswer, diff --git a/packages/junior/src/chat/slack/user-input.ts b/packages/junior/src/chat/slack/user-input.ts index 551aec4e6b..047b2662a5 100644 --- a/packages/junior/src/chat/slack/user-input.ts +++ b/packages/junior/src/chat/slack/user-input.ts @@ -7,6 +7,7 @@ import { buttonUserInputField, normalizeUserInputAnswer, USER_INPUT_LIMITS, + USER_INPUT_NOTE, type UserInputAnswer, type UserInputRequest, } from "@/chat/tools/runtime/user-input-request"; @@ -67,8 +68,8 @@ function toOption(option: { /** * Render a request as a Slack modal form: one input for each field and one - * optional Other text input. Every input is optional, so a person can answer - * with free text only. `callbackId` and `privateMetadata` tell the submit + * optional note input that goes with the answers. Every input is optional, + * so a person can answer with free text only. `callbackId` and `privateMetadata` tell the submit * handler what the form answers. */ export function buildSlackUserInputForm( @@ -113,9 +114,9 @@ export function buildSlackUserInputForm( { type: "input", block_id: OTHER_BLOCK_ID, - label: plainText("Other"), + label: plainText(USER_INPUT_NOTE.label), optional: true, - hint: plainText("Anything else, or an answer that is not listed."), + hint: plainText(USER_INPUT_NOTE.description), element: { type: "plain_text_input", action_id: VALUE_ACTION_ID, diff --git a/packages/junior/src/chat/tools/runtime/user-input-request.ts b/packages/junior/src/chat/tools/runtime/user-input-request.ts index 7afadbf540..41141ee0b4 100644 --- a/packages/junior/src/chat/tools/runtime/user-input-request.ts +++ b/packages/junior/src/chat/tools/runtime/user-input-request.ts @@ -111,6 +111,16 @@ export const userInputRequestSchema = z } }); +/** + * Label and help text of the free-text input that every form adds after the + * fields. The text goes with the picked answers and does not replace them, so + * every surface says that in the same words. + */ +export const USER_INPUT_NOTE = { + label: "Anything else?", + description: "Optional. This note is sent together with your answers above.", +} as const; + /** One field of a user input request. */ export type UserInputField = z.output;