From 2f1af8c4ac4ee4e009b2a6447765b7a7c8da0a6f Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sun, 1 Mar 2026 11:54:35 +0100 Subject: [PATCH 01/11] Fix an issue that misplaced context menus when running in a web browser Signed-off-by: Axel Boberg --- CHANGELOG.md | 4 +++ api/browser/ui/contextMenu.js | 29 +++++++++++++++++++ app/components/ContextMenuBoundary/index.jsx | 21 ++------------ docs/api/README.md | 12 ++++++-- .../rundown/app/components/Header/index.jsx | 5 +++- .../app/components/RundownListItem/index.jsx | 4 ++- plugins/rundown/app/views/Rundown.jsx | 5 +++- 7 files changed, 55 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cb59ac..535d61dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.0.0-beta.11 [UNRELEASED] +### Fixed +- An issue where context menues wouldn't be properly placed when running in a web browser + ## 1.0.0-beta.10 ### Added - Human/AI readable descriptors for types diff --git a/api/browser/ui/contextMenu.js b/api/browser/ui/contextMenu.js index bcd97d01..7a96a325 100644 --- a/api/browser/ui/contextMenu.js +++ b/api/browser/ui/contextMenu.js @@ -103,6 +103,35 @@ class UIContextMenu { this.#openedAt = Date.now() this.#props.Events.emitLocally('ui.contextMenu.open', spec, opts) } + + /** + * Get the event position for use + * when opening a context menu + * @param { PointerEvent } e + * @returns + */ + getPositionFromEvent (e) { + if (Object.prototype.hasOwnProperty.call(e.nativeEvent, 'pointerType')) { + throw new InvalidArgumentError('Provided event is not of type PointerEvent') + } + + /* + Find the root element, either an encapsulating + iframe or the body and use its position + as offset for the event + */ + let rootEl = e?.target?.ownerDocument?.defaultView?.frameElement + if (!rootEl) { + rootEl = document.body + } + + const bounds = rootEl.getBoundingClientRect() + + return { + x: e.clientX + bounds.x, + y: e.clientY + bounds.y + } + } } DIController.main.register('UIContextMenu', UIContextMenu, [ diff --git a/app/components/ContextMenuBoundary/index.jsx b/app/components/ContextMenuBoundary/index.jsx index bdb8f55f..063e9ec2 100644 --- a/app/components/ContextMenuBoundary/index.jsx +++ b/app/components/ContextMenuBoundary/index.jsx @@ -22,20 +22,6 @@ function isNumber (x) { return typeof x === 'number' && !Number.isNaN(x) } -function getScreenCoordinates () { - return { - x: window.screenLeft, - y: window.screenTop - } -} - -function convertToPageCoordinates (ctxX, ctxY, screenX, screenY) { - return { - x: ctxX - screenX, - y: ctxY - screenY - } -} - function sanitizeItemSpec (spec) { const out = {} for (const property of ALLOWED_SPEC_PROPERTIES) { @@ -90,12 +76,9 @@ export function ContextMenuBoundary ({ children }) { return } - const screenCoords = getScreenCoordinates() - const pageCoords = convertToPageCoordinates(opts.x, opts.y, screenCoords.x, screenCoords.y) - setContextPos({ - x: Math.max(pageCoords.x, 0), - y: Math.max(pageCoords.y, 0) + x: Math.max(opts.x, 0), + y: Math.max(opts.y, 0) }) setOpts(opts) diff --git a/docs/api/README.md b/docs/api/README.md index 6d81f50c..79a2f3b1 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -634,9 +634,15 @@ const spec = [ window.addEventListener('contextmenu', e => { bridge.ui.contextMenu.open(spec, { - x: e.screenX, // Required - y: e.screenY, // Required - searchable: true // Optional, defaults to false, whether or not to show a search field and allow the user to search for any items in the menu + searchable: true // Optional, defaults to false, whether or not to show a search field and allow the user to search for any items in the menu, + + // Coordinates must be required either by + // 1. Auto populating coordinates (recommended) + ...bridge.ui.contextMenu.getPositionFromEvent(e), + + // 2. Manually defining coordinates + y: 10, + x: 10 }) }) ``` diff --git a/plugins/rundown/app/components/Header/index.jsx b/plugins/rundown/app/components/Header/index.jsx index e06175ec..9f93b3da 100644 --- a/plugins/rundown/app/components/Header/index.jsx +++ b/plugins/rundown/app/components/Header/index.jsx @@ -17,7 +17,10 @@ export function Header () { const types = await bridge.state.get('_types') const spec = contextMenu.generateAddContextMenuItems(types, typeId => handleAdd(typeId)) - bridge.ui.contextMenu.open(spec, { x: e.screenX, y: e.screenY, searchable: true }) + bridge.ui.contextMenu.open(spec, { + searchable: true, + ...bridge.ui.contextMenu.getPositionFromEvent(e) + }) } /** diff --git a/plugins/rundown/app/components/RundownListItem/index.jsx b/plugins/rundown/app/components/RundownListItem/index.jsx index 54bffddc..8143edf2 100644 --- a/plugins/rundown/app/components/RundownListItem/index.jsx +++ b/plugins/rundown/app/components/RundownListItem/index.jsx @@ -170,7 +170,9 @@ export function RundownListItem ({ ) ] - bridge.ui.contextMenu.open(spec, { x: e.screenX, y: e.screenY }) + bridge.ui.contextMenu.open(spec, { + ...bridge.ui.contextMenu.getPositionFromEvent(e) + }) } async function handleDelete () { diff --git a/plugins/rundown/app/views/Rundown.jsx b/plugins/rundown/app/views/Rundown.jsx index e29fc022..1583449e 100644 --- a/plugins/rundown/app/views/Rundown.jsx +++ b/plugins/rundown/app/views/Rundown.jsx @@ -43,7 +43,10 @@ export function Rundown () { label: 'Add', children: contextMenu.generateAddContextMenuItems(types, typeId => handleItemCreate(typeId)) } - ], { x: e.screenX, y: e.screenY, searchable: true }) + ], { + searchable: true, + ...bridge.ui.contextMenu.getPositionFromEvent(e) + }) } /** From cc2a34680976df5b2fa08d7c54885ed1257b93a9 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sun, 1 Mar 2026 13:23:37 +0100 Subject: [PATCH 02/11] Use server time for time displays in the rundown Signed-off-by: Axel Boberg --- CHANGELOG.md | 3 + api/time.js | 65 ++++++++++++++++++- api/time.unit.test.js | 24 +++++++ docs/api/README.md | 12 ++++ lib/api/STime.js | 13 ++++ .../components/RundownItemProgress/index.jsx | 16 +++-- .../RundownItemTimeSection/index.jsx | 20 ++++-- 7 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 api/time.unit.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 535d61dd..38e95c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog ## 1.0.0-beta.11 [UNRELEASED] +### Added +- A `bridge.time.now()` api for getting the current server time ### Fixed - An issue where context menues wouldn't be properly placed when running in a web browser +- An issue where time displays in the rundown didn't sync with the server time ## 1.0.0-beta.10 ### Added diff --git a/api/time.js b/api/time.js index 24ba6ffc..a828a1d7 100644 --- a/api/time.js +++ b/api/time.js @@ -4,9 +4,15 @@ const DIController = require('../shared/DIController') +const SERVER_TIME_TTL_MS = 10000 + class Time { #props + #serverTime + #serverTimeUpdatedAt + #serverTimePromise + constructor (props) { this.#props = props } @@ -30,10 +36,65 @@ class Time { submitFrame (id, frame) { return this.#props.Commands.executeRawCommand('time.submitFrame', id, frame) } + + /** + * Update the record of + * the current server time + * + * This will set the #serverTime and #serverTimeUpdatedAt + * properties, allowing the client to return the correct time + * accordingly + * + * @returns { Promise. } + */ + async #updateServerTime () { + /* + Skip updating if it's already trying + to update to avoid multiple requests + */ + if (this.#serverTimePromise) { + return this.#serverTimePromise + } + + /* + Resolve immediately if the local + time hasn't become outdated + */ + if (this.#serverTimeUpdatedAt && (Date.now() - this.#serverTimeUpdatedAt) < SERVER_TIME_TTL_MS) { + return Promise.resolve() + } + + this.#serverTimePromise = new Promise((resolve, reject) => { + const start = Date.now() + this.#props.Commands.executeCommand('time.getServerTime') + .then(now => { + const roundtripDur = Date.now() - start + this.#serverTime = now + Math.round(roundtripDur / 2) + this.#serverTimeUpdatedAt = Date.now() + resolve() + }) + .catch(err => { + this.#serverTimeUpdatedAt = Date.now() + reject(err) + }) + .finally(() => { + this.#serverTimePromise = undefined + }) + }) + return this.#serverTimePromise + } + + /** + * Get the current time according to the server, + * compensating for latency and clock drift + * @returns { Promise. } + */ + async now (id) { + await this.#updateServerTime() + return this.#serverTime + (Date.now() - this.#serverTimeUpdatedAt) + } } DIController.main.register('Time', Time, [ - 'State', - 'Events', 'Commands' ]) diff --git a/api/time.unit.test.js b/api/time.unit.test.js new file mode 100644 index 00000000..3c7a25a6 --- /dev/null +++ b/api/time.unit.test.js @@ -0,0 +1,24 @@ +require('./time') + +const DIController = require('../shared/DIController') + +let time +beforeAll(() => { + time = DIController.main.instantiate('Time', { + Commands: { + executeCommand: command => { + if (command === 'time.getServerTime') { + return Promise.resolve(Date.now()) + } + }, + executeRawCommand: () => {} + } + }) +}) + +test('get the server time', async () => { + const value = await time.now() + const now = Date.now() + expect(value).toBeGreaterThanOrEqual(now - 100) + expect(value).toBeLessThan(now + 100) +}) diff --git a/docs/api/README.md b/docs/api/README.md index 79a2f3b1..4cf562e2 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -21,6 +21,7 @@ Bridge provides a JavaScript api for use in plugins and their widgets. - [Messages](#messages) - [UI](#ui) - [Context menus](#context-menus) +- [Time](#time) ## Getting started The api is available for plugins and widgets running in either the main process or browser processes of Bridge and can be included as follows. The module will be provided by Bridge at runtime. @@ -668,4 +669,15 @@ Close any opened context menus, this does not need to be called as a response to ```javascript import bridge from 'bridge' bridge.ui.contextMenu.close() +``` + +## Time + +### `bridge.time.now(): Promise.` +Get the current server time, consider this a replacement for Date.now(). +This function compensates for roundtrip latency and local clock drift. + +```javascript +import bridge from 'bridge' +const now = await bridge.time.now() ``` \ No newline at end of file diff --git a/lib/api/STime.js b/lib/api/STime.js index 8f6f3e02..d17ea68b 100644 --- a/lib/api/STime.js +++ b/lib/api/STime.js @@ -50,6 +50,7 @@ class STime extends DIBase { } #setup () { + this.props.SCommands.registerAsyncCommand('time.getServerTime', this.getServerTime.bind(this)) this.props.SCommands.registerAsyncCommand('time.registerClock', this.registerClock.bind(this)) this.props.SCommands.registerAsyncCommand('time.getAllClocks', this.getAllClocks.bind(this)) this.props.SCommands.registerAsyncCommand('time.removeClock', this.removeClock.bind(this)) @@ -126,6 +127,18 @@ class STime extends DIBase { this.#emitClocksChangedEvent() } + /** + * Get the current + * server time + * + * @returns { + * now: number + * } + */ + getServerTime () { + return Date.now() + } + /** * Submit a clock frame * for a specific clock diff --git a/plugins/rundown/app/components/RundownItemProgress/index.jsx b/plugins/rundown/app/components/RundownItemProgress/index.jsx index d57f7b1e..338c1e97 100644 --- a/plugins/rundown/app/components/RundownItemProgress/index.jsx +++ b/plugins/rundown/app/components/RundownItemProgress/index.jsx @@ -1,4 +1,5 @@ import React from 'react' +import bridge from 'bridge' import './style.css' @@ -13,29 +14,34 @@ export function RundownItemProgress ({ item }) { let shouldLoop = true - function loop () { + async function loop () { if (!shouldLoop) { return } let progress = 0 + const now = await bridge.time.now() switch (item?.state) { case 'playing': - progress = (Date.now() - item?.didStartPlayingAt) / item?.data?.duration + progress = (now - item?.didStartPlayingAt) / item?.data?.duration break case 'scheduled': - progress = (item?.willStartPlayingAt - Date.now()) / (item?.willStartPlayingAt - item?.wasScheduledAt) + progress = (item?.willStartPlayingAt - now) / (item?.willStartPlayingAt - item?.wasScheduledAt) break } - if (Number.isNaN(progress) || (progress >= 1 && item?.state === 'playing')) { + /* + Reset and stop the loop if the + progress is undefined or more than 1 + */ + if (Number.isNaN(progress) || progress >= 1) { setProgress(0) + shouldLoop = false return } setProgress(Math.max(Math.min(progress, 1), 0)) - window.requestAnimationFrame(loop) } loop() diff --git a/plugins/rundown/app/components/RundownItemTimeSection/index.jsx b/plugins/rundown/app/components/RundownItemTimeSection/index.jsx index 09a02704..431a2efc 100644 --- a/plugins/rundown/app/components/RundownItemTimeSection/index.jsx +++ b/plugins/rundown/app/components/RundownItemTimeSection/index.jsx @@ -1,4 +1,5 @@ import React from 'react' +import bridge from 'bridge' import './style.css' @@ -45,19 +46,21 @@ export function RundownItemTimeSection ({ item }) { let shouldLoop = true - function loop () { + async function loop () { if (!shouldLoop) { return } let remaining = 0 + const now = await bridge.time.now() + switch (item?.state) { case 'playing': - remaining = Math.min(item?.data?.duration - (Date.now() - item?.didStartPlayingAt), item?.data?.duration) + remaining = Math.min(item?.data?.duration - (now - item?.didStartPlayingAt), item?.data?.duration) break case 'scheduled': - remaining = item?.willStartPlayingAt - Date.now() + remaining = item?.willStartPlayingAt - now break default: setRemaining(undefined) @@ -68,8 +71,17 @@ export function RundownItemTimeSection ({ item }) { setRemaining(undefined) return } - + setRemaining(Math.max(remaining, 0)) + + /* + Prevent the loop from continuing + if there is no remaining time + */ + if (remaining < 0) { + shouldLoop = false + return + } window.requestAnimationFrame(loop) } loop() From 82faf80b7f7fe510fe4cc2b668d057626f4465d9 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Mon, 2 Mar 2026 23:37:36 +0100 Subject: [PATCH 03/11] Fix an issue where the clipboard isn't available in a insecure contexts Signed-off-by: Axel Boberg --- CHANGELOG.md | 1 + api/browser/clipboard.js | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e95c08..edd9746c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Fixed - An issue where context menues wouldn't be properly placed when running in a web browser - An issue where time displays in the rundown didn't sync with the server time +- The clipboard isn't available in insecure contexts ## 1.0.0-beta.10 ### Added diff --git a/api/browser/clipboard.js b/api/browser/clipboard.js index 3fae06a7..dd64e2a4 100644 --- a/api/browser/clipboard.js +++ b/api/browser/clipboard.js @@ -7,6 +7,8 @@ const DIController = require('../../shared/DIController') const InvalidArgumentError = require('../error/InvalidArgumentError') class Clipboard { + #copiedContent + /** * Write a string into the clipboard * @param { String } str A string to write @@ -17,7 +19,17 @@ class Clipboard { throw new InvalidArgumentError('Provided text is not a string and cannot be written to the clipboard') } - return navigator.clipboard.writeText(str) + if (navigator.clipboard) { + return navigator.clipboard.writeText(str) + } + + /* + Fall back to using an internal + property as store if navigator.clipboard + is not available (it isn't in insecure contexts) + */ + this.#copiedContent = str + return Promise.resolve(true) } /** @@ -27,7 +39,10 @@ class Clipboard { * @returns { Promise. } */ readText () { - return navigator.clipboard.readText() + if (navigator.clipboard) { + return navigator.clipboard.readText() + } + return Promise.resolve(this.#copiedContent) } /** From 61ebb59ab2c65dbd55115390f67a163824dd5f04 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 18 Mar 2026 21:15:46 +0100 Subject: [PATCH 04/11] Start preparing for server deployments Signed-off-by: Axel Boberg --- .dockerignore | 4 ++- Dockerfile | 10 ++++++-- docker-compose.yml | 3 ++- lib/config.js | 3 ++- lib/init-common.js | 2 +- lib/init-node.js | 41 +------------------------------ lib/server.js | 15 ++++++++--- plugins/scheduler/lib/Interval.js | 9 ++++++- 8 files changed, 37 insertions(+), 50 deletions(-) diff --git a/.dockerignore b/.dockerignore index 191381ee..e93f3b7f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,3 @@ -.git \ No newline at end of file +.git +assets.json +node_modules \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 46c070d1..4f4a8705 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,19 @@ # The first stage will # build the app into /app -FROM node:14.16.0-alpine3.10 +FROM node:24-trixie + +# RUN apk add --update --no-cache python3 make g++ + WORKDIR /app COPY package*.json ./ +COPY plugins/* ./plugins/ +COPY scripts/* ./scripts/ RUN npm ci COPY . ./ + RUN npm run build CMD ["npm", "start"] @@ -16,7 +22,7 @@ CMD ["npm", "start"] # to force-squash the history # and prevent any tokens # from leaking out -FROM node:14.16.0-alpine3.10 +FROM node:24-trixie WORKDIR /app COPY --from=0 /app /app diff --git a/docker-compose.yml b/docker-compose.yml index 08ff66b4..ff363ff4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,8 @@ services: - .:/app environment: - NODE_ENV=development - - PORT=3000 + - HTTP_PORT=3000 + - HTTP_ADDR=0.0.0.0 - APP_DATA_BASE_PATH=../data ports: - 3000:3000 diff --git a/lib/config.js b/lib/config.js index f00fc81b..665c0c10 100644 --- a/lib/config.js +++ b/lib/config.js @@ -3,5 +3,6 @@ // SPDX-License-Identifier: MIT exports.defaults = { - HTTP_PORT: 5544 + HTTP_PORT: process.env.HTTP_PORT || 5544, + HTTP_ADDR: process.env.HTTP_ADDR || 'localhost' } diff --git a/lib/init-common.js b/lib/init-common.js index bd39539c..9bd97de9 100644 --- a/lib/init-common.js +++ b/lib/init-common.js @@ -81,7 +81,7 @@ const DEFAULT_HTTP_PORT = config.defaults.HTTP_PORT logger.warn('Failed to restore user defaults, maybe it\'s the first time the application is running', err) } finally { UserDefaults.apply({ - httpPort: process.env.PORT || json?.httpPort || DEFAULT_HTTP_PORT + httpPort: process.env.HTTP_PORT || json?.httpPort || DEFAULT_HTTP_PORT }) } })() diff --git a/lib/init-node.js b/lib/init-node.js index 99e8c093..3859130f 100644 --- a/lib/init-node.js +++ b/lib/init-node.js @@ -6,18 +6,11 @@ const fs = require('node:fs') const paths = require('./paths') const UserDefaults = require('./UserDefaults') -const WorkspaceRegistry = require('./WorkspaceRegistry') const Logger = require('./Logger') const logger = new Logger({ name: 'init-node' }) -/** -* The minimum threshold after creation -* that a workspace can be teared down, -* assuming no connections -* @type { Number } -*/ -const WORKSPACE_TEARDOWN_MIN_THRESHOLD_MS = 20000 +logger.info('Initializing') /* Write the user defaults-state to disk @@ -39,35 +32,3 @@ process.on('SIGINT', () => { writeUserDeafults() process.exit(0) }) - -/* -Setup listeners for new workspaces -in order to remove any dangling -references -*/ -WorkspaceRegistry.getInstance().on('add', async workspace => { - const creationTimeStamp = Date.now() - - function conditionalTeardownWorkspaces () { - /* - Make sure that we've given clients - a timeframe to connect before - terminating the workspace - */ - if (Date.now() - creationTimeStamp < WORKSPACE_TEARDOWN_MIN_THRESHOLD_MS) { - return - } - - if (Object.keys(workspace?.state?.data?._connections || {}).length > 0) { - return - } - - logger.debug('Tearing down workspace', workspace.id) - WorkspaceRegistry.getInstance().delete(workspace.id) - workspace.teardown() - } - - workspace.on('cleanup', async () => { - conditionalTeardownWorkspaces() - }) -}) diff --git a/lib/server.js b/lib/server.js index 3885cb75..1bb2776b 100644 --- a/lib/server.js +++ b/lib/server.js @@ -33,11 +33,16 @@ MUST be declared AFTER initialization as UserDefaults would otherwise be blank */ const HTTP_PORT = UserDefaults.data.httpPort || config.defaults.HTTP_PORT -const HTTP_BIND_ADDR = UserDefaults.data.httpBindToAll ? '0.0.0.0' : 'localhost' +const HTTP_BIND_ADDR = UserDefaults.data.httpBindToAll ? '0.0.0.0' : config.defaults.HTTP_ADDR const app = express() app.disable('x-powered-by') + +app.use((req, res, next) => { + logger.debug(`${req.method} ${req.path}`) + next() +}) app.use(express.static(path.join(__dirname, '../public'))) app.use(express.static(path.join(__dirname, '../dist'))) @@ -46,8 +51,12 @@ app.use(express.static(path.join(__dirname, '../dist'))) * the main http server * @type { HttpError.Server } */ -const server = app.listen(HTTP_PORT, HTTP_BIND_ADDR, () => { - logger.info('Listening on port', HTTP_PORT) +const server = app.listen(HTTP_PORT, HTTP_BIND_ADDR, err => { + if (err) { + logger.error(err) + } else { + logger.info('Listening on', `${HTTP_BIND_ADDR}:${HTTP_PORT}`) + } }) ;(function () { diff --git a/plugins/scheduler/lib/Interval.js b/plugins/scheduler/lib/Interval.js index 8ac693c0..f6d8a472 100644 --- a/plugins/scheduler/lib/Interval.js +++ b/plugins/scheduler/lib/Interval.js @@ -60,7 +60,14 @@ class Interval { _loop () { const now = Date.now() const drift = Math.max((now - this._lastTrigger) - this._delay, 0) - const delay = this._delay - drift + + let delay = this._delay - drift + if (isNaN(delay)) { + delay = 0 + } + if (delay < 0) { + delay = 0 + } this._lastTrigger = now From 74886c00f1ecef8a1458b8523598b730946b89c8 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 18 Mar 2026 21:16:07 +0100 Subject: [PATCH 05/11] Catch plugin initialization errors Signed-off-by: Axel Boberg --- lib/plugin/context.js | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/lib/plugin/context.js b/lib/plugin/context.js index bd90e19d..676557f8 100644 --- a/lib/plugin/context.js +++ b/lib/plugin/context.js @@ -46,21 +46,7 @@ function factory (workspace, manifest) { * in a worker thread */ function activate () { - ctx.worker = new Worker(WORKER_ENTRYPOINT_PATH, { - workerData: { - manifest - } - }) - - ctx.worker.on('exit', () => { - /* - Clear all commands registered - by this context - */ - ctx.workspace.api.commands.removeAllByOwner(ctx.id) - }) - - ctx.worker.on('error', err => { + function handleError (err) { /* Show worker errors as messages in the UI */ @@ -72,6 +58,29 @@ function factory (workspace, manifest) { return } throw err + } + + try { + ctx.worker = new Worker(WORKER_ENTRYPOINT_PATH, { + workerData: { + manifest + } + }) + } catch (err) { + logger.error('Fatal error when setting up', manifest?.name) + handleError(err) + } + + ctx.worker.on('exit', () => { + /* + Clear all commands registered + by this context + */ + ctx.workspace.api.commands.removeAllByOwner(ctx.id) + }) + + ctx.worker.on('error', err => { + handleError(err) }) /* From d3bc860382ce338c10006e6842cd5cbfc915faef Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 18 Mar 2026 21:16:23 +0100 Subject: [PATCH 06/11] Add a default tab for the new agent mode Signed-off-by: Axel Boberg --- lib/template.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/template.json b/lib/template.json index 88892e61..cd1e02d2 100644 --- a/lib/template.json +++ b/lib/template.json @@ -31,6 +31,26 @@ "component": "bridge.plugins.inspector" } } + }, + "2": { + "title": "Agent", + "component": "bridge.internals.grid", + "layout": { + "agent": { "x": 0, "y": 0, "w": 6, "h": 12 }, + "rundown": { "x": 6, "y": 0, "w": 12, "h": 12 }, + "inspector": { "x": 18, "y": 0, "w": 6, "h": 12 } + }, + "children": { + "agent": { + "component": "bridge.plugins.agent" + }, + "rundown": { + "component": "bridge.plugins.rundown" + }, + "inspector": { + "component": "bridge.plugins.inspector" + } + } } } } From 135eb5195eb946a68298e186c96bb47adfec012a Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 18 Mar 2026 21:17:01 +0100 Subject: [PATCH 07/11] Allow plugins to use warnings in settings Signed-off-by: Axel Boberg --- lib/schemas/setting.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/schemas/setting.schema.json b/lib/schemas/setting.schema.json index 3de21786..cd021ce2 100644 --- a/lib/schemas/setting.schema.json +++ b/lib/schemas/setting.schema.json @@ -7,7 +7,7 @@ "properties": { "type": { "type": "string", - "enum": ["boolean", "theme", "number", "string", "button", "frame", "select", "segmented", "list"] + "enum": ["boolean", "theme", "number", "string", "button", "frame", "select", "segmented", "list", "warning"] }, "bind": { "type": "string" From 12d2a01532b35ce17ee39b33de506c00455f04df Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 18 Mar 2026 21:17:41 +0100 Subject: [PATCH 08/11] Set a default font size for h4 headings Signed-off-by: Axel Boberg --- app/bridge.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/bridge.css b/app/bridge.css index 746e0bf5..6e3967b0 100644 --- a/app/bridge.css +++ b/app/bridge.css @@ -145,6 +145,7 @@ h3, .u-heading--3 { } h4, .u-heading--4 { + font-size: 1em; margin: 0.2em 0; } From 797017af80bf2a033c07601e06d3ff88ec212687 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Wed, 18 Mar 2026 21:36:23 +0100 Subject: [PATCH 09/11] Add the first version of the ai agent Signed-off-by: Axel Boberg --- CHANGELOG.md | 1 + plugins/agent/README.md | 2 + plugins/agent/app/App.jsx | 23 + plugins/agent/app/assets/icons/error.svg | 11 + plugins/agent/app/assets/icons/index.jsx | 13 + plugins/agent/app/assets/icons/reload.svg | 10 + plugins/agent/app/assets/icons/send.svg | 7 + plugins/agent/app/assets/icons/success.svg | 8 + .../agent/app/components/ChatFooter/index.jsx | 51 + .../agent/app/components/ChatFooter/style.css | 72 + .../agent/app/components/ChatHero/index.jsx | 20 + .../agent/app/components/ChatHero/style.css | 22 + .../ChatInteractionConfirm/index.jsx | 51 + .../ChatInteractionConfirm/style.css | 44 + .../app/components/ChatLoading/index.jsx | 18 + .../app/components/ChatLoading/style.css | 13 + .../app/components/ChatMessage/index.jsx | 89 + .../app/components/ChatMessage/style.css | 71 + .../components/ChatMessageToolUse/index.jsx | 10 + .../components/ChatMessageToolUse/style.css | 9 + plugins/agent/app/components/Icon/index.jsx | 10 + plugins/agent/app/components/Icon/style.css | 15 + .../app/components/NotConfigured/index.jsx | 15 + .../app/components/NotConfigured/style.css | 19 + .../agent/app/components/Settings/index.jsx | 204 + .../agent/app/components/Settings/style.css | 17 + .../components/SettingsDisclaimer/index.jsx | 23 + .../components/SettingsDisclaimer/style.css | 9 + plugins/agent/app/index.jsx | 12 + plugins/agent/app/style.css | 23 + plugins/agent/app/utils/Animation.js | 50 + plugins/agent/app/utils/message.js | 6 + plugins/agent/app/views/Chat.jsx | 188 + plugins/agent/app/views/Settings.jsx | 13 + plugins/agent/index.js | 313 ++ plugins/agent/lib/Client.js | 15 + plugins/agent/lib/Model.js | 113 + plugins/agent/lib/Server.js | 19 + plugins/agent/lib/Session.js | 232 + plugins/agent/lib/errors/SessionError.js | 14 + plugins/agent/lib/models/AnthropicModel.js | 183 + plugins/agent/lib/models/CustomModel.js | 198 + plugins/agent/lib/models/OpenAIModel.js | 216 + plugins/agent/lib/tools.js | 232 + plugins/agent/package-lock.json | 4908 +++++++++++++++++ plugins/agent/package.json | 37 + 46 files changed, 7629 insertions(+) create mode 100644 plugins/agent/README.md create mode 100644 plugins/agent/app/App.jsx create mode 100644 plugins/agent/app/assets/icons/error.svg create mode 100644 plugins/agent/app/assets/icons/index.jsx create mode 100644 plugins/agent/app/assets/icons/reload.svg create mode 100644 plugins/agent/app/assets/icons/send.svg create mode 100644 plugins/agent/app/assets/icons/success.svg create mode 100644 plugins/agent/app/components/ChatFooter/index.jsx create mode 100644 plugins/agent/app/components/ChatFooter/style.css create mode 100644 plugins/agent/app/components/ChatHero/index.jsx create mode 100644 plugins/agent/app/components/ChatHero/style.css create mode 100644 plugins/agent/app/components/ChatInteractionConfirm/index.jsx create mode 100644 plugins/agent/app/components/ChatInteractionConfirm/style.css create mode 100644 plugins/agent/app/components/ChatLoading/index.jsx create mode 100644 plugins/agent/app/components/ChatLoading/style.css create mode 100644 plugins/agent/app/components/ChatMessage/index.jsx create mode 100644 plugins/agent/app/components/ChatMessage/style.css create mode 100644 plugins/agent/app/components/ChatMessageToolUse/index.jsx create mode 100644 plugins/agent/app/components/ChatMessageToolUse/style.css create mode 100644 plugins/agent/app/components/Icon/index.jsx create mode 100644 plugins/agent/app/components/Icon/style.css create mode 100644 plugins/agent/app/components/NotConfigured/index.jsx create mode 100644 plugins/agent/app/components/NotConfigured/style.css create mode 100644 plugins/agent/app/components/Settings/index.jsx create mode 100644 plugins/agent/app/components/Settings/style.css create mode 100644 plugins/agent/app/components/SettingsDisclaimer/index.jsx create mode 100644 plugins/agent/app/components/SettingsDisclaimer/style.css create mode 100644 plugins/agent/app/index.jsx create mode 100644 plugins/agent/app/style.css create mode 100644 plugins/agent/app/utils/Animation.js create mode 100644 plugins/agent/app/utils/message.js create mode 100644 plugins/agent/app/views/Chat.jsx create mode 100644 plugins/agent/app/views/Settings.jsx create mode 100644 plugins/agent/index.js create mode 100644 plugins/agent/lib/Client.js create mode 100644 plugins/agent/lib/Model.js create mode 100644 plugins/agent/lib/Server.js create mode 100644 plugins/agent/lib/Session.js create mode 100644 plugins/agent/lib/errors/SessionError.js create mode 100644 plugins/agent/lib/models/AnthropicModel.js create mode 100644 plugins/agent/lib/models/CustomModel.js create mode 100644 plugins/agent/lib/models/OpenAIModel.js create mode 100644 plugins/agent/lib/tools.js create mode 100644 plugins/agent/package-lock.json create mode 100644 plugins/agent/package.json diff --git a/CHANGELOG.md b/CHANGELOG.md index edd9746c..e5fdf2c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 1.0.0-beta.11 [UNRELEASED] ### Added +- An AI agent - A `bridge.time.now()` api for getting the current server time ### Fixed - An issue where context menues wouldn't be properly placed when running in a web browser diff --git a/plugins/agent/README.md b/plugins/agent/README.md new file mode 100644 index 00000000..78220e4c --- /dev/null +++ b/plugins/agent/README.md @@ -0,0 +1,2 @@ +# Agent plugin +An MCP client, server and host for using agents within Bridge diff --git a/plugins/agent/app/App.jsx b/plugins/agent/app/App.jsx new file mode 100644 index 00000000..1efaebeb --- /dev/null +++ b/plugins/agent/app/App.jsx @@ -0,0 +1,23 @@ +import React from 'react' +import './style.css' + +import { Chat } from './views/Chat' +import { Settings } from './views/Settings' + +export default function () { + const [view, setView] = React.useState() + + React.useEffect(() => { + const params = new URLSearchParams(window.location.search) + setView(params.get('path')) + }, []) + + switch (view) { + case 'chat': + return + case 'settings': + return + default: + return <> + } +} \ No newline at end of file diff --git a/plugins/agent/app/assets/icons/error.svg b/plugins/agent/app/assets/icons/error.svg new file mode 100644 index 00000000..cf5ae74e --- /dev/null +++ b/plugins/agent/app/assets/icons/error.svg @@ -0,0 +1,11 @@ + + + icons/message/error + + + + + + + + \ No newline at end of file diff --git a/plugins/agent/app/assets/icons/index.jsx b/plugins/agent/app/assets/icons/index.jsx new file mode 100644 index 00000000..f6508b38 --- /dev/null +++ b/plugins/agent/app/assets/icons/index.jsx @@ -0,0 +1,13 @@ +import reload from './reload.svg' +import send from './send.svg' + +import success from './success.svg' +import error from './error.svg' + +export default { + reload, + send, + + success, + error +} \ No newline at end of file diff --git a/plugins/agent/app/assets/icons/reload.svg b/plugins/agent/app/assets/icons/reload.svg new file mode 100644 index 00000000..ae0de5a6 --- /dev/null +++ b/plugins/agent/app/assets/icons/reload.svg @@ -0,0 +1,10 @@ + + + icons/reload + + + + + + + \ No newline at end of file diff --git a/plugins/agent/app/assets/icons/send.svg b/plugins/agent/app/assets/icons/send.svg new file mode 100644 index 00000000..70daafe7 --- /dev/null +++ b/plugins/agent/app/assets/icons/send.svg @@ -0,0 +1,7 @@ + + + icons/send + + + + \ No newline at end of file diff --git a/plugins/agent/app/assets/icons/success.svg b/plugins/agent/app/assets/icons/success.svg new file mode 100644 index 00000000..fefa585c --- /dev/null +++ b/plugins/agent/app/assets/icons/success.svg @@ -0,0 +1,8 @@ + + + icons/message/success + + + + + \ No newline at end of file diff --git a/plugins/agent/app/components/ChatFooter/index.jsx b/plugins/agent/app/components/ChatFooter/index.jsx new file mode 100644 index 00000000..71932bd9 --- /dev/null +++ b/plugins/agent/app/components/ChatFooter/index.jsx @@ -0,0 +1,51 @@ +import React from 'react' +import './style.css' + +import { Icon } from '../Icon' + +export function ChatFooter ({ model, contextUsage = 0, onSend = () => {} }) { + const [input, setInput] = React.useState() + + function handleSendMessage () { + if (!input || input?.length === 0) { + return + } + onSend({ text: input }) + setInput('') + } + + function handleKeyDown (e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + return ( +
+