From d64c1ddcf7283285a64e09fa137976a6559bf9cc Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 16 Jul 2026 06:52:30 +0200 Subject: [PATCH] feat: give the assistant a serious monochrome AgentMark logomark (panel header, empty state, top bar), center single-line composer text against the send button, add minimize-to-dock with a live bottom-right status bar (working/step count, needs approval, response ready), unseen dots on tabs, and a live status badge on the header spark button --- docs/content/docs/guides/ai-assistant.mdx | 11 +- web/src/components/app/agent/AgentMark.tsx | 11 + web/src/components/app/agent/AgentPanel.tsx | 211 +++++++++++++++++--- web/src/components/layout/AppHeader.tsx | 61 +++++- 4 files changed, 260 insertions(+), 34 deletions(-) create mode 100644 web/src/components/app/agent/AgentMark.tsx diff --git a/docs/content/docs/guides/ai-assistant.mdx b/docs/content/docs/guides/ai-assistant.mdx index 7d1b6b9f..e943d850 100644 --- a/docs/content/docs/guides/ai-assistant.mdx +++ b/docs/content/docs/guides/ai-assistant.mdx @@ -6,7 +6,16 @@ icon: Sparkles The AI assistant is a chat panel built into the dashboard. It can look things up across your contacts, campaigns, unified inbox, and CRM, and it can take actions on your behalf, but it always asks before changing anything, and it never sends email for you. -Open it from the sparkle button in the top bar, or press `Cmd/Ctrl + I`. The panel stays with you as you move between pages, so you can keep a conversation going while you work. +Open it from the spark button in the top bar, or press `Cmd/Ctrl + I`. The panel stays with you as you move between pages, so you can keep a conversation going while you work. + +## Tabs, minimizing, and background runs + +Conversations run as tabs, like an editor: open several at once with the **+** button, and a run keeps streaming in its own tab while you read another. The expand button turns the panel into a full workspace with your conversation history down the side. + +You do not have to keep the panel open to keep the assistant working: + +- **Minimize** docks it into a compact status bar at the bottom of the screen. The bar shows what the assistant is doing live: working (with the step count), waiting for an approval, or holding a finished response you have not read. Click it to bring the panel back on that conversation. +- **Close it entirely** and any running work carries on. The spark button in the top bar shows a pulsing dot while a run streams, an amber dot when an action is waiting for your approval, and a solid dot when a response is ready; click it to reopen. The assistant acts as you. It can only see and do what your own role allows, so it can never reach data or actions you could not reach by hand. It drafts replies but never sends them, and it never starts a campaign on its own. diff --git a/web/src/components/app/agent/AgentMark.tsx b/web/src/components/app/agent/AgentMark.tsx new file mode 100644 index 00000000..2174c3eb --- /dev/null +++ b/web/src/components/app/agent/AgentMark.tsx @@ -0,0 +1,11 @@ +// The assistant's logomark: a single geometric spark, monochrome by design. +// Rendered inside a plain slate-900 tile (see AgentPanel / AppHeader) instead +// of a pastel gradient box, so the assistant reads as part of the product +// chrome rather than a bolted-on AI widget. +export default function AgentMark({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/web/src/components/app/agent/AgentPanel.tsx b/web/src/components/app/agent/AgentPanel.tsx index c2ac0768..8a011104 100644 --- a/web/src/components/app/agent/AgentPanel.tsx +++ b/web/src/components/app/agent/AgentPanel.tsx @@ -1,7 +1,8 @@ // Right-side AI assistant workspace. Persistent across routes, opened from the -// header sparkle button or Cmd+I. A real multi-conversation surface: several +// header spark button or Cmd+I. A real multi-conversation surface: several // chats run as tabs (each streams its own tool-using agent over SSE), a history -// rail lists past sessions, and the panel expands to a full-width workspace. +// rail lists past sessions, the panel expands to a full-width workspace, and +// minimizing docks it into a bottom-right status bar while runs keep going. // // Tab state (including the unsent composer draft) lives in the store // (agentSlice) so tabs survive the panel closing and a background run keeps @@ -13,7 +14,6 @@ import React from "react"; import { motion } from "framer-motion"; import { useLocation, useNavigate } from "react-router-dom"; import { - SparklesIcon, XIcon, ArrowUpIcon, ArrowDownIcon, @@ -27,6 +27,8 @@ import { ShieldQuestionIcon, Maximize2Icon, Minimize2Icon, + MinusIcon, + ChevronUpIcon, ClockIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -42,6 +44,7 @@ import getAgentMessages from "@/lib/api/client/app/agent/getAgentMessages"; import streamAgentRun from "@/lib/api/client/app/agent/streamAgentRun"; import useAgentSessions from "@/lib/api/hooks/app/agent/useAgentSessions"; import Markdown from "./Markdown"; +import AgentMark from "./AgentMark"; import type { AgentStreamEvent, AgentSession, @@ -65,8 +68,11 @@ export default function AgentPanel() { const setOpen = useAppStore((s) => s.setAIAssistantOpen); const expanded = useAppStore((s) => s.agentExpanded); const setExpanded = useAppStore((s) => s.setAgentExpanded); + const minimized = useAppStore((s) => s.agentMinimized); + const setMinimized = useAppStore((s) => s.setAgentMinimized); const tabs = useAppStore((s) => s.agentTabs); const activeKey = useAppStore((s) => s.agentActiveKey); + const visible = open && !minimized; const navigate = useNavigate(); const location = useLocation(); @@ -105,21 +111,28 @@ export default function AgentPanel() { el.style.height = Math.min(el.scrollHeight, 128) + "px"; }, []); - // Opening the panel with no tabs starts a fresh conversation; focus lands - // in the composer once the slide-in starts. + // Opening (or restoring from the dock) with no tabs starts a fresh + // conversation; focus lands in the composer once the slide-in starts. React.useEffect(() => { - if (!open) return; + if (!visible) return; useAppStore.getState().agentEnsureTab(); const t = window.setTimeout(() => inputRef.current?.focus(), 80); return () => window.clearTimeout(t); - }, [open]); + }, [visible]); + + // Viewing a tab (panel open, not minimized) marks its finished runs seen. + React.useEffect(() => { + if (visible && activeTab?.unseen) { + useAppStore.getState().agentPatchTab(activeTab.key, { unseen: false }); + } + }, [visible, activeTab?.key, activeTab?.unseen]); // Switching tabs jumps to that conversation's latest message and refocuses // the composer. React.useEffect(() => { setPinned(true); requestAnimationFrame(() => scrollToBottom()); - if (open) inputRef.current?.focus(); + if (visible) inputRef.current?.focus(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeKey]); @@ -242,7 +255,13 @@ export default function AgentPanel() { ); } finally { aborts.delete(tabKey); - useAppStore.getState().agentPatchTab(tabKey, { running: false }); + const st = useAppStore.getState(); + st.agentPatchTab(tabKey, { running: false }); + // Finished while the user wasn't looking at this tab: flag it so the + // tab dot, dock, and header icon can say a response is ready. + if (!st.aiAssistantOpen || st.agentMinimized || st.agentActiveKey !== tabKey) { + st.agentPatchTab(tabKey, { unseen: true }); + } } } @@ -327,32 +346,49 @@ export default function AgentPanel() { } } + function closePanel() { + setOpen(false); + setMinimized(false); + } + return ( <> {/* Mobile backdrop. */} - {open && ( + {visible && (
setOpen(false)} + onClick={closePanel} className="fixed inset-0 z-40 bg-slate-900/20 md:hidden" /> )} + {/* Minimized dock: compact status bar; runs keep streaming into it. */} + {open && minimized && ( + { + if (key) useAppStore.getState().agentSetActive(key); + setMinimized(false); + }} + onClose={closePanel} + /> + )} { if (e.key === "Escape") { e.stopPropagation(); - setOpen(false); + closePanel(); } }} className={cn( "fixed right-0 top-0 z-50 h-full bg-white border-l border-slate-200 shadow-[0_0_60px_-12px_rgba(15,23,42,0.3)] flex", expanded ? "w-full sm:w-[min(1080px,94vw)]" : "w-full sm:w-[440px]", )} - aria-hidden={!open} + aria-hidden={!visible} > {/* History rail (expanded only). */} {expanded && ( @@ -367,13 +403,21 @@ export default function AgentPanel() {
{/* Header */}
-
- +
+
Assistant
+