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. */}
+
- {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) => (
) : 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 ? (