Add Hub Monitor UI to menubar example (#10688)

* add menubar hub monitor dashboard

* docs: add menubar preview screenshot
This commit is contained in:
Ara
2026-05-18 17:44:32 -07:00
committed by GitHub
parent 0b1fe6a982
commit 6e0ae8db58
7 changed files with 1775 additions and 37 deletions
+18
View File
@@ -1,3 +1,7 @@
### Preview
![Cline Hub Monitor preview](./assets/hub-monitor-preview.jpg)
### Architecture Overview
```
@@ -14,6 +18,12 @@ Menu Bar Sidecar (apps/examples/menubar/sidecar/index.ts) ← TypeScript/Bun pr
│ JSON lines on stdout: hub_state / notification / ready
Rust Tauri App (apps/examples/menubar/src-tauri/src/main.rs)
├── Hub Monitor Window (ui/index.html)
│ ● Live hub status, uptime, clients, sessions
│ ● Running session tracker and inspector
│ ● Recent events and background-session launcher
├── System Tray Icon with dynamic menu
│ ● Hub Connected — 3 clients, 2 sessions
@@ -24,3 +34,11 @@ Rust Tauri App (apps/examples/menubar/src-tauri/src/main.rs)
└── Logs notifications to stderr (with severity)
```
### Dev Commands
From `sdk/apps/examples/menubar/`:
- `bun run dev:ui` - run only the Hub Monitor UI at `http://127.0.0.1:3466/` with preview data
- `bun run dev` - run the full Tauri app with the real hub sidecar
- `bun run typecheck` - TypeScript check
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+1
View File
@@ -4,6 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev:ui": "python3 -m http.server 3466 --bind 127.0.0.1 --directory ui",
"dev": "tauri dev",
"build": "tauri build",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
+298 -35
View File
@@ -23,8 +23,18 @@ interface TrackedSession {
sessionId: string;
status: string;
workspaceRoot: string;
cwd?: string;
createdAt: number;
updatedAt: number;
createdByClientId?: string;
title?: string;
provider?: string;
model?: string;
prompt?: string;
inputTokens?: number;
outputTokens?: number;
totalCost?: number;
agentCount?: number;
}
interface ClientSummary {
@@ -33,6 +43,33 @@ interface ClientSummary {
sessionCount: number;
}
interface SessionSummary {
sessionId: string;
title: string;
status: string;
workspaceRoot: string;
workspaceName: string;
cwd?: string;
model?: string;
provider?: string;
createdAt: number;
updatedAt: number;
createdByClientId?: string;
prompt?: string;
inputTokens?: number;
outputTokens?: number;
totalCost?: number;
agentCount: number;
}
interface EventRecord {
id: string;
title: string;
body: string;
severity: "info" | "success" | "warn" | "error";
timestamp: number;
}
type ClientSummaryGroup = {
label: string;
name: string;
@@ -67,8 +104,9 @@ interface ProviderLaunchAuth {
}
interface SidecarCommand {
type: "new_chat" | "shutdown_hub";
type: "new_chat" | "shutdown_hub" | "abort_session";
prompt?: string;
sessionId?: string;
}
function isVisibleClient(clientType: string): boolean {
@@ -103,6 +141,70 @@ function emitNotification(
});
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object"
? (value as Record<string, unknown>)
: undefined;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function basename(value: string | undefined): string {
const trimmed = value?.trim();
if (!trimmed) {
return "workspace";
}
const parts = trimmed.split(/[\\/]+/).filter(Boolean);
return parts.at(-1) ?? trimmed;
}
function shortSessionId(sessionId: string): string {
return sessionId.length > 10 ? sessionId.slice(0, 10) : sessionId;
}
function pushEvent(
events: EventRecord[],
title: string,
body: string,
severity: EventRecord["severity"] = "info",
timestamp = Date.now(),
): void {
events.unshift({
id: `${timestamp}-${events.length}-${title}`,
title,
body,
severity,
timestamp,
});
if (events.length > 30) {
events.length = 30;
}
}
function metadataFor(
session: SessionRecord | Record<string, unknown>,
): Record<string, unknown> {
return asRecord(session.metadata) ?? {};
}
function usageFor(
session: SessionRecord | Record<string, unknown>,
): Record<string, unknown> {
return (
asRecord(session.aggregateUsage) ??
asRecord(session.usage) ??
asRecord(metadataFor(session).aggregateUsage) ??
asRecord(metadataFor(session).usage) ??
{}
);
}
function formatUptime(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const days = Math.floor(totalSeconds / 86_400);
@@ -169,6 +271,87 @@ function summarizeClient(client: TrackedClient): {
};
}
function sessionTitle(session: SessionRecord | Record<string, unknown>): string {
const raw = session as Record<string, unknown>;
const metadata = metadataFor(session);
const title = asString(metadata.title);
if (title) {
return title;
}
const prompt = asString(raw.prompt) ?? asString(metadata.prompt);
if (prompt) {
return prompt.length > 34 ? `${prompt.slice(0, 31)}...` : prompt;
}
return basename(asString(raw.workspaceRoot) ?? asString(raw.cwd));
}
function trackedSessionFrom(
session: SessionRecord | Record<string, unknown>,
): TrackedSession | undefined {
const raw = session as Record<string, unknown>;
const sessionId = asString(raw.sessionId);
if (!sessionId) {
return undefined;
}
const metadata = metadataFor(session);
const usage = usageFor(session);
const createdAt =
asNumber(raw.createdAt) ??
asNumber(raw.startedAt) ??
asNumber(metadata.createdAt) ??
Date.now();
return {
sessionId,
status: asString(raw.status) ?? "running",
workspaceRoot: asString(raw.workspaceRoot) ?? asString(raw.cwd) ?? "",
cwd: asString(raw.cwd),
createdAt,
updatedAt:
asNumber(raw.updatedAt) ??
asNumber(raw.endedAt) ??
asNumber(metadata.updatedAt) ??
createdAt,
createdByClientId: asString(raw.createdByClientId),
title: sessionTitle(session),
provider: asString(raw.provider) ?? asString(metadata.provider),
model: asString(raw.model) ?? asString(metadata.model),
prompt: asString(raw.prompt) ?? asString(metadata.prompt),
inputTokens:
asNumber(usage.inputTokens) ??
asNumber(usage.input) ??
asNumber(usage.totalInputTokens),
outputTokens:
asNumber(usage.outputTokens) ??
asNumber(usage.output) ??
asNumber(usage.totalOutputTokens),
totalCost: asNumber(usage.totalCost) ?? asNumber(metadata.totalCost),
agentCount: Array.isArray(raw.participants)
? Math.max(1, raw.participants.length)
: 1,
};
}
function toSessionSummary(session: TrackedSession): SessionSummary {
return {
sessionId: session.sessionId,
title: session.title || basename(session.workspaceRoot),
status: session.status,
workspaceRoot: session.workspaceRoot,
workspaceName: basename(session.workspaceRoot || session.cwd),
cwd: session.cwd,
model: session.model,
provider: session.provider,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
createdByClientId: session.createdByClientId,
prompt: session.prompt,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalCost: session.totalCost,
agentCount: session.agentCount ?? 1,
};
}
function parseLastSessionContext(
session: SessionRecord | Record<string, unknown> | undefined,
): LastSessionContext | undefined {
@@ -267,6 +450,7 @@ async function main(): Promise<void> {
const clients = new Map<string, TrackedClient>();
const sessions = new Map<string, TrackedSession>();
const events: EventRecord[] = [];
let lastSessionContext: LastSessionContext | undefined;
let hubStartedAt: string | undefined;
@@ -316,13 +500,10 @@ async function main(): Promise<void> {
) {
continue;
}
sessions.set(session.sessionId, {
sessionId: session.sessionId,
status: session.status,
workspaceRoot: session.workspaceRoot,
createdAt: session.createdAt,
createdByClientId: session.createdByClientId,
});
const tracked = trackedSessionFrom(session);
if (tracked) {
sessions.set(tracked.sessionId, tracked);
}
}
const mostRecentContext = [...knownSessions]
.sort((a, b) => b.updatedAt - a.updatedAt)
@@ -331,6 +512,12 @@ async function main(): Promise<void> {
if (mostRecentContext) {
lastSessionContext = mostRecentContext;
}
pushEvent(
events,
"Hub monitor connected",
`${knownClients.length} clients and ${knownSessions.length} sessions discovered`,
"success",
);
};
function emitState(): void {
@@ -378,6 +565,10 @@ async function main(): Promise<void> {
clients: Array.from(clients.values()),
sessions: Array.from(sessions.values()),
clientSummaries,
sessionSummaries: Array.from(sessions.values())
.sort((a, b) => b.updatedAt - a.updatedAt)
.map(toSessionSummary),
events,
lastWorkspaceRoot: lastSessionContext?.workspaceRoot,
hubStartedAt,
hubUptime: hubStartedAt
@@ -455,6 +646,30 @@ async function main(): Promise<void> {
}
}
async function abortBackgroundSession(sessionId: string): Promise<void> {
const trimmedSessionId = sessionId.trim();
if (!trimmedSessionId) {
return;
}
try {
await sessionClient.abortRuntimeSession(trimmedSessionId);
pushEvent(
events,
"Session abort requested",
`Requested stop for ${shortSessionId(trimmedSessionId)}`,
"warn",
);
emitState();
} catch (error) {
emit({
type: "notification",
title: "Stop session failed",
body: error instanceof Error ? error.message : String(error),
severity: "error",
});
}
}
const stdin = createInterface({ input: process.stdin, terminal: false });
stdin.on("line", (line) => {
const trimmed = line.trim();
@@ -476,10 +691,25 @@ async function main(): Promise<void> {
if (command?.type === "shutdown_hub") {
void shutdownHub();
}
if (command?.type === "abort_session") {
if (typeof command.sessionId === "string") {
void abortBackgroundSession(command.sessionId);
}
}
});
uiClient.subscribeUI({
onNotify(payload: HubUINotifyPayload) {
pushEvent(
events,
payload.title,
payload.body,
payload.severity === "error"
? "error"
: payload.severity === "warning"
? "warn"
: "info",
);
emit({
type: "notification",
title: payload.title,
@@ -510,13 +740,26 @@ async function main(): Promise<void> {
: "unknown",
connectedAt: Date.now(),
});
pushEvent(
events,
"Client connected",
`${formatClientLabel(typeof payload.clientType === "string" ? payload.clientType : "unknown")} joined the hub`,
"success",
);
emitState();
},
onClientDisconnected(payload) {
const clientId =
typeof payload.clientId === "string" ? payload.clientId : undefined;
if (!clientId) return;
const client = clients.get(clientId);
clients.delete(clientId);
pushEvent(
events,
"Client disconnected",
`${client?.displayName ?? client?.clientType ?? clientId} left the hub`,
"warn",
);
emitState();
},
onSessionCreated(payload) {
@@ -543,19 +786,16 @@ async function main(): Promise<void> {
emitState();
return;
}
sessions.set(sessionId, {
sessionId,
status,
workspaceRoot:
typeof session.workspaceRoot === "string"
? session.workspaceRoot
: "",
createdAt: Date.now(),
createdByClientId:
typeof session.createdByClientId === "string"
? session.createdByClientId
: undefined,
});
const tracked = trackedSessionFrom({ ...session, status });
if (!tracked) return;
sessions.set(sessionId, tracked);
pushEvent(
events,
`Started session "${tracked.title ?? shortSessionId(sessionId)}"`,
`${tracked.workspaceRoot || "workspace"} on ${tracked.model ?? "selected model"}`,
"success",
tracked.createdAt,
);
emitState();
},
onSessionUpdated(payload) {
@@ -584,26 +824,42 @@ async function main(): Promise<void> {
return;
}
if (existing) {
const previousStatus = existing.status;
existing.status = status;
existing.updatedAt =
asNumber(session.updatedAt) ?? asNumber(session.endedAt) ?? Date.now();
existing.title = sessionTitle(session);
existing.workspaceRoot =
typeof session.workspaceRoot === "string"
? session.workspaceRoot
: existing.workspaceRoot;
existing.cwd =
typeof session.cwd === "string" ? session.cwd : existing.cwd;
const metadata = metadataFor(session);
existing.provider =
asString(session.provider) ??
asString(metadata.provider) ??
existing.provider;
existing.model =
asString(session.model) ?? asString(metadata.model) ?? existing.model;
existing.createdByClientId =
typeof session.createdByClientId === "string"
? session.createdByClientId
: existing.createdByClientId;
sessions.set(sessionId, existing);
if (previousStatus !== status) {
pushEvent(
events,
`Session ${status}`,
`${existing.title ?? shortSessionId(sessionId)} changed from ${previousStatus}`,
status === "running" ? "success" : status === "idle" ? "info" : "warn",
);
}
} else {
sessions.set(sessionId, {
sessionId,
status,
workspaceRoot:
typeof session.workspaceRoot === "string"
? session.workspaceRoot
: "",
createdAt: Date.now(),
createdByClientId:
typeof session.createdByClientId === "string"
? session.createdByClientId
: undefined,
});
const tracked = trackedSessionFrom({ ...session, status });
if (tracked) {
sessions.set(sessionId, tracked);
}
}
emitState();
},
@@ -617,13 +873,20 @@ async function main(): Promise<void> {
? session.sessionId
: typeof payload.sessionId === "string"
? payload.sessionId
: undefined;
: undefined;
if (!sessionId) return;
const participantCount = Array.isArray(session?.participants)
? session.participants.length
: 0;
if (participantCount <= 0) {
const tracked = sessions.get(sessionId);
sessions.delete(sessionId);
pushEvent(
events,
"Session detached",
`${tracked?.title ?? shortSessionId(sessionId)} has no active participants`,
"warn",
);
emitState();
}
},
+103 -1
View File
@@ -23,6 +23,8 @@ struct HubState {
hub_uptime: Option<String>,
last_error: Option<String>,
client_summaries: Vec<ClientSummary>,
session_summaries: Vec<SessionSummary>,
events: Vec<HubEventRecord>,
notifications: Vec<NotificationRecord>,
}
@@ -34,6 +36,37 @@ struct ClientSummary {
session_count: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SessionSummary {
session_id: String,
title: String,
status: String,
workspace_root: String,
workspace_name: String,
cwd: Option<String>,
model: Option<String>,
provider: Option<String>,
created_at: u64,
updated_at: u64,
created_by_client_id: Option<String>,
prompt: Option<String>,
input_tokens: Option<u64>,
output_tokens: Option<u64>,
total_cost: Option<f64>,
agent_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct HubEventRecord {
id: String,
title: String,
body: String,
severity: String,
timestamp: u64,
}
#[derive(Clone, Serialize, Deserialize)]
struct NotificationRecord {
title: String,
@@ -53,6 +86,8 @@ struct SidecarMessage {
clients: Option<Vec<serde_json::Value>>,
sessions: Option<Vec<serde_json::Value>>,
client_summaries: Option<Vec<ClientSummary>>,
session_summaries: Option<Vec<SessionSummary>>,
events: Option<Vec<HubEventRecord>>,
last_workspace_root: Option<String>,
hub_uptime: Option<String>,
title: Option<String>,
@@ -133,6 +168,13 @@ fn push_notification(
fn resolve_sidecar_script(workspace_root: &str, launch_cwd: &str) -> Option<PathBuf> {
let candidates = [
PathBuf::from(workspace_root)
.join("sdk")
.join("apps")
.join("examples")
.join("menubar")
.join("sidecar")
.join("index.ts"),
PathBuf::from(workspace_root)
.join("apps")
.join("examples")
@@ -161,6 +203,16 @@ fn resolve_sidecar_binary(workspace_root: &str) -> Option<PathBuf> {
let binary_name = sidecar_binary_name();
let current_exe = std::env::current_exe().ok();
let candidates = [
Some(
PathBuf::from(workspace_root)
.join("sdk")
.join("apps")
.join("examples")
.join("menubar")
.join("src-tauri")
.join("bin")
.join(&binary_name),
),
Some(
PathBuf::from(workspace_root)
.join("apps")
@@ -305,6 +357,8 @@ fn handle_sidecar_message(state: &Arc<AppState>, msg: SidecarMessage, raw: &str)
if let Ok(mut hub) = state.hub_state.lock() {
hub.connected = connected;
hub.client_summaries = msg.client_summaries.unwrap_or_default();
hub.session_summaries = msg.session_summaries.unwrap_or_default();
hub.events = msg.events.unwrap_or_default();
hub.last_workspace_root = msg
.last_workspace_root
.and_then(|value| if value.trim().is_empty() { None } else { Some(value) });
@@ -517,6 +571,9 @@ fn build_tray_menu(
hub_state.last_workspace_root.is_some(),
None::<&str>,
)?;
let open_dashboard_item =
MenuItem::with_id(app, "open_dashboard", "Open Dashboard", true, None::<&str>)?;
items.push(Box::new(open_dashboard_item));
items.push(Box::new(new_chat_item));
items.push(Box::new(PredefinedMenuItem::separator(app)?));
@@ -554,6 +611,8 @@ fn get_hub_state(state: tauri::State<'_, Arc<AppState>>) -> serde_json::Value {
serde_json::json!({
"connected": hub.connected,
"clientSummaries": hub.client_summaries,
"sessionSummaries": hub.session_summaries,
"events": hub.events,
"lastWorkspaceRoot": hub.last_workspace_root,
"hubUptime": hub.hub_uptime,
"lastError": hub.last_error,
@@ -562,6 +621,39 @@ fn get_hub_state(state: tauri::State<'_, Arc<AppState>>) -> serde_json::Value {
})
}
#[tauri::command]
fn start_new_session(prompt: String, state: tauri::State<'_, Arc<AppState>>) -> Result<(), String> {
let prompt = prompt.trim().to_string();
if prompt.is_empty() {
return Err("Prompt cannot be empty".to_string());
}
send_sidecar_command(
state.inner(),
serde_json::json!({
"type": "new_chat",
"prompt": prompt,
}),
)
}
#[tauri::command]
fn abort_session(
session_id: String,
state: tauri::State<'_, Arc<AppState>>,
) -> Result<(), String> {
let session_id = session_id.trim().to_string();
if session_id.is_empty() {
return Err("Session id cannot be empty".to_string());
}
send_sidecar_command(
state.inner(),
serde_json::json!({
"type": "abort_session",
"sessionId": session_id,
}),
)
}
fn main() {
let app_state = Arc::new(AppState::new());
let launch_cwd = std::env::current_dir()
@@ -649,6 +741,12 @@ fn main() {
thread::sleep(Duration::from_millis(300));
app.exit(0);
}
"open_dashboard" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"latest_error" => {
let state = app.state::<Arc<AppState>>();
let error = {
@@ -709,7 +807,11 @@ fn main() {
Ok(())
})
.invoke_handler(tauri::generate_handler![get_hub_state])
.invoke_handler(tauri::generate_handler![
get_hub_state,
start_new_session,
abort_session
])
.run(tauri::generate_context!())
.expect("error running menubar app");
}
@@ -6,10 +6,23 @@
"build": {
"beforeDevCommand": "bun run build:sidecar:bin",
"beforeBuildCommand": "bun run build:sidecar:bin",
"frontendDist": "../ui",
"beforeBundleCommand": "[ \"$(uname)\" = \"Darwin\" ] && [ -e ./target/release/bundle/macos/Cline\\ Hub.app ] && xattr -d com.apple.quarantine ./target/release/bundle/macos/Cline\\ Hub.app || true"
},
"app": {
"windows": [],
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "Cline Hub Monitor",
"width": 1600,
"height": 900,
"minWidth": 1180,
"minHeight": 720,
"resizable": true,
"decorations": false
}
],
"security": {
"csp": null
}
File diff suppressed because it is too large Load Diff