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 = ``;
+ 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";
+});