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:
parent
520010d5a2
commit
b605ee8ed4
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)]",
|
||||
|
|
|
|||
|
|
@ -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", // 💀
|
||||
};
|
||||
|
|
@ -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";
|
||||
});
|
||||
Loading…
Reference in New Issue