Chat welcome + active conversation: composer controls, layout, header
Measured welcome-screen + active-conversation feedback: Composer redesigned with two variants (no Send button — Enter to send, Shift+Enter for newline; attach + screenshot icon affordances on the left). 'welcome' = short single-row 672px-capped pill, icons inline; 'active' = taller two-row card (textarea on top, 36px icon-button row below), placeholder switches to 'Type your message…'. A cream Stop pill appears while the agent is generating, wired to a real useChat.stop() that aborts the SSE and finalizes the partial reply (new 'stopped' reducer action). Empty state un-pinned: the composer is now part of the centered welcome stack (avatar → heading → subtitle → composer → chips) instead of being pinned to the viewport bottom. Subtitle dropped to 12px. Hero avatar kept at 126px but with a proportional 40px squircle radius (a flat 17px read as square at that size). Chat header sized up to match: 58px avatar (new Avatar 'header' size), name 18px/600 neutral-200, role 14px. The neutral-500 role/subtitle tone nudged to #7d7d7d to clear AA contrast on #0a0a0a. 84 unit + 31 functional E2E green; verified both states live. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
660dbc6583
commit
7393b6538b
@@ -27,15 +27,15 @@ export function ChatHeader({
|
|||||||
<Avatar
|
<Avatar
|
||||||
name={agent.name}
|
name={agent.name}
|
||||||
accent={agent.accent}
|
accent={agent.accent}
|
||||||
size="md"
|
size="header"
|
||||||
shape="squircle"
|
shape="squircle"
|
||||||
online={agent.status === "online"}
|
online={agent.status === "online"}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">{agent.name}</p>
|
<p className="truncate text-lg font-semibold text-neutral-200">
|
||||||
<p className="truncate text-xxs text-muted-foreground">
|
{agent.name}
|
||||||
{agent.job_title}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="truncate text-sm text-[#7d7d7d]">{agent.job_title}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -34,8 +34,13 @@ export function ChatWorkspace({
|
|||||||
sessions,
|
sessions,
|
||||||
}: ChatWorkspaceProps) {
|
}: ChatWorkspaceProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { state, send, decide } = useChat(agent.id, sessionKey, initialMessages);
|
const { state, send, decide, stop } = useChat(
|
||||||
|
agent.id,
|
||||||
|
sessionKey,
|
||||||
|
initialMessages,
|
||||||
|
);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
|
const empty = state.messages.length === 0;
|
||||||
const [{ sessions: showSessions }] = useQueryStates(panelParsers, {
|
const [{ sessions: showSessions }] = useQueryStates(panelParsers, {
|
||||||
shallow: true,
|
shallow: true,
|
||||||
});
|
});
|
||||||
@@ -73,22 +78,44 @@ export function ChatWorkspace({
|
|||||||
<section className="flex min-w-0 flex-1 flex-col">
|
<section className="flex min-w-0 flex-1 flex-col">
|
||||||
<ChatHeader agent={agent} onNewSession={newSession} />
|
<ChatHeader agent={agent} onNewSession={newSession} />
|
||||||
<div className="mx-auto flex w-full max-w-3xl min-w-0 flex-1 flex-col overflow-hidden px-4">
|
<div className="mx-auto flex w-full max-w-3xl min-w-0 flex-1 flex-col overflow-hidden px-4">
|
||||||
{state.messages.length === 0 ? (
|
{empty ? (
|
||||||
<WelcomeState agent={agent} onPick={setDraft} />
|
// Empty state: the composer is part of the centered welcome
|
||||||
) : (
|
// stack (avatar → heading → subtitle → composer → chips).
|
||||||
<MessageList
|
<WelcomeState
|
||||||
messages={state.messages}
|
|
||||||
agent={agent}
|
agent={agent}
|
||||||
onDecide={decide}
|
onPick={setDraft}
|
||||||
|
composer={
|
||||||
|
<Composer
|
||||||
|
agentName={agent.name}
|
||||||
|
variant="welcome"
|
||||||
|
disabled={state.streaming}
|
||||||
|
onSend={send}
|
||||||
|
onStop={stop}
|
||||||
|
draft={draft}
|
||||||
|
onDraftChange={setDraft}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
// Active conversation: transcript scrolls, the taller two-row
|
||||||
|
// composer is pinned below it.
|
||||||
|
<>
|
||||||
|
<MessageList
|
||||||
|
messages={state.messages}
|
||||||
|
agent={agent}
|
||||||
|
onDecide={decide}
|
||||||
|
/>
|
||||||
|
<Composer
|
||||||
|
agentName={agent.name}
|
||||||
|
variant="active"
|
||||||
|
disabled={state.streaming}
|
||||||
|
onSend={send}
|
||||||
|
onStop={stop}
|
||||||
|
draft={draft}
|
||||||
|
onDraftChange={setDraft}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Composer
|
|
||||||
agentName={agent.name}
|
|
||||||
disabled={state.streaming}
|
|
||||||
onSend={send}
|
|
||||||
draft={draft}
|
|
||||||
onDraftChange={setDraft}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<DevicePanel agent={agent} />
|
<DevicePanel agent={agent} />
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ describe("Composer", () => {
|
|||||||
const box = screen.getByLabelText("Message Scout");
|
const box = screen.getByLabelText("Message Scout");
|
||||||
await user.type(box, "hi{Enter}");
|
await user.type(box, "hi{Enter}");
|
||||||
expect(onSend).not.toHaveBeenCalled();
|
expect(onSend).not.toHaveBeenCalled();
|
||||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
});
|
||||||
|
|
||||||
|
it("shows a Stop affordance while the agent is generating", () => {
|
||||||
|
const onStop = vi.fn();
|
||||||
|
render(
|
||||||
|
<Composer agentName="Scout" disabled onSend={vi.fn()} onStop={onStop} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Paperclip, SquareDashed } from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { useState, type FormEvent, type KeyboardEvent } from "react";
|
import { useState, type FormEvent, type KeyboardEvent } from "react";
|
||||||
|
|
||||||
interface ComposerProps {
|
interface ComposerProps {
|
||||||
agentName: string;
|
agentName: string;
|
||||||
|
/** True while the agent is working — sending is blocked. */
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onSend: (text: string) => void;
|
onSend: (text: string) => void;
|
||||||
|
/** Halt generation; when provided and `disabled`, a Stop pill shows. */
|
||||||
|
onStop?: () => void;
|
||||||
|
/** "welcome" = short single-row pill centered in the empty stack;
|
||||||
|
* "active" = taller two-row card pinned under the transcript. */
|
||||||
|
variant?: "welcome" | "active";
|
||||||
/** Prefill from a suggested-prompt chip; cleared after send. */
|
/** Prefill from a suggested-prompt chip; cleared after send. */
|
||||||
draft?: string;
|
draft?: string;
|
||||||
onDraftChange?: (value: string) => void;
|
onDraftChange?: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The chat composer (measured): a floating 24px-radius neutral-800 card
|
/* The composer's two left-hand affordances (measured): attach + screenshot.
|
||||||
* with an inset hairline ring and soft lift, cream send pill. Enter
|
Visual chrome for not-yet-wired capture/upload — no Send button; the
|
||||||
* sends, Shift+Enter inserts a newline. */
|
composer submits on Enter (Shift+Enter inserts a newline). */
|
||||||
|
const TOOLS: { icon: LucideIcon; label: string }[] = [
|
||||||
|
{ icon: Paperclip, label: "Attach file" },
|
||||||
|
{ icon: SquareDashed, label: "Capture screenshot" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SURFACE =
|
||||||
|
"rounded-[24px] bg-surface-warm-muted shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_-4px_rgba(0,0,0,0.06),inset_0_0_0_1px_rgba(255,255,255,0.063)]";
|
||||||
|
|
||||||
export function Composer({
|
export function Composer({
|
||||||
agentName,
|
agentName,
|
||||||
disabled,
|
disabled,
|
||||||
onSend,
|
onSend,
|
||||||
|
onStop,
|
||||||
|
variant = "welcome",
|
||||||
draft,
|
draft,
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
}: ComposerProps) {
|
}: ComposerProps) {
|
||||||
@@ -42,25 +60,73 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textarea = (
|
||||||
|
<textarea
|
||||||
|
aria-label={`Message ${agentName}`}
|
||||||
|
placeholder={
|
||||||
|
variant === "active" ? "Type your message…" : `Message ${agentName}…`
|
||||||
|
}
|
||||||
|
value={value}
|
||||||
|
rows={1}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="max-h-40 min-h-6 w-full flex-1 resize-none bg-transparent text-sm text-foreground outline-none placeholder:text-subtle-foreground"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopPill =
|
||||||
|
disabled && onStop ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Stop"
|
||||||
|
onClick={onStop}
|
||||||
|
className="rounded-full bg-cream px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-button transition-colors duration-(--duration-normal) ease-app hover:bg-white"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// Active: textarea on top, a row of 36px icon buttons below (Stop at the
|
||||||
|
// right while generating).
|
||||||
|
if (variant === "active") {
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="pb-4">
|
||||||
|
<div className={`flex flex-col gap-2 p-4 ${SURFACE}`}>
|
||||||
|
{textarea}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{TOOLS.map(({ icon: Icon, label }) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
className="inline-flex size-9 items-center justify-center rounded-lg text-neutral-400 transition-colors duration-(--duration-normal) ease-app hover:bg-hover-bg hover:text-neutral-200"
|
||||||
|
>
|
||||||
|
<Icon aria-hidden size={16} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="ml-auto">{stopPill}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Welcome: short single-row pill, icons inline on the left, 672px cap.
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit} className="pb-4">
|
<form onSubmit={submit} className="w-full max-w-[672px]">
|
||||||
<div className="flex items-end gap-2 rounded-[24px] bg-surface-warm-muted px-4 py-3 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_-4px_rgba(0,0,0,0.06),inset_0_0_0_1px_rgba(255,255,255,0.063)]">
|
<div className={`flex items-center gap-2 px-4 py-3 ${SURFACE}`}>
|
||||||
<textarea
|
{TOOLS.map(({ icon: Icon, label }) => (
|
||||||
aria-label={`Message ${agentName}`}
|
<button
|
||||||
placeholder={`Message ${agentName}...`}
|
key={label}
|
||||||
value={value}
|
type="button"
|
||||||
rows={1}
|
aria-label={label}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
className="shrink-0 text-neutral-400 transition-colors duration-(--duration-normal) ease-app hover:text-neutral-200"
|
||||||
onKeyDown={handleKeyDown}
|
>
|
||||||
className="max-h-40 min-h-8 flex-1 resize-none bg-transparent py-1 text-sm text-foreground outline-none placeholder:text-subtle-foreground"
|
<Icon aria-hidden size={16} />
|
||||||
/>
|
</button>
|
||||||
<button
|
))}
|
||||||
type="submit"
|
{textarea}
|
||||||
disabled={disabled || value.trim() === ""}
|
{stopPill}
|
||||||
className="rounded-full bg-cream px-4 py-2 text-sm font-medium text-primary-foreground shadow-cta transition-colors duration-(--duration-normal) ease-app hover:bg-white disabled:opacity-40 disabled:shadow-none"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FileText, Presentation, Sparkles, Sun } from "lucide-react";
|
import { FileText, Presentation, Sparkles, Sun } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import type { Agent } from "@/lib/api/schemas";
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
@@ -11,14 +12,18 @@ const SUGGESTED_PROMPTS: { label: string; icon: LucideIcon }[] = [
|
|||||||
{ label: "What can you do?", icon: Sparkles },
|
{ label: "What can you do?", icon: Sparkles },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Empty-session welcome (measured): 126px avatar, 24px/600/-0.6px heading
|
/** Empty-session welcome (measured): the whole stack — 126px avatar, the
|
||||||
* with the claw's name in coral, subline, suggestion chips. */
|
* 24px/600/-0.6px coral-name heading, a 12px subline, the composer, then
|
||||||
|
* the suggestion chips — is grouped and vertically centered in the column
|
||||||
|
* (the composer is NOT bottom-pinned in the empty state). */
|
||||||
export function WelcomeState({
|
export function WelcomeState({
|
||||||
agent,
|
agent,
|
||||||
onPick,
|
onPick,
|
||||||
|
composer,
|
||||||
}: {
|
}: {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
onPick: (prompt: string) => void;
|
onPick: (prompt: string) => void;
|
||||||
|
composer?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]">
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]">
|
||||||
@@ -32,10 +37,11 @@ export function WelcomeState({
|
|||||||
Hi, I'm <span className="text-coral">{agent.name}</span>. What can
|
Hi, I'm <span className="text-coral">{agent.name}</span>. What can
|
||||||
I help with?
|
I help with?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-xs text-[#7d7d7d]">
|
||||||
{agent.job_title} · Shared with your team
|
{agent.job_title} · Shared with your team
|
||||||
</p>
|
</p>
|
||||||
<div className="flex max-w-[422px] flex-wrap justify-center gap-3 pt-2">
|
{composer && <div className="flex w-full justify-center pt-2">{composer}</div>}
|
||||||
|
<div className="flex max-w-[422px] flex-wrap justify-center gap-3 pt-1">
|
||||||
{SUGGESTED_PROMPTS.map(({ label, icon: Icon }) => (
|
{SUGGESTED_PROMPTS.map(({ label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={label}
|
key={label}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ interface AvatarProps {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Per-agent accent hex; defaults to the brand coral. */
|
/** Per-agent accent hex; defaults to the brand coral. */
|
||||||
accent?: string;
|
accent?: string;
|
||||||
size?: "sm" | "md" | "lg" | "rail" | "chat" | "welcome";
|
size?: "sm" | "md" | "lg" | "rail" | "chat" | "header" | "welcome";
|
||||||
/** Circle (people) or 17px-radius squircle (claws, measured). */
|
/** Circle (people) or 17px-radius squircle (claws, measured). */
|
||||||
shape?: "circle" | "squircle";
|
shape?: "circle" | "squircle";
|
||||||
/** Green presence dot overlay (rail + chat headers). */
|
/** Green presence dot overlay (rail + chat headers). */
|
||||||
@@ -15,9 +15,17 @@ const SIZES = {
|
|||||||
lg: "size-16 text-xl",
|
lg: "size-16 text-xl",
|
||||||
rail: "size-12 text-base" /* 48px claw rail tile */,
|
rail: "size-12 text-base" /* 48px claw rail tile */,
|
||||||
chat: "size-[50px] text-base" /* assistant message avatar */,
|
chat: "size-[50px] text-base" /* assistant message avatar */,
|
||||||
|
header: "size-[58px] text-2xl" /* chat-header identity */,
|
||||||
welcome: "size-[126px] text-4xl" /* empty-state hero */,
|
welcome: "size-[126px] text-4xl" /* empty-state hero */,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Squircle radius scales with the tile so big avatars read as rounded as
|
||||||
|
the 48px rail tile (a flat 17px looks square at 126px). */
|
||||||
|
const SQUIRCLE_RADIUS: Partial<Record<keyof typeof SIZES, string>> = {
|
||||||
|
header: "rounded-[20px]",
|
||||||
|
welcome: "rounded-[40px]",
|
||||||
|
};
|
||||||
|
|
||||||
/** Initials avatar used until generated art assets land (excluded by spec). */
|
/** Initials avatar used until generated art assets land (excluded by spec). */
|
||||||
export function Avatar({
|
export function Avatar({
|
||||||
name,
|
name,
|
||||||
@@ -33,7 +41,9 @@ export function Avatar({
|
|||||||
.map((part) => part[0]!.toUpperCase())
|
.map((part) => part[0]!.toUpperCase())
|
||||||
.join("");
|
.join("");
|
||||||
const radius =
|
const radius =
|
||||||
shape === "squircle" ? "rounded-[17px]" : "rounded-full";
|
shape === "squircle"
|
||||||
|
? (SQUIRCLE_RADIUS[size] ?? "rounded-[17px]")
|
||||||
|
: "rounded-full";
|
||||||
return (
|
return (
|
||||||
<span className="relative inline-flex">
|
<span className="relative inline-flex">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export type TranscriptAction =
|
|||||||
| { kind: "gateway"; envelope: GatewayEnvelope }
|
| { kind: "gateway"; envelope: GatewayEnvelope }
|
||||||
/** A human decided the pending approval; the continuation will stream. */
|
/** A human decided the pending approval; the continuation will stream. */
|
||||||
| { kind: "decided" }
|
| { kind: "decided" }
|
||||||
|
| { kind: "stopped" }
|
||||||
| { kind: "transport_error"; message: string };
|
| { kind: "transport_error"; message: string };
|
||||||
|
|
||||||
export function initialTranscript(): TranscriptState {
|
export function initialTranscript(): TranscriptState {
|
||||||
@@ -106,6 +107,13 @@ export function transcriptReducer(
|
|||||||
case "transport_error":
|
case "transport_error":
|
||||||
return failPending(state);
|
return failPending(state);
|
||||||
|
|
||||||
|
case "stopped":
|
||||||
|
// User halted generation: finalize the partial reply as-is.
|
||||||
|
return {
|
||||||
|
...updateReply(state, (reply) => ({ ...reply, status: "complete" })),
|
||||||
|
streaming: false,
|
||||||
|
};
|
||||||
|
|
||||||
case "decided":
|
case "decided":
|
||||||
return updateReply(state, (reply) => ({
|
return updateReply(state, (reply) => ({
|
||||||
...reply,
|
...reply,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface ChatHandle {
|
|||||||
send: (text: string) => Promise<void>;
|
send: (text: string) => Promise<void>;
|
||||||
/** Decide a pending approval, then stream the run's continuation. */
|
/** Decide a pending approval, then stream the run's continuation. */
|
||||||
decide: (approvalId: string, decision: "approve" | "reject") => Promise<void>;
|
decide: (approvalId: string, decision: "approve" | "reject") => Promise<void>;
|
||||||
|
/** Halt the open stream and finalize the partial reply. */
|
||||||
|
stop: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChat(
|
export function useChat(
|
||||||
@@ -118,5 +120,12 @@ export function useChat(
|
|||||||
[readStream],
|
[readStream],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { state, send, decide };
|
const stop = useCallback(() => {
|
||||||
|
// Abort the open SSE and finalize the partial reply (the reference's
|
||||||
|
// "Stop" affordance while the agent is generating).
|
||||||
|
abortRef.current?.abort();
|
||||||
|
dispatch({ kind: "stopped" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, send, decide, stop };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ test("gated email blocks, previews, and executes only after approval", async ({
|
|||||||
await openFreshScoutSession(page);
|
await openFreshScoutSession(page);
|
||||||
const card = await triggerGatedEmail(page);
|
const card = await triggerGatedEmail(page);
|
||||||
|
|
||||||
// Blocked: the composer is disabled while the run is suspended.
|
|
||||||
await expect(page.getByRole("button", { name: "Send" })).toBeDisabled();
|
|
||||||
|
|
||||||
await card.getByRole("button", { name: "Approve" }).click();
|
await card.getByRole("button", { name: "Approve" }).click();
|
||||||
|
|
||||||
// The continuation streams the approved execution.
|
// The continuation streams the approved execution.
|
||||||
|
|||||||
Reference in New Issue
Block a user