Skip to content

Commit 943d4fc

Browse files
committed
feat(webapp): agent chat restyled to the chat.agent design language
- history rows: mono type, dashed rounded rows, hover lift, 'TUE, 11 AUG' stamps - assistant text in a soft bordered card; user bubble back to indigo - tool pills: mono with a lime dot, dimmed detail, chevron; error tone for failures - progress line in the dimmed mono 'steps · time' register - composer as a single pill with a circular send button; prompt pills with icon+chevron - storybook: history-list, composer, and failed-tool sections added
1 parent c929c0f commit 943d4fc

9 files changed

Lines changed: 304 additions & 57 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { useEffect, useRef } from "react";
33
import { Button } from "~/components/primitives/Buttons";
44
import { cn } from "~/utils/cn";
55

6+
/** The circular icon button that sits inside the field, on the right. */
7+
const SEND_BUTTON = "size-7 shrink-0 rounded-full p-0";
8+
69
export function DashboardAgentComposer({
710
value,
811
onChange,
@@ -39,7 +42,9 @@ export function DashboardAgentComposer({
3942
// gradient in `ChatTranscript`'s scroller edge is for.
4043
<div className="flex shrink-0 flex-col gap-1.5 bg-background-bright px-3 pb-3 pt-1">
4144
{context}
42-
<div className="rounded-md border border-border-bright bg-background-bright p-1 transition focus-within:border-border-brighter">
45+
{/* One pill: the field and its send button share a fully rounded border, so
46+
the composer reads as a single thing to type into. */}
47+
<div className="rounded-3xl border border-border-bright bg-background-hover p-1 pl-3 transition focus-within:border-border-brighter">
4348
<div className="flex items-end gap-1">
4449
{/* One text line tall at rest (matches the button height), grows with
4550
content up to the cap. rows={1} + field-sizing-content do the work. */}
@@ -57,24 +62,24 @@ export function DashboardAgentComposer({
5762
placeholder="Type a message…"
5863
aria-label="Message the dashboard agent"
5964
className={cn(
60-
"max-h-[40vh] flex-1 resize-none border-0 bg-transparent px-1.5 py-0.5 text-sm leading-6 text-text-bright placeholder-text-dimmed outline-hidden ring-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control field-sizing-content focus:outline-hidden focus:ring-0"
65+
"max-h-[40vh] flex-1 resize-none border-0 bg-transparent py-1 text-sm leading-6 text-text-bright placeholder-text-dimmed outline-hidden ring-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control field-sizing-content focus:outline-hidden focus:ring-0"
6166
)}
6267
/>
6368
{isStreaming ? (
6469
// Grey, not red: stopping is a normal thing to do mid-answer, not a
6570
// destructive action.
6671
<Button
6772
variant="minimal/small"
68-
className="aspect-square h-6 p-1"
73+
className={SEND_BUTTON}
6974
aria-label="Stop generating"
7075
tooltip="Stop generating"
7176
onClick={onStop}
7277
LeadingIcon={<StopIcon className="size-4 text-text-dimmed" />}
7378
/>
7479
) : (
7580
<Button
76-
variant="primary/small"
77-
className="aspect-square h-6 p-1"
81+
variant="secondary/small"
82+
className={SEND_BUTTON}
7883
aria-label="Send"
7984
tooltip="Send"
8085
onClick={onSubmit}

apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { MagnifyingGlassIcon, TrashIcon } from "@heroicons/react/20/solid";
22
import { formatDurationMilliseconds } from "@trigger.dev/core/v3/utils/durations";
3+
import { format } from "date-fns";
34
import { useState } from "react";
45
import { Button } from "~/components/primitives/Buttons";
56
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
@@ -76,6 +77,19 @@ function unreadFirst(chats: DashboardAgentChat[]): DashboardAgentChat[] {
7677
* eight weeks, which is worse than "8w" — weeks are the coarsest useful unit. */
7778
const AGE_UNITS = ["w", "d", "h", "m"] as const;
7879

80+
/**
81+
* "TUE, 11 AUG" — the date stamp on a row.
82+
*
83+
* A date, not an age: the list is read as a log of past conversations, and a
84+
* stamp is what lets two rows be compared. {@link chatAge} is still here for
85+
* anywhere an at-a-glance "how long ago" is the better answer.
86+
*/
87+
export function chatDateStamp(lastMessageAt: string): string | undefined {
88+
const at = new Date(lastMessageAt);
89+
if (Number.isNaN(at.getTime())) return undefined;
90+
return format(at, "EEE, d MMM").toUpperCase();
91+
}
92+
7993
/** "2m", "3d", "8w" — the project's short duration style, one unit only. */
8094
export function chatAge(lastMessageAt: string, now: number = Date.now()): string | undefined {
8195
const at = Date.parse(lastMessageAt);
@@ -115,7 +129,6 @@ export function DashboardAgentHistoryMenu({
115129
// Deleting a chat is irreversible, so it goes through a confirm step. Holding
116130
// the whole chat lets the dialog name what's being deleted.
117131
const [pendingDelete, setPendingDelete] = useState<DashboardAgentChat | null>(null);
118-
const now = Date.now();
119132

120133
return (
121134
<>
@@ -128,7 +141,7 @@ export function DashboardAgentHistoryMenu({
128141
<AgentList>
129142
{unreadFirst(chats).map((chat) => {
130143
const process = chatProcess(chat, chat.id === thinkingChatId);
131-
const age = chat.lastMessageAt ? chatAge(chat.lastMessageAt, now) : undefined;
144+
const stamp = chat.lastMessageAt ? chatDateStamp(chat.lastMessageAt) : undefined;
132145
return (
133146
<AgentListRow
134147
key={chat.id}
@@ -137,7 +150,7 @@ export function DashboardAgentHistoryMenu({
137150
// null keeps the leading slot so every title starts at the
138151
// same x whether or not this chat has a status.
139152
status={process ? <ProcessIcon process={process} /> : null}
140-
meta={age}
153+
meta={stamp}
141154
variant={chat.id === currentChatId ? "selected" : "default"}
142155
onSelect={() => onSelect(chat.id)}
143156
action={

apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
ChatPendingTool,
1616
ChatProgress,
1717
ChatText,
18+
ChatToolRow,
1819
ChatTranscript,
1920
ChatTurn,
2021
ChatWakeSlot,
@@ -160,8 +161,8 @@ function withoutSupersededInvestigations(
160161
* dashboard's default size, and tool calls never show their mechanics — while
161162
* running they are a pending pill ("Reading the queue…"), and once they land
162163
* they leave NO row at all: the answer is the prose and the cards, not the
163-
* input/output plumbing. The one exception is a FAILED call, which keeps its
164-
* error row — a silent failure would read as the agent ignoring the question.
164+
* input/output plumbing. The one exception is a FAILED call, which keeps a pill
165+
* — a silent failure would read as the agent ignoring the question.
165166
* Citations are handled a level up, where a run of them can be grouped into one
166167
* row.
167168
*/
@@ -176,6 +177,7 @@ function renderDashboardPart(
176177
url?: string;
177178
title?: string;
178179
state?: string;
180+
errorText?: string;
179181
};
180182
const type = part.type as string;
181183

@@ -190,7 +192,20 @@ function renderDashboardPart(
190192
if (options?.suppressPendingPill) return null;
191193
return <ChatPendingTool key={i} label={`${toolPendingLabel(type.slice(5))}…`} />;
192194
}
193-
if (p.state === "output-error") return renderPart(part, i);
195+
if (p.state === "output-error") {
196+
// A failed call stays visible, but in the panel's own language: what the
197+
// agent was doing and what came back, not the inspector's input/output
198+
// tabs. The full text is on the row's title for the rare long error.
199+
const errorText = p.errorText ?? "failed";
200+
return (
201+
<ChatToolRow
202+
key={i}
203+
tone="error"
204+
label={toolPendingLabel(type.slice(5))}
205+
detail={<span title={errorText}>{errorText}</span>}
206+
/>
207+
);
208+
}
194209
return null;
195210
}
196211

apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SparklesIcon, XMarkIcon } from "@heroicons/react/20/solid";
1+
import { CodeBracketIcon, SparklesIcon, XMarkIcon } from "@heroicons/react/20/solid";
22
import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts";
33
import { useCallback, useMemo, useState } from "react";
44
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -77,6 +77,11 @@ export function DashboardAgentSuggestedPrompts({
7777
<AgentListRow
7878
key={prompt.id}
7979
label={prompt.label}
80+
// A prompt is an invitation, so it reads as a chip: fully rounded,
81+
// with the icon and the chevron the public chat page uses.
82+
shape="pill"
83+
icon={CodeBracketIcon}
84+
chevron
8085
variant={prompt.source === "promoted" ? "promoted" : "default"}
8186
onSelect={() => onSelect(prompt.prompt)}
8287
action={

apps/webapp/app/components/dashboard-agent/chat-layout.test.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,25 @@ describe("chat-layout enforcement", () => {
9797
}
9898
});
9999

100-
it("renders assistant text as prose, not as a card", () => {
101-
// A box around every text answer made the whole transcript read as cards.
102-
// Only ChatCardSlot content is boxed now, so nothing here may reintroduce
103-
// the shared bubble.
100+
it("puts assistant text in a soft card of its own", () => {
101+
// Boxed, but in the library's own surface — the shared inspector bubble
102+
// brings its own spacing and would put a second layout owner in here.
103+
expect(source).toContain('const SOFT_SURFACE = "border border-border-bright');
104104
expect(source).not.toContain("ChatBubble");
105105
});
106106

107-
it("gives the user bubble a grey surface, not the accent", () => {
108-
expect(source).toContain("bg-background-raised");
109-
expect(source).not.toMatch(/bg-indigo-\d/);
107+
it("gives the user bubble the accent surface", () => {
108+
expect(source).toMatch(/bg-indigo-\d/);
109+
});
110+
111+
it("sets tool pills and progress in mono", () => {
112+
expect(source).toContain('const MONO_TEXT = "font-mono');
113+
// Both pills read as one family, so neither may restyle its own shape.
114+
expect(source).toContain("const PILL =");
115+
for (const component of ["ChatPendingTool", "ChatToolRow"]) {
116+
const body = source.slice(source.indexOf(`export function ${component}(`));
117+
expect(body.slice(0, body.indexOf("\n}\n")), component).toContain("PILL");
118+
}
110119
});
111120

112121
it("documents the composition rules", () => {

apps/webapp/app/components/dashboard-agent/chat-layout.tsx

Lines changed: 85 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,14 @@
4646
* rhythm — is the card's business and must stay in the card. Anything about
4747
* how the card sits in the transcript — full width, inset, distance to its
4848
* neighbours — is `ChatCardSlot`'s, and a card must not set it.
49-
* 4. **Role drives alignment and nothing else.** `role="user"` is right-aligned
50-
* in a grey bubble; `role="assistant"` is left-aligned, full width and
51-
* unboxed — plain prose, so the cards are the only things that read as cards.
49+
* 4. **Role drives alignment and surface.** `role="user"` is right-aligned in an
50+
* indigo bubble; `role="assistant"` is left-aligned and near-full width in a
51+
* soft dark card. Rich cards are still distinct — they carry headers, badges
52+
* and actions inside the same border language.
53+
* 5. **Machine text is mono.** Anything the system names — a tool call, a step
54+
* count, a duration — is set in mono; sentences the agent writes stay sans.
5255
*/
56+
import { ChevronRightIcon } from "@heroicons/react/20/solid";
5357
import type { Ref } from "react";
5458
import { createContext, Suspense, useContext } from "react";
5559
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
@@ -74,6 +78,22 @@ const UNIT_GAP = "space-y-1.5";
7478
const SCROLLER =
7579
"flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control";
7680

81+
/**
82+
* The surface a turn's own content sits on: a soft dark card, one step above the
83+
* panel. Used by the assistant's text and by the pills, so a transcript reads as
84+
* one material.
85+
*/
86+
const SOFT_SURFACE = "border border-border-bright bg-background-hover";
87+
88+
/** Machine text — a tool name, a step count, a duration. See rule 5. */
89+
const MONO_TEXT = "font-mono text-xs tracking-wide";
90+
91+
/**
92+
* A pill: a tool call, in flight or landed. Fully rounded and dashed, so it reads
93+
* as a trace of what happened rather than as a control to press.
94+
*/
95+
const PILL = "inline-flex min-w-0 max-w-full items-center rounded-full border-dashed px-3 py-1";
96+
7797
/**
7898
* The soft edge at the bottom of the scroller. There is no rule above the
7999
* composer — the transcript runs behind it — so this is what says the content
@@ -165,27 +185,28 @@ export function ChatTurn({
165185
/**
166186
* A text body.
167187
*
168-
* The assistant variant is the panel's markdown path, rendered as plain prose:
169-
* NOT a card. The agent answers in text most of the time, and a box around every
170-
* one of those answers made the transcript read as a stack of cards — so the box
171-
* is reserved for the things that really are cards (report, investigation,
172-
* diagnosis, chart), which mount through `ChatCardSlot`. A plain-text fallback
173-
* shows while the markdown renderer loads.
188+
* The assistant variant is the panel's markdown path, in a soft card: near-full
189+
* width, left-aligned, so an answer has an edge without shouting. A plain-text
190+
* fallback shows while the markdown renderer loads.
174191
*
175-
* The user variant is the one bubble left: grey, right-aligned, so a turn is
176-
* legible at a glance without competing with the accent the agent's own surfaces
177-
* use.
192+
* The user variant is the accent bubble: indigo, right-aligned, tighter than full
193+
* width, so who said what is legible at a glance.
178194
*/
179195
export function ChatText({ role = "assistant", text }: { role?: ChatRole; text: string }) {
180196
if (role === "user") {
181197
return (
182-
<div className="max-w-[80%] rounded-lg bg-background-raised px-4 py-2.5 text-sm text-text-bright">
198+
<div className="max-w-[85%] rounded-2xl bg-indigo-600 px-4 py-2.5 text-sm text-white">
183199
<div className="whitespace-pre-wrap wrap-anywhere">{text}</div>
184200
</div>
185201
);
186202
}
187203
return (
188-
<div className="streamdown-container min-w-0 font-sans text-sm font-normal text-text-dimmed wrap-anywhere">
204+
<div
205+
className={cn(
206+
SOFT_SURFACE,
207+
"streamdown-container min-w-0 rounded-xl px-4 py-3 font-sans text-sm font-normal text-text-bright wrap-anywhere"
208+
)}
209+
>
189210
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
190211
<StreamdownRenderer>{text}</StreamdownRenderer>
191212
</Suspense>
@@ -203,7 +224,8 @@ export function ChatCardSlot({ children }: { children: React.ReactNode }) {
203224
}
204225

205226
/**
206-
* The progress line: a spinner and one line of dimmed text, left-aligned.
227+
* The progress line: a spinner and one line of dimmed mono text, left-aligned —
228+
* the same register as the step summary a finished turn leaves behind.
207229
*
208230
* It always carries the transcript's inset — either from the turn it sits in, or
209231
* by applying it itself when it is mounted loose (under a card, say). There is no
@@ -216,9 +238,9 @@ export function ChatCardSlot({ children }: { children: React.ReactNode }) {
216238
export function ChatProgress({ children }: { children: React.ReactNode }) {
217239
const insetClass = useInsetClass();
218240
return (
219-
<div className={cn(insetClass, "flex items-start text-sm text-text-dimmed", ROW_GAP)}>
220-
{/* text-sm line box is 20px, the spinner 12px: 4px centres it on line one. */}
221-
<Spinner className="mt-1 size-3 shrink-0" />
241+
<div className={cn(insetClass, MONO_TEXT, "flex items-start text-text-dimmed", ROW_GAP)}>
242+
{/* text-xs line box is 16px, the spinner 12px: 2px centres it on line one. */}
243+
<Spinner className="mt-0.5 size-3 shrink-0" />
222244
{children}
223245
</div>
224246
);
@@ -238,26 +260,60 @@ export function ChatPendingTool({ label }: { label: string }) {
238260
const insetClass = useInsetClass();
239261
return (
240262
<div className={cn(insetClass, "flex min-w-0")}>
241-
<span
242-
className={cn(
243-
"inline-flex h-6 min-w-0 items-center rounded-full border border-border-bright bg-background-bright px-2.5 text-xs text-text-dimmed",
244-
CHIP_GAP
245-
)}
246-
>
263+
<span className={cn(PILL, SOFT_SURFACE, MONO_TEXT, "text-text-dimmed", CHIP_GAP)}>
247264
<Spinner className="size-3 shrink-0" />
248265
<span className="truncate">{label}</span>
249266
</span>
250267
</div>
251268
);
252269
}
253270

271+
/** How a landed tool call ended. Carried by the pill's dot, not by its text. */
272+
export type ChatToolTone = "done" | "error";
273+
274+
const TOOL_DOT: Record<ChatToolTone, string> = {
275+
done: "bg-primary",
276+
error: "bg-error",
277+
};
278+
254279
/**
255-
* A tool-call row, and optionally a `ChatProgress` under it while the call is in
256-
* flight. Nothing but the row's placement lives here — the row itself is the
257-
* shared `ToolUseRow`.
280+
* A landed tool call, as a pill: a dot for how it ended, what it did in bright
281+
* mono, and what it did it to in dimmed mono. The chevron marks a pill whose
282+
* detail is longer than the room it has.
283+
*
284+
* `children` is anything that belongs under the row — a `ChatProgress` while the
285+
* turn continues, say.
258286
*/
259-
export function ChatToolRow({ children }: { children: React.ReactNode }) {
260-
return <div className={cn("min-w-0", TURN_BODY_GAP)}>{children}</div>;
287+
export function ChatToolRow({
288+
label,
289+
detail,
290+
tone = "done",
291+
children,
292+
}: {
293+
label?: React.ReactNode;
294+
/** What the call was about: a query, an error message, a run id. Truncated. */
295+
detail?: React.ReactNode;
296+
tone?: ChatToolTone;
297+
children?: React.ReactNode;
298+
}) {
299+
const insetClass = useInsetClass();
300+
return (
301+
<div className={cn(insetClass, "min-w-0", TURN_BODY_GAP)}>
302+
{label ? (
303+
<div className="flex min-w-0">
304+
<span className={cn(PILL, SOFT_SURFACE, MONO_TEXT, CHIP_GAP)}>
305+
<span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", TOOL_DOT[tone])} />
306+
<span className="shrink-0 text-text-bright">{label}</span>
307+
{detail ? <span className="truncate text-text-dimmed">{detail}</span> : null}
308+
{detail ? (
309+
<ChevronRightIcon aria-hidden className="size-3 shrink-0 text-text-faint" />
310+
) : null}
311+
</span>
312+
</div>
313+
) : null}
314+
{children}
315+
</div>
316+
);
261317
}
262318

263319
/**

0 commit comments

Comments
 (0)