shelley: ui: add dynamic favicon status indicator

Philip Zeyliger and Shelley created

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

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;

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");

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 </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);
+}