Kill off agents pane now that we have FAB (#6613)

# Description of Changes
Kill off agents pane now that we have the FAB. Also fixes a bug with the
FAB where it would sometimes fail to render the chat, and fixes a
duplicated entry in the Vite config which was throwing a warning
This commit is contained in:
James Brunton
2026-06-11 17:28:05 +01:00
committed by GitHub
parent 5bc7ae626d
commit 34ead60194
12 changed files with 58 additions and 1006 deletions
@@ -1,43 +0,0 @@
/**
* Core stubs for the right-rail Agents UI.
*
* The real implementations live in {@code proprietary/components/agents/AgentsPanel.tsx}
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
* build is active. Core builds render nothing, so the right rail collapses to the
* tool list unchanged.
*/
/** Whether the right rail should reserve space for agents UI. False in core. */
export function useAgentsEnabled(): boolean {
return false;
}
/**
* Whether the agent chat panel is currently open. Core builds have no chat,
* so this always returns false. Proprietary builds bridge to the ChatContext.
*/
export function useAgentChatOpen(): boolean {
return false;
}
/** Inline "Agents" section rendered above the tool list in {@code ToolPicker}. */
export function AgentsSection() {
return null;
}
/**
* Icon-only agent button rendered in the collapsed (minimised) right rail.
* Returns null in core; proprietary renders the Stirling agent shortcut.
*/
export function AgentsCollapsedButton(_props: { onExpand: () => void }) {
return null;
}
/**
* Agents card rendered inside the fullscreen tool picker. Matches the visual
* language of the fullscreen category cards (gradient border, title, items).
* Returns null in core; proprietary renders the Stirling agent.
*/
export function AgentsFullscreenSection() {
return null;
}
@@ -7,12 +7,9 @@
export function useChat() { export function useChat() {
return { return {
messages: [] as never[], messages: [] as never[],
isOpen: false,
isLoading: false, isLoading: false,
progress: null, progress: null,
progressLog: [] as never[], progressLog: [] as never[],
toggleOpen: () => {},
setOpen: (_open: boolean) => {},
sendMessage: async (_content: string) => {}, sendMessage: async (_content: string) => {},
clearChat: () => {}, clearChat: () => {},
}; };
@@ -3,11 +3,6 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useIsMobile } from "@app/hooks/useIsMobile"; import { useIsMobile } from "@app/hooks/useIsMobile";
import { usePreferences } from "@app/contexts/PreferencesContext"; import { usePreferences } from "@app/contexts/PreferencesContext";
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext"; import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
import {
AgentsFullscreenSection,
useAgentChatOpen,
useAgentsEnabled,
} from "@app/components/agents/AgentsPanel";
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface"; import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
import { ToolId } from "@app/types/toolId"; import { ToolId } from "@app/types/toolId";
import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry"; import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
@@ -16,13 +11,11 @@ import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
export function useIsFullscreenExpanded(): boolean { export function useIsFullscreenExpanded(): boolean {
const { toolPanelMode, leftPanelView, readerMode } = useToolWorkflow(); const { toolPanelMode, leftPanelView, readerMode } = useToolWorkflow();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const agentChatOpen = useAgentChatOpen();
return ( return (
toolPanelMode === "fullscreen" && toolPanelMode === "fullscreen" &&
leftPanelView === "toolPicker" && leftPanelView === "toolPicker" &&
!isMobile && !isMobile &&
!readerMode && !readerMode
!agentChatOpen
); );
} }
@@ -50,8 +43,6 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
handleToolSelect, handleToolSelect,
} = useToolWorkflow(); } = useToolWorkflow();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const agentsEnabled = useAgentsEnabled();
const agentChatOpen = useAgentChatOpen();
const { setAllButtonsDisabled } = useWorkbenchBar(); const { setAllButtonsDisabled } = useWorkbenchBar();
const { preferences, updatePreference } = usePreferences(); const { preferences, updatePreference } = usePreferences();
@@ -59,8 +50,7 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
toolPanelMode === "fullscreen" && toolPanelMode === "fullscreen" &&
leftPanelView === "toolPicker" && leftPanelView === "toolPicker" &&
!isMobile && !isMobile &&
!readerMode && !readerMode;
!agentChatOpen;
useEffect(() => { useEffect(() => {
setAllButtonsDisabled(fullscreenExpanded); setAllButtonsDisabled(fullscreenExpanded);
@@ -94,9 +84,6 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
} }
onExitFullscreenMode={() => setToolPanelMode("sidebar")} onExitFullscreenMode={() => setToolPanelMode("sidebar")}
geometry={geometry} geometry={geometry}
agentsSlot={
agentsEnabled && !searchQuery ? <AgentsFullscreenSection /> : null
}
/> />
); );
} }
@@ -25,8 +25,6 @@ interface FullscreenToolSurfaceProps {
onToggleDescriptions: () => void; onToggleDescriptions: () => void;
onExitFullscreenMode: () => void; onExitFullscreenMode: () => void;
geometry: ToolPanelGeometry | null; geometry: ToolPanelGeometry | null;
/** Optional agents block rendered above the tool list. */
agentsSlot?: React.ReactNode;
} }
const FullscreenToolSurface = ({ const FullscreenToolSurface = ({
@@ -41,7 +39,6 @@ const FullscreenToolSurface = ({
onToggleDescriptions, onToggleDescriptions,
onExitFullscreenMode: _onExitFullscreenMode, onExitFullscreenMode: _onExitFullscreenMode,
geometry, geometry,
agentsSlot,
}: FullscreenToolSurfaceProps) => { }: FullscreenToolSurfaceProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const surfaceRef = useRef<HTMLDivElement>(null); const surfaceRef = useRef<HTMLDivElement>(null);
@@ -92,9 +89,6 @@ const FullscreenToolSurface = ({
className="tool-panel__fullscreen-scroll" className="tool-panel__fullscreen-scroll"
offsetScrollbars offsetScrollbars
> >
{agentsSlot && (
<div className="tool-panel__fullscreen-agents">{agentsSlot}</div>
)}
<FullscreenToolList <FullscreenToolList
filteredTools={filteredTools} filteredTools={filteredTools}
searchQuery={searchQuery} searchQuery={searchQuery}
@@ -1,4 +1,4 @@
import { useMemo, useState, useRef, useCallback } from "react"; import { useMemo, useState } from "react";
import { ActionIcon } from "@mantine/core"; import { ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
@@ -8,11 +8,6 @@ import rainbowStyles from "@app/styles/rainbow.module.css";
import { useIsMobile } from "@app/hooks/useIsMobile"; import { useIsMobile } from "@app/hooks/useIsMobile";
import ToolPanel from "@app/components/tools/ToolPanel"; import ToolPanel from "@app/components/tools/ToolPanel";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch"; import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import {
AgentsCollapsedButton,
AgentsSection,
useAgentsEnabled,
} from "@app/components/agents/AgentsPanel";
import { import {
PoliciesCollapsedButton, PoliciesCollapsedButton,
PoliciesSection, PoliciesSection,
@@ -21,8 +16,6 @@ import {
usePolicyDetailActive, usePolicyDetailActive,
} from "@app/components/policies/PoliciesSidebar"; } from "@app/components/policies/PoliciesSidebar";
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController"; import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
import { useChat } from "@app/components/chat/ChatContext";
import { ChatPanel } from "@app/components/chat/ChatPanel";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems"; import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import { useToolSections } from "@app/hooks/useToolSections"; import { useToolSections } from "@app/hooks/useToolSections";
import type { SubcategoryGroup } from "@app/hooks/useToolSections"; import type { SubcategoryGroup } from "@app/hooks/useToolSections";
@@ -41,16 +34,12 @@ import {
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry"; import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
import "@app/components/tools/ToolPanel.css"; import "@app/components/tools/ToolPanel.css";
const DEFAULT_CHAT_WIDTH_PX = 18.5 * 16; // 18.5rem in px
const MIN_CHAT_WIDTH_PX = 240;
const MAX_CHAT_WIDTH_PX = 720;
/** /**
* Right-side rail wrapping the tool panel. * Right-side rail wrapping the tool panel.
* *
* Owns the rail-level concerns: collapse/expand chrome, the AGENTS top label, * Owns the rail-level concerns: collapse/expand chrome and the collapsed strip
* the collapsed strip (agent button + favourite/recommended icon shortcuts), * (favourite/recommended icon shortcuts). Fullscreen takeover lives in
* and the agents chat overlay. Fullscreen takeover lives in FullscreenToolPanel. * FullscreenToolPanel.
*/ */
export default function RightSidebar() { export default function RightSidebar() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -77,7 +66,6 @@ export default function RightSidebar() {
favoriteTools, favoriteTools,
} = useToolWorkflow(); } = useToolWorkflow();
const agentsEnabled = useAgentsEnabled();
const policiesEnabled = usePoliciesEnabled(); const policiesEnabled = usePoliciesEnabled();
const rawPolicyDetailActive = usePolicyDetailActive(); const rawPolicyDetailActive = usePolicyDetailActive();
const fullscreenExpanded = useIsFullscreenExpanded(); const fullscreenExpanded = useIsFullscreenExpanded();
@@ -101,57 +89,6 @@ export default function RightSidebar() {
const [allToolsView, setAllToolsView] = useState(false); const [allToolsView, setAllToolsView] = useState(false);
const { isOpen: isChatOpen, setOpen: setChatOpen } = useChat();
const [chatWidthPx, setChatWidthPx] = useState(DEFAULT_CHAT_WIDTH_PX);
const [isChatDragging, setIsChatDragging] = useState(false);
const chatDragState = useRef<{ startX: number; startWidth: number } | null>(
null,
);
const handleChatClose = useCallback(() => {
withViewTransition(() => setChatOpen(false));
setChatWidthPx(DEFAULT_CHAT_WIDTH_PX);
}, [setChatOpen]);
const handleResizeChatPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
chatDragState.current = { startX: e.clientX, startWidth: chatWidthPx };
setIsChatDragging(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const onMove = (ev: PointerEvent) => {
if (!chatDragState.current) return;
const delta = chatDragState.current.startX - ev.clientX;
setChatWidthPx(
Math.max(
MIN_CHAT_WIDTH_PX,
Math.min(
MAX_CHAT_WIDTH_PX,
chatDragState.current.startWidth + delta,
),
),
);
};
const cleanup = () => {
chatDragState.current = null;
setIsChatDragging(false);
document.body.style.removeProperty("cursor");
document.body.style.removeProperty("user-select");
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", cleanup);
window.removeEventListener("pointercancel", cleanup);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", cleanup);
window.addEventListener("pointercancel", cleanup);
},
[chatWidthPx],
);
const handleShowAllTools = () => { const handleShowAllTools = () => {
withViewTransition(() => setAllToolsView(true)); withViewTransition(() => setAllToolsView(true));
}; };
@@ -186,12 +123,11 @@ export default function RightSidebar() {
policiesEnabled && !allToolsView && leftPanelView === "toolPicker"; policiesEnabled && !allToolsView && leftPanelView === "toolPicker";
// When Policies are shown, the search moves OUT of the header to sit between // When Policies are shown, the search moves OUT of the header to sit between
// the Policies and Tools sections (separating them); otherwise it stays in the // the Policies and Tools sections (separating them); otherwise it stays in the
// header. Show the header search when there's a close button, or when agents // header. Show the header search when there's a close button, or in the
// are off in the default tool-picker view (inline filtering). // default tool-picker view.
const showInlineSearch = showPolicies && !showCloseButton; const showInlineSearch = showPolicies && !showCloseButton;
const showHeaderSearch = const showHeaderSearch =
!showInlineSearch && !showInlineSearch && (showCloseButton || leftPanelView === "toolPicker");
(showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker"));
const handleHeaderBack = () => { const handleHeaderBack = () => {
if (inToolView) { if (inToolView) {
@@ -224,13 +160,6 @@ export default function RightSidebar() {
? (toolRegistry[selectedToolKey as ToolId] ?? null) ? (toolRegistry[selectedToolKey as ToolId] ?? null)
: null; : null;
// Agents header + section is hidden when:
// - the AI engine is off, or
// - the user is in the all-tools view, or
// - a specific tool is being rendered (leftPanelView ≠ "toolPicker").
const showAgents =
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
// The detail takeover replaces the tool list ONLY in the same default view — // The detail takeover replaces the tool list ONLY in the same default view —
// never over an open tool or the all-tools view (which must keep priority). // never over an open tool or the all-tools view (which must keep priority).
// A lingering selection is harmless: it stays hidden behind a tool and the // A lingering selection is harmless: it stays hidden behind a tool and the
@@ -243,7 +172,6 @@ export default function RightSidebar() {
const computedWidth = () => { const computedWidth = () => {
if (isMobile) return "100%"; if (isMobile) return "100%";
if (isChatOpen) return `${chatWidthPx}px`;
if (!isPanelVisible) return "3.5rem"; if (!isPanelVisible) return "3.5rem";
return expandedWidth; return expandedWidth;
}; };
@@ -279,41 +207,17 @@ export default function RightSidebar() {
ref={toolPanelRef} ref={toolPanelRef}
data-sidebar="tool-panel" data-sidebar="tool-panel"
data-tour={fullscreenExpanded ? undefined : "tool-panel"} data-tour={fullscreenExpanded ? undefined : "tool-panel"}
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : isChatOpen ? "" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${ className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : "" isRainbowMode ? rainbowStyles.rainbowPaper : ""
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`} } ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
style={{ style={{
width: computedWidth(), width: computedWidth(),
padding: "0", padding: "0",
...(isChatDragging ? { transition: "none" } : {}),
}} }}
> >
{/* Headless: enforces enabled policies on every uploaded file. */} {/* Headless: enforces enabled policies on every uploaded file. */}
{policiesEnabled && <PolicyAutoRunController />} {policiesEnabled && <PolicyAutoRunController />}
{!fullscreenExpanded && isChatOpen && ( {!fullscreenExpanded && !isPanelVisible && !isMobile && (
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
}}
>
<div
className="agents-takeover__resize-handle"
onPointerDown={handleResizeChatPointerDown}
role="separator"
aria-label={t("chat.resize", "Resize chat panel")}
aria-orientation="vertical"
/>
<ChatPanel
onBack={handleChatClose}
backLabel={t("agents.back_to_tools", "Back to tools")}
/>
</div>
)}
{!fullscreenExpanded && !isChatOpen && !isPanelVisible && !isMobile && (
<div className="tool-panel__collapsed-strip"> <div className="tool-panel__collapsed-strip">
<div className="tool-panel__collapsed-top"> <div className="tool-panel__collapsed-top">
<ActionIcon <ActionIcon
@@ -327,7 +231,6 @@ export default function RightSidebar() {
> >
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} /> <ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon> </ActionIcon>
<AgentsCollapsedButton onExpand={handleExpand} />
</div> </div>
<div className="tool-panel__collapsed-divider" /> <div className="tool-panel__collapsed-divider" />
<PoliciesCollapsedButton onExpand={handleOpenPolicy} /> <PoliciesCollapsedButton onExpand={handleOpenPolicy} />
@@ -358,7 +261,7 @@ export default function RightSidebar() {
</div> </div>
)} )}
{!fullscreenExpanded && !isChatOpen && isPanelVisible && ( {!fullscreenExpanded && isPanelVisible && (
<div <div
/* Fixed width matches the expanded panel width so the inner content is /* Fixed width matches the expanded panel width so the inner content is
laid out at its final size from the moment it mounts. The outer laid out at its final size from the moment it mounts. The outer
@@ -409,13 +312,7 @@ export default function RightSidebar() {
autoFocus={allToolsView && !inToolView} autoFocus={allToolsView && !inToolView}
/> />
</div> </div>
) : ( ) : null}
showAgents && (
<span className="tool-panel__section-label">
{t("agents.section_title", "Agents")}
</span>
)
)}
{showCloseButton ? ( {showCloseButton ? (
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
@@ -447,8 +344,6 @@ export default function RightSidebar() {
</div> </div>
)} )}
{showAgents && <AgentsSection />}
{showPolicies && ( {showPolicies && (
<PoliciesSection <PoliciesSection
leadingControl={ leadingControl={
@@ -481,7 +376,7 @@ export default function RightSidebar() {
allToolsView={allToolsView} allToolsView={allToolsView}
onShowAllTools={handleShowAllTools} onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition} onToolSelect={handleToolSelectWithTransition}
compact={agentsEnabled && !allToolsView} compact={false}
/> />
</> </>
)} )}
@@ -385,15 +385,6 @@
} }
} }
.tool-panel__section-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
line-height: 1;
}
.tool-panel__mode-toggle { .tool-panel__mode-toggle {
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
@@ -855,33 +846,6 @@
gap: 0.75rem; gap: 0.75rem;
} }
/* Agents card lives in its own column above the tool grid. It uses the same
.tool-panel__fullscreen-group base styles but with a colourful gradient
border so it stands out as a distinct surface (and doesn't blend into the
neighbouring category cards). */
.tool-panel__fullscreen-agents {
padding: 1.5rem 1.75rem 0;
}
.tool-panel__fullscreen-group--agents {
position: relative;
background:
linear-gradient(var(--fullscreen-bg-group), var(--fullscreen-bg-group))
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;
border: 1.5px solid transparent;
}
.tool-panel__fullscreen-section-icon--agents {
color: var(--mantine-color-blue-6);
}
@keyframes tool-panel-fullscreen-slide-in { @keyframes tool-panel-fullscreen-slide-in {
from { from {
transform: translateX(6%) scaleX(0.85); transform: translateX(6%) scaleX(0.85);
@@ -1,483 +0,0 @@
/* ─── Chat takeover ─────────────────────────────────────────────────────── */
.agents-takeover {
position: fixed;
right: 0;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
background: var(--bg-toolbar);
border-left: 1px solid var(--border-subtle);
view-transition-name: agents-rail;
}
.agents-takeover__resize-handle {
position: absolute;
left: -3px;
top: 0;
bottom: 0;
width: 7px;
cursor: col-resize;
z-index: 10;
touch-action: none;
}
.agents-takeover__resize-handle::after {
content: "";
position: absolute;
left: 3px;
top: 0;
bottom: 0;
width: 1px;
background: transparent;
transition:
background 150ms ease,
width 150ms ease,
left 150ms ease;
}
.agents-takeover__resize-handle:hover::after {
left: 2px;
width: 3px;
background: var(--mantine-color-blue-4);
opacity: 0.6;
}
/* ─── 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;
position: relative;
}
/* 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;
position: relative;
}
.agents-collapsed-btn:hover {
background: var(--mantine-color-default-hover);
}
/* ─── Running / in-progress status dot ──────────────────────────────────── */
.agent-status-dot {
position: absolute;
bottom: 0;
right: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--mantine-color-blue-5);
border: 1.5px solid var(--mantine-color-body);
animation: agent-dot-pulse 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes agent-dot-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
/* Blue outline on hero Stirling button when agent is running */
.agent-button.agent-button--running {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-5) 60%,
var(--border-subtle)
);
}
[data-mantine-color-scheme="dark"] .agent-button.agent-button--running {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-4) 55%,
var(--border-subtle)
);
}
@media (prefers-reduced-motion: reduce) {
.agent-status-dot {
animation: none;
}
}
/* ─── 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;
}
}
@@ -1,275 +0,0 @@
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 { 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, isLoading } = 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${isLoading ? " agent-button--running" : ""}`}
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} />
{isLoading && <span className="agent-status-dot" />}
</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>
{isLoading
? t("agents.stirling_running", "Running...")
: 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, isLoading } = 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} />
{isLoading && <span className="agent-status-dot" />}
</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>
);
}
@@ -183,7 +183,6 @@ interface AiWorkflowResponse {
interface ChatState { interface ChatState {
messages: ChatMessage[]; messages: ChatMessage[];
isOpen: boolean;
isLoading: boolean; isLoading: boolean;
progress: AiWorkflowProgress | null; progress: AiWorkflowProgress | null;
/** Ordered log of every progress event in the current request. UI shows the last N entries. */ /** Ordered log of every progress event in the current request. UI shows the last N entries. */
@@ -200,8 +199,6 @@ type ChatAction =
| { type: "SET_LOADING"; loading: boolean } | { type: "SET_LOADING"; loading: boolean }
| { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null } | { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null }
| { type: "APPEND_PROGRESS"; progress: AiWorkflowProgress } | { type: "APPEND_PROGRESS"; progress: AiWorkflowProgress }
| { type: "TOGGLE_OPEN" }
| { type: "SET_OPEN"; open: boolean }
| { type: "CLEAR" }; | { type: "CLEAR" };
function chatReducer(state: ChatState, action: ChatAction): ChatState { function chatReducer(state: ChatState, action: ChatAction): ChatState {
@@ -231,10 +228,6 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState {
action.progress, action.progress,
], ],
}; };
case "TOGGLE_OPEN":
return { ...state, isOpen: !state.isOpen };
case "SET_OPEN":
return { ...state, isOpen: action.open };
case "CLEAR": case "CLEAR":
return { return {
...state, ...state,
@@ -363,13 +356,10 @@ async function consumeSSEStream(
interface ChatContextValue { interface ChatContextValue {
messages: ChatMessage[]; messages: ChatMessage[];
isOpen: boolean;
isLoading: boolean; isLoading: boolean;
progress: AiWorkflowProgress | null; progress: AiWorkflowProgress | null;
/** Ordered log of every progress event for the current in-flight request. */ /** Ordered log of every progress event for the current in-flight request. */
progressLog: AiWorkflowProgress[]; progressLog: AiWorkflowProgress[];
toggleOpen: () => void;
setOpen: (open: boolean) => void;
sendMessage: (content: string) => Promise<void>; sendMessage: (content: string) => Promise<void>;
/** Abort any in-flight request and reset the chat to an empty conversation. */ /** Abort any in-flight request and reset the chat to an empty conversation. */
clearChat: () => void; clearChat: () => void;
@@ -379,7 +369,6 @@ const ChatContext = createContext<ChatContextValue | null>(null);
const initialState: ChatState = { const initialState: ChatState = {
messages: [], messages: [],
isOpen: false,
isLoading: false, isLoading: false,
progress: null, progress: null,
progressLog: [], progressLog: [],
@@ -465,11 +454,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
[fileActions, downloadFile], [fileActions, downloadFile],
); );
const toggleOpen = useCallback(() => dispatch({ type: "TOGGLE_OPEN" }), []);
const setOpen = useCallback(
(open: boolean) => dispatch({ type: "SET_OPEN", open }),
[],
);
const clearChat = useCallback(() => { const clearChat = useCallback(() => {
abortRef.current?.abort(); abortRef.current?.abort();
abortRef.current = null; abortRef.current = null;
@@ -649,12 +633,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
<ChatContext.Provider <ChatContext.Provider
value={{ value={{
messages: state.messages, messages: state.messages,
isOpen: state.isOpen,
isLoading: state.isLoading, isLoading: state.isLoading,
progress: state.progress, progress: state.progress,
progressLog: state.progressLog, progressLog: state.progressLog,
toggleOpen,
setOpen,
sendMessage, sendMessage,
clearChat, clearChat,
}} }}
@@ -6,7 +6,7 @@ import { ChatFABButton } from "@shared/components/ChatFABButton";
import { ChatFABWindow } from "@shared/components/ChatFABWindow"; import { ChatFABWindow } from "@shared/components/ChatFABWindow";
import { ChatPanel } from "@app/components/chat/ChatPanel"; import { ChatPanel } from "@app/components/chat/ChatPanel";
import { useChat } from "@app/components/chat/ChatContext"; import { useChat } from "@app/components/chat/ChatContext";
import { useAgentsEnabled } from "@app/components/agents/AgentsPanel"; import { useAppConfig } from "@app/contexts/AppConfigContext";
import { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex"; import { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex";
import "@app/components/chat/ChatFAB.css"; import "@app/components/chat/ChatFAB.css";
@@ -54,7 +54,8 @@ export function ChatFAB() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [hasUnviewedResult, setHasUnviewedResult] = useState(false); const [hasUnviewedResult, setHasUnviewedResult] = useState(false);
const { isLoading } = useChat(); const { isLoading } = useChat();
const enabled = useAgentsEnabled(); const { config } = useAppConfig();
const enabled = Boolean(config?.aiEngineEnabled);
// Detect loading → done transition. If the FAB is closed when the agent // Detect loading → done transition. If the FAB is closed when the agent
// finishes, show the tick badge until the user opens the panel. // finishes, show the tick badge until the user opens the panel.
@@ -90,9 +91,13 @@ export function ChatFAB() {
}; };
useLayoutEffect(() => { useLayoutEffect(() => {
// The overlay only mounts once the AI engine is enabled (config can load
// after first render), so re-measure when that flips true rather than only
// on initial mount, otherwise the default position never gets computed.
if (!enabled) return;
const pos = getDefaultPos(); const pos = getDefaultPos();
if (pos) setRndPos(pos); if (pos) setRndPos(pos);
}, []); }, [enabled]);
// Clear the reset timer on unmount to avoid state updates on dead components. // Clear the reset timer on unmount to avoid state updates on dead components.
// Also ensure body user-select is restored if we unmount mid-resize. // Also ensure body user-select is restored if we unmount mid-resize.
@@ -140,6 +145,12 @@ export function ChatFAB() {
<ChatFABButton <ChatFABButton
className={`chat-fab-trigger${isOpen ? " chat-fab-trigger--hidden" : ""}`} className={`chat-fab-trigger${isOpen ? " chat-fab-trigger--hidden" : ""}`}
onClick={() => { onClick={() => {
// Fallback: ensure a position exists before opening, in case the
// layout effect measured before the overlay was laid out.
if (rndPos === null) {
const pos = getDefaultPos();
if (pos) setRndPos(pos);
}
setIsOpen(true); setIsOpen(true);
setHasUnviewedResult(false); setHasUnviewedResult(false);
}} }}
@@ -35,7 +35,6 @@
transition: transition:
background 120ms ease-out, background 120ms ease-out,
border-color 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; view-transition-name: stirling-agent;
} }
@@ -64,6 +63,36 @@
position: relative; position: relative;
} }
/* Running / in-progress status dot, anchored to the agent pill icon. */
.agent-status-dot {
position: absolute;
bottom: 0;
right: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--mantine-color-blue-5);
border: 1.5px solid var(--mantine-color-body);
animation: agent-dot-pulse 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes agent-dot-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
.agent-status-dot {
animation: none;
}
}
.chat-panel__agent-pill--loading { .chat-panel__agent-pill--loading {
border-color: color-mix( border-color: color-mix(
in srgb, in srgb,
-5
View File
@@ -242,11 +242,6 @@ export default defineConfig(async ({ mode }) => {
}, },
}, },
}, },
resolve: {
alias: {
"@shared": path.resolve(__dirname, "../shared"),
},
},
optimizeDeps: { optimizeDeps: {
exclude: ["@embedpdf/pdfium"], exclude: ["@embedpdf/pdfium"],
}, },