diff --git a/sdk/apps/examples/menubar/README.md b/sdk/apps/examples/menubar/README.md index 7855a08d39..d81d085eef 100644 --- a/sdk/apps/examples/menubar/README.md +++ b/sdk/apps/examples/menubar/README.md @@ -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 diff --git a/sdk/apps/examples/menubar/assets/hub-monitor-preview.jpg b/sdk/apps/examples/menubar/assets/hub-monitor-preview.jpg new file mode 100644 index 0000000000..0bd2fec222 Binary files /dev/null and b/sdk/apps/examples/menubar/assets/hub-monitor-preview.jpg differ diff --git a/sdk/apps/examples/menubar/package.json b/sdk/apps/examples/menubar/package.json index 61ec00bd1b..b95c3fffc5 100644 --- a/sdk/apps/examples/menubar/package.json +++ b/sdk/apps/examples/menubar/package.json @@ -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", diff --git a/sdk/apps/examples/menubar/sidecar/index.ts b/sdk/apps/examples/menubar/sidecar/index.ts index 68460708ef..df0d42023f 100644 --- a/sdk/apps/examples/menubar/sidecar/index.ts +++ b/sdk/apps/examples/menubar/sidecar/index.ts @@ -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 | undefined { + return value && typeof value === "object" + ? (value as Record) + : 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, +): Record { + return asRecord(session.metadata) ?? {}; +} + +function usageFor( + session: SessionRecord | Record, +): Record { + 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 { + const raw = session as Record; + 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, +): TrackedSession | undefined { + const raw = session as Record; + 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 | undefined, ): LastSessionContext | undefined { @@ -267,6 +450,7 @@ async function main(): Promise { const clients = new Map(); const sessions = new Map(); + const events: EventRecord[] = []; let lastSessionContext: LastSessionContext | undefined; let hubStartedAt: string | undefined; @@ -316,13 +500,10 @@ async function main(): Promise { ) { 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 { 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 { 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 { } } + async function abortBackgroundSession(sessionId: string): Promise { + 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 { 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 { : "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 { 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 { 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 { ? 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(); } }, diff --git a/sdk/apps/examples/menubar/src-tauri/src/main.rs b/sdk/apps/examples/menubar/src-tauri/src/main.rs index 32721d61fd..a166141a62 100644 --- a/sdk/apps/examples/menubar/src-tauri/src/main.rs +++ b/sdk/apps/examples/menubar/src-tauri/src/main.rs @@ -23,6 +23,8 @@ struct HubState { hub_uptime: Option, last_error: Option, client_summaries: Vec, + session_summaries: Vec, + events: Vec, notifications: Vec, } @@ -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, + model: Option, + provider: Option, + created_at: u64, + updated_at: u64, + created_by_client_id: Option, + prompt: Option, + input_tokens: Option, + output_tokens: Option, + total_cost: Option, + 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>, sessions: Option>, client_summaries: Option>, + session_summaries: Option>, + events: Option>, last_workspace_root: Option, hub_uptime: Option, title: Option, @@ -133,6 +168,13 @@ fn push_notification( fn resolve_sidecar_script(workspace_root: &str, launch_cwd: &str) -> Option { 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 { 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, 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>) -> 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>) -> serde_json::Value { }) } +#[tauri::command] +fn start_new_session(prompt: String, state: tauri::State<'_, Arc>) -> 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>, +) -> 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::>(); 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"); } diff --git a/sdk/apps/examples/menubar/src-tauri/tauri.conf.json b/sdk/apps/examples/menubar/src-tauri/tauri.conf.json index d5c5dda37a..1a3b3312fc 100644 --- a/sdk/apps/examples/menubar/src-tauri/tauri.conf.json +++ b/sdk/apps/examples/menubar/src-tauri/tauri.conf.json @@ -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 } diff --git a/sdk/apps/examples/menubar/ui/index.html b/sdk/apps/examples/menubar/ui/index.html new file mode 100644 index 0000000000..8bc5f0042b --- /dev/null +++ b/sdk/apps/examples/menubar/ui/index.html @@ -0,0 +1,1341 @@ + + + + + + Cline Hub Monitor + + + +
+
+
+ + +
+ +
+ + +
+
+
Session Inspector
+
+ + + +
+
+ +
+
+ +
+ refactor-auth + Preview data +
+
Running
+
+
+ dev-studio - Started 8m ago - ID: sess_8f3c2a7d +
+ +
+ +
+
+
Overview
+
+
+ +
+
Live Transcript (preview)
+
+
+ +
+
+ Timeline + +
+
+
+
+ + +
+
+
+
+ + + +