From 27ce96b3a4630ce1209ac14089280de3b6d3689d Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 3 Aug 2026 18:17:46 +0800 Subject: [PATCH 1/4] release v0.9.0: Markus 0.9.0 stable --- package.json | 2 +- packages/a2a/package.json | 2 +- packages/chrome-extension/package.json | 2 +- packages/cli/package.json | 2 +- packages/comms/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/gui/package.json | 2 +- packages/org-manager/package.json | 2 +- packages/remote/package.json | 2 +- packages/shared/package.json | 2 +- packages/storage/package.json | 2 +- packages/web-ui/package.json | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index a7190d77..f1a57d76 100644 --- a/package.json +++ b/package.json @@ -52,5 +52,5 @@ "node-datachannel" ] }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } diff --git a/packages/a2a/package.json b/packages/a2a/package.json index 1371fdf3..065481dd 100644 --- a/packages/a2a/package.json +++ b/packages/a2a/package.json @@ -12,5 +12,5 @@ "dependencies": { "@markus/shared": "workspace:*" }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } diff --git a/packages/chrome-extension/package.json b/packages/chrome-extension/package.json index a216c5ed..966947a5 100644 --- a/packages/chrome-extension/package.json +++ b/packages/chrome-extension/package.json @@ -12,5 +12,5 @@ "esbuild": "^0.25.0", "typescript": "^5.6.0" }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } diff --git a/packages/cli/package.json b/packages/cli/package.json index 89a74e38..a1a21a58 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,5 +41,5 @@ "commander": "^14.0.3", "esbuild": "^0.27.4" }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } diff --git a/packages/comms/package.json b/packages/comms/package.json index 835165c5..04e663a2 100644 --- a/packages/comms/package.json +++ b/packages/comms/package.json @@ -12,5 +12,5 @@ "dependencies": { "@markus/shared": "workspace:*" }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } diff --git a/packages/core/package.json b/packages/core/package.json index e5996f2f..00aec8ec 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,5 +25,5 @@ "@types/turndown": "^5.0.6", "@types/ws": "^8.18.1" }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 27f9a1c8..4bc42428 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -35,5 +35,5 @@ "esbuild": "^0.27.4", "typescript": "^5.9.3" }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } diff --git a/packages/gui/package.json b/packages/gui/package.json index 9d61a6b3..5900a2ae 100644 --- a/packages/gui/package.json +++ b/packages/gui/package.json @@ -17,5 +17,5 @@ "devDependencies": { "@types/sharp": "^0.32.0" }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } diff --git a/packages/org-manager/package.json b/packages/org-manager/package.json index a64f8992..5b21e895 100644 --- a/packages/org-manager/package.json +++ b/packages/org-manager/package.json @@ -21,5 +21,5 @@ "devDependencies": { "@types/ws": "^8.18.1" }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } diff --git a/packages/remote/package.json b/packages/remote/package.json index 9376ed02..e36f2d68 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -17,5 +17,5 @@ "devDependencies": { "@types/ws": "^8.18.1" }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } diff --git a/packages/shared/package.json b/packages/shared/package.json index e76c7916..c1e68602 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -9,5 +9,5 @@ "dev": "tsc -b --watch", "clean": "rm -rf dist *.tsbuildinfo" }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } diff --git a/packages/storage/package.json b/packages/storage/package.json index 4f62c97e..5ffb0eb8 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -12,5 +12,5 @@ "dependencies": { "@markus/shared": "workspace:*" }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index fc4c3a37..56085c43 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -45,5 +45,5 @@ "typescript": "^5.9.3", "vite": "^7.3.1" }, - "version": "0.9.0-rc.17" + "version": "0.9.0" } From b7500896285a1183438a0d6787f248efed5f4924 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 3 Aug 2026 21:22:59 +0800 Subject: [PATCH 2/4] bugfix, work page pending task canceling fixup --- packages/org-manager/src/task-service.ts | 4 +++- packages/org-manager/test/task-service.test.ts | 7 +++++++ packages/web-ui/src/pages/Work.tsx | 5 ++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/org-manager/src/task-service.ts b/packages/org-manager/src/task-service.ts index 0797e1b3..66a51bd9 100644 --- a/packages/org-manager/src/task-service.ts +++ b/packages/org-manager/src/task-service.ts @@ -2457,7 +2457,9 @@ export class TaskService { } cancelTask(id: string, cascade: boolean, updatedBy?: string, updatedByType?: 'human' | 'agent' | 'system'): Task { - const task = this.updateTaskStatus(id, 'cancelled', updatedBy, false, false, updatedByType); + // _internal=true: cancel is an explicit endpoint (like reject), so pending→cancelled + // must bypass the "use approve/reject" guard that blocks generic status updates. + const task = this.updateTaskStatus(id, 'cancelled', updatedBy, true, false, updatedByType); if (task.taskType === 'scheduled' && task.scheduleConfig && !task.scheduleConfig.paused) { task.scheduleConfig.paused = true; this.updateScheduleConfig(id, task.scheduleConfig) diff --git a/packages/org-manager/test/task-service.test.ts b/packages/org-manager/test/task-service.test.ts index a4ff0ab3..9d43f3f5 100644 --- a/packages/org-manager/test/task-service.test.ts +++ b/packages/org-manager/test/task-service.test.ts @@ -1281,6 +1281,13 @@ describe('TaskService', () => { expect(webhook).toHaveBeenCalledWith(expect.objectContaining({ type: 'status_changed', status: 'cancelled' })); }); + it('cancelTask can cancel a pending task without approve/reject', () => { + const task = ts.createTask(createDefaults({ creatorRole: 'human' }) as never); + expect(task.status).toBe('pending'); + const cancelled = ts.cancelTask(task.id, false, 'user-1', 'human'); + expect(cancelled.status).toBe('cancelled'); + }); + it('assignTask to new agent updates assignment', () => { const task = ts.createTask(createDefaults({ creatorRole: 'human' }) as never); const assigned = ts.assignTask(task.id, AGENT_B, 'user-1'); diff --git a/packages/web-ui/src/pages/Work.tsx b/packages/web-ui/src/pages/Work.tsx index b93208fa..68153f8f 100644 --- a/packages/web-ui/src/pages/Work.tsx +++ b/packages/web-ui/src/pages/Work.tsx @@ -2519,7 +2519,10 @@ function TaskDetailPanel({ {task.createdBy && !task.createdBy.startsWith('agt_') ? t('work:task.startExecution') : t('work:task.approve')} {task.createdBy && !task.createdBy.startsWith('agt_') ? ( - + ) : ( )} From df2b7d204a56dcda8ed76db5524acc387aca6fc4 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 3 Aug 2026 23:06:50 +0800 Subject: [PATCH 3/4] update ui --- packages/cli/src/commands/start.ts | 24 +++ packages/core/src/agent-manager.ts | 15 ++ packages/core/src/tools/browser-session.ts | 101 ++++++++++-- packages/core/test/browser-session.test.ts | 26 +++ packages/org-manager/src/api-server.ts | 7 + packages/web-ui/src/api.ts | 4 + .../web-ui/src/components/EmbeddedBrowser.tsx | 10 ++ packages/web-ui/src/components/RightPanel.tsx | 151 ++++++++++++++++-- .../web-ui/src/contexts/LayoutContext.tsx | 19 ++- packages/web-ui/src/locales/en/common.json | 3 + packages/web-ui/src/locales/zh-CN/common.json | 3 + packages/web-ui/src/pages/InstalledStore.tsx | 2 +- packages/web-ui/src/pages/Notifications.tsx | 2 +- packages/web-ui/src/pages/SkillStore.tsx | 2 +- packages/web-ui/src/pages/Team.tsx | 17 +- packages/web-ui/src/pages/TeamsStore.tsx | 2 +- .../web-ui/src/pages/TemplateMarketplace.tsx | 2 +- packages/web-ui/src/pages/Work.tsx | 4 +- 18 files changed, 350 insertions(+), 44 deletions(-) diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 0cddb801..366e5b30 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1045,6 +1045,30 @@ async function startServerCore( log.warn('Failed to persist heartbeat interval change', { agentId, error: String(e) }); } }); + // Embedded-browser tab claim/release — UI shows which agent controls a tab. + agentManager.getEventBus().on('browser:tab-ownership', (evt: unknown) => { + const event = evt as { action: 'claimed' | 'released'; pageId: number; agentId: string }; + let agentName = event.agentId; + try { + const agent = agentManager.getAgent(event.agentId); + agentName = agent?.config?.name || event.agentId; + } catch { /* agent may already be gone on release */ } + try { + ws.broadcast({ + type: 'ui:browser_ownership', + payload: { + action: event.action, + pageId: event.pageId, + agentId: event.action === 'released' ? null : event.agentId, + agentName: event.action === 'released' ? null : agentName, + }, + timestamp: new Date().toISOString(), + }); + } catch (e) { + log.warn('Failed to broadcast browser ownership', { pageId: event.pageId, error: String(e) }); + } + }); + // Team Chat right-panel open/collapse — deliver to the interacting user's UI. agentManager.getEventBus().on('agent:ui-layout', (evt: unknown) => { const event = evt as { diff --git a/packages/core/src/agent-manager.ts b/packages/core/src/agent-manager.ts index a73f1c1c..bc5ebc67 100644 --- a/packages/core/src/agent-manager.ts +++ b/packages/core/src/agent-manager.ts @@ -501,6 +501,9 @@ export class AgentManager { this.triggerChromeDialogAutoClick(serverName); }); this.browserSessionManager = new BrowserSessionManager(); + this.browserSessionManager.onOwnershipChange((event) => { + this.eventBus.emit('browser:tab-ownership', event); + }); this.browserBridge = new MarkusBrowserBridge(); this.globalSecurityPolicy = options.securityPolicy; this.globalMcpServers = options.mcpServers; @@ -685,6 +688,18 @@ export class AgentManager { return this.browserBridge; } + /** Current agent→page ownership for embedded-browser UI badges. */ + getBrowserTabOwnership(): Array<{ pageId: number; agentId: string; agentName: string }> { + return this.browserSessionManager.listOwnership().map(({ pageId, agentId }) => { + let agentName = agentId; + try { + const agent = this.agents.get(agentId); + if (agent) agentName = agent.config.name || agentId; + } catch { /* agent may be mid-removal */ } + return { pageId, agentId, agentName }; + }); + } + async runQuickBrowserTest(): Promise { return runQuickBrowserTest(this.browserBridge, this.browserSessionManager); } diff --git a/packages/core/src/tools/browser-session.ts b/packages/core/src/tools/browser-session.ts index 76af84bc..61fba50f 100644 --- a/packages/core/src/tools/browser-session.ts +++ b/packages/core/src/tools/browser-session.ts @@ -12,6 +12,14 @@ const SESSION_KEY = '_browserSessionId'; /** Error substring that chrome-devtools-mcp returns when the selected page is gone. */ const STALE_PAGE_ERROR = 'The selected page has been closed'; +/** Fired when an agent claims or releases a browser tab (for UI badges). */ +export type BrowserTabOwnershipEvent = { + action: 'claimed' | 'released'; + pageId: number; + agentId: string; + ownerKey: string; +}; + /** * Tracks browser tab ownership per session and wraps chrome-devtools MCP * tool handlers to enforce strict tab isolation. @@ -78,11 +86,75 @@ export class BrowserSessionManager { private _bringToFront = false; private _autoCloseTabs = true; + private ownershipListeners = new Set<(e: BrowserTabOwnershipEvent) => void>(); + get bringToFront(): boolean { return this._bringToFront; } set bringToFront(v: boolean) { this._bringToFront = v; } get autoCloseTabs(): boolean { return this._autoCloseTabs; } set autoCloseTabs(v: boolean) { this._autoCloseTabs = v; } + /** Subscribe to tab claim/release events (UI badge wiring). */ + onOwnershipChange(listener: (e: BrowserTabOwnershipEvent) => void): () => void { + this.ownershipListeners.add(listener); + return () => { this.ownershipListeners.delete(listener); }; + } + + /** Snapshot of currently owned tabs for UI hydration. */ + listOwnership(): Array<{ pageId: number; agentId: string; ownerKey: string }> { + const out: Array<{ pageId: number; agentId: string; ownerKey: string }> = []; + for (const [ownerKey, owned] of this.ownedPages) { + const agentId = this.agentIdFromOwnerKey(ownerKey); + for (const pageId of owned) { + out.push({ pageId, agentId, ownerKey }); + } + } + return out; + } + + private agentIdFromOwnerKey(ownerKey: string): string { + const idx = ownerKey.indexOf('::'); + return idx === -1 ? ownerKey : ownerKey.slice(0, idx); + } + + private emitOwnership(event: BrowserTabOwnershipEvent): void { + for (const listener of this.ownershipListeners) { + try { listener(event); } catch (err) { + log.warn('Ownership listener error', { error: String(err) }); + } + } + } + + /** Claim a page for an owner key; emits `claimed` when newly assigned. */ + private assignPage(ownerKey: string, pageId: number): void { + const owned = this.getOwned(ownerKey); + if (owned.has(pageId)) return; + const other = this.findOwnerOfPage(pageId); + if (other && other !== ownerKey) { + this.releasePage(other, pageId); + } + owned.add(pageId); + this.emitOwnership({ + action: 'claimed', + pageId, + agentId: this.agentIdFromOwnerKey(ownerKey), + ownerKey, + }); + } + + /** Release a page from an owner key; emits `released` when removed. */ + private releasePage(ownerKey: string, pageId: number): boolean { + const owned = this.ownedPages.get(ownerKey); + if (!owned?.has(pageId)) return false; + owned.delete(pageId); + this.emitOwnership({ + action: 'released', + pageId, + agentId: this.agentIdFromOwnerKey(ownerKey), + ownerKey, + }); + return true; + } + /** * Register a reconnect callback for a specific MCP server of an agent. * Multiple servers can each have their own reconnector without overwriting. @@ -105,8 +177,8 @@ export class BrowserSessionManager { */ handleTabClosed(pageId: number | undefined): void { if (pageId === undefined) return; - for (const [key, owned] of this.ownedPages) { - if (owned.delete(pageId)) { + for (const key of [...this.ownedPages.keys()]) { + if (this.releasePage(key, pageId)) { log.debug(`Removed closed page ${pageId} from ownership set ${key}`); } } @@ -177,9 +249,9 @@ export class BrowserSessionManager { const prefix = `${agentId}::`; for (const [key, owned] of this.ownedPages) { if (key === agentId || key.startsWith(prefix)) { - for (const pageId of owned) { + for (const pageId of [...owned]) { if (!liveIds.has(pageId)) { - owned.delete(pageId); + this.releasePage(key, pageId); log.debug(`Pruned stale page ${pageId} from ${key}`); } } @@ -413,9 +485,8 @@ export class BrowserSessionManager { ?? 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) - const currentOwned = this.getOwned(ownerKey); - currentOwned.add(newPage.id); + // Re-fetch after potential reconnect, then claim for UI + isolation. + this.assignPage(ownerKey, newPage.id); this.currentPage.set(ownerKey, newPage.id); this.lastActiveSession.set(agentId, { ownerKey, pageId: newPage.id }); log.debug(`Page ${newPage.id} (${newPage.url}) assigned to ${ownerKey}`); @@ -481,7 +552,7 @@ export class BrowserSessionManager { if (ok) { // Remove only the failed page from ownership if (pageId !== undefined) { - this.getOwned(ownerKey).delete(pageId); + this.releasePage(ownerKey, pageId); if (this.currentPage.get(ownerKey) === pageId) { this.currentPage.delete(ownerKey); } @@ -492,7 +563,7 @@ export class BrowserSessionManager { } if (pageId !== undefined && !this.isToolError(result)) { - owned.add(pageId); + this.assignPage(ownerKey, pageId); this.currentPage.set(ownerKey, pageId); this.lastActiveSession.set(agentId, { ownerKey, pageId }); } @@ -525,7 +596,7 @@ export class BrowserSessionManager { // 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); + this.releasePage(ownerKey, pageId); if (this.currentPage.get(ownerKey) === pageId) { const remaining = this.getOwned(ownerKey); const next = remaining.size > 0 ? [...remaining][remaining.size - 1] : undefined; @@ -640,14 +711,13 @@ export class BrowserSessionManager { } if (!this.isStalePageError(result)) { - const owned = this.getOwned(ownerKey); const pages = this.parsePageEntries(result); // 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.assignPage(ownerKey, newPage.id); this.currentPage.set(ownerKey, newPage.id); this.lastActiveSession.set(agentId, { ownerKey, pageId: newPage.id }); log.info(`Auto-created page ${newPage.id} (${newPage.url}) for ${ownerKey}`); @@ -693,7 +763,7 @@ export class BrowserSessionManager { const ok = await this.reconnectMcp(agentId); if (ok) { if (currentPageId !== undefined) { - this.getOwned(ownerKey).delete(currentPageId); + this.releasePage(ownerKey, currentPageId); this.currentPage.delete(ownerKey); } return `The tab you were operating on was closed externally. ` @@ -713,9 +783,12 @@ export class BrowserSessionManager { cleanupAgent(agentId: string): void { const prefix = `${agentId}::`; let total = 0; - for (const [key, owned] of this.ownedPages) { + for (const [key, owned] of [...this.ownedPages]) { if (key === agentId || key.startsWith(prefix)) { total += owned.size; + for (const pageId of [...owned]) { + this.releasePage(key, pageId); + } this.ownedPages.delete(key); this.currentPage.delete(key); } diff --git a/packages/core/test/browser-session.test.ts b/packages/core/test/browser-session.test.ts index 84788d21..936c4028 100644 --- a/packages/core/test/browser-session.test.ts +++ b/packages/core/test/browser-session.test.ts @@ -73,6 +73,32 @@ describe('BrowserSessionManager', () => { expect(bsm.getOwnedTabIds(agentId, sessionA)).toEqual([9]); }); + it('emits ownership events on claim and release for UI badges', async () => { + const events: Array<{ action: string; pageId: number; agentId: string }> = []; + const unsub = bsm.onOwnershipChange((e) => { + events.push({ action: e.action, pageId: e.pageId, agentId: e.agentId }); + }); + + const handlers = bsm.wrapToolHandlers([ + makeHandler('select_page', () => 'Selected page 4\n'), + makeHandler('close_page', () => 'Closed page 4\n'), + makeHandler('list_pages', () => '4: https://example.com [selected]\n'), + ], agentId); + + const select = handlers.find(h => h.name.endsWith('__select_page'))!; + await select.execute({ _browserSessionId: sessionA, pageId: 4 }); + expect(events).toContainEqual({ action: 'claimed', pageId: 4, agentId }); + expect(bsm.listOwnership()).toEqual([ + expect.objectContaining({ pageId: 4, agentId }), + ]); + + const close = handlers.find(h => h.name.endsWith('__close_page'))!; + await close.execute({ _browserSessionId: sessionA, pageId: 4 }); + expect(events).toContainEqual({ action: 'released', pageId: 4, agentId }); + expect(bsm.listOwnership()).toEqual([]); + unsub(); + }); + it('list_pages annotates owned vs shared vs other-session tabs', async () => { const handlers = bsm.wrapToolHandlers([ makeHandler('new_page', (args) => { diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index 93ee549f..b95a917d 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -10679,6 +10679,12 @@ EXPLANATION_END`; return; } + if (path === '/api/browser/tab-ownership' && req.method === 'GET') { + const am = this.orgService.getAgentManager(); + this.json(res, 200, { ownership: am.getBrowserTabOwnership() }); + return; + } + if (path === '/api/system/storage' && req.method === 'GET') { try { const dataDir = join(homedir(), '.markus'); @@ -12200,6 +12206,7 @@ EXPLANATION_END`; exact('/api/system/resume-all', 'POST'), exact('/api/system/emergency-stop', 'POST'), exact('/api/system/status', 'GET'), + exact('/api/browser/tab-ownership', 'GET'), exact('/api/system/storage', 'GET'), exact('/api/system/storage/orphans', 'GET', 'DELETE'), exact('/api/system/announcements', 'GET', 'POST'), diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index da59816d..66bbf945 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -1552,6 +1552,10 @@ export const api = { body: ids ? JSON.stringify({ ids }) : undefined, }), }, + browser: { + tabOwnership: () => + request<{ ownership: Array<{ pageId: number; agentId: string; agentName: string }> }>('/browser/tab-ownership'), + }, settings: { getLlm: () => request<{ defaultProvider: string; providers: Record }>('/settings/llm'), /** Local preferred org name (markus.json). Applied to Hub org on connect. */ diff --git a/packages/web-ui/src/components/EmbeddedBrowser.tsx b/packages/web-ui/src/components/EmbeddedBrowser.tsx index 87d67a84..1fdc93c6 100644 --- a/packages/web-ui/src/components/EmbeddedBrowser.tsx +++ b/packages/web-ui/src/components/EmbeddedBrowser.tsx @@ -20,10 +20,13 @@ export function EmbeddedBrowser({ url, browserId: externalBrowserId, className, + onMeta, }: { url: string; browserId?: string; className?: string; + /** Sync native pageId (and optional url/title) back to the right-panel tab model. */ + onMeta?: (meta: { pageId?: number; url?: string; title?: string }) => void; }) { const { t } = useTranslation('common'); const reactId = useId().replace(/:/g, ''); @@ -74,6 +77,13 @@ export function EmbeddedBrowser({ setIsLoading(!!state.isLoading); setLoadError(state.loadError ?? null); setDirectoryPath(state.directoryPath ?? null); + if (state.pageId != null || state.url || state.title) { + onMeta?.({ + pageId: state.pageId, + url: state.url && state.url !== 'about:blank' ? state.url : undefined, + title: state.title || undefined, + }); + } })(); return () => { cancelled = true; diff --git a/packages/web-ui/src/components/RightPanel.tsx b/packages/web-ui/src/components/RightPanel.tsx index 9987e971..47d97530 100644 --- a/packages/web-ui/src/components/RightPanel.tsx +++ b/packages/web-ui/src/components/RightPanel.tsx @@ -1,12 +1,25 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { api } from '../api.ts'; +import { api, wsClient } from '../api.ts'; import { navBus } from '../navBus.ts'; import { PAGE } from '../routes.ts'; +import { openExternal } from '../hooks/useElectron.ts'; import { ContentRenderer, resolveFormat, type HtmlSelectionData } from './ContentRenderer.tsx'; import { EmbeddedBrowser } from './EmbeddedBrowser.tsx'; import type { RightPanelPayload, RightPanelTab } from '../contexts/LayoutContext.tsx'; +type TabOwner = { agentId: string; agentName: string }; + +function toFileUrl(filePath: string): string { + if (/^(https?|file):\/\//i.test(filePath)) return filePath; + // Windows: C:\foo → file:///C:/foo ; POSIX: /foo → file:///foo + if (/^[a-zA-Z]:[\\/]/.test(filePath)) { + return `file:///${filePath.replace(/\\/g, '/')}`; + } + const normalized = filePath.startsWith('/') ? filePath : `/${filePath}`; + return `file://${normalized}`; +} + export interface ChatContextChip { label: string; content: string; @@ -79,6 +92,7 @@ export function RightPanel({ onNewTab, fullscreen, onToggleFullscreen, + onBrowserMeta, }: { payload: RightPanelPayload; onClose: () => void; @@ -92,15 +106,53 @@ export function RightPanel({ onNewTab?: () => void; fullscreen?: boolean; onToggleFullscreen?: () => void; + onBrowserMeta?: (browserId: string, meta: { pageId?: number; url?: string; title?: string }) => void; }) { const { t } = useTranslation(['deliverables', 'agent', 'common']); const [preview, setPreview] = useState({ mode: 'loading' }); const [copied, setCopied] = useState(false); const [selectionToolbar, setSelectionToolbar] = useState(null); + const [tabOwners, setTabOwners] = useState>({}); const contentRef = useRef(null); const tabStripRef = useRef(null); const activeTabBtnRef = useRef(null); + // Hydrate + live-update which agent currently owns each embedded-browser pageId. + useEffect(() => { + let cancelled = false; + void api.browser.tabOwnership().then(res => { + if (cancelled) return; + const next: Record = {}; + for (const row of res.ownership ?? []) { + next[row.pageId] = { agentId: row.agentId, agentName: row.agentName || row.agentId }; + } + setTabOwners(next); + }).catch(() => { /* endpoint may be unavailable in preview */ }); + + const unsub = wsClient.on('ui:browser_ownership', (event) => { + const p = event.payload as { + action?: 'claimed' | 'released'; + pageId?: number; + agentId?: string | null; + agentName?: string | null; + }; + if (typeof p.pageId !== 'number') return; + setTabOwners(prev => { + if (p.action === 'released' || !p.agentId) { + if (!(p.pageId! in prev)) return prev; + const next = { ...prev }; + delete next[p.pageId!]; + return next; + } + return { + ...prev, + [p.pageId!]: { agentId: p.agentId, agentName: p.agentName || p.agentId }, + }; + }); + }); + return () => { cancelled = true; unsub(); }; + }, []); + const reference = payloadReference(payload); const title = payloadTitle(payload); // Show tabs in the chrome row when present (browser-style). Panel × collapses the panel. @@ -289,6 +341,57 @@ export function RightPanel({ }).catch(() => {}); }; + /** One chrome action: deliverable → Output page; url/file → system browser (or default app). */ + const openExternally = useCallback(async () => { + if (payload.kind === 'deliverable') { + navBus.navigate(PAGE.DELIVERABLES, { openDeliverable: payload.deliverable.id }); + return; + } + + if (payload.kind === 'url') { + let target = payload.url; + if (payload.browserId && window.markusDesktop?.browser) { + try { + const s = await window.markusDesktop.browser.getState(payload.browserId); + if (s.ok) { + if (s.directoryPath) { + await api.system.openPath(s.directoryPath).catch(() => api.files.reveal(s.directoryPath!)); + return; + } + if (s.url && s.url !== 'about:blank') target = s.url; + } + } catch { /* fall through with payload.url */ } + } + if (!target || target === 'about:blank') return; + if (/^https?:\/\//i.test(target) || /^file:\/\//i.test(target)) { + openExternal(target); + return; + } + // Local path typed into the address bar + await api.system.openPath(target).catch(() => openExternal(toFileUrl(target))); + return; + } + + // file + const path = payload.path; + if (!path) return; + if (/^https?:\/\//i.test(path) || /^file:\/\//i.test(path)) { + openExternal(path); + return; + } + await api.system.openPath(path).catch(() => openExternal(toFileUrl(path))); + }, [payload]); + + const canOpenExternally = payload.kind === 'deliverable' + ? !!payload.deliverable.id + : payload.kind === 'url' + ? !!(payload.url && payload.url !== 'about:blank') || !!payload.browserId + : !!payload.path; + + const openExternallyTitle = payload.kind === 'deliverable' + ? t('agent:deliverables.openInPage', { defaultValue: 'Open in Output' }) + : t('common:openInSystemBrowser'); + // Local path that can be revealed in Finder / Explorer (not a remote URL / embedded browser). const canRevealInFileBrowser = !!reference && !isUrl(reference) && (payload.kind === 'file' || payload.kind === 'deliverable'); @@ -313,9 +416,10 @@ export function RightPanel({ {/* Single chrome row: tabs (or title) + panel actions — saves a header band. z-20 keeps chrome above panel content; native views still paint above HTML but must be bounds-synced only to the host below this header. */} -
+
{(showTabs || onNewTab) ? (
{tabs?.map(tab => { const active = tab.id === activeTabId; + const pageId = tab.payload.kind === 'url' ? tab.payload.pageId : undefined; + const owner = pageId != null ? tabOwners[pageId] : undefined; + const tabTitle = owner + ? `${tab.title} — ${t('common:browserTabControlledBy', { name: owner.agentName })}` + : tab.title; return ( // Sibling buttons (never nest {onCloseTab && (
)} diff --git a/packages/web-ui/src/contexts/LayoutContext.tsx b/packages/web-ui/src/contexts/LayoutContext.tsx index b04ffb68..38ecb2ec 100644 --- a/packages/web-ui/src/contexts/LayoutContext.tsx +++ b/packages/web-ui/src/contexts/LayoutContext.tsx @@ -70,8 +70,8 @@ export interface LayoutContextValue { closeRightPanel: () => void; closeRightPanelTab: (tabId: string) => void; setActiveRightPanelTab: (tabId: string) => void; - /** Update url/title for an embedded-browser tab (navigation sync). */ - updateRightPanelBrowserTab: (browserId: string, patch: { url?: string; title?: string }) => void; + /** Update url/title/pageId for an embedded-browser tab (navigation sync). */ + updateRightPanelBrowserTab: (browserId: string, patch: { url?: string; title?: string; pageId?: number }) => void; toggleRightPanel: () => void; /** Collapse the panel if open; never opens / restores (unlike toggle). */ collapseRightPanelOnly: () => void; @@ -210,18 +210,27 @@ export function LayoutProvider({ children }: { children: React.ReactNode }) { lastActiveRef.current = tabId; }, []); - const updateRightPanelBrowserTab = useCallback((browserId: string, patch: { url?: string; title?: string }) => { + const updateRightPanelBrowserTab = useCallback((browserId: string, patch: { url?: string; title?: string; pageId?: number }) => { setTabs(prev => { let changed = false; const next = prev.map(t => { if (t.payload.kind !== 'url' || t.payload.browserId !== browserId) return t; - changed = true; const url = patch.url ?? t.payload.url; const title = patch.title || t.payload.title || url; + const pageId = patch.pageId ?? t.payload.pageId; + if ( + url === t.payload.url + && title === (t.payload.title || t.title) + && pageId === t.payload.pageId + && title === t.title + ) { + return t; + } + changed = true; return { ...t, title, - payload: { ...t.payload, url, title }, + payload: { ...t.payload, url, title, ...(pageId != null ? { pageId } : {}) }, }; }); if (changed) lastTabsRef.current = next; diff --git a/packages/web-ui/src/locales/en/common.json b/packages/web-ui/src/locales/en/common.json index 49df7791..21e867af 100644 --- a/packages/web-ui/src/locales/en/common.json +++ b/packages/web-ui/src/locales/en/common.json @@ -117,6 +117,9 @@ }, "signOut": "Log out", "openInBrowser": "Open in Browser", + "openInSystemBrowser": "Open in system browser", + "browserTabControlledBy": "Controlled by {{name}}", + "browserTabControlledBadge": "Agent", "browserFolderTitle": "This is a folder — the browser can’t preview folders", "browserFolderOpen": "Open in {{app}}", "browserFolderOpening": "Opening…", diff --git a/packages/web-ui/src/locales/zh-CN/common.json b/packages/web-ui/src/locales/zh-CN/common.json index 2c3c31b9..0fb90e6f 100644 --- a/packages/web-ui/src/locales/zh-CN/common.json +++ b/packages/web-ui/src/locales/zh-CN/common.json @@ -117,6 +117,9 @@ }, "signOut": "登出", "openInBrowser": "在浏览器中打开", + "openInSystemBrowser": "在系统浏览器中打开", + "browserTabControlledBy": "由 {{name}} 控制中", + "browserTabControlledBadge": "Agent", "browserFolderTitle": "这是一个文件夹,浏览器无法直接预览", "browserFolderOpen": "在{{app}}中打开", "browserFolderOpening": "正在打开…", diff --git a/packages/web-ui/src/pages/InstalledStore.tsx b/packages/web-ui/src/pages/InstalledStore.tsx index 72ee66b2..e31f4c28 100644 --- a/packages/web-ui/src/pages/InstalledStore.tsx +++ b/packages/web-ui/src/pages/InstalledStore.tsx @@ -170,7 +170,7 @@ export function InstalledStore() { return (
-
+

{t('installedTab.title')}

{Object.keys(updates).length > 0 && ( diff --git a/packages/web-ui/src/pages/Notifications.tsx b/packages/web-ui/src/pages/Notifications.tsx index 8a836871..bec0bf49 100644 --- a/packages/web-ui/src/pages/Notifications.tsx +++ b/packages/web-ui/src/pages/Notifications.tsx @@ -9,7 +9,7 @@ export function NotificationsPage({ authUser }: { authUser?: { id: string; name: return (
-
+
{isMobile && }

{t('notifications')}

diff --git a/packages/web-ui/src/pages/SkillStore.tsx b/packages/web-ui/src/pages/SkillStore.tsx index 5f9f00ea..c189f864 100644 --- a/packages/web-ui/src/pages/SkillStore.tsx +++ b/packages/web-ui/src/pages/SkillStore.tsx @@ -569,7 +569,7 @@ export function SkillStore({ highlightItemId, onHighlightDone }: { highlightItem return (
-
+

{t('skillStore.title')}

diff --git a/packages/web-ui/src/pages/Team.tsx b/packages/web-ui/src/pages/Team.tsx index 1e8dedc5..be08717e 100644 --- a/packages/web-ui/src/pages/Team.tsx +++ b/packages/web-ui/src/pages/Team.tsx @@ -4048,7 +4048,7 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: return (
{/* Row 1: L1/L2 toggle + avatar + name/desc + action buttons */} -
+
{/* Expand sidebars button — shown when sidebars are collapsed */} {sidebarsCollapsed && !isMobile && (