diff --git a/packages/chrome-extension/manifest.json b/packages/chrome-extension/manifest.json index 4299c697..8d0eed46 100644 --- a/packages/chrome-extension/manifest.json +++ b/packages/chrome-extension/manifest.json @@ -3,7 +3,7 @@ "name": "Markus Browser Automation", "description": "Enables Markus AI agents to automate browser tasks without the remote debugging dialog.", "version": "1.0.0", - "permissions": ["debugger", "tabs", "activeTab", "scripting"], + "permissions": ["debugger", "tabs", "activeTab", "scripting", "storage"], "host_permissions": [""], "background": { "service_worker": "dist/background.js", diff --git a/packages/chrome-extension/src/background.ts b/packages/chrome-extension/src/background.ts index 8694a31e..0e9e326f 100644 --- a/packages/chrome-extension/src/background.ts +++ b/packages/chrome-extension/src/background.ts @@ -27,8 +27,9 @@ setupNetworkListener(); // Clean up page state when tabs are closed chrome.tabs.onRemoved.addListener((tabId) => { + const pageId = pm.peekPageId(tabId); pm.removeByTabId(tabId); - client.send({ event: 'tab_closed', data: { tabId } }); + client.send({ event: 'tab_closed', data: { tabId, pageId } }); }); // Handle debugger detach events @@ -50,7 +51,12 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } }); -// Connect to bridge -client.connect(); - -console.log('[Markus] Browser automation extension initialized'); +// Restore PM state from chrome.storage.session (survives service worker restarts), +// then connect to bridge. +pm.restore().then((restored) => { + if (restored) { + console.log('[Markus] Reconnecting with restored page state'); + } + client.connect(); + console.log('[Markus] Browser automation extension initialized'); +}); diff --git a/packages/chrome-extension/src/debugger-helper.ts b/packages/chrome-extension/src/debugger-helper.ts new file mode 100644 index 00000000..3e2a38e1 --- /dev/null +++ b/packages/chrome-extension/src/debugger-helper.ts @@ -0,0 +1,33 @@ +/** + * Shared debugger attachment helper. + * Handles state desynchronization caused by service worker restarts: + * the PM's in-memory `debuggerAttached` set may have been cleared while + * Chrome's actual debugger connections persist. + */ + +import type { PageManager } from './page-manager.js'; + +export async function ensureDebugger(pm: PageManager, tabId: number): Promise { + if (pm.isDebuggerAttached(tabId)) return; + + try { + await chrome.debugger.attach({ tabId }, '1.3'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('already attached') || msg.includes('Another debugger')) { + // Chrome says attached but PM disagrees → stale PM state after SW restart. + // Re-sync: mark attached and enable domains (they may also be stale). + pm.setDebuggerAttached(tabId, true); + try { + await chrome.debugger.sendCommand({ tabId }, 'Page.enable'); + await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); + } catch { /* domains may already be enabled */ } + return; + } + throw err; + } + + pm.setDebuggerAttached(tabId, true); + await chrome.debugger.sendCommand({ tabId }, 'Page.enable'); + await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); +} diff --git a/packages/chrome-extension/src/page-manager.ts b/packages/chrome-extension/src/page-manager.ts index 7257afc8..17fa5073 100644 --- a/packages/chrome-extension/src/page-manager.ts +++ b/packages/chrome-extension/src/page-manager.ts @@ -10,6 +10,7 @@ export class PageManager { private nextPageId = 1; private _selectedPageId: number | null = null; private debuggerAttached = new Set(); + private persistTimer: ReturnType | null = null; get selectedPageId(): number | null { return this._selectedPageId; } get selectedTabId(): number | null { @@ -17,23 +18,111 @@ export class PageManager { return this.pageToTab.get(this._selectedPageId) ?? null; } + /** + * Restore PM state from chrome.storage.session. + * Called on service worker startup to recover page↔tab mappings + * that would otherwise be lost when the service worker is terminated. + */ + async restore(): Promise { + try { + const data = await chrome.storage.session.get('pm_state'); + if (!data.pm_state) return false; + const s = data.pm_state as { + tabToPage: [number, number][]; + pageToTab: [number, number][]; + nextPageId: number; + selectedPageId: number | null; + }; + + // Verify tabs still exist before restoring mappings + const liveTabs = new Set(); + try { + for (const tab of await chrome.tabs.query({})) { + if (tab.id) liveTabs.add(tab.id); + } + } catch { /* ignore */ } + + this.tabToPage = new Map(); + this.pageToTab = new Map(); + let maxId = 0; + for (const [tabId, pageId] of s.tabToPage) { + if (liveTabs.has(tabId)) { + this.tabToPage.set(tabId, pageId); + this.pageToTab.set(pageId, tabId); + if (pageId > maxId) maxId = pageId; + } + } + this.nextPageId = Math.max(s.nextPageId, maxId + 1); + this._selectedPageId = + s.selectedPageId !== null && this.pageToTab.has(s.selectedPageId) + ? s.selectedPageId + : null; + + console.log(`[Markus] PM restored: ${this.tabToPage.size} pages, nextId=${this.nextPageId}`); + return true; + } catch (err) { + console.warn('[Markus] PM restore failed:', err); + return false; + } + } + + private schedulePersist(): void { + if (this.persistTimer) return; + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + chrome.storage.session.set({ + pm_state: { + tabToPage: [...this.tabToPage.entries()], + pageToTab: [...this.pageToTab.entries()], + nextPageId: this.nextPageId, + selectedPageId: this._selectedPageId, + }, + }).catch(() => { /* ignore persist failures */ }); + }, 50); + } + getPageId(tabId: number): number { let pageId = this.tabToPage.get(tabId); if (pageId === undefined) { pageId = this.nextPageId++; this.tabToPage.set(tabId, pageId); this.pageToTab.set(pageId, tabId); + this.schedulePersist(); } return pageId; } + /** Read-only lookup: returns existing pageId for a tabId, or undefined. */ + peekPageId(tabId: number): number | undefined { + return this.tabToPage.get(tabId); + } + getTabId(pageId: number): number | undefined { return this.pageToTab.get(pageId); } + /** + * Resolve which tab to operate on. If params contains `_pageId`, use that + * explicit page (multi-agent safe). Otherwise fall back to the global + * selectedTabId (legacy / npx compat). + */ + resolveTabId(params: Record): number { + const pageId = params._pageId as number | undefined; + if (pageId !== undefined) { + const tabId = this.pageToTab.get(pageId); + if (tabId === undefined) throw new Error(`Page ${pageId} not found.`); + return tabId; + } + if (this._selectedPageId === null) throw new Error('No page selected. Call new_page or select_page first.'); + const tabId = this.pageToTab.get(this._selectedPageId); + if (tabId === undefined) throw new Error('No page selected. Call new_page or select_page first.'); + return tabId; + } + selectPage(pageId: number): boolean { if (!this.pageToTab.has(pageId)) return false; this._selectedPageId = pageId; + this.schedulePersist(); return true; } @@ -47,6 +136,7 @@ export class PageManager { if (this._selectedPageId === pageId) { this._selectedPageId = null; } + this.schedulePersist(); } removeByTabId(tabId: number): void { @@ -78,5 +168,6 @@ export class PageManager { this.debuggerAttached.clear(); this._selectedPageId = null; this.nextPageId = 1; + this.schedulePersist(); } } diff --git a/packages/chrome-extension/src/protocol.ts b/packages/chrome-extension/src/protocol.ts index d40f892b..0120c8b8 100644 --- a/packages/chrome-extension/src/protocol.ts +++ b/packages/chrome-extension/src/protocol.ts @@ -28,6 +28,7 @@ export class BridgeClient { private reconnectTimer: ReturnType | null = null; private keepaliveTimer: ReturnType | null = null; private _connected = false; + private requestQueue: Promise = Promise.resolve(); constructor(url?: string) { this.url = url ?? DEFAULT_URL; @@ -75,13 +76,24 @@ export class BridgeClient { this.ws.onmessage = (event) => { try { const msg = JSON.parse(event.data as string) as BridgeRequest; - this.handleRequest(msg); + this.enqueueRequest(msg); } catch (err) { console.error('[Markus] Failed to parse message:', err); } }; } + /** + * Serialize all incoming requests so only one tool runs at a time. + * Prevents race conditions on shared PageManager state (selectedPageId) + * when multiple agents issue concurrent tool calls. + */ + private enqueueRequest(req: BridgeRequest): void { + this.requestQueue = this.requestQueue + .then(() => this.handleRequest(req)) + .catch((err) => console.error('[Markus] Request queue error:', err)); + } + private async handleRequest(req: BridgeRequest): Promise { const handler = this.handlers.get(req.method); if (!handler) { diff --git a/packages/chrome-extension/src/tools/input.ts b/packages/chrome-extension/src/tools/input.ts index 3fb3e3c8..f6f7ee00 100644 --- a/packages/chrome-extension/src/tools/input.ts +++ b/packages/chrome-extension/src/tools/input.ts @@ -6,25 +6,12 @@ */ import type { PageManager } from '../page-manager.js'; +import { ensureDebugger } from '../debugger-helper.js'; async function cdp(tabId: number, method: string, params?: Record): Promise { return chrome.debugger.sendCommand({ tabId }, method, params); } -async function ensureDebugger(pm: PageManager, tabId: number): Promise { - if (pm.isDebuggerAttached(tabId)) return; - await chrome.debugger.attach({ tabId }, '1.3'); - pm.setDebuggerAttached(tabId, true); - await chrome.debugger.sendCommand({ tabId }, 'Page.enable'); - await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); -} - -function requireSelectedTab(pm: PageManager): number { - const tabId = pm.selectedTabId; - if (tabId === null) throw new Error('No page selected. Call new_page or select_page first.'); - return tabId; -} - /** * Resolve a snapshot uid to DOM coordinates via JS evaluation. * The uid corresponds to an aria-snapshot element with a data-uid attribute @@ -74,7 +61,7 @@ export function registerInputTools( register('click', async (params) => { const uid = params.uid as string; if (!uid) throw new Error('uid is required'); - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); const { x, y } = await resolveUidToCoords(tabId, uid); @@ -87,7 +74,7 @@ export function registerInputTools( const value = params.value as string; if (!uid) throw new Error('uid is required'); if (value === undefined) throw new Error('value is required'); - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); const { x, y } = await resolveUidToCoords(tabId, uid); @@ -102,7 +89,7 @@ export function registerInputTools( register('fill_form', async (params) => { const fields = params.fields as Array<{ uid: string; value: string }>; if (!fields || !Array.isArray(fields)) throw new Error('fields array is required'); - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); const results: string[] = []; @@ -120,7 +107,7 @@ export function registerInputTools( register('type_text', async (params) => { const text = params.text as string; if (!text) throw new Error('text is required'); - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); await cdp(tabId, 'Input.insertText', { text }); @@ -130,7 +117,7 @@ export function registerInputTools( register('press_key', async (params) => { const key = params.key as string; if (!key) throw new Error('key is required'); - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); const parts = key.split('+'); @@ -157,7 +144,7 @@ export function registerInputTools( register('hover', async (params) => { const uid = params.uid as string; if (!uid) throw new Error('uid is required'); - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); const { x, y } = await resolveUidToCoords(tabId, uid); @@ -171,7 +158,7 @@ export function registerInputTools( const fromUid = params.from_uid as string ?? params.fromUid as string; const toUid = params.to_uid as string ?? params.toUid as string; if (!fromUid || !toUid) throw new Error('from_uid and to_uid are required'); - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); const from = await resolveUidToCoords(tabId, fromUid); @@ -194,7 +181,7 @@ export function registerInputTools( register('handle_dialog', async (params) => { const accept = params.accept !== false; const promptText = params.promptText as string | undefined; - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); await cdp(tabId, 'Page.handleJavaScriptDialog', { @@ -208,7 +195,7 @@ export function registerInputTools( const uid = params.uid as string; const filePath = params.filePath as string ?? params.file_path as string; if (!uid || !filePath) throw new Error('uid and filePath are required'); - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); // Resolve uid to DOM node diff --git a/packages/chrome-extension/src/tools/inspection.ts b/packages/chrome-extension/src/tools/inspection.ts index e4f8f682..f87fd90a 100644 --- a/packages/chrome-extension/src/tools/inspection.ts +++ b/packages/chrome-extension/src/tools/inspection.ts @@ -4,25 +4,12 @@ */ import type { PageManager } from '../page-manager.js'; +import { ensureDebugger } from '../debugger-helper.js'; async function cdp(tabId: number, method: string, params?: Record): Promise { return chrome.debugger.sendCommand({ tabId }, method, params); } -async function ensureDebugger(pm: PageManager, tabId: number): Promise { - if (pm.isDebuggerAttached(tabId)) return; - await chrome.debugger.attach({ tabId }, '1.3'); - pm.setDebuggerAttached(tabId, true); - await chrome.debugger.sendCommand({ tabId }, 'Page.enable'); - await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); -} - -function requireSelectedTab(pm: PageManager): number { - const tabId = pm.selectedTabId; - if (tabId === null) throw new Error('No page selected. Call new_page or select_page first.'); - return tabId; -} - // Console message storage per tab const consoleMessages = new Map>(); let nextMsgId = 1; @@ -55,7 +42,7 @@ export function registerInspectionTools( ): void { register('take_screenshot', async (params) => { - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); const format = (params.format as string) || 'png'; @@ -75,7 +62,7 @@ export function registerInspectionTools( }); register('take_snapshot', async (params) => { - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); // Get accessibility tree @@ -136,17 +123,27 @@ export function registerInspectionTools( register('evaluate_script', async (params) => { const expression = params.expression as string; if (!expression) throw new Error('expression is required'); - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); const result = await cdp(tabId, 'Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true, - }) as { result?: { value?: unknown; description?: string }; exceptionDetails?: { text?: string } }; + }) as { + result?: { value?: unknown; description?: string }; + exceptionDetails?: { + text?: string; + exception?: { description?: string; value?: unknown }; + }; + }; if (result?.exceptionDetails) { - throw new Error(`Script error: ${result.exceptionDetails.text}`); + const detail = result.exceptionDetails.exception?.description + ?? String(result.exceptionDetails.exception?.value ?? '') + ?? result.exceptionDetails.text + ?? 'Unknown error'; + throw new Error(`Script error: ${detail}`); } const value = result?.result?.value; @@ -157,7 +154,7 @@ export function registerInspectionTools( register('get_console_message', async (params) => { const msgId = params.msgid as number ?? params.id as number; if (msgId === undefined) throw new Error('msgid is required'); - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); const messages = consoleMessages.get(tabId) ?? []; const msg = messages.find(m => m.id === msgId); @@ -166,8 +163,8 @@ export function registerInspectionTools( return `[${msg.level}] ${msg.text}`; }); - register('list_console_messages', async () => { - const tabId = requireSelectedTab(pm); + register('list_console_messages', async (params) => { + const tabId = pm.resolveTabId(params); const messages = consoleMessages.get(tabId) ?? []; if (messages.length === 0) return 'No console messages'; @@ -176,7 +173,7 @@ export function registerInspectionTools( }); register('lighthouse_audit', async (params) => { - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); const categories = (params.categories as string[]) ?? ['accessibility', 'best-practices', 'seo']; // Lighthouse is not available via chrome.debugger — provide a basic a11y audit instead diff --git a/packages/chrome-extension/src/tools/navigation.ts b/packages/chrome-extension/src/tools/navigation.ts index 87a7e540..a190b3a8 100644 --- a/packages/chrome-extension/src/tools/navigation.ts +++ b/packages/chrome-extension/src/tools/navigation.ts @@ -6,15 +6,7 @@ */ import type { PageManager } from '../page-manager.js'; - -/** Helper: attach debugger to tab if not already attached */ -async function ensureDebugger(pm: PageManager, tabId: number): Promise { - if (pm.isDebuggerAttached(tabId)) return; - await chrome.debugger.attach({ tabId }, '1.3'); - pm.setDebuggerAttached(tabId, true); - await chrome.debugger.sendCommand({ tabId }, 'Page.enable'); - await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); -} +import { ensureDebugger } from '../debugger-helper.js'; /** Helper: send CDP command on a tab */ async function cdp(tabId: number, method: string, params?: Record): Promise { @@ -118,14 +110,18 @@ export function registerNavigationTools( return `Closed page ${pageId}`; }); - register('list_pages', async () => { + register('list_pages', async (params) => { const tabs = await chrome.tabs.query({}); const entries: Array<{ pageId: number; tab: chrome.tabs.Tab; selected: boolean }> = []; + const explicitPageId = params._pageId as number | undefined; for (const tab of tabs) { if (!tab.id || tab.id === chrome.tabs.TAB_ID_NONE) continue; const pageId = pm.getPageId(tab.id); - entries.push({ pageId, tab, selected: pageId === pm.selectedPageId }); + const isSelected = explicitPageId !== undefined + ? pageId === explicitPageId + : pageId === pm.selectedPageId; + entries.push({ pageId, tab, selected: isSelected }); } entries.sort((a, b) => a.pageId - b.pageId); @@ -158,8 +154,7 @@ export function registerNavigationTools( const url = params.url as string | undefined; const timeout = (params.timeout as number) || 15000; - const tabId = pm.selectedTabId; - if (tabId === null) throw new Error('No page selected. Call new_page or select_page first.'); + const tabId = pm.resolveTabId(params); if (url) { await ensureDebugger(pm, tabId); @@ -190,8 +185,7 @@ export function registerNavigationTools( const timeout = (params.timeout as number) || 30000; if (!text) throw new Error('text parameter is required'); - const tabId = pm.selectedTabId; - if (tabId === null) throw new Error('No page selected.'); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); diff --git a/packages/chrome-extension/src/tools/network.ts b/packages/chrome-extension/src/tools/network.ts index f62813bf..8e0dcaa0 100644 --- a/packages/chrome-extension/src/tools/network.ts +++ b/packages/chrome-extension/src/tools/network.ts @@ -5,25 +5,12 @@ */ import type { PageManager } from '../page-manager.js'; +import { ensureDebugger } from '../debugger-helper.js'; async function cdp(tabId: number, method: string, params?: Record): Promise { return chrome.debugger.sendCommand({ tabId }, method, params); } -async function ensureDebugger(pm: PageManager, tabId: number): Promise { - if (pm.isDebuggerAttached(tabId)) return; - await chrome.debugger.attach({ tabId }, '1.3'); - pm.setDebuggerAttached(tabId, true); - await chrome.debugger.sendCommand({ tabId }, 'Page.enable'); - await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); -} - -function requireSelectedTab(pm: PageManager): number { - const tabId = pm.selectedTabId; - if (tabId === null) throw new Error('No page selected.'); - return tabId; -} - // Network request storage per tab interface StoredRequest { id: string; @@ -75,8 +62,8 @@ export function registerNetworkTools( pm: PageManager, ): void { - register('list_network_requests', async () => { - const tabId = requireSelectedTab(pm); + register('list_network_requests', async (params) => { + const tabId = pm.resolveTabId(params); await enableNetwork(pm, tabId); const reqs = networkRequests.get(tabId) ?? []; @@ -90,7 +77,7 @@ export function registerNetworkTools( register('get_network_request', async (params) => { const reqId = params.reqid as string ?? params.id as string; if (!reqId) throw new Error('reqid is required'); - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); const reqs = networkRequests.get(tabId) ?? []; const req = reqs.find(r => r.id === reqId); @@ -105,8 +92,8 @@ export function registerNetworkTools( }, null, 2); }); - register('performance_start_trace', async () => { - const tabId = requireSelectedTab(pm); + register('performance_start_trace', async (params) => { + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); await cdp(tabId, 'Tracing.start', { categories: '-*,devtools.timeline,v8.execute,disabled-by-default-devtools.timeline', @@ -114,8 +101,8 @@ export function registerNetworkTools( return 'Performance trace started'; }); - register('performance_stop_trace', async () => { - const tabId = requireSelectedTab(pm); + register('performance_stop_trace', async (params) => { + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); await cdp(tabId, 'Tracing.end'); return 'Performance trace stopped. Results will be available via tracing events.'; @@ -131,7 +118,7 @@ export function registerNetworkTools( }); register('emulate', async (params) => { - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); await ensureDebugger(pm, tabId); if (params.width || params.height) { @@ -171,7 +158,7 @@ export function registerNetworkTools( const width = params.width as number; const height = params.height as number; if (!width || !height) throw new Error('width and height are required'); - const tabId = requireSelectedTab(pm); + const tabId = pm.resolveTabId(params); const tab = await chrome.tabs.get(tabId); if (tab.windowId) { diff --git a/packages/core/src/agent-manager.ts b/packages/core/src/agent-manager.ts index e2e56d98..ef6df9a9 100644 --- a/packages/core/src/agent-manager.ts +++ b/packages/core/src/agent-manager.ts @@ -37,6 +37,7 @@ import type { SkillRegistry } from './skills/types.js'; import { clickChromeAllowDialog } from './tools/chrome-dialog-clicker.js'; import { MarkusBrowserBridge } from './tools/markus-browser-bridge.js'; import { createBridgeToolHandlers, getBridgeToolDescriptors } from './tools/markus-browser-mcp.js'; +import { runQuickBrowserTest, runChaosBrowserTest, type BrowserTestResult, type ChaosEvent } from './tools/browser-test.js'; import { SecurityGuard, type SecurityPolicy } from './security.js'; import { DelegationManager, type TaskDelegation } from '@markus/a2a'; import type { TemplateRegistry } from './templates/registry.js'; @@ -605,6 +606,12 @@ export class AgentManager { if (port !== undefined) { this.browserBridge = new MarkusBrowserBridge(port); } + this.browserBridge.onEvent((event, data) => { + if (event === 'tab_closed') { + const { pageId } = (data ?? {}) as { pageId?: number }; + this.browserSessionManager.handleTabClosed(pageId); + } + }); this.browserBridge.start(); } @@ -620,6 +627,18 @@ export class AgentManager { return this.browserBridge; } + async runQuickBrowserTest(): Promise { + return runQuickBrowserTest(this.browserBridge, this.browserSessionManager); + } + + runChaosBrowserTest(opts: { + durationMs?: number; + agentCount?: number; + signal?: AbortSignal; + }): AsyncGenerator { + return runChaosBrowserTest(this.browserBridge, this.browserSessionManager, opts); + } + /** * When remoteDebuggingPort is configured, replace --autoConnect with * --browserUrl so that the chrome-devtools MCP server reuses a persistent @@ -683,8 +702,10 @@ export class AgentManager { if (result.error) return `Error: ${result.error}`; return result.content; } - // npx fallback; auto-click is triggered by mcpManager's onReconnect callback - return this.mcpManager.callToolScoped(serverName, agentId, tool.name, args); + // npx fallback — strip _pageId (npx MCP doesn't understand it; + // tab selection is handled by ensureCorrectPage + select_page) + const { _pageId, ...cleanArgs } = args; + return this.mcpManager.callToolScoped(serverName, agentId, tool.name, cleanArgs); }, })); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 84be4669..45ab07be 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -244,3 +244,4 @@ export { } from './tools/chrome-dialog-clicker.js'; export { MarkusBrowserBridge } from './tools/markus-browser-bridge.js'; export { createBridgeToolHandlers, getBridgeToolDescriptors } from './tools/markus-browser-mcp.js'; +export type { BrowserTestResult, BrowserTestStep, ChaosEvent, ChaosOpResult, ChaosStats, ChaosDone } from './tools/browser-test.js'; diff --git a/packages/core/src/tools/browser-session.ts b/packages/core/src/tools/browser-session.ts index c628f6f6..144f180c 100644 --- a/packages/core/src/tools/browser-session.ts +++ b/packages/core/src/tools/browser-session.ts @@ -90,10 +90,33 @@ export class BrowserSessionManager { map.set(serverKey, callback); } + // ─── Extension event handling ───────────────────────────────────────────── + + /** + * Called when the Chrome extension reports a tab was closed. + * Proactively remove the pageId from all ownership sets and currentPage + * pointers so agents don't try to use a stale page. + */ + handleTabClosed(pageId: number | undefined): void { + if (pageId === undefined) return; + for (const [key, owned] of this.ownedPages) { + if (owned.delete(pageId)) { + log.debug(`Removed closed page ${pageId} from ownership set ${key}`); + } + } + for (const [key, val] of this.currentPage) { + if (val === pageId) { + this.currentPage.delete(key); + log.debug(`Cleared currentPage pointer for ${key} (was page ${pageId})`); + } + } + } + // ─── Stale page recovery ────────────────────────────────────────────────── private isStalePageError(result: string): boolean { - return result.includes(STALE_PAGE_ERROR); + return result.includes(STALE_PAGE_ERROR) + || /Page \d+ not found/.test(result); } /** @@ -311,8 +334,13 @@ export class BrowserSessionManager { if (!this.isStalePageError(result)) { const pages = this.parsePageEntries(result); - const newPage = pages.find((p) => p.selected) - ?? pages.find((p) => !prevIds.has(p.id)) + // Identify the newly created page. Prefer pages NOT previously + // owned by this session (highest ID among those = the new tab), + // falling back to selected / highest-ID overall. + const notPrevOwned = pages.filter((p) => !prevIds.has(p.id)); + const newPage = notPrevOwned.find((p) => p.selected) + ?? (notPrevOwned.length > 0 ? notPrevOwned.reduce((a, b) => (a.id > b.id ? a : b)) : undefined) + ?? pages.find((p) => p.selected) ?? (pages.length > 0 ? pages.reduce((a, b) => (a.id > b.id ? a : b)) : undefined); if (newPage) { // Re-fetch owned after potential reconnect (reconnect clears state) @@ -337,6 +365,8 @@ export class BrowserSessionManager { execute: async (args: Record) => { const ownerKey = this.extractOwnerKey(agentId, args); return this.withAgentLock(agentId, async () => { + const currentPageId = this.currentPage.get(ownerKey); + if (currentPageId !== undefined) args._pageId = currentPageId; let result = await handler.execute(args); if (this.isStalePageError(result)) { @@ -404,15 +434,29 @@ export class BrowserSessionManager { return JSON.stringify({ error: msg }); } return this.withAgentLock(agentId, async () => { + // Adjust ownership and currentPage BEFORE calling the handler. + // The handler triggers chrome.tabs.remove → chrome.tabs.onRemoved → + // handleTabClosed, which runs during our await. If we haven't + // adjusted currentPage yet, handleTabClosed will delete it outright + // instead of letting us switch to the next owned tab. + if (pageId !== undefined) { + this.getOwned(ownerKey).delete(pageId); + if (this.currentPage.get(ownerKey) === pageId) { + const remaining = this.getOwned(ownerKey); + const next = remaining.size > 0 ? [...remaining][remaining.size - 1] : undefined; + if (next !== undefined) { + this.currentPage.set(ownerKey, next); + } else { + this.currentPage.delete(ownerKey); + } + } + } + const result = await handler.execute(args); if (this.isStalePageError(result)) { const ok = await this.reconnectMcp(agentId); if (ok) { - // Remove only the failed page from ownership - if (pageId !== undefined) { - this.getOwned(ownerKey).delete(pageId); - } const remaining = this.getOwned(ownerKey); if (remaining.size > 0) { return `Tab ${pageId ?? 'unknown'} was already closed externally. ` @@ -423,18 +467,6 @@ export class BrowserSessionManager { } } - if (pageId !== undefined) { - this.getOwned(ownerKey).delete(pageId); - if (this.currentPage.get(ownerKey) === pageId) { - const remaining = this.getOwned(ownerKey); - const next = remaining.size > 0 ? [...remaining][remaining.size - 1] : undefined; - if (next !== undefined) { - this.currentPage.set(ownerKey, next); - } else { - this.currentPage.delete(ownerKey); - } - } - } return this.annotateResponse(result, ownerKey); }); }, @@ -461,12 +493,13 @@ export class BrowserSessionManager { return this.withAgentLock(agentId, async () => { await this.ensureCorrectPage(agentId, ownerKey); + const currentPageId = this.currentPage.get(ownerKey); + if (currentPageId !== undefined) args._pageId = currentPageId; const result = await handler.execute(args); if (this.isStalePageError(result)) { const ok = await this.reconnectMcp(agentId); if (ok) { - // After reconnect, state is cleared. Fall back to auto-create. return this.navigateAutoCreateLocked(agentId, ownerKey, args, newPageHandler); } } @@ -523,8 +556,10 @@ export class BrowserSessionManager { if (!this.isStalePageError(result)) { const owned = this.getOwned(ownerKey); const pages = this.parsePageEntries(result); - const newPage = pages.find((p) => p.selected) - ?? (pages.length > 0 ? pages.reduce((a, b) => (a.id > b.id ? a : b)) : undefined); + // Prefer highest-ID page (the just-created tab always gets the + // highest auto-incremented ID). Only fall back to [selected] if + // no pages exist at all. + const newPage = (pages.length > 0 ? pages.reduce((a, b) => (a.id > b.id ? a : b)) : undefined); if (newPage) { owned.add(newPage.id); this.currentPage.set(ownerKey, newPage.id); @@ -561,13 +596,15 @@ export class BrowserSessionManager { } return this.withAgentLock(agentId, async () => { await this.ensureCorrectPage(agentId, ownerKey); + // Inject _pageId so the extension resolves target tab explicitly, + // eliminating dependency on shared selectedPageId state. + const currentPageId = this.currentPage.get(ownerKey); + if (currentPageId !== undefined) args._pageId = currentPageId; const result = await handler.execute(args); if (this.isStalePageError(result)) { const ok = await this.reconnectMcp(agentId); if (ok) { - // Remove the stale page from ownership - const currentPageId = this.currentPage.get(ownerKey); if (currentPageId !== undefined) { this.getOwned(ownerKey).delete(currentPageId); this.currentPage.delete(ownerKey); diff --git a/packages/core/src/tools/browser-test.ts b/packages/core/src/tools/browser-test.ts new file mode 100644 index 00000000..f31755ed --- /dev/null +++ b/packages/core/src/tools/browser-test.ts @@ -0,0 +1,719 @@ +/** + * Comprehensive browser integration test suite. + * + * Two modes: + * - Quick: structured 8-group sanity check (~15s) + * - Chaos: continuous randomized multi-agent stress test with per-op correctness verification + */ + +import { createLogger } from '@markus/shared'; +import type { AgentToolHandler } from '../agent.js'; +import type { MarkusBrowserBridge } from './markus-browser-bridge.js'; +import type { BrowserSessionManager } from './browser-session.js'; +import { getBridgeToolDescriptors } from './markus-browser-mcp.js'; + +const log = createLogger('browser-test'); + +// ─── Types ───────────────────────────────────────────────────────────────────── + +export interface BrowserTestStep { + name: string; + group: string; + passed: boolean; + durationMs: number; + error?: string; + detail?: string; +} + +export interface BrowserTestResult { + connected: boolean; + steps: BrowserTestStep[]; + totalDurationMs: number; + passed: number; + failed: number; + summary: string; +} + +export interface ChaosOpResult { + type: 'op'; + agent: string; + op: string; + target: string; + passed: boolean; + durationMs: number; + error?: string; + detail?: string; + timestamp: number; +} + +export interface ChaosStats { + type: 'stats'; + elapsed: number; + totalOps: number; + passed: number; + failed: number; + opsPerSec: number; +} + +export interface ChaosDone { + type: 'done'; + totalOps: number; + passed: number; + failed: number; + elapsed: number; +} + +export type ChaosEvent = ChaosOpResult | ChaosStats | ChaosDone; + +// ─── Constants ───────────────────────────────────────────────────────────────── + +const SITE_POOL = [ + 'https://example.com', + 'https://httpbin.org/html', + 'https://jsonplaceholder.typicode.com', + 'https://httpbin.org/get', + 'https://httpbin.org/headers', +]; + +const SITE_KEYWORDS: Record = { + 'https://example.com': ['Example Domain', 'example'], + 'https://httpbin.org/html': ['Herman Melville', 'Moby Dick'], + 'https://jsonplaceholder.typicode.com': ['JSONPlaceholder', 'jsonplaceholder'], + 'https://httpbin.org/get': ['httpbin', 'origin'], + 'https://httpbin.org/headers': ['headers', 'Host'], +}; + +function getKeywords(url: string): string[] { + for (const [pattern, kw] of Object.entries(SITE_KEYWORDS)) { + if (url.includes(pattern.replace('https://', ''))) return kw; + } + return []; +} + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +function parsePageId(result: string): number | null { + const m = result.match(/^(\d+):/m); + return m ? parseInt(m[1], 10) : null; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function truncate(s: string, max = 300): string { + return s.length > max ? s.slice(0, max) + '...' : s; +} + +function createTestAgentTools( + bridge: MarkusBrowserBridge, + bsm: BrowserSessionManager, + agentId: string, +): Map { + const toolDescriptors = getBridgeToolDescriptors(); + let tools: AgentToolHandler[] = toolDescriptors.map((tool) => ({ + name: `chrome-devtools__${tool.name}`, + description: tool.description, + inputSchema: tool.inputSchema, + execute: async (args: Record) => { + const result = await bridge.callTool(tool.name, args); + if (result.error) return `Error: ${result.error}`; + return result.content; + }, + })); + tools = bsm.wrapToolHandlers(tools, agentId); + const map = new Map(); + for (const t of tools) map.set(t.name.replace('chrome-devtools__', ''), t); + return map; +} + +async function callTool( + tools: Map, + name: string, + args: Record = {}, +): Promise { + const handler = tools.get(name); + if (!handler) throw new Error(`Tool ${name} not found`); + return handler.execute(args); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Quick Test +// ═══════════════════════════════════════════════════════════════════════════════ + +export async function runQuickBrowserTest( + bridge: MarkusBrowserBridge, + bsm: BrowserSessionManager, +): Promise { + const t0 = Date.now(); + const steps: BrowserTestStep[] = []; + + if (!bridge.connected) { + return { connected: false, steps: [], totalDurationMs: 0, passed: 0, failed: 0, summary: 'Extension not connected' }; + } + + const agentIds = ['__test-quick-a__', '__test-quick-b__', '__test-quick-c__']; + const toolSets = agentIds.map((id) => createTestAgentTools(bridge, bsm, id)); + const [toolsA, toolsB, toolsC] = toolSets; + + let pageA = 0, pageB = 0, pageC = 0; + + async function step(group: string, name: string, fn: () => Promise): Promise { + const st = Date.now(); + try { + await fn(); + steps.push({ name, group, passed: true, durationMs: Date.now() - st }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + steps.push({ name, group, passed: false, durationMs: Date.now() - st, error: msg }); + } + } + + try { + // ── Group 1: Setup (sequential to avoid page-ID race) ──────────────── + await step('Setup', 'Agent A: new_page (example.com)', async () => { + const r = await callTool(toolsA, 'new_page', { url: 'https://example.com', background: true }); + pageA = parsePageId(r)!; + if (!pageA) throw new Error(`Failed to parse pageId: ${truncate(r)}`); + }); + await step('Setup', 'Agent B: new_page (httpbin.org/html)', async () => { + const r = await callTool(toolsB, 'new_page', { url: 'https://httpbin.org/html', background: true }); + pageB = parsePageId(r)!; + if (!pageB) throw new Error(`Failed to parse pageId: ${truncate(r)}`); + }); + await step('Setup', 'Agent C: new_page (jsonplaceholder)', async () => { + const r = await callTool(toolsC, 'new_page', { url: 'https://jsonplaceholder.typicode.com', background: true }); + pageC = parsePageId(r)!; + if (!pageC) throw new Error(`Failed to parse pageId: ${truncate(r)}`); + }); + await step('Setup', 'All pageIds distinct', async () => { + if (pageA === pageB || pageB === pageC || pageA === pageC) { + throw new Error(`Duplicate pageIds: A=${pageA}, B=${pageB}, C=${pageC}`); + } + }); + + log.info('Setup complete', { pageA, pageB, pageC }); + + // ── Group 2: Tab Verification ─────────────────────────────────────────── + await step('Tab Verification', 'list_pages: all 3 pages present', async () => { + const r = await callTool(toolsA, 'list_pages'); + for (const pid of [pageA, pageB, pageC]) { + if (!new RegExp(`^${pid}:\\s`, 'm').test(r)) throw new Error(`Page ${pid} not in list: ${truncate(r)}`); + } + }); + await step('Tab Verification', 'Agent A: select own page', async () => { + const r = await callTool(toolsA, 'select_page', { pageId: pageA }); + if (r.includes('Error') || r.includes('NOT your tab')) throw new Error(truncate(r)); + }); + await step('Tab Verification', 'Agent B: select own page', async () => { + const r = await callTool(toolsB, 'select_page', { pageId: pageB }); + if (r.includes('Error') || r.includes('NOT your tab')) throw new Error(truncate(r)); + }); + await step('Tab Verification', 'Agent C: select own page', async () => { + const r = await callTool(toolsC, 'select_page', { pageId: pageC }); + if (r.includes('Error') || r.includes('NOT your tab')) throw new Error(truncate(r)); + }); + + // ── Group 3: Inspection Tools (parallel) ──────────────────────────────── + await step('Inspection', 'Agent A: take_snapshot (example.com)', async () => { + const r = await callTool(toolsA, 'take_snapshot'); + if (!r.toLowerCase().includes('example')) { + throw new Error(`Snapshot doesn't contain "example": ${truncate(r)}`); + } + }); + await step('Inspection', 'Agent B: take_screenshot', async () => { + const r = await callTool(toolsB, 'take_screenshot'); + if (!r || r.length < 100) { + throw new Error(`Screenshot too short (${r.length} chars): ${truncate(r, 120)}`); + } + }); + await step('Inspection', 'Agent C: evaluate_script (document.title)', async () => { + const r = await callTool(toolsC, 'evaluate_script', { expression: 'document.title' }); + if (!r.toLowerCase().includes('jsonplaceholder')) { + throw new Error(`Title doesn't contain "jsonplaceholder": ${truncate(r)}`); + } + }); + + // ── Group 4: Parallel evaluation + input ──────────────────────────────── + { + const [linksA, h1B, keyC] = await Promise.all([ + callTool(toolsA, 'evaluate_script', { expression: 'document.querySelectorAll("a").length' }), + callTool(toolsB, 'evaluate_script', { expression: 'document.querySelector("h1")?.textContent || ""' }), + callTool(toolsC, 'press_key', { key: 'Tab' }), + ]); + await step('Input', 'Agent A: count links on example.com', async () => { + const count = parseInt(linksA, 10); + if (isNaN(count) || count < 1) throw new Error(`Expected links > 0, got: ${linksA}`); + }); + await step('Input', 'Agent B: h1 text on httpbin/html', async () => { + if (!h1B.toLowerCase().includes('melville') && !h1B.toLowerCase().includes('moby')) { + throw new Error(`Expected Melville content, got: ${truncate(h1B)}`); + } + }); + await step('Input', 'Agent C: press_key Tab', async () => { + if (keyC.includes('Error:')) throw new Error(truncate(keyC)); + }); + } + + // ── Group 5: Navigation ───────────────────────────────────────────────── + await step('Navigation', 'Agent A: reload page', async () => { + const r = await callTool(toolsA, 'navigate_page', { action: 'reload' }); + if (r.includes('Error:')) throw new Error(truncate(r)); + }); + await step('Navigation', 'Agent B: wait_for "Melville"', async () => { + const r = await callTool(toolsB, 'wait_for', { text: 'Melville', timeout: 10000 }); + if (r.includes('not found') || r.includes('Error:')) throw new Error(truncate(r)); + }); + await step('Navigation', 'Agent C: reload page', async () => { + const r = await callTool(toolsC, 'navigate_page', { action: 'reload' }); + if (r.includes('Error:')) throw new Error(truncate(r)); + }); + + // ── Group 6: Cross-Agent Isolation (parallel eval) ────────────────────── + { + const [isoA, isoB, isoC] = await Promise.all([ + callTool(toolsA, 'evaluate_script', { expression: 'document.URL' }), + callTool(toolsB, 'evaluate_script', { expression: 'document.URL' }), + callTool(toolsC, 'evaluate_script', { expression: 'document.URL' }), + ]); + await step('Isolation', 'Agent A: URL is example.com', async () => { + if (!isoA.includes('example.com')) throw new Error(`Expected example.com, got: ${truncate(isoA)}`); + }); + await step('Isolation', 'Agent B: URL is httpbin.org', async () => { + if (!isoB.includes('httpbin.org')) throw new Error(`Expected httpbin.org, got: ${truncate(isoB)}`); + }); + await step('Isolation', 'Agent C: URL is jsonplaceholder', async () => { + if (!isoC.includes('jsonplaceholder')) throw new Error(`Expected jsonplaceholder, got: ${truncate(isoC)}`); + }); + } + + // ── Group 7: Security ─────────────────────────────────────────────────── + await step('Security', 'Agent A: cannot close Agent B tab', async () => { + const r = await callTool(toolsA, 'close_page', { pageId: pageB }); + if (!r.includes('NOT your tab') && !r.includes('not your tab')) { + throw new Error(`Expected rejection, got: ${truncate(r)}`); + } + }); + await step('Security', 'Agent B: cannot select Agent C tab', async () => { + const r = await callTool(toolsB, 'select_page', { pageId: pageC }); + if (!r.includes('NOT your tab') && !r.includes('not your tab')) { + throw new Error(`Expected rejection, got: ${truncate(r)}`); + } + }); + await step('Security', 'Agent C: cannot close Agent A tab', async () => { + const r = await callTool(toolsC, 'close_page', { pageId: pageA }); + if (!r.includes('NOT your tab') && !r.includes('not your tab')) { + throw new Error(`Expected rejection, got: ${truncate(r)}`); + } + }); + + // ── Group 8: Tab Lifecycle ────────────────────────────────────────────── + await step('Lifecycle', 'Agent A: close own tab', async () => { + const r = await callTool(toolsA, 'close_page', { pageId: pageA }); + if (r.includes('Error:') && !r.includes('externally')) throw new Error(truncate(r)); + }); + await step('Lifecycle', 'Agent B: page A gone from list', async () => { + await sleep(300); + const r = await callTool(toolsB, 'list_pages'); + if (new RegExp(`^${pageA}:\\s`, 'm').test(r)) throw new Error(`Page ${pageA} still in list`); + if (!new RegExp(`^${pageB}:\\s`, 'm').test(r)) throw new Error(`Own page ${pageB} missing`); + }); + await step('Lifecycle', 'Agent C: create new tab', async () => { + const r = await callTool(toolsC, 'new_page', { url: 'https://example.com', background: true }); + const newId = parsePageId(r); + if (!newId || newId === pageA) throw new Error(`Bad new pageId: ${newId}`); + }); + } finally { + // Cleanup all test agent state + for (const agentId of agentIds) { + try { + const tools = createTestAgentTools(bridge, bsm, agentId); + const list = await callTool(tools, 'list_pages'); + const entries = [...list.matchAll(/^(\d+):.*YOUR TAB/gm)]; + for (const e of entries) { + const pid = parseInt(e[1], 10); + await callTool(tools, 'close_page', { pageId: pid }).catch(() => {}); + } + } catch { /* ignore */ } + bsm.cleanupAgent(agentId); + } + } + + const passed = steps.filter((s) => s.passed).length; + const failed = steps.filter((s) => !s.passed).length; + return { + connected: true, + steps, + totalDurationMs: Date.now() - t0, + passed, + failed, + summary: `${passed}/${passed + failed} passed`, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Chaos Test +// ═══════════════════════════════════════════════════════════════════════════════ + +interface ChaosAgent { + name: string; + agentId: string; + tabs: Array<{ pageId: number; url: string; keywords: string[] }>; + currentTabIndex: number; + tools: Map; +} + +type OpType = + | 'new_page' | 'select_page' | 'navigate_url' | 'navigate_reload' + | 'take_snapshot' | 'take_screenshot' | 'eval_title' | 'eval_url' + | 'wait_for' | 'press_key' | 'list_pages' | 'close_page' + | 'security_close' | 'security_select'; + +interface WeightedOp { op: OpType; weight: number; precondition: (a: ChaosAgent, all: ChaosAgent[]) => boolean } + +const OP_POOL: WeightedOp[] = [ + { op: 'new_page', weight: 10, precondition: (a) => a.tabs.length < 4 }, + { op: 'select_page', weight: 8, precondition: (a) => a.tabs.length >= 2 }, + { op: 'navigate_url', weight: 12, precondition: (a) => a.tabs.length >= 1 }, + { op: 'navigate_reload', weight: 5, precondition: (a) => a.tabs.length >= 1 }, + { op: 'take_snapshot', weight: 18, precondition: (a) => a.tabs.length >= 1 }, + { op: 'take_screenshot', weight: 8, precondition: (a) => a.tabs.length >= 1 }, + { op: 'eval_title', weight: 12, precondition: (a) => a.tabs.length >= 1 }, + { op: 'eval_url', weight: 10, precondition: (a) => a.tabs.length >= 1 }, + { op: 'wait_for', weight: 5, precondition: (a) => a.tabs.length >= 1 && a.tabs[a.currentTabIndex]?.keywords.length > 0 }, + { op: 'press_key', weight: 3, precondition: (a) => a.tabs.length >= 1 }, + { op: 'list_pages', weight: 5, precondition: () => true }, + { op: 'close_page', weight: 5, precondition: (a) => a.tabs.length >= 1 }, + { op: 'security_close', weight: 3, precondition: (_, all) => all.some((a) => a.tabs.length > 0) }, + { op: 'security_select', weight: 3, precondition: (_, all) => all.some((a) => a.tabs.length > 0) }, +]; + +function pickRandomOp(agent: ChaosAgent, allAgents: ChaosAgent[]): OpType { + const eligible = OP_POOL.filter((o) => o.precondition(agent, allAgents.filter((a) => a !== agent))); + if (eligible.length === 0) return 'new_page'; + const totalWeight = eligible.reduce((s, o) => s + o.weight, 0); + let r = Math.random() * totalWeight; + for (const o of eligible) { + r -= o.weight; + if (r <= 0) return o.op; + } + return eligible[eligible.length - 1].op; +} + +function pickOtherAgentWithTabs(agent: ChaosAgent, allAgents: ChaosAgent[]): ChaosAgent | null { + const others = allAgents.filter((a) => a !== agent && a.tabs.length > 0); + return others.length > 0 ? others[Math.floor(Math.random() * others.length)] : null; +} + +async function executeChaosOp( + op: OpType, + agent: ChaosAgent, + allAgents: ChaosAgent[], +): Promise<{ target: string; result: string; passed: boolean; error?: string }> { + const tools = agent.tools; + const currentTab = agent.tabs[agent.currentTabIndex]; + + switch (op) { + case 'new_page': { + const url = SITE_POOL[Math.floor(Math.random() * SITE_POOL.length)]; + const r = await callTool(tools, 'new_page', { url, background: true }); + const pid = parsePageId(r); + if (!pid) return { target: url, result: r, passed: false, error: `Failed to parse pageId from: ${truncate(r)}` }; + const kw = getKeywords(url); + agent.tabs.push({ pageId: pid, url, keywords: kw }); + agent.currentTabIndex = agent.tabs.length - 1; + return { target: `${url} -> pageId:${pid}`, result: truncate(r), passed: true }; + } + + case 'select_page': { + const otherIdx = agent.tabs.findIndex((_, i) => i !== agent.currentTabIndex); + if (otherIdx < 0) return { target: 'no other tab', result: '', passed: true }; + const tab = agent.tabs[otherIdx]; + const r = await callTool(tools, 'select_page', { pageId: tab.pageId }); + if (r.includes('Error') || r.includes('NOT your tab')) { + return { target: `pageId:${tab.pageId}`, result: truncate(r), passed: false, error: truncate(r) }; + } + agent.currentTabIndex = otherIdx; + const urlCheck = await callTool(tools, 'evaluate_script', { expression: 'document.URL' }); + const urlMatches = urlCheck.includes(new URL(tab.url).hostname); + if (!urlMatches) { + return { target: `pageId:${tab.pageId}`, result: truncate(urlCheck), passed: false, + error: `After select, URL is ${truncate(urlCheck)} but expected ${tab.url}` }; + } + return { target: `pageId:${tab.pageId}`, result: truncate(r), passed: true }; + } + + case 'navigate_url': { + const url = SITE_POOL[Math.floor(Math.random() * SITE_POOL.length)]; + const r = await callTool(tools, 'navigate_page', { url }); + if (r.includes('Error:')) return { target: url, result: truncate(r), passed: false, error: truncate(r) }; + if (currentTab) { + // Verify actual URL after navigation instead of optimistic update + const actualUrl = await callTool(tools, 'evaluate_script', { expression: 'document.URL' }); + if (!actualUrl.includes('Error:')) { + currentTab.url = actualUrl.trim(); + currentTab.keywords = getKeywords(actualUrl); + } else { + currentTab.url = url; + currentTab.keywords = getKeywords(url); + } + } + return { target: url, result: truncate(r), passed: true }; + } + + case 'navigate_reload': { + const r = await callTool(tools, 'navigate_page', { action: 'reload' }); + if (r.includes('Error:')) return { target: 'reload', result: truncate(r), passed: false, error: truncate(r) }; + return { target: 'reload', result: truncate(r), passed: true }; + } + + case 'take_snapshot': { + const r = await callTool(tools, 'take_snapshot'); + if (r.includes('Error:')) return { target: currentTab?.url ?? '?', result: truncate(r), passed: false, error: truncate(r) }; + if (currentTab) { + const hasKeyword = currentTab.keywords.some((kw) => r.toLowerCase().includes(kw.toLowerCase())); + if (!hasKeyword) { + return { target: currentTab.url, result: truncate(r, 200), passed: false, + error: `Expected keywords [${currentTab.keywords.join(', ')}] not found in snapshot` }; + } + } + return { target: currentTab?.url ?? '?', result: truncate(r, 200), passed: true }; + } + + case 'take_screenshot': { + const r = await callTool(tools, 'take_screenshot'); + if (r.includes('Error:')) return { target: currentTab?.url ?? '?', result: truncate(r, 100), passed: false, error: truncate(r) }; + const ok = r.length > 100; + return { target: currentTab?.url ?? '?', result: `${r.length} chars`, passed: ok, + error: ok ? undefined : `Screenshot too short: ${r.length} chars` }; + } + + case 'eval_title': { + const r = await callTool(tools, 'evaluate_script', { expression: 'document.title' }); + if (r.includes('Error:')) return { target: currentTab?.url ?? '?', result: truncate(r), passed: false, error: truncate(r) }; + if (currentTab && r.length > 0) { + // Only validate hostname match when the page actually has a title. + // Some test pages (e.g. httpbin.org/html) have no tag. + const hostname = new URL(currentTab.url).hostname.replace('www.', '').split('.')[0]; + const match = r.toLowerCase().includes(hostname.toLowerCase()); + if (!match) { + return { target: currentTab.url, result: truncate(r), passed: false, + error: `Title "${truncate(r, 80)}" doesn't contain hostname "${hostname}"` }; + } + } + return { target: currentTab?.url ?? '?', result: truncate(r), passed: true }; + } + + case 'eval_url': { + const r = await callTool(tools, 'evaluate_script', { expression: 'document.URL' }); + if (r.includes('Error:')) return { target: currentTab?.url ?? '?', result: truncate(r), passed: false, error: truncate(r) }; + if (currentTab) { + const expected = new URL(currentTab.url).hostname; + if (!r.includes(expected)) { + return { target: currentTab.url, result: truncate(r), passed: false, + error: `URL "${truncate(r, 80)}" doesn't contain "${expected}"` }; + } + } + return { target: currentTab?.url ?? '?', result: truncate(r), passed: true }; + } + + case 'wait_for': { + if (!currentTab || currentTab.keywords.length === 0) return { target: '?', result: 'skip', passed: true }; + const kw = currentTab.keywords[0]; + const r = await callTool(tools, 'wait_for', { text: kw, timeout: 8000 }); + const found = !r.toLowerCase().includes('not found') && !r.includes('Error:'); + return { target: `"${kw}"`, result: truncate(r), passed: found, + error: found ? undefined : `wait_for "${kw}" failed: ${truncate(r)}` }; + } + + case 'press_key': { + const r = await callTool(tools, 'press_key', { key: 'Tab' }); + const ok = !r.includes('Error:'); + return { target: 'Tab', result: truncate(r), passed: ok, error: ok ? undefined : truncate(r) }; + } + + case 'list_pages': { + const r = await callTool(tools, 'list_pages'); + if (r.includes('Error:')) return { target: 'list', result: truncate(r), passed: false, error: truncate(r) }; + let ok = true; + let err: string | undefined; + for (const tab of agent.tabs) { + if (!new RegExp(`^${tab.pageId}:\\s`, 'm').test(r)) { + ok = false; + err = `Own page ${tab.pageId} missing from list`; + break; + } + } + return { target: `${agent.tabs.length} tabs`, result: truncate(r, 200), passed: ok, error: err }; + } + + case 'close_page': { + const idx = Math.floor(Math.random() * agent.tabs.length); + const tab = agent.tabs[idx]; + const r = await callTool(tools, 'close_page', { pageId: tab.pageId }); + const ok = !r.includes('Error:') || r.includes('externally'); + agent.tabs.splice(idx, 1); + if (agent.tabs.length === 0) { + agent.currentTabIndex = 0; + } else if (idx < agent.currentTabIndex) { + // Removed tab was before current → shift index down to stay on same tab + agent.currentTabIndex--; + } else if (agent.currentTabIndex >= agent.tabs.length) { + agent.currentTabIndex = agent.tabs.length - 1; + } + return { target: `pageId:${tab.pageId}`, result: truncate(r), passed: ok, error: ok ? undefined : truncate(r) }; + } + + case 'security_close': { + const victim = pickOtherAgentWithTabs(agent, allAgents); + if (!victim) return { target: 'no victim', result: 'skip', passed: true }; + const victimTab = victim.tabs[Math.floor(Math.random() * victim.tabs.length)]; + const r = await callTool(tools, 'close_page', { pageId: victimTab.pageId }); + const rejected = r.toLowerCase().includes('not your tab'); + return { target: `${victim.name}:pageId:${victimTab.pageId}`, result: truncate(r), passed: rejected, + error: rejected ? undefined : `Expected "NOT your tab" rejection, got: ${truncate(r)}` }; + } + + case 'security_select': { + const victim = pickOtherAgentWithTabs(agent, allAgents); + if (!victim) return { target: 'no victim', result: 'skip', passed: true }; + const victimTab = victim.tabs[Math.floor(Math.random() * victim.tabs.length)]; + const r = await callTool(tools, 'select_page', { pageId: victimTab.pageId }); + const rejected = r.toLowerCase().includes('not your tab'); + return { target: `${victim.name}:pageId:${victimTab.pageId}`, result: truncate(r), passed: rejected, + error: rejected ? undefined : `Expected "NOT your tab" rejection, got: ${truncate(r)}` }; + } + + default: + return { target: '?', result: 'unknown op', passed: false, error: `Unknown op: ${op}` }; + } +} + +async function runAgentLoop( + agent: ChaosAgent, + allAgents: ChaosAgent[], + deadline: number, + queue: ChaosEvent[], + signal?: AbortSignal, +): Promise<void> { + while (Date.now() < deadline && !signal?.aborted) { + const op = pickRandomOp(agent, allAgents); + const t0 = Date.now(); + try { + const res = await executeChaosOp(op, agent, allAgents); + queue.push({ + type: 'op', + agent: agent.name, + op, + target: res.target, + passed: res.passed, + durationMs: Date.now() - t0, + error: res.error, + detail: res.result, + timestamp: Date.now(), + }); + } catch (err) { + queue.push({ + type: 'op', + agent: agent.name, + op, + target: '?', + passed: false, + durationMs: Date.now() - t0, + error: err instanceof Error ? err.message : String(err), + timestamp: Date.now(), + }); + } + await sleep(50 + Math.random() * 150); + } + + // Cleanup: close all owned tabs + for (const tab of [...agent.tabs]) { + try { + await callTool(agent.tools, 'close_page', { pageId: tab.pageId }); + } catch { /* ignore cleanup errors */ } + } + agent.tabs.length = 0; +} + +export async function* runChaosBrowserTest( + bridge: MarkusBrowserBridge, + bsm: BrowserSessionManager, + opts: { durationMs?: number; agentCount?: number; signal?: AbortSignal }, +): AsyncGenerator<ChaosEvent> { + if (!bridge.connected) throw new Error('Extension not connected'); + + const count = Math.min(opts.agentCount ?? 3, 5); + const agents: ChaosAgent[] = Array.from({ length: count }, (_, i) => { + const agentId = `__test-chaos-${i + 1}__`; + return { + name: `Agent-${i + 1}`, + agentId, + tabs: [], + currentTabIndex: 0, + tools: createTestAgentTools(bridge, bsm, agentId), + }; + }); + + const deadline = Date.now() + (opts.durationMs ?? 120_000); + const queue: ChaosEvent[] = []; + let totalOps = 0, passedOps = 0, failedOps = 0; + const startTime = Date.now(); + let lastStats = Date.now(); + + const loops = agents.map((a) => runAgentLoop(a, agents, deadline, queue, opts.signal)); + const allDone = Promise.allSettled(loops); + + let done = false; + allDone.then(() => { done = true; }); + + while (!done) { + while (queue.length > 0) { + const ev = queue.shift()!; + if (ev.type === 'op') { + totalOps++; + if (ev.passed) passedOps++; else failedOps++; + } + yield ev; + } + + const now = Date.now(); + if (now - lastStats >= 5000) { + const elapsed = now - startTime; + yield { + type: 'stats', + elapsed, + totalOps, + passed: passedOps, + failed: failedOps, + opsPerSec: Math.round((totalOps / (elapsed / 1000)) * 100) / 100, + }; + lastStats = now; + } + + await sleep(50); + } + + // Drain remaining + while (queue.length > 0) { + const ev = queue.shift()!; + if (ev.type === 'op') { + totalOps++; + if (ev.passed) passedOps++; else failedOps++; + } + yield ev; + } + + // Cleanup BrowserSessionManager state + for (const a of agents) { + bsm.cleanupAgent(a.agentId); + } + + yield { + type: 'done', + totalOps, + passed: passedOps, + failed: failedOps, + elapsed: Date.now() - startTime, + }; +} diff --git a/packages/core/src/tools/markus-browser-bridge.ts b/packages/core/src/tools/markus-browser-bridge.ts index 23ba0b73..ad4eba2a 100644 --- a/packages/core/src/tools/markus-browser-bridge.ts +++ b/packages/core/src/tools/markus-browser-bridge.ts @@ -32,6 +32,7 @@ export class MarkusBrowserBridge { private port: number; private _started = false; private connectionListeners: Array<(connected: boolean) => void> = []; + private eventListeners: Array<(event: string, data: unknown) => void> = []; constructor(port?: number) { this.port = port ?? DEFAULT_PORT; @@ -44,6 +45,10 @@ export class MarkusBrowserBridge { this.connectionListeners.push(listener); } + onEvent(listener: (event: string, data: unknown) => void): void { + this.eventListeners.push(listener); + } + private notifyConnectionChange(connected: boolean): void { for (const listener of this.connectionListeners) { try { listener(connected); } catch { /* ignore */ } @@ -135,6 +140,9 @@ export class MarkusBrowserBridge { private handleMessage(msg: { id?: number; result?: unknown; error?: string; event?: string; data?: unknown }): void { if (msg.event) { log.debug(`Extension event: ${msg.event}`, msg.data as Record<string, unknown>); + for (const listener of this.eventListeners) { + try { listener(msg.event, msg.data); } catch { /* ignore */ } + } return; } diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index 7939f54a..b5491064 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -1,6 +1,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { join, resolve, dirname } from 'node:path'; import { readdirSync, readFileSync, existsSync, writeFileSync, mkdirSync, rmSync, statSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; import { homedir } from 'node:os'; import { execSync } from 'node:child_process'; import { createLogger, generateId, userId as genUserId, kebab, saveConfig, getTextContent, stripInternalBlocks, extractThinkBlocks, APP_VERSION, checkForUpdate, buildManifest, manifestFilename, CHANNEL_CONTEXT_MESSAGES, type TaskStatus, type TaskPriority, type TaskSortField, type SortOrder, type PackageType, type RequirementStatus } from '@markus/shared'; @@ -2546,10 +2547,7 @@ export class APIServer { const userId = authUser.userId; const isAdmin = authUser.role === 'owner' || authUser.role === 'admin'; const teams = this.orgService.listTeamsWithMembers(orgId); - const filteredTeams = isAdmin - ? teams - : teams.filter(t => t.members.some(m => m.id === userId)); - const teamChats = filteredTeams.map(t => ({ + const teamChats = teams.map(t => ({ id: `group:${t.id}`, name: t.name, type: 'team' as const, @@ -7240,6 +7238,66 @@ EXPLANATION_END`; return; } + // Settings — Browser concurrent integration test + if (path === '/api/settings/browser/test-concurrent' && req.method === 'POST') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + const am = this.orgService.getAgentManager(); + const params = await this.readBody(req).catch(() => ({} as Record<string, unknown>)); + const mode = (params.mode as string) ?? 'quick'; + + if (mode === 'chaos') { + const durationMs = Math.min(((params.durationSec as number) ?? 120) * 1000, 600_000); + const agentCount = Math.min((params.agents as number) ?? 3, 5); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + const ac = new AbortController(); + const chaosAbortKey = '__chaos_abort__'; + (this as unknown as Record<string, AbortController>)[chaosAbortKey] = ac; + + req.on('close', () => ac.abort()); + + try { + const gen = am.runChaosBrowserTest({ durationMs, agentCount, signal: ac.signal }); + for await (const ev of gen) { + if (ac.signal.aborted) break; + res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + res.write(`event: error\ndata: ${JSON.stringify({ error: msg })}\n\n`); + } + res.end(); + delete (this as unknown as Record<string, AbortController>)[chaosAbortKey]; + return; + } + + // Quick mode (default) + const result = await am.runQuickBrowserTest(); + this.json(res, 200, result); + return; + } + + // Settings — Stop chaos test + if (path === '/api/settings/browser/test-concurrent' && req.method === 'DELETE') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + const chaosAbortKey = '__chaos_abort__'; + const ac = (this as unknown as Record<string, AbortController>)[chaosAbortKey]; + if (ac) { + ac.abort(); + delete (this as unknown as Record<string, AbortController>)[chaosAbortKey]; + } + this.json(res, 200, { ok: true }); + return; + } + // Settings — Search API keys if (path === '/api/settings/search' && req.method === 'GET') { const { loadConfig: loadCfg } = await import('@markus/shared'); @@ -9612,13 +9670,13 @@ EXPLANATION_END`; const safePath = path.replace(/\.\./g, '').replace(/\/\//g, '/'); const filePath = join(this.webUiDir, safePath === '/' ? 'index.html' : safePath); if (existsSync(filePath) && statSync(filePath).isFile()) { - this.serveStaticFile(res, filePath); + this.serveStaticFile(res, filePath, req); return; } // SPA fallback: serve index.html for non-API routes const indexPath = join(this.webUiDir, 'index.html'); if (existsSync(indexPath) && !path.startsWith('/api/')) { - this.serveStaticFile(res, indexPath); + this.serveStaticFile(res, indexPath, req); return; } } @@ -9836,6 +9894,7 @@ EXPLANATION_END`; exact('/api/settings/browser', 'GET', 'POST'), exact('/api/settings/browser/check', 'GET'), exact('/api/settings/browser/test-auto-click', 'POST'), + exact('/api/settings/browser/test-concurrent', 'POST', 'DELETE'), exact('/api/settings/search', 'GET', 'POST'), exact('/api/settings/env-models', 'GET', 'POST'), exact('/api/settings/detect-ollama', 'GET'), @@ -9963,7 +10022,7 @@ EXPLANATION_END`; return null; } - private serveStaticFile(res: ServerResponse, filePath: string): void { + private serveStaticFile(res: ServerResponse, filePath: string, req?: IncomingMessage): void { const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; const MIME: Record<string, string> = { html: 'text/html; charset=utf-8', @@ -9984,10 +10043,27 @@ EXPLANATION_END`; }; const contentType = MIME[ext] ?? 'application/octet-stream'; const body = readFileSync(filePath); + const cacheControl = ext === 'html' ? 'no-cache' : 'public, max-age=31536000, immutable'; + + const COMPRESSIBLE = new Set(['html', 'js', 'mjs', 'css', 'json', 'svg', 'map']); + const acceptEncoding = req?.headers?.['accept-encoding'] ?? ''; + if (COMPRESSIBLE.has(ext) && body.byteLength > 1024 && typeof acceptEncoding === 'string' && acceptEncoding.includes('gzip')) { + const compressed = gzipSync(body); + res.writeHead(200, { + 'Content-Type': contentType, + 'Content-Length': compressed.byteLength, + 'Content-Encoding': 'gzip', + 'Cache-Control': cacheControl, + 'Vary': 'Accept-Encoding', + }); + res.end(compressed); + return; + } + res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': body.byteLength, - 'Cache-Control': ext === 'html' ? 'no-cache' : 'public, max-age=31536000, immutable', + 'Cache-Control': cacheControl, }); res.end(body); } diff --git a/packages/web-ui/src/App.tsx b/packages/web-ui/src/App.tsx index 36f8371a..ec066ad7 100644 --- a/packages/web-ui/src/App.tsx +++ b/packages/web-ui/src/App.tsx @@ -172,13 +172,20 @@ export function App() { setUpdateInfo({ latestVersion: h.latestVersion, currentVersion: h.version }); } }).catch(() => {}); - prefetch(PREFETCH_KEYS.builderArtifacts, () => api.builder.artifacts.list()); - prefetch(PREFETCH_KEYS.builderAgents, () => api.agents.list()); - prefetch(PREFETCH_KEYS.builderHubMyItems, () => hubApi.myItems()); - prefetch(PREFETCH_KEYS.builderInstalled, () => api.builder.artifacts.installed()); - prefetch(PREFETCH_KEYS.hubAgents, () => hubApi.search({ type: 'agent', limit: 50 })); - prefetch(PREFETCH_KEYS.hubTeams, () => hubApi.search({ type: 'team', limit: 50 })); - prefetch(PREFETCH_KEYS.hubSkills, () => hubApi.search({ type: 'skill', limit: 50 })); + const doPrefetch = () => { + prefetch(PREFETCH_KEYS.builderArtifacts, () => api.builder.artifacts.list()); + prefetch(PREFETCH_KEYS.builderAgents, () => api.agents.list()); + prefetch(PREFETCH_KEYS.builderHubMyItems, () => hubApi.myItems()); + prefetch(PREFETCH_KEYS.builderInstalled, () => api.builder.artifacts.installed()); + prefetch(PREFETCH_KEYS.hubAgents, () => hubApi.search({ type: 'agent', limit: 50 })); + prefetch(PREFETCH_KEYS.hubTeams, () => hubApi.search({ type: 'team', limit: 50 })); + prefetch(PREFETCH_KEYS.hubSkills, () => hubApi.search({ type: 'skill', limit: 50 })); + }; + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(doPrefetch, { timeout: 5000 }); + } else { + setTimeout(doPrefetch, 3000); + } }) .catch(() => { setAuthUser(null); diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index 25807b64..5e11af52 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -306,24 +306,54 @@ export class ApiError extends Error { } } +const _dedupCache = new Map<string, { promise: Promise<unknown>; ts: number }>(); +const DEDUP_TTL_MS = 3000; + +export function invalidateApiCache(pathPrefix?: string) { + if (!pathPrefix) { _dedupCache.clear(); return; } + for (const key of _dedupCache.keys()) { + if (key.startsWith(pathPrefix)) _dedupCache.delete(key); + } +} + async function request<T>(path: string, opts?: RequestInit): Promise<T> { - const res = await fetch(`${BASE}${path}`, { - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', // send cookies - ...opts, - body: opts?.body ? (typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)) : undefined, - }); - if (!res.ok) { - let detail = ''; - let code: string | undefined; - try { - const body = await res.json() as { error?: string; message?: string; code?: string }; - detail = body.error ?? body.message ?? ''; - code = body.code; - } catch { /* ignore parse failures */ } - throw new ApiError(detail || `API error: ${res.status}`, code); + const method = opts?.method?.toUpperCase() ?? 'GET'; + const isGet = method === 'GET' && !opts?.body; + if (isGet) { + const cached = _dedupCache.get(path); + if (cached && Date.now() - cached.ts < DEDUP_TTL_MS) return cached.promise as Promise<T>; + } else { + const basePath = path.replace(/\/[^/]+$/, ''); + for (const key of _dedupCache.keys()) { + if (key === path || key.startsWith(basePath)) _dedupCache.delete(key); + } } - return res.json() as Promise<T>; + + const promise = (async () => { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + ...opts, + body: opts?.body ? (typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)) : undefined, + }); + if (!res.ok) { + let detail = ''; + let code: string | undefined; + try { + const body = await res.json() as { error?: string; message?: string; code?: string }; + detail = body.error ?? body.message ?? ''; + code = body.code; + } catch { /* ignore parse failures */ } + throw new ApiError(detail || `API error: ${res.status}`, code); + } + return res.json() as Promise<T>; + })(); + + if (isGet) { + _dedupCache.set(path, { promise, ts: Date.now() }); + promise.catch(() => _dedupCache.delete(path)); + } + return promise; } export interface RemotePeerInfo { @@ -1212,6 +1242,44 @@ export const api = { }); }, openExtensionsPage: () => request<{ ok: boolean }>('/settings/browser/open-extensions-page', { method: 'POST' }), + testConcurrentBrowserQuick: () => request<{ + connected: boolean; + steps: Array<{ name: string; group: string; passed: boolean; durationMs: number; error?: string; detail?: string }>; + totalDurationMs: number; passed: number; failed: number; summary: string; + }>('/settings/browser/test-concurrent', { method: 'POST', body: JSON.stringify({ mode: 'quick' }) }), + testConcurrentBrowserChaos: async ( + opts: { durationSec?: number; agents?: number }, + onEvent: (ev: { type: string; [k: string]: unknown }) => void, + ): Promise<void> => { + const headers: Record<string, string> = { 'Content-Type': 'application/json' }; + const token = localStorage.getItem('markus_token'); + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(`${BASE}/settings/browser/test-concurrent`, { + method: 'POST', + headers, + body: JSON.stringify({ mode: 'chaos', durationSec: opts.durationSec ?? 120, agents: opts.agents ?? 3 }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop() ?? ''; + let currentEvent = ''; + for (const line of lines) { + if (line.startsWith('event: ')) { currentEvent = line.slice(7).trim(); } + else if (line.startsWith('data: ')) { + try { onEvent({ type: currentEvent || 'unknown', ...JSON.parse(line.slice(6)) }); } catch { /* skip */ } + currentEvent = ''; + } + } + } + }, + stopConcurrentBrowserTest: () => request<{ ok: boolean }>('/settings/browser/test-concurrent', { method: 'DELETE' }), getSearch: () => request<{ serper: { configured: boolean; preview: string }; brave: { configured: boolean; preview: string }; bocha: { configured: boolean; preview: string } }>('/settings/search'), updateSearch: (keys: { serperApiKey?: string; braveApiKey?: string; bochaApiKey?: string }) => request<{ serper: { configured: boolean; preview: string }; brave: { configured: boolean; preview: string }; bocha: { configured: boolean; preview: string } }>('/settings/search', { method: 'POST', body: JSON.stringify(keys) }), diff --git a/packages/web-ui/src/components/BrowserTestPanel.tsx b/packages/web-ui/src/components/BrowserTestPanel.tsx new file mode 100644 index 00000000..b683902f --- /dev/null +++ b/packages/web-ui/src/components/BrowserTestPanel.tsx @@ -0,0 +1,419 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { api } from '../api.ts'; + +// ─── Types ───────────────────────────────────────────────────────────────────── + +interface TestStep { + name: string; + group: string; + passed: boolean; + durationMs: number; + error?: string; + detail?: string; +} + +interface QuickResult { + connected: boolean; + steps: TestStep[]; + totalDurationMs: number; + passed: number; + failed: number; + summary: string; +} + +interface ChaosOp { + type: 'op'; + agent: string; + op: string; + target: string; + passed: boolean; + durationMs: number; + error?: string; + detail?: string; + timestamp: number; +} + +interface ChaosStatsEv { + type: 'stats'; + elapsed: number; + totalOps: number; + passed: number; + failed: number; + opsPerSec: number; +} + +// ─── Constants ───────────────────────────────────────────────────────────────── + +const AGENT_COLORS: Record<string, string> = { + 'Agent-1': 'text-blue-400', + 'Agent-2': 'text-green-400', + 'Agent-3': 'text-amber-400', + 'Agent-4': 'text-purple-400', + 'Agent-5': 'text-pink-400', +}; + +const AGENT_BG: Record<string, string> = { + 'Agent-1': 'bg-blue-500/10', + 'Agent-2': 'bg-green-500/10', + 'Agent-3': 'bg-amber-500/10', + 'Agent-4': 'bg-purple-500/10', + 'Agent-5': 'bg-pink-500/10', +}; + +// ─── Component ───────────────────────────────────────────────────────────────── + +export function BrowserTestPanel({ extensionConnected }: { extensionConnected: boolean }) { + const [mode, setMode] = useState<'quick' | 'chaos'>('quick'); + const [running, setRunning] = useState(false); + + // Quick state + const [quickResult, setQuickResult] = useState<QuickResult | null>(null); + const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); + + // Chaos state + const [chaosOps, setChaosOps] = useState<ChaosOp[]>([]); + const [chaosStats, setChaosStats] = useState<ChaosStatsEv | null>(null); + const [chaosDuration, setChaosDuration] = useState(2); + const [chaosAgents, setChaosAgents] = useState(3); + const [showFailuresOnly, setShowFailuresOnly] = useState(false); + const [chaosDone, setChaosDone] = useState<{ totalOps: number; passed: number; failed: number; elapsed: number } | null>(null); + const logRef = useRef<HTMLDivElement>(null); + const [autoScroll, setAutoScroll] = useState(true); + + // Auto-scroll log + useEffect(() => { + if (autoScroll && logRef.current) { + logRef.current.scrollTop = logRef.current.scrollHeight; + } + }, [chaosOps.length, autoScroll]); + + const toggleGroup = useCallback((group: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(group)) next.delete(group); else next.add(group); + return next; + }); + }, []); + + // ── Quick Test ────────────────────────────────────────────────────────────── + + const runQuick = useCallback(async () => { + setRunning(true); + setQuickResult(null); + try { + const r = await api.settings.testConcurrentBrowserQuick(); + setQuickResult(r); + const groups = new Set(r.steps.map((s) => s.group)); + setExpandedGroups(groups); + } catch (err) { + setQuickResult({ + connected: false, steps: [], totalDurationMs: 0, passed: 0, failed: 0, + summary: err instanceof Error ? err.message : String(err), + }); + } + setRunning(false); + }, []); + + // ── Chaos Test ────────────────────────────────────────────────────────────── + + const runChaos = useCallback(async () => { + setRunning(true); + setChaosOps([]); + setChaosStats(null); + setChaosDone(null); + setAutoScroll(true); + try { + await api.settings.testConcurrentBrowserChaos( + { durationSec: chaosDuration * 60, agents: chaosAgents }, + (ev) => { + if (ev.type === 'op') { + setChaosOps((prev) => { + const next = [...prev, ev as unknown as ChaosOp]; + if (next.length > 2000) return next.slice(-1500); + return next; + }); + } else if (ev.type === 'stats') { + setChaosStats(ev as unknown as ChaosStatsEv); + } else if (ev.type === 'done') { + setChaosDone(ev as unknown as { totalOps: number; passed: number; failed: number; elapsed: number }); + } + }, + ); + } catch (err) { + console.error('Chaos test error:', err); + } + setRunning(false); + }, [chaosDuration, chaosAgents]); + + const stopChaos = useCallback(async () => { + try { await api.settings.stopConcurrentBrowserTest(); } catch { /* ignore */ } + }, []); + + // ── Render ────────────────────────────────────────────────────────────────── + + const groups = quickResult + ? [...new Set(quickResult.steps.map((s) => s.group))] + : []; + + const filteredOps = showFailuresOnly ? chaosOps.filter((o) => !o.passed) : chaosOps; + const totalDurationSec = chaosDuration * 60; + const elapsedSec = chaosStats ? Math.round(chaosStats.elapsed / 1000) : 0; + const progressPct = totalDurationSec > 0 ? Math.min(100, Math.round((elapsedSec / totalDurationSec) * 100)) : 0; + + return ( + <div className="mt-6 rounded-xl border-2 border-dashed border-purple-500/30 bg-purple-500/5 p-5"> + {/* Header */} + <div className="flex items-center gap-3 mb-4"> + <span className="px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-purple-500/20 text-purple-400 rounded">DEV</span> + <div> + <div className="text-sm font-semibold text-fg-primary">Browser Integration Test Suite</div> + <div className="text-xs text-fg-tertiary">Concurrent multi-agent browser test with correctness verification</div> + </div> + </div> + + {!extensionConnected && ( + <div className="mb-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-xs text-amber-400"> + Chrome extension not connected. Connect the extension first to run tests. + </div> + )} + + {/* Mode tabs */} + <div className="flex gap-1 mb-4 bg-surface-primary rounded-lg p-1"> + <button + onClick={() => setMode('quick')} + className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${mode === 'quick' ? 'bg-purple-500/20 text-purple-400' : 'text-fg-tertiary hover:text-fg-secondary'}`} + > + Quick Test (~15s) + </button> + <button + onClick={() => setMode('chaos')} + className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${mode === 'chaos' ? 'bg-purple-500/20 text-purple-400' : 'text-fg-tertiary hover:text-fg-secondary'}`} + > + Chaos Test + </button> + </div> + + {/* ═══════ Quick Test ═══════ */} + {mode === 'quick' && ( + <div> + <div className="flex items-center gap-3 mb-4"> + <button + onClick={runQuick} + disabled={running || !extensionConnected} + className="px-4 py-1.5 text-xs font-medium bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors disabled:opacity-40" + > + {running ? ( + <span className="flex items-center gap-2"> + <svg className="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" strokeDasharray="28 56" /></svg> + Running... + </span> + ) : 'Run Quick Test'} + </button> + {quickResult && ( + <span className={`text-xs font-medium px-2.5 py-1 rounded-full ${quickResult.failed === 0 ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}> + {quickResult.summary} | {(quickResult.totalDurationMs / 1000).toFixed(1)}s + </span> + )} + </div> + + {quickResult && groups.map((group) => { + const groupSteps = quickResult.steps.filter((s) => s.group === group); + const groupPassed = groupSteps.filter((s) => s.passed).length; + const expanded = expandedGroups.has(group); + return ( + <div key={group} className="mb-2"> + <button + onClick={() => toggleGroup(group)} + className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-surface-elevated hover:bg-surface-primary transition-colors text-left" + > + <span className="text-xs font-medium text-fg-primary flex items-center gap-2"> + <svg className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg> + {group} + </span> + <span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${groupPassed === groupSteps.length ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}> + {groupPassed}/{groupSteps.length} + </span> + </button> + {expanded && ( + <div className="mt-1 ml-5 space-y-0.5"> + {groupSteps.map((step, i) => ( + <div key={i} className={`flex items-start gap-2 px-3 py-1.5 rounded text-xs ${step.passed ? '' : 'bg-red-500/5'}`}> + {step.passed + ? <svg className="w-3.5 h-3.5 text-green-400 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg> + : <svg className="w-3.5 h-3.5 text-red-400 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg> + } + <div className="flex-1 min-w-0"> + <div className="flex items-center justify-between"> + <span className="text-fg-primary">{step.name}</span> + <span className="text-fg-tertiary ml-2 shrink-0">{step.durationMs}ms</span> + </div> + {step.error && <div className="text-red-400 mt-0.5 break-all">{step.error}</div>} + </div> + </div> + ))} + </div> + )} + </div> + ); + })} + </div> + )} + + {/* ═══════ Chaos Test ═══════ */} + {mode === 'chaos' && ( + <div> + {/* Controls */} + <div className="flex items-center gap-3 mb-4 flex-wrap"> + <div className="flex items-center gap-2"> + <label className="text-xs text-fg-tertiary">Duration:</label> + <input + type="number" min={1} max={10} value={chaosDuration} + onChange={(e) => setChaosDuration(Math.max(1, Math.min(10, Number(e.target.value))))} + disabled={running} + className="w-14 px-2 py-1 text-xs border border-border-default rounded bg-surface-primary text-fg-primary text-center" + /> + <span className="text-xs text-fg-tertiary">min</span> + </div> + <div className="flex items-center gap-2"> + <label className="text-xs text-fg-tertiary">Agents:</label> + <input + type="number" min={2} max={5} value={chaosAgents} + onChange={(e) => setChaosAgents(Math.max(2, Math.min(5, Number(e.target.value))))} + disabled={running} + className="w-14 px-2 py-1 text-xs border border-border-default rounded bg-surface-primary text-fg-primary text-center" + /> + </div> + {!running ? ( + <button + onClick={runChaos} + disabled={!extensionConnected} + className="px-4 py-1.5 text-xs font-medium bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors disabled:opacity-40" + > + Start Chaos + </button> + ) : ( + <button + onClick={stopChaos} + className="px-4 py-1.5 text-xs font-medium bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors" + > + Stop + </button> + )} + </div> + + {/* Stats bar */} + {(chaosStats || chaosDone) && ( + <div className="mb-3 space-y-2"> + <div className="flex items-center gap-4 text-xs"> + <span className="text-fg-secondary font-medium"> + {(chaosStats?.totalOps ?? chaosDone?.totalOps ?? 0)} ops + </span> + <span className="text-green-400">{chaosStats?.passed ?? chaosDone?.passed ?? 0} passed</span> + <span className={`${(chaosStats?.failed ?? chaosDone?.failed ?? 0) > 0 ? 'text-red-400' : 'text-fg-tertiary'}`}> + {chaosStats?.failed ?? chaosDone?.failed ?? 0} failed + </span> + {chaosStats && <span className="text-fg-tertiary">{chaosStats.opsPerSec} ops/s</span>} + </div> + <div className="flex items-center gap-2"> + <div className="flex-1 h-1.5 rounded-full bg-surface-primary overflow-hidden"> + <div + className={`h-full rounded-full transition-all duration-500 ${chaosDone ? (chaosDone.failed > 0 ? 'bg-amber-500' : 'bg-green-500') : 'bg-purple-500'}`} + style={{ width: `${chaosDone ? 100 : progressPct}%` }} + /> + </div> + <span className="text-[10px] text-fg-tertiary w-20 text-right"> + {formatTime(elapsedSec)} / {formatTime(totalDurationSec)} + </span> + </div> + {chaosDone && ( + <div className={`text-xs font-medium ${chaosDone.failed === 0 ? 'text-green-400' : 'text-amber-400'}`}> + Complete: {chaosDone.passed}/{chaosDone.totalOps} passed in {(chaosDone.elapsed / 1000).toFixed(1)}s + </div> + )} + </div> + )} + + {/* Filter toggle */} + {chaosOps.length > 0 && ( + <div className="flex items-center justify-between mb-2"> + <span className="text-[10px] text-fg-tertiary uppercase tracking-wider"> + Live Log ({filteredOps.length}{showFailuresOnly ? ' failures' : ' ops'}) + </span> + <label className="flex items-center gap-1.5 cursor-pointer"> + <input + type="checkbox" + checked={showFailuresOnly} + onChange={(e) => setShowFailuresOnly(e.target.checked)} + className="w-3 h-3 rounded" + /> + <span className="text-[10px] text-fg-tertiary">Failures only</span> + </label> + </div> + )} + + {/* Log */} + {chaosOps.length > 0 && ( + <div + ref={logRef} + className="h-72 overflow-y-auto rounded-lg bg-surface-primary border border-border-default font-mono text-[11px] leading-relaxed" + onScroll={(e) => { + const el = e.currentTarget; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + setAutoScroll(atBottom); + }} + > + {filteredOps.map((op, i) => ( + <ChaosLogEntry key={i} op={op} /> + ))} + </div> + )} + </div> + )} + </div> + ); +} + +// ─── Sub-components ──────────────────────────────────────────────────────────── + +function ChaosLogEntry({ op }: { op: ChaosOp }) { + const [expanded, setExpanded] = useState(false); + const time = new Date(op.timestamp).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); + const agentColor = AGENT_COLORS[op.agent] ?? 'text-fg-secondary'; + const agentBg = AGENT_BG[op.agent] ?? ''; + + return ( + <div + className={`px-2 py-0.5 border-b border-border-default/50 hover:bg-surface-elevated/50 cursor-pointer ${!op.passed ? 'bg-red-500/5' : ''}`} + onClick={() => setExpanded(!expanded)} + > + <div className="flex items-center gap-2"> + <span className="text-fg-tertiary w-16 shrink-0">{time}</span> + <span className={`w-16 shrink-0 font-medium ${agentColor} ${agentBg} px-1 rounded text-center`}> + {op.agent} + </span> + <span className="w-28 shrink-0 text-fg-secondary truncate">{op.op}</span> + <span className="flex-1 text-fg-tertiary truncate">{op.target}</span> + <span className={`w-10 text-right shrink-0 ${op.passed ? 'text-green-400' : 'text-red-400'}`}> + {op.passed ? 'PASS' : 'FAIL'} + </span> + <span className="w-14 text-right text-fg-tertiary shrink-0">{op.durationMs}ms</span> + </div> + {!op.passed && op.error && ( + <div className="ml-[8.5rem] text-red-400/80 text-[10px] truncate"> + {op.error} + </div> + )} + {expanded && op.detail && ( + <div className="ml-[8.5rem] mt-1 mb-1 text-fg-tertiary text-[10px] whitespace-pre-wrap break-all bg-surface-primary p-1.5 rounded"> + {op.detail} + </div> + )} + </div> + ); +} + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; +} diff --git a/packages/web-ui/src/components/ChatTeamSidebar.tsx b/packages/web-ui/src/components/ChatTeamSidebar.tsx index 57fb4291..4a3fe263 100644 --- a/packages/web-ui/src/components/ChatTeamSidebar.tsx +++ b/packages/web-ui/src/components/ChatTeamSidebar.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../hooks/useIsMobile.ts'; +import { usePageActive } from '../hooks/usePageActive.ts'; import { MobileMenuButton } from './MobileMenuButton.tsx'; import { api, wsClient, @@ -201,6 +202,7 @@ export function ChatTeamSidebar({ }: ChatTeamSidebarProps) { const { t } = useTranslation(['team', 'common']); const isMobile = useIsMobile(); + const isActive = usePageActive(PAGE.TEAM); const isAdmin = authUser?.role === 'owner' || authUser?.role === 'admin'; const externalMarkusIds = useMemo(() => new Set(externalAgents.map(ea => ea.markusAgentId).filter(Boolean) as string[]), [externalAgents]); @@ -316,7 +318,7 @@ export function ChatTeamSidebar({ const agentIdsKey = useMemo(() => agents.map(a => a.id).sort().join(','), [agents]); useEffect(() => { - if (!agents.length) return; + if (!agents.length || !isActive) return; let cancelled = false; const fetchAll = async () => { const entries: [string, string][] = []; @@ -350,9 +352,29 @@ export function ChatTeamSidebar({ } }; fetchAll(); - const timer = setInterval(fetchAll, 30_000); - return () => { cancelled = true; clearInterval(timer); }; - }, [agentIdsKey]); // eslint-disable-line react-hooks/exhaustive-deps + const timer = setInterval(fetchAll, 120_000); + + const agentIdSet = new Set(agents.map(a => a.id)); + const stripMarkup = (raw: string) => raw + .replace(/<think>[\s\S]*?(<\/think>|$)/g, '') + .replace(/<(invoke|function_calls|antml:\w+)\b[\s\S]*?(<\/\1>|$)/g, '') + .replace(/\n+/g, ' ').trim().slice(0, 80); + const unsubMsg = wsClient.on('chat:proactive_message', (event) => { + const p = event.payload; + const agentId = (p['agentId'] as string) ?? ''; + const message = (p['message'] as string) ?? ''; + if (!agentId || !message || !agentIdSet.has(agentId)) return; + const txt = stripMarkup(message); + if (!txt) return; + setAgentLastMsg(prev => { + const next = new Map(prev); + next.set(agentId, txt); + _lastMsgCache = next; + return next; + }); + }); + return () => { cancelled = true; clearInterval(timer); unsubMsg(); }; + }, [agentIdsKey, isActive]); // eslint-disable-line react-hooks/exhaustive-deps // ── Data loading ────────────────────────────────────────────────────────── const refreshUngrouped = useCallback(() => { @@ -364,10 +386,11 @@ export function ChatTeamSidebar({ }, [refreshUngrouped]); useEffect(() => { + if (!isActive) return; const unsub1 = wsClient.on('team:update', refreshUngrouped); const unsub2 = wsClient.on('agent:removed', refreshUngrouped); return () => { unsub1(); unsub2(); }; - }, [refreshUngrouped]); + }, [refreshUngrouped, isActive]); // Close menus on outside click useEffect(() => { diff --git a/packages/web-ui/src/components/NotificationBell.tsx b/packages/web-ui/src/components/NotificationBell.tsx index 4aa27e0a..19ce64f6 100644 --- a/packages/web-ui/src/components/NotificationBell.tsx +++ b/packages/web-ui/src/components/NotificationBell.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; -import { api, type NotificationInfo, type ApprovalInfo } from '../api.ts'; +import { api, invalidateApiCache, wsClient, type NotificationInfo, type ApprovalInfo } from '../api.ts'; import { navBus } from '../navBus.ts'; import { PAGE } from '../routes.ts'; import { MarkdownMessage } from './MarkdownMessage.tsx'; @@ -184,11 +184,15 @@ export function NotificationBell({ collapsed, userId, embeddedMode, onClose, sid useEffect(() => { fetchData(); - const timer = setInterval(fetchData, 15000); + const timer = setInterval(fetchData, 60000); const onChanged = () => fetchData(); + const onOpenNotifications = () => { setOpen(true); fetchData(); }; window.addEventListener('markus:notifications-changed', onChanged); window.addEventListener('markus:mark-read-by-ref', markReadByRef); - return () => { clearInterval(timer); window.removeEventListener('markus:notifications-changed', onChanged); window.removeEventListener('markus:mark-read-by-ref', markReadByRef); }; + window.addEventListener('markus:open-notifications', onOpenNotifications); + const unsubNotif = wsClient.on('notification:created', () => fetchData()); + const unsubApproval = wsClient.on('approval:created', () => fetchData()); + return () => { clearInterval(timer); window.removeEventListener('markus:notifications-changed', onChanged); window.removeEventListener('markus:mark-read-by-ref', markReadByRef); window.removeEventListener('markus:open-notifications', onOpenNotifications); unsubNotif(); unsubApproval(); }; }, [fetchData, markReadByRef]); const reposition = useCallback(() => { @@ -413,7 +417,12 @@ export function NotificationBell({ collapsed, userId, embeddedMode, onClose, sid setNotifications(prev => prev.map(n => ids.has(n.id) ? { ...n, read: true } : n)); setUnreadCount(prev => Math.max(0, prev - relatedNotifs.length)); } - window.dispatchEvent(new CustomEvent('markus:notifications-changed')); + invalidateApiCache('/approvals'); + invalidateApiCache('/notifications'); + invalidateApiCache('/tasks'); + invalidateApiCache('/requirements'); + window.dispatchEvent(new CustomEvent('markus:data-changed')); + setTimeout(() => fetchData(), 800); } catch { /* */ } setResponding(null); }; @@ -424,11 +433,13 @@ export function NotificationBell({ collapsed, userId, embeddedMode, onClose, sid await api.notifications.markAllRead(userId); setNotifications(prev => prev.map(n => ({ ...n, read: true }))); setUnreadCount(0); + invalidateApiCache('/notifications'); } catch { const unread = displayNotifications.filter(n => !n.read); await Promise.all(unread.map(n => api.notifications.markRead(n.id))); setNotifications(prev => prev.map(n => ({ ...n, read: true }))); setUnreadCount(0); + invalidateApiCache('/notifications'); } }; diff --git a/packages/web-ui/src/env.d.ts b/packages/web-ui/src/env.d.ts index 41fad5b5..d5337f4c 100644 --- a/packages/web-ui/src/env.d.ts +++ b/packages/web-ui/src/env.d.ts @@ -1 +1,2 @@ +/// <reference types="vite/client" /> declare const __APP_VERSION__: string; diff --git a/packages/web-ui/src/hooks/usePageActive.ts b/packages/web-ui/src/hooks/usePageActive.ts new file mode 100644 index 00000000..93b90ce4 --- /dev/null +++ b/packages/web-ui/src/hooks/usePageActive.ts @@ -0,0 +1,32 @@ +import { useSyncExternalStore } from 'react'; +import { type PageId, getPageFromHash } from '../routes.ts'; + +let currentPage: PageId = getPageFromHash(); +const listeners = new Set<() => void>(); + +function notifyAll() { + currentPage = getPageFromHash(); + listeners.forEach(cb => cb()); +} + +if (typeof window !== 'undefined') { + window.addEventListener('hashchange', notifyAll); +} + +function subscribe(cb: () => void): () => void { + listeners.add(cb); + return () => listeners.delete(cb); +} + +/** + * Returns true only when the given pageId is the currently visible page. + * Uses useSyncExternalStore for tear-free reads — polling/WS effects + * should gate on this to avoid background work on hidden pages. + */ +export function usePageActive(pageId: PageId): boolean { + return useSyncExternalStore( + subscribe, + () => currentPage === pageId, + () => false, + ); +} diff --git a/packages/web-ui/src/index.css b/packages/web-ui/src/index.css index 932ff531..1167f6c7 100644 --- a/packages/web-ui/src/index.css +++ b/packages/web-ui/src/index.css @@ -37,13 +37,13 @@ html.light { color-scheme: light; - --color-surface-primary: oklch(0.985 0.002 270); - --color-surface-secondary: oklch(0.95 0.004 270); - --color-surface-elevated: oklch(0.97 0.003 270); + --color-surface-primary: oklch(1 0 0); + --color-surface-secondary: oklch(0.97 0.003 270); + --color-surface-elevated: oklch(1 0 0); --color-surface-overlay: oklch(0.93 0.005 270); - --color-surface-chat-bubble: oklch(0.95 0.02 250); - --color-border-default: oklch(0.82 0.012 270); - --color-border-subtle: oklch(0.88 0.008 270); + --color-surface-chat-bubble: oklch(0.96 0.01 250); + --color-border-default: oklch(0.91 0.006 270); + --color-border-subtle: oklch(0.94 0.004 270); --color-fg-primary: oklch(0.18 0.006 264); --color-fg-secondary: oklch(0.36 0.012 264); --color-fg-tertiary: oklch(0.46 0.012 264); @@ -57,13 +57,13 @@ html.light { @media (prefers-color-scheme: light) { html:not(.dark):not(.light):not(.cyberpunk):not(.mono) { color-scheme: light; - --color-surface-primary: oklch(0.985 0.002 270); - --color-surface-secondary: oklch(0.95 0.004 270); - --color-surface-elevated: oklch(0.97 0.003 270); + --color-surface-primary: oklch(1 0 0); + --color-surface-secondary: oklch(0.97 0.003 270); + --color-surface-elevated: oklch(1 0 0); --color-surface-overlay: oklch(0.93 0.005 270); - --color-surface-chat-bubble: oklch(0.95 0.02 250); - --color-border-default: oklch(0.82 0.012 270); - --color-border-subtle: oklch(0.88 0.008 270); + --color-surface-chat-bubble: oklch(0.96 0.01 250); + --color-border-default: oklch(0.91 0.006 270); + --color-border-subtle: oklch(0.94 0.004 270); --color-fg-primary: oklch(0.18 0.006 264); --color-fg-secondary: oklch(0.36 0.012 264); --color-fg-tertiary: oklch(0.46 0.012 264); diff --git a/packages/web-ui/src/locales/en/home.json b/packages/web-ui/src/locales/en/home.json index e0fc0da2..4460806a 100644 --- a/packages/web-ui/src/locales/en/home.json +++ b/packages/web-ui/src/locales/en/home.json @@ -2,11 +2,25 @@ "title": "Dashboard", "subtitle": "Here's what's happening with your AI workforce today.", "hireAgent": "Deploy Agent", - "metrics": { - "activeAgents": "Active Agents", - "workingNow": "Working Now", - "healthScore": "Health Score", - "totalTasks": "Total Tasks" + "metricCards": { + "working": "Working", + "tasksDone": "Tasks Done", + "projects": "Projects", + "health": "Health" + }, + "statusBar": { + "agentsWorking": "working", + "tasksDone": "tasks done", + "projects": "projects", + "health": "Health" + }, + "systemHealth": { + "title": "System Health", + "successRate": "Success Rate", + "tasksTotal": "Completed / Total", + "tokenUsage": "Token Usage", + "currentWorking": "Currently Working", + "storage": "Storage" }, "gettingStarted": { "title": "Your AI workforce is ready", @@ -24,6 +38,29 @@ "watchDemo": "Watch Demo", "learnMore": "Learn More" }, + "attention": { + "title": "Needs Your Attention", + "approvals": "approval(s) waiting", + "requirements": "requirement(s) to review", + "tasksReview": "task(s) need your review", + "reportPlans": "report plan(s) pending", + "blocked": "task(s) stuck/blocked", + "viewBlocked": "View blocked tasks →", + "report": "report", + "planItems": "items" + }, + "globalOverview": { + "title": "Overview", + "projects": "Projects", + "requirements": "Requirements", + "tasks": "Tasks", + "deliverables": "Deliverables", + "active": "active" + }, + "liveActivity": { + "whosWorking": "Working Now", + "recentChanges": "Recent Changes" + }, "agentFocus": { "title": "Agent Focus", "totalQueued": "{{count}} total queued", @@ -32,15 +69,10 @@ "heartbeatSkip": "Heartbeat skipped (idle)" }, "teamOverview": { - "title": "Team Overview", + "title": "Teams", "subtitle": "{{teams}} teams · {{members}} members", "activeCount": "{{count}} Active" }, - "taskOverview": { - "title": "Task Overview", - "subtitle": "{{total}} tasks total", - "stuckBlocked": "{{count}} task(s) blocked without pending dependencies — may need attention" - }, "teamStatus": { "title": "Team Status", "noTeams": "No teams yet. Create a team or hire agents to get started.", @@ -48,21 +80,10 @@ "workingCount": "{{count}} working", "activeCount": "{{count}} active" }, - "pendingReviews": { - "title": "Pending Reviews", - "review": "Review →", - "proposedBy": "proposed by {{author}} · {{priority}}", - "proposedBy2": "proposed by {{author}}", - "agentProposed": "Agents proposed {{count}} requirement(s) — review to authorize work.", - "agent": "Agent", - "user": "User" - }, - "recentActivity": { - "title": "Activity Feed" - }, "topPerformers": { - "title": "Top Performing Agents", - "tasksCompleted": "{{count}} tasks completed" + "title": "Top Performers", + "tasksCompleted": "{{count}} tasks completed", + "done": "done" }, "ranking": { "title": "Agent Ranking", @@ -73,27 +94,10 @@ "errorRate": "Errors", "noData": "No agent data available yet." }, - "systemHealth": { - "title": "System Health", - "allOperational": "All Systems Operational", - "healthScore": "Health Score", - "successRate": "Success Rate", - "activeTotal": "Active / Total", - "tokenUsage": "Token Usage", - "workingNow": "Working Now", - "needsAttention": "Needs attention:", - "agentScoreChip": "{{name}} ({{score}}%)" - }, - "storage": { - "title": "Storage", - "total": "total", - "db": "DB: {{size}}", - "agents": "Agents: {{count}}", - "clickToView": "Click to view details in Settings", - "bytesZero": "0 B", - "bytesB": "B", - "bytesKB": "KB", - "bytesMB": "MB", - "bytesGB": "GB" + "createMenu": { + "agent": "Create Agent", + "team": "Create Team", + "skill": "Create Skill", + "discover": "Discover More" } } diff --git a/packages/web-ui/src/locales/zh-CN/home.json b/packages/web-ui/src/locales/zh-CN/home.json index 04748b5f..b81741a5 100644 --- a/packages/web-ui/src/locales/zh-CN/home.json +++ b/packages/web-ui/src/locales/zh-CN/home.json @@ -2,11 +2,25 @@ "title": "仪表盘", "subtitle": "以下是你的 AI 团队今日动态。", "hireAgent": "部署智能体", - "metrics": { - "activeAgents": "活跃智能体", - "workingNow": "工作中", - "healthScore": "健康分", - "totalTasks": "总任务数" + "metricCards": { + "working": "工作中", + "tasksDone": "任务完成", + "projects": "项目", + "health": "健康分" + }, + "statusBar": { + "agentsWorking": "工作中", + "tasksDone": "任务完成", + "projects": "个项目", + "health": "健康" + }, + "systemHealth": { + "title": "系统健康", + "successRate": "成功率", + "tasksTotal": "已完成 / 总计", + "tokenUsage": "Token 用量", + "currentWorking": "当前工作中", + "storage": "存储" }, "gettingStarted": { "title": "你的 AI 团队已就绪", @@ -24,6 +38,29 @@ "watchDemo": "观看演示", "learnMore": "了解更多" }, + "attention": { + "title": "需要你的关注", + "approvals": "项审批等待处理", + "requirements": "条需求待审核", + "tasksReview": "个任务需要你审查", + "reportPlans": "份报告计划待批准", + "blocked": "个任务阻塞/卡住", + "viewBlocked": "查看阻塞任务 →", + "report": "报告", + "planItems": "项" + }, + "globalOverview": { + "title": "全局概览", + "projects": "项目", + "requirements": "需求", + "tasks": "任务", + "deliverables": "交付物", + "active": "进行中" + }, + "liveActivity": { + "whosWorking": "正在工作", + "recentChanges": "最近变更" + }, "agentFocus": { "title": "智能体焦点", "totalQueued": "共 {{count}} 项排队", @@ -32,15 +69,10 @@ "heartbeatSkip": "心跳已跳过(空闲)" }, "teamOverview": { - "title": "团队概览", + "title": "团队", "subtitle": "{{teams}} 个团队 · {{members}} 名成员", "activeCount": "{{count}} 活跃" }, - "taskOverview": { - "title": "任务总览", - "subtitle": "共 {{total}} 个任务", - "stuckBlocked": "{{count}} 个任务阻塞但无未完成依赖,可能需要关注" - }, "teamStatus": { "title": "团队状态", "noTeams": "暂无团队。创建团队或招聘智能体以开始。", @@ -48,21 +80,10 @@ "workingCount": "{{count}} 工作中", "activeCount": "{{count}} 活跃" }, - "pendingReviews": { - "title": "待审核", - "review": "去审核 →", - "proposedBy": "由 {{author}} 提出 · {{priority}}", - "proposedBy2": "由 {{author}} 提出", - "agentProposed": "智能体提出了 {{count}} 条需求 — 请审核以授权执行。", - "agent": "智能体", - "user": "用户" - }, - "recentActivity": { - "title": "活动动态" - }, "topPerformers": { - "title": "最佳表现智能体", - "tasksCompleted": "已完成 {{count}} 个任务" + "title": "最佳表现", + "tasksCompleted": "已完成 {{count}} 个任务", + "done": "完成" }, "ranking": { "title": "智能体排行榜", @@ -73,27 +94,10 @@ "errorRate": "错误率", "noData": "暂无智能体数据。" }, - "systemHealth": { - "title": "系统健康", - "allOperational": "所有系统正常运行", - "healthScore": "健康分", - "successRate": "成功率", - "activeTotal": "活跃 / 总计", - "tokenUsage": "Token 用量", - "workingNow": "当前工作中", - "needsAttention": "需要关注:", - "agentScoreChip": "{{name}}({{score}}%)" - }, - "storage": { - "title": "存储", - "total": "合计", - "db": "数据库:{{size}}", - "agents": "智能体:{{count}}", - "clickToView": "点击在设置中查看详情", - "bytesZero": "0 B", - "bytesB": "B", - "bytesKB": "KB", - "bytesMB": "MB", - "bytesGB": "GB" + "createMenu": { + "agent": "创建智能体", + "team": "创建智能体团队", + "skill": "创建技能", + "discover": "发现更多" } } diff --git a/packages/web-ui/src/pages/Deliverables.tsx b/packages/web-ui/src/pages/Deliverables.tsx index 2085227c..9c9387be 100644 --- a/packages/web-ui/src/pages/Deliverables.tsx +++ b/packages/web-ui/src/pages/Deliverables.tsx @@ -8,6 +8,7 @@ import { ArtifactPreview, type BuilderMode } from '../components/BuilderArtifact import { navBus } from '../navBus.ts'; import { PAGE } from '../routes.ts'; import { useIsMobile } from '../hooks/useIsMobile.ts'; +import { usePageActive } from '../hooks/usePageActive.ts'; import { MobileMenuButton } from '../components/MobileMenuButton.tsx'; import { useResizablePanel } from '../hooks/useResizablePanel.ts'; import { useSwipeTabs } from '../hooks/useSwipeTabs.ts'; @@ -38,6 +39,7 @@ const ARTIFACT_META: Record<string, { icon: string; color: string }> = { export function DeliverablesPage({ authUser: _authUser }: { authUser?: AuthUser } = {}) { const { t } = useTranslation(['deliverables', 'common']); const isMobile = useIsMobile(); + const isActive = usePageActive(PAGE.DELIVERABLES); const listPanel = useResizablePanel({ side: 'left', defaultWidth: 384, minWidth: 280, maxWidth: 600, storageKey: 'markus_deliverables_list' }); const [mobileShowDetail, setMobileShowDetail] = useState(false); const mobileShowDetailRef = useRef(mobileShowDetail); @@ -161,11 +163,12 @@ export function DeliverablesPage({ authUser: _authUser }: { authUser?: AuthUser useEffect(() => { refresh(); }, [refresh]); useEffect(() => { + if (!isActive) return; const unsub1 = wsClient.on('deliverable:created', () => refresh()); const unsub2 = wsClient.on('deliverable:updated', () => refresh()); const unsub3 = wsClient.on('deliverable:removed', () => refresh()); return () => { unsub1(); unsub2(); unsub3(); }; - }, [refresh]); + }, [refresh, isActive]); // Handle deep navigation to a specific deliverable const pendingOpenRef = useRef<string | null>(null); diff --git a/packages/web-ui/src/pages/Home.tsx b/packages/web-ui/src/pages/Home.tsx index e333cf77..e455a6fc 100644 --- a/packages/web-ui/src/pages/Home.tsx +++ b/packages/web-ui/src/pages/Home.tsx @@ -1,47 +1,57 @@ -import { useEffect, useState, useMemo, useRef } from 'react'; +import { useEffect, useState, useMemo, useRef, useCallback, forwardRef } from 'react'; import type { TFunction } from 'i18next'; import { useTranslation } from 'react-i18next'; -import { api, type AgentInfo, type TaskInfo, type OpsDashboard, type TeamInfo, type RequirementInfo, type StorageInfo } from '../api.ts'; +import { api, type AgentInfo, type TaskInfo, type OpsDashboard, type TeamInfo, type RequirementInfo, type ProjectInfo, type StorageInfo } from '../api.ts'; import { navBus } from '../navBus.ts'; import { PAGE } from '../routes.ts'; +import { usePageActive } from '../hooks/usePageActive.ts'; import { Avatar } from '../components/Avatar.tsx'; import { MobileMenuButton } from '../components/MobileMenuButton.tsx'; import { useIsMobile } from '../hooks/useIsMobile.ts'; -const SHOW_HERO_BANNER = false; - const DONUT_COLORS: Record<string, string> = { completed: '#22c55e', in_progress: '#8b5cf6', review: '#3b82f6', pending: '#f59e0b', failed: '#ef4444', blocked: '#f59e0b', rejected: '#fb7185', cancelled: '#6b7280', }; - const STATUS_COLORS_BG: Record<string, string> = { completed: 'bg-green-500', in_progress: 'bg-brand-500', review: 'bg-blue-500', pending: 'bg-amber-500', failed: 'bg-red-500', blocked: 'bg-amber-500', rejected: 'bg-rose-400', cancelled: 'bg-gray-500', }; - const TASK_STATUS_I18N: Record<string, string> = { pending: 'common:status.pending', in_progress: 'common:status.inProgress', blocked: 'common:status.blocked', review: 'common:status.review', completed: 'common:status.completed', failed: 'common:status.failed', rejected: 'common:status.rejected', cancelled: 'common:status.cancelled', }; - const STATUS_ORDER = ['completed', 'in_progress', 'review', 'pending', 'failed', 'blocked', 'rejected', 'cancelled']; +const ACTIVITY_ICON_BG: Record<string, string> = { + completed: 'bg-green-500/15', in_progress: 'bg-brand-500/15', pending: 'bg-amber-500/15', + review: 'bg-blue-500/15', failed: 'bg-red-500/15', blocked: 'bg-amber-500/15', + rejected: 'bg-red-500/15', cancelled: 'bg-gray-500/15', +}; +const ACTIVITY_LABEL_KEYS: Record<string, string> = { + 'Heartbeat check-in': 'agentFocus.heartbeatCheckIn', + 'Heartbeat check-in (idle skip)': 'agentFocus.heartbeatSkip', +}; + +// ═════════════════════════════════════════════════════════════════════════════ export function HomePage({ authUser }: { authUser?: { id: string; name: string; role: string; orgId: string } } = {}) { const { t } = useTranslation(['home', 'common', 'team']); const isMobile = useIsMobile(); + const isActive = usePageActive(PAGE.HOME); const [agents, setAgents] = useState<AgentInfo[]>([]); const [teams, setTeams] = useState<TeamInfo[]>([]); const [board, setBoard] = useState<Record<string, TaskInfo[]>>({}); const [ops, setOps] = useState<OpsDashboard | null>(null); const opsPeriod = '7d' as const; - const [pendingReqs, setPendingReqs] = useState<RequirementInfo[]>([]); + const [allRequirements, setAllRequirements] = useState<RequirementInfo[]>([]); + const [projects, setProjects] = useState<ProjectInfo[]>([]); + const [deliverableTotal, setDeliverableTotal] = useState(0); const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null); - const [showDeployChoice, setShowDeployChoice] = useState(false); + const [usageInfo, setUsageInfo] = useState<{ llmTokens: number; storageBytes: number } | null>(null); const [showCreateMenu, setShowCreateMenu] = useState(false); const [showRankingModal, setShowRankingModal] = useState(false); const createMenuRef = useRef<HTMLDivElement>(null); @@ -55,84 +65,75 @@ export function HomePage({ authUser }: { authUser?: { id: string; name: string; return () => document.removeEventListener('mousedown', handler); }, [showCreateMenu]); - const refresh = () => { + const refresh = useCallback(() => { api.agents.list().then(d => setAgents(d.agents)).catch(() => {}); api.teams.list().then(d => setTeams(d.teams)).catch(() => {}); api.tasks.board().then(d => setBoard(d.board)).catch(() => {}); api.ops.dashboard(opsPeriod).then(setOps).catch(() => {}); - api.requirements.list({ source: 'agent' }).then(d => { - setPendingReqs(d.requirements.filter(r => r.status === 'pending')); - }).catch(() => {}); + api.requirements.list().then(d => setAllRequirements(d.requirements)).catch(() => {}); + api.projects.list().then(d => setProjects(d.projects)).catch(() => {}); + api.deliverables.search({ limit: 1 }).then(d => setDeliverableTotal(d.total)).catch(() => {}); api.system.storage().then(setStorageInfo).catch(() => {}); - }; + api.usage.summary().then(d => setUsageInfo(d.usage)).catch(() => {}); + }, [opsPeriod]); useEffect(() => { + if (!isActive) return; refresh(); const i = setInterval(refresh, 30000); const onDataChanged = () => refresh(); window.addEventListener('markus:data-changed', onDataChanged); return () => { clearInterval(i); window.removeEventListener('markus:data-changed', onDataChanged); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [opsPeriod]); + }, [opsPeriod, isActive, refresh]); + // ── Computed ── const rootStatusCounts: Record<string, number> = {}; for (const [status, tasks] of Object.entries(board)) { if (status === 'archived') continue; - const count = tasks.length; - if (count > 0) rootStatusCounts[status] = count; + if (tasks.length > 0) rootStatusCounts[status] = tasks.length; } const completed = rootStatusCounts['completed'] ?? 0; const totalRootTasks = Object.values(rootStatusCounts).reduce((s, c) => s + c, 0); - - const activeAgents = agents.filter(a => a.status === 'idle' || a.status === 'working').length; const workingAgents = agents.filter(a => a.status === 'working').length; - const totalMailboxDepth = agents.reduce((sum, a) => sum + (a.mailboxDepth ?? 0), 0); + const activeProjects = projects.filter(p => p.status === 'active').length; + const completionRate = totalRootTasks > 0 ? Math.round((completed / totalRootTasks) * 100) : 0; + const sortedStatusEntries = STATUS_ORDER.filter(s => (rootStatusCounts[s] ?? 0) > 0).map(s => ({ status: s, count: rootStatusCounts[s]! })); + + const reqStatusCounts = useMemo(() => { + const c: Record<string, number> = {}; + allRequirements.forEach(r => { c[r.status] = (c[r.status] ?? 0) + 1; }); + return c; + }, [allRequirements]); const teamSummaries = useMemo(() => { const agentMap = new Map(agents.map(a => [a.id, a])); return teams.map(team => { - const memberAgents = team.members - .filter(m => m.type === 'agent') - .map(m => agentMap.get(m.id)) - .filter((a): a is AgentInfo => !!a); - const working = memberAgents.filter(a => a.status === 'working').length; - const active = memberAgents.filter(a => a.status === 'idle' || a.status === 'working').length; - return { team, agents: memberAgents, working, active, total: memberAgents.length }; + const ma = team.members.filter(m => m.type === 'agent').map(m => agentMap.get(m.id)).filter((a): a is AgentInfo => !!a); + return { team, agents: ma, working: ma.filter(a => a.status === 'working').length, total: ma.length }; }); }, [teams, agents]); const topPerformers = useMemo(() => { if (!ops) return []; - return [...ops.agentEfficiency] - .filter(a => a.taskMetrics.completed > 0) - .sort((a, b) => b.taskMetrics.completed - a.taskMetrics.completed) - .slice(0, 5); + return [...ops.agentEfficiency].filter(a => a.taskMetrics.completed > 0).sort((a, b) => b.taskMetrics.completed - a.taskMetrics.completed).slice(0, 3); }, [ops]); const allRankedAgents = useMemo(() => { if (!ops) return []; - const rankScore = (a: typeof ops.agentEfficiency[0]) => { - const tasks = a.taskMetrics.completed + a.taskMetrics.failed; - // Health score is modulated by activity: without actual work, - // an agent only gets 30% credit for its health score. - // Reaches full credit at 3+ completed/failed tasks. - const activityWeight = 0.3 + 0.7 * Math.min(1, tasks / 3); - return a.healthScore * activityWeight + a.taskMetrics.completed * 0.5; + const score = (a: typeof ops.agentEfficiency[0]) => { + const w = 0.3 + 0.7 * Math.min(1, (a.taskMetrics.completed + a.taskMetrics.failed) / 3); + return a.healthScore * w + a.taskMetrics.completed * 0.5; }; - return [...ops.agentEfficiency].sort((a, b) => rankScore(b) - rankScore(a)); + return [...ops.agentEfficiency].sort((a, b) => score(b) - score(a)); }, [ops]); - const completionRate = totalRootTasks > 0 ? Math.round((completed / totalRootTasks) * 100) : 0; - - const sortedStatusEntries = STATUS_ORDER - .filter(s => (rootStatusCounts[s] ?? 0) > 0) - .map(s => ({ status: s, count: rootStatusCounts[s]! })); + const workingAgentsList = agents.filter(a => a.status === 'working'); - const totalTokens = ops ? ops.agentEfficiency.reduce((sum, a) => sum + a.tokenUsage.input + a.tokenUsage.output, 0) : 0; + // ═══════════════════════════════════════════════════════════════════════════ return ( <div className="flex-1 overflow-y-auto scrollbar-thin"> - {/* Header */} + {/* ── Header ── */} <div className="flex items-center justify-between px-4 sm:px-6 lg:px-8 h-14 sm:h-16 max-w-7xl mx-auto w-full"> <div className="flex items-center gap-2"> {isMobile && <MobileMenuButton />} @@ -141,115 +142,31 @@ export function HomePage({ authUser }: { authUser?: { id: string; name: string; <p className="text-xs text-fg-tertiary hidden sm:block">{t('subtitle')}</p> </div> </div> - {isMobile ? ( - <div className="flex items-center gap-1"> - <button - onClick={() => navBus.navigate(PAGE.SEARCH)} - className="p-2 rounded-lg hover:bg-surface-overlay transition-colors text-fg-secondary" - aria-label="Search" - > - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg> - </button> - <div ref={createMenuRef} className="relative"> - <button - onClick={() => setShowCreateMenu(!showCreateMenu)} - className="p-2 rounded-lg hover:bg-surface-overlay transition-colors text-fg-secondary" - aria-label="Create" - > - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg> - </button> - {showCreateMenu && ( - <div className="absolute right-0 top-full mt-2 bg-surface-elevated border border-border-default rounded-xl shadow-xl z-50 overflow-hidden w-48 animate-fadeIn"> - <div className="py-1"> - <button - onClick={() => { setShowCreateMenu(false); navBus.navigate(PAGE.BUILDER, { storeTab: 'builder' }); }} - className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-fg-secondary hover:bg-surface-overlay transition-colors" - > - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><line x1="19" y1="8" x2="19" y2="14" /><line x1="22" y1="11" x2="16" y2="11" /></svg> - {t('home:createMenu.agent', { defaultValue: '创建智能体' })} - </button> - <button - onClick={() => { setShowCreateMenu(false); navBus.navigate(PAGE.BUILDER, { storeTab: 'builder' }); }} - className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-fg-secondary hover:bg-surface-overlay transition-colors" - > - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg> - {t('home:createMenu.team', { defaultValue: '创建智能体团队' })} - </button> - <button - onClick={() => { setShowCreateMenu(false); navBus.navigate(PAGE.BUILDER, { storeTab: 'builder' }); }} - className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-fg-secondary hover:bg-surface-overlay transition-colors" - > - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /></svg> - {t('home:createMenu.skill', { defaultValue: '创建技能' })} - </button> - <div className="border-t border-border-default my-1" /> - <button - onClick={() => { setShowCreateMenu(false); const tabs = ['agents', 'teams', 'skills'] as const; navBus.navigate(PAGE.BUILDER, { storeTab: tabs[Math.floor(Math.random() * 3)] }); }} - className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-fg-secondary hover:bg-surface-overlay transition-colors" - > - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg> - {t('home:createMenu.discover', { defaultValue: '发现更多' })} - </button> - </div> - </div> - )} - </div> - </div> - ) : ( - <div className="flex items-center gap-2"> - <button - onClick={() => window.dispatchEvent(new CustomEvent('markus:open-search'))} - className="p-2 rounded-lg hover:bg-surface-overlay transition-colors text-fg-secondary" - aria-label="Search" - > - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg> - </button> - <div ref={createMenuRef} className="relative"> - <button - onClick={() => setShowCreateMenu(!showCreateMenu)} - className="p-2 rounded-lg hover:bg-surface-overlay transition-colors text-fg-secondary" - aria-label="Create" - > - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg> - </button> - {showCreateMenu && ( - <div className="absolute right-0 top-full mt-2 bg-surface-elevated border border-border-default rounded-xl shadow-xl z-50 overflow-hidden w-48 animate-fadeIn"> - <div className="py-1"> - <button onClick={() => { setShowCreateMenu(false); navBus.navigate(PAGE.BUILDER); }} className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-fg-secondary hover:bg-surface-overlay transition-colors"> - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><line x1="19" y1="8" x2="19" y2="14" /><line x1="22" y1="11" x2="16" y2="11" /></svg> - {t('home:createMenu.agent', { defaultValue: '创建智能体' })} - </button> - <button onClick={() => { setShowCreateMenu(false); navBus.navigate(PAGE.BUILDER); }} className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-fg-secondary hover:bg-surface-overlay transition-colors"> - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg> - {t('home:createMenu.team', { defaultValue: '创建智能体团队' })} - </button> - <button onClick={() => { setShowCreateMenu(false); navBus.navigate(PAGE.BUILDER); }} className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-fg-secondary hover:bg-surface-overlay transition-colors"> - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /></svg> - {t('home:createMenu.skill', { defaultValue: '创建技能' })} - </button> - <div className="border-t border-border-default my-1" /> - <button onClick={() => { setShowCreateMenu(false); navBus.navigate(PAGE.STORE); }} className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-fg-secondary hover:bg-surface-overlay transition-colors"> - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 2L3 7v13a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7l-3-5z" /><line x1="3" y1="7" x2="21" y2="7" /><path d="M16 11a4 4 0 0 1-8 0" /></svg> - {t('home:createMenu.discover', { defaultValue: '发现更多' })} - </button> - </div> - </div> - )} - </div> - </div> - )} + <div className="flex items-center gap-1.5"> + <button onClick={() => isMobile ? navBus.navigate(PAGE.SEARCH) : window.dispatchEvent(new CustomEvent('markus:open-search'))} + className="p-2 rounded-lg hover:bg-surface-overlay transition-colors text-fg-tertiary" aria-label="Search"> + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg> + </button> + <CreateMenu ref={createMenuRef} show={showCreateMenu} onToggle={() => setShowCreateMenu(!showCreateMenu)} onClose={() => setShowCreateMenu(false)} t={t} isMobile={isMobile} /> + </div> </div> - <div className="p-4 sm:p-6 lg:p-8 space-y-5 sm:space-y-6 max-w-7xl mx-auto w-full"> - {/* Metric Cards */} - <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4"> - <MetricCard label={t('metrics.activeAgents')} value={activeAgents} total={agents.length} icon={<IconAgents />} color="brand" onClick={() => navBus.navigate(PAGE.TEAM)} /> - <MetricCard label={t('metrics.workingNow')} value={workingAgents} icon={<IconRunning />} color="blue" onClick={() => navBus.navigate(PAGE.TEAM)} /> - <MetricCard label={t('metrics.healthScore')} value={ops?.systemHealth.overallScore ?? 0} suffix="%" icon={<IconHealth />} color="green" onClick={() => setShowRankingModal(true)} /> - <MetricCard label={t('metrics.totalTasks')} value={totalRootTasks} icon={<IconTasks />} color="amber" onClick={() => navBus.navigate(PAGE.WORK)} /> + <div className="px-4 sm:px-6 lg:px-8 pb-8 space-y-6 max-w-7xl mx-auto w-full"> + + {/* ── Metric Cards ── */} + <div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> + <MetricCard label={t('metricCards.working')} value={String(workingAgents)} + icon={<MetricIcon type="working" />} pulse={workingAgents > 0} onClick={() => navBus.navigate(PAGE.TEAM)} /> + <MetricCard label={t('metricCards.tasksDone')} value={`${completed}`} sub={`/${totalRootTasks}`} + icon={<MetricIcon type="tasks" />} onClick={() => navBus.navigate(PAGE.WORK)} /> + <MetricCard label={t('metricCards.projects')} value={String(activeProjects)} + icon={<MetricIcon type="projects" />} onClick={() => navBus.navigate(PAGE.WORK)} /> + <MetricCard label={t('metricCards.health')} value={`${ops?.systemHealth.overallScore ?? '—'}`} sub={ops ? '%' : undefined} + icon={<MetricIcon type="health" />} color={!ops ? undefined : ops.systemHealth.overallScore >= 80 ? 'green' : ops.systemHealth.overallScore >= 50 ? 'amber' : 'red'} + onClick={() => setShowRankingModal(true)} /> </div> - {/* Getting Started */} + {/* ── Getting Started (empty state) ── */} {totalRootTasks === 0 && (!ops || ops.taskKPI.recentActivity.length === 0) && ( <div className="bg-gradient-to-br from-brand-600/10 via-surface-secondary to-surface-secondary border border-brand-500/20 rounded-2xl p-5 sm:p-6"> <h3 className="text-sm font-semibold text-fg-primary mb-1">{t('gettingStarted.title')}</h3> @@ -269,491 +186,326 @@ export function HomePage({ authUser }: { authUser?: { id: string; name: string; </div> )} - {/* Hidden Hero Banner */} - {SHOW_HERO_BANNER && ( - <div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-brand-700 via-brand-600 to-blue-600 p-8"> - <div className="absolute inset-0 opacity-10"> - <div className="absolute top-4 right-12 w-24 h-24 rounded-full bg-white/20" /> - <div className="absolute bottom-4 right-32 w-16 h-16 rounded-full bg-white/15" /> - <div className="absolute top-8 right-48 w-12 h-12 rounded-full bg-white/10" /> - </div> - <div className="relative z-10 max-w-md"> - <h3 className="text-xl font-bold text-white mb-2">{t('heroBanner.title')}</h3> - <p className="text-sm text-white/80 mb-5">{t('heroBanner.subtitle')}</p> - <div className="flex gap-3"> - <button className="px-5 py-2.5 bg-white text-brand-700 text-sm font-semibold rounded-xl hover:bg-white/90 transition-colors shadow-lg">{t('heroBanner.watchDemo')}</button> - <button className="px-5 py-2.5 bg-white/15 text-white text-sm font-medium rounded-xl hover:bg-white/25 transition-colors border border-white/20">{t('heroBanner.learnMore')}</button> - </div> - </div> - </div> - )} + {/* ── Main Content: 2-column layout ── */} + <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> - {/* Main Content Grid */} - <div className="grid grid-cols-1 lg:grid-cols-3 gap-5 sm:gap-6"> - {/* Left Column */} - <div className="lg:col-span-2 space-y-5 sm:space-y-6"> - {/* Pending Requirement Reviews */} - {pendingReqs.length > 0 && ( - <div className="bg-surface-secondary border border-amber-500/30 rounded-2xl p-4 sm:p-5"> - <div className="flex items-center justify-between mb-3"> - <div className="flex items-center gap-2"> - <span className="w-2 h-2 bg-amber-400 rounded-full animate-pulse" /> - <h3 className="text-xs font-semibold text-amber-600 uppercase tracking-wider">{t('pendingReviews.title')}</h3> - </div> - <button onClick={() => navBus.navigate(PAGE.WORK)} className="text-[11px] text-fg-tertiary hover:text-fg-secondary">{t('pendingReviews.review')}</button> - </div> - <div className="space-y-2"> - {pendingReqs.slice(0, 5).map(req => { - const authorAgent = agents.find(a => a.id === req.createdBy); - const authorName = authorAgent?.name ?? req.createdBy; - return ( - <div key={req.id} className="flex items-start gap-2.5 py-2 px-2.5 rounded-xl bg-surface-elevated/40 hover:bg-surface-elevated/60 transition-colors cursor-pointer" onClick={() => navBus.navigate(PAGE.WORK, { openRequirement: req.id })}> - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2"> - {(req.priority === 'urgent' || req.priority === 'high') && ( - <span className={`text-[10px] font-medium shrink-0 ${req.priority === 'urgent' ? 'text-red-500' : 'text-amber-500'}`}>[{t(`common:priority.${req.priority}`)}]</span> - )} - <span className="text-xs text-fg-primary font-medium truncate">{req.title}</span> - </div> - <div className="flex items-center gap-1.5 mt-0.5"> - <span className="text-[10px] text-fg-tertiary">{t('pendingReviews.proposedBy2', { author: authorName })}</span> - <span className="text-[10px] text-fg-muted">·</span> - <span className="text-[10px] text-fg-tertiary">{formatRelativeTime(req.createdAt, t)}</span> - </div> - </div> - <span className="text-[10px] bg-amber-500/15 text-amber-600 px-1.5 py-0.5 rounded-full shrink-0">{req.source === 'agent' ? t('pendingReviews.agent') : t('pendingReviews.user')}</span> - </div> - ); - })} - {pendingReqs.length > 5 && <div className="text-[10px] text-fg-tertiary text-center pt-1">{t('common:units.more', { count: pendingReqs.length - 5 })}</div>} - </div> - <p className="text-[10px] text-fg-tertiary mt-3">{t('pendingReviews.agentProposed', { count: pendingReqs.length })}</p> - </div> - )} + {/* ── Left Column (2/3) ── */} + <div className="lg:col-span-2 space-y-6"> - {/* Task Overview — donut + all status legend */} + {/* Task Overview with donut */} {totalRootTasks > 0 && ( - <div className="bg-surface-elevated rounded-2xl p-4 sm:p-5 cursor-pointer hover:bg-surface-overlay transition-colors" onClick={() => navBus.navigate(PAGE.WORK)}> - <div className="flex items-center justify-between mb-5"> - <div> - <h3 className="text-sm font-semibold text-fg-primary">{t('taskOverview.title')}</h3> - <p className="text-[11px] text-fg-tertiary mt-0.5">{t('taskOverview.subtitle', { total: totalRootTasks })}</p> - </div> - <button onClick={e => { e.stopPropagation(); navBus.navigate(PAGE.WORK); }} className="text-[11px] text-brand-400 hover:text-brand-300 font-medium">{t('common:viewAll')}</button> + <div className="bg-surface-elevated shadow-sm rounded-2xl overflow-hidden"> + <div className="flex items-center justify-between px-5 pt-5 pb-3"> + <h3 className="text-sm font-semibold text-fg-primary">{t('globalOverview.title')}</h3> + <button onClick={() => navBus.navigate(PAGE.WORK)} className="text-[11px] text-brand-400 hover:text-brand-300 font-medium">{t('common:viewAll')}</button> </div> - <div className="flex flex-col sm:flex-row items-center gap-6 sm:gap-10"> + {/* Tasks with donut */} + <div className="px-5 py-4 flex flex-col sm:flex-row items-center gap-6 border-t border-border-subtle/50"> <DonutChart statusCounts={rootStatusCounts} total={totalRootTasks} completionRate={completionRate} completed={completed} /> - <div className="flex-1 grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-3 min-w-0 w-full sm:w-auto"> - {sortedStatusEntries.map(({ status, count }) => ( - <DonutLegendItem key={status} color={STATUS_COLORS_BG[status] ?? 'bg-gray-500'} label={t(TASK_STATUS_I18N[status] ?? status)} count={count} /> - ))} + <div className="flex-1 min-w-0"> + <div className="grid grid-cols-2 gap-x-5 gap-y-2.5"> + {sortedStatusEntries.map(({ status, count }) => ( + <button key={status} onClick={() => navBus.navigate(PAGE.WORK, { statusFilter: status })} + className="flex items-center gap-2 group cursor-pointer text-left"> + <span className={`w-2 h-2 rounded-full shrink-0 ${STATUS_COLORS_BG[status] ?? 'bg-gray-500'}`} /> + <span className="text-xs text-fg-tertiary group-hover:text-fg-secondary transition-colors">{t(TASK_STATUS_I18N[status] ?? status)}</span> + <span className="text-sm font-semibold text-fg-primary ml-auto tabular-nums">{count}</span> + </button> + ))} + </div> </div> </div> - {ops && (ops.taskKPI.stuckBlockedCount ?? 0) > 0 && ( - <div className="mt-3 flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-xl text-xs text-red-400" - onClick={e => { e.stopPropagation(); navBus.navigate(PAGE.WORK, { filter: 'blocked' }); }}> - <span className="w-2 h-2 bg-red-500 rounded-full animate-pulse shrink-0" /> - {t('taskOverview.stuckBlocked', { count: ops.taskKPI.stuckBlockedCount })} - </div> - )} - </div> - )} - {/* Activity Feed */} - {ops && ops.taskKPI.recentActivity.length > 0 && ( - <div className="bg-surface-elevated rounded-2xl p-4 sm:p-5"> - <div className="flex items-center justify-between mb-4"> - <h3 className="text-sm font-semibold text-fg-primary">{t('recentActivity.title')}</h3> - <button onClick={() => navBus.navigate(PAGE.WORK)} className="text-[11px] text-brand-400 hover:text-brand-300 font-medium">{t('common:viewAll')}</button> - </div> - <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-0.5"> - {ops.taskKPI.recentActivity.slice(0, 10).map(act => ( - <div key={act.taskId} - className="flex items-center gap-2.5 py-2 px-2 rounded-xl hover:bg-surface-elevated/40 transition-colors cursor-pointer" - onClick={() => navBus.navigate(PAGE.WORK, { openTask: act.taskId })} - > - <div className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 ${ACTIVITY_ICON_BG[act.status] ?? 'bg-gray-500/15'}`}> - <ActivityIcon status={act.status} /> - </div> - <span className="text-xs text-fg-secondary truncate flex-1">{act.title}</span> - <span className="text-[10px] text-fg-tertiary shrink-0">{formatRelativeTime(act.updatedAt, t)}</span> - </div> - ))} + {/* Entity rows */} + <div className="divide-y divide-border-subtle/50"> + {projects.length > 0 && ( + <EntityRow icon="folder" label={t('globalOverview.projects')} value={projects.length} onClick={() => navBus.navigate(PAGE.WORK)}> + <Chip>{activeProjects} {t('globalOverview.active')}</Chip> + </EntityRow> + )} + {allRequirements.length > 0 && ( + <EntityRow icon="edit" label={t('globalOverview.requirements')} value={allRequirements.length} onClick={() => navBus.navigate(PAGE.WORK)}> + {(['in_progress', 'pending', 'completed'] as const).map(s => + (reqStatusCounts[s] ?? 0) > 0 && <Chip key={s}>{reqStatusCounts[s]} {t(`common:status.${s === 'in_progress' ? 'inProgress' : s}`)}</Chip> + )} + </EntityRow> + )} + {deliverableTotal > 0 && ( + <EntityRow icon="book" label={t('globalOverview.deliverables')} value={deliverableTotal} onClick={() => navBus.navigate(PAGE.DELIVERABLES)} /> + )} </div> </div> )} - {/* Team Overview */} - <div className="bg-surface-elevated rounded-2xl p-4 sm:p-5"> - <div className="flex items-center justify-between mb-4"> - <div> - <h3 className="text-sm font-semibold text-fg-primary">{t('teamOverview.title')}</h3> - <p className="text-[11px] text-fg-tertiary mt-0.5">{t('teamOverview.subtitle', { teams: teams.length, members: agents.length })}</p> - </div> - <button onClick={() => navBus.navigate(PAGE.TEAM)} className="text-[11px] text-brand-400 hover:text-brand-300 font-medium">{t('common:viewAll')}</button> - </div> - {teamSummaries.length === 0 && agents.length === 0 ? ( - <div className="text-sm text-fg-tertiary py-6 text-center cursor-pointer" onClick={() => navBus.navigate(PAGE.TEAM)}>{t('teamStatus.noTeams')}</div> - ) : ( - <div className="space-y-1"> - {teamSummaries.slice(0, 5).map(ts => ( - <div key={ts.team.id} - className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-surface-elevated/40 cursor-pointer transition-colors" - onClick={() => navBus.navigate(PAGE.TEAM, { selectTeam: ts.team.id })} - > - <div className="w-8 h-8 rounded-lg bg-brand-600/20 flex items-center justify-center text-xs font-bold text-brand-400 shrink-0">{ts.team.name.charAt(0)}</div> - <div className="flex-1 min-w-0"> - <div className="text-sm font-medium text-fg-primary truncate">{ts.team.name}</div> - {ts.team.description && <div className="text-[11px] text-fg-tertiary truncate">{ts.team.description}</div>} + {/* Activity Feed */} + {(workingAgentsList.length > 0 || (ops && ops.taskKPI.recentActivity.length > 0)) && ( + <div className="bg-surface-elevated shadow-sm rounded-2xl overflow-hidden"> + <div className={`grid grid-cols-1 ${workingAgentsList.length > 0 && ops && ops.taskKPI.recentActivity.length > 0 ? 'sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x divide-border-subtle/50' : ''}`}> + {/* Who's Working */} + {workingAgentsList.length > 0 && ( + <div className="p-5"> + <div className="flex items-center gap-2 mb-4"> + <span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" /> + <h4 className="text-xs font-semibold text-fg-tertiary uppercase tracking-wider">{t('liveActivity.whosWorking')}</h4> </div> - <div className="flex items-center -space-x-1.5 shrink-0"> - {ts.agents.slice(0, 3).map(a => ( - <Avatar key={a.id} name={a.name} avatarUrl={(a as any).avatarUrl} size={22} bgClass="bg-surface-overlay text-fg-secondary ring-2 ring-surface-secondary" /> + <div className="space-y-1"> + {workingAgentsList.map(a => ( + <div key={a.id} className="flex items-center gap-2.5 px-2 py-2 rounded-lg hover:bg-surface-overlay/40 cursor-pointer transition-colors" + onClick={() => navBus.navigate(PAGE.TEAM, { agentId: a.id, profileTab: 'overview' })}> + <Avatar name={a.name} avatarUrl={(a as any).avatarUrl} size={24} bgClass="bg-brand-600/30 text-brand-300" /> + <div className="flex-1 min-w-0"> + <div className="text-xs font-medium text-fg-primary truncate">{a.name}</div> + <div className="text-[10px] text-fg-tertiary truncate">{localizeActivityLabel(a.currentActivity?.label, t) ?? t('agentFocus.working')}</div> + </div> + </div> ))} - {ts.agents.length > 3 && ( - <div className="w-[22px] h-[22px] rounded-full bg-surface-overlay text-fg-tertiary flex items-center justify-center text-[8px] font-bold ring-2 ring-surface-secondary">+{ts.agents.length - 3}</div> - )} </div> - {ts.active > 0 && ( - <span className="text-[10px] bg-green-500/15 text-green-500 px-2 py-0.5 rounded-full shrink-0">{t('teamOverview.activeCount', { count: ts.active })}</span> - )} </div> - ))} - </div> - )} - </div> - - </div> - - {/* Right Column */} - <div className="space-y-5 sm:space-y-6"> - {/* Agent Focus — compact, only when agents working */} - {workingAgents > 0 && ( - <div className="bg-surface-elevated rounded-2xl p-4 sm:p-5"> - <div className="flex items-center justify-between mb-3"> - <div className="flex items-center gap-2"> - <div className="w-2 h-2 rounded-full bg-brand-500 animate-pulse" /> - <h3 className="text-xs font-semibold text-fg-tertiary uppercase tracking-wider">{t('agentFocus.title')}</h3> - </div> - {totalMailboxDepth > 0 && <span className="text-[10px] text-fg-tertiary">{t('agentFocus.totalQueued', { count: totalMailboxDepth })}</span>} - </div> - <div className="space-y-1.5"> - {agents.filter(a => a.status === 'working').map(a => ( - <div key={a.id} - className="flex items-center gap-2 px-2.5 py-2 rounded-xl bg-surface-elevated/30 hover:bg-surface-elevated/60 cursor-pointer transition-colors" - onClick={() => navBus.navigate(PAGE.TEAM, { agentId: a.id, profileTab: 'overview' })} - > - <Avatar name={a.name} avatarUrl={(a as any).avatarUrl} size={22} bgClass="bg-brand-600/40 text-brand-300" /> - <div className="flex-1 min-w-0"> - <div className="text-xs font-medium text-fg-primary truncate">{a.name}</div> - <div className="text-[10px] text-fg-tertiary truncate">{localizeActivityLabel(a.currentActivity?.label, t) ?? t('agentFocus.working')}</div> + )} + {/* Recent Changes - takes full width when no working agents */} + {ops && ops.taskKPI.recentActivity.length > 0 && ( + <div className="p-5"> + <div className="flex items-center justify-between mb-4"> + <h4 className="text-xs font-semibold text-fg-tertiary uppercase tracking-wider">{t('liveActivity.recentChanges')}</h4> + <button onClick={() => navBus.navigate(PAGE.WORK)} className="text-[11px] text-brand-400 hover:text-brand-300 font-medium">{t('common:viewAll')}</button> + </div> + <div className={`${workingAgentsList.length > 0 ? '' : 'grid grid-cols-1 sm:grid-cols-2 gap-x-4'}`}> + {ops.taskKPI.recentActivity.slice(0, workingAgentsList.length > 0 ? 8 : 12).map(act => ( + <div key={act.taskId} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-surface-overlay/40 cursor-pointer transition-colors" + onClick={() => navBus.navigate(PAGE.WORK, { openTask: act.taskId })}> + <span className={`w-5 h-5 rounded-full flex items-center justify-center shrink-0 ${ACTIVITY_ICON_BG[act.status] ?? 'bg-gray-500/15'}`}> + <ActivityIcon status={act.status} /> + </span> + <span className="text-[11px] text-fg-secondary truncate flex-1">{act.title}</span> + <span className="text-[10px] text-fg-muted shrink-0">{formatRelativeTime(act.updatedAt, t)}</span> + </div> + ))} </div> - <span className={`w-2 h-2 rounded-full shrink-0 ${ - a.attentionState === 'focused' ? 'bg-brand-400 animate-pulse' - : a.attentionState === 'deciding' ? 'bg-amber-400 animate-pulse' - : 'bg-green-400' - }`} /> </div> - ))} + )} </div> </div> )} + </div> - {/* Top Performing Agents */} - {topPerformers.length > 0 && ( - <div className="bg-surface-elevated rounded-2xl p-4 sm:p-5"> - <div className="flex items-center justify-between mb-4"> - <h3 className="text-sm font-semibold text-fg-primary">{t('topPerformers.title')}</h3> - <button onClick={() => setShowRankingModal(true)} className="text-[11px] text-brand-400 hover:text-brand-300 font-medium">{t('common:viewAll')}</button> - </div> - <div className="space-y-2.5"> - {topPerformers.map(agent => ( - <div key={agent.agentId} - className="flex items-center gap-3 cursor-pointer hover:bg-surface-elevated/30 rounded-xl px-2 py-1.5 transition-colors" - onClick={() => navBus.navigate(PAGE.TEAM, { selectAgent: agent.agentId })} - > - <Avatar name={agent.agentName} size={28} bgClass="bg-brand-600/30 text-brand-300" /> - <div className="flex-1 min-w-0"> - <div className="text-xs font-medium text-fg-primary truncate">{agent.agentName}</div> - <div className="text-[10px] text-fg-tertiary">{t('topPerformers.tasksCompleted', { count: agent.taskMetrics.completed })}</div> + {/* ── Right Column (1/3) ── */} + <div className="space-y-6"> + + {/* Top Performers + Teams (combined card) */} + <div className="bg-surface-elevated shadow-sm rounded-2xl overflow-hidden"> + {/* Top Performers section (on top) */} + {topPerformers.length > 0 && ( + <div className="p-5 pb-4"> + <div className="flex items-center justify-between mb-3"> + <h3 className="text-sm font-semibold text-fg-primary">{t('topPerformers.title')}</h3> + <button onClick={() => setShowRankingModal(true)} className="text-[11px] text-brand-400 hover:text-brand-300 font-medium">{t('common:viewAll')}</button> + </div> + <div className="space-y-0.5"> + {topPerformers.map((agent, idx) => ( + <div key={agent.agentId} className="flex items-center gap-2.5 px-2 py-2 rounded-lg hover:bg-surface-overlay/40 cursor-pointer transition-colors" + onClick={() => navBus.navigate(PAGE.TEAM, { selectAgent: agent.agentId })}> + <span className={`text-[10px] font-bold w-4 text-center ${idx === 0 ? 'text-amber-400' : idx === 1 ? 'text-gray-400' : 'text-amber-600'}`}>{idx + 1}</span> + <Avatar name={agent.agentName} size={24} bgClass="bg-brand-600/20 text-brand-300" /> + <span className="text-xs font-medium text-fg-primary truncate flex-1">{agent.agentName}</span> + <span className="text-[10px] text-fg-tertiary tabular-nums">{agent.taskMetrics.completed} {t('topPerformers.done')}</span> </div> - {agent.healthScore >= 80 && ( - <span className="text-[10px] text-green-500 font-medium">~{agent.healthScore}%</span> - )} - </div> - ))} + ))} + </div> </div> - </div> - )} + )} - {/* System Health + Storage */} - {ops && ( - <div className="bg-surface-elevated rounded-2xl p-4 sm:p-5"> - <div className="flex items-center justify-between mb-4"> - <h3 className="text-sm font-semibold text-fg-primary">{t('systemHealth.title')}</h3> - {ops.systemHealth.overallScore >= 80 && ( - <span className="flex items-center gap-1 text-[11px] text-green-500"> - <span className="w-1.5 h-1.5 rounded-full bg-green-500" /> - {t('systemHealth.allOperational')} - </span> - )} - </div> - <div className="space-y-3"> - <HealthRow label={t('systemHealth.successRate')} value={ops.taskKPI.successRate} max={100} suffix="%" /> - <HealthRow label={t('systemHealth.activeTotal')} value={activeAgents} max={agents.length || 1} suffix={`/${agents.length}`} /> - <HealthRow label={t('systemHealth.tokenUsage')} value={totalTokens} max={totalTokens || 1} displayValue={fmtNumber(totalTokens)} alwaysGreen /> - <HealthRow label={t('systemHealth.workingNow')} value={workingAgents} max={agents.length || 1} suffix={`/${agents.length}`} neutral /> + {/* Teams section */} + <div className={`p-5 ${topPerformers.length > 0 ? 'pt-3 border-t border-border-subtle/50' : ''}`}> + <div className="flex items-center justify-between mb-3"> + <h3 className="text-sm font-semibold text-fg-primary">{t('teamOverview.title')}</h3> + <button onClick={() => navBus.navigate(PAGE.TEAM)} className="text-[11px] text-brand-400 hover:text-brand-300 font-medium">{t('common:viewAll')}</button> </div> - - {ops.systemHealth.criticalAgents.length > 0 && ( - <div className="mt-4 pt-3 border-t border-border-default"> - <div className="flex items-center gap-2 text-xs text-red-500 flex-wrap"> - <span className="w-2 h-2 bg-red-500 rounded-full animate-pulse" /> - {t('systemHealth.needsAttention')} - {ops.systemHealth.criticalAgents.map(a => ( - <span key={a.id} className="px-2 py-0.5 bg-red-500/10 rounded-lg text-red-500 cursor-pointer hover:bg-red-500/20 transition-colors" onClick={() => navBus.navigate(PAGE.TEAM, { selectAgent: a.id })}>{t('systemHealth.agentScoreChip', { name: a.name, score: a.score })}</span> - ))} - </div> + {teamSummaries.length === 0 && agents.length === 0 ? ( + <div className="text-xs text-fg-tertiary py-4 text-center cursor-pointer" onClick={() => navBus.navigate(PAGE.TEAM)}>{t('teamStatus.noTeams')}</div> + ) : ( + <div className="space-y-0.5"> + {teamSummaries.map(ts => ( + <div key={ts.team.id} className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-surface-overlay/40 cursor-pointer transition-colors" + onClick={() => navBus.navigate(PAGE.TEAM, { selectTeam: ts.team.id })}> + <div className="w-7 h-7 rounded-md bg-brand-600/15 flex items-center justify-center text-[10px] font-bold text-brand-400 shrink-0">{ts.team.name.charAt(0)}</div> + <div className="flex-1 min-w-0"> + <div className="text-xs font-medium text-fg-primary truncate">{ts.team.name}</div> + </div> + <div className="flex items-center -space-x-1 shrink-0"> + {ts.agents.slice(0, 3).map(a => ( + <Avatar key={a.id} name={a.name} avatarUrl={(a as any).avatarUrl} size={18} bgClass="bg-surface-overlay text-fg-secondary ring-1 ring-surface-elevated" /> + ))} + </div> + {ts.working > 0 && <span className="text-[10px] text-green-500 font-medium shrink-0">{ts.working}/{ts.total}</span>} + </div> + ))} </div> )} + </div> + </div> - {/* Storage */} + {/* System Health */} + <div className="bg-surface-elevated shadow-sm rounded-2xl p-5"> + <h3 className="text-sm font-semibold text-fg-primary mb-4">{t('systemHealth.title')}</h3> + <div className="space-y-3"> + {ops && ( + <HealthRow label={t('systemHealth.successRate')} value={`${Math.round(ops.taskKPI.successRate)}%`} + bar={Math.round(ops.taskKPI.successRate)} color="bg-green-500" /> + )} + <HealthKV label={t('systemHealth.tasksTotal')} value={`${completed}/${totalRootTasks}`} /> + {usageInfo && ( + <HealthKV label={t('systemHealth.tokenUsage')} value={formatTokenCount(usageInfo.llmTokens)} /> + )} + {workingAgents > 0 && ( + <HealthKV label={t('systemHealth.currentWorking')} value={`${workingAgents}/${agents.length}`} accent /> + )} {storageInfo && ( - <div className="mt-4 pt-3 border-t border-border-default cursor-pointer" onClick={() => navBus.navigate(PAGE.SETTINGS)}> - <div className="flex items-center justify-between"> - <span className="text-xs text-fg-secondary">{t('storage.title')}</span> - <span className="text-sm font-semibold text-fg-primary">{fmtBytes(storageInfo.totalSize, t)}</span> - </div> - <div className="mt-1.5 flex gap-3 text-[11px] text-fg-tertiary"> - <span>{t('storage.db', { size: fmtBytes(storageInfo.database.size, t) })}</span> - <span>{t('storage.agents', { count: storageInfo.agents.length })}</span> - </div> - </div> + <HealthKV label={t('systemHealth.storage')} value={fmtBytes(storageInfo.totalSize)} /> )} </div> - )} - </div> - </div> - </div> - - {/* Deploy Agent Method Choice Modal */} - {showDeployChoice && ( - <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowDeployChoice(false)}> - <div className="bg-surface-elevated rounded-xl border border-border-default shadow-2xl w-[340px] max-w-[90vw] overflow-hidden" onClick={e => e.stopPropagation()}> - <div className="px-5 py-4 border-b border-border-default"> - <h3 className="text-sm font-semibold">{t('hireAgent')}</h3> - <p className="text-[11px] text-fg-tertiary mt-0.5">{t('team:chat.methodChoiceSubtitle')}</p> - </div> - <div className="p-3 space-y-2"> - <button - onClick={() => { - setShowDeployChoice(false); - const secretary = agents.find(a => a.role === 'secretary') ?? agents.find(a => a.name?.toLowerCase().includes('secretary')); - if (secretary) { - navBus.navigate(PAGE.TEAM, { - agentId: secretary.id, - prefillMessage: t('team:chat.addAgentPrefill'), - }); - } else { - navBus.navigate(PAGE.STORE); - } - }} - className="w-full flex items-center gap-3 px-4 py-3 rounded-lg border border-brand-500/30 bg-brand-500/5 hover:bg-brand-500/10 transition-colors text-left" - > - <div className="w-8 h-8 rounded-full bg-brand-500/15 flex items-center justify-center shrink-0"> - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" className="text-brand-500" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" /><path d="M20 3v4" /><path d="M22 5h-4" /></svg> - </div> - <div className="flex-1 min-w-0"> - <div className="text-xs font-medium text-brand-500">{t('team:chat.methodSecretary')}</div> - <div className="text-[10px] text-fg-tertiary mt-0.5">{t('team:chat.methodSecretaryDesc')}</div> - </div> - <span className="text-[9px] px-1.5 py-0.5 rounded-full bg-brand-500/10 text-brand-500 font-medium shrink-0">{t('team:chat.recommended')}</span> - </button> - <button - onClick={() => { - setShowDeployChoice(false); - navBus.navigate(PAGE.STORE, { storeTab: 'agents' }); - }} - className="w-full flex items-center gap-3 px-4 py-3 rounded-lg border border-border-default hover:bg-surface-overlay transition-colors text-left" - > - <div className="w-8 h-8 rounded-full bg-surface-overlay flex items-center justify-center shrink-0"> - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" className="text-fg-secondary" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z" /><circle cx="12" cy="12" r="3" /></svg> - </div> - <div className="flex-1 min-w-0"> - <div className="text-xs font-medium text-fg-secondary">{t('team:chat.methodManual')}</div> - <div className="text-[10px] text-fg-tertiary mt-0.5">{t('team:chat.methodManualAgentDesc')}</div> - </div> - </button> - </div> - <div className="px-5 py-3 border-t border-border-default flex justify-end"> - <button onClick={() => setShowDeployChoice(false)} className="px-3 py-1.5 text-xs text-fg-secondary hover:text-fg-primary rounded-lg hover:bg-surface-overlay transition-colors">{t('common:cancel')}</button> </div> </div> </div> - )} + </div> - {/* Agent Ranking Modal */} - {showRankingModal && ops && ( - <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowRankingModal(false)}> - <div className="bg-surface-elevated rounded-xl border border-border-default shadow-2xl w-[480px] max-w-[90vw] max-h-[80vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}> - <div className="px-5 py-4 border-b border-border-default flex items-center justify-between shrink-0"> - <div> - <h3 className="text-sm font-semibold">{t('ranking.title')}</h3> - <p className="text-[11px] text-fg-tertiary mt-0.5">{t('ranking.subtitle', { count: allRankedAgents.length })}</p> - </div> - <button onClick={() => setShowRankingModal(false)} className="p-1.5 rounded-lg hover:bg-surface-overlay transition-colors text-fg-tertiary hover:text-fg-primary"> - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg> - </button> - </div> - <div className="overflow-y-auto flex-1 scrollbar-thin"> - <div className="px-4 py-2 flex items-center gap-3 text-[10px] text-fg-tertiary uppercase tracking-wider font-medium border-b border-border-default/50"> - <span className="w-7 text-center">#</span> - <span className="flex-1">{t('ranking.agent')}</span> - <span className="w-16 text-center">{t('ranking.health')}</span> - <span className="w-16 text-center">{t('ranking.tasks')}</span> - <span className="w-16 text-center">{t('ranking.errorRate')}</span> - </div> - {allRankedAgents.map((agent, idx) => { - const healthColor = agent.healthScore >= 80 ? 'text-green-500' : agent.healthScore >= 50 ? 'text-amber-500' : 'text-red-500'; - const errorPct = Math.round(agent.errorRate * 100); - const medal = idx === 0 ? 'text-amber-400' : idx === 1 ? 'text-gray-400' : idx === 2 ? 'text-amber-600' : 'text-fg-tertiary'; - return ( - <div - key={agent.agentId} - className="px-4 py-2.5 flex items-center gap-3 hover:bg-surface-overlay/50 cursor-pointer transition-colors border-b border-border-default/30 last:border-0" - onClick={() => { setShowRankingModal(false); navBus.navigate(PAGE.TEAM, { selectAgent: agent.agentId }); }} - > - <span className={`w-7 text-center text-xs font-bold ${medal}`}>{idx + 1}</span> - <div className="flex items-center gap-2.5 flex-1 min-w-0"> - <Avatar name={agent.agentName} size={28} bgClass="bg-brand-600/30 text-brand-300" /> - <div className="min-w-0"> - <div className="text-xs font-medium text-fg-primary truncate">{agent.agentName}</div> - <div className="text-[10px] text-fg-tertiary truncate">{agent.role || agent.agentRole || '—'}</div> - </div> - </div> - <span className={`w-16 text-center text-xs font-semibold ${healthColor}`}>{agent.healthScore}%</span> - <span className="w-16 text-center text-xs text-fg-secondary">{agent.taskMetrics.completed}<span className="text-fg-tertiary">/{agent.taskMetrics.completed + agent.taskMetrics.failed}</span></span> - <span className={`w-16 text-center text-xs ${errorPct > 20 ? 'text-red-500' : errorPct > 5 ? 'text-amber-500' : 'text-green-500'}`}>{errorPct}%</span> - </div> - ); - })} - {allRankedAgents.length === 0 && ( - <div className="py-8 text-center text-xs text-fg-tertiary">{t('ranking.noData')}</div> - )} - </div> - </div> - </div> - )} + {/* ── Ranking Modal ── */} + {showRankingModal && ops && <RankingModal agents={allRankedAgents} onClose={() => setShowRankingModal(false)} t={t} />} </div> ); } -// ─── Metric Card ───────────────────────────────────────────────────────────── +// ═════════════════════════════════════════════════════════════════════════════ +// Sub-components +// ═════════════════════════════════════════════════════════════════════════════ -function MetricCard({ label, value, total, suffix, icon, color, onClick }: { - label: string; value: number; total?: number; suffix?: string; icon: React.ReactNode; color: string; onClick: () => void; +function MetricCard({ label, value, sub, icon, pulse, color, onClick }: { + label: string; value: string; sub?: string; icon: React.ReactNode; pulse?: boolean; color?: 'green' | 'amber' | 'red'; onClick?: () => void; }) { - const styles: Record<string, { bg: string; iconBg: string; text: string; glow: string }> = { - brand: { bg: 'border-brand-500/20', iconBg: 'bg-brand-500/15', text: 'text-brand-400', glow: 'hover:shadow-brand-500/10' }, - blue: { bg: 'border-blue-500/20', iconBg: 'bg-blue-500/15', text: 'text-blue-400', glow: 'hover:shadow-blue-500/10' }, - amber: { bg: 'border-amber-500/20', iconBg: 'bg-amber-500/15', text: 'text-amber-400', glow: 'hover:shadow-amber-500/10' }, - green: { bg: 'border-green-500/20', iconBg: 'bg-green-500/15', text: 'text-green-400', glow: 'hover:shadow-green-500/10' }, - }; - const s = styles[color] ?? styles.brand!; - + const colorClass = color === 'green' ? 'text-green-500' : color === 'amber' ? 'text-amber-500' : color === 'red' ? 'text-red-500' : 'text-fg-primary'; return ( - <div onClick={onClick} className={`bg-surface-secondary border ${s.bg} rounded-2xl p-4 sm:p-5 cursor-pointer hover:bg-surface-elevated/60 hover:shadow-lg ${s.glow} transition-all duration-200 card-shine`}> - <div className="flex items-start justify-between mb-2 sm:mb-3"> - <div className="text-[11px] sm:text-xs text-fg-tertiary font-medium">{label}</div> - <div className={`w-8 h-8 sm:w-9 sm:h-9 rounded-xl ${s.iconBg} ${s.text} flex items-center justify-center`}>{icon}</div> + <div onClick={onClick} className="bg-surface-elevated shadow-sm rounded-2xl p-4 sm:p-5 flex items-start justify-between cursor-pointer hover:shadow-md transition-shadow"> + <div> + <div className="text-[11px] text-fg-tertiary mb-2">{label}</div> + <div className="flex items-baseline gap-0.5"> + <span className={`text-2xl sm:text-3xl font-bold ${colorClass} leading-none`}>{value}</span> + {sub && <span className="text-sm text-fg-muted font-medium">{sub}</span>} + </div> </div> - <div className="flex items-baseline gap-1"> - <span className={`text-2xl sm:text-3xl font-bold ${s.text}`}>{value}</span> - {suffix && <span className="text-base sm:text-lg font-semibold text-fg-muted">{suffix}</span>} - {total !== undefined && <span className="text-xs sm:text-sm font-normal text-fg-muted">/{total}</span>} + <div className="relative"> + <div className="w-10 h-10 rounded-xl bg-surface-overlay/60 flex items-center justify-center text-fg-tertiary">{icon}</div> + {pulse && <span className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 animate-pulse" />} </div> </div> ); } -// ─── Donut Chart ───────────────────────────────────────────────────────────── +function MetricIcon({ type }: { type: string }) { + const paths: Record<string, React.ReactNode> = { + working: <><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" /></>, + tasks: <><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></>, + projects: <><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /></>, + health: <><path d="M22 12h-4l-3 9L9 3l-3 9H2" /></>, + }; + return <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">{paths[type]}</svg>; +} + +// ── Entity Row ────────────────────────────────────────────────────────────── + +function EntityRow({ icon, label, value, onClick, children }: { + icon: 'folder' | 'edit' | 'book'; label: string; value: number; onClick: () => void; children?: React.ReactNode; +}) { + return ( + <div className="flex items-center gap-3 px-5 py-3 hover:bg-surface-overlay/30 cursor-pointer transition-colors" onClick={onClick}> + <span className="text-fg-muted"><EntityIcon type={icon} /></span> + <span className="text-xs text-fg-secondary">{label}</span> + <span className="text-sm font-semibold text-fg-primary">{value}</span> + <div className="flex items-center gap-2 ml-auto text-[11px] text-fg-tertiary">{children}</div> + </div> + ); +} + +function Chip({ children }: { children: React.ReactNode }) { + return <span className="px-2 py-0.5 rounded-full bg-surface-overlay/60 text-[11px] text-fg-secondary">{children}</span>; +} + +function EntityIcon({ type }: { type: 'folder' | 'edit' | 'book' }) { + const p: Record<string, string> = { + folder: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z', + edit: 'M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7 M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z', + book: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5v15z', + }; + return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d={p[type]} /></svg>; +} + +// ── Donut Chart ───────────────────────────────────────────────────────────── function DonutChart({ statusCounts, total, completionRate, completed }: { statusCounts: Record<string, number>; total: number; completionRate: number; completed: number; }) { - const size = 140; - const r = 38; - const strokeW = 14; + const size = 120; + const r = 42; + const strokeW = 12; const c = 2 * Math.PI * r; - - const segments = STATUS_ORDER - .filter(s => (statusCounts[s] ?? 0) > 0) - .map(s => ({ value: statusCounts[s]!, color: DONUT_COLORS[s] ?? '#6b7280' })); - + const segments = STATUS_ORDER.filter(s => (statusCounts[s] ?? 0) > 0).map(s => ({ status: s, value: statusCounts[s]!, color: DONUT_COLORS[s] ?? '#6b7280' })); let offset = 0; const arcs = segments.map(seg => { const len = (seg.value / total) * c; - const gap = Math.max(0, c - len - 1); - const arc = { len: Math.max(len - 1, 0.5), gap, offset, color: seg.color }; + const arc = { len: Math.max(len - 0.8, 0.5), offset, color: seg.color, status: seg.status }; offset += len; return arc; }); return ( <div className="relative shrink-0" style={{ width: size, height: size }}> - <svg width={size} height={size} viewBox="0 0 100 100"> + <svg width={size} height={size} viewBox="0 0 120 120"> {arcs.map((arc, i) => ( - <circle - key={i} - cx="50" cy="50" r={r} - fill="none" - stroke={arc.color} - strokeWidth={strokeW} - strokeDasharray={`${arc.len} ${c - arc.len}`} - strokeDashoffset={-arc.offset} - transform="rotate(-90 50 50)" - className="transition-all duration-500" - /> + <circle key={i} cx="60" cy="60" r={r} fill="none" stroke={arc.color} strokeWidth={strokeW} + strokeDasharray={`${arc.len} ${c - arc.len}`} strokeDashoffset={-arc.offset} + transform="rotate(-90 60 60)" className="transition-all duration-500 cursor-pointer" + onClick={() => navBus.navigate(PAGE.WORK, { statusFilter: arc.status })} /> ))} </svg> <div className="absolute inset-0 flex flex-col items-center justify-center"> - <span className="text-2xl sm:text-3xl font-bold text-fg-primary">{completionRate}%</span> - <span className="text-[10px] text-fg-tertiary">{completed}/{total}</span> + <span className="text-2xl font-bold text-fg-primary leading-none">{completionRate}%</span> + <span className="text-[10px] text-fg-tertiary mt-0.5">{completed}/{total}</span> </div> </div> ); } -function DonutLegendItem({ color, label, count }: { color: string; label: string; count: number }) { +// ── Health Row / KV ───────────────────────────────────────────────────────── + +function HealthRow({ label, value, bar, color }: { label: string; value: string; bar: number; color: string }) { return ( - <div className="flex items-center justify-between gap-2"> - <div className="flex items-center gap-2 min-w-0"> - <span className={`w-2.5 h-2.5 rounded-full shrink-0 ${color}`} /> - <span className="text-xs text-fg-secondary truncate">{label}</span> + <div> + <div className="flex items-center justify-between mb-1.5"> + <span className="text-xs text-fg-secondary">{label}</span> + <span className="text-xs font-semibold text-fg-primary">{value}</span> + </div> + <div className="h-1 rounded-full bg-surface-overlay/60 overflow-hidden"> + <div className={`h-full rounded-full ${color} transition-all duration-500`} style={{ width: `${bar}%` }} /> </div> - <span className="text-xs font-semibold text-fg-primary shrink-0">{count}</span> </div> ); } -// ─── Activity Feed ─────────────────────────────────────────────────────────── +function HealthKV({ label, value, accent }: { label: string; value: string; accent?: boolean }) { + return ( + <div className="flex items-center justify-between"> + <span className="text-xs text-fg-secondary">{label}</span> + <span className={`text-xs font-semibold ${accent ? 'text-green-500' : 'text-fg-primary'}`}>{value}</span> + </div> + ); +} -const ACTIVITY_ICON_BG: Record<string, string> = { - completed: 'bg-green-500/15', in_progress: 'bg-brand-500/15', pending: 'bg-amber-500/15', - review: 'bg-blue-500/15', failed: 'bg-red-500/15', blocked: 'bg-amber-500/15', - rejected: 'bg-red-500/15', cancelled: 'bg-gray-500/15', -}; +// ── Activity Icon ─────────────────────────────────────────────────────────── function ActivityIcon({ status }: { status: string }) { - const sz = 12; - const colorMap: Record<string, string> = { - completed: '#22c55e', in_progress: '#8b5cf6', pending: '#f59e0b', - review: '#3b82f6', failed: '#ef4444', blocked: '#f59e0b', rejected: '#ef4444', cancelled: '#6b7280', - }; - const color = colorMap[status] ?? '#6b7280'; + const color: Record<string, string> = { completed: '#22c55e', in_progress: '#8b5cf6', pending: '#f59e0b', review: '#3b82f6', failed: '#ef4444', blocked: '#f59e0b', rejected: '#ef4444', cancelled: '#6b7280' }; + const c = color[status] ?? '#6b7280'; return ( - <svg width={sz} height={sz} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke={c} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> {status === 'completed' && <polyline points="20 6 9 17 4 12" />} {status === 'in_progress' && <><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></>} {status === 'pending' && <><circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" /></>} @@ -765,97 +517,111 @@ function ActivityIcon({ status }: { status: string }) { ); } -function formatRelativeTime(dateStr: string, t: TFunction): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return t('common:time.now'); - if (mins < 60) return t('common:time.minutesAgo', { count: mins }); - const hours = Math.floor(mins / 60); - if (hours < 24) return t('common:time.hoursAgo', { count: hours }); - const days = Math.floor(hours / 24); - return t('common:time.daysAgo', { count: days }); -} - -// ─── Health Row ────────────────────────────────────────────────────────────── - -function HealthRow({ label, value, max, suffix, displayValue, alwaysGreen, neutral }: { - label: string; value: number; max: number; suffix?: string; displayValue?: string; alwaysGreen?: boolean; neutral?: boolean; -}) { - const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0; - const barColor = neutral ? 'bg-blue-500' : alwaysGreen ? 'bg-brand-500' : pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-amber-500' : 'bg-red-500'; - const textColor = neutral ? 'text-blue-400' : alwaysGreen ? 'text-brand-400' : pct >= 80 ? 'text-green-500' : pct >= 50 ? 'text-amber-500' : 'text-red-500'; +// ── Ranking Modal ─────────────────────────────────────────────────────────── +function RankingModal({ agents, onClose, t }: { agents: Array<{ agentId: string; agentName: string; role: string; agentRole: string; healthScore: number; taskMetrics: { completed: number; failed: number }; errorRate: number }>; onClose: () => void; t: TFunction }) { return ( - <div> - <div className="flex items-center justify-between mb-1.5"> - <span className="text-xs text-fg-secondary">{label}</span> - <span className={`text-sm font-semibold ${textColor}`}>{displayValue ?? `${value}${suffix ?? ''}`}</span> - </div> - <div className="w-full h-1.5 bg-surface-elevated rounded-full overflow-hidden"> - <div className={`h-full rounded-full ${barColor} transition-all duration-500`} style={{ width: `${pct}%` }} /> + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}> + <div className="bg-surface-elevated rounded-xl border border-border-default shadow-2xl w-[480px] max-w-[90vw] max-h-[80vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}> + <div className="px-5 py-4 border-b border-border-default flex items-center justify-between shrink-0"> + <h3 className="text-sm font-semibold">{t('ranking.title')}</h3> + <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-surface-overlay transition-colors text-fg-tertiary hover:text-fg-primary"> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg> + </button> + </div> + <div className="overflow-y-auto flex-1 scrollbar-thin"> + <div className="px-4 py-2 flex items-center gap-3 text-[10px] text-fg-tertiary uppercase tracking-wider font-medium border-b border-border-default/50"> + <span className="w-7 text-center">#</span><span className="flex-1">{t('ranking.agent')}</span> + <span className="w-14 text-center">{t('ranking.health')}</span><span className="w-14 text-center">{t('ranking.tasks')}</span><span className="w-14 text-center">{t('ranking.errorRate')}</span> + </div> + {agents.map((agent, idx) => { + const hc = agent.healthScore >= 80 ? 'text-green-500' : agent.healthScore >= 50 ? 'text-amber-500' : 'text-red-500'; + const ep = Math.round(agent.errorRate * 100); + const medal = idx < 3 ? ['text-amber-400', 'text-gray-400', 'text-amber-600'][idx] : 'text-fg-tertiary'; + return ( + <div key={agent.agentId} className="px-4 py-2.5 flex items-center gap-3 hover:bg-surface-overlay/50 cursor-pointer transition-colors border-b border-border-default/30 last:border-0" + onClick={() => { onClose(); navBus.navigate(PAGE.TEAM, { selectAgent: agent.agentId }); }}> + <span className={`w-7 text-center text-xs font-bold ${medal}`}>{idx + 1}</span> + <div className="flex items-center gap-2 flex-1 min-w-0"> + <Avatar name={agent.agentName} size={26} bgClass="bg-brand-600/20 text-brand-300" /> + <div className="min-w-0"><div className="text-xs font-medium text-fg-primary truncate">{agent.agentName}</div><div className="text-[10px] text-fg-tertiary truncate">{agent.role || agent.agentRole || '—'}</div></div> + </div> + <span className={`w-14 text-center text-xs font-semibold ${hc}`}>{agent.healthScore}%</span> + <span className="w-14 text-center text-xs text-fg-secondary">{agent.taskMetrics.completed}<span className="text-fg-tertiary">/{agent.taskMetrics.completed + agent.taskMetrics.failed}</span></span> + <span className={`w-14 text-center text-xs ${ep > 20 ? 'text-red-500' : ep > 5 ? 'text-amber-500' : 'text-green-500'}`}>{ep}%</span> + </div> + ); + })} + {agents.length === 0 && <div className="py-8 text-center text-xs text-fg-tertiary">{t('ranking.noData')}</div>} + </div> </div> </div> ); } -// ─── Icons ─────────────────────────────────────────────────────────────────── - -function IconAgents() { - return ( - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> - <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /> - </svg> - ); -} +// ── Create Menu ───────────────────────────────────────────────────────────── + +const CreateMenu = forwardRef<HTMLDivElement, { show: boolean; onToggle: () => void; onClose: () => void; t: TFunction; isMobile?: boolean }>( + ({ show, onToggle, onClose, t, isMobile }, ref) => ( + <div ref={ref} className="relative"> + <button onClick={onToggle} className="p-2 rounded-lg hover:bg-surface-overlay transition-colors text-fg-tertiary" aria-label="Create"> + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg> + </button> + {show && ( + <div className="absolute right-0 top-full mt-2 bg-surface-elevated border border-border-default rounded-xl shadow-xl z-50 overflow-hidden w-48 animate-fadeIn"> + <div className="py-1"> + <MenuBtn onClick={() => { onClose(); navBus.navigate(PAGE.BUILDER, isMobile ? { storeTab: 'builder' } : undefined); }} + icon="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2 M9 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M19 8v6 M22 11h-6">{t('home:createMenu.agent')}</MenuBtn> + <MenuBtn onClick={() => { onClose(); navBus.navigate(PAGE.BUILDER); }} + icon="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75">{t('home:createMenu.team')}</MenuBtn> + <MenuBtn onClick={() => { onClose(); navBus.navigate(PAGE.BUILDER); }} + icon="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z">{t('home:createMenu.skill')}</MenuBtn> + <div className="border-t border-border-default my-1" /> + <MenuBtn onClick={() => { onClose(); navBus.navigate(PAGE.STORE); }} + icon="M6 2L3 7v13a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7l-3-5z M3 7h18 M16 11a4 4 0 0 1-8 0">{t('home:createMenu.discover')}</MenuBtn> + </div> + </div> + )} + </div> + ) +); -function IconRunning() { +function MenuBtn({ onClick, icon, children }: { onClick: () => void; icon: string; children: React.ReactNode }) { return ( - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> - <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /> - </svg> + <button onClick={onClick} className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-fg-secondary hover:bg-surface-overlay transition-colors"> + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d={icon} /></svg> + {children} + </button> ); } -function IconHealth() { - return ( - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> - <path d="M22 12h-4l-3 9L9 3l-3 9H2" /> - </svg> - ); -} +// ── Utilities ─────────────────────────────────────────────────────────────── -function IconTasks() { - return ( - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> - <path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" /> - </svg> - ); +function formatRelativeTime(dateStr: string, t: TFunction): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return t('common:time.now'); + if (mins < 60) return t('common:time.minutesAgo', { count: mins }); + const hours = Math.floor(mins / 60); + if (hours < 24) return t('common:time.hoursAgo', { count: hours }); + return t('common:time.daysAgo', { count: Math.floor(hours / 24) }); } -// ─── Utils ─────────────────────────────────────────────────────────────────── - -function fmtBytes(bytes: number, t: TFunction): string { - if (bytes === 0) return t('storage.bytesZero'); - const unitKeys = ['storage.bytesB', 'storage.bytesKB', 'storage.bytesMB', 'storage.bytesGB'] as const; - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), unitKeys.length - 1); - const val = bytes / Math.pow(1024, i); - return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${t(unitKeys[i])}`; +function localizeActivityLabel(label: string | undefined, t: TFunction): string | null { + if (!label) return null; + return ACTIVITY_LABEL_KEYS[label] ? t(ACTIVITY_LABEL_KEYS[label]) : label; } -function fmtNumber(n: number): string { +function formatTokenCount(n: number): string { + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(n); } -const ACTIVITY_LABEL_KEYS: Record<string, string> = { - 'Heartbeat check-in': 'agentFocus.heartbeatCheckIn', - 'Heartbeat check-in (idle skip)': 'agentFocus.heartbeatSkip', -}; - -function localizeActivityLabel(label: string | undefined, t: TFunction): string | null { - if (!label) return null; - const key = ACTIVITY_LABEL_KEYS[label]; - if (key) return t(key); - return label; +function fmtBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`; } diff --git a/packages/web-ui/src/pages/Reports.tsx b/packages/web-ui/src/pages/Reports.tsx index 5b432f83..a75c903e 100644 --- a/packages/web-ui/src/pages/Reports.tsx +++ b/packages/web-ui/src/pages/Reports.tsx @@ -5,6 +5,7 @@ import { navBus } from '../navBus.ts'; import { PAGE } from '../routes.ts'; import { MobileMenuButton } from '../components/MobileMenuButton.tsx'; import { useIsMobile } from '../hooks/useIsMobile.ts'; +import { usePageActive } from '../hooks/usePageActive.ts'; type Period = 'daily' | 'weekly' | 'monthly'; interface ReportsPageProps { authUser?: AuthUser } @@ -39,6 +40,7 @@ function formatBytes(b: number): string { export function ReportsPage({ authUser }: ReportsPageProps) { const { t } = useTranslation(['reports', 'common']); const isMobile = useIsMobile(); + const isActive = usePageActive(PAGE.REPORTS); const [period, setPeriod] = useState<Period>('weekly'); const [report, setReport] = useState<ReportInfo | null>(null); const [loading, setLoading] = useState(true); @@ -87,10 +89,11 @@ export function ReportsPage({ authUser }: ReportsPageProps) { useEffect(() => { fetchReport(period); }, [period, fetchReport]); useEffect(() => { + if (!isActive) return; fetchUsage(); const i = setInterval(fetchUsage, 30000); return () => clearInterval(i); - }, [fetchUsage]); + }, [fetchUsage, isActive]); useEffect(() => { if (tab === 'history') fetchHistory(); }, [tab, fetchHistory]); const sortedAgents = useMemo(() => { diff --git a/packages/web-ui/src/pages/Settings.tsx b/packages/web-ui/src/pages/Settings.tsx index 53e1f51b..55a7b020 100644 --- a/packages/web-ui/src/pages/Settings.tsx +++ b/packages/web-ui/src/pages/Settings.tsx @@ -7,6 +7,7 @@ import { navBus } from '../navBus.ts'; import { PAGE } from '../routes.ts'; import { Avatar, AvatarUpload } from '../components/Avatar.tsx'; import { useIsMobile } from '../hooks/useIsMobile.ts'; +import { BrowserTestPanel } from '../components/BrowserTestPanel.tsx'; interface ModelCost { input: number; output: number; cacheRead?: number; cacheWrite?: number } interface ModelDef { id: string; name: string; provider: string; contextWindow: number; maxOutputTokens: number; cost: ModelCost; reasoning?: boolean; inputTypes?: string[] } @@ -2072,6 +2073,9 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat {browserMsg && <div className="mt-4"><Msg type={browserMsg.type} text={browserMsg.text} /></div>} </Section> + + {/* DEV-only: Browser Integration Test Suite */} + {import.meta.env.DEV && <BrowserTestPanel extensionConnected={browserExtensionConnected} />} </>} {resolvedTab === 'search' && <> diff --git a/packages/web-ui/src/pages/Team.tsx b/packages/web-ui/src/pages/Team.tsx index 04b674d2..33ba2010 100644 --- a/packages/web-ui/src/pages/Team.tsx +++ b/packages/web-ui/src/pages/Team.tsx @@ -30,6 +30,7 @@ import { useResizablePanel } from '../hooks/useResizablePanel.ts'; import { useIsMobile } from '../hooks/useIsMobile.ts'; import { useSwipeTabs } from '../hooks/useSwipeTabs.ts'; import { useUnreadCounts } from '../hooks/useUnreadCounts.ts'; +import { usePageActive } from '../hooks/usePageActive.ts'; import { Avatar } from '../components/Avatar.tsx'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -794,6 +795,7 @@ const _introSentGlobal = new Set<string>(); export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string; authUser?: AuthUser } = {}) { const { t, i18n } = useTranslation(['team', 'common']); + const isActive = usePageActive(PAGE.TEAM); const [agents, setAgents] = useState<AgentInfo[]>([]); const [humans, setHumans] = useState<HumanUserInfo[]>([]); const [initialLoading, setInitialLoading] = useState(true); @@ -1250,7 +1252,12 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string api.tasks.list().then(d => setTasks(d.tasks)).catch(() => {}); api.externalAgents.list().then(d => setExternalAgents(d.agents)).catch(() => {}); refreshGroupChats(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refreshHumans, refreshUnreadCounts]); + useEffect(() => { + if (!isActive) return; + refreshAgents(); const timer = setInterval(refreshAgents, 30_000); const teamTimer = setInterval(refreshTeams, 60_000); const unsub = wsClient.on('agent:update', throttledRefreshAgents); @@ -1265,7 +1272,7 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string window.addEventListener('markus:notifications-changed', onNotifChanged); return () => { clearInterval(timer); clearInterval(teamTimer); unsub(); unsubTeamUpdate(); unsubTeamOnAgentRemoved(); unsubGroup(); unsubGroupUpdate(); unsubGroupDelete(); window.removeEventListener('markus:data-changed', onDataChanged); window.removeEventListener('markus:notifications-changed', onNotifChanged); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [refreshHumans, refreshUnreadCounts]); + }, [isActive, refreshHumans, refreshUnreadCounts]); // Check for nav params (e.g., navigated here from AgentProfile or Team redirect) useEffect(() => { diff --git a/packages/web-ui/src/pages/Work.tsx b/packages/web-ui/src/pages/Work.tsx index ce416471..b83446cb 100644 --- a/packages/web-ui/src/pages/Work.tsx +++ b/packages/web-ui/src/pages/Work.tsx @@ -14,6 +14,7 @@ import { CommentInput, type PendingImage } from '../components/CommentInput.tsx' import { navBus } from '../navBus.ts'; import { PAGE, resolvePageId, hashPath } from '../routes.ts'; import { useIsMobile } from '../hooks/useIsMobile.ts'; +import { usePageActive } from '../hooks/usePageActive.ts'; import { useResizablePanel } from '../hooks/useResizablePanel.ts'; import { useSwipeTabs } from '../hooks/useSwipeTabs.ts'; import { MobileMenuButton } from '../components/MobileMenuButton.tsx'; @@ -454,6 +455,7 @@ function NoteComment({ note, compact }: { note: string; compact?: boolean }) { // ─── Constants ────────────────────────────────────────────────────────────────── const ALL_STATUSES = ['pending', 'in_progress', 'blocked', 'review', 'completed', 'failed', 'rejected', 'cancelled', 'archived'] as const; +const CLOSED_STATUSES_SET = new Set(['rejected', 'cancelled', 'archived']); const BOARD_COLUMNS_BASE = [ { id: 'failed', statuses: ['failed'], accent: 'border-t-red-500', dropStatus: 'failed' }, @@ -3243,6 +3245,7 @@ function BacklogTable({ tasks, requirements, agents, projects, onTaskClick, onRe export function WorkPage({ authUser }: { authUser?: AuthUser }) { const { t } = useTranslation(['work', 'common']); + const isActive = usePageActive(PAGE.WORK); const boardColumns = useMemo(() => BOARD_COLUMNS_BASE.map(c => ({ ...c, label: t(`work:boardColumn.${c.id}`) })), [t]); const subStatusBadges = useMemo(() => buildSubStatusBadges(t), [t]); const reqStatusBadges = useMemo(() => buildReqStatusBadges(t), [t]); @@ -3316,6 +3319,7 @@ export function WorkPage({ authUser }: { authUser?: AuthUser }) { const kanbanSwipeOpts = useMemo(() => ({ scrollContainerRef: kanbanScrollRef }), []); const kanbanSwipe = useSwipeTabs(boardTabs, boardType, setBoardType, kanbanSwipeOpts); const [showClosed, setShowClosed] = useState(false); + const [statusFilter, setStatusFilter] = useState<string | null>(null); const [showFilterSheet, setShowFilterSheet] = useState(false); const dragTaskRef = useRef<TaskInfo | null>(null); const dragReqRef = useRef<RequirementInfo | null>(null); @@ -3450,10 +3454,21 @@ export function WorkPage({ authUser }: { authUser?: AuthUser }) { }, []); useEffect(() => { - const pollMs = selectedTaskRef.current ? 60000 : 15000; + if (!isActive) return; + const pollMs = selectedTaskRef.current ? 120000 : 45000; const i = setInterval(() => { refreshBoard(); refreshAgents(); refreshRequirements(); }, pollMs); + let boardDebounce: ReturnType<typeof setTimeout> | null = null; + const debouncedRefreshBoard = () => { + if (boardDebounce) return; + boardDebounce = setTimeout(() => { boardDebounce = null; refreshBoard(); }, 800); + }; + let reqDebounce: ReturnType<typeof setTimeout> | null = null; + const debouncedRefreshReqs = () => { + if (reqDebounce) return; + reqDebounce = setTimeout(() => { reqDebounce = null; refreshRequirements(); }, 800); + }; const unsub = wsClient.on('task:update', (event) => { - refreshBoard(); + debouncedRefreshBoard(); const p = event?.payload as Record<string, unknown> | undefined; if (p?.taskId) { setSelectedTask(prev => { @@ -3468,7 +3483,7 @@ export function WorkPage({ authUser }: { authUser?: AuthUser }) { } }); const unsubTaskCreate = wsClient.on('task:create', () => { - refreshBoard(); + debouncedRefreshBoard(); }); const reqEvents = [ 'requirement:created', 'requirement:approved', 'requirement:rejected', @@ -3476,10 +3491,10 @@ export function WorkPage({ authUser }: { authUser?: AuthUser }) { 'requirement:resubmitted', ]; const reqUnsubs = reqEvents.map(evt => - wsClient.on(evt, () => { refreshRequirements(); }) + wsClient.on(evt, () => { debouncedRefreshReqs(); }) ); - return () => { clearInterval(i); unsub(); unsubTaskCreate(); reqUnsubs.forEach(u => u()); }; - }, [refreshBoard, refreshAgents, refreshRequirements]); + return () => { clearInterval(i); unsub(); unsubTaskCreate(); reqUnsubs.forEach(u => u()); if (boardDebounce) clearTimeout(boardDebounce); if (reqDebounce) clearTimeout(reqDebounce); }; + }, [isActive, refreshBoard, refreshAgents, refreshRequirements]); // Refs for event handlers that need current state without re-registering const boardRef = useRef(board); @@ -3601,6 +3616,12 @@ export function WorkPage({ authUser }: { authUser?: AuthUser }) { } } if (detail.params?.projectId) selectProject(detail.params.projectId); + if (detail.params?.statusFilter) { + const sf = detail.params.statusFilter; + localStorage.removeItem('markus_nav_statusFilter'); + if (CLOSED_STATUSES_SET.has(sf)) setShowClosed(true); + setStatusFilter(sf); + } } }; window.addEventListener('markus:navigate', handler); @@ -3819,6 +3840,7 @@ export function WorkPage({ authUser }: { authUser?: AuthUser }) { const filterTasks = (tasks: TaskInfo[], includeArchived = false) => { let result = tasks.filter(t => showClosed || !isClosed(t)); + if (statusFilter) result = result.filter(t => t.status === statusFilter); if (viewMode === 'project' && selectedProjectId) { result = result.filter(t => t.projectId === selectedProjectId); } @@ -3968,6 +3990,13 @@ export function WorkPage({ authUser }: { authUser?: AuthUser }) { </button> )} <div className="flex-1" /> + {statusFilter && ( + <button onClick={() => setStatusFilter(null)} + className="px-2 py-0.5 text-[11px] rounded-md font-medium bg-brand-600/20 text-brand-500 ring-1 ring-brand-500/30 flex items-center gap-1"> + {t(`common:status.${statusFilter === 'in_progress' ? 'inProgress' : statusFilter}`)} + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg> + </button> + )} {(projectFilter.size > 0 || agentFilter.size > 0 || myTasksOnly || projects.length > 1 || agents.length > 0) && ( <button onClick={() => setShowFilterSheet(true)} className={`px-2 py-1 text-[11px] rounded-md font-medium transition-colors flex items-center gap-1 ${