mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
add agent warning and update style (#6629)
This commit is contained in:
@@ -2695,6 +2695,7 @@ clearChat = "Clear chat"
|
|||||||
[chat.input]
|
[chat.input]
|
||||||
placeholder = "What do you want to do?"
|
placeholder = "What do you want to do?"
|
||||||
send = "Send message"
|
send = "Send message"
|
||||||
|
disclaimer = "AI can make mistakes. Be sure to verify the output before sharing."
|
||||||
|
|
||||||
[chat.progress]
|
[chat.progress]
|
||||||
analyzing = "Analysing your request..."
|
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.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 }) => {
|
test("admin can reach the setup wizard", async ({ page }) => {
|
||||||
await openSecurityPolicy(page);
|
await openSecurityPolicy(page);
|
||||||
|
|||||||
@@ -165,6 +165,16 @@
|
|||||||
.chat-panel-messages {
|
.chat-panel-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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. */
|
/* Quick action cards above the input. */
|
||||||
@@ -282,12 +292,14 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.chat-panel-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.85rem 1rem 0.75rem;
|
padding: 0.7rem 0.8rem;
|
||||||
margin: 0.75rem 0.75rem 1rem;
|
margin: 0.75rem 0.75rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 1.1rem;
|
border-radius: 1.1rem;
|
||||||
@@ -309,7 +321,7 @@
|
|||||||
.chat-panel-input__field,
|
.chat-panel-input__field,
|
||||||
.chat-panel-input__field:focus,
|
.chat-panel-input__field:focus,
|
||||||
.chat-panel-input__field:focus-visible {
|
.chat-panel-input__field:focus-visible {
|
||||||
font-size: 0.95rem;
|
font-size: 1.05rem;
|
||||||
padding: 0.15rem 0 !important;
|
padding: 0.15rem 0 !important;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
@@ -317,10 +329,49 @@
|
|||||||
box-shadow: none !important;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
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 */
|
/* Message layout */
|
||||||
@@ -425,109 +476,88 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Progress step log ─────────────────────────────────────────────────── */
|
/* ─── Live progress indicator ──────────────────────────────────────────── */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Shown while the AI is working. Each step slides in from below as events
|
* Shown while the AI works — one step at a time, no growing list. Our animated
|
||||||
* arrive; the active (last) step is full-opacity with a pulsing icon, and
|
* logo sits on the left, the current step's label shimmers in the middle, and
|
||||||
* earlier completed steps are dimmed. Only the most-recent N steps are
|
* the phase-specific icon (what it's doing right now) sits on the right.
|
||||||
* rendered — there is no scrollable list.
|
|
||||||
*/
|
*/
|
||||||
|
.chat-progress-live {
|
||||||
.chat-progress-log {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
padding: 0.15rem 0.5rem 0.35rem;
|
gap: 0.6rem;
|
||||||
/* No gap — the connector line div provides the inter-step spacing. */
|
width: 100%;
|
||||||
|
padding: 0.2rem 0.5rem 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-progress-step {
|
.chat-progress-live__logo {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 0.65rem;
|
justify-content: center;
|
||||||
/*
|
flex-shrink: 0;
|
||||||
* 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 {
|
|
||||||
color: var(--mantine-color-blue-filled);
|
color: var(--mantine-color-blue-filled);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-progress-step--active .chat-progress-step__label {
|
/* Shimmer: a soft highlight sweeps left-to-right across the muted label. */
|
||||||
color: var(--mantine-color-text);
|
.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-live__phase-icon {
|
||||||
.chat-progress-step--active
|
display: inline-flex;
|
||||||
.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;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
color: var(--text-muted);
|
||||||
/* Inherits color from .chat-progress-step so currentColor flows into icons. */
|
animation: chat-phase-pulse 1.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
@keyframes chat-shimmer {
|
||||||
* Connector line between steps. Not a child of the label so it is unaffected
|
from {
|
||||||
* by the step's color; it sits on its own and reads clearly against the
|
background-position: 200% 0;
|
||||||
* toolbar background.
|
}
|
||||||
*/
|
to {
|
||||||
.chat-progress-step__line {
|
background-position: -200% 0;
|
||||||
width: 1px;
|
}
|
||||||
height: 18px;
|
|
||||||
background: var(--border-subtle);
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active step label — slightly larger, inherits the active color. */
|
@keyframes chat-phase-pulse {
|
||||||
.chat-progress-step__label {
|
0%,
|
||||||
font-size: 0.875rem;
|
100% {
|
||||||
padding-top: 3px;
|
opacity: 0.5;
|
||||||
line-height: 1.35;
|
}
|
||||||
}
|
50% {
|
||||||
|
opacity: 1;
|
||||||
/* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Wrapper that scales a registry tool icon (LocalIcon, typically 1.5rem/24px)
|
* 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
|
* down to fit the compact icon slots used by the live indicator and the
|
||||||
* parent overflow:hidden clips the un-scaled footprint cleanly.
|
* completed tool list. Transform does not affect layout, so the parent's
|
||||||
|
* overflow:hidden clips the un-scaled footprint cleanly.
|
||||||
*/
|
*/
|
||||||
.chat-step-icon-scaled {
|
.chat-step-icon-scaled {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -538,22 +568,12 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Entry: steps slide up from a few pixels below their final position. */
|
/* ─── Completed tool breakdown ─────────────────────────────────────────── */
|
||||||
@keyframes chat-step-enter {
|
|
||||||
from {
|
|
||||||
transform: translateY(7px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Completed progress log dropdown ──────────────────────────────────── */
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Shown above each completed assistant turn. The toggle displays a small
|
* Shown above each completed assistant turn. The toggle displays a small
|
||||||
* "Ran for X seconds" label; expanding reveals the full ordered step list
|
* "Ran for X seconds" label; expanding reveals a numbered list of just the
|
||||||
* for that turn in a compact, read-only format.
|
* tools that ran. Turns with no tools show the duration as a plain label.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.chat-completed-log {
|
.chat-completed-log {
|
||||||
@@ -573,31 +593,56 @@
|
|||||||
background: var(--mantine-color-default-hover);
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.35rem 0.4rem 0.2rem 0.25rem;
|
gap: 0.1rem;
|
||||||
margin-top: 0.2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* In the completed log every step is a past step — no active highlighting,
|
.chat-completed-log__tool {
|
||||||
no entry animation (the content is static once opened). */
|
display: flex;
|
||||||
.chat-completed-log__steps .chat-progress-step {
|
align-items: center;
|
||||||
animation: none;
|
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__tool-num {
|
||||||
.chat-completed-log__steps .chat-progress-step__icon {
|
flex-shrink: 0;
|
||||||
height: 20px;
|
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 {
|
.chat-completed-log__tool-icon {
|
||||||
height: 14px;
|
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 {
|
.chat-completed-log__tool-name {
|
||||||
font-size: 0.775rem;
|
flex: 1;
|
||||||
padding-top: 2px;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Legacy thinking indicator (kept in case any other code references it). */
|
/* Legacy thinking indicator (kept in case any other code references it). */
|
||||||
@@ -610,9 +655,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.chat-progress-step {
|
.chat-progress-live__label {
|
||||||
animation: none;
|
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 DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
||||||
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
AiWorkflowPhase,
|
AiWorkflowPhase,
|
||||||
ChatRole,
|
ChatRole,
|
||||||
PROGRESS_LOG_MAX,
|
|
||||||
isKnownEngineProgressDetail,
|
isKnownEngineProgressDetail,
|
||||||
type AiWorkflowProgress,
|
type AiWorkflowProgress,
|
||||||
type AnyEngineProgressDetail,
|
type AnyEngineProgressDetail,
|
||||||
@@ -151,20 +151,15 @@ function formatEngineProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Choose an icon for a progress step.
|
* 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
|
||||||
* The active (current) step always shows the animated Stirling logo so it reads
|
* right-hand "what it's doing" icon in the live indicator and for each row of
|
||||||
* as the "live" indicator. Past steps get a phase-specific icon so the trail
|
* the completed tool breakdown.
|
||||||
* is scannable at a glance.
|
|
||||||
*/
|
*/
|
||||||
function progressStepIcon(
|
function progressStepIcon(
|
||||||
progress: AiWorkflowProgress,
|
progress: AiWorkflowProgress,
|
||||||
resolveToolIcon: ToolIconResolver,
|
resolveToolIcon: ToolIconResolver,
|
||||||
isActive: boolean,
|
|
||||||
): ReactNode {
|
): ReactNode {
|
||||||
if (isActive) {
|
|
||||||
return <StirlingLogoAnimated size={18} />;
|
|
||||||
}
|
|
||||||
if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL) {
|
if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL) {
|
||||||
const registryIcon = progress.tool ? resolveToolIcon(progress.tool) : null;
|
const registryIcon = progress.tool ? resolveToolIcon(progress.tool) : null;
|
||||||
if (registryIcon) {
|
if (registryIcon) {
|
||||||
@@ -182,9 +177,10 @@ function progressStepIcon(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Animated step-by-step progress log shown while the AI is working.
|
* Live progress indicator shown while the AI is working. One step at a time:
|
||||||
* Displays the last {@link PROGRESS_LOG_VISIBLE} steps from the live event stream,
|
* our animated logo on the left, the current step's label shimmering in the
|
||||||
* with the active (most recent) step highlighted and older steps dimmed.
|
* 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({
|
function ProgressLogDisplay({
|
||||||
progressLog,
|
progressLog,
|
||||||
@@ -197,55 +193,23 @@ function ProgressLogDisplay({
|
|||||||
resolveToolName: ToolNameResolver;
|
resolveToolName: ToolNameResolver;
|
||||||
resolveToolIcon: ToolIconResolver;
|
resolveToolIcon: ToolIconResolver;
|
||||||
}) {
|
}) {
|
||||||
// Placeholder shown before the first SSE event arrives.
|
const current =
|
||||||
if (progressLog.length === 0) {
|
progressLog.length > 0 ? progressLog[progressLog.length - 1] : null;
|
||||||
|
const label = current
|
||||||
|
? formatProgress(current, t, resolveToolName)
|
||||||
|
: t("chat.progress.thinking");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-progress-log">
|
<div className="chat-progress-live">
|
||||||
<div className="chat-progress-step chat-progress-step--active">
|
<span className="chat-progress-live__logo">
|
||||||
<div className="chat-progress-step__left">
|
|
||||||
<div className="chat-progress-step__icon">
|
|
||||||
<StirlingLogoAnimated size={18} />
|
<StirlingLogoAnimated size={18} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="chat-progress-step__label">
|
|
||||||
{t("chat.progress.thinking")}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="chat-progress-live__label">{label}</span>
|
||||||
</div>
|
{current && (
|
||||||
);
|
<span className="chat-progress-live__phase-icon">
|
||||||
}
|
{progressStepIcon(current, resolveToolIcon)}
|
||||||
|
</span>
|
||||||
// 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;
|
|
||||||
|
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -265,8 +229,10 @@ function formatDuration(ms: number, t: TranslateFn): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapsed "Ran for X seconds" dropdown that appears above each completed
|
* Collapsed "Ran for X seconds" control above each completed assistant turn.
|
||||||
* assistant turn. Expands to show the full ordered progress log for that 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({
|
function CompletedProgressLogDropdown({
|
||||||
progressLog,
|
progressLog,
|
||||||
@@ -284,6 +250,22 @@ function CompletedProgressLogDropdown({
|
|||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const label = formatDuration(durationMs, t);
|
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 (
|
return (
|
||||||
<div className="chat-completed-log">
|
<div className="chat-completed-log">
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
@@ -303,28 +285,21 @@ function CompletedProgressLogDropdown({
|
|||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<Collapse in={expanded}>
|
<Collapse in={expanded}>
|
||||||
<div className="chat-completed-log__steps">
|
<ol className="chat-completed-log__tools">
|
||||||
{progressLog.map((step, i) => {
|
{toolSteps.map((step, i) => {
|
||||||
const showConnector = i < progressLog.length - 1;
|
const endpoint = step.tool ?? "";
|
||||||
const stepLabel = formatProgress(step, t, resolveToolName);
|
const name = resolveToolName(endpoint) ?? endpoint;
|
||||||
return (
|
return (
|
||||||
<div
|
<li key={i} className="chat-completed-log__tool">
|
||||||
key={i}
|
<span className="chat-completed-log__tool-num">{i + 1}</span>
|
||||||
className="chat-progress-step chat-progress-step--done"
|
<span className="chat-completed-log__tool-icon">
|
||||||
>
|
{progressStepIcon(step, resolveToolIcon)}
|
||||||
<div className="chat-progress-step__left">
|
</span>
|
||||||
<div className="chat-progress-step__icon">
|
<span className="chat-completed-log__tool-name">{name}</span>
|
||||||
{progressStepIcon(step, resolveToolIcon, false)}
|
</li>
|
||||||
</div>
|
|
||||||
{showConnector && (
|
|
||||||
<div className="chat-progress-step__line" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="chat-progress-step__label">{stepLabel}</span>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ol>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -488,6 +463,10 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showQuickActions = messages.length === 0 && !isLoading;
|
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 (
|
return (
|
||||||
<Box className="chat-panel chat-panel--embedded">
|
<Box className="chat-panel chat-panel--embedded">
|
||||||
@@ -533,8 +512,23 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</div>
|
</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}>
|
<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) => (
|
{messages.map((msg) => (
|
||||||
<ChatMessageBubble
|
<ChatMessageBubble
|
||||||
key={msg.id}
|
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">
|
<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
|
<Textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={t("chat.input.placeholder", "What do you want to do?")}
|
placeholder={t("chat.input.placeholder", "What do you want to do?")}
|
||||||
@@ -580,21 +596,11 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
|||||||
minRows={1}
|
minRows={1}
|
||||||
maxRows={4}
|
maxRows={4}
|
||||||
variant="unstyled"
|
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>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -160,7 +160,10 @@ describe("handlePaygError — usage-limit modals", () => {
|
|||||||
|
|
||||||
it("FEATURE_DEGRADED + unsubscribed → opens the free-limit modal (no spend-cap)", () => {
|
it("FEATURE_DEGRADED + unsubscribed → opens the free-limit modal (no spend-cap)", () => {
|
||||||
handlePaygError("FEATURE_DEGRADED", {
|
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(freeOpened).toBe(1);
|
||||||
expect(spendOpened).toBe(0);
|
expect(spendOpened).toBe(0);
|
||||||
@@ -168,7 +171,10 @@ describe("handlePaygError — usage-limit modals", () => {
|
|||||||
|
|
||||||
it("FEATURE_DEGRADED + subscribed → opens the spend-cap modal", () => {
|
it("FEATURE_DEGRADED + subscribed → opens the spend-cap modal", () => {
|
||||||
handlePaygError("FEATURE_DEGRADED", {
|
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(spendOpened).toBe(1);
|
||||||
expect(freeOpened).toBe(0);
|
expect(freeOpened).toBe(0);
|
||||||
@@ -176,7 +182,10 @@ describe("handlePaygError — usage-limit modals", () => {
|
|||||||
|
|
||||||
it("PAYG_LIMIT_REACHED + subscribed → opens the spend-cap modal", () => {
|
it("PAYG_LIMIT_REACHED + subscribed → opens the spend-cap modal", () => {
|
||||||
handlePaygError("PAYG_LIMIT_REACHED", {
|
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(spendOpened).toBe(1);
|
||||||
expect(freeOpened).toBe(0);
|
expect(freeOpened).toBe(0);
|
||||||
@@ -184,7 +193,10 @@ describe("handlePaygError — usage-limit modals", () => {
|
|||||||
|
|
||||||
it("PAYG_LIMIT_REACHED + unsubscribed → opens the free-limit modal", () => {
|
it("PAYG_LIMIT_REACHED + unsubscribed → opens the free-limit modal", () => {
|
||||||
handlePaygError("PAYG_LIMIT_REACHED", {
|
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(freeOpened).toBe(1);
|
||||||
expect(spendOpened).toBe(0);
|
expect(spendOpened).toBe(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user