diff --git a/ui/src/components/ChatInterface.tsx b/ui/src/components/ChatInterface.tsx index 02b696e6569c393f3311d5a76110b1ceacb093b4..af7ad6cafa7cb16934bbaebda068050165d2c54f 100644 --- a/ui/src/components/ChatInterface.tsx +++ b/ui/src/components/ChatInterface.tsx @@ -8,6 +8,7 @@ import { } from "../types"; import { api } from "../services/api"; import { ThemeMode, getStoredTheme, setStoredTheme, applyTheme } from "../services/theme"; +import { setFaviconStatus } from "../services/favicon"; import MessageComponent from "./Message"; import MessageInput from "./MessageInput"; import DiffViewer from "./DiffViewer"; @@ -496,6 +497,11 @@ function ChatInterface({ }; }, [conversationId]); + // Update favicon when agent working state changes + useEffect(() => { + setFaviconStatus(agentWorking ? "working" : "ready"); + }, [agentWorking]); + // Check scroll position and handle scroll-to-bottom button useEffect(() => { const container = messagesContainerRef.current; diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 7f2ae20b5cc530b00009a0322ae19f718c57f951..d8934c326a23a795a746ea967cd486d2496254b7 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -2,10 +2,14 @@ import React from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; import { initializeTheme } from "./services/theme"; +import { initializeFavicon } from "./services/favicon"; // Apply theme before render to avoid flash initializeTheme(); +// Initialize dynamic favicon +initializeFavicon(); + // Render main app const rootContainer = document.getElementById("root"); if (!rootContainer) throw new Error("Root container not found"); diff --git a/ui/src/services/favicon.ts b/ui/src/services/favicon.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1ce030c787556db7823885aba97884a80e63444 --- /dev/null +++ b/ui/src/services/favicon.ts @@ -0,0 +1,84 @@ +// Favicon service for dynamic status indication +// Modifies the server-injected favicon to show a colored dot when the agent state changes + +type FaviconStatus = "working" | "ready"; + +let currentStatus: FaviconStatus = "ready"; +let originalSVG: string | null = null; + +// Get the existing favicon link (injected by server) +function getFaviconLink(): HTMLLinkElement | null { + return document.querySelector('link[rel="icon"]'); +} + +// Extract and decode the SVG from the data URI +function extractSVGFromDataURI(dataURI: string): string | null { + if (!dataURI.startsWith("data:image/svg+xml,")) { + return null; + } + try { + return decodeURIComponent(dataURI.substring("data:image/svg+xml,".length)); + } catch { + return null; + } +} + +// Add a status dot to the SVG +function addStatusDot(svg: string, status: FaviconStatus): string { + // Remove the closing tag + const closingTagIndex = svg.lastIndexOf(""); + if (closingTagIndex === -1) { + return svg; + } + + const svgWithoutClose = svg.substring(0, closingTagIndex); + + // Add the status dot in the bottom-right corner + const dotColor = status === "working" ? "#f59e0b" : "#22c55e"; + const statusDot = ` + + +`; + + return svgWithoutClose + statusDot + ""; +} + +// Update the favicon to reflect the current status +export function setFaviconStatus(status: FaviconStatus): void { + if (status === currentStatus && originalSVG !== null) { + return; + } + + const link = getFaviconLink(); + if (!link) { + return; + } + + // Capture the original SVG on first call + if (originalSVG === null) { + const extracted = extractSVGFromDataURI(link.href); + if (extracted) { + originalSVG = extracted; + } else { + // If we can't extract SVG, give up + return; + } + } + + currentStatus = status; + + // Generate new SVG with status dot + const newSVG = addStatusDot(originalSVG, status); + const newDataURI = "data:image/svg+xml," + encodeURIComponent(newSVG); + + // Update the favicon + link.href = newDataURI; +} + +// Initialize the favicon service (call on app start) +export function initializeFavicon(): void { + // Wait a tick for the server-injected favicon to be present + setTimeout(() => { + setFaviconStatus("ready"); + }, 0); +}