diff --git a/openbot/app/src/components/agents/new-bot-sheet.tsx b/openbot/app/src/components/agents/new-bot-sheet.tsx index a8535a63..fb0bd6e6 100644 --- a/openbot/app/src/components/agents/new-bot-sheet.tsx +++ b/openbot/app/src/components/agents/new-bot-sheet.tsx @@ -41,6 +41,7 @@ import { } from "@/lib/agents/queries"; import { isComposing } from "@/lib/composing"; import { appConfig } from "@/lib/generated/application-config"; +import { DURATION, EASE_OUT } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { queryClient } from "@/query-client"; @@ -332,7 +333,7 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { className="mt-6" initial={{ opacity: 0, x: 24 * direction }} key={step} - transition={{ duration: 0.2, ease: "easeOut" }} + transition={{ duration: DURATION.swap, ease: EASE_OUT }} > {step === "about" ? ( void; /** Only the title, the outcome and who decided: a decided card left in a conversation. */ compact?: boolean; + /** The request was decided while it was on screen, in a card this one replaces. */ + decidedNow?: boolean; className?: string; }) { const view = describeApproval(request); const waiting = request.status === "pending"; + // Drawn waiting at some point, so a decision that comes in is one the person saw being made. + const [sawWaiting, setSawWaiting] = useState(waiting); + if (waiting && !sawWaiting) setSawWaiting(true); + const arrives = decidedNow || sawWaiting; + const reduced = useReducedMotionPreference(); const handOff = request.action.policy?.behaviour === "hand_off"; const outcome = approvalOutcome(request); const receipt = waiting ? null : decisionReceipt(request); @@ -94,7 +111,7 @@ export function ApprovalCard({ raised role={waiting ? "region" : undefined} > -
+

@@ -104,14 +121,34 @@ export function ApprovalCard({

{caption}

) : null}
- {outcome ? ( - - {outcome.label} - - ) : null} + + {outcome ? ( + + + {outcome.label} + + + ) : null} +
{compact ? null : ( @@ -154,7 +191,7 @@ export function ApprovalCard({
{handOff ? null : ( ) : callAction ? ( ) : voiceAction ? ( ) : ( ); @@ -1813,3 +1825,33 @@ function withMediaTypeOnly(file: File): File { type: mediaType, }); } + +/** + * The glyph on the composer's one round button, which stands for four things in turn: Send, Stop + * while the Bot answers, a voice call, and speaking a message. When what it stands for changes, as + * it does the moment a message is sent and Stop takes Send's place, the old glyph shrinks into a + * blur as the new one grows out of it, both at once, in place. The button itself stays put. + * + * Drawn as it is on the first paint; only a change swaps. With reduced motion the two cross-fade. + */ +function SwapGlyph({ name, children }: { name: string; children: ReactNode }) { + const reduced = useReducedMotionPreference(); + const away = reduced + ? { opacity: 0 } + : { opacity: 0, scale: 0.25, filter: "blur(2px)" }; + return ( + + + {children} + + + ); +} diff --git a/openbot/app/src/components/channels/composer/dictation-controls.tsx b/openbot/app/src/components/channels/composer/dictation-controls.tsx index ccaab93c..40970788 100644 --- a/openbot/app/src/components/channels/composer/dictation-controls.tsx +++ b/openbot/app/src/components/channels/composer/dictation-controls.tsx @@ -75,7 +75,7 @@ export function DictationSurface({ aria-hidden={recording || undefined} inert={recording} className={cn( - "min-w-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transform-none motion-reduce:transition-none", + "min-w-0 transition-[opacity,transform] duration-[var(--duet-dur-swap)] motion-reduce:transform-none motion-reduce:transition-none", recording ? "pointer-events-none -translate-y-1 opacity-0" : "translate-y-0 opacity-100", diff --git a/openbot/app/src/components/channels/composer/voice-sheet.tsx b/openbot/app/src/components/channels/composer/voice-sheet.tsx index 8b0aa642..b3308e9c 100644 --- a/openbot/app/src/components/channels/composer/voice-sheet.tsx +++ b/openbot/app/src/components/channels/composer/voice-sheet.tsx @@ -57,7 +57,7 @@ export function VoiceSheet({ aria-modal="true" className={cn( "duet-veil duet-face group fixed inset-0 z-50 flex flex-col overflow-y-auto text-duet-ink outline-none", - "transition-opacity duration-200 ease-out data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none", + "transition-opacity duration-[var(--duet-dur-enter)] data-ending-style:duration-[var(--duet-dur-swap)] data-ending-style:opacity-0 data-starting-style:opacity-0", )} // The sheet itself takes focus, not ×: a stray Enter or Space should not throw the words away. initialFocus={popup} @@ -74,7 +74,7 @@ export function VoiceSheet({ -
+
); } diff --git a/openbot/app/src/components/duet/page-card.tsx b/openbot/app/src/components/duet/page-card.tsx index a76c83e9..fa277b5d 100644 --- a/openbot/app/src/components/duet/page-card.tsx +++ b/openbot/app/src/components/duet/page-card.tsx @@ -62,7 +62,7 @@ export function PageCard({ children: ( (null); + const reduced = useReducedMotionPreference(); const morph = useSheetMorph(pageId, onClose, (summary, size) => ( )); @@ -121,7 +125,7 @@ export function PageSheet({ className={cn( "duet-face group fixed inset-0 z-50 flex flex-col bg-duet-surface text-duet-ink outline-none", // The veil fades in and out with reduced motion too: a fade is the reduced transition. - "transition-opacity duration-200 ease-out data-ending-style:opacity-0 data-starting-style:opacity-0", + "transition-opacity duration-[var(--duet-dur-swap)] data-ending-style:opacity-0 data-starting-style:opacity-0", morph.fading && "opacity-0", )} data-closing={morph.fading || undefined} @@ -138,9 +142,11 @@ export function PageSheet({ className={cn( // A size container, so the header below can ask how much room it has. "@container flex min-h-0 flex-1 flex-col overflow-hidden motion-reduce:transition-none", + // Morphing, the card fades in under the stand-in as it leaves, on the morph's own + // short hand-over (page-morph.tsx), not a token; otherwise it rises like a sheet. morph.morphing ? "transition-opacity duration-150 ease-out" - : "transition-transform duration-300 ease-out motion-safe:group-data-starting-style:translate-y-6", + : "transition-transform duration-[var(--duet-dur-sheet)] motion-safe:group-data-starting-style:translate-y-6", morph.hidden && "opacity-0", )} data-morph-hidden={morph.hidden || undefined} @@ -176,73 +182,110 @@ export function PageSheet({ > … - {actions ? ( -
- - - - -
- ) : null} + + + + + + ) : null} +
{notice ? ( -

+

{notice}

) : null} @@ -430,7 +473,7 @@ export function SlideDeck({ onPointerUp={onPointerUp} >
{slides.map((slide, at) => ( diff --git a/openbot/app/src/components/duet/question-sheet.tsx b/openbot/app/src/components/duet/question-sheet.tsx index 061dc2ed..e084976d 100644 --- a/openbot/app/src/components/duet/question-sheet.tsx +++ b/openbot/app/src/components/duet/question-sheet.tsx @@ -9,6 +9,7 @@ import { approvalInboxOptions, waitingOnYou, } from "@/lib/approvals"; +import { useSheetPresence } from "@/hooks/use-sheet-presence"; import { cn } from "@/lib/utils"; import { queryClient } from "@/query-client"; import { DuetIcon } from "./icons"; @@ -34,6 +35,9 @@ export function QuestionSheet({ const answer = useMutation(answerPersonQuestionMutationOptions(queryClient)); const [draft, setDraft] = useState(""); const popup = useRef(null); + // Fades out and sinks a little before the route closes it (use-sheet-presence.ts). + const presence = useSheetPresence(); + const close = () => presence.close(onClose); const question = waitingOnYou(inbox.data).questions.find( (item) => item.id === questionId, ); @@ -44,33 +48,34 @@ export function QuestionSheet({ const send = () => { const response = draft.trim(); if (!question || !response || answer.isPending) return; - answer.mutate({ id: question.id, response }, { onSuccess: onClose }); + answer.mutate({ id: question.id, response }, { onSuccess: close }); }; return ( { - if (!next) onClose(); + if (!next) close(); }} - open + onOpenChangeComplete={presence.onOpenChangeComplete} + open={presence.open} >
-
+
{searching ? ( -
+ // Fades in under the switch as it opens, with a short drop that reduced motion leaves out. +
+ + + + ); +} + +const wait = (ms: number) => + act(() => new Promise((resolve) => setTimeout(resolve, ms))); + +test("mounted by its parent, it opens a frame later, so its entrance has a closed state to come from", async () => { + // Base UI only plays `data-starting-style` when `open` turns true after the dialog has mounted. + const seen: boolean[] = []; + function Probe() { + seen.push(useSheetPresence().open); + return null; + } + render(); + expect(seen[0]).toBe(false); + expect(seen.at(-1)).toBe(true); + const view = render( {}} />); + expect(await view.findByRole("dialog", { name: "New task" })).toBeTruthy(); +}); + +/** + * The sheet's exit as the browser would run it: one animation on it, finished when `end` is called, + * or never. This DOM runs no CSS, so without it every exit would be over before it began. + */ +function exiting() { + let end = () => {}; + const finished = new Promise((resolve) => { + end = resolve; + }); + const before = HTMLElement.prototype.getAnimations; + HTMLElement.prototype.getAnimations = () => + [{ finished, pending: false, playState: "running" }] as never; + return { + end: () => act(async () => end()), + restore: () => { + HTMLElement.prototype.getAnimations = before; + }, + }; +} + +test("closed, it stays drawn through its exit and tells its parent once, after", async () => { + let closed = 0; + const view = render( closed++} />); + const close = await view.findByRole("button", { name: "Close" }); + const exit = exiting(); + try { + fireEvent.click(close); + // Not on the press: the parent would take it away before its exit could play. + await wait(20); + expect(closed).toBe(0); + expect(view.getByTestId("sheet").hasAttribute("data-ending-style")).toBe( + true, + ); + // A second close while it goes, by Escape say, does not tell the parent twice. + fireEvent.keyDown(view.getByTestId("sheet"), { key: "Escape" }); + await exit.end(); + await wait(20); + expect(closed).toBe(1); + expect(view.queryByRole("dialog")).toBeNull(); + await wait(SHEET_EXIT_GRACE_MS); + expect(closed).toBe(1); + } finally { + exit.restore(); + } +}); + +test("an exit that never says it ended still closes, once the longest exit has had its time", async () => { + let closed = 0; + const view = render( closed++} />); + const close = await view.findByRole("button", { name: "Close" }); + const exit = exiting(); + try { + fireEvent.click(close); + await wait(SHEET_EXIT_GRACE_MS - 100); + expect(closed).toBe(0); + await wait(150); + expect(closed).toBe(1); + } finally { + exit.restore(); + } +}); diff --git a/openbot/app/tests/swipe-to-answer.test.tsx b/openbot/app/tests/swipe-to-answer.test.tsx index 858eabb6..b2621c63 100644 --- a/openbot/app/tests/swipe-to-answer.test.tsx +++ b/openbot/app/tests/swipe-to-answer.test.tsx @@ -106,6 +106,10 @@ describe("the card, while it is slid", () => { await card.hold(THRESHOLD * 0.25); const quarter = blurOf(card.content); const quarterTint = opacityOf(card.tint("start")); + // The content fades on the blur's own ramp: already going a quarter of the way. + const quarterContent = opacityOf(card.content); + expect(quarterContent).toBeLessThan(1); + expect(quarterContent).toBeGreaterThan(0.4); // Already unreadable a quarter of the way: the card reads as on its way the moment it moves. expect(quarter).toBeGreaterThan(4); expect(quarterTint).toBeGreaterThan(0); @@ -122,8 +126,12 @@ describe("the card, while it is slid", () => { // The blur is the content's own, drawn on an opaque slab of the card's colour. expect(card.content.style.filter).toMatch(/^blur\(/); expect(opacityOf(card.slab)).toBe(1); - // Clipped to the card's corners, so the slab keeps its edge. - expect(card.content.style.clipPath).toBe("inset(0 round 22px)"); + // Clipped to the card's own corners, the card token, so the slab keeps its edge. + expect(card.content.style.clipPath).toBe( + "inset(0 round var(--duet-radius-card))", + ); + // And faded to a ghost under the tint, so nothing dark in it is blurred out to that edge. + expect(opacityOf(card.content)).toBeCloseTo(0.4, 2); // And it still tilts toward where it is going. expect(card.layer.style.transform).toContain("rotate(4deg)"); @@ -148,6 +156,7 @@ describe("the card, while it is slid", () => { const atRest = () => { // `none`, not a zero blur: any filter would cut the card's glass off from what is behind it. expect(card.content.style.filter).toBe("none"); + expect(opacityOf(card.content)).toBe(1); expect(card.content.style.clipPath).toBe("none"); expect(opacityOf(card.slab)).toBe(0); expect(opacityOf(card.tint("start"))).toBe(0); @@ -225,6 +234,7 @@ describe("the card, while it is slid", () => { await runFrames(6); const midway = travelOf(card.layer); expect(midway).toBeGreaterThan(THRESHOLD + 20); + expect(opacityOf(card.layer)).toBe(1); expect(midway).toBeLessThan(OFF_SCREEN); expect(blurOf(card.content)).toBe(14); expect(card.mark("start").getAttribute("data-state")).toBe("filled"); @@ -239,6 +249,9 @@ describe("the card, while it is slid", () => { await runFrames(4); expect(travelOf(card.layer)).toBe(OFF_SCREEN); expect(card.answers).toEqual(["start"]); + // Faded out over the second half of the way, so it dissolves at the edge of what shows it + // rather than being cut off by it. + expect(opacityOf(card.layer)).toBe(0); }); test("let go past the threshold in a tab that gets no frames, it still answers, once", async () => { diff --git a/openbot/app/tests/tasks-screen.test.tsx b/openbot/app/tests/tasks-screen.test.tsx index 32eb4936..5ac72a8b 100644 --- a/openbot/app/tests/tasks-screen.test.tsx +++ b/openbot/app/tests/tasks-screen.test.tsx @@ -24,6 +24,7 @@ import { } from "@/components/shell/tasks-screen"; import { agentKeys } from "@/lib/agents/queries"; import { queryClient } from "@/query-client"; +import { runMotionFrames } from "./run-motion-frames"; import { settleReactWork } from "./settle-react-work"; /** @@ -1150,6 +1151,49 @@ test("an Up next card slides off: right starts it, left hides it, a short drag d ); }); +test("a swiped card's place closes as soon as it has flown, and it comes back with the reason if starting fails", async () => { + quiet(); + suggestions = [ + suggestion({ id: "s-1" }), + suggestion({ + id: "s-2", + title: "Pay the booth balance", + createdAt: ago(90), + }), + ]; + serving(); + // The start is held open, so what the list does before the answer is in can be seen. + let refuse = () => {}; + const served = global.fetch; + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "/api/proactive/suggestions/s-1/start") + return new Promise((resolve) => { + refuse = () => + resolve( + Response.json({ error: "Couldn't start that" }, { status: 500 }), + ); + }); + return served(input, init); + }) as unknown as typeof fetch; + const view = await upNext(); + swipe(await view.findByText("Reply to Rosa about the glaze"), 200); + await flown(); + await runMotionFrames(40); + // Flown off and gone from the list while the answer is still on its way: the card under it has + // its place, rather than a gap the size of a card waiting on the server. + await waitFor(() => + expect(view.queryByText("Reply to Rosa about the glaze")).toBeNull(), + ); + expect(view.getByText("Pay the booth balance")).toBeTruthy(); + + refuse(); + // Refused, it is back in its place, with why on it. + expect(await view.findByText("Reply to Rosa about the glaze")).toBeTruthy(); + expect((await view.findByRole("alert")).textContent).toBe( + "Couldn't start that", + ); +}); + test("empty, Up next asks what it can take off your plate, and Show me checks in now", async () => { quiet(); serving();