add agent warning and update style (#6629)

This commit is contained in:
EthanHealy01
2026-06-11 21:55:51 +01:00
committed by GitHub
parent eddc54c6c0
commit b756b5befb
5 changed files with 291 additions and 218 deletions
@@ -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..."
@@ -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);
@@ -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;
}
}
@@ -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 <StirlingLogoAnimated size={18} />;
}
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 (
<div className="chat-progress-log">
<div className="chat-progress-step chat-progress-step--active">
<div className="chat-progress-step__left">
<div className="chat-progress-step__icon">
<StirlingLogoAnimated size={18} />
</div>
</div>
<span className="chat-progress-step__label">
{t("chat.progress.thinking")}
</span>
</div>
</div>
);
}
// 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 (
<div className="chat-progress-log">
{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 (
<div
key={globalIndex}
className={`chat-progress-step${isActive ? " chat-progress-step--active" : ""}`}
>
<div className="chat-progress-step__left">
<div className="chat-progress-step__icon">
{progressStepIcon(step, resolveToolIcon, isActive)}
</div>
{showConnector && <div className="chat-progress-step__line" />}
</div>
<span className="chat-progress-step__label">{label}</span>
</div>
);
})}
<div className="chat-progress-live">
<span className="chat-progress-live__logo">
<StirlingLogoAnimated size={18} />
</span>
<span className="chat-progress-live__label">{label}</span>
{current && (
<span className="chat-progress-live__phase-icon">
{progressStepIcon(current, resolveToolIcon)}
</span>
)}
</div>
);
}
@@ -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 (
<div className="chat-completed-log">
<Text size="xs" c="dimmed" className="chat-completed-log__static">
{label}
</Text>
</div>
);
}
return (
<div className="chat-completed-log">
<UnstyledButton
@@ -303,28 +285,21 @@ function CompletedProgressLogDropdown({
</Group>
</UnstyledButton>
<Collapse in={expanded}>
<div className="chat-completed-log__steps">
{progressLog.map((step, i) => {
const showConnector = i < progressLog.length - 1;
const stepLabel = formatProgress(step, t, resolveToolName);
<ol className="chat-completed-log__tools">
{toolSteps.map((step, i) => {
const endpoint = step.tool ?? "";
const name = resolveToolName(endpoint) ?? endpoint;
return (
<div
key={i}
className="chat-progress-step chat-progress-step--done"
>
<div className="chat-progress-step__left">
<div className="chat-progress-step__icon">
{progressStepIcon(step, resolveToolIcon, false)}
</div>
{showConnector && (
<div className="chat-progress-step__line" />
)}
</div>
<span className="chat-progress-step__label">{stepLabel}</span>
</div>
<li key={i} className="chat-completed-log__tool">
<span className="chat-completed-log__tool-num">{i + 1}</span>
<span className="chat-completed-log__tool-icon">
{progressStepIcon(step, resolveToolIcon)}
</span>
<span className="chat-completed-log__tool-name">{name}</span>
</li>
);
})}
</div>
</ol>
</Collapse>
</div>
);
@@ -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 (
<Box className="chat-panel chat-panel--embedded">
@@ -533,8 +512,23 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
</ActionIcon>
</div>
{showQuickActions && (
<div className="chat-panel-disclaimer chat-panel-disclaimer--banner">
<InfoOutlinedIcon
className="chat-panel-disclaimer__icon"
sx={{ fontSize: 18 }}
/>
<span>{disclaimerText}</span>
</div>
)}
<ScrollArea className="chat-panel-messages" viewportRef={scrollRef}>
<Stack gap="sm" px="md" py="sm">
<Stack
gap="sm"
px="md"
pt="sm"
className="chat-panel-messages__content"
>
{messages.map((msg) => (
<ChatMessageBubble
key={msg.id}
@@ -568,7 +562,29 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
/>
)}
{!showQuickActions && (
<div className="chat-panel-disclaimer chat-panel-disclaimer--inline">
<InfoOutlinedIcon
className="chat-panel-disclaimer__icon"
sx={{ fontSize: 13 }}
/>
<span>{disclaimerText}</span>
</div>
)}
<div className="chat-panel-input">
<ActionIcon
className="chat-panel-input__send"
variant="filled"
color="blue"
radius="xl"
size="sm"
onClick={() => handleSend()}
disabled={!input.trim() || isLoading}
aria-label={t("chat.input.send", "Send message")}
>
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
</ActionIcon>
<Textarea
ref={inputRef}
placeholder={t("chat.input.placeholder", "What do you want to do?")}
@@ -580,21 +596,11 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
minRows={1}
maxRows={4}
variant="unstyled"
classNames={{ input: "chat-panel-input__field" }}
classNames={{
root: "chat-panel-input__textarea",
input: "chat-panel-input__field",
}}
/>
<div className="chat-panel-input__actions">
<ActionIcon
variant="filled"
color="blue"
radius="xl"
size="md"
onClick={() => handleSend()}
disabled={!input.trim() || isLoading}
aria-label={t("chat.input.send", "Send message")}
>
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</div>
</div>
</Box>
);
@@ -160,7 +160,10 @@ describe("handlePaygError — usage-limit modals", () => {
it("FEATURE_DEGRADED + unsubscribed → opens the free-limit modal (no spend-cap)", () => {
handlePaygError("FEATURE_DEGRADED", {
response: { status: 402, data: { error: "FEATURE_DEGRADED", subscribed: false } },
response: {
status: 402,
data: { error: "FEATURE_DEGRADED", subscribed: false },
},
});
expect(freeOpened).toBe(1);
expect(spendOpened).toBe(0);
@@ -168,7 +171,10 @@ describe("handlePaygError — usage-limit modals", () => {
it("FEATURE_DEGRADED + subscribed → opens the spend-cap modal", () => {
handlePaygError("FEATURE_DEGRADED", {
response: { status: 402, data: { error: "FEATURE_DEGRADED", subscribed: true } },
response: {
status: 402,
data: { error: "FEATURE_DEGRADED", subscribed: true },
},
});
expect(spendOpened).toBe(1);
expect(freeOpened).toBe(0);
@@ -176,7 +182,10 @@ describe("handlePaygError — usage-limit modals", () => {
it("PAYG_LIMIT_REACHED + subscribed → opens the spend-cap modal", () => {
handlePaygError("PAYG_LIMIT_REACHED", {
response: { status: 402, data: { error: "PAYG_LIMIT_REACHED", subscribed: true } },
response: {
status: 402,
data: { error: "PAYG_LIMIT_REACHED", subscribed: true },
},
});
expect(spendOpened).toBe(1);
expect(freeOpened).toBe(0);
@@ -184,7 +193,10 @@ describe("handlePaygError — usage-limit modals", () => {
it("PAYG_LIMIT_REACHED + unsubscribed → opens the free-limit modal", () => {
handlePaygError("PAYG_LIMIT_REACHED", {
response: { status: 402, data: { error: "PAYG_LIMIT_REACHED", subscribed: false } },
response: {
status: 402,
data: { error: "PAYG_LIMIT_REACHED", subscribed: false },
},
});
expect(freeOpened).toBe(1);
expect(spendOpened).toBe(0);