Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions client/features/applets/WidgetErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'

import { IconAlertTriangle } from '@tabler/icons-react'

type Props = {
import { reportAppletError } from '@/client/features/applets/applet-log'
import type { AppletKind } from '@/lib/types'

type WidgetErrorBoundaryProps = {
name: string
// Applet attribution for the error journal (docs/self-correction.md). The
// boundary wraps both widgets (WidgetShell) and views (WorkspaceScreen).
kind: AppletKind
workspaceId: string
resetKey: number
children: ReactNode
}

type State = { error: Error | null }

export class WidgetErrorBoundary extends Component<Props, State> {
export class WidgetErrorBoundary extends Component<WidgetErrorBoundaryProps, State> {
state: State = { error: null }

static getDerivedStateFromError(error: Error): State {
Expand All @@ -19,9 +26,18 @@ export class WidgetErrorBoundary extends Component<Props, State> {

componentDidCatch(error: Error, info: ErrorInfo) {
console.error(`[widget:${this.props.name}]`, error, info.componentStack)
// Journal the crash so `moi debug logs` can surface it to the agent — the
// console line above dies in a console nobody reads.
reportAppletError(this.props.workspaceId, {
source: 'render',
kind: this.props.kind,
name: this.props.name,
message: error.message || String(error),
stack: [error.stack, info.componentStack].filter(Boolean).join('\n')
})
}

componentDidUpdate(prev: Props) {
componentDidUpdate(prev: WidgetErrorBoundaryProps) {
if (prev.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null })
}
Expand Down
9 changes: 8 additions & 1 deletion client/features/applets/WidgetShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AnimatePresence, motion } from 'motion/react'

import { AppletMount } from '@/client/features/applets/AppletMount'
import { useWidget } from '@/client/features/applets/useApplet'
import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext'

import { WidgetErrorBoundary } from './WidgetErrorBoundary'

Expand All @@ -10,6 +11,7 @@ type WidgetShellProps = {
}

export function WidgetShell({ name }: WidgetShellProps) {
const workspaceId = useWorkspaceId()
const widget = useWidget(name)

return (
Expand All @@ -23,7 +25,12 @@ export function WidgetShell({ name }: WidgetShellProps) {
exit={{ opacity: 0, filter: 'blur(4px)' }}
transition={{ duration: 0.45, ease: 'easeInOut' }}
>
<WidgetErrorBoundary name={name} resetKey={widget.version}>
<WidgetErrorBoundary
name={name}
kind="widget"
workspaceId={workspaceId}
resetKey={widget.version}
>
<AppletMount segment="widgets" name={name} version={widget.version}>
<widget.Component />
</AppletMount>
Expand Down
46 changes: 46 additions & 0 deletions client/features/applets/applet-log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'bun:test'

import { matchBundleUrl } from './applet-log'

const WS = '17c35e98-f9a0-4cc3-bb71-cd8c279edb9b'

describe('matchBundleUrl', () => {
test('matches a bare bundle URL (ErrorEvent.filename)', () => {
expect(
matchBundleUrl(`http://localhost:13337/api/workspaces/${WS}/widgets/hello/index.js`)
).toEqual({ workspaceId: WS, kind: 'widget', name: 'hello' })
})

test('matches a cache-busted view chunk URL', () => {
expect(
matchBundleUrl(`http://localhost:13337/api/workspaces/${WS}/views/crm/chunk-ab12.js?v=3`)
).toEqual({ workspaceId: WS, kind: 'view', name: 'crm' })
})

test('matches inside a Chrome-style stack frame (parenthesized URL)', () => {
const stack = [
'TypeError: boom',
` at onClick (http://localhost:13337/api/workspaces/${WS}/widgets/rps-chart/index.js:10:5)`,
' at invokeGuardedCallback (http://localhost:13337/vendor/react/react-dom.js:4:2)'
].join('\n')
expect(matchBundleUrl(stack)).toEqual({ workspaceId: WS, kind: 'widget', name: 'rps-chart' })
})

test('matches inside a Firefox-style stack frame (fn@url)', () => {
const stack = `onClick@http://localhost:13337/api/workspaces/${WS}/views/crm/index.js:10:5`
expect(matchBundleUrl(stack)).toEqual({ workspaceId: WS, kind: 'view', name: 'crm' })
})

test('does not match host-app or vendor URLs', () => {
expect(matchBundleUrl('at fn (http://localhost:13337/vendor/react/react.js:1:1)')).toBeNull()
expect(
matchBundleUrl(`at fn (http://localhost:13337/api/workspaces/${WS}/rpc/widgets/hello/f)`)
).toBeNull()
})

test('does not match empty or stack-less input', () => {
expect(matchBundleUrl(undefined)).toBeNull()
expect(matchBundleUrl('')).toBeNull()
expect(matchBundleUrl('TypeError: boom')).toBeNull()
})
})
100 changes: 100 additions & 0 deletions client/features/applets/applet-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { AppletClientError, AppletKind } from '@/lib/types'

// Fire-and-forget reporter for browser-side applet errors — the client half of
// the applet error journal (docs/self-correction.md). Failures only the user's
// tab sees (module load failures, render crashes, window errors attributed to
// a bundle) are POSTed into the workspace journal so `moi debug logs` can show
// them to the agent. Always on: when the user says "it's broken", the crash
// their tab saw five minutes ago is already on record.
//
// Reporting must never hurt the app: fetch errors are swallowed, and two
// throttles bound the network chatter. The per-error cooldown collapses a
// crash loop of the SAME error into one POST per window; the global cap
// bounds the total regardless of message — an error whose text varies every
// occurrence (timestamps, ids) would defeat key-based cooldown and server
// dedup alike, and a tight throw loop must not become a POST-per-frame storm.
// Drops are silent: the journal is best-effort diagnostics, not telemetry.
const COOLDOWN_MS = 5_000
const lastSent = new Map<string, number>()

const RATE_WINDOW_MS = 60_000
const RATE_MAX_PER_WINDOW = 30
let rateWindowStart = 0
let rateCount = 0

export function reportAppletError(workspaceId: string, event: AppletClientError): void {
const now = Date.now()

const key = [workspaceId, event.source, event.kind, event.name, event.message].join('\0')
const last = lastSent.get(key)
if (last !== undefined && now - last < COOLDOWN_MS) return

if (now - rateWindowStart >= RATE_WINDOW_MS) {
rateWindowStart = now
rateCount = 0
}
if (rateCount >= RATE_MAX_PER_WINDOW) return
rateCount++

lastSent.set(key, now)
// Bound the cooldown map: drop the oldest half if it ever balloons.
if (lastSent.size > 256) {
for (const k of [...lastSent.keys()].slice(0, 128)) lastSent.delete(k)
}
void fetch(`/api/workspaces/${workspaceId}/applet-log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events: [event] })
}).catch(() => {})
}

// Attribute a window-level error to an applet by a bundle URL: applet bundles
// load from `/api/workspaces/<id>/(widgets|views)/<name>/…`, so any reference
// to one pins the error to that applet — and carries the workspace id along.
// Host-app URLs never match; their errors are not the journal's business.
// Matched against ErrorEvent.filename (a bare URL) and stack-trace text, where
// segments stop at `/`, whitespace, or `)` because stack frame formats differ
// per browser (Chrome parenthesizes URLs, Firefox uses `fn@url`).
const BUNDLE_URL_RE = /\/api\/workspaces\/([^/\s)]+)\/(widgets|views)\/([^/\s)]+)\//

type BundleRef = { workspaceId: string; kind: AppletKind; name: string }

// Exported for tests.
export function matchBundleUrl(text: string | undefined): BundleRef | null {
if (!text) return null
const m = BUNDLE_URL_RE.exec(text)
if (!m) return null
return { workspaceId: m[1], kind: m[2] === 'widgets' ? 'widget' : 'view', name: m[3] }
}

let installed = false

// Catch errors that escape React entirely — event handlers, async effects,
// unawaited promises — which no error boundary sees. Installed once at startup.
export function installAppletErrorHook(): void {
if (installed) return
installed = true

const report = (ref: BundleRef | null, raw: unknown, fallbackMessage?: string) => {
const err = raw instanceof Error ? raw : null
const stack = err?.stack ?? ''
const hit = ref ?? matchBundleUrl(stack)
if (!hit) return
reportAppletError(hit.workspaceId, {
source: 'window',
kind: hit.kind,
name: hit.name,
message: err?.message || fallbackMessage || String(raw),
...(stack ? { stack } : {})
})
}

// Fast path for 'error' events: ErrorEvent.filename is the structured URL of
// the script whose top frame threw — no stack-text parsing, no per-browser
// format quirks, and it works even for throws with no usable stack
// (`throw 'string'`). The stack scan remains the fallback (e.g. an applet
// handler that threw inside vendor code, where filename names the vendor
// chunk) and the only path for promise rejections, which carry no filename.
window.addEventListener('error', e => report(matchBundleUrl(e.filename), e.error, e.message))
window.addEventListener('unhandledrejection', e => report(null, e.reason))
}
28 changes: 21 additions & 7 deletions client/features/applets/useApplet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import {
invalidateApplet,
setCachedApplet
} from '@/client/features/applets/applet-cache'
import { reportAppletError } from '@/client/features/applets/applet-log'
import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext'
import { type WorkspaceEvent, useWorkspaceEvent } from '@/client/runtime/useWorkspaceEvents'
import type { AppletKind } from '@/lib/types'

type AppletState =
| { status: 'loading'; version: number }
Expand All @@ -21,19 +23,22 @@ type AppletState =
// MEI events tell it to reload. Everything else — dynamic import, caching,
// cache-busting — is shared. An applet is an agent-authored UI unit loaded as an
// ESM module and mounted into the workspace.
type AppletKind = {
type AppletKindSpec = {
kind: AppletKind
segment: AppletSegment
// True when this MEI event means the named applet should cache-bust + reload.
shouldReload: (event: WorkspaceEvent, name: string) => boolean
}

const WIDGET_KIND: AppletKind = {
const WIDGET_KIND: AppletKindSpec = {
kind: 'widget',
segment: 'widgets',
shouldReload: (e, name) =>
(e.type === 'widget:updated' && e.name === name) || e.type === 'widgets:refresh'
}

const VIEW_KIND: AppletKind = {
const VIEW_KIND: AppletKindSpec = {
kind: 'view',
segment: 'views',
shouldReload: (e, name) => e.type === 'view:updated' && e.name === name
}
Expand All @@ -59,7 +64,7 @@ function loadApplet(
return promise
}

function useApplet(kind: AppletKind, name: string): AppletState {
function useApplet(kind: AppletKindSpec, name: string): AppletState {
const workspaceId = useWorkspaceId()
const [state, setState] = useState<AppletState>({ status: 'loading', version: 0 })

Expand All @@ -69,10 +74,19 @@ function useApplet(kind: AppletKind, name: string): AppletState {
.then(Component =>
setState(prev => ({ status: 'ready', Component, version: prev.version + 1 }))
)
.catch(err =>
.catch(err => {
// Journal the load failure so `moi debug logs` can surface it to the
// agent — otherwise only this tab ever sees it (docs/self-correction.md).
reportAppletError(workspaceId, {
source: 'load',
kind: kind.kind,
name,
message: String(err),
...(err instanceof Error && err.stack ? { stack: err.stack } : {})
})
setState(prev => ({ status: 'error', error: String(err), version: prev.version + 1 }))
)
}, [kind.segment, workspaceId, name])
})
}, [kind, workspaceId, name])

useEffect(() => {
load()
Expand Down
9 changes: 8 additions & 1 deletion client/features/workspace/WorkspaceScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { ViewBuilderTab } from '@/client/features/views/ViewBuilderTab'
import { useViewBuilderActions } from '@/client/features/views/useViewBuilderActions'
import { useFitsSplitLayout } from '@/client/features/workspace/useFitsSplitLayout'
import { useWorkspaceTheme } from '@/client/features/workspace/useWorkspaceTheme'
import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext'
import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext'
import { resolveAppIcon } from '@/client/lib/app-icon-registry'
import { cn } from '@/client/lib/cn'
Expand Down Expand Up @@ -100,6 +101,7 @@ type ViewAppProps = {
// layout + scroll, so we give it a plain filled box and fade it in (mirroring
// WidgetShell, but full-area instead of a grid cell).
function ViewApp({ view }: ViewAppProps) {
const workspaceId = useWorkspaceId()
const bundle = useView(view.id)

return (
Expand All @@ -120,7 +122,12 @@ function ViewApp({ view }: ViewAppProps) {
exit={{ opacity: 0, filter: 'blur(4px)' }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<WidgetErrorBoundary name={view.id} resetKey={bundle.version}>
<WidgetErrorBoundary
name={view.id}
kind="view"
workspaceId={workspaceId}
resetKey={bundle.version}
>
<bundle.Component />
</WidgetErrorBoundary>
</motion.div>
Expand Down
6 changes: 6 additions & 0 deletions client/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Agentation } from 'agentation'
import { createRoot } from 'react-dom/client'
import { Router } from 'wouter'

import { installAppletErrorHook } from '@/client/features/applets/applet-log'
import { initConnection } from '@/client/features/chat/chat-connection'
import { AppRouter } from './app/AppRouter'

Expand All @@ -12,6 +13,11 @@ const queryClient = new QueryClient()
// live frames fold into the RQ transcript cache. Lives for the page's lifetime.
initConnection(queryClient)

// Catch applet errors that escape React (handlers, async effects) and journal
// them for `moi debug logs` — attribution is by bundle URL in the stack, so
// host-app errors never match (see features/applets/applet-log.ts).
installAppletErrorHook()

export function mount(el: HTMLElement) {
function Root() {
return (
Expand Down
Loading