diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 5e3325072..b0916e65f 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2695,6 +2695,7 @@ clearChat = "Clear chat" [chat.input] placeholder = "What do you want to do?" send = "Send message" +disclaimer = "AI can make mistakes. Be sure to verify the output before sharing." [chat.progress] analyzing = "Analysing your request..." diff --git a/frontend/editor/src/core/tests/stubbed/policy-admin-gate.spec.ts b/frontend/editor/src/core/tests/stubbed/policy-admin-gate.spec.ts index 7b1546a68..20f77cae6 100644 --- a/frontend/editor/src/core/tests/stubbed/policy-admin-gate.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/policy-admin-gate.spec.ts @@ -43,7 +43,10 @@ test.describe("Policy editing gate — non-admin (login on)", () => { }); test.describe("Policy editing gate — admin (login on)", () => { - test.use({ stubOptions: { enableLogin: true, isAdmin: true }, seedJwt: true }); + test.use({ + stubOptions: { enableLogin: true, isAdmin: true }, + seedJwt: true, + }); test("admin can reach the setup wizard", async ({ page }) => { await openSecurityPolicy(page); diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.css b/frontend/editor/src/proprietary/components/chat/ChatPanel.css index 3f66123e9..af278310a 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatPanel.css +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.css @@ -165,6 +165,16 @@ .chat-panel-messages { flex: 1; min-height: 0; + /* Establish a size container so the message list's bottom padding can be + expressed relative to the scroll viewport height (cqh) below. */ + container-type: size; +} + +/* Extra room beneath the last message so that, scrolled to the bottom, the most + recent turn rests near the vertical centre of the conversation rather than + jammed against the composer. cqh is a fraction of the scroll viewport height. */ +.chat-panel-messages__content { + padding-bottom: 15cqh; } /* Quick action cards above the input. */ @@ -282,12 +292,14 @@ flex-shrink: 0; } -/* Input area: textarea on top row, action icons on bottom row. */ +/* Input area: send button on the left, textarea fills the rest — one compact + row so the composer stays short when the welcome screen is fully populated. */ .chat-panel-input { display: flex; - flex-direction: column; + flex-direction: row; + align-items: center; gap: 0.5rem; - padding: 0.85rem 1rem 0.75rem; + padding: 0.7rem 0.8rem; margin: 0.75rem 0.75rem 1rem; border: none; border-radius: 1.1rem; @@ -309,7 +321,7 @@ .chat-panel-input__field, .chat-panel-input__field:focus, .chat-panel-input__field:focus-visible { - font-size: 0.95rem; + font-size: 1.05rem; padding: 0.15rem 0 !important; background: transparent !important; border: none !important; @@ -317,10 +329,49 @@ box-shadow: none !important; } -.chat-panel-input__actions { +/* Textarea grows to fill the row; the send button keeps its size on the left. */ +.chat-panel-input__textarea { + flex: 1; + min-width: 0; +} + +.chat-panel-input__send { + flex-shrink: 0; +} + +/* AI disclaimer. Two presentations share one base: + - --banner: a soft grey card below the header on the welcome screen. + - --inline: a compact line pinned above the composer once a chat is underway. */ +.chat-panel-disclaimer { display: flex; align-items: center; - justify-content: space-between; + gap: 0.45rem; + flex-shrink: 0; + color: var(--text-muted); +} + +.chat-panel-disclaimer__icon { + flex-shrink: 0; + opacity: 0.8; +} + +/* Welcome-screen banner: a little grey box just under the header. */ +.chat-panel-disclaimer--banner { + margin: 0.35rem 0.75rem 0; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border-subtle); + border-radius: 0.65rem; + background: var(--mantine-color-gray-light); + font-size: 0.78rem; + line-height: 1.4; +} + +/* Compact line above the composer once the conversation has started. */ +.chat-panel-disclaimer--inline { + justify-content: center; + padding: 0.1rem 1.25rem 0; + font-size: 0.7rem; + line-height: 1.3; } /* Message layout */ @@ -425,109 +476,88 @@ margin-bottom: 0; } -/* ─── Progress step log ─────────────────────────────────────────────────── */ +/* ─── Live progress indicator ──────────────────────────────────────────── */ /* - * Shown while the AI is working. Each step slides in from below as events - * arrive; the active (last) step is full-opacity with a pulsing icon, and - * earlier completed steps are dimmed. Only the most-recent N steps are - * rendered — there is no scrollable list. + * Shown while the AI works — one step at a time, no growing list. Our animated + * logo sits on the left, the current step's label shimmers in the middle, and + * the phase-specific icon (what it's doing right now) sits on the right. */ - -.chat-progress-log { +.chat-progress-live { display: flex; - flex-direction: column; - padding: 0.15rem 0.5rem 0.35rem; - /* No gap — the connector line div provides the inter-step spacing. */ + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.2rem 0.5rem 0.35rem; } -.chat-progress-step { - display: flex; - align-items: flex-start; - gap: 0.65rem; - /* - * Use color rather than opacity for past-step dimming so the connector - * line (a sibling element inside __left, not a text node) is NOT affected - * and stays clearly visible. currentColor flows into SVG icon fills; - * color inherits into the label span automatically. - */ - color: var(--text-muted); - transition: color 250ms ease-out; - animation: chat-step-enter 300ms cubic-bezier(0.22, 1, 0.36, 1) both; -} - -/* - * Active step: white text on dark mode so currentColor feeds directly into - * the StirlingLogoAnimated SVG paths and the label at the same time. - * Light-mode falls back to the default text colour so it's not invisible. - */ -/* Active step: icon and label are coloured independently so the logo can be - blue while the text uses the normal text colour. */ -.chat-progress-step--active .chat-progress-step__icon { +.chat-progress-live__logo { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; color: var(--mantine-color-blue-filled); } -.chat-progress-step--active .chat-progress-step__label { - color: var(--mantine-color-text); +/* Shimmer: a soft highlight sweeps left-to-right across the muted label. */ +.chat-progress-live__label { + flex: 1; + min-width: 0; + text-align: center; + font-size: 0.875rem; + line-height: 1.35; + background: linear-gradient( + 90deg, + var(--text-muted) 0%, + var(--text-muted) 40%, + var(--mantine-color-text) 50%, + var(--text-muted) 60%, + var(--text-muted) 100% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + animation: chat-shimmer 1.8s linear infinite; } -[data-mantine-color-scheme="dark"] - .chat-progress-step--active - .chat-progress-step__label { - color: #ffffff; -} - -.chat-progress-step__left { - display: flex; - flex-direction: column; - align-items: center; - flex-shrink: 0; - width: 20px; -} - -/* Icon cell — fixed height so the connector line aligns cleanly. */ -.chat-progress-step__icon { - width: 20px; - height: 24px; - display: flex; +.chat-progress-live__phase-icon { + display: inline-flex; align-items: center; justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; overflow: hidden; - flex-shrink: 0; - /* Inherits color from .chat-progress-step so currentColor flows into icons. */ + color: var(--text-muted); + animation: chat-phase-pulse 1.6s ease-in-out infinite; } -/* - * Connector line between steps. Not a child of the label so it is unaffected - * by the step's color; it sits on its own and reads clearly against the - * toolbar background. - */ -.chat-progress-step__line { - width: 1px; - height: 18px; - background: var(--border-subtle); - flex-shrink: 0; - margin-top: 2px; +@keyframes chat-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } } -/* Active step label — slightly larger, inherits the active color. */ -.chat-progress-step__label { - font-size: 0.875rem; - padding-top: 3px; - line-height: 1.35; -} - -/* Past step labels — a touch smaller so the active row reads as the primary. */ -.chat-progress-step:not(.chat-progress-step--active) - .chat-progress-step__label { - font-size: 0.8rem; - padding-top: 4px; +@keyframes chat-phase-pulse { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } } /* * Wrapper that scales a registry tool icon (LocalIcon, typically 1.5rem/24px) - * down to fit the 20px icon column. Transform does not affect layout, so the - * parent overflow:hidden clips the un-scaled footprint cleanly. + * down to fit the compact icon slots used by the live indicator and the + * completed tool list. Transform does not affect layout, so the parent's + * overflow:hidden clips the un-scaled footprint cleanly. */ .chat-step-icon-scaled { display: inline-flex; @@ -538,22 +568,12 @@ flex-shrink: 0; } -/* Entry: steps slide up from a few pixels below their final position. */ -@keyframes chat-step-enter { - from { - transform: translateY(7px); - } - to { - transform: translateY(0); - } -} - -/* ─── Completed progress log dropdown ──────────────────────────────────── */ +/* ─── Completed tool breakdown ─────────────────────────────────────────── */ /* * Shown above each completed assistant turn. The toggle displays a small - * "Ran for X seconds" label; expanding reveals the full ordered step list - * for that turn in a compact, read-only format. + * "Ran for X seconds" label; expanding reveals a numbered list of just the + * tools that ran. Turns with no tools show the duration as a plain label. */ .chat-completed-log { @@ -573,31 +593,56 @@ background: var(--mantine-color-default-hover); } -.chat-completed-log__steps { +.chat-completed-log__static { + display: inline-block; + padding: 0.1rem; +} + +/* Numbered list of the tools that ran — icon + name, no connector lines. */ +.chat-completed-log__tools { + list-style: none; + margin: 0.25rem 0 0; + padding: 0; display: flex; flex-direction: column; - padding: 0.35rem 0.4rem 0.2rem 0.25rem; - margin-top: 0.2rem; + gap: 0.1rem; } -/* In the completed log every step is a past step — no active highlighting, - no entry animation (the content is static once opened). */ -.chat-completed-log__steps .chat-progress-step { - animation: none; +.chat-completed-log__tool { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.2rem 0.25rem; + font-size: 0.8rem; + color: var(--mantine-color-text); } -/* Tighten the icon and connector dimensions for the denser historical view. */ -.chat-completed-log__steps .chat-progress-step__icon { - height: 20px; +.chat-completed-log__tool-num { + flex-shrink: 0; + min-width: 1.1rem; + text-align: right; + font-size: 0.75rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; } -.chat-completed-log__steps .chat-progress-step__line { - height: 14px; +.chat-completed-log__tool-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + overflow: hidden; + color: var(--text-muted); } -.chat-completed-log__steps .chat-progress-step__label { - font-size: 0.775rem; - padding-top: 2px; +.chat-completed-log__tool-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* Legacy thinking indicator (kept in case any other code references it). */ @@ -610,9 +655,15 @@ } @media (prefers-reduced-motion: reduce) { - .chat-progress-step { + .chat-progress-live__label { animation: none; - transition: none; + background: none; + -webkit-text-fill-color: var(--text-muted); + color: var(--text-muted); + } + .chat-progress-live__phase-icon { + animation: none; + opacity: 0.85; } } diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx index ff0fdc63b..270a289a4 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx @@ -31,12 +31,12 @@ import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import DeleteSweepIcon from "@mui/icons-material/DeleteSweep"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { useChat, AiWorkflowPhase, ChatRole, - PROGRESS_LOG_MAX, isKnownEngineProgressDetail, type AiWorkflowProgress, type AnyEngineProgressDetail, @@ -151,20 +151,15 @@ function formatEngineProgress( } /** - * Choose an icon for a progress step. - * - * The active (current) step always shows the animated Stirling logo so it reads - * as the "live" indicator. Past steps get a phase-specific icon so the trail - * is scannable at a glance. + * Phase-specific icon for a progress step: the tool's registry icon while a + * tool runs, or a generic glyph for the read/extract/think phases. Used for the + * right-hand "what it's doing" icon in the live indicator and for each row of + * the completed tool breakdown. */ function progressStepIcon( progress: AiWorkflowProgress, resolveToolIcon: ToolIconResolver, - isActive: boolean, ): ReactNode { - if (isActive) { - return ; - } if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL) { const registryIcon = progress.tool ? resolveToolIcon(progress.tool) : null; if (registryIcon) { @@ -182,9 +177,10 @@ function progressStepIcon( } /** - * Animated step-by-step progress log shown while the AI is working. - * Displays the last {@link PROGRESS_LOG_VISIBLE} steps from the live event stream, - * with the active (most recent) step highlighted and older steps dimmed. + * Live progress indicator shown while the AI is working. One step at a time: + * our animated logo on the left, the current step's label shimmering in the + * middle, and the phase-specific icon (what it's doing right now) on the right. + * The latest event replaces the previous one in place — no growing list. */ function ProgressLogDisplay({ progressLog, @@ -197,55 +193,23 @@ function ProgressLogDisplay({ resolveToolName: ToolNameResolver; resolveToolIcon: ToolIconResolver; }) { - // Placeholder shown before the first SSE event arrives. - if (progressLog.length === 0) { - return ( -
-
-
-
- -
-
- - {t("chat.progress.thinking")} - -
-
- ); - } - - // Chronological order: oldest at top, newest (active) at bottom. - // The reducer already caps progressLog at PROGRESS_LOG_MAX entries, so this - // slice is effectively a no-op but kept for defensive correctness. - const visibleSteps = progressLog.slice(-PROGRESS_LOG_MAX); - const startIndex = progressLog.length - visibleSteps.length; + const current = + progressLog.length > 0 ? progressLog[progressLog.length - 1] : null; + const label = current + ? formatProgress(current, t, resolveToolName) + : t("chat.progress.thinking"); return ( -
- {visibleSteps.map((step, i) => { - // Stable key based on absolute position in the full log — React reuses - // existing DOM elements and only mounts (and animates) new ones. - const globalIndex = startIndex + i; - const isActive = i === visibleSteps.length - 1; // last = newest = bottom - // Connector runs below every step except the active one at the bottom. - const showConnector = i < visibleSteps.length - 1; - const label = formatProgress(step, t, resolveToolName); - return ( -
-
-
- {progressStepIcon(step, resolveToolIcon, isActive)} -
- {showConnector &&
} -
- {label} -
- ); - })} +
+ + + + {label} + {current && ( + + {progressStepIcon(current, resolveToolIcon)} + + )}
); } @@ -265,8 +229,10 @@ function formatDuration(ms: number, t: TranslateFn): string { } /** - * Collapsed "Ran for X seconds" dropdown that appears above each completed - * assistant turn. Expands to show the full ordered progress log for that turn. + * Collapsed "Ran for X seconds" control above each completed assistant turn. + * Expands to a numbered list of just the tools that actually ran — generic + * progress phases (analysing, thinking, reading the document, …) are omitted. + * When no tool ran, the duration shows as a plain label with nothing to expand. */ function CompletedProgressLogDropdown({ progressLog, @@ -284,6 +250,22 @@ function CompletedProgressLogDropdown({ const [expanded, setExpanded] = useState(false); const label = formatDuration(durationMs, t); + const toolSteps = progressLog.filter( + (step) => step.phase === AiWorkflowPhase.EXECUTING_TOOL && step.tool, + ); + + // A purely conversational turn (no tools): just show the duration, nothing + // to expand. + if (toolSteps.length === 0) { + return ( +
+ + {label} + +
+ ); + } + return (
-
- {progressLog.map((step, i) => { - const showConnector = i < progressLog.length - 1; - const stepLabel = formatProgress(step, t, resolveToolName); +
    + {toolSteps.map((step, i) => { + const endpoint = step.tool ?? ""; + const name = resolveToolName(endpoint) ?? endpoint; return ( -
    -
    -
    - {progressStepIcon(step, resolveToolIcon, false)} -
    - {showConnector && ( -
    - )} -
    - {stepLabel} -
    +
  1. + {i + 1} + + {progressStepIcon(step, resolveToolIcon)} + + {name} +
  2. ); })} -
    +
); @@ -488,6 +463,10 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) { }; const showQuickActions = messages.length === 0 && !isLoading; + const disclaimerText = t( + "chat.input.disclaimer", + "AI can make mistakes. Be sure to verify the output before sharing.", + ); return ( @@ -533,8 +512,23 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
+ {showQuickActions && ( +
+ + {disclaimerText} +
+ )} + - + {messages.map((msg) => ( )} + {!showQuickActions && ( +
+ + {disclaimerText} +
+ )} +
+ handleSend()} + disabled={!input.trim() || isLoading} + aria-label={t("chat.input.send", "Send message")} + > + +