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
82 changes: 82 additions & 0 deletions app/src/components/channels/activity-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { EASE_OUT } from "@/lib/motion";

/**
* A slim, persistent account of the turn in flight, pinned above the composer.
*
* WHY IT EXISTS ALONGSIDE THE TRANSCRIPT'S OWN THINKING LINE: the thinking line lives at the bottom
* of the conversation, where the answer will land — exactly right while the person is watching the
* end of the thread, and invisible the moment they scroll up to re-read something. This bar does not
* scroll. Whatever part of the transcript is on screen, the fact that the Bot is still working, what
* it was last seen doing, and for how long stay in one fixed place.
*
* The action label is the latest tool call's humanised name ("Reading the page"), or plain thinking
* before the first tool runs. Both shimmer through the same `.tool-line-running` treatment a running
* tool line uses, so "working" reads identically everywhere it appears.
*/
export function AgentActivityBar({
active,
actionLabel,
}: {
/** A turn is in flight. False collapses the bar entirely. */
active: boolean;
/** What the Bot was last seen doing; undefined falls back to "Thinking". */
actionLabel?: string | undefined;
}) {
const shouldReduceMotion = useReducedMotion();
const [seconds, setSeconds] = useState(0);

useEffect(() => {
if (!active) {
setSeconds(0);
return;
}
const started = Date.now();
setSeconds(Math.floor((Date.now() - started) / 1000));
const timer = setInterval(
() => setSeconds(Math.floor((Date.now() - started) / 1000)),
1000,
);
return () => clearInterval(timer);
}, [active]);

return (
<AnimatePresence initial={false}>
{active ? (
<motion.div
animate={{ opacity: 1, transform: "translateY(0px)" }}
aria-label="The assistant is working"
className="mb-2 flex items-center gap-2.5 rounded-2xl border border-border/70 bg-card/90 py-2 pr-3 pl-3.5 shadow-xs backdrop-blur-sm"
exit={{
opacity: 0,
...(shouldReduceMotion ? {} : { transform: "translateY(4px)" }),
}}
initial={{
opacity: 0,
...(shouldReduceMotion ? {} : { transform: "translateY(4px)" }),
}}
role="status"
transition={{
duration: 0.18,
ease: EASE_OUT,
}}
>
<span aria-hidden className="thinking-orb-halo">
<span className="thinking-orb scale-75" />
</span>
<span className="tool-line-running min-w-0 truncate text-muted-foreground text-sm">
{actionLabel ?? "Thinking"}
</span>
{/* Tabular figures so the count ticks without the digits shifting under themselves. */}
<span
aria-hidden
className="ml-auto shrink-0 font-mono text-[11px] text-muted-foreground/80 tabular-nums"
>
{seconds >= 5 ? `${seconds}s` : ""}
</span>
</motion.div>
) : null}
</AnimatePresence>
);
}
50 changes: 41 additions & 9 deletions app/src/components/channels/channel-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,52 @@ function EmptyConversation({
title?: string | undefined;
}) {
return (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<span aria-hidden className="size-12 overflow-hidden rounded-full">
<Avatar className="size-full" name={agentId} size={48} />
<div className="relative flex flex-col items-center gap-4 py-16 text-center">
{/*
* A faint pool of light behind the face. Foreground-derived rather than a fixed hue, so it
* stays monochrome in both themes and reads as depth rather than decoration.
*/}
<span
aria-hidden
className="pointer-events-none absolute top-6 size-44 rounded-full bg-[radial-gradient(closest-side,var(--foreground),transparent)] opacity-[0.07] blur-2xl"
/>
<span
aria-hidden
className="relative size-14 overflow-hidden rounded-full shadow-sm ring-1 ring-foreground/10"
>
<Avatar className="size-full" name={agentId} size={56} />
</span>
<div>
<p className="font-medium text-sm">{name}</p>
<div className="relative flex flex-col items-center gap-0.5">
<p className="font-medium text-base tracking-tight">{name}</p>
{title ? (
<p className="text-muted-foreground text-xs">{title}</p>
) : null}
<p className="max-w-sm pt-2 text-balance text-muted-foreground text-sm">
Ask anything below. Attach files, invoke a skill, and stop a running
answer at any time.
</p>
</div>
<p className="max-w-sm text-muted-foreground text-sm">
Ask anything below. Attach files with the + button, invoke a skill with
/, and stop a running answer at any time.
</p>
{/* The composer's grammar, where the person is looking before they have learned it. */}
<ul className="relative flex flex-wrap items-center justify-center gap-x-3 gap-y-1.5 text-muted-foreground text-xs">
<li className="inline-flex items-center gap-1.5">
<kbd className="rounded-md border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-[11px] leading-none text-foreground/70">
/
</kbd>
skills
</li>
<li className="inline-flex items-center gap-1.5">
<kbd className="rounded-md border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-[11px] leading-none text-foreground/70">
@
</kbd>
mention
</li>
<li className="inline-flex items-center gap-1.5">
<kbd className="rounded-md border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-[11px] leading-none text-foreground/70">
+
</kbd>
attach files
</li>
</ul>
</div>
);
}
Expand Down
63 changes: 44 additions & 19 deletions app/src/components/channels/chat-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import {
IconBox,
IconCheck,
IconChevronRight,
IconCopy,
IconFile,
IconPencil,
Expand Down Expand Up @@ -255,23 +256,23 @@ function Stopped({
onRetry?: (() => void) | undefined;
}) {
return (
<p
className="flex items-center gap-3 text-destructive text-sm"
<div
className="flex items-center gap-3 rounded-xl border border-destructive/20 bg-destructive/5 py-2 pr-2 pl-3.5 text-destructive text-sm"
data-testid="transcript-stopped"
role="alert"
>
<span>{reason}</span>
<span className="min-w-0 flex-1">{reason}</span>
{onRetry ? (
<button
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-0.5 text-foreground text-xs hover:bg-muted focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
className="inline-flex shrink-0 items-center gap-1 rounded-lg border border-border bg-card px-2 py-1 font-medium text-foreground text-xs shadow-xs transition-colors duration-150 hover:bg-muted focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
onClick={onRetry}
type="button"
>
<IconRefresh className="size-3" />
Retry
</button>
) : null}
</p>
</div>
);
}

Expand Down Expand Up @@ -518,13 +519,15 @@ function TurnHeader({
<div className="flex items-center gap-2 pt-1 pb-1.5">
<span
aria-hidden
className="size-5 shrink-0 overflow-hidden rounded-full"
className="size-5 shrink-0 overflow-hidden rounded-full ring-1 ring-foreground/10"
>
<Avatar className="size-full" name={agentId ?? name} size={20} />
</span>
<span className="font-medium text-foreground text-xs">{name}</span>
<span className="font-medium text-foreground text-xs tracking-tight">
{name}
</span>
{time !== undefined ? (
<span className="text-[11px] text-muted-foreground">
<span className="text-[11px] text-muted-foreground/80 tabular-nums">
{formatTime(time)}
</span>
) : null}
Expand All @@ -543,9 +546,17 @@ function DaySeparator({ label }: { label: string }) {
// rules are decoration and the row claims no ARIA role it cannot fully honour.
return (
<div className="flex items-center gap-3 py-1">
<span aria-hidden className="h-px flex-1 bg-border" />
<span className="text-[11px] text-muted-foreground">{label}</span>
<span aria-hidden className="h-px flex-1 bg-border" />
<span
aria-hidden
className="h-px flex-1 bg-gradient-to-r from-transparent to-border"
/>
<span className="font-medium text-[10px] tracking-widest text-muted-foreground/80 uppercase">
{label}
</span>
<span
aria-hidden
className="h-px flex-1 bg-gradient-to-l from-transparent to-border"
/>
</div>
);
}
Expand Down Expand Up @@ -574,7 +585,7 @@ function MessageActions({
time?: number | undefined;
}) {
const button =
"inline-flex items-center gap-1 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
"inline-flex items-center gap-1 rounded-md p-1 text-muted-foreground transition-all duration-150 hover:bg-muted hover:text-foreground active:scale-90 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
return (
<span
className={`flex items-center gap-0.5 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover/message:opacity-100 ${
Expand Down Expand Up @@ -762,7 +773,18 @@ const TranscriptMessage = memo(
}
variant={isUser ? "muted" : "ghost"}
>
<BubbleContent>
<BubbleContent
/*
* A person's words sit in a slightly softer, roomier shell than the default: the
* extra radius and hairline shadow lift their turns off the canvas just enough to
* read as "what I said" against the Bot's open prose.
*/
className={
isUser
? "rounded-2xl border-border/50 px-3.5 py-2.5 shadow-xs"
: undefined
}
>
{isUser ? (
// A person's own message is shown exactly as they typed it. Rendering it as markdown
// would silently reformat what they said, and an asterisk in a sentence is not
Expand All @@ -778,7 +800,7 @@ const TranscriptMessage = memo(
* `align-middle` rather than a block: this sits mid-sentence, and a badge that
* breaks the line it is in reads as a separate message.
*/}
<span className="mr-1 inline-flex items-center gap-1 rounded bg-foreground/10 px-1.5 py-0.5 align-middle font-mono text-foreground/80 text-xs">
<span className="mr-1 inline-flex items-center gap-1 rounded-md bg-foreground/[0.08] px-1.5 py-0.5 align-middle font-mono font-medium text-foreground/80 text-xs ring-1 ring-foreground/10">
<IconBox className="size-3 shrink-0" />/{invoked.chip}
</span>
{invoked.rest}
Expand Down Expand Up @@ -1146,15 +1168,18 @@ function TranscriptToolGroup({

return (
<Arriving delay={delay}>
<details className="tool-line my-1.5 min-w-0">
<summary className="flex cursor-pointer list-none items-baseline gap-1.5">
<details className="tool-line group/steps my-1.5 min-w-0">
<summary className="-mx-1 flex cursor-pointer list-none items-center gap-1.5 rounded-lg px-1 py-1 transition-colors duration-150 hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 [&::-webkit-details-marker]:hidden">
<span
aria-hidden
className="tool-line-chevron shrink-0 text-muted-foreground text-xs transition-transform"
className="tool-line-chevron flex shrink-0 items-center text-muted-foreground/70 transition-transform"
>
<IconChevronRight className="size-3" />
</span>
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-muted font-medium text-[9px] text-muted-foreground tabular-nums">
{tools.length}
</span>
<span className="text-muted-foreground text-sm">
<span className="min-w-0 truncate text-muted-foreground text-sm">
{toolGroupSummary(tools)}
</span>
</summary>
Expand Down
25 changes: 15 additions & 10 deletions app/src/components/channels/composer/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ function AttachmentChips({
>
{attachments.map((attachment) => (
<li
className="inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs"
className="inline-flex max-w-full items-center gap-1 rounded-lg border border-border/60 bg-muted/60 px-2 py-1 text-xs"
key={attachment.id}
>
<IconPaperclip className="size-3 shrink-0" />
Expand Down Expand Up @@ -427,7 +427,8 @@ export function Composer({
);

const dropOverlay = draggingFiles ? (
<div className="pointer-events-none absolute inset-1 z-10 flex items-center justify-center rounded-xl border border-dashed border-primary bg-card/95 text-sm font-medium text-primary">
<div className="pointer-events-none absolute inset-1 z-10 flex items-center justify-center gap-2 rounded-xl border border-dashed border-primary bg-card/80 text-sm font-medium text-primary backdrop-blur-sm">
<IconPaperclip className="size-4" />
Drop files to attach
</div>
) : null;
Expand All @@ -452,9 +453,13 @@ export function Composer({
* It goes outside the editor because the editor scrolls internally at
* COMPACT_MAX_HEIGHT_PX: padding inside that box would scroll away with the text, so the
* first visible line would still touch the top edge on a long message.
*
* FOCUS RAISES THE SURFACE, NOT A COLOURED RING. A four-layer soft halo plus a slightly
* darker border reads as the field coming forward; a saturated ring on a monochrome app
* reads as an error state.
*/
"relative flex min-h-14 flex-col rounded-2xl border border-border bg-card transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
draggingFiles && "border-primary bg-primary/5 ring-3 ring-primary/15",
"relative flex min-h-14 flex-col rounded-2xl border border-border bg-card shadow-xs transition-[border-color,box-shadow] duration-200 ease-out focus-within:border-foreground/25 focus-within:shadow-md focus-within:ring-4 focus-within:ring-foreground/[0.04]",
draggingFiles && "border-primary bg-primary/5 ring-4 ring-primary/10",
className,
)}
onSubmit={handleFormSubmit}
Expand Down Expand Up @@ -494,7 +499,7 @@ export function Composer({
{canStop ? (
<Button
aria-label="Stop the Bot"
className="size-8 rounded-full p-0"
className="size-8 rounded-full bg-destructive p-0 text-white transition-all duration-150 hover:bg-destructive/90 hover:text-white active:scale-90"
data-testid="composer-stop"
onClick={onStop}
size="icon"
Expand All @@ -505,7 +510,7 @@ export function Composer({
) : (
<Button
aria-label={sendLabel}
className="size-8 rounded-full p-0"
className="size-8 rounded-full p-0 shadow-sm transition-all duration-150 hover:-translate-y-px hover:shadow-md active:translate-y-0 active:scale-90"
disabled={!canSend}
size="icon"
type="submit"
Expand All @@ -525,8 +530,8 @@ export function Composer({
<form
aria-busy={isBusy}
className={cn(
"relative overflow-hidden rounded-2xl border border-border bg-card transition-colors",
draggingFiles && "border-primary bg-primary/5 ring-3 ring-primary/15",
"relative overflow-hidden rounded-2xl border border-border bg-card shadow-xs transition-[border-color,box-shadow] duration-200 ease-out focus-within:border-foreground/25 focus-within:shadow-md focus-within:ring-4 focus-within:ring-foreground/[0.04]",
draggingFiles && "border-primary bg-primary/5 ring-4 ring-primary/10",
)}
onSubmit={handleFormSubmit}
{...dragProps}
Expand Down Expand Up @@ -571,7 +576,7 @@ export function Composer({
{canStop ? (
<Button
aria-label="Stop the Bot"
className="size-7 rounded-full bg-primary p-0"
className="size-7 rounded-full bg-destructive p-0 text-white transition-all duration-150 hover:bg-destructive/90 hover:text-white active:scale-90"
data-testid="composer-stop"
onClick={onStop}
type="button"
Expand All @@ -581,7 +586,7 @@ export function Composer({
) : (
<Button
aria-label={sendLabel}
className="size-7 rounded-full bg-primary p-0 disabled:cursor-not-allowed disabled:opacity-50"
className="size-7 rounded-full bg-primary p-0 shadow-sm transition-all duration-150 hover:-translate-y-px hover:shadow-md active:translate-y-0 active:scale-90 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-sm"
disabled={!canSend}
type="submit"
>
Expand Down
Loading
Loading