mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
@@ -22,12 +22,13 @@ tasks:
|
|||||||
vars:
|
vars:
|
||||||
PORT: '{{.PORT | default "8080"}}'
|
PORT: '{{.PORT | default "8080"}}'
|
||||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||||
|
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||||
env:
|
env:
|
||||||
SERVER_PORT: '{{.PORT}}'
|
SERVER_PORT: '{{.PORT}}'
|
||||||
cmds:
|
cmds:
|
||||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||||
platforms: [windows]
|
platforms: [windows]
|
||||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun'
|
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||||
platforms: [linux, darwin]
|
platforms: [linux, darwin]
|
||||||
|
|
||||||
dev:bundled:
|
dev:bundled:
|
||||||
|
|||||||
+1
-1
@@ -91,7 +91,7 @@ tasks:
|
|||||||
vars:
|
vars:
|
||||||
PORT: '{{.BACKEND_PORT}}'
|
PORT: '{{.BACKEND_PORT}}'
|
||||||
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
||||||
- task: frontend:dev:prototypes
|
- task: frontend:dev
|
||||||
vars:
|
vars:
|
||||||
PORT: '{{.FRONTEND_PORT}}'
|
PORT: '{{.FRONTEND_PORT}}'
|
||||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||||
|
|||||||
+3
@@ -307,6 +307,9 @@ public class ConfigController {
|
|||||||
// Premium/Enterprise settings
|
// Premium/Enterprise settings
|
||||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||||
|
|
||||||
|
// AI Engine settings
|
||||||
|
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
|
||||||
|
|
||||||
// Timestamp TSA settings — single source of truth for presets + admin URLs
|
// Timestamp TSA settings — single source of truth for presets + admin URLs
|
||||||
ApplicationProperties.Security.Timestamp tsConfig =
|
ApplicationProperties.Security.Timestamp tsConfig =
|
||||||
applicationProperties.getSecurity().getTimestamp();
|
applicationProperties.getSecurity().getTimestamp();
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import jakarta.validation.constraints.NotNull;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Schema(description = "Run an AI workflow against one or more PDF files")
|
@Schema(description = "Run an AI workflow")
|
||||||
public class AiWorkflowRequest {
|
public class AiWorkflowRequest {
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
|
|||||||
@@ -2684,6 +2684,59 @@ title = "Change Permissions"
|
|||||||
[changePermissions.tooltip.warning]
|
[changePermissions.tooltip.warning]
|
||||||
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
|
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
|
||||||
|
|
||||||
|
[agents]
|
||||||
|
section_title = "Agents"
|
||||||
|
fullscreen_title = "Stirling Agents"
|
||||||
|
stirling_name = "Stirling"
|
||||||
|
stirling_full_name = "Stirling General Agent"
|
||||||
|
stirling_tooltip = "Stirling agent"
|
||||||
|
stirling_description = "Your general-purpose PDF assistant"
|
||||||
|
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
|
||||||
|
back_to_tools = "Back to tools"
|
||||||
|
coming_soon = "Coming soon"
|
||||||
|
view_all = "View all agents"
|
||||||
|
show_less = "Show less"
|
||||||
|
start_chat = "Start chatting"
|
||||||
|
data_extraction_name = "Data Extraction"
|
||||||
|
data_extraction_description = "Extract tables & structured data"
|
||||||
|
doc_summary_name = "Summariser"
|
||||||
|
doc_summary_description = "Summarise long documents"
|
||||||
|
auto_redaction_name = "Auto Redaction"
|
||||||
|
auto_redaction_description = "Redact PII automatically"
|
||||||
|
compliance_name = "Compliance Check"
|
||||||
|
compliance_description = "Audit documents for compliance"
|
||||||
|
form_filler_name = "Form Filler"
|
||||||
|
form_filler_description = "Fill PDF forms intelligently"
|
||||||
|
pdf_to_markdown_name = "PDF to Markdown"
|
||||||
|
pdf_to_markdown_description = "Convert PDFs to clean Markdown"
|
||||||
|
|
||||||
|
[chat.header]
|
||||||
|
settings = "Agent settings"
|
||||||
|
agentMenu = "Stirling agent options"
|
||||||
|
clearChat = "Clear chat"
|
||||||
|
|
||||||
|
[chat.input]
|
||||||
|
placeholder = "What do you want to do?"
|
||||||
|
send = "Send message"
|
||||||
|
attach = "Attach files"
|
||||||
|
|
||||||
|
[chat.quickActions]
|
||||||
|
heading = "Get started"
|
||||||
|
openFromComputer = "Open from computer"
|
||||||
|
browseYourFiles = "Browse your files"
|
||||||
|
rotateOne = "Rotate this document"
|
||||||
|
rotateMany = "Rotate these documents"
|
||||||
|
compressOne = "Compress this document"
|
||||||
|
compressMany = "Compress these documents"
|
||||||
|
mergeMany = "Merge these {{count}} documents into 1"
|
||||||
|
splitOne = "Split this document"
|
||||||
|
convertOne = "Convert this document to PDF"
|
||||||
|
convertMany = "Convert these documents to PDF"
|
||||||
|
fileSummary_one = "1 file in workbench ({{types}})"
|
||||||
|
fileSummary_other = "{{count}} files in workbench ({{types}})"
|
||||||
|
moreFiles = "+{{count}} more"
|
||||||
|
removeFile = "Remove {{name}}"
|
||||||
|
|
||||||
[chat.progress]
|
[chat.progress]
|
||||||
analyzing = "Analysing your request..."
|
analyzing = "Analysing your request..."
|
||||||
calling_engine = "AI is thinking..."
|
calling_engine = "AI is thinking..."
|
||||||
@@ -2699,6 +2752,15 @@ whole_doc_read_done = "Finished reading the document..."
|
|||||||
whole_doc_read_started = "Reading the document..."
|
whole_doc_read_started = "Reading the document..."
|
||||||
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
|
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
|
||||||
|
|
||||||
|
[chat.responses]
|
||||||
|
done = "Done."
|
||||||
|
need_clarification = "Could you clarify your request?"
|
||||||
|
cannot_do = "I'm unable to do that."
|
||||||
|
not_found = "I couldn't find the requested information."
|
||||||
|
unsupported_capability = "Unsupported capability: {{capability}}"
|
||||||
|
cannot_continue = "Something went wrong and I can't continue."
|
||||||
|
processing = "Processing ({{outcome}})..."
|
||||||
|
|
||||||
[chat.toolsUsed]
|
[chat.toolsUsed]
|
||||||
summary = "Ran {{count}} tools"
|
summary = "Ran {{count}} tools"
|
||||||
summary_one = "Ran 1 tool"
|
summary_one = "Ran 1 tool"
|
||||||
@@ -8072,6 +8134,11 @@ expand = "Expand panel"
|
|||||||
placeholder = "Choose a tool to get started"
|
placeholder = "Choose a tool to get started"
|
||||||
premiumFeature = "Premium feature:"
|
premiumFeature = "Premium feature:"
|
||||||
search = "Search tools"
|
search = "Search tools"
|
||||||
|
toolsHeader = "Tools"
|
||||||
|
viewAllTools = "View all tools"
|
||||||
|
backToDefault = "Back"
|
||||||
|
backToAllTools = "Back to all tools"
|
||||||
|
goBack = "Go back"
|
||||||
|
|
||||||
[toolPanel.fullscreen]
|
[toolPanel.fullscreen]
|
||||||
comingSoon = "Coming soon:"
|
comingSoon = "Coming soon:"
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 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 overlay is currently open. Core builds have no chat,
|
||||||
|
* so this always returns false. Proprietary builds bridge to the ChatContext.
|
||||||
|
* Used by {@code RightSidebar} so the fullscreen tool picker can yield to the
|
||||||
|
* chat overlay just like it yields to a selected tool.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-rail chat overlay rendered inside {@code ToolPanel}. Covers the panel
|
||||||
|
* (including the search bar) when an agent conversation is active.
|
||||||
|
*/
|
||||||
|
export function AgentsChatOverlay() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
|
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||||
|
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||||
|
import {
|
||||||
|
AgentsFullscreenSection,
|
||||||
|
useAgentChatOpen,
|
||||||
|
useAgentsEnabled,
|
||||||
|
} from "@app/components/agents/AgentsPanel";
|
||||||
|
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
||||||
|
import { ToolId } from "@app/types/toolId";
|
||||||
|
import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||||
|
|
||||||
|
/** Derives whether the fullscreen tool picker is currently expanded. */
|
||||||
|
export function useIsFullscreenExpanded(): boolean {
|
||||||
|
const { toolPanelMode, leftPanelView, readerMode } = useToolWorkflow();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const agentChatOpen = useAgentChatOpen();
|
||||||
|
return (
|
||||||
|
toolPanelMode === "fullscreen" &&
|
||||||
|
leftPanelView === "toolPicker" &&
|
||||||
|
!isMobile &&
|
||||||
|
!readerMode &&
|
||||||
|
!agentChatOpen
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FullscreenToolPanelProps {
|
||||||
|
geometry: ToolPanelGeometry | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-contained fullscreen tool picker. Renders null when inactive, and takes
|
||||||
|
* over the right rail (via FullscreenToolSurface) when fullscreen mode is on.
|
||||||
|
* Geometry is computed by the parent (RightSidebar) so its useLayoutEffect runs
|
||||||
|
* after the ref div is committed, ensuring toolPanelRef.current is always set.
|
||||||
|
*/
|
||||||
|
export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
|
||||||
|
const {
|
||||||
|
toolPanelMode,
|
||||||
|
setToolPanelMode,
|
||||||
|
leftPanelView,
|
||||||
|
readerMode,
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
filteredTools,
|
||||||
|
toolRegistry,
|
||||||
|
selectedToolKey,
|
||||||
|
handleToolSelect,
|
||||||
|
} = useToolWorkflow();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const agentsEnabled = useAgentsEnabled();
|
||||||
|
const agentChatOpen = useAgentChatOpen();
|
||||||
|
const { setAllButtonsDisabled } = useWorkbenchBar();
|
||||||
|
const { preferences, updatePreference } = usePreferences();
|
||||||
|
|
||||||
|
const fullscreenExpanded =
|
||||||
|
toolPanelMode === "fullscreen" &&
|
||||||
|
leftPanelView === "toolPicker" &&
|
||||||
|
!isMobile &&
|
||||||
|
!readerMode &&
|
||||||
|
!agentChatOpen;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAllButtonsDisabled(fullscreenExpanded);
|
||||||
|
}, [fullscreenExpanded, setAllButtonsDisabled]);
|
||||||
|
|
||||||
|
const matchedTextMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
filteredTools.forEach(({ item: [id], matchedText }) => {
|
||||||
|
if (matchedText) map.set(id, matchedText);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [filteredTools]);
|
||||||
|
|
||||||
|
if (!fullscreenExpanded) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FullscreenToolSurface
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
toolRegistry={toolRegistry}
|
||||||
|
filteredTools={filteredTools}
|
||||||
|
selectedToolKey={selectedToolKey}
|
||||||
|
showDescriptions={preferences.showLegacyToolDescriptions}
|
||||||
|
matchedTextMap={matchedTextMap}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
onSelect={(id: ToolId) => handleToolSelect(id)}
|
||||||
|
onToggleDescriptions={() =>
|
||||||
|
updatePreference(
|
||||||
|
"showLegacyToolDescriptions",
|
||||||
|
!preferences.showLegacyToolDescriptions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
|
||||||
|
geometry={geometry}
|
||||||
|
agentsSlot={
|
||||||
|
agentsEnabled && !searchQuery ? <AgentsFullscreenSection /> : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { ScrollArea, Switch } from "@mantine/core";
|
import { ScrollArea, Switch } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||||
@@ -6,8 +7,6 @@ import FullscreenToolList from "@app/components/tools/FullscreenToolList";
|
|||||||
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||||
import { ToolId } from "@app/types/toolId";
|
import { ToolId } from "@app/types/toolId";
|
||||||
import { useFocusTrap } from "@app/hooks/useFocusTrap";
|
import { useFocusTrap } from "@app/hooks/useFocusTrap";
|
||||||
import { LogoIcon } from "@app/components/shared/LogoIcon";
|
|
||||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
|
||||||
import "@app/components/tools/ToolPanel.css";
|
import "@app/components/tools/ToolPanel.css";
|
||||||
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||||
|
|
||||||
@@ -26,6 +25,8 @@ 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 = ({
|
||||||
@@ -40,6 +41,7 @@ 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);
|
||||||
@@ -47,18 +49,16 @@ const FullscreenToolSurface = ({
|
|||||||
// Enable focus trap when surface is active
|
// Enable focus trap when surface is active
|
||||||
useFocusTrap(surfaceRef, true);
|
useFocusTrap(surfaceRef, true);
|
||||||
|
|
||||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
if (!geometry) return null;
|
||||||
|
|
||||||
const style = geometry
|
const style = {
|
||||||
? {
|
left: `${geometry.left}px`,
|
||||||
left: `${geometry.left}px`,
|
top: `${geometry.top}px`,
|
||||||
top: `${geometry.top}px`,
|
width: `${geometry.width}px`,
|
||||||
width: `${geometry.width}px`,
|
height: `${geometry.height}px`,
|
||||||
height: `${geometry.height}px`,
|
};
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
const surface = (
|
||||||
<div
|
<div
|
||||||
className="tool-panel__fullscreen-surface"
|
className="tool-panel__fullscreen-surface"
|
||||||
style={style}
|
style={style}
|
||||||
@@ -70,16 +70,6 @@ const FullscreenToolSurface = ({
|
|||||||
data-tour="tool-panel"
|
data-tour="tool-panel"
|
||||||
>
|
>
|
||||||
<div ref={surfaceRef} className="tool-panel__fullscreen-surface-inner">
|
<div ref={surfaceRef} className="tool-panel__fullscreen-surface-inner">
|
||||||
<header className="tool-panel__fullscreen-header">
|
|
||||||
<div className="tool-panel__fullscreen-brand">
|
|
||||||
<LogoIcon className="tool-panel__fullscreen-brand-icon" />
|
|
||||||
<Wordmark
|
|
||||||
alt={brandAltText}
|
|
||||||
className="tool-panel__fullscreen-brand-text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="tool-panel__fullscreen-controls">
|
<div className="tool-panel__fullscreen-controls">
|
||||||
<ToolSearch
|
<ToolSearch
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -102,6 +92,9 @@ 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}
|
||||||
@@ -115,6 +108,9 @@ const FullscreenToolSurface = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (typeof document === "undefined") return surface;
|
||||||
|
return createPortal(surface, document.body);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FullscreenToolSurface;
|
export default FullscreenToolSurface;
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { ActionIcon } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||||
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
|
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||||
|
import rainbowStyles from "@app/styles/rainbow.module.css";
|
||||||
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
|
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||||
|
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||||
|
import {
|
||||||
|
AgentsChatOverlay,
|
||||||
|
AgentsCollapsedButton,
|
||||||
|
AgentsSection,
|
||||||
|
useAgentsEnabled,
|
||||||
|
} from "@app/components/agents/AgentsPanel";
|
||||||
|
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||||
|
import { useToolSections } from "@app/hooks/useToolSections";
|
||||||
|
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
|
||||||
|
import { ToolIcon } from "@app/components/shared/ToolIcon";
|
||||||
|
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||||
|
import { withViewTransition } from "@app/utils/viewTransition";
|
||||||
|
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||||
|
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import { ToolId } from "@app/types/toolId";
|
||||||
|
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||||
|
import {
|
||||||
|
FullscreenToolPanel,
|
||||||
|
useIsFullscreenExpanded,
|
||||||
|
} from "@app/components/tools/FullscreenToolPanel";
|
||||||
|
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||||
|
import "@app/components/tools/ToolPanel.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-side rail wrapping the tool panel.
|
||||||
|
*
|
||||||
|
* Owns the rail-level concerns: collapse/expand chrome, the AGENTS top label,
|
||||||
|
* the collapsed strip (agent button + favourite/recommended icon shortcuts),
|
||||||
|
* and the agents chat overlay. Fullscreen takeover lives in FullscreenToolPanel.
|
||||||
|
*/
|
||||||
|
export default function RightSidebar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
|
const { sidebarRefs } = useSidebarContext();
|
||||||
|
const { toolPanelRef, quickAccessRef } = sidebarRefs;
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const {
|
||||||
|
leftPanelView,
|
||||||
|
isPanelVisible,
|
||||||
|
searchQuery,
|
||||||
|
filteredTools,
|
||||||
|
toolRegistry,
|
||||||
|
setSearchQuery,
|
||||||
|
selectedToolKey,
|
||||||
|
handleToolSelect,
|
||||||
|
handleBackToTools,
|
||||||
|
setLeftPanelView,
|
||||||
|
setReaderMode,
|
||||||
|
setSidebarsVisible,
|
||||||
|
sidebarsVisible,
|
||||||
|
readerMode,
|
||||||
|
favoriteTools,
|
||||||
|
} = useToolWorkflow();
|
||||||
|
|
||||||
|
const agentsEnabled = useAgentsEnabled();
|
||||||
|
const fullscreenExpanded = useIsFullscreenExpanded();
|
||||||
|
const fullscreenGeometry = useToolPanelGeometry({
|
||||||
|
enabled: fullscreenExpanded,
|
||||||
|
toolPanelRef,
|
||||||
|
quickAccessRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleExpand = () => {
|
||||||
|
withViewTransition(() => {
|
||||||
|
if (readerMode) setReaderMode(false);
|
||||||
|
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
|
||||||
|
if (!sidebarsVisible) setSidebarsVisible(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCollapse = () => {
|
||||||
|
withViewTransition(() => setLeftPanelView("hidden"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const [allToolsView, setAllToolsView] = useState(false);
|
||||||
|
|
||||||
|
const handleShowAllTools = () => {
|
||||||
|
withViewTransition(() => setAllToolsView(true));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackToDefault = () => {
|
||||||
|
withViewTransition(() => {
|
||||||
|
setAllToolsView(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// The header shows [back] [search] when we have somewhere to go back to —
|
||||||
|
// i.e. the user is in a specific tool, or already in the all-tools/search view.
|
||||||
|
const inToolView = leftPanelView !== "toolPicker";
|
||||||
|
// Show X (close) button only when there's somewhere to go back to.
|
||||||
|
const showCloseButton = inToolView || allToolsView;
|
||||||
|
// Show search input whenever there's a close button, or when agents are off and
|
||||||
|
// we're in the default tool-picker view (search filters the full list inline).
|
||||||
|
const showHeaderSearch =
|
||||||
|
showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
|
||||||
|
|
||||||
|
const handleHeaderBack = () => {
|
||||||
|
if (inToolView) {
|
||||||
|
withViewTransition(() => handleBackToTools());
|
||||||
|
} else {
|
||||||
|
handleBackToDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToolSelectWithTransition = (id: ToolId) => {
|
||||||
|
withViewTransition(() => handleToolSelect(id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typing in the header search while inside a tool exits the tool and lifts the
|
||||||
|
// panel into the all-tools view so the user immediately sees search results.
|
||||||
|
const handleHeaderSearchChange = (value: string) => {
|
||||||
|
if (inToolView) {
|
||||||
|
withViewTransition(() => {
|
||||||
|
handleBackToTools();
|
||||||
|
setAllToolsView(true);
|
||||||
|
setSearchQuery(value);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchQuery(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeTool: ToolRegistryEntry | null =
|
||||||
|
inToolView && selectedToolKey
|
||||||
|
? (toolRegistry[selectedToolKey as ToolId] ?? 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";
|
||||||
|
|
||||||
|
const computedWidth = () => {
|
||||||
|
if (isMobile) return "100%";
|
||||||
|
if (!isPanelVisible) return "3.5rem";
|
||||||
|
return "18.5rem";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collapsed rail: show favourites + recommended tools as icons.
|
||||||
|
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
|
||||||
|
const { sections: collapsedSections } = useToolSections(filteredTools);
|
||||||
|
const collapsedQuickSection = useMemo(
|
||||||
|
() => collapsedSections.find((s) => s.key === "quick"),
|
||||||
|
[collapsedSections],
|
||||||
|
);
|
||||||
|
const collapsedRecommendedItems = useMemo(() => {
|
||||||
|
if (!collapsedQuickSection) return [];
|
||||||
|
const items: Array<{ id: ToolId; tool: ToolRegistryEntry }> = [];
|
||||||
|
collapsedQuickSection.subcategories.forEach((sc: SubcategoryGroup) =>
|
||||||
|
sc.tools.forEach((entry) =>
|
||||||
|
items.push({ id: entry.id as ToolId, tool: entry.tool }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return items;
|
||||||
|
}, [collapsedQuickSection]);
|
||||||
|
const collapsedRailItems = useMemo(() => {
|
||||||
|
const map = new Map<ToolId, ToolRegistryEntry>();
|
||||||
|
favoriteToolItems.forEach(({ id, tool }) => map.set(id, tool));
|
||||||
|
collapsedRecommendedItems.forEach(({ id, tool }) => {
|
||||||
|
if (!map.has(id)) map.set(id, tool);
|
||||||
|
});
|
||||||
|
return Array.from(map, ([id, tool]) => ({ id, tool }));
|
||||||
|
}, [favoriteToolItems, collapsedRecommendedItems]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={toolPanelRef}
|
||||||
|
data-sidebar="tool-panel"
|
||||||
|
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
|
||||||
|
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 : ""
|
||||||
|
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||||
|
style={{
|
||||||
|
width: computedWidth(),
|
||||||
|
padding: "0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
|
||||||
|
<div className="tool-panel__collapsed-strip">
|
||||||
|
<div className="tool-panel__collapsed-top">
|
||||||
|
<ActionIcon
|
||||||
|
variant="outline"
|
||||||
|
color="gray.4"
|
||||||
|
radius="xl"
|
||||||
|
size="md"
|
||||||
|
className="tool-panel__expand-btn"
|
||||||
|
onClick={handleExpand}
|
||||||
|
aria-label={t("toolPanel.expand", "Expand panel")}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
|
||||||
|
</ActionIcon>
|
||||||
|
<AgentsCollapsedButton onExpand={handleExpand} />
|
||||||
|
</div>
|
||||||
|
<div className="tool-panel__collapsed-divider" />
|
||||||
|
<div className="tool-panel__collapsed-tools">
|
||||||
|
{collapsedRailItems.map(({ id, tool }) => (
|
||||||
|
<AppTooltip
|
||||||
|
key={id}
|
||||||
|
content={tool.name}
|
||||||
|
position="left"
|
||||||
|
arrow
|
||||||
|
delay={300}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tool-panel__collapsed-tool-btn"
|
||||||
|
data-selected={selectedToolKey === id}
|
||||||
|
onClick={() => {
|
||||||
|
handleExpand();
|
||||||
|
handleToolSelectWithTransition(id);
|
||||||
|
}}
|
||||||
|
aria-label={tool.name}
|
||||||
|
>
|
||||||
|
<ToolIcon icon={tool.icon} marginRight="0" />
|
||||||
|
</button>
|
||||||
|
</AppTooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fullscreenExpanded && isPanelVisible && (
|
||||||
|
<div
|
||||||
|
/* 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
|
||||||
|
.tool-panel clips it (overflow-hidden) while it animates from the
|
||||||
|
collapsed 3.5rem width — text/icons stay put and just come into view
|
||||||
|
instead of jiggling as space becomes available. */
|
||||||
|
style={{
|
||||||
|
opacity: 1,
|
||||||
|
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||||
|
height: "100%",
|
||||||
|
width: isMobile ? "100%" : "18.5rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="tool-panel__compact-header">
|
||||||
|
{activeTool ? (
|
||||||
|
<div
|
||||||
|
className="tool-panel__active-tool-pill"
|
||||||
|
aria-label={activeTool.name}
|
||||||
|
>
|
||||||
|
<span className="tool-panel__active-tool-pill-icon">
|
||||||
|
<ToolIcon
|
||||||
|
icon={activeTool.icon}
|
||||||
|
marginRight="0"
|
||||||
|
color="var(--mantine-color-blue-filled)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="tool-panel__active-tool-pill-label">
|
||||||
|
{activeTool.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : showHeaderSearch ? (
|
||||||
|
<div className="tool-panel__compact-header-search">
|
||||||
|
<ToolSearch
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleHeaderSearchChange}
|
||||||
|
toolRegistry={toolRegistry}
|
||||||
|
mode="filter"
|
||||||
|
autoFocus={allToolsView && !inToolView}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
showAgents && (
|
||||||
|
<span className="tool-panel__section-label">
|
||||||
|
{t("agents.section_title", "Agents")}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{showCloseButton ? (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="xl"
|
||||||
|
size="md"
|
||||||
|
onClick={handleHeaderBack}
|
||||||
|
aria-label={
|
||||||
|
inToolView
|
||||||
|
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||||
|
: t("toolPanel.goBack", "Go back")
|
||||||
|
}
|
||||||
|
className="tool-panel__expand-btn"
|
||||||
|
>
|
||||||
|
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : (
|
||||||
|
<ActionIcon
|
||||||
|
variant="outline"
|
||||||
|
radius="xl"
|
||||||
|
size="md"
|
||||||
|
onClick={handleCollapse}
|
||||||
|
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||||
|
className="tool-panel__expand-btn"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAgents && <AgentsSection />}
|
||||||
|
|
||||||
|
<ToolPanel
|
||||||
|
allToolsView={allToolsView}
|
||||||
|
onShowAllTools={handleShowAllTools}
|
||||||
|
onToolSelect={handleToolSelectWithTransition}
|
||||||
|
compact={agentsEnabled && !allToolsView}
|
||||||
|
/>
|
||||||
|
<AgentsChatOverlay />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FullscreenToolPanel geometry={fullscreenGeometry} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -129,17 +129,70 @@
|
|||||||
transition:
|
transition:
|
||||||
width 0.3s ease,
|
width 0.3s ease,
|
||||||
max-width 0.3s ease;
|
max-width 0.3s ease;
|
||||||
|
view-transition-name: tool-rail;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-panel__collapsed-strip {
|
.tool-panel__collapsed-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
justify-content: flex-start;
|
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
gap: 8px;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__collapsed-top {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__collapsed-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
margin: 0 0.5rem 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__collapsed-tools {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 0 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__collapsed-tool-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tools-text-and-icon-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 120ms ease-out,
|
||||||
|
border-color 120ms ease-out;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__collapsed-tool-btn:hover {
|
||||||
|
background: var(--mantine-color-default-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__collapsed-tool-btn[data-selected="true"] {
|
||||||
|
background: var(--mantine-color-default-hover);
|
||||||
|
border-color: var(--mantine-color-default-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-panel__expand-btn {
|
.tool-panel__expand-btn {
|
||||||
@@ -174,6 +227,11 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-panel__back-bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-panel--fullscreen-active {
|
.tool-panel--fullscreen-active {
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
@@ -191,6 +249,128 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-panel__compact-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__compact-header .tool-panel__expand-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__compact-header-search {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__compact-header-search .search-input-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__active-tool-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--mantine-color-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__active-tool-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme="dark"] .tool-panel__active-tool-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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The inner .tool-button-icon wrapper carries its own transform/margin; reset
|
||||||
|
them so the icon glyph sits dead-centre in the circular background. */
|
||||||
|
.tool-panel__active-tool-pill-icon .tool-button-icon {
|
||||||
|
margin: 0 !important;
|
||||||
|
transform: none !important;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__active-tool-pill-icon svg {
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel__active-tool-pill-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(tool-rail) {
|
||||||
|
animation: vt-rail-fade-out 180ms ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(tool-rail) {
|
||||||
|
animation: vt-rail-fade-in 220ms ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vt-rail-fade-out {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vt-rail-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
::view-transition-old(tool-rail),
|
||||||
|
::view-transition-new(tool-rail) {
|
||||||
|
animation-duration: 1ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
@@ -652,6 +832,33 @@
|
|||||||
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,303 +1,85 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { ScrollArea } from "@mantine/core";
|
||||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
useToolWorkflow,
|
|
||||||
useToolWorkflowActions,
|
|
||||||
} from "@app/contexts/ToolWorkflowContext";
|
|
||||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
|
||||||
import ToolPicker from "@app/components/tools/ToolPicker";
|
import ToolPicker from "@app/components/tools/ToolPicker";
|
||||||
import SearchResults from "@app/components/tools/SearchResults";
|
import SearchResults from "@app/components/tools/SearchResults";
|
||||||
import ToolRenderer from "@app/components/tools/ToolRenderer";
|
import ToolRenderer from "@app/components/tools/ToolRenderer";
|
||||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
|
||||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
|
||||||
import rainbowStyles from "@app/styles/rainbow.module.css";
|
|
||||||
import { ActionIcon, Button, ScrollArea } from "@mantine/core";
|
|
||||||
import { ToolId } from "@app/types/toolId";
|
|
||||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
|
||||||
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
|
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
|
||||||
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
import { ToolId } from "@app/types/toolId";
|
||||||
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
|
||||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
|
||||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
|
||||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
|
||||||
import "@app/components/tools/ToolPanel.css";
|
|
||||||
|
|
||||||
// No props needed - component uses context
|
interface ToolPanelProps {
|
||||||
|
/** Whether to expand into the full categorised tools view (with search). */
|
||||||
|
allToolsView: boolean;
|
||||||
|
/** Trigger to enter the all-tools view (from the "View all tools" button). */
|
||||||
|
onShowAllTools: () => void;
|
||||||
|
/**
|
||||||
|
* Tool-selection handler injected by {@code RightSidebar} so the click can
|
||||||
|
* be wrapped in a View Transition (the tool card morphs into the header
|
||||||
|
* pill). Falls back to the workflow context's handler when not provided.
|
||||||
|
*/
|
||||||
|
onToolSelect?: (id: ToolId) => void;
|
||||||
|
/** Whether to render the compact (favourites + recommended only) view. */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ToolPanel() {
|
/** Tool list and renderer for the right rail; rail chrome lives in RightSidebar. */
|
||||||
|
export default function ToolPanel({
|
||||||
|
allToolsView,
|
||||||
|
onShowAllTools,
|
||||||
|
onToolSelect,
|
||||||
|
compact: compactProp,
|
||||||
|
}: ToolPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isRainbowMode } = useRainbowThemeContext();
|
|
||||||
const { sidebarRefs } = useSidebarContext();
|
|
||||||
const { toolPanelRef, quickAccessRef } = sidebarRefs;
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
leftPanelView,
|
leftPanelView,
|
||||||
isPanelVisible,
|
|
||||||
searchQuery,
|
searchQuery,
|
||||||
filteredTools,
|
filteredTools,
|
||||||
toolRegistry,
|
|
||||||
setSearchQuery,
|
|
||||||
selectedToolKey,
|
selectedToolKey,
|
||||||
toolPanelMode,
|
|
||||||
sidebarsVisible,
|
|
||||||
readerMode,
|
|
||||||
} = useToolWorkflow();
|
|
||||||
const {
|
|
||||||
handleToolSelect,
|
handleToolSelect,
|
||||||
handleBackToTools,
|
|
||||||
setPreviewFile,
|
setPreviewFile,
|
||||||
setToolPanelMode,
|
} = useToolWorkflow();
|
||||||
setLeftPanelView,
|
const selectTool = onToolSelect ?? handleToolSelect;
|
||||||
setReaderMode,
|
|
||||||
setSidebarsVisible,
|
|
||||||
} = useToolWorkflowActions();
|
|
||||||
|
|
||||||
const { setAllButtonsDisabled } = useWorkbenchBar();
|
|
||||||
const { preferences, updatePreference } = usePreferences();
|
|
||||||
|
|
||||||
const isFullscreenMode = toolPanelMode === "fullscreen";
|
|
||||||
const toolPickerVisible = !readerMode;
|
|
||||||
const fullscreenExpanded =
|
|
||||||
isFullscreenMode &&
|
|
||||||
leftPanelView === "toolPicker" &&
|
|
||||||
!isMobile &&
|
|
||||||
toolPickerVisible;
|
|
||||||
|
|
||||||
// Disable workbench bar buttons when fullscreen mode is active
|
|
||||||
useEffect(() => {
|
|
||||||
setAllButtonsDisabled(fullscreenExpanded);
|
|
||||||
}, [fullscreenExpanded, setAllButtonsDisabled]);
|
|
||||||
|
|
||||||
const fullscreenGeometry = useToolPanelGeometry({
|
|
||||||
enabled: fullscreenExpanded,
|
|
||||||
toolPanelRef,
|
|
||||||
quickAccessRef,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleExpand = () => {
|
|
||||||
if (readerMode) setReaderMode(false);
|
|
||||||
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
|
|
||||||
if (!sidebarsVisible) setSidebarsVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCollapse = () => {
|
|
||||||
setLeftPanelView("hidden");
|
|
||||||
};
|
|
||||||
|
|
||||||
const [focusSearch, setFocusSearch] = useState(false);
|
|
||||||
const focusSearchOnNextOpen = useRef(false);
|
|
||||||
|
|
||||||
const handleExpandAndSearch = () => {
|
|
||||||
focusSearchOnNextOpen.current = true;
|
|
||||||
handleExpand();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Once the panel becomes visible, consume the focus-search request
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPanelVisible && focusSearchOnNextOpen.current) {
|
|
||||||
focusSearchOnNextOpen.current = false;
|
|
||||||
setFocusSearch(true);
|
|
||||||
// Reset after one render so autoFocus doesn't re-fire on subsequent renders
|
|
||||||
const id = setTimeout(() => setFocusSearch(false), 100);
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
}
|
|
||||||
}, [isPanelVisible]);
|
|
||||||
|
|
||||||
const computedWidth = () => {
|
|
||||||
if (isMobile) {
|
|
||||||
return "100%";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPanelVisible) {
|
|
||||||
return "3.5rem";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "18.5rem";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(id: string) => handleToolSelect(id as ToolId),
|
|
||||||
[handleToolSelect],
|
|
||||||
);
|
|
||||||
|
|
||||||
const matchedTextMap = useMemo(() => {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
filteredTools.forEach(({ item: [id], matchedText }) => {
|
|
||||||
if (matchedText) {
|
|
||||||
map.set(id, matchedText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [filteredTools]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
ref={toolPanelRef}
|
{/* Viewer mode tools — annotate, redact, form fill */}
|
||||||
data-sidebar="tool-panel"
|
<ToolPanelViewerBar />
|
||||||
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
|
|
||||||
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 ${
|
{allToolsView && searchQuery.trim().length > 0 ? (
|
||||||
isRainbowMode ? rainbowStyles.rainbowPaper : ""
|
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||||
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
<SearchResults
|
||||||
style={{
|
filteredTools={filteredTools}
|
||||||
width: computedWidth(),
|
onSelect={(id) => selectTool(id as ToolId)}
|
||||||
padding: "0",
|
searchQuery={searchQuery}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
|
|
||||||
<div className="tool-panel__collapsed-strip">
|
|
||||||
<ActionIcon
|
|
||||||
variant="outline"
|
|
||||||
color="gray.4"
|
|
||||||
radius="xl"
|
|
||||||
size="md"
|
|
||||||
className="tool-panel__expand-btn"
|
|
||||||
onClick={handleExpand}
|
|
||||||
aria-label={t("toolPanel.expand", "Expand panel")}
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
radius="md"
|
|
||||||
size="md"
|
|
||||||
className="tool-panel__collapsed-search-btn"
|
|
||||||
onClick={handleExpandAndSearch}
|
|
||||||
aria-label={t("toolPanel.search", "Search tools")}
|
|
||||||
style={{ marginTop: "8px" }}
|
|
||||||
>
|
|
||||||
<SearchIcon sx={{ fontSize: "1.25rem" }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : leftPanelView === "toolPicker" ? (
|
||||||
|
<div className="flex-1 flex flex-col overflow-auto">
|
||||||
{!fullscreenExpanded && isPanelVisible && (
|
<ToolPicker
|
||||||
<div
|
selectedToolKey={selectedToolKey}
|
||||||
style={{
|
onSelect={(id) => selectTool(id as ToolId)}
|
||||||
opacity: 1,
|
filteredTools={filteredTools}
|
||||||
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
|
||||||
height: "100%",
|
compact={compactProp ?? !allToolsView}
|
||||||
display: "flex",
|
onShowAllTools={onShowAllTools}
|
||||||
flexDirection: "column",
|
/>
|
||||||
}}
|
</div>
|
||||||
>
|
) : (
|
||||||
{/* Viewer mode tools — annotate, redact, form fill */}
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<ToolPanelViewerBar />
|
<ScrollArea h="100%">
|
||||||
|
{selectedToolKey ? (
|
||||||
<div
|
<ToolRenderer
|
||||||
className="tool-panel__search-row"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
borderBottom: "1px solid var(--border-subtle)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ToolSearch
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={setSearchQuery}
|
|
||||||
toolRegistry={toolRegistry}
|
|
||||||
mode="filter"
|
|
||||||
autoFocus={focusSearch}
|
|
||||||
/>
|
|
||||||
<ActionIcon
|
|
||||||
variant="outline"
|
|
||||||
radius="xl"
|
|
||||||
size="md"
|
|
||||||
onClick={handleCollapse}
|
|
||||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
|
||||||
className="tool-panel__expand-btn"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{searchQuery.trim().length > 0 ? (
|
|
||||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
|
||||||
<SearchResults
|
|
||||||
filteredTools={filteredTools}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : leftPanelView === "toolPicker" ? (
|
|
||||||
<div className="flex-1 flex flex-col overflow-auto">
|
|
||||||
<ToolPicker
|
|
||||||
selectedToolKey={selectedToolKey}
|
selectedToolKey={selectedToolKey}
|
||||||
onSelect={handleSelect}
|
onPreviewFile={setPreviewFile}
|
||||||
filteredTools={filteredTools}
|
|
||||||
isSearching={Boolean(
|
|
||||||
searchQuery && searchQuery.trim().length > 0,
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="tool-panel__placeholder">
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
{t("toolPanel.placeholder", "Choose a tool to get started")}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderBottom: "1px solid var(--border-subtle)",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="blue"
|
|
||||||
size="sm"
|
|
||||||
fullWidth
|
|
||||||
radius={0}
|
|
||||||
leftSection={<ArrowBackIcon sx={{ fontSize: "0.9rem" }} />}
|
|
||||||
onClick={handleBackToTools}
|
|
||||||
aria-label={t("toolPanel.backToTools", "Back to tools")}
|
|
||||||
styles={{ root: { justifyContent: "flex-start" } }}
|
|
||||||
>
|
|
||||||
{t("toolPanel.backToTools", "Back to tools")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
)}
|
||||||
<ScrollArea h="100%">
|
</ScrollArea>
|
||||||
{selectedToolKey ? (
|
|
||||||
<ToolRenderer
|
|
||||||
selectedToolKey={selectedToolKey}
|
|
||||||
onPreviewFile={setPreviewFile}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="tool-panel__placeholder">
|
|
||||||
{t(
|
|
||||||
"toolPanel.placeholder",
|
|
||||||
"Choose a tool to get started",
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
{fullscreenExpanded && (
|
|
||||||
<FullscreenToolSurface
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
toolRegistry={toolRegistry}
|
|
||||||
filteredTools={filteredTools}
|
|
||||||
selectedToolKey={selectedToolKey}
|
|
||||||
showDescriptions={preferences.showLegacyToolDescriptions}
|
|
||||||
matchedTextMap={matchedTextMap}
|
|
||||||
onSearchChange={setSearchQuery}
|
|
||||||
onSelect={(id: ToolId) => handleToolSelect(id)}
|
|
||||||
onToggleDescriptions={() =>
|
|
||||||
updatePreference(
|
|
||||||
"showLegacyToolDescriptions",
|
|
||||||
!preferences.showLegacyToolDescriptions,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
|
|
||||||
geometry={fullscreenGeometry}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { memo, useMemo, useRef } from "react";
|
import React, { useMemo, useRef } from "react";
|
||||||
import { Box, Stack } from "@mantine/core";
|
import { Box, Button, Stack } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||||
import "@app/components/tools/toolPicker/ToolPicker.css";
|
import "@app/components/tools/toolPicker/ToolPicker.css";
|
||||||
@@ -22,6 +22,10 @@ interface ToolPickerProps {
|
|||||||
matchedText?: string;
|
matchedText?: string;
|
||||||
}>;
|
}>;
|
||||||
isSearching?: boolean;
|
isSearching?: boolean;
|
||||||
|
/** Compact "resting" view: favourites + recommended only, with a button to expand. */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Called when the user clicks "View all tools" in compact mode. */
|
||||||
|
onShowAllTools?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_FILTERED_TOOLS: ToolPickerProps["filteredTools"] = [];
|
const EMPTY_FILTERED_TOOLS: ToolPickerProps["filteredTools"] = [];
|
||||||
@@ -57,6 +61,8 @@ const ToolPicker = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
filteredTools,
|
filteredTools,
|
||||||
isSearching = false,
|
isSearching = false,
|
||||||
|
compact = false,
|
||||||
|
onShowAllTools,
|
||||||
}: ToolPickerProps) => {
|
}: ToolPickerProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -118,9 +124,60 @@ const ToolPicker = ({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
) : compact ? (
|
||||||
|
/* Resting state: flat list of pinned + recommended only. */
|
||||||
|
<Box className="tool-picker__compact">
|
||||||
|
<div style={HEADER_TEXT_STYLE}>
|
||||||
|
{t("toolPanel.toolsHeader", "Tools")}
|
||||||
|
</div>
|
||||||
|
{favoriteToolItems.length === 0 && recommendedItems.length === 0 ? (
|
||||||
|
<NoToolsFound />
|
||||||
|
) : (
|
||||||
|
<div className="tool-picker__compact-list">
|
||||||
|
{favoriteToolItems.map(({ id, tool }) => (
|
||||||
|
<ToolButton
|
||||||
|
key={`fav-${id}`}
|
||||||
|
id={id}
|
||||||
|
tool={tool}
|
||||||
|
isSelected={selectedToolKey === id}
|
||||||
|
onSelect={onSelect}
|
||||||
|
hasStars
|
||||||
|
showDescription
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{recommendedItems
|
||||||
|
.filter(
|
||||||
|
({ id }) => !favoriteToolItems.some((fav) => fav.id === id),
|
||||||
|
)
|
||||||
|
.map(({ id, tool }) => (
|
||||||
|
<ToolButton
|
||||||
|
key={`rec-${id}`}
|
||||||
|
id={id as ToolId}
|
||||||
|
tool={tool}
|
||||||
|
isSelected={selectedToolKey === id}
|
||||||
|
onSelect={onSelect}
|
||||||
|
hasStars
|
||||||
|
showDescription
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onShowAllTools && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
onClick={onShowAllTools}
|
||||||
|
className="tool-picker__view-all"
|
||||||
|
aria-label={t("toolPanel.viewAllTools", "View all tools")}
|
||||||
|
>
|
||||||
|
{t("toolPanel.viewAllTools", "View all tools")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Flat list: favorites and recommended first, then all subcategories */}
|
{/* All-tools view: favourites + recommended + all subcategories. */}
|
||||||
<Stack p="sm" gap="xs">
|
<Stack p="sm" gap="xs">
|
||||||
{favoriteToolItems.length > 0 && (
|
{favoriteToolItems.length > 0 && (
|
||||||
<Box w="100%">
|
<Box w="100%">
|
||||||
@@ -182,7 +239,6 @@ const ToolPicker = ({
|
|||||||
|
|
||||||
{!quickSection && !allSection && <NoToolsFound />}
|
{!quickSection && !allSection && <NoToolsFound />}
|
||||||
|
|
||||||
{/* bottom spacer to allow scrolling past the last row */}
|
|
||||||
<div aria-hidden style={{ height: 200 }} />
|
<div aria-hidden style={{ height: 200 }} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -192,4 +248,4 @@ const ToolPicker = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(ToolPicker);
|
export default ToolPicker;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ interface ToolButtonProps {
|
|||||||
disableNavigation?: boolean;
|
disableNavigation?: boolean;
|
||||||
matchedSynonym?: string;
|
matchedSynonym?: string;
|
||||||
hasStars?: boolean;
|
hasStars?: boolean;
|
||||||
|
showDescription?: boolean;
|
||||||
/** Called when an unavailable tool is clicked; if provided, overrides the default no-op */
|
/** Called when an unavailable tool is clicked; if provided, overrides the default no-op */
|
||||||
onUnavailableClick?: () => void;
|
onUnavailableClick?: () => void;
|
||||||
}
|
}
|
||||||
@@ -44,6 +45,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
disableNavigation = false,
|
disableNavigation = false,
|
||||||
matchedSynonym,
|
matchedSynonym,
|
||||||
hasStars = false,
|
hasStars = false,
|
||||||
|
showDescription = false,
|
||||||
onUnavailableClick,
|
onUnavailableClick,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -183,6 +185,14 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
)}
|
)}
|
||||||
{usesCloud && !visuallyUnavailable && <CloudBadge />}
|
{usesCloud && !visuallyUnavailable && <CloudBadge />}
|
||||||
</div>
|
</div>
|
||||||
|
{showDescription && tool.description && (
|
||||||
|
<span
|
||||||
|
className="tool-button__description"
|
||||||
|
style={{ opacity: visuallyUnavailable ? 0.25 : 1 }}
|
||||||
|
>
|
||||||
|
{tool.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{matchedSynonym && (
|
{matchedSynonym && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -205,8 +215,8 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
handleUnlessSpecialClick(e, () => handleClick(id));
|
handleUnlessSpecialClick(e, () => handleClick(id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedStyles = isSelected
|
const selectedBg = isSelected
|
||||||
? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" }
|
? { backgroundColor: "var(--tool-button-selected-bg)" }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const buttonElement = navProps ? (
|
const buttonElement = navProps ? (
|
||||||
@@ -227,7 +237,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
color: "var(--tools-text-and-icon-color)",
|
color: "var(--tools-text-and-icon-color)",
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
...selectedStyles,
|
...selectedBg,
|
||||||
},
|
},
|
||||||
label: { overflow: "visible" },
|
label: { overflow: "visible" },
|
||||||
}}
|
}}
|
||||||
@@ -254,7 +264,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
color: "var(--tools-text-and-icon-color)",
|
color: "var(--tools-text-and-icon-color)",
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
...selectedStyles,
|
...selectedBg,
|
||||||
},
|
},
|
||||||
label: { overflow: "visible" },
|
label: { overflow: "visible" },
|
||||||
}}
|
}}
|
||||||
@@ -279,7 +289,6 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
|||||||
color: "var(--tools-text-and-icon-color)",
|
color: "var(--tools-text-and-icon-color)",
|
||||||
cursor: visuallyUnavailable ? "not-allowed" : undefined,
|
cursor: visuallyUnavailable ? "not-allowed" : undefined,
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
...selectedStyles,
|
|
||||||
},
|
},
|
||||||
label: { overflow: "visible" },
|
label: { overflow: "visible" },
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -55,6 +55,15 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Selected tool highlight — theme-aware via CSS variable */
|
||||||
|
:root {
|
||||||
|
--tool-button-selected-bg: var(--mantine-color-gray-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme="dark"] {
|
||||||
|
--tool-button-selected-bg: var(--mantine-color-dark-4);
|
||||||
|
}
|
||||||
|
|
||||||
/* Compact tool buttons */
|
/* Compact tool buttons */
|
||||||
.tool-button {
|
.tool-button {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -96,3 +105,37 @@
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-button__description {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resting / compact tool list — taller rows, room for description. */
|
||||||
|
.tool-picker__compact {
|
||||||
|
padding: 0.5rem var(--mantine-spacing-sm) var(--mantine-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-picker__compact .tool-button {
|
||||||
|
padding-top: 0.65rem;
|
||||||
|
padding-bottom: 0.65rem;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-picker__compact .tool-button .mantine-Button-label {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-picker__compact-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-picker__view-all {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { PageEditorFunctions } from "@app/types/pageEditor";
|
import { PageEditorFunctions } from "@app/types/pageEditor";
|
||||||
import {
|
import { type ToolPanelMode } from "@app/constants/toolPanel";
|
||||||
type ToolPanelMode,
|
import { preferencesService } from "@app/services/preferencesService";
|
||||||
DEFAULT_TOOL_PANEL_MODE,
|
|
||||||
} from "@app/constants/toolPanel";
|
|
||||||
|
|
||||||
export interface ToolWorkflowState {
|
export interface ToolWorkflowState {
|
||||||
// UI State
|
// UI State
|
||||||
@@ -43,7 +41,7 @@ export const baseState: Omit<ToolWorkflowState, "toolPanelMode"> = {
|
|||||||
|
|
||||||
export const createInitialState = (): ToolWorkflowState => ({
|
export const createInitialState = (): ToolWorkflowState => ({
|
||||||
...baseState,
|
...baseState,
|
||||||
toolPanelMode: DEFAULT_TOOL_PANEL_MODE,
|
toolPanelMode: preferencesService.getPreference("defaultToolPanelMode"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function toolWorkflowReducer(
|
export function toolWorkflowReducer(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { generateId } from "@app/utils/generateId";
|
||||||
import {
|
import {
|
||||||
signatureStorageService,
|
signatureStorageService,
|
||||||
type StorageType,
|
type StorageType,
|
||||||
@@ -28,16 +29,6 @@ export type AddSignatureResult =
|
|||||||
const isSupportedEnvironment = () =>
|
const isSupportedEnvironment = () =>
|
||||||
typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||||
|
|
||||||
const generateId = () => {
|
|
||||||
if (
|
|
||||||
typeof crypto !== "undefined" &&
|
|
||||||
typeof crypto.randomUUID === "function"
|
|
||||||
) {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return `sig_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSavedSignatures = () => {
|
export const useSavedSignatures = () => {
|
||||||
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>([]);
|
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>([]);
|
||||||
const [storageType, setStorageType] = useState<StorageType | null>(null);
|
const [storageType, setStorageType] = useState<StorageType | null>(null);
|
||||||
|
|||||||
@@ -13,6 +13,31 @@ interface UseToolPanelGeometryOptions {
|
|||||||
quickAccessRef: RefObject<HTMLDivElement | null>;
|
quickAccessRef: RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeGeometry(
|
||||||
|
panelEl: HTMLDivElement,
|
||||||
|
quickAccessRef: RefObject<HTMLDivElement | null>,
|
||||||
|
): ToolPanelGeometry {
|
||||||
|
const rect = panelEl.getBoundingClientRect();
|
||||||
|
const isRTL =
|
||||||
|
typeof document !== "undefined" && document.documentElement.dir === "rtl";
|
||||||
|
let width: number;
|
||||||
|
let left: number;
|
||||||
|
|
||||||
|
if (isRTL) {
|
||||||
|
// RTL: panel is on the left, expands rightward
|
||||||
|
width = Math.max(360, window.innerWidth - rect.right);
|
||||||
|
left = rect.right;
|
||||||
|
} else {
|
||||||
|
// LTR: panel is on the right, expands leftward to the file sidebar
|
||||||
|
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
|
||||||
|
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
|
||||||
|
width = Math.max(360, rect.right - leftOffset);
|
||||||
|
left = leftOffset;
|
||||||
|
}
|
||||||
|
const height = Math.max(rect.height, window.innerHeight - rect.top);
|
||||||
|
return { left, top: rect.top, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
export function useToolPanelGeometry({
|
export function useToolPanelGeometry({
|
||||||
enabled,
|
enabled,
|
||||||
toolPanelRef,
|
toolPanelRef,
|
||||||
@@ -36,31 +61,7 @@ export function useToolPanelGeometry({
|
|||||||
let rafId: number | null = null;
|
let rafId: number | null = null;
|
||||||
|
|
||||||
const computeAndSetGeometry = () => {
|
const computeAndSetGeometry = () => {
|
||||||
const rect = panelEl.getBoundingClientRect();
|
setGeometry(computeGeometry(panelEl, quickAccessRef));
|
||||||
const isRTL =
|
|
||||||
typeof document !== "undefined" &&
|
|
||||||
document.documentElement.dir === "rtl";
|
|
||||||
let width: number;
|
|
||||||
let left: number;
|
|
||||||
|
|
||||||
if (isRTL) {
|
|
||||||
// RTL: panel is on the left, expands rightward
|
|
||||||
width = Math.max(360, window.innerWidth - rect.right);
|
|
||||||
left = rect.right;
|
|
||||||
} else {
|
|
||||||
// LTR: panel is on the right, expands leftward to the file sidebar
|
|
||||||
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
|
|
||||||
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
|
|
||||||
width = Math.max(360, rect.right - leftOffset);
|
|
||||||
left = leftOffset;
|
|
||||||
}
|
|
||||||
const height = Math.max(rect.height, window.innerHeight - rect.top);
|
|
||||||
setGeometry({
|
|
||||||
left,
|
|
||||||
top: rect.top,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleUpdate = () => {
|
const scheduleUpdate = () => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import AppsIcon from "@mui/icons-material/AppsRounded";
|
|||||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||||
|
|
||||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
import RightSidebar from "@app/components/tools/RightSidebar";
|
||||||
import Workbench from "@app/components/layout/Workbench";
|
import Workbench from "@app/components/layout/Workbench";
|
||||||
import FileSidebar from "@app/components/shared/FileSidebar";
|
import FileSidebar from "@app/components/shared/FileSidebar";
|
||||||
import FileManager from "@app/components/FileManager";
|
import FileManager from "@app/components/FileManager";
|
||||||
@@ -380,7 +380,7 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mobile-slide-content">
|
<div className="mobile-slide-content">
|
||||||
<ToolPanel />
|
<RightSidebar />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -510,7 +510,7 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
<FolderTreePanel active={navigationState.workbench === "myFiles"} />
|
<FolderTreePanel active={navigationState.workbench === "myFiles"} />
|
||||||
<Workbench />
|
<Workbench />
|
||||||
{!hideToolPanel && <ToolPanel />}
|
{!hideToolPanel && <RightSidebar />}
|
||||||
<FileManager selectedTool={selectedTool} />
|
<FileManager selectedTool={selectedTool} />
|
||||||
<AppConfigModal
|
<AppConfigModal
|
||||||
opened={configModalOpen}
|
opened={configModalOpen}
|
||||||
|
|||||||
@@ -1099,3 +1099,17 @@
|
|||||||
animation-duration: 160ms;
|
animation-duration: 160ms;
|
||||||
animation-timing-function: cubic-bezier(0.2, 0, 0.2, 1);
|
animation-timing-function: cubic-bezier(0.2, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mantine animates a button's background-color and its text/icon colour on
|
||||||
|
* separate transitions, so when a filled button flips state (selected ↔
|
||||||
|
* unselected, enabled ↔ disabled) you briefly see e.g. a blue background
|
||||||
|
* with the old dark-grey label still showing through. Drop the colour
|
||||||
|
* tween — the swap is instant, but transforms/shadows/borders/opacity keep
|
||||||
|
* animating for hover and press feedback. */
|
||||||
|
.mantine-Button-root,
|
||||||
|
.mantine-ActionIcon-root,
|
||||||
|
.mantine-SegmentedControl-control,
|
||||||
|
.mantine-SegmentedControl-label,
|
||||||
|
.mantine-SegmentedControl-indicator {
|
||||||
|
transition-property: transform, box-shadow, border-color, opacity !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ test.describe("Navigation", () => {
|
|||||||
await page.locator('a[href="/merge"]').first().click();
|
await page.locator('a[href="/merge"]').first().click();
|
||||||
await expect(page).toHaveURL(/\/merge/);
|
await expect(page).toHaveURL(/\/merge/);
|
||||||
|
|
||||||
// In the redesigned UI, "Back to tools" button replaces the old "Tools" link
|
// In the redesigned UI, "Back to all tools" button replaces the old "Tools" link
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: /Back to tools/i })
|
.getByRole("button", { name: /Back to all tools/i })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
await expect(page).toHaveURL("/");
|
await expect(page).toHaveURL("/");
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ test.describe("4. PDF Tool Pages - Common Patterns", () => {
|
|||||||
await page.goto("/compress");
|
await page.goto("/compress");
|
||||||
await page.waitForLoadState("domcontentloaded");
|
await page.waitForLoadState("domcontentloaded");
|
||||||
|
|
||||||
// Step 2: Click the "Back to tools" button in ToolPanel to go back to /.
|
// Step 2: Click the "Back to all tools" button in ToolPanel to go back to /.
|
||||||
// In the redesigned UI this replaces the old "Tools" sidebar link.
|
// In the redesigned UI this replaces the old "Tools" sidebar link.
|
||||||
const homeLink = page
|
const homeLink = page
|
||||||
.getByRole("button", { name: /Back to tools/i })
|
.getByRole("button", { name: /Back to all tools/i })
|
||||||
.first();
|
.first();
|
||||||
await homeLink.click();
|
await homeLink.click();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useContext, useCallback, useRef } from "react";
|
import { useEffect, useState, useContext, useCallback, useRef } from "react";
|
||||||
|
import { generateId } from "@app/utils/generateId";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||||
@@ -578,10 +579,7 @@ const Annotate = (_props: BaseToolProps) => {
|
|||||||
const OFFSET = 20;
|
const OFFSET = 20;
|
||||||
const pasted: Record<string, unknown> = { ...annotation };
|
const pasted: Record<string, unknown> = { ...annotation };
|
||||||
// Assign a new id so EmbedPDF tracks the copy and delete works on it
|
// Assign a new id so EmbedPDF tracks the copy and delete works on it
|
||||||
pasted.id =
|
pasted.id = generateId();
|
||||||
typeof crypto !== "undefined" && crypto.randomUUID
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `paste-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
||||||
delete pasted.uid;
|
delete pasted.uid;
|
||||||
// Remove appearance stream reference — the copy needs its own rendering
|
// Remove appearance stream reference — the copy needs its own rendering
|
||||||
delete pasted.appearanceModes;
|
delete pasted.appearanceModes;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface AppConfig {
|
|||||||
timestampDefaultTsaUrl?: string;
|
timestampDefaultTsaUrl?: string;
|
||||||
timestampCustomTsaUrls?: string[];
|
timestampCustomTsaUrls?: string[];
|
||||||
timestampTsaPresets?: { label: string; url: string }[];
|
timestampTsaPresets?: { label: string; url: string }[];
|
||||||
|
aiEngineEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppConfigBootstrapMode = "blocking" | "non-blocking";
|
export type AppConfigBootstrapMode = "blocking" | "non-blocking";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { PageOperation } from "@app/types/pageEditor";
|
import { PageOperation } from "@app/types/pageEditor";
|
||||||
import { FileId, BaseFileMetadata } from "@app/types/file";
|
import { FileId, BaseFileMetadata } from "@app/types/file";
|
||||||
|
import { generateId } from "@app/utils/generateId";
|
||||||
|
|
||||||
// Re-export FileId for convenience
|
// Re-export FileId for convenience
|
||||||
export type { FileId };
|
export type { FileId };
|
||||||
@@ -58,18 +59,8 @@ export interface FileContextNormalizedFiles {
|
|||||||
byId: Record<FileId, StirlingFileStub>;
|
byId: Record<FileId, StirlingFileStub>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions - UUID-based primary keys (zero collisions, synchronous)
|
|
||||||
export function createFileId(): FileId {
|
export function createFileId(): FileId {
|
||||||
// Use crypto.randomUUID for authoritative primary key
|
return generateId() as FileId;
|
||||||
if (typeof window !== "undefined" && window.crypto?.randomUUID) {
|
|
||||||
return window.crypto.randomUUID() as FileId;
|
|
||||||
}
|
|
||||||
// Fallback for environments without randomUUID
|
|
||||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
|
||||||
const r = (Math.random() * 16) | 0;
|
|
||||||
const v = c == "x" ? r : (r & 0x3) | 0x8;
|
|
||||||
return v.toString(16);
|
|
||||||
}) as FileId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate quick deduplication key from file metadata
|
// Generate quick deduplication key from file metadata
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* and may reference a parent folder; a `null` parent means the root.
|
* and may reference a parent folder; a `null` parent means the root.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { generateId } from "@app/utils/generateId";
|
||||||
|
|
||||||
declare const folderTag: unique symbol;
|
declare const folderTag: unique symbol;
|
||||||
export type FolderId = string & { readonly [folderTag]: "FolderId" };
|
export type FolderId = string & { readonly [folderTag]: "FolderId" };
|
||||||
|
|
||||||
@@ -81,17 +83,7 @@ export function parseFolderId(value: unknown): FolderId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createFolderId(): FolderId {
|
export function createFolderId(): FolderId {
|
||||||
if (typeof window !== "undefined" && window.crypto?.randomUUID) {
|
return generateId() as FolderId;
|
||||||
return window.crypto.randomUUID() as FolderId;
|
|
||||||
}
|
|
||||||
// Math.random fallback is non-cryptographic but acceptable here - these ids are
|
|
||||||
// not used as security tokens, only as opaque local handles. The brand is the
|
|
||||||
// contract; the entropy is best-effort.
|
|
||||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
|
||||||
const r = (Math.random() * 16) | 0;
|
|
||||||
const v = c == "x" ? r : (r & 0x3) | 0x8;
|
|
||||||
return v.toString(16);
|
|
||||||
}) as FolderId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickFolderColor(seed: string): FolderPaletteColor {
|
export function pickFolderColor(seed: string): FolderPaletteColor {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* Generates and persists a unique UUID in localStorage for WAU tracking
|
* Generates and persists a unique UUID in localStorage for WAU tracking
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { generateId } from "@app/utils/generateId";
|
||||||
|
|
||||||
const BROWSER_ID_KEY = "stirling_browser_id";
|
const BROWSER_ID_KEY = "stirling_browser_id";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,19 +30,6 @@ export function getBrowserId(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a UUID v4
|
|
||||||
*/
|
|
||||||
function generateUUID(): string {
|
function generateUUID(): string {
|
||||||
// Use crypto.randomUUID if available (modern browsers)
|
return generateId();
|
||||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to manual UUID generation
|
|
||||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
||||||
const r = (Math.random() * 16) | 0;
|
|
||||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { generateId } from "@app/utils/generateId";
|
||||||
|
|
||||||
export interface BookmarkPayload {
|
export interface BookmarkPayload {
|
||||||
title: string;
|
title: string;
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
@@ -12,13 +14,7 @@ export interface BookmarkNode {
|
|||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createBookmarkId = () => {
|
const createBookmarkId = () => generateId();
|
||||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
return `bookmark-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createBookmarkNode = (
|
export const createBookmarkNode = (
|
||||||
bookmark?: Partial<BookmarkNode>,
|
bookmark?: Partial<BookmarkNode>,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function generateId(): string {
|
||||||
|
if (
|
||||||
|
typeof crypto !== "undefined" &&
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { flushSync } from "react-dom";
|
||||||
|
|
||||||
|
type ViewTransitionDoc = Document & {
|
||||||
|
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a state update inside a View Transition so the browser cross-fades
|
||||||
|
* (and morphs any elements sharing a {@code view-transition-name}) between
|
||||||
|
* the before/after DOMs.
|
||||||
|
*
|
||||||
|
* Falls back to a plain synchronous update when the API is unavailable
|
||||||
|
* (Firefox <130, JSDOM, motion-reduced preference).
|
||||||
|
*/
|
||||||
|
export function withViewTransition(update: () => void): Promise<void> {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
update();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
const doc = document as ViewTransitionDoc;
|
||||||
|
if (doc.startViewTransition) {
|
||||||
|
return doc.startViewTransition(() => flushSync(update)).finished;
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
|
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
|
||||||
import { alert } from "@app/components/toast";
|
import { alert } from "@app/components/toast";
|
||||||
import { setupApiInterceptors as coreSetup } from "@core/services/apiClientSetup";
|
import {
|
||||||
|
setupApiInterceptors as coreSetup,
|
||||||
|
getAuthHeaders,
|
||||||
|
} from "@core/services/apiClientSetup";
|
||||||
|
|
||||||
|
export { getAuthHeaders };
|
||||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||||
import { createBackendNotReadyError } from "@app/constants/backendErrors";
|
import { createBackendNotReadyError } from "@app/constants/backendErrors";
|
||||||
import { operationRouter } from "@app/services/operationRouter";
|
import { operationRouter } from "@app/services/operationRouter";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
|
import { generateId } from "@app/utils/generateId";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { open as shellOpen } from "@tauri-apps/plugin-shell";
|
import { open as shellOpen } from "@tauri-apps/plugin-shell";
|
||||||
import { connectionModeService } from "@app/services/connectionModeService";
|
import { connectionModeService } from "@app/services/connectionModeService";
|
||||||
@@ -1008,7 +1009,7 @@ export class AuthService {
|
|||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
): Promise<UserInfo> {
|
): Promise<UserInfo> {
|
||||||
// Generate and store nonce for CSRF protection
|
// Generate and store nonce for CSRF protection
|
||||||
const nonce = crypto.randomUUID();
|
const nonce = generateId();
|
||||||
sessionStorage.setItem("oauth_nonce", nonce);
|
sessionStorage.setItem("oauth_nonce", nonce);
|
||||||
console.log(
|
console.log(
|
||||||
"[Desktop AuthService] Generated OAuth nonce for CSRF protection",
|
"[Desktop AuthService] Generated OAuth nonce for CSRF protection",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { generateId } from "@app/utils/generateId";
|
||||||
|
|
||||||
function sanitizeFileName(fileName: string) {
|
function sanitizeFileName(fileName: string) {
|
||||||
const cleaned = fileName.replace(/[^A-Za-z0-9._-]+/g, "_");
|
const cleaned = fileName.replace(/[^A-Za-z0-9._-]+/g, "_");
|
||||||
@@ -40,7 +41,7 @@ export async function printPdfNatively(
|
|||||||
|
|
||||||
const tempPath = await join(
|
const tempPath = await join(
|
||||||
await tempDir(),
|
await tempDir(),
|
||||||
`stirling-print-${crypto.randomUUID()}-${sanitizeFileName(fileName)}`,
|
`stirling-print-${generateId()}-${sanitizeFileName(fileName)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await writeFile(tempPath, new Uint8Array(await source.arrayBuffer()));
|
await writeFile(tempPath, new Uint8Array(await source.arrayBuffer()));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CheckoutProvider } from "@app/contexts/CheckoutContext";
|
|||||||
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
|
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
|
||||||
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
|
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
|
||||||
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
|
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
|
||||||
|
import { ChatProvider } from "@app/components/chat/ChatContext";
|
||||||
|
|
||||||
export function AppProviders({
|
export function AppProviders({
|
||||||
children,
|
children,
|
||||||
@@ -25,7 +26,7 @@ export function AppProviders({
|
|||||||
<ServerExperienceProvider>
|
<ServerExperienceProvider>
|
||||||
<CheckoutProvider>
|
<CheckoutProvider>
|
||||||
<UpgradeBannerInitializer />
|
<UpgradeBannerInitializer />
|
||||||
{children}
|
<ChatProvider>{children}</ChatProvider>
|
||||||
</CheckoutProvider>
|
</CheckoutProvider>
|
||||||
</ServerExperienceProvider>
|
</ServerExperienceProvider>
|
||||||
</UpdateSeatsProvider>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook that provides the proprietary tool registry.
|
|
||||||
*
|
|
||||||
* This is the definition of all proprietary tools,
|
|
||||||
* and will be included in the main tool registry.
|
|
||||||
*/
|
|
||||||
export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
|
|
||||||
return useMemo<ProprietaryToolRegistry>(() => ({}), []);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,11 @@
|
|||||||
|
|
||||||
export const PROPRIETARY_REGULAR_TOOL_IDS = [] as const;
|
export const PROPRIETARY_REGULAR_TOOL_IDS = [] as const;
|
||||||
|
|
||||||
export const PROPRIETARY_SUPER_TOOL_IDS = [] as const;
|
// "ai-workflow" is a generic marker stamped onto files produced by the agents
|
||||||
|
// chat orchestrator (which may invoke one or more underlying tools). Lives here
|
||||||
|
// as a super-tool so ``ToolOperation.toolId`` stays typed; not user-pickable —
|
||||||
|
// see ChatContext.tsx. The tool registry doesn't include it as an entry.
|
||||||
|
export const PROPRIETARY_SUPER_TOOL_IDS = ["ai-workflow"] as const;
|
||||||
|
|
||||||
export const PROPRIETARY_LINK_TOOL_IDS = [] as const;
|
export const PROPRIETARY_LINK_TOOL_IDS = [] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
|
||||||
import { type AppProvidersProps } from "@core/components/AppProviders";
|
|
||||||
import { ChatProvider } from "@app/components/chat/ChatContext";
|
|
||||||
|
|
||||||
export type { AppProvidersProps };
|
|
||||||
|
|
||||||
export function AppProviders({
|
|
||||||
children,
|
|
||||||
appConfigRetryOptions,
|
|
||||||
appConfigProviderProps,
|
|
||||||
}: AppProvidersProps) {
|
|
||||||
return (
|
|
||||||
<ProprietaryAppProviders
|
|
||||||
appConfigRetryOptions={appConfigRetryOptions}
|
|
||||||
appConfigProviderProps={appConfigProviderProps}
|
|
||||||
>
|
|
||||||
<ChatProvider>{children}</ChatProvider>
|
|
||||||
</ProprietaryAppProviders>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
|
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
|
||||||
|
import { generateId } from "@app/utils/generateId";
|
||||||
import apiClient from "@app/services/apiClient";
|
import apiClient from "@app/services/apiClient";
|
||||||
import { getAuthHeaders } from "@app/services/apiClientSetup";
|
import { getAuthHeaders } from "@app/services/apiClientSetup";
|
||||||
import { createChildStub } from "@app/contexts/file/fileActions";
|
import { createChildStub } from "@app/contexts/file/fileActions";
|
||||||
@@ -419,7 +420,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
const priorMessages = messagesRef.current;
|
const priorMessages = messagesRef.current;
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
const userMessage: ChatMessage = {
|
||||||
id: crypto.randomUUID(),
|
id: generateId(),
|
||||||
role: "user",
|
role: "user",
|
||||||
content,
|
content,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -480,7 +481,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
dispatch({
|
dispatch({
|
||||||
type: "ADD_MESSAGE",
|
type: "ADD_MESSAGE",
|
||||||
message: {
|
message: {
|
||||||
id: crypto.randomUUID(),
|
id: generateId(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: replyContent,
|
content: replyContent,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -493,7 +494,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
dispatch({
|
dispatch({
|
||||||
type: "ADD_MESSAGE",
|
type: "ADD_MESSAGE",
|
||||||
message: {
|
message: {
|
||||||
id: crypto.randomUUID(),
|
id: generateId(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content:
|
content:
|
||||||
"The file was processed but I couldn't download it.",
|
"The file was processed but I couldn't download it.",
|
||||||
@@ -509,7 +510,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
dispatch({
|
dispatch({
|
||||||
type: "ADD_MESSAGE",
|
type: "ADD_MESSAGE",
|
||||||
message: {
|
message: {
|
||||||
id: crypto.randomUUID(),
|
id: generateId(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: data.message || "Something went wrong.",
|
content: data.message || "Something went wrong.",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -529,7 +530,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
dispatch({
|
dispatch({
|
||||||
type: "ADD_MESSAGE",
|
type: "ADD_MESSAGE",
|
||||||
message: {
|
message: {
|
||||||
id: crypto.randomUUID(),
|
id: generateId(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "Cancelled.",
|
content: "Cancelled.",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -542,7 +543,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
dispatch({
|
dispatch({
|
||||||
type: "ADD_MESSAGE",
|
type: "ADD_MESSAGE",
|
||||||
message: {
|
message: {
|
||||||
id: crypto.randomUUID(),
|
id: generateId(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content:
|
content:
|
||||||
"Failed to get a response. The AI engine may not be available yet.",
|
"Failed to get a response. The AI engine may not be available yet.",
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
.chat-toggle-button {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 1.5rem;
|
|
||||||
right: 1.5rem;
|
|
||||||
z-index: 1000;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-panel {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 380px;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--mantine-color-body);
|
|
||||||
border-left: 1px solid
|
|
||||||
var(--border-subtle, var(--mantine-color-default-border));
|
|
||||||
z-index: 999;
|
|
||||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-panel-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-bottom: 1px solid
|
|
||||||
var(--border-subtle, var(--mantine-color-default-border));
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-panel-messages {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-panel-input {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-top: 1px solid
|
|
||||||
var(--border-subtle, var(--mantine-color-default-border));
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
@@ -207,7 +207,12 @@ function ChatMessageBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanel() {
|
export interface ChatPanelProps {
|
||||||
|
onBack?: () => void;
|
||||||
|
backLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatPanel(_props: ChatPanelProps = {}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
|
||||||
|
|
||||||
export function HomePageExtensions() {
|
|
||||||
return <ChatPanel />;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user