diff --git a/dembrane/frontend/e2e/verify-explore-harness.html b/dembrane/frontend/e2e/verify-explore-harness.html new file mode 100644 index 000000000..68dae60c4 --- /dev/null +++ b/dembrane/frontend/e2e/verify-explore-harness.html @@ -0,0 +1,12 @@ + + + + + + Verify and Explore harness + + +
+ + + diff --git a/dembrane/frontend/e2e/verify-explore-harness.spec.ts b/dembrane/frontend/e2e/verify-explore-harness.spec.ts new file mode 100644 index 000000000..aa3f3707a --- /dev/null +++ b/dembrane/frontend/e2e/verify-explore-harness.spec.ts @@ -0,0 +1,151 @@ +import { expect, type Page, test } from "@playwright/test"; + +// ECHO-1052: every portal state of Verify and Explore at phone width, and the portal +// editor's Verify and Explore sections at desktop width, in English and Dutch. +// E2E_SHOTS= also saves screenshots. +const shots = process.env.E2E_SHOTS; + +async function answerApi(page: Page) { + // Anything the seeded queries do not cover answers empty, as a fresh project would. + await page.route("**/api/**", (route) => route.fulfill({ json: {} })); +} + +const open = async (page: Page, view: string, lang: string) => { + await page.addInitScript((l) => { + localStorage.setItem("dembrane-language", l); + }, lang); + await answerApi(page); + await page.goto(`/e2e/verify-explore-harness.html?view=${view}&lang=${lang}`); +}; + +const noSideScroll = (page: Page) => + page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ); + +for (const lang of ["en-US", "nl-NL"]) { + const nl = lang === "nl-NL"; + + test(`portal states at 390px, ${lang}`, async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.setViewportSize({ height: 844, width: 390 }); + const shot = async (name: string) => { + if (shots) + await page.screenshot({ + fullPage: true, + path: `${shots}/portal-${lang}-${name}.png`, + }); + }; + + for (const [view, label] of [ + ["button-verify", nl ? "Verifiëren" : "Verify"], + ["button-explore", nl ? "Verkennen" : "Explore"], + // Both on: the product name, in every language. + ["button-both", "ECHO"], + ] as const) { + await open(page, view, lang); + await expect(page.getByTestId("portal-audio-echo-button")).toHaveText( + label, + ); + await shot(view); + expect(await noSideScroll(page)).toBe(true); + } + + await open(page, "choice-both", lang); + const verify = page.getByTestId("portal-echo-verify-card"); + const explore = page.getByTestId("portal-echo-explore-card"); + await expect(verify).toBeVisible(); + await expect(explore).toBeVisible(); + for (const choice of [verify, explore]) { + const box = await choice.boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); + } + await shot("choice-both"); + await page.keyboard.press("Tab"); + await expect(verify).toBeFocused(); + await shot("choice-both-focus"); + expect(await noSideScroll(page)).toBe(true); + + await open(page, "picker", lang); + await expect( + page.getByTestId("portal-verify-topic-agreements"), + ).toBeVisible(); + await page.getByTestId("portal-verify-topic-heard").click(); + await expect(page.getByTestId("portal-verify-topic-heard")).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect( + page.getByTestId("portal-verify-selection-next-button"), + ).toBeEnabled(); + await page.mouse.move(0, 0); + await page.waitForTimeout(300); + await shot("picker"); + expect(await noSideScroll(page)).toBe(true); + + await open(page, "reminder", lang); + await page.getByTestId("portal-audio-stop-finish-button").click(); + await expect(page.getByTestId("portal-audio-verify-button")).toBeVisible(); + await page.waitForTimeout(300); + await shot("reminder"); + + expect(errors).toEqual([]); + }); + + test(`portal editor Verify and Explore at 1280px, ${lang}`, async ({ + page, + }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.setViewportSize({ height: 900, width: 1280 }); + await open(page, "editor", lang); + const cross = page.getByTestId("portal-editor-cross-pollination-switch"); + await expect(cross).toBeAttached(); + await expect( + page.getByTestId("portal-editor-verify-topic-heard"), + ).toBeAttached(); + await expect( + page.getByTestId("portal-editor-ask-email-checkbox"), + ).toHaveCount(0); + const explore = page.getByTestId("portal-editor-go-deeper-switch"); + await explore.scrollIntoViewIfNeeded(); + if (shots) { + const section = page.locator("form").first(); + await section.screenshot({ path: `${shots}/editor-${lang}-full.png` }); + await page + .getByTestId("portal-editor-go-deeper-switch") + .locator( + "xpath=ancestor::div[contains(@class,'mantine-Stack-root')][2]", + ) + .screenshot({ path: `${shots}/editor-${lang}-explore-verify.png` }); + } + expect(errors).toEqual([]); + }); +} + +// The recorder's footer (the ECHO, Verify or Explore button beside Stop) stays on one row +// on a 390px phone in the languages with the longest words. +for (const lang of ["nl-NL", "de-DE"]) { + test(`recorder footer on one row at 390px, ${lang}`, async ({ page }) => { + await page.setViewportSize({ height: 844, width: 390 }); + for (const view of ["button-both", "button-verify", "button-explore"]) { + await open(page, view, lang); + const refine = page.getByTestId("portal-audio-echo-button"); + const stop = page.getByTestId("portal-audio-stop-button"); + await expect(refine).toBeVisible(); + if (view === "button-both") await expect(refine).toHaveText("ECHO"); + const [a, b] = [await refine.boundingBox(), await stop.boundingBox()]; + expect([view, Math.abs((a?.y ?? 0) - (b?.y ?? 1)) < 2]).toEqual([ + view, + true, + ]); + expect(await noSideScroll(page)).toBe(true); + if (shots && lang === "de-DE") + await page.screenshot({ + fullPage: true, + path: `${shots}/portal-${lang}-${view}.png`, + }); + } + }); +} diff --git a/dembrane/frontend/src/Router.tsx b/dembrane/frontend/src/Router.tsx index 12238c729..450b53283 100644 --- a/dembrane/frontend/src/Router.tsx +++ b/dembrane/frontend/src/Router.tsx @@ -15,10 +15,8 @@ import { ProjectLibraryLayout } from "./components/layout/ProjectLibraryLayout"; import { ProjectOverviewLayout } from "./components/layout/ProjectOverviewLayout"; import { WorkspaceLayout } from "./components/layout/WorkspaceLayout"; import { ParticipantConversationAudioContent } from "./components/participant/ParticipantConversationAudioContent"; -import { RefineSelection } from "./components/participant/refine/RefineSelection"; import { Verify } from "./components/participant/verify/Verify"; import { VerifyArtefact } from "./components/participant/verify/VerifyArtefact"; -import { VerifySelection } from "./components/participant/verify/VerifySelection"; import { ProjectAccessGuard } from "./components/project/ProjectAccessGuard"; import { ENABLE_CANVAS, ENABLE_MONITOR, ENABLE_PRESENT } from "./config"; import { @@ -45,6 +43,16 @@ import { // Lazy-loaded route components // The conversation page is dashboard only; loaded lazily so the portal's first load stays small. +// The choice between Verify and Explore and the Verify picker open only after a minute of +// recording, so they stay out of the portal's first load (ECHO-1052). +const RefineSelection = createLazyNamedRoute( + () => import("./components/participant/refine/RefineSelection"), + "RefineSelection", +); +const VerifySelection = createLazyNamedRoute( + () => import("./components/participant/verify/VerifySelection"), + "VerifySelection", +); const ProjectConversationRoute = createLazyNamedRoute( () => import("./routes/project/conversation/ProjectConversationRoute"), "ProjectConversationRoute", diff --git a/dembrane/frontend/src/components/common/settingChange.tsx b/dembrane/frontend/src/components/common/settingChange.tsx index 75f116172..ecb64fc5f 100644 --- a/dembrane/frontend/src/components/common/settingChange.tsx +++ b/dembrane/frontend/src/components/common/settingChange.tsx @@ -29,6 +29,7 @@ export const FIELD_LABELS: Record string> = { host_guide: () => t`Host guide`, image_generation_model: () => t`Image style`, is_conversation_allowed: () => t`Portal open for new conversations`, + is_get_reply_cross_pollination_enabled: () => t`Cross-pollination`, is_get_reply_enabled: () => t`Replies to participants`, is_project_notification_subscription_allowed: () => t`Participant updates subscription`, diff --git a/dembrane/frontend/src/components/participant/ParticipantConversationAudio.test.tsx b/dembrane/frontend/src/components/participant/ParticipantConversationAudio.test.tsx index c66e97103..ffb44c831 100644 --- a/dembrane/frontend/src/components/participant/ParticipantConversationAudio.test.tsx +++ b/dembrane/frontend/src/components/participant/ParticipantConversationAudio.test.tsx @@ -10,6 +10,7 @@ import { ParticipantConversationAudio } from "./ParticipantConversationAudio"; const state = vi.hoisted(() => ({ chunks: [] as unknown[], isRecording: false, + navigate: vi.fn(), onRecordingInterrupted: undefined as (() => void) | undefined, onChunkAccepted: undefined as (() => void) | undefined, onRecordingStop: undefined as @@ -22,6 +23,9 @@ const state = vi.hoisted(() => ({ })); vi.mock("posthog-js", () => ({ default: { capture: vi.fn() } })); +vi.mock("@/hooks/useI18nNavigate", () => ({ + useI18nNavigate: () => state.navigate, +})); vi.mock("@/lib/api", () => ({ finishConversation: vi.fn(), pingConversation: vi.fn(async () => ({})), @@ -146,6 +150,8 @@ afterEach(() => { state.project = {}; state.recordingTime = 0; state.stopRecording.mockReset(); + state.navigate.mockReset(); + localStorage.clear(); }); const page = () => ( @@ -292,3 +298,62 @@ it.each([ expect(screen.getByTestId("stop-modal").dataset.verify).toBe(offered); }, ); + +// ECHO-1052: the button under the recorder is named after where it goes, and goes there. +const recordingAMinute = (project: Record) => { + state.taking = true; + state.chunks = [{ id: "ch-1" }]; + state.isRecording = true; + state.recordingTime = 61; + state.project = project; + renderPage(); + return screen.queryByTestId("portal-audio-echo-button"); +}; + +it("with only Verify on, the button says Verify and goes straight to Verify", () => { + const button = recordingAMinute({ + is_get_reply_enabled: false, + is_verify_enabled: true, + }); + expect(button?.textContent).toBe("Verify"); + expect(button?.getAttribute("data-mode")).toBe("verify"); + act(() => button?.click()); + expect(state.navigate).toHaveBeenCalledWith("/p-1/conversation/c-1/verify"); +}); + +it("with only Explore on, the button says Explore and asks for a question at once", () => { + const button = recordingAMinute({ + is_get_reply_enabled: true, + is_verify_enabled: false, + }); + expect(button?.textContent).toBe("Explore"); + act(() => button?.click()); + expect(state.navigate).toHaveBeenCalledWith("/p-1/conversation/c-1?echo=1"); + expect(localStorage.getItem("cooldown_c-1_echo")).not.toBeNull(); +}); + +it("with both on, the button is named ECHO and offers the choice", () => { + const button = recordingAMinute({ + is_get_reply_enabled: true, + is_verify_enabled: true, + }); + expect(button?.textContent).toBe("ECHO"); + act(() => button?.click()); + expect(state.navigate).toHaveBeenCalledWith("/p-1/conversation/c-1/refine"); +}); + +it("with neither on, there is no button", () => { + expect( + recordingAMinute({ is_get_reply_enabled: false, is_verify_enabled: false }), + ).toBeNull(); +}); + +it("a locked single feature opens the choice screen, which says why", () => { + const button = recordingAMinute({ + is_ai_available: false, + is_get_reply_enabled: false, + is_verify_enabled: true, + }); + act(() => button?.click()); + expect(state.navigate).toHaveBeenCalledWith("/p-1/conversation/c-1/refine"); +}); diff --git a/dembrane/frontend/src/components/participant/ParticipantConversationAudio.tsx b/dembrane/frontend/src/components/participant/ParticipantConversationAudio.tsx index 3aad366c6..098b1f6f4 100644 --- a/dembrane/frontend/src/components/participant/ParticipantConversationAudio.tsx +++ b/dembrane/frontend/src/components/participant/ParticipantConversationAudio.tsx @@ -55,6 +55,9 @@ import { ParticipantRecordingWaveform } from "./ParticipantRecordingWaveform"; import { ParticipantSettingsModal } from "./ParticipantSettingsModal"; import { PermissionErrorModal } from "./PermissionErrorModal"; import { recordingClosed } from "./recordingCap"; +import { RefineButtonLabel } from "./refine/RefineButtonLabel"; +import { refineMode } from "./refine/refineMode"; +import { useRefineActions } from "./refine/useRefineActions"; import { StopRecordingConfirmationModal } from "./StopRecordingConfirmationModal"; import { useConversationArtefacts } from "./verify/hooks"; import { WrapUpModal } from "./WrapUpModal"; @@ -739,13 +742,22 @@ export const ParticipantConversationAudio = () => { const showVerify = projectQuery.data?.is_verify_enabled; const showEcho = projectQuery.data?.is_get_reply_enabled; + const mode = refineMode(projectQuery.data); + const refine = useRefineActions({ + aiUnavailable: projectQuery.data?.is_ai_available === false, + conversationId, + projectId, + }); + // One feature on: straight to it. Both on, or the one on locked: the choice screen, + // which says why a locked one cannot open yet. const handleRefineClick = () => { if (recordingTime < REFINE_BUTTON_THRESHOLD_SECONDS) { openRefineInfoModal(); return; } - + if (mode === "verify" && refine.goVerify()) return; + if (mode === "explore" && refine.goExplore()) return; navigate(`/${projectId}/conversation/${conversationId}/refine`); }; @@ -808,13 +820,13 @@ export const ParticipantConversationAudio = () => { const getRefineInfoText = () => { if (showVerify && showEcho) { - return t`Take some time to create an outcome that makes your contribution concrete or get an immediate reply from dembrane to help you deepen the conversation.`; + return t`Check what we captured together, or get a question to go deeper.`; } if (showVerify) { - return t`Take some time to create an outcome that makes your contribution concrete.`; + return t`Check what we captured together.`; } if (showEcho) { - return t`Get an immediate reply from dembrane to help you deepen the conversation.`; + return t`Get a question to go deeper.`; } return ""; }; @@ -822,7 +834,7 @@ export const ParticipantConversationAudio = () => { const getRefineModalTitle = () => { if (showVerify && showEcho) { return ( - + "ECHO" available soon ); @@ -850,9 +862,8 @@ export const ParticipantConversationAudio = () => { const getRefineInfoReason = () => { return ( - - We need a bit more context to help you use ECHO effectively. Please - continue recording so we can provide better suggestions. + + We need a bit more of your conversation first. Please keep recording. ); }; @@ -1191,6 +1202,7 @@ export const ParticipantConversationAudio = () => { ? undefined : "filled" } + data-mode={mode ?? undefined} {...testId("portal-audio-echo-button")} > {recordingTime < REFINE_BUTTON_THRESHOLD_SECONDS && ( @@ -1200,7 +1212,7 @@ export const ParticipantConversationAudio = () => { /> )} - ECHO + )} diff --git a/dembrane/frontend/src/components/participant/StopRecordingConfirmationModal.tsx b/dembrane/frontend/src/components/participant/StopRecordingConfirmationModal.tsx index c2c332a54..ac342d518 100644 --- a/dembrane/frontend/src/components/participant/StopRecordingConfirmationModal.tsx +++ b/dembrane/frontend/src/components/participant/StopRecordingConfirmationModal.tsx @@ -69,8 +69,8 @@ export const StopRecordingConfirmationModal = ({ centered title={ showVerifyPrompt ? ( - - Verification reminder + + Before you finish ) : ( Recording paused @@ -84,9 +84,8 @@ export const StopRecordingConfirmationModal = ({ {showVerifyPrompt ? ( <> - - You haven't verified any outcomes yet. Would you like to verify - before finishing? + + Check what we captured together? It takes about a minute. diff --git a/dembrane/frontend/src/components/participant/refine/RefineButtonLabel.tsx b/dembrane/frontend/src/components/participant/refine/RefineButtonLabel.tsx new file mode 100644 index 000000000..87262a7dc --- /dev/null +++ b/dembrane/frontend/src/components/participant/refine/RefineButtonLabel.tsx @@ -0,0 +1,15 @@ +import { Trans } from "@lingui/react/macro"; +import type { RefineMode } from "./refineMode"; + +/** + * The recorder's button after the first minute (ECHO-1052): named after where it goes. + * With both on it is "ECHO", the product name, in every language (never translated), and + * opens the choice between Verify and Explore. + */ +export const RefineButtonLabel = ({ mode }: { mode: RefineMode | null }) => { + if (mode === "verify") + return Verify; + if (mode === "explore") + return Explore; + return "ECHO"; +}; diff --git a/dembrane/frontend/src/components/participant/refine/RefineChoice.tsx b/dembrane/frontend/src/components/participant/refine/RefineChoice.tsx new file mode 100644 index 000000000..3a5a1a085 --- /dev/null +++ b/dembrane/frontend/src/components/participant/refine/RefineChoice.tsx @@ -0,0 +1,67 @@ +import { Group, Progress, Stack, Text, UnstyledButton } from "@mantine/core"; +import { CaretRightIcon, type Icon } from "@phosphor-icons/react"; +import { type ReactNode, useId } from "react"; +import { AiUnavailableText } from "@/components/participant/AiUnavailable"; + +/** + * One pressable choice on a phone (ECHO-1052): the full box that means "do", in the + * control grey so it reads as a button at a glance, an icon, a label, one line on what + * happens, and a caret. At least 44px tall; the label is its accessible name and the line + * its description. A locked choice says why and does nothing when pressed. + */ +export const RefineChoice = ({ + icon: IconComponent, + label, + description, + onPress, + locked, + aiUnavailable, + cooldown, + testId, +}: { + icon: Icon; + label: ReactNode; + description: ReactNode; + onPress: () => void; + locked: boolean; + aiUnavailable: boolean; + cooldown: { isActive: boolean; progress: number; text: ReactNode }; + testId: string; +}) => { + const labelId = useId(); + const descriptionId = useId(); + return ( + { + if (!locked) onPress(); + }} + className="app-do app-choice block w-full px-5 py-5" + aria-disabled={locked || undefined} + aria-labelledby={labelId} + aria-describedby={descriptionId} + data-testid={testId} + > + + + + ); +}; diff --git a/dembrane/frontend/src/components/participant/refine/RefineSelection.test.tsx b/dembrane/frontend/src/components/participant/refine/RefineSelection.test.tsx index 26a87952e..24979a0af 100644 --- a/dembrane/frontend/src/components/participant/refine/RefineSelection.test.tsx +++ b/dembrane/frontend/src/components/participant/refine/RefineSelection.test.tsx @@ -98,3 +98,38 @@ it.each([ fireEvent.click(verify); expect(state.navigate).toHaveBeenCalledWith("/p-1/conversation/c-1/verify"); }); + +// ECHO-1052: the choice reads as two buttons, each named by its label and described by +// one line of what happens. +it("with both on, offers two named choices, each saying what happens", () => { + renderPage({}); + expect(screen.getByText("What would help now?")).toBeTruthy(); + const verify = screen.getByRole("button", { name: "Verify" }); + const explore = screen.getByRole("button", { name: "Explore" }); + expect(verify.getAttribute("aria-describedby")).toBeTruthy(); + expect( + document.getElementById(verify.getAttribute("aria-describedby") ?? "") + ?.textContent, + ).toBe("Check what we captured together"); + expect( + document.getElementById(explore.getAttribute("aria-describedby") ?? "") + ?.textContent, + ).toBe("Get a question to go deeper"); + expect(verify.className).toContain("app-choice"); + + fireEvent.click(explore); + expect(state.navigate).toHaveBeenCalledWith("/p-1/conversation/c-1?echo=1"); + expect(localStorage.getItem("cooldown_c-1_echo")).not.toBeNull(); +}); + +it("with one on, shows only that choice and no question", () => { + renderPage({ is_get_reply_enabled: false }); + expect(screen.queryByText("What would help now?")).toBeNull(); + expect(screen.getByTestId("portal-echo-verify-card")).toBeTruthy(); + expect(screen.queryByTestId("portal-echo-explore-card")).toBeNull(); +}); + +it("with neither on, shows nothing", () => { + renderPage({ is_get_reply_enabled: false, is_verify_enabled: false }); + expect(screen.queryByRole("button")).toBeNull(); +}); diff --git a/dembrane/frontend/src/components/participant/refine/RefineSelection.tsx b/dembrane/frontend/src/components/participant/refine/RefineSelection.tsx index fe141c699..2ad79e417 100644 --- a/dembrane/frontend/src/components/participant/refine/RefineSelection.tsx +++ b/dembrane/frontend/src/components/participant/refine/RefineSelection.tsx @@ -1,140 +1,91 @@ import { Trans } from "@lingui/react/macro"; -import { Progress, Stack, Text, Title, UnstyledButton } from "@mantine/core"; -import { useLocalStorage } from "@mantine/hooks"; +import { Stack, Title } from "@mantine/core"; +import { ChatCircleDotsIcon, SealCheckIcon } from "@phosphor-icons/react"; import { useParams } from "react-router"; -import { AiUnavailableText } from "@/components/participant/AiUnavailable"; import { useParticipantProjectById } from "@/components/participant/hooks"; -import { useI18nNavigate } from "@/hooks/useI18nNavigate"; -import { testId } from "@/lib/testUtils"; -import { useRefineSelectionCooldown } from "./hooks/useRefineSelectionCooldown"; +import { RefineChoice } from "./RefineChoice"; +import { refineMode } from "./refineMode"; +import { useRefineActions } from "./useRefineActions"; +/** + * The choice between Verify and Explore (ECHO-1052). The recorder's button leads here when + * both are on, or when the one that is on is locked (cooling down, or AI not available), so + * the participant sees why. + */ export const RefineSelection = () => { const { projectId, conversationId } = useParams(); - const navigate = useI18nNavigate(); - const cooldown = useRefineSelectionCooldown(conversationId); const projectQuery = useParticipantProjectById(projectId ?? ""); - const [_isRefineDisabled, setIsRefineDisabled] = useLocalStorage({ - defaultValue: false, - key: `refine_disabled_${conversationId}`, - }); - const aiUnavailable = projectQuery.data?.is_ai_available === false; - const verifyLocked = aiUnavailable || cooldown.verify.isActive; - const echoLocked = aiUnavailable || cooldown.echo.isActive; - - const handleVerifyClick = () => { - if (verifyLocked) return; - navigate(`/${projectId}/conversation/${conversationId}/verify`); - }; - - const handleEchoClick = () => { - if (echoLocked) return; - cooldown.startEchoCooldown(); - // Disable refine button while echo is generating - setIsRefineDisabled(true); - navigate(`/${projectId}/conversation/${conversationId}?echo=1`); - }; - - const showVerify = projectQuery.data?.is_verify_enabled ?? false; - const showEcho = projectQuery.data?.is_get_reply_enabled ?? false; + const actions = useRefineActions({ + aiUnavailable, + conversationId, + projectId, + }); // If still loading, return null to avoid flicker - if (projectQuery.isLoading) { - return null; - } - - if (!showVerify && !showEcho) { - return null; - } - - const flexClass = showVerify && showEcho ? "flex-1" : "h-[50%]"; + if (projectQuery.isLoading) return null; + const mode = refineMode(projectQuery.data); + if (!mode) return null; + const showVerify = mode !== "explore"; + const showExplore = mode !== "verify"; return ( - - {/* Verify option */} + + {mode === "both" && ( + + <Trans id="participant.refine.choice.title"> + What would help now? + </Trans> + + )} {showVerify && ( - - - - <Trans id="participant.echo.verify">Verify</Trans> - - - - Take some time to create an outcome that makes your contribution - concrete. + Verify} + description={ + + Check what we captured together + + } + onPress={actions.goVerify} + locked={actions.verifyLocked} + aiUnavailable={aiUnavailable} + cooldown={{ + isActive: actions.cooldown.verify.isActive, + progress: actions.cooldown.verify.progress, + text: ( + + Cooling down. Available in{" "} + {actions.cooldown.verify.formattedTime} - - - {aiUnavailable && } - - {!aiUnavailable && cooldown.verify.isActive && ( - - - - Cooling down. Available in {cooldown.verify.formattedTime} - - - - - )} - - - )} - - {/* Explore option */} - {showEcho && ( - - - - <Trans id="participant.echo.explore">Explore</Trans> - - - - Get an immediate reply from dembrane to help you deepen the - conversation. + testId="portal-echo-verify-card" + /> + )} + {showExplore && ( + Explore} + description={ + + Get a question to go deeper + + } + onPress={actions.goExplore} + locked={actions.exploreLocked} + aiUnavailable={aiUnavailable} + cooldown={{ + isActive: actions.cooldown.echo.isActive, + progress: actions.cooldown.echo.progress, + text: ( + + Cooling down. Available in {actions.cooldown.echo.formattedTime} - - - {aiUnavailable && } - - {!aiUnavailable && cooldown.echo.isActive && ( - - - - Cooling down. Available in {cooldown.echo.formattedTime} - - - - - )} - - + ), + }} + testId="portal-echo-explore-card" + /> )} ); diff --git a/dembrane/frontend/src/components/participant/refine/refineMode.ts b/dembrane/frontend/src/components/participant/refine/refineMode.ts new file mode 100644 index 000000000..58a8d2b48 --- /dev/null +++ b/dembrane/frontend/src/components/participant/refine/refineMode.ts @@ -0,0 +1,22 @@ +/** + * What the portal's one button after recording leads to (ECHO-1052): Verify alone, Explore + * alone, a choice between both, or nothing when neither is on. + */ +export type RefineMode = "verify" | "explore" | "both"; + +export const refineMode = ( + project: + | { + is_verify_enabled?: boolean | null; + is_get_reply_enabled?: boolean | null; + } + | null + | undefined, +): RefineMode | null => { + const verify = !!project?.is_verify_enabled; + const explore = !!project?.is_get_reply_enabled; + if (verify && explore) return "both"; + if (verify) return "verify"; + if (explore) return "explore"; + return null; +}; diff --git a/dembrane/frontend/src/components/participant/refine/useRefineActions.ts b/dembrane/frontend/src/components/participant/refine/useRefineActions.ts new file mode 100644 index 000000000..5e4e27f69 --- /dev/null +++ b/dembrane/frontend/src/components/participant/refine/useRefineActions.ts @@ -0,0 +1,44 @@ +import { useLocalStorage } from "@mantine/hooks"; +import { useI18nNavigate } from "@/hooks/useI18nNavigate"; +import { useRefineSelectionCooldown } from "./hooks/useRefineSelectionCooldown"; + +/** + * Going to Verify or Explore from the recorder, shared by the button under the recorder and + * the choice screen: the same locks (AI unavailable, cooling down) and the same start of + * Explore (its cooldown, and the button held while the reply comes in). + */ +export const useRefineActions = ({ + projectId, + conversationId, + aiUnavailable, +}: { + projectId: string | undefined; + conversationId: string | undefined; + aiUnavailable: boolean; +}) => { + const navigate = useI18nNavigate(); + const cooldown = useRefineSelectionCooldown(conversationId); + const [, setIsRefineDisabled] = useLocalStorage({ + defaultValue: false, + key: `refine_disabled_${conversationId}`, + }); + const verifyLocked = aiUnavailable || cooldown.verify.isActive; + const exploreLocked = aiUnavailable || cooldown.echo.isActive; + + const goVerify = () => { + if (verifyLocked) return false; + navigate(`/${projectId}/conversation/${conversationId}/verify`); + return true; + }; + + const goExplore = () => { + if (exploreLocked) return false; + cooldown.startEchoCooldown(); + // The button stays disabled while the reply is generated. + setIsRefineDisabled(true); + navigate(`/${projectId}/conversation/${conversationId}?echo=1`); + return true; + }; + + return { cooldown, exploreLocked, goExplore, goVerify, verifyLocked }; +}; diff --git a/dembrane/frontend/src/components/participant/verify/VerifiedArtefactsList.tsx b/dembrane/frontend/src/components/participant/verify/VerifiedArtefactsList.tsx index 2e925a6f6..4ab80fd5a 100644 --- a/dembrane/frontend/src/components/participant/verify/VerifiedArtefactsList.tsx +++ b/dembrane/frontend/src/components/participant/verify/VerifiedArtefactsList.tsx @@ -6,7 +6,6 @@ import { testId } from "@/lib/testUtils"; import { ArtefactModal } from "./ArtefactModal"; import { useConversationArtefacts, useVerificationTopics } from "./hooks"; import { VerifiedArtefactItem } from "./VerifiedArtefactItem"; -import { TOPIC_ICON_MAP } from "./VerifySelection"; type VerifiedArtefactsListProps = { conversationId: string; @@ -46,9 +45,9 @@ export const VerifiedArtefactsList = ({ availableTopics.map((topic) => [ topic.key, { + // A host's own emoji; built-in topics carry the seal the item already shows. icon: - TOPIC_ICON_MAP[topic.key] ?? - (topic.icon && !topic.icon.startsWith(":") ? topic.icon : undefined), + topic.icon && !topic.icon.startsWith(":") ? topic.icon : undefined, label: topic.translations?.[locale]?.label ?? topic.translations?.["en-US"]?.label ?? diff --git a/dembrane/frontend/src/components/participant/verify/VerifySelection.test.tsx b/dembrane/frontend/src/components/participant/verify/VerifySelection.test.tsx index 9b348c0ca..770e2d606 100644 --- a/dembrane/frontend/src/components/participant/verify/VerifySelection.test.tsx +++ b/dembrane/frontend/src/components/participant/verify/VerifySelection.test.tsx @@ -29,7 +29,7 @@ vi.mock("./hooks", () => ({ data: { available_topics: state.topics.map((key) => ({ key, - translations: { "en-US": { label: key } }, + translations: { "en-US": { label: `Label ${key}` } }, })), selected_topics: state.topics, }, @@ -137,3 +137,36 @@ it("still toasts other generation failures", async () => { await vi.waitFor(() => expect(state.toastError).toHaveBeenCalledTimes(1)); expect(screen.queryByText("Not available right now")).toBeNull(); }); + +// ECHO-1052: one topic starts at once; several are clear buttons, each with what it gives. +it("starts generating the one selected topic at once, with no picker", async () => { + state.topics = ["agreements"]; + state.mutateAsync.mockResolvedValue({ id: "a-1" }); + renderPage(); + await vi.waitFor(() => + expect(state.mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ topicKey: "agreements" }), + ), + ); + expect(screen.queryByTestId("portal-verify-selection-container")).toBeNull(); +}); + +it("shows several topics as buttons with their label and one line each", () => { + state.topics = ["agreements", "heard", "my-own-topic"]; + renderPage(); + const tableCheck = screen.getByTestId("portal-verify-topic-agreements"); + expect(tableCheck.textContent).toContain("Label agreements"); + expect(tableCheck.textContent).toContain( + "What this group mostly agreed on, and what differed.", + ); + expect(screen.getByTestId("portal-verify-topic-heard").textContent).toContain( + "What this person or group told us, in their own words.", + ); + // A host's own topic has its label and no invented description. + expect( + screen.getByTestId("portal-verify-topic-my-own-topic").textContent, + ).toBe("Label my-own-topic"); + fireEvent.click(tableCheck); + expect(tableCheck.getAttribute("aria-pressed")).toBe("true"); + expect(state.mutateAsync).not.toHaveBeenCalled(); +}); diff --git a/dembrane/frontend/src/components/participant/verify/VerifySelection.tsx b/dembrane/frontend/src/components/participant/verify/VerifySelection.tsx index d491cf168..b2a52d6cf 100644 --- a/dembrane/frontend/src/components/participant/verify/VerifySelection.tsx +++ b/dembrane/frontend/src/components/participant/verify/VerifySelection.tsx @@ -24,6 +24,7 @@ import { useGenerateVerificationArtefactMutation, useVerificationTopics, } from "./hooks"; +import { TopicIcon, topicDescription } from "./topics"; import { VerifyInstructions } from "./VerifyInstructions"; type LanguageCode = "de" | "en" | "es" | "fr" | "nl" | "it" | "uk" | "cs"; @@ -39,15 +40,6 @@ const LANGUAGE_TO_LOCALE: Record = { uk: "uk-UA", }; -export const TOPIC_ICON_MAP: Record = { - actions: "↗️", - agreements: "✅", - disagreements: "⚠️", - gems: "🔍", - moments: "🚀", - truths: "👀", -}; - const localeFromLanguage = (language?: string) => { if (!language) return undefined; const iso = language.includes("-") ? language.split("-")[0] : language; @@ -93,13 +85,8 @@ export const VerifySelection = () => { translations["en-US"]?.label ?? topic.key; - const icon = - TOPIC_ICON_MAP[topic.key] ?? - (topic.icon && !topic.icon.startsWith(":") ? topic.icon : undefined) ?? - (topic.is_custom ? undefined : "•"); - return { - icon, + description: topicDescription(topic.key), key: topic.key, label: localizedLabel, }; @@ -263,8 +250,8 @@ export const VerifySelection = () => { - {/* Options list */} - + {/* Options list: one pressable box per topic, with what it gives back. */} + {availableOptions.length === 0 && ( @@ -276,20 +263,25 @@ export const VerifySelection = () => { setSelectedOption(option.key)} - className="app-do px-4 py-3" + className="app-do app-choice block w-full px-4 py-3" data-selected={selectedOption === option.key || undefined} aria-pressed={selectedOption === option.key} {...testId(`portal-verify-topic-${option.key}`)} > - - {option.icon ? ( - {option.icon} - ) : null} - {option.label} + + + + {option.label} + {option.description && ( + + {option.description} + + )} + ))} - + {aiUnavailable && } diff --git a/dembrane/frontend/src/components/participant/verify/topics.tsx b/dembrane/frontend/src/components/participant/verify/topics.tsx new file mode 100644 index 000000000..1b623b6f7 --- /dev/null +++ b/dembrane/frontend/src/components/participant/verify/topics.tsx @@ -0,0 +1,71 @@ +import { t } from "@lingui/core/macro"; +import { + ArrowBendUpRightIcon, + ArrowsLeftRightIcon, + CheckSquareIcon, + EarIcon, + type Icon, + LightbulbIcon, + ListChecksIcon, + MountainsIcon, + SealCheckIcon, +} from "@phosphor-icons/react"; + +/** + * The built-in Verify topics as hosts and participants see them (ECHO-1052): an icon and one + * line saying what the draft holds. Labels come from the server (verify/src/defaults.ts), in + * the order hosts see them; the keys are the ones stored in a project's selection. + */ +export const BUILT_IN_TOPIC_KEYS = [ + "agreements", + "disagreements", + "actions", + "gems", + "truths", + "heard", + "moments", +] as const; + +const ICONS: Record = { + actions: ListChecksIcon, + agreements: CheckSquareIcon, + disagreements: ArrowsLeftRightIcon, + gems: LightbulbIcon, + heard: EarIcon, + moments: ArrowBendUpRightIcon, + truths: MountainsIcon, +}; + +/** The topic's icon; a host's own topic gets the Verify seal. */ +export const TopicIcon = ({ + topicKey, + size = 20, +}: { + topicKey: string; + size?: number; +}) => { + const Component = ICONS[topicKey] ?? SealCheckIcon; + return