diff --git a/app/src/components/channels/activity-bar.tsx b/app/src/components/channels/activity-bar.tsx new file mode 100644 index 0000000..39ecef9 --- /dev/null +++ b/app/src/components/channels/activity-bar.tsx @@ -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 ( + + {active ? ( + + + + + + {actionLabel ?? "Thinking"} + + {/* Tabular figures so the count ticks without the digits shifting under themselves. */} + + {seconds >= 5 ? `${seconds}s` : ""} + + + ) : null} + + ); +} diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 009bf05..12da4a2 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -60,20 +60,52 @@ function EmptyConversation({ title?: string | undefined; }) { return ( -
- - +
+ {/* + * 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. + */} + + + -
-

{name}

+
+

{name}

{title ? (

{title}

) : null} +

+ Ask anything below. Attach files, invoke a skill, and stop a running + answer at any time. +

-

- Ask anything below. Attach files with the + button, invoke a skill with - /, and stop a running answer at any time. -

+ {/* The composer's grammar, where the person is looking before they have learned it. */} +
    +
  • + + / + + skills +
  • +
  • + + @ + + mention +
  • +
  • + + + + + attach files +
  • +
); } diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 9578e27..f80d784 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -6,6 +6,7 @@ import { import { IconBox, IconCheck, + IconChevronRight, IconCopy, IconFile, IconPencil, @@ -255,15 +256,15 @@ function Stopped({ onRetry?: (() => void) | undefined; }) { return ( -

- {reason} + {reason} {onRetry ? ( ) : null} -

+
); } @@ -518,13 +519,15 @@ function TurnHeader({
- {name} + + {name} + {time !== undefined ? ( - + {formatTime(time)} ) : null} @@ -543,9 +546,17 @@ function DaySeparator({ label }: { label: string }) { // rules are decoration and the row claims no ARIA role it cannot fully honour. return (
- - {label} - + + + {label} + +
); } @@ -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 ( - + {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 @@ -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. */} - + /{invoked.chip} {invoked.rest} @@ -1146,15 +1168,18 @@ function TranscriptToolGroup({ return ( -
- +
+ - ▸ + + + + {tools.length} - + {toolGroupSummary(tools)} diff --git a/app/src/components/channels/composer/composer.tsx b/app/src/components/channels/composer/composer.tsx index dd43bc5..c1ffa14 100644 --- a/app/src/components/channels/composer/composer.tsx +++ b/app/src/components/channels/composer/composer.tsx @@ -135,7 +135,7 @@ function AttachmentChips({ > {attachments.map((attachment) => (
  • @@ -427,7 +427,8 @@ export function Composer({ ); const dropOverlay = draggingFiles ? ( -
    +
    + Drop files to attach
    ) : null; @@ -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} @@ -494,7 +499,7 @@ export function Composer({ {canStop ? (