feat: dynamic tab titles and health-aware favicons (#111)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix: use "ao | <project>" format for dashboard title

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-02-19 04:21:49 +05:30 committed by GitHub
parent 520010d5a2
commit b605ee8ed4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 213 additions and 15 deletions

View File

@ -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(
(
<div
style={{
width: "32px",
height: "32px",
borderRadius: "6px",
background: `hsl(${hue}, 60%, 45%)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "20px",
fontWeight: 700,
fontFamily: "sans-serif",
}}
>
{initial}
</div>
),
{ ...size },
);
}

View File

@ -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<Metadata> {
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 (

View File

@ -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<Metadata> {
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 (
<Dashboard sessions={sessions} stats={computeStats(sessions)} orchestratorId={orchestratorId} />
<Dashboard sessions={sessions} stats={computeStats(sessions)} orchestratorId={orchestratorId} projectName={projectName} />
);
}

View File

@ -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<string | null>(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 {

View File

@ -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<AttentionLevel, DashboardSession[]> = {
merge: [],
@ -82,6 +84,7 @@ export function Dashboard({ sessions, stats, orchestratorId }: DashboardProps) {
return (
<div className="mx-auto max-w-[1100px] px-8 py-8">
<DynamicFavicon sessions={sessions} projectName={projectName} />
{/* Header */}
<div className="mb-7 flex items-baseline justify-between">
<h1 className="text-[22px] font-semibold tracking-tight">

View File

@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="${color}"/>
<text x="16" y="23" text-anchor="middle" fill="white" font-family="sans-serif" font-weight="700" font-size="20">${initial}</text>
</svg>`;
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<HTMLLinkElement>('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;
}

View File

@ -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<string, string> = {
active: "\u26A1",
ready: "\uD83D\uDFE2",
idle: "\uD83D\uDCA4",
waiting_input: "\u2753",
blocked: "\uD83D\uDEA7",
exited: "\uD83D\uDC80",
};
const borderColorByLevel: Record<AttentionLevel, string> = {
merge: "border-l-[var(--color-accent-green)]",
respond: "border-l-[var(--color-accent-red)]",

View File

@ -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<ActivityState, string> = {
active: "\u26A1", // ⚡
ready: "\uD83D\uDFE2", // 🟢
idle: "\uD83D\uDCA4", // 💤
waiting_input: "\u2753", // ❓
blocked: "\uD83D\uDEA7", // 🚧
exited: "\uD83D\uDC80", // 💀
};

View File

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