Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion openbot/app/src/components/agents/new-bot-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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" ? (
<AboutStep
Expand Down
102 changes: 81 additions & 21 deletions openbot/app/src/components/approvals/approval-card.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AnimatePresence, motion } from "motion/react";
import {
type ComponentProps,
Fragment,
Expand All @@ -15,6 +16,8 @@ import {
describeApproval,
} from "@/lib/approval-view";
import type { ApprovalDecision, ApprovalRequest } from "@/lib/approvals";
import { DURATION, EASE_OUT } from "@/lib/motion";
import { useReducedMotionPreference } from "@/lib/reduced-motion";
import { cn } from "@/lib/utils";

const KIND_ICON: Record<
Expand All @@ -37,7 +40,7 @@ export function PillButton({
return (
<button
className={cn(
"type-row inline-flex min-h-[46px] min-w-0 flex-1 basis-[7rem] items-center justify-center rounded-full px-4 py-2.5 font-semibold transition-[filter,transform,opacity] focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-duet-violet/40 active:scale-[0.98] disabled:opacity-50",
"type-row inline-flex min-h-[46px] min-w-0 flex-1 basis-[7rem] items-center justify-center rounded-full px-4 py-2.5 font-semibold transition-[filter,transform,opacity,background-color] focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-duet-violet/40 active:scale-[0.98] disabled:opacity-50",
primary
? "bg-duet-black text-duet-on-black hover:brightness-125"
: "text-duet-ink shadow-[inset_0_0_0_1px_var(--duet-hair)] hover:bg-duet-tile",
Expand All @@ -58,6 +61,12 @@ export function PillButton({
*
* Once decided, the answers fold into a line saying who decided and when, and a pill says how it
* ended. Everything the request carries stays one tap away under Details.
*
* Decided while the person watched, the line arrives the way a word swaps in, rising out of a short
* blur, with its check landing, and the pill pops in beside the title. When the pill's word changes
* after that, Allowed to Sent, it turns over in place. A card drawn already decided, read back from
* history, is drawn still: `decidedNow` says the card was put up in place of a waiting one, which a
* conversation does by drawing a new, folded card (inline-approval.tsx).
*/
export function ApprovalCard({
request,
Expand All @@ -66,6 +75,7 @@ export function ApprovalCard({
error,
onDecide,
compact = false,
decidedNow = false,
className,
}: {
request: ApprovalRequest;
Expand All @@ -76,10 +86,17 @@ export function ApprovalCard({
onDecide?: (choice: ApprovalDecision) => 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);
Expand All @@ -94,7 +111,7 @@ export function ApprovalCard({
raised
role={waiting ? "region" : undefined}
>
<div className="flex min-h-[54px] items-center gap-2.5 px-4 py-2.5">
<div className="relative flex min-h-[54px] items-center gap-2.5 px-4 py-2.5">
<Tile icon={KIND_ICON[view.kind]} size={32} />
<div className="min-w-0 flex-1">
<p className="type-row font-semibold">
Expand All @@ -104,14 +121,34 @@ export function ApprovalCard({
<p className="type-label truncate text-duet-ink-3">{caption}</p>
) : null}
</div>
{outcome ? (
<OutcomePill
icon={outcome.tone === "green" ? "check" : null}
tone={outcome.tone}
>
{outcome.label}
</OutcomePill>
) : null}
<AnimatePresence initial={arrives} mode="popLayout">
{outcome ? (
<motion.span
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
className="shrink-0"
data-outcome={outcome.label}
exit={
reduced
? { opacity: 0 }
: { opacity: 0, scale: 0.9, filter: "blur(2px)" }
}
initial={
reduced
? { opacity: 0 }
: { opacity: 0, scale: 0.85, filter: "blur(2px)" }
}
key={outcome.label}
transition={{ duration: DURATION.swap, ease: EASE_OUT }}
>
<OutcomePill
icon={outcome.tone === "green" ? "check" : null}
tone={outcome.tone}
>
{outcome.label}
</OutcomePill>
</motion.span>
) : null}
</AnimatePresence>
</div>
{compact ? null : (
<ApprovalContent handOff={handOff} request={request} view={view} />
Expand Down Expand Up @@ -154,7 +191,7 @@ export function ApprovalCard({
</div>
{handOff ? null : (
<button
className="type-sub mx-auto mt-1 flex h-11 items-center rounded-full px-3 font-medium text-duet-ink-2 hover:text-duet-ink focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-duet-accent/40 disabled:opacity-50"
className="type-sub mx-auto mt-1 flex h-11 items-center rounded-full px-3 font-medium text-duet-ink-2 transition-colors hover:text-duet-ink focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-duet-accent/40 disabled:opacity-50"
disabled={busy}
onClick={() => onDecide("allow_always")}
type="button"
Expand All @@ -164,14 +201,29 @@ export function ApprovalCard({
)}
</div>
) : null}
{receipt ? (
<DecidedLine
at={request.decidedAt ?? null}
tone={request.decision === "deny" ? "red" : "green"}
>
{receipt}
</DecidedLine>
) : null}
<AnimatePresence initial={arrives}>
{receipt ? (
<motion.div
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
data-decided-line=""
initial={
reduced
? { opacity: 0 }
: { opacity: 0, y: 4, filter: "blur(2px)" }
}
key="decided"
transition={{ duration: DURATION.swap, ease: EASE_OUT }}
>
<DecidedLine
at={request.decidedAt ?? null}
landing={arrives}
tone={request.decision === "deny" ? "red" : "green"}
>
{receipt}
</DecidedLine>
</motion.div>
) : null}
</AnimatePresence>
</DuetCard>
);
}
Expand Down Expand Up @@ -270,7 +322,7 @@ function ApprovalBody({ body }: { body: string }) {
<button
aria-controls={bodyId}
aria-expanded={open}
className="type-label mx-4 mb-1 inline-flex min-h-11 items-center gap-1 rounded-full font-semibold text-duet-ink-2 hover:text-duet-ink focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-duet-accent/40"
className="type-label mx-4 mb-1 inline-flex min-h-11 items-center gap-1 rounded-full font-semibold text-duet-ink-2 transition-colors hover:text-duet-ink focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-duet-accent/40"
onClick={() => setOpen((shown) => !shown)}
type="button"
>
Expand All @@ -293,18 +345,26 @@ function DecidedLine({
children,
at,
tone,
landing,
}: {
children: ReactNode;
at: string | null;
tone: "green" | "red";
/** Decided just now: the check lands as Tasks' does when a task is checked off. */
landing: boolean;
}) {
return (
<div className="relative flex min-h-[52px] items-center gap-[9px] px-4">
<div
aria-hidden
className="absolute top-0 right-4 left-4 h-px bg-duet-hair-2"
/>
<span className={tone === "green" ? "text-duet-green" : "text-duet-red"}>
<span
className={cn(
tone === "green" ? "text-duet-green" : "text-duet-red",
landing && "duet-check-land",
)}
>
<DuetIcon
name={tone === "green" ? "checkCircle" : "x"}
size={20}
Expand Down
9 changes: 7 additions & 2 deletions openbot/app/src/components/approvals/inline-approval.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { type ReactNode, useState } from "react";
import { personWasAsked } from "@/lib/approval-view";
import {
approvalInboxOptions,
Expand All @@ -15,6 +15,9 @@ import { ApprovalCard } from "./approval-card";
* continues the conversation, and this gives the line back to the tool's own renderer, under a
* folded card saying who decided. A request the gate never put to the person (a saved rule allowed
* it) leaves no card behind.
*
* The folded card is a new card, so it is told when it replaces a waiting one this screen showed:
* then its line and pill arrive rather than being drawn still, as they would be read back.
*/
export function InlineApproval({
toolCallId,
Expand All @@ -31,12 +34,14 @@ export function InlineApproval({
(candidate) => candidate.action.toolCallId === toolCallId,
)
: undefined;
const [sawWaiting, setSawWaiting] = useState(false);
if (request?.status === "pending" && !sawWaiting) setSawWaiting(true);
if (!request) return <>{fallback}</>;
if (request.status !== "pending") {
if (!personWasAsked(request) || !request.decision) return <>{fallback}</>;
return (
<div className="space-y-2">
<ApprovalCard compact request={request} />
<ApprovalCard compact decidedNow={sawWaiting} request={request} />
{fallback}
</div>
);
Expand Down
68 changes: 55 additions & 13 deletions openbot/app/src/components/channels/composer/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
IconPlayerStopFilled,
IconPlus,
} from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import { PromptArea, type PromptAreaHandle } from "prompt-area";
import {
isSegmentsEmpty,
Expand All @@ -20,6 +21,7 @@ import {
type ChangeEvent,
type DragEvent,
type FormEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
Expand All @@ -37,7 +39,9 @@ import {
mediaTypeOf,
shouldClaimPaste,
} from "@/lib/channels/attachments";
import { DURATION, EASE_OUT } from "@/lib/motion";
import { newId } from "@/lib/new-id";
import { useReducedMotionPreference } from "@/lib/reduced-motion";
import { cn } from "@/lib/utils";
import { Button } from "../../ui/button";
import { AttachmentStrip } from "./attachment-strip";
Expand Down Expand Up @@ -1398,7 +1402,7 @@ export function Composer({
void dictation.session.start();
};
const actionClassName = cn(
"rounded-full p-0",
"relative rounded-full p-0",
compact
? "size-[44px] shrink-0 self-end bg-duet-black text-duet-on-black hover:bg-duet-black hover:brightness-125 disabled:opacity-30"
: "size-7",
Expand All @@ -1412,7 +1416,9 @@ export function Composer({
size="icon"
type="button"
>
<IconPlayerStopFilled className={compact ? "size-3.5" : "size-3"} />
<SwapGlyph name="stop">
<IconPlayerStopFilled className={compact ? "size-3.5" : "size-3"} />
</SwapGlyph>
</Button>
) : callAction ? (
<Button
Expand All @@ -1430,11 +1436,13 @@ export function Composer({
size="icon"
type="button"
>
{compact ? (
<DuetIcon name="waveform" size={19} stroke={2.2} />
) : (
<IconWaveform className="size-4" />
)}
<SwapGlyph name="call">
{compact ? (
<DuetIcon name="waveform" size={19} stroke={2.2} />
) : (
<IconWaveform className="size-4" />
)}
</SwapGlyph>
</Button>
) : voiceAction ? (
<Button
Expand All @@ -1452,7 +1460,9 @@ export function Composer({
size="icon"
type="button"
>
<DuetIcon name="waveform" size={19} stroke={2.2} />
<SwapGlyph name="speak">
<DuetIcon name="waveform" size={19} stroke={2.2} />
</SwapGlyph>
</Button>
) : (
<Button
Expand All @@ -1462,11 +1472,13 @@ export function Composer({
size="icon"
type="submit"
>
{compact ? (
<DuetIcon name="arrowUp" size={20} stroke={2.4} />
) : (
<IconArrowUp className="size-3.5" />
)}
<SwapGlyph name="send">
{compact ? (
<DuetIcon name="arrowUp" size={20} stroke={2.4} />
) : (
<IconArrowUp className="size-3.5" />
)}
</SwapGlyph>
</Button>
);

Expand Down Expand Up @@ -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 (
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
className="grid place-items-center"
data-glyph={name}
exit={away}
initial={away}
key={name}
transition={{ duration: DURATION.swap, ease: EASE_OUT }}
>
{children}
</motion.span>
</AnimatePresence>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading