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:
Omar Sobh
2026-06-11 04:27:27 -05:00
co-authored by Claude Fable 5
parent 660dbc6583
commit 7393b6538b
9 changed files with 180 additions and 50 deletions
+4 -4
View File
@@ -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"
+41 -14
View File
@@ -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();
}); });
}); });
+87 -21
View File
@@ -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>
); );
+10 -4
View File
@@ -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&apos;m <span className="text-coral">{agent.name}</span>. What can Hi, I&apos;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}
+12 -2
View File
@@ -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
+8
View File
@@ -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,
+10 -1
View File
@@ -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 };
} }
-3
View File
@@ -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.