shelley: ui: add dynamic favicon status indicator
Philip Zeyliger
and
Shelley
created 3 months ago
Prompt: https://github.com/boldsoftware/shelley/issues/14 . Work on this. Have the favicon change when the state changes from agent is working to ready.
Changes the browser tab favicon to show a colored dot indicating agent status:
- Green dot: ready/idle - agent is waiting for input
- Orange dot: working - agent is processing a request
The status dot is added to the existing server-injected seashell favicon SVG.
This helps users who have Shelley in a background tab know when the agent
has finished working without switching tabs.
Fixes: https://github.com/boldsoftware/shelley/issues/14
Co-authored-by: Shelley <shelley@exe.dev>
Change summary
ui/src/components/ChatInterface.tsx | 6 ++
ui/src/main.tsx | 4 +
ui/src/services/favicon.ts | 84 +++++++++++++++++++++++++++++++
3 files changed, 94 insertions(+)
Detailed changes
@@ -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;
@@ -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");
@@ -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 </svg> tag
+ const closingTagIndex = svg.lastIndexOf("</svg>");
+ 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 = `
+ <circle cx="26" cy="26" r="6" fill="white"/>
+ <circle cx="26" cy="26" r="5" fill="${dotColor}"/>
+`;
+
+ return svgWithoutClose + statusDot + "</svg>";
+}
+
+// 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);
+}