From b605ee8ed4b436a23a0f5d56ff6e229169b6db75 Mon Sep 17 00:00:00 2001 From: prateek Date: Thu, 19 Feb 2026 04:21:49 +0530 Subject: [PATCH] feat: dynamic tab titles and health-aware favicons (#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: dynamic browser tab titles and health-aware favicons Tabs now show contextual titles so multiple dashboard instances are distinguishable at a glance. Favicons reflect system health (green/ yellow/red) and display the project initial for visual identification. Co-Authored-By: Claude Opus 4.6 * fix: address review — dedupe project name, handle merge level, add activity emoji - Extract getProjectName() to shared lib/project-name.ts (used by layout, page, icon) — fixes bugbot duplication comment - computeHealth now treats "merge" attention level as yellow (needs human action) instead of silently mapping to green — fixes bugbot merge-ignored comment - Add activity status emoji to session tab titles (⚡🟢💤❓🚧💀) that updates live as session state changes - Special-case orchestrator sessions: "ao-orchestrator | Orchestrator Terminal" - Extract activityIcon map to shared lib/activity-icons.ts Co-Authored-By: Claude Opus 4.6 * fix: use absolute title to avoid layout template duplication The layout template `%s | project` was wrapping the page title `project | Agent Orchestrator`, producing `project | Agent Orchestrator | project`. Use `title.absolute` to opt out of the template on the root page. Co-Authored-By: Claude Opus 4.6 * fix: use "ao | " format for dashboard title Co-Authored-By: Claude Opus 4.6 * fix: dedupe config reads with React.cache() on getProjectName Wraps getProjectName with React.cache() so layout, page, and icon share a single loadConfig() call per server render pass instead of reading the YAML file three times. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- packages/web/src/app/icon.tsx | 43 ++++++++++++ packages/web/src/app/layout.tsx | 15 ++-- packages/web/src/app/page.tsx | 11 ++- packages/web/src/app/sessions/[id]/page.tsx | 40 +++++++++++ packages/web/src/components/Dashboard.tsx | 5 +- .../web/src/components/DynamicFavicon.tsx | 70 +++++++++++++++++++ packages/web/src/components/SessionCard.tsx | 10 +-- packages/web/src/lib/activity-icons.ts | 11 +++ packages/web/src/lib/project-name.ts | 23 ++++++ 9 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 packages/web/src/app/icon.tsx create mode 100644 packages/web/src/components/DynamicFavicon.tsx create mode 100644 packages/web/src/lib/activity-icons.ts create mode 100644 packages/web/src/lib/project-name.ts diff --git a/packages/web/src/app/icon.tsx b/packages/web/src/app/icon.tsx new file mode 100644 index 000000000..910438c1c --- /dev/null +++ b/packages/web/src/app/icon.tsx @@ -0,0 +1,43 @@ +import { ImageResponse } from "next/og"; +import { getProjectName } from "@/lib/project-name"; + +export const size = { width: 32, height: 32 }; +export const contentType = "image/png"; + +/** Derive a consistent hue from a string (0-360). */ +function stringToHue(s: string): number { + let hash = 0; + for (let i = 0; i < s.length; i++) { + hash = s.charCodeAt(i) + ((hash << 5) - hash); + } + return ((hash % 360) + 360) % 360; +} + +export default function Icon() { + const name = getProjectName(); + const initial = name.charAt(0).toUpperCase(); + const hue = stringToHue(name); + + return new ImageResponse( + ( +
+ {initial} +
+ ), + { ...size }, + ); +} diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index a5f4369a5..d102c4634 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -1,10 +1,17 @@ import type { Metadata } from "next"; +import { getProjectName } from "@/lib/project-name"; import "./globals.css"; -export const metadata: Metadata = { - title: "Agent Orchestrator", - description: "Dashboard for managing parallel AI coding agents", -}; +export async function generateMetadata(): Promise { + const projectName = getProjectName(); + return { + title: { + template: `%s | ${projectName}`, + default: `ao | ${projectName}`, + }, + description: "Dashboard for managing parallel AI coding agents", + }; +} export default function RootLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 1b92a6a0c..b3a5f80b2 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { Dashboard } from "@/components/Dashboard"; import type { DashboardSession } from "@/lib/types"; import { getServices, getSCM, getTracker } from "@/lib/services"; @@ -8,12 +9,20 @@ import { computeStats, } from "@/lib/serialize"; import { prCache, prCacheKey } from "@/lib/cache"; +import { getProjectName } from "@/lib/project-name"; export const dynamic = "force-dynamic"; +export async function generateMetadata(): Promise { + const projectName = getProjectName(); + // Use absolute to opt out of the layout's "%s | project" template + return { title: { absolute: `ao | ${projectName}` } }; +} + export default async function Home() { let sessions: DashboardSession[] = []; let orchestratorId: string | null = null; + const projectName = getProjectName(); try { const { config, registry, sessionManager } = await getServices(); const allSessions = await sessionManager.list(); @@ -114,6 +123,6 @@ export default async function Home() { } return ( - + ); } diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index 33ae22707..6967b9d31 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -4,6 +4,37 @@ import { useEffect, useState, useCallback } from "react"; import { useParams } from "next/navigation"; import { SessionDetail } from "@/components/SessionDetail"; import type { DashboardSession } from "@/lib/types"; +import { activityIcon } from "@/lib/activity-icons"; + +/** Build a descriptive tab title from session data. */ +function buildSessionTitle(session: DashboardSession): string { + const id = session.id; + const emoji = session.activity ? (activityIcon[session.activity] ?? "") : ""; + const isOrchestrator = id.endsWith("-orchestrator"); + + let detail: string; + + if (isOrchestrator) { + detail = "Orchestrator Terminal"; + } else if (session.pr) { + const prNum = `#${session.pr.number}`; + const branch = session.pr.branch; + const maxBranch = 30; + const truncated = branch.length > maxBranch ? branch.slice(0, maxBranch) + "..." : branch; + detail = `${prNum} ${truncated}`; + } else if (session.branch) { + const maxBranch = 30; + const truncated = + session.branch.length > maxBranch + ? session.branch.slice(0, maxBranch) + "..." + : session.branch; + detail = truncated; + } else { + detail = "Session Detail"; + } + + return emoji ? `${emoji} ${id} | ${detail}` : `${id} | ${detail}`; +} export default function SessionPage() { const params = useParams(); @@ -13,6 +44,15 @@ export default function SessionPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Update document title based on session data + useEffect(() => { + if (session) { + document.title = buildSessionTitle(session); + } else { + document.title = `${id} | Session Detail`; + } + }, [session, id]); + // Fetch session data (memoized to avoid recreating on every render) const fetchSession = useCallback(async () => { try { diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index fbee50abc..06ff71357 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -11,14 +11,16 @@ import { import { CI_STATUS } from "@composio/ao-core/types"; import { AttentionZone } from "./AttentionZone"; import { PRTableRow } from "./PRStatus"; +import { DynamicFavicon } from "./DynamicFavicon"; interface DashboardProps { sessions: DashboardSession[]; stats: DashboardStats; orchestratorId?: string | null; + projectName?: string; } -export function Dashboard({ sessions, stats, orchestratorId }: DashboardProps) { +export function Dashboard({ sessions, stats, orchestratorId, projectName }: DashboardProps) { const grouped = useMemo(() => { const zones: Record = { merge: [], @@ -82,6 +84,7 @@ export function Dashboard({ sessions, stats, orchestratorId }: DashboardProps) { return (
+ {/* Header */}

diff --git a/packages/web/src/components/DynamicFavicon.tsx b/packages/web/src/components/DynamicFavicon.tsx new file mode 100644 index 000000000..d537472a0 --- /dev/null +++ b/packages/web/src/components/DynamicFavicon.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect } from "react"; +import { type DashboardSession, type AttentionLevel, getAttentionLevel } from "@/lib/types"; + +/** + * Determine overall health from sessions. + * - "green" — all sessions working/done/pending, nothing needs attention + * - "yellow" — some sessions need review or response + * - "red" — critical: sessions stuck, errored, or needing immediate action + */ +function computeHealth(sessions: DashboardSession[]): "green" | "yellow" | "red" { + if (sessions.length === 0) return "green"; + + let hasYellow = false; + + for (const session of sessions) { + const level: AttentionLevel = getAttentionLevel(session); + if (level === "respond") return "red"; + if (level === "review" || level === "merge") hasYellow = true; + } + + return hasYellow ? "yellow" : "green"; +} + +const HEALTH_COLORS: Record<"green" | "yellow" | "red", string> = { + green: "#22c55e", + yellow: "#eab308", + red: "#ef4444", +}; + +/** Generate an SVG favicon as a data URL with the given initial and color. */ +function generateFaviconSvg(initial: string, color: string): string { + const svg = ` + + ${initial} + `; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +interface DynamicFaviconProps { + sessions: DashboardSession[]; + projectName?: string; +} + +/** + * Client component that dynamically updates the browser favicon + * based on system health (session attention levels). + */ +export function DynamicFavicon({ sessions, projectName = "A" }: DynamicFaviconProps) { + const initial = projectName.charAt(0).toUpperCase(); + + useEffect(() => { + const health = computeHealth(sessions); + const color = HEALTH_COLORS[health]; + const href = generateFaviconSvg(initial, color); + + // Find or create the favicon link element + let link = document.querySelector('link[rel="icon"]'); + if (!link) { + link = document.createElement("link"); + link.rel = "icon"; + document.head.appendChild(link); + } + link.type = "image/svg+xml"; + link.href = href; + }, [sessions, initial]); + + return null; +} diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index e5a2c77bc..2391e0bb2 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -10,6 +10,7 @@ import { } from "@/lib/types"; import { CI_STATUS } from "@composio/ao-core/types"; import { cn } from "@/lib/cn"; +import { activityIcon } from "@/lib/activity-icons"; import { PRStatus } from "./PRStatus"; import { CICheckList } from "./CIBadge"; @@ -21,15 +22,6 @@ interface SessionCardProps { onRestore?: (sessionId: string) => void; } -const activityIcon: Record = { - active: "\u26A1", - ready: "\uD83D\uDFE2", - idle: "\uD83D\uDCA4", - waiting_input: "\u2753", - blocked: "\uD83D\uDEA7", - exited: "\uD83D\uDC80", -}; - const borderColorByLevel: Record = { merge: "border-l-[var(--color-accent-green)]", respond: "border-l-[var(--color-accent-red)]", diff --git a/packages/web/src/lib/activity-icons.ts b/packages/web/src/lib/activity-icons.ts new file mode 100644 index 000000000..469339228 --- /dev/null +++ b/packages/web/src/lib/activity-icons.ts @@ -0,0 +1,11 @@ +import type { ActivityState } from "@composio/ao-core/types"; + +/** Emoji indicators for each activity state, shared across components. */ +export const activityIcon: Record = { + active: "\u26A1", // ⚡ + ready: "\uD83D\uDFE2", // 🟢 + idle: "\uD83D\uDCA4", // 💤 + waiting_input: "\u2753", // ❓ + blocked: "\uD83D\uDEA7", // 🚧 + exited: "\uD83D\uDC80", // 💀 +}; diff --git a/packages/web/src/lib/project-name.ts b/packages/web/src/lib/project-name.ts new file mode 100644 index 000000000..b396d7deb --- /dev/null +++ b/packages/web/src/lib/project-name.ts @@ -0,0 +1,23 @@ +import { cache } from "react"; +import { loadConfig } from "@composio/ao-core"; + +/** + * Load the primary project name from config. + * Falls back to "ao" if config is unavailable. + * + * Wrapped with React.cache() to deduplicate filesystem reads + * within a single server render pass (layout + page + icon all + * call this, but config is only read once per request). + */ +export const getProjectName = cache((): string => { + try { + const config = loadConfig(); + const firstKey = Object.keys(config.projects)[0]; + if (firstKey) { + return config.projects[firstKey].name ?? firstKey; + } + } catch { + // Config not available + } + return "ao"; +});