feat: add Agents UI to proprietary right sidebar (#6454)

Update UI to include agents

Run `task dev:all` to test
This commit is contained in:
EthanHealy01
2026-05-28 17:26:23 +00:00
committed by GitHub
parent 398617391b
commit 763595a5a3
47 changed files with 3394 additions and 538 deletions
@@ -8,6 +8,7 @@ import { CheckoutProvider } from "@app/contexts/CheckoutContext";
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
import { ChatProvider } from "@app/components/chat/ChatContext";
export function AppProviders({
children,
@@ -25,7 +26,7 @@ export function AppProviders({
<ServerExperienceProvider>
<CheckoutProvider>
<UpgradeBannerInitializer />
{children}
<ChatProvider>{children}</ChatProvider>
</CheckoutProvider>
</ServerExperienceProvider>
</UpdateSeatsProvider>
@@ -0,0 +1,399 @@
/* ─── Chat takeover ─────────────────────────────────────────────────────── */
.agents-takeover {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
background: var(--bg-toolbar);
z-index: 5;
view-transition-name: agents-rail;
}
/* ─── Sidebar agents section ─────────────────────────────────────────────── */
.agents-section {
padding: 0.5rem 0.75rem 0.875rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-toolbar);
flex-shrink: 0;
view-transition-name: agents-rail;
}
/* Hero button — real Stirling agent */
.agent-button {
display: block;
width: 100%;
padding: 0.75rem;
border: 1px solid
color-mix(in srgb, var(--mantine-color-blue-5) 40%, var(--border-subtle));
border-radius: 0.625rem;
background: linear-gradient(
135deg,
color-mix(
in srgb,
var(--mantine-color-blue-6) 6%,
var(--mantine-color-body)
)
0%,
var(--mantine-color-body) 100%
);
transition:
background 150ms ease-out,
border-color 150ms ease-out;
}
.agent-button:hover {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-5) 65%,
var(--border-subtle)
);
background: linear-gradient(
135deg,
color-mix(
in srgb,
var(--mantine-color-blue-6) 12%,
var(--mantine-color-default-hover)
)
0%,
var(--mantine-color-default-hover) 100%
);
}
.agent-button__logo {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
}
/* Coming-soon agent rows */
.agents-sidebar-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: 0.375rem;
}
.agent-button--coming-soon {
display: block;
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-subtle);
border-radius: 0.625rem;
background: var(--mantine-color-body);
cursor: not-allowed !important;
opacity: 0.65;
transition:
opacity 180ms ease-out,
transform 180ms ease-out,
border-color 120ms ease-out;
}
@starting-style {
.agent-button--coming-soon {
opacity: 0;
transform: translateY(5px);
}
}
.agent-button--coming-soon:hover {
opacity: 0.85;
border-color: color-mix(
in srgb,
var(--mantine-color-gray-4) 60%,
var(--border-subtle)
);
background: var(--mantine-color-default-hover);
}
.agent-button__icon-plain {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
color: var(--mantine-color-dimmed);
flex-shrink: 0;
}
.agents-view-all {
all: unset;
display: block;
width: 100%;
margin-top: 0.375rem;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--mantine-color-dimmed);
text-align: center;
cursor: pointer;
opacity: 0.7;
border-radius: 0.375rem;
transition: opacity 120ms ease-out;
box-sizing: border-box;
}
.agents-view-all:hover {
opacity: 1;
}
/* Dark mode — sidebar */
[data-mantine-color-scheme="dark"] .agent-button--coming-soon {
background: transparent;
border-color: color-mix(
in srgb,
var(--mantine-color-dark-4) 60%,
transparent
);
}
[data-mantine-color-scheme="dark"] .agent-button--coming-soon:hover {
border-color: var(--mantine-color-dark-3);
background: var(--mantine-color-dark-6);
}
[data-mantine-color-scheme="dark"] .agent-button {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-7) 55%,
var(--border-subtle)
);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--mantine-color-blue-9) 35%, transparent) 0%,
transparent 100%
);
}
[data-mantine-color-scheme="dark"] .agent-button:hover {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-5) 60%,
var(--border-subtle)
);
background: linear-gradient(
135deg,
color-mix(
in srgb,
var(--mantine-color-blue-8) 45%,
rgba(255, 255, 255, 0.04)
)
0%,
rgba(255, 255, 255, 0.04) 100%
);
}
[data-mantine-color-scheme="dark"] .agent-button__logo {
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
}
/* ─── Collapsed-rail agent button ────────────────────────────────────────── */
.agents-collapsed-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
border-radius: 0.5rem;
background: transparent;
color: var(--mantine-color-blue-filled);
cursor: pointer;
transition: background 120ms ease-out;
}
.agents-collapsed-btn:hover {
background: var(--mantine-color-default-hover);
}
/* ─── Fullscreen hero card ───────────────────────────────────────────────── */
/*
* .tool-panel__fullscreen-group--agents gives the gradient border via the
* padding-box / border-box trick. We only override the padding-box with a
* very light tint so the card doesn't read as a colourful surface.
*/
.agents-hero {
padding: 1.25rem 1.5rem 1.5rem;
border-radius: 1rem;
background:
linear-gradient(
125deg,
color-mix(in srgb, var(--mantine-color-blue-1) 35%, white) 0%,
color-mix(in srgb, var(--mantine-color-violet-1) 45%, white) 50%,
color-mix(in srgb, var(--mantine-color-pink-0) 35%, white) 100%
)
padding-box,
linear-gradient(
135deg,
var(--mantine-color-blue-6) 0%,
var(--mantine-color-violet-6) 45%,
var(--mantine-color-pink-6) 100%
)
border-box;
}
/* Body: left CTA + right grid */
.agents-hero__body {
display: flex;
gap: 2rem;
align-items: flex-start;
}
/* ── Left CTA — plain content, only the button inside is interactive ── */
.agents-hero__cta {
display: flex;
flex-direction: column;
gap: 0;
flex: 0 0 18rem;
box-sizing: border-box;
padding: 0.25rem 0;
}
.agents-hero__cta-logo {
color: var(--mantine-color-blue-filled);
margin-bottom: 0.625rem;
}
.agents-hero__cta-headline {
font-size: 1.375rem;
}
.agents-hero__cta-btn {
all: unset;
margin-top: 1.25rem;
display: inline-flex;
align-items: center;
padding: 0.4375rem 1rem;
border-radius: 0.5rem;
background: var(--mantine-color-blue-6);
color: white;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1;
width: fit-content;
cursor: pointer;
transition: background 0.15s ease;
}
.agents-hero__cta-btn:hover {
background: var(--mantine-color-blue-7);
}
.agents-hero__cta-btn:focus-visible {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: 3px;
border-radius: 0.5rem;
}
/* ── Right 2×3 grid — sizes to content, pushed to the right ── */
.agents-hero__grid {
flex: 0 0 auto;
margin-left: auto;
display: grid;
/* auto columns: each column = widest item in that column */
grid-template-columns: repeat(2, auto);
gap: 0.625rem;
align-content: start;
align-self: start;
}
/* Solid white cards — no opacity so the white is opaque */
.agents-hero__grid-item {
all: unset;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.07);
border-radius: 0.75rem;
background: var(--mantine-color-body);
cursor: not-allowed;
box-sizing: border-box;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.agents-hero__grid-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--mantine-color-blue-5);
}
.agents-hero__grid-body {
min-width: 0;
flex: 1;
}
/* ── Dark mode — fullscreen ── */
[data-mantine-color-scheme="dark"] .agents-hero {
background:
linear-gradient(
125deg,
color-mix(in srgb, var(--mantine-color-blue-9) 55%, #1a1b2e) 0%,
color-mix(in srgb, var(--mantine-color-violet-9) 45%, #1a1b2e) 50%,
color-mix(in srgb, var(--mantine-color-pink-9) 40%, #1a1b2e) 100%
)
padding-box,
linear-gradient(
135deg,
var(--mantine-color-blue-6) 0%,
var(--mantine-color-violet-6) 45%,
var(--mantine-color-pink-6) 100%
)
border-box;
}
[data-mantine-color-scheme="dark"] .agents-hero__cta-logo {
color: var(--mantine-color-blue-3);
}
[data-mantine-color-scheme="dark"] .agents-hero__grid-item {
background: var(--fullscreen-bg-item);
border-color: var(--fullscreen-border-subtle-70);
box-shadow: none;
}
[data-mantine-color-scheme="dark"] .agents-hero__grid-icon {
color: var(--mantine-color-blue-3);
}
/* ─── agents-rail view transition — slide up ─────────────────────────────── */
::view-transition-old(agents-rail) {
animation: vt-agents-out 150ms ease-out forwards;
}
::view-transition-new(agents-rail) {
animation: vt-agents-in 260ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
}
@keyframes vt-agents-out {
to {
opacity: 0;
}
}
@keyframes vt-agents-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(agents-rail),
::view-transition-new(agents-rail) {
animation-duration: 1ms !important;
}
}
@@ -0,0 +1,290 @@
import { useState } from "react";
import type { ElementType } from "react";
import { Box, Group, Text, UnstyledButton } from "@mantine/core";
import TableChartRoundedIcon from "@mui/icons-material/TableChartRounded";
import SummarizeRoundedIcon from "@mui/icons-material/SummarizeRounded";
import VisibilityOffRoundedIcon from "@mui/icons-material/VisibilityOffRounded";
import GavelRoundedIcon from "@mui/icons-material/GavelRounded";
import AssignmentRoundedIcon from "@mui/icons-material/AssignmentRounded";
import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useChat } from "@app/components/chat/ChatContext";
import { ChatPanel } from "@app/components/chat/ChatPanel";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
import { withViewTransition } from "@app/utils/viewTransition";
import "@app/components/agents/AgentsPanel.css";
interface ComingSoonAgent {
id: string;
nameKey: string;
descriptionKey: string;
Icon: ElementType;
}
const COMING_SOON_AGENTS: ComingSoonAgent[] = [
{
id: "data-extraction",
nameKey: "agents.data_extraction_name",
descriptionKey: "agents.data_extraction_description",
Icon: TableChartRoundedIcon,
},
{
id: "doc-summary",
nameKey: "agents.doc_summary_name",
descriptionKey: "agents.doc_summary_description",
Icon: SummarizeRoundedIcon,
},
{
id: "auto-redaction",
nameKey: "agents.auto_redaction_name",
descriptionKey: "agents.auto_redaction_description",
Icon: VisibilityOffRoundedIcon,
},
{
id: "compliance",
nameKey: "agents.compliance_name",
descriptionKey: "agents.compliance_description",
Icon: GavelRoundedIcon,
},
{
id: "form-filler",
nameKey: "agents.form_filler_name",
descriptionKey: "agents.form_filler_description",
Icon: AssignmentRoundedIcon,
},
{
id: "pdf-to-markdown",
nameKey: "agents.pdf_to_markdown_name",
descriptionKey: "agents.pdf_to_markdown_description",
Icon: CodeRoundedIcon,
},
];
export function useAgentsEnabled(): boolean {
const { config } = useAppConfig();
return Boolean(config?.aiEngineEnabled);
}
export function useAgentChatOpen(): boolean {
const { isOpen } = useChat();
return isOpen;
}
const PREVIEW_COUNT = 3;
/** Sidebar agents section — Stirling as hero CTA, coming-soon agents below. */
export function AgentsSection() {
const { t } = useTranslation();
const { isOpen, setOpen } = useChat();
const enabled = useAgentsEnabled();
const [showAll, setShowAll] = useState(false);
if (!enabled || isOpen) return null;
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
const visibleAgents = showAll
? COMING_SOON_AGENTS
: COMING_SOON_AGENTS.slice(0, PREVIEW_COUNT);
return (
<Box className="agents-section" w="100%">
{/* Main Stirling agent — real and clickable */}
<UnstyledButton
className="agent-button agent-button--hero"
onClick={() => withViewTransition(() => setOpen(true))}
aria-label={t("agents.stirling_name", "Stirling")}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box className="agent-button__logo">
<StirlingLogoOutline size={28} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} truncate>
{t("agents.stirling_name", "Stirling")}
</Text>
<Text size="xs" c="dimmed" truncate>
{t(
"agents.stirling_description",
"Your general-purpose PDF assistant",
)}
</Text>
</Box>
</Group>
</UnstyledButton>
{/* Coming-soon agents */}
<div className="agents-sidebar-list">
{visibleAgents.map(({ id, nameKey, descriptionKey, Icon }) => (
<AppTooltip
key={id}
content={comingSoonLabel}
position="left"
arrow
delay={0}
>
<UnstyledButton
className="agent-button agent-button--coming-soon"
aria-disabled="true"
tabIndex={-1}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box className="agent-button__icon-plain">
<Icon sx={{ fontSize: "1.1rem" }} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={500} truncate>
{t(nameKey)}
</Text>
<Text size="xs" c="dimmed" truncate>
{t(descriptionKey)}
</Text>
</Box>
</Group>
</UnstyledButton>
</AppTooltip>
))}
</div>
{!showAll ? (
<button
type="button"
className="agents-view-all"
onClick={() => withViewTransition(() => setShowAll(true))}
>
{t("agents.view_all", "View all agents")}
</button>
) : (
<button
type="button"
className="agents-view-all"
onClick={() => withViewTransition(() => setShowAll(false))}
>
{t("agents.show_less", "Show less")}
</button>
)}
</Box>
);
}
/** Icon-only agent button in the collapsed (minimised) right rail. */
export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) {
const { t } = useTranslation();
const { setOpen } = useChat();
const enabled = useAgentsEnabled();
if (!enabled) return null;
const label = t("agents.stirling_tooltip", "Stirling agent");
return (
<AppTooltip content={label} position="left" arrow delay={300}>
<UnstyledButton
onClick={() => {
onExpand();
setOpen(true);
}}
aria-label={label}
className="agents-collapsed-btn"
>
<StirlingLogoOutline size={22} />
</UnstyledButton>
</AppTooltip>
);
}
/**
* Fullscreen hero card — Stirling CTA on the left, 2×3 coming-soon grid on
* the right. Matches the gradient border of the other fullscreen category cards.
*/
export function AgentsFullscreenSection() {
const { t } = useTranslation();
const { isOpen, setOpen } = useChat();
const enabled = useAgentsEnabled();
if (!enabled || isOpen) return null;
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
return (
<section
className="agents-hero tool-panel__fullscreen-group--agents"
aria-label={t("agents.section_title", "Agents")}
>
<div className="agents-hero__body">
{/* Left: Stirling content — only the button is interactive */}
<div className="agents-hero__cta">
<div className="agents-hero__cta-logo">
<StirlingLogoOutline size={36} />
</div>
<Text className="agents-hero__cta-headline" fw={700} lh={1.2} mt="xs">
{t("agents.stirling_full_name", "Stirling General Agent")}
</Text>
<Text size="sm" c="dimmed" mt={8} lh={1.55}>
{t(
"agents.stirling_long_description",
"General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents.",
)}
</Text>
<button
type="button"
className="agents-hero__cta-btn"
onClick={() => withViewTransition(() => setOpen(true))}
>
{t("agents.start_chat", "Start chatting")}
</button>
</div>
{/* Right: 2×3 grid of coming-soon agents */}
<div className="agents-hero__grid">
{COMING_SOON_AGENTS.map(({ id, nameKey, descriptionKey, Icon }) => (
<AppTooltip
key={id}
content={comingSoonLabel}
position="top"
arrow
delay={0}
>
<button
type="button"
className="agents-hero__grid-item"
aria-disabled="true"
>
<span className="agents-hero__grid-icon">
<Icon sx={{ fontSize: "1rem" }} />
</span>
<div className="agents-hero__grid-body">
<Text size="sm" fw={500} truncate>
{t(nameKey)}
</Text>
<Text size="xs" c="dimmed" truncate>
{t(descriptionKey)}
</Text>
</div>
</button>
</AppTooltip>
))}
</div>
</div>
</section>
);
}
/** Full-rail chat overlay rendered inside ToolPanel. */
export function AgentsChatOverlay() {
const { t } = useTranslation();
const { isOpen, setOpen } = useChat();
const enabled = useAgentsEnabled();
if (!enabled || !isOpen) return null;
return (
<Box className="agents-takeover">
<ChatPanel
onBack={() => withViewTransition(() => setOpen(false))}
backLabel={t("agents.back_to_tools", "Back to tools")}
/>
</Box>
);
}
@@ -0,0 +1,21 @@
/**
* A temp stirling logo, may change in future.
*/
export function StirlingLogoOutline({ size = 20 }: { size?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 192 192"
fill="currentColor"
aria-hidden="true"
>
<path
d="M68.48 102.4 L184.73 6.45 L184.73 96.05 L68.48 192 Z"
opacity="0.5"
/>
<path d="M7.26 95.83 L123.37 0 L123.37 89.5 L7.26 185.33 Z" />
</svg>
);
}
@@ -0,0 +1,610 @@
import {
createContext,
useContext,
useReducer,
useCallback,
useRef,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { generateId } from "@app/utils/generateId";
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
import apiClient from "@app/services/apiClient";
import { getAuthHeaders } from "@app/services/apiClientSetup";
import { createChildStub } from "@app/contexts/file/fileActions";
import {
createNewStirlingFileStub,
createStirlingFile,
type StirlingFileStub,
} from "@app/types/fileContext";
import type { ToolOperation } from "@app/types/file";
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
timestamp: number;
/**
* Tool endpoint paths executed during this assistant turn (e.g.
* {@code /api/v1/general/rotate-pdf}). Populated for assistant messages when the workflow
* ran one or more tools, in execution order. Undefined for user messages and for assistant
* turns that answered without running any tool.
*/
toolsUsed?: string[];
}
export enum AiWorkflowPhase {
ANALYZING = "analyzing",
CALLING_ENGINE = "calling_engine",
EXTRACTING_CONTENT = "extracting_content",
EXECUTING_TOOL = "executing_tool",
PROCESSING = "processing",
ENGINE_PROGRESS = "engine_progress",
}
/**
* Engine-side progress detail for ENGINE_PROGRESS events. Mirrors the Python
* {@code ProgressEvent} discriminated union (engine/src/stirling/contracts/progress.py)
* and the Java {@code AiEngineProgressDetail} sealed interface; the {@code phase}
* string is the discriminator. Field names are camelCase because the engine
* serialises by alias.
*/
export interface WholeDocReadStartedDetail {
phase: "whole_doc_read_started";
question: string;
pages: number;
slices: number;
}
export interface WholeDocSliceDoneDetail {
phase: "whole_doc_slice_done";
completed: number;
total: number;
/** Page-range label, e.g. "pages=1-5". */
pages: string;
durationMs: number;
excerpts: number;
facts: number;
}
export interface WholeDocCompressionRoundDetail {
phase: "whole_doc_compression_round";
roundNumber: number;
notesIn: number;
groups: number;
}
export interface WholeDocReadDoneDetail {
phase: "whole_doc_read_done";
completed: number;
slices: number;
durationSeconds: number;
}
export type EngineProgressDetail =
| WholeDocReadStartedDetail
| WholeDocSliceDoneDetail
| WholeDocCompressionRoundDetail
| WholeDocReadDoneDetail;
/**
* What we actually carry across the wire boundary: a known typed variant, or a
* forward-compat shape with just the discriminator string. The "unknown" arm
* exists so a new engine-side phase rolling out before a frontend update keeps
* rendering the generic processing message instead of crashing the union.
*/
export interface UnknownEngineProgressDetail {
phase: string;
}
export type AnyEngineProgressDetail =
| EngineProgressDetail
| UnknownEngineProgressDetail;
const KNOWN_ENGINE_PHASES = new Set<string>([
"whole_doc_read_started",
"whole_doc_slice_done",
"whole_doc_compression_round",
"whole_doc_read_done",
]);
export function isKnownEngineProgressDetail(
detail: AnyEngineProgressDetail,
): detail is EngineProgressDetail {
return KNOWN_ENGINE_PHASES.has(detail.phase);
}
export interface AiWorkflowProgress {
phase: AiWorkflowPhase;
/** Tool endpoint path currently executing, for EXECUTING_TOOL events. */
tool?: string;
/** 1-based step index, for EXECUTING_TOOL events. */
stepIndex?: number;
/** Total number of plan steps, for EXECUTING_TOOL events. */
stepCount?: number;
/**
* Engine-side event payload, for ENGINE_PROGRESS events. Typed sub-phase
* record (e.g. {@link WholeDocSliceDoneDetail}) the UI can render in detail.
*/
engineDetail?: AnyEngineProgressDetail;
}
type AiWorkflowOutcome =
| "answer"
| "not_found"
| "need_content"
| "plan"
| "need_clarification"
| "cannot_do"
| "tool_call"
| "completed"
| "unsupported_capability"
| "cannot_continue";
interface AiWorkflowResultFile {
/** Stirling file ID — download with /api/v1/general/files/{fileId}. */
fileId: string;
fileName: string;
contentType: string;
}
interface AiWorkflowResponse {
outcome: AiWorkflowOutcome;
answer?: string;
summary?: string;
rationale?: string;
reason?: string;
question?: string;
capability?: string;
message?: string;
evidence?: Array<{ pageNumber: number; text: string }>;
steps?: Array<Record<string, unknown>>;
/** Every file produced by the workflow (empty if the outcome has no files). */
resultFiles?: AiWorkflowResultFile[];
// Legacy single-file fields — mirror the first entry of resultFiles.
fileId?: string;
fileName?: string;
contentType?: string;
}
interface ChatState {
messages: ChatMessage[];
isOpen: boolean;
isLoading: boolean;
progress: AiWorkflowProgress | null;
}
type ChatAction =
| { type: "ADD_MESSAGE"; message: ChatMessage }
| { type: "SET_LOADING"; loading: boolean }
| { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null }
| { type: "TOGGLE_OPEN" }
| { type: "SET_OPEN"; open: boolean }
| { type: "CLEAR" };
function chatReducer(state: ChatState, action: ChatAction): ChatState {
switch (action.type) {
case "ADD_MESSAGE":
return { ...state, messages: [...state.messages, action.message] };
case "SET_LOADING":
return { ...state, isLoading: action.loading };
case "SET_PROGRESS":
return { ...state, progress: action.progress };
case "TOGGLE_OPEN":
return { ...state, isOpen: !state.isOpen };
case "SET_OPEN":
return { ...state, isOpen: action.open };
case "CLEAR":
return { ...state, messages: [], isLoading: false, progress: null };
}
}
type TranslateFn = ReturnType<typeof useTranslation>["t"];
function formatWorkflowResponse(
data: AiWorkflowResponse,
t: TranslateFn,
): string {
switch (data.outcome) {
case "answer":
case "completed":
return data.answer ?? data.summary ?? t("chat.responses.done");
case "need_clarification":
return data.question ?? t("chat.responses.need_clarification");
case "cannot_do":
return data.reason ?? t("chat.responses.cannot_do");
case "not_found":
return data.reason ?? t("chat.responses.not_found");
case "unsupported_capability":
return (
data.message ??
t("chat.responses.unsupported_capability", {
capability: data.capability ?? "unknown",
})
);
case "cannot_continue":
return data.reason ?? t("chat.responses.cannot_continue");
case "plan":
return data.rationale
? `${data.rationale}\n\n${(data.steps ?? []).map((s, i) => `${i + 1}. ${JSON.stringify(s)}`).join("\n")}`
: JSON.stringify(data.steps, null, 2);
case "need_content":
case "tool_call":
return (
data.rationale ??
data.summary ??
t("chat.responses.processing", { outcome: data.outcome })
);
default:
return (
data.answer ?? data.summary ?? data.message ?? JSON.stringify(data)
);
}
}
/**
* Parses an SSE text stream and invokes callbacks for each named event.
*/
interface ProgressEvent {
phase: string;
timestamp: number;
tool?: string;
stepIndex?: number;
stepCount?: number;
engineDetail?: AnyEngineProgressDetail;
}
async function consumeSSEStream(
response: Response,
handlers: {
onProgress: (data: ProgressEvent) => void;
onResult: (data: AiWorkflowResponse) => void;
onError: (data: { message: string }) => void;
},
) {
if (!response.body) {
throw new Error("Response body is null");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let currentEvent = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by double newlines
let boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
let dataPayload = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith("data:")) {
dataPayload += line.slice(5);
}
}
if (dataPayload) {
try {
const parsed = JSON.parse(dataPayload);
if (currentEvent === "progress") {
handlers.onProgress(parsed);
} else if (currentEvent === "result") {
handlers.onResult(parsed);
} else if (currentEvent === "error") {
handlers.onError(parsed);
}
} catch {
// Skip malformed JSON frames
}
}
currentEvent = "";
boundary = buffer.indexOf("\n\n");
}
}
} finally {
reader.releaseLock();
}
}
interface ChatContextValue {
messages: ChatMessage[];
isOpen: boolean;
isLoading: boolean;
progress: AiWorkflowProgress | null;
toggleOpen: () => void;
setOpen: (open: boolean) => void;
sendMessage: (content: string) => Promise<void>;
/** Abort any in-flight request and reset the chat to an empty conversation. */
clearChat: () => void;
}
const ChatContext = createContext<ChatContextValue | null>(null);
const initialState: ChatState = {
messages: [],
isOpen: false,
isLoading: false,
progress: null,
};
export function ChatProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const [state, dispatch] = useReducer(chatReducer, initialState);
const { files: activeFiles, fileStubs: activeFileStubs } = useAllFiles();
const { actions: fileActions } = useFileActions();
const abortRef = useRef<AbortController | null>(null);
const messagesRef = useRef<ChatMessage[]>(state.messages);
messagesRef.current = state.messages;
// Download a File from the Stirling files endpoint.
const downloadFile = useCallback(
async (descriptor: AiWorkflowResultFile): Promise<File> => {
const response = await apiClient.get<Blob>(
`/api/v1/general/files/${descriptor.fileId}`,
{ responseType: "blob" },
);
return new File([response.data], descriptor.fileName, {
type: descriptor.contentType ?? response.data.type,
});
},
[],
);
// Import the files produced by an AI workflow result into FileContext.
//
// If the workflow produced the same number of outputs as inputs, map each output to its
// corresponding input as a new version in the same chain. Otherwise (merge, split, etc.)
// add the outputs as new root files.
const importResultFile = useCallback(
async (
result: AiWorkflowResponse,
sourceStubs: StirlingFileStub[],
): Promise<void> => {
const descriptors = result.resultFiles?.length
? result.resultFiles
: result.fileId && result.fileName && result.contentType
? [
{
fileId: result.fileId,
fileName: result.fileName,
contentType: result.contentType,
} satisfies AiWorkflowResultFile,
]
: [];
if (descriptors.length === 0) return;
const files = await Promise.all(descriptors.map(downloadFile));
const operation: ToolOperation = {
toolId: "ai-workflow",
timestamp: Date.now(),
};
const isVersionMapping =
sourceStubs.length > 0 && files.length === sourceStubs.length;
const stubs = files.map((file, i) =>
isVersionMapping
? createChildStub(sourceStubs[i], operation, file)
: createNewStirlingFileStub(file),
);
const stirlingFiles = files.map((file, i) =>
createStirlingFile(file, stubs[i].id),
);
if (sourceStubs.length > 0) {
// Always consume the inputs so merge/split inputs are removed from the workbench.
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
// merge/split they're fresh roots.
await fileActions.consumeFiles(
sourceStubs.map((s) => s.id),
stirlingFiles,
stubs,
);
} else {
// No inputs were provided (unlikely for completed workflows, but handle it safely).
await fileActions.addFiles(files, { selectFiles: true });
}
},
[fileActions, downloadFile],
);
const toggleOpen = useCallback(() => dispatch({ type: "TOGGLE_OPEN" }), []);
const setOpen = useCallback(
(open: boolean) => dispatch({ type: "SET_OPEN", open }),
[],
);
const clearChat = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
dispatch({ type: "CLEAR" });
}, []);
const sendMessage = useCallback(
async (content: string) => {
// Abort any in-flight request
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const priorMessages = messagesRef.current;
const userMessage: ChatMessage = {
id: generateId(),
role: "user",
content,
timestamp: Date.now(),
};
dispatch({ type: "ADD_MESSAGE", message: userMessage });
dispatch({ type: "SET_LOADING", loading: true });
dispatch({ type: "SET_PROGRESS", progress: null });
try {
const formData = new FormData();
formData.append("userMessage", content);
activeFiles.forEach((file, i) => {
formData.append(`fileInputs[${i}].fileInput`, file);
});
priorMessages.forEach((message, i) => {
formData.append(`conversationHistory[${i}].role`, message.role);
formData.append(`conversationHistory[${i}].content`, message.content);
});
const response = await fetch("/api/v1/ai/orchestrate/stream", {
method: "POST",
body: formData,
headers: getAuthHeaders(),
credentials: "include",
signal: controller.signal,
});
if (!response.ok) {
let detail: string | undefined;
try {
const body = await response.json();
detail =
body?.message ??
body?.detail ??
body?.error ??
(Array.isArray(body?.errors)
? body.errors[0]?.message
: undefined);
} catch {
// non-JSON body — ignore
}
throw new Error(
detail ?? `AI engine request failed: ${response.status}`,
);
}
let receivedResult = false;
const toolsUsed: string[] = [];
await consumeSSEStream(response, {
onProgress: (data) => {
if (
data.phase === AiWorkflowPhase.EXECUTING_TOOL &&
typeof data.tool === "string"
) {
toolsUsed.push(data.tool);
}
dispatch({
type: "SET_PROGRESS",
progress: {
phase: data.phase as AiWorkflowPhase,
tool: data.tool,
stepIndex: data.stepIndex,
stepCount: data.stepCount,
engineDetail: data.engineDetail,
},
});
},
onResult: (data) => {
receivedResult = true;
dispatch({ type: "SET_PROGRESS", progress: null });
const replyContent = formatWorkflowResponse(data, t);
dispatch({
type: "ADD_MESSAGE",
message: {
id: generateId(),
role: "assistant",
content: replyContent,
timestamp: Date.now(),
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
},
});
if (data.fileId || data.resultFiles?.length) {
importResultFile(data, activeFileStubs).catch((err) => {
console.error("Failed to import AI result file", err);
dispatch({
type: "ADD_MESSAGE",
message: {
id: generateId(),
role: "assistant",
content:
"The file was processed but I couldn't download it.",
timestamp: Date.now(),
},
});
});
}
},
onError: (data) => {
receivedResult = true;
dispatch({ type: "SET_PROGRESS", progress: null });
dispatch({
type: "ADD_MESSAGE",
message: {
id: generateId(),
role: "assistant",
content: data.message || "Something went wrong.",
timestamp: Date.now(),
},
});
},
});
if (!receivedResult) {
throw new Error("Stream ended without a result");
}
} catch (e) {
if ((e as Error).name === "AbortError") return;
const err = e as Error;
const isEngineError =
err.message.startsWith("AI engine request failed:") ||
err.message === "Stream ended without a result";
const content = isEngineError
? "Failed to get a response. The AI engine may not be available yet."
: (err.message ??
"Failed to get a response. The AI engine may not be available yet.");
dispatch({ type: "SET_PROGRESS", progress: null });
dispatch({
type: "ADD_MESSAGE",
message: {
id: generateId(),
role: "assistant",
content,
timestamp: Date.now(),
},
});
} finally {
dispatch({ type: "SET_LOADING", loading: false });
if (abortRef.current === controller) {
abortRef.current = null;
}
}
},
[activeFiles, activeFileStubs, importResultFile],
);
return (
<ChatContext.Provider
value={{
messages: state.messages,
isOpen: state.isOpen,
isLoading: state.isLoading,
progress: state.progress,
toggleOpen,
setOpen,
sendMessage,
clearChat,
}}
>
{children}
</ChatContext.Provider>
);
}
export function useChat(): ChatContextValue {
const context = useContext(ChatContext);
if (!context) {
throw new Error("useChat must be used within a ChatProvider");
}
return context;
}
@@ -0,0 +1,309 @@
.chat-panel {
display: flex;
flex-direction: column;
/* Match the right-rail toolbar background so dark-mode doesn't show a
lighter card behind the chat, and so the agent-card → pill morph
doesn't flash a different colour mid-transition. */
background: var(--bg-toolbar);
}
.chat-panel--embedded {
height: 100%;
width: 100%;
}
/* Top header: agent pill + close. */
.chat-panel__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 1rem 0.75rem;
flex-shrink: 0;
}
.chat-panel__agent-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
padding: 0.4rem 0.75rem 0.4rem 0.4rem;
border: 1px solid var(--border-subtle);
border-radius: 9999px;
background: var(--mantine-color-body);
cursor: pointer;
transition:
background 120ms ease-out,
border-color 120ms ease-out;
/* Pair with .agent-button to morph between the card and the pill. */
view-transition-name: stirling-agent;
}
/* Match the active-tool morph timing for a consistent rail feel. */
::view-transition-group(stirling-agent),
::view-transition-old(stirling-agent),
::view-transition-new(stirling-agent) {
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
.chat-panel__agent-pill:hover {
background: var(--mantine-color-default-hover);
}
.chat-panel__agent-pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
}
/* Dark-mode: let the pill blend into the rail. Just a thin border, no fill —
so it doesn't read as a clashing lighter card on the dark toolbar. */
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill {
background: transparent;
border-color: var(--border-subtle);
}
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill:hover {
background: rgba(255, 255, 255, 0.04);
}
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill-icon {
background: color-mix(
in srgb,
var(--mantine-color-blue-filled) 18%,
transparent
);
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
}
/* Same treatment for the input pill and quick-action cards. */
[data-mantine-color-scheme="dark"] .chat-panel-input {
background: transparent;
box-shadow:
0 0 0 1px var(--border-subtle),
0 6px 16px rgba(0, 0, 0, 0.25);
}
[data-mantine-color-scheme="dark"] .chat-panel-input:focus-within {
box-shadow:
0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-3) 40%, transparent),
0 8px 22px color-mix(in srgb, var(--mantine-color-blue-6) 18%, transparent);
}
[data-mantine-color-scheme="dark"] .chat-quick-action {
background: transparent;
}
[data-mantine-color-scheme="dark"] .chat-quick-action:hover {
background: rgba(255, 255, 255, 0.04);
border-color: var(--border-subtle);
}
.chat-panel__agent-pill-label {
font-size: 0.95rem;
font-weight: 600;
flex: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-panel-messages {
flex: 1;
min-height: 0;
}
/* Quick action cards above the input. */
.chat-panel__quick-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 0.75rem 0.25rem;
flex-shrink: 0;
}
.chat-panel__quick-actions-label {
font-size: 0.95rem;
font-weight: 600;
margin-top: 0.25rem;
margin-bottom: 0.1rem;
}
/* File pills shown above the quick actions when files are loaded. */
.chat-file-pills {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.chat-file-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
max-width: 11rem;
padding: 0.2rem 0.35rem 0.2rem 0.45rem;
border: 1px solid var(--mantine-color-blue-light);
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
font-size: 0.8rem;
font-weight: 500;
line-height: 1.1;
}
.chat-file-pill__icon {
flex-shrink: 0;
}
.chat-file-pill__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-file-pill__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
padding: 0;
border: none;
border-radius: 9999px;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.7;
transition:
background 120ms ease-out,
opacity 120ms ease-out;
}
.chat-file-pill__remove:hover {
background: var(--mantine-color-default-hover);
opacity: 1;
}
.chat-file-pill--more {
background: transparent;
border: 1px dashed var(--border-subtle);
color: var(--text-muted);
cursor: pointer;
padding: 0.2rem 0.55rem;
}
.chat-file-pill--more:hover {
background: var(--mantine-color-default-hover);
}
.chat-quick-action {
display: block;
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border-subtle);
border-radius: 0.65rem;
background: var(--mantine-color-body);
transition:
background 120ms ease-out,
border-color 120ms ease-out;
}
.chat-quick-action:hover {
background: var(--mantine-color-default-hover);
border-color: var(--mantine-color-default-border);
}
.chat-quick-action__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
}
/* Input area: textarea on top row, action icons on bottom row. */
.chat-panel-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.85rem 1rem 0.75rem;
margin: 0.75rem 0.75rem 1rem;
border: none;
border-radius: 1.1rem;
background: var(--mantine-color-body);
flex-shrink: 0;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.04),
0 4px 14px rgba(15, 23, 42, 0.08);
transition: box-shadow 160ms ease-out;
}
.chat-panel-input:focus-within {
box-shadow:
0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-6) 20%, transparent),
0 6px 18px color-mix(in srgb, var(--mantine-color-blue-6) 12%, transparent);
}
/* Kill the Mantine Textarea's own border/outline — the wrapper owns the chrome. */
.chat-panel-input__field,
.chat-panel-input__field:focus,
.chat-panel-input__field:focus-visible {
font-size: 0.95rem;
padding: 0.15rem 0 !important;
background: transparent !important;
border: none !important;
outline: none !important;
box-shadow: none !important;
}
.chat-panel-input__actions {
display: flex;
align-items: center;
justify-content: space-between;
}
/* Message layout */
.chat-message {
display: flex;
}
.chat-message-user {
justify-content: flex-end;
}
.chat-message-assistant {
justify-content: flex-start;
}
/* Bubble styling */
.chat-bubble {
max-width: 85%;
}
.chat-bubble-user {
background: var(--mantine-color-blue-filled) !important;
color: white !important;
}
.chat-bubble-user * {
color: white !important;
}
.chat-bubble-assistant {
background: var(--mantine-color-default-hover) !important;
}
@@ -0,0 +1,359 @@
import {
useMemo,
useRef,
useEffect,
useState,
type KeyboardEvent,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActionIcon,
Box,
Collapse,
Group,
List,
Loader,
Menu,
Paper,
ScrollArea,
Stack,
Text,
Textarea,
UnstyledButton,
} from "@mantine/core";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import CloseIcon from "@mui/icons-material/Close";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import {
useChat,
AiWorkflowPhase,
isKnownEngineProgressDetail,
type AiWorkflowProgress,
type AnyEngineProgressDetail,
} from "@app/components/chat/ChatContext";
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
import "@app/components/chat/ChatPanel.css";
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
/** Resolver mapping a tool endpoint path to its translated display name. */
type ToolNameResolver = (endpoint: string) => string | null;
/**
* Look up a tool's translated name from the tool catalog. The catalog's {@code operationConfig}
* exposes the full API endpoint path for each tool, so we key the lookup on the exact path that
* arrives in SSE progress events — no string parsing.
*/
function useToolNameResolver(): ToolNameResolver {
const { allTools } = useTranslatedToolCatalog();
return useMemo(() => {
const nameByEndpoint = new Map<string, string>();
Object.values(allTools).forEach((tool) => {
const endpoint = tool.operationConfig?.endpoint;
if (typeof endpoint === "string") {
nameByEndpoint.set(endpoint, tool.name);
}
});
return (endpoint: string) => nameByEndpoint.get(endpoint) ?? null;
}, [allTools]);
}
function formatProgress(
progress: AiWorkflowProgress,
t: TranslateFn,
resolveToolName: ToolNameResolver,
): string {
if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL && progress.tool) {
const tool = resolveToolName(progress.tool);
const hasSteps =
progress.stepIndex != null &&
progress.stepCount != null &&
progress.stepCount > 1;
if (tool) {
return hasSteps
? t("chat.progress.executing_tool_step", {
tool,
step: progress.stepIndex,
total: progress.stepCount,
})
: t("chat.progress.executing_tool_single", { tool });
}
return hasSteps
? t("chat.progress.executing_tool_generic_step", {
step: progress.stepIndex,
total: progress.stepCount,
})
: t("chat.progress.executing_tool_generic");
}
if (progress.phase === AiWorkflowPhase.ENGINE_PROGRESS) {
return formatEngineProgress(progress.engineDetail, t);
}
return t(`chat.progress.${progress.phase}`);
}
function formatEngineProgress(
detail: AnyEngineProgressDetail | undefined,
t: TranslateFn,
): string {
if (!detail || !isKnownEngineProgressDetail(detail)) {
return t("chat.progress.processing");
}
switch (detail.phase) {
case "whole_doc_read_started":
return t("chat.progress.whole_doc_read_started");
case "whole_doc_slice_done": {
const percent =
detail.total > 0
? Math.round((detail.completed / detail.total) * 100)
: 0;
return t("chat.progress.whole_doc_slice_done", { percent });
}
case "whole_doc_compression_round":
return t("chat.progress.whole_doc_compression_round");
case "whole_doc_read_done":
return t("chat.progress.whole_doc_read_done");
}
}
function ToolsUsedBlock({
tools,
resolveToolName,
t,
}: {
tools: string[];
resolveToolName: ToolNameResolver;
t: TranslateFn;
}) {
const [expanded, setExpanded] = useState(false);
const names = tools.map(
(endpoint) => resolveToolName(endpoint) ?? t("chat.toolsUsed.unknownTool"),
);
const label = t("chat.toolsUsed.summary", { count: tools.length });
return (
<Box mt={6}>
<UnstyledButton
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<Group gap={4} wrap="nowrap">
{expanded ? (
<ExpandLessIcon sx={{ fontSize: 14 }} />
) : (
<ExpandMoreIcon sx={{ fontSize: 14 }} />
)}
<Text size="xs" c="dimmed">
{label}
</Text>
</Group>
</UnstyledButton>
<Collapse in={expanded}>
<List
type="ordered"
size="xs"
mt={4}
pl="lg"
styles={{ itemWrapper: { lineHeight: 1.4 } }}
>
{names.map((name, i) => (
<List.Item key={i}>{name}</List.Item>
))}
</List>
</Collapse>
</Box>
);
}
function ChatMessageBubble({
role,
content,
toolsUsed,
resolveToolName,
t,
}: {
role: "user" | "assistant";
content: string;
toolsUsed?: string[];
resolveToolName: ToolNameResolver;
t: TranslateFn;
}) {
return (
<div className={`chat-message chat-message-${role}`}>
<Paper className={`chat-bubble chat-bubble-${role}`} p="xs" radius="md">
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{content}
</Text>
{toolsUsed && toolsUsed.length > 0 && (
<ToolsUsedBlock
tools={toolsUsed}
resolveToolName={resolveToolName}
t={t}
/>
)}
</Paper>
</div>
);
}
export interface ChatPanelProps {
/** Called when the user closes the chat to return to the tool list. */
onBack: () => void;
/** Accessible label for the close button. */
backLabel: string;
}
export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
const { t } = useTranslation();
const { messages, isLoading, progress, sendMessage, clearChat } = useChat();
const resolveToolName = useToolNameResolver();
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}
}, [messages]);
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSend = (override?: string) => {
const text = (override ?? input).trim();
if (!text || isLoading) return;
setInput("");
sendMessage(text);
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const showQuickActions = messages.length === 0 && !isLoading;
return (
<Box className="chat-panel chat-panel--embedded">
<div className="chat-panel__header">
<Menu shadow="md" width={220} position="bottom-start" withinPortal>
<Menu.Target>
<button
type="button"
className="chat-panel__agent-pill"
aria-label={t("chat.header.agentMenu", "Stirling agent options")}
>
<span className="chat-panel__agent-pill-icon">
<StirlingLogoOutline size={16} />
</span>
<span className="chat-panel__agent-pill-label">
{t("agents.stirling_name", "Stirling")}
</span>
<KeyboardArrowDownIcon
sx={{ fontSize: 18, color: "var(--text-muted)" }}
/>
</button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<DeleteSweepIcon sx={{ fontSize: 18 }} />}
onClick={clearChat}
disabled={messages.length === 0 && !isLoading}
>
{t("chat.header.clearChat", "Clear chat")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<ActionIcon
variant="subtle"
color="gray"
size="md"
radius="xl"
onClick={onBack}
aria-label={backLabel}
>
<CloseIcon sx={{ fontSize: 18 }} />
</ActionIcon>
</div>
<ScrollArea className="chat-panel-messages" viewportRef={scrollRef}>
<Stack gap="sm" p="sm">
{messages.map((msg) => (
<ChatMessageBubble
key={msg.id}
role={msg.role}
content={msg.content}
toolsUsed={msg.toolsUsed}
resolveToolName={resolveToolName}
t={t}
/>
))}
{isLoading && (
<div className="chat-message chat-message-assistant">
<Paper
className="chat-bubble chat-bubble-assistant"
p="xs"
radius="md"
>
<Group gap="xs" wrap="nowrap">
<Loader size="xs" type="dots" />
<Text size="sm" c="dimmed">
{progress
? formatProgress(progress, t, resolveToolName)
: t("chat.progress.thinking")}
</Text>
</Group>
</Paper>
</div>
)}
</Stack>
</ScrollArea>
{showQuickActions && (
<ChatQuickActions
heading={t("chat.quickActions.heading", "Get started")}
onAction={(text) => handleSend(text)}
/>
)}
<div className="chat-panel-input">
<Textarea
ref={inputRef}
placeholder={t("chat.input.placeholder", "What do you want to do?")}
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
autosize
minRows={1}
maxRows={4}
variant="unstyled"
classNames={{ 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>
);
}
@@ -0,0 +1,303 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Box, Group, Stack, Text, UnstyledButton } from "@mantine/core";
import CloseIcon from "@mui/icons-material/Close";
import CompressIcon from "@mui/icons-material/Compress";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFileOutlined";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import LayersIcon from "@mui/icons-material/Layers";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { detectFileExtension, isPdfFile } from "@app/utils/fileUtils";
import type { StirlingFileStub } from "@app/types/fileContext";
const MAX_FILE_PILLS = 3;
interface QuickAction {
key: string;
icon: React.ReactNode;
title: string;
subtitle?: string;
onClick: () => void;
}
function QuickActionCard({ action }: { action: QuickAction }) {
return (
<UnstyledButton
className="chat-quick-action"
onClick={action.onClick}
aria-label={action.title}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box className="chat-quick-action__icon">{action.icon}</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={500}>
{action.title}
</Text>
{action.subtitle && (
<Text size="xs" c="dimmed" truncate>
{action.subtitle}
</Text>
)}
</Box>
<KeyboardArrowDownIcon
sx={{
fontSize: 18,
transform: "rotate(-90deg)",
color: "var(--text-muted)",
}}
/>
</Group>
</UnstyledButton>
);
}
function WorkbenchFilePills({
stubs,
onOpenFilesModal,
onRemove,
moreLabel,
removeLabel,
}: {
stubs: StirlingFileStub[];
onOpenFilesModal: () => void;
onRemove: (id: StirlingFileStub["id"]) => void;
moreLabel: (count: number) => string;
removeLabel: (name: string) => string;
}) {
const visible = stubs.slice(0, MAX_FILE_PILLS);
const overflow = Math.max(0, stubs.length - visible.length);
return (
<div className="chat-file-pills">
{visible.map((stub) => (
<span key={stub.id} className="chat-file-pill">
<InsertDriveFileIcon
className="chat-file-pill__icon"
sx={{ fontSize: 14 }}
/>
<span className="chat-file-pill__label" title={stub.name}>
{stub.name}
</span>
<button
type="button"
className="chat-file-pill__remove"
onClick={() => onRemove(stub.id)}
aria-label={removeLabel(stub.name)}
>
<CloseIcon sx={{ fontSize: 12 }} />
</button>
</span>
))}
{overflow > 0 && (
<UnstyledButton
className="chat-file-pill chat-file-pill--more"
onClick={onOpenFilesModal}
>
{moreLabel(overflow)}
</UnstyledButton>
)}
</div>
);
}
interface WorkbenchSummary {
fileCount: number;
pdfCount: number;
nonPdfCount: number;
hasNonPdf: boolean;
singleFilePageCount: number | null;
typeBreakdown: { label: string; count: number }[];
}
function summariseWorkbench(stubs: StirlingFileStub[]): WorkbenchSummary {
const counts = new Map<string, number>();
let pdfCount = 0;
let nonPdfCount = 0;
for (const stub of stubs) {
const ext = detectFileExtension(stub.name ?? "");
const isPdf = isPdfFile({ name: stub.name, type: stub.type });
if (isPdf) pdfCount += 1;
else nonPdfCount += 1;
const label = ext ? ext.toUpperCase() : "FILE";
counts.set(label, (counts.get(label) ?? 0) + 1);
}
const typeBreakdown = Array.from(counts.entries())
.sort((a, b) => b[1] - a[1])
.map(([label, count]) => ({ label, count }));
return {
fileCount: stubs.length,
pdfCount,
nonPdfCount,
hasNonPdf: nonPdfCount > 0,
singleFilePageCount:
stubs.length === 1 ? (stubs[0].processedFile?.totalPages ?? null) : null,
typeBreakdown,
};
}
export interface ChatQuickActionsProps {
/** Heading text shown above the actions. */
heading: string;
/** Invoked when the user selects an action — sends the given text as a chat message. */
onAction: (text: string) => void;
}
export function ChatQuickActions({ heading, onAction }: ChatQuickActionsProps) {
const { t } = useTranslation();
const { fileStubs } = useAllFiles();
const { actions: fileActions } = useFileActions();
const { openFilesModal } = useFilesModalContext();
const summary = useMemo(() => summariseWorkbench(fileStubs), [fileStubs]);
const actions = useMemo<QuickAction[]>(() => {
const send = (text: string) => () => onAction(text);
if (summary.fileCount === 0) {
return [
{
key: "open-from-computer",
icon: <UploadFileIcon sx={{ fontSize: 18 }} />,
title: t("chat.quickActions.openFromComputer", "Open from computer"),
subtitle: t("chat.quickActions.browseYourFiles", "Browse your files"),
onClick: () => openFilesModal(),
},
];
}
if (summary.fileCount === 1) {
// Non-PDF: only suggest converting to PDF.
if (summary.hasNonPdf) {
const text = t(
"chat.quickActions.convertOne",
"Convert this document to PDF",
);
return [
{
key: "convert",
icon: <PictureAsPdfIcon sx={{ fontSize: 18 }} />,
title: text,
onClick: send(text),
},
];
}
const result: QuickAction[] = [];
const hasMultiplePages =
summary.singleFilePageCount != null && summary.singleFilePageCount > 1;
if (hasMultiplePages) {
const text = t("chat.quickActions.splitOne", "Split this document");
result.push({
key: "split",
icon: <ContentCutIcon sx={{ fontSize: 18 }} />,
title: text,
onClick: send(text),
});
}
const compressText = t(
"chat.quickActions.compressOne",
"Compress this document",
);
const rotateText = t(
"chat.quickActions.rotateOne",
"Rotate this document",
);
result.push({
key: "compress",
icon: <CompressIcon sx={{ fontSize: 18 }} />,
title: compressText,
onClick: send(compressText),
});
result.push({
key: "rotate",
icon: <RotateRightIcon sx={{ fontSize: 18 }} />,
title: rotateText,
onClick: send(rotateText),
});
return result;
}
// Multiple files.
const result: QuickAction[] = [];
if (summary.hasNonPdf) {
const text = t(
"chat.quickActions.convertMany",
"Convert these documents to PDF",
);
result.push({
key: "convert",
icon: <PictureAsPdfIcon sx={{ fontSize: 18 }} />,
title: text,
onClick: send(text),
});
}
const mergeText = t("chat.quickActions.mergeMany", {
count: summary.fileCount,
defaultValue: "Merge these {{count}} documents into 1",
});
const compressText = t(
"chat.quickActions.compressMany",
"Compress these documents",
);
const rotateText = t(
"chat.quickActions.rotateMany",
"Rotate these documents",
);
result.push({
key: "merge",
icon: <LayersIcon sx={{ fontSize: 18 }} />,
title: mergeText,
onClick: send(mergeText),
});
result.push({
key: "compress",
icon: <CompressIcon sx={{ fontSize: 18 }} />,
title: compressText,
onClick: send(compressText),
});
result.push({
key: "rotate",
icon: <RotateRightIcon sx={{ fontSize: 18 }} />,
title: rotateText,
onClick: send(rotateText),
});
return result;
}, [summary, t, onAction, openFilesModal]);
return (
<div className="chat-panel__quick-actions">
{summary.fileCount > 0 && (
<WorkbenchFilePills
stubs={fileStubs}
onOpenFilesModal={() => openFilesModal()}
onRemove={(id) => fileActions.removeFiles([id])}
moreLabel={(count) =>
t("chat.quickActions.moreFiles", {
count,
defaultValue: "+{{count}} more",
})
}
removeLabel={(name) =>
t("chat.quickActions.removeFile", {
name,
defaultValue: "Remove {{name}}",
})
}
/>
)}
<Text className="chat-panel__quick-actions-label">{heading}</Text>
<Stack gap="xs">
{actions.map((action) => (
<QuickActionCard key={action.key} action={action} />
))}
</Stack>
</div>
);
}