@sero-ai/app-runtime 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sero-ai/app-runtime",
3
- "version": "0.1.0",
4
- "description": "Hooks for Sero federated app modules — useAppState, useAppInfo, useAgentPrompt",
3
+ "version": "0.1.1",
4
+ "description": "Hooks for Sero federated app modules — useAppState, useAppInfo, useAgentPrompt, useAppTools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
@@ -10,7 +10,8 @@
10
10
  "./package.json": "./package.json"
11
11
  },
12
12
  "files": [
13
- "src"
13
+ "src",
14
+ "README.md"
14
15
  ],
15
16
  "scripts": {
16
17
  "typecheck": "tsc --noEmit"
@@ -22,6 +23,7 @@
22
23
  "react": ">=19.0.0"
23
24
  },
24
25
  "devDependencies": {
26
+ "@sero/common": "^0.1.0",
25
27
  "@types/react": "^19.2.13",
26
28
  "typescript": "^5.9.3"
27
29
  }
package/src/context.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * multiple times (which happens in Vite dev mode with MF).
7
7
  */
8
8
 
9
- import { createContext } from 'react';
9
+ import { createContext, type Context } from 'react';
10
10
 
11
11
  export interface AppContextValue {
12
12
  /** App identifier (e.g. "todo"). */
@@ -29,15 +29,15 @@ export interface AppContextValue {
29
29
  themePresetId?: string;
30
30
  }
31
31
 
32
- const CONTEXT_KEY = '__sero_app_context__';
33
-
34
- // Ensure a single context instance across all module copies
35
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
- const g = globalThis as any;
37
- if (!g[CONTEXT_KEY]) {
38
- g[CONTEXT_KEY] = createContext<AppContextValue | null>(null);
32
+ declare global {
33
+ var __sero_app_context__: Context<AppContextValue | null> | undefined;
39
34
  }
40
35
 
41
- export const AppContext: React.Context<AppContextValue | null> = g[CONTEXT_KEY];
36
+ const appContext = globalThis.__sero_app_context__
37
+ ?? createContext<AppContextValue | null>(null);
38
+
39
+ globalThis.__sero_app_context__ = appContext;
40
+
41
+ export const AppContext: Context<AppContextValue | null> = appContext;
42
42
 
43
43
  export const AppProvider = AppContext.Provider;
package/src/index.ts CHANGED
@@ -10,7 +10,12 @@ export { useAppState } from './use-app-state';
10
10
  export { useAppInfo } from './use-app-info';
11
11
  export { useAgentPrompt } from './use-agent-prompt';
12
12
  export { useAI, type AppAI } from './use-ai';
13
+ export { useAppTools, type AppTools } from './use-app-tools';
13
14
  export { useAvailableModels, type UseAvailableModelsResult } from './use-available-models';
14
15
  export { useTheme, type UseThemeResult } from './use-theme';
15
16
  export { getSeroApi } from './sero-bridge';
16
17
  export type { AppModelInfo, AppModelGroup } from './sero-bridge';
18
+ export type { AppToolContentBlock, AppToolImageContent, AppToolResult, AppToolTextContent } from '@sero/common';
19
+ export { registerWidget, getRuntimeWidgets, onWidgetRegistryChange } from './widget-registry';
20
+ export type { RuntimeWidget } from './widget-registry';
21
+ export { useWidgetRegistration } from './use-widget-registration';
@@ -5,17 +5,24 @@
5
5
  * module declares only the subset app-runtime hooks need, keeping the
6
6
  * package decoupled from the desktop app's types while providing type
7
7
  * safety for all IPC calls.
8
- *
9
- * The single `(window as ...)` cast lives here — every other module
10
- * imports the typed getter.
11
8
  */
12
9
 
13
- export interface SeroAppStateBridge {
14
- read(filePath: string): Promise<unknown>;
15
- write(filePath: string, data: unknown): Promise<void>;
16
- watch(filePath: string): Promise<unknown>;
10
+ import type {
11
+ AppToolResult,
12
+ GitActionResult,
13
+ GitManagerRequest,
14
+ SharedAvailableModelGroup,
15
+ SharedModelInfo,
16
+ WebAppActionResult,
17
+ WebAppRequest,
18
+ } from '@sero/common';
19
+
20
+ export interface SeroWindowAppStateBridge {
21
+ read<TData = unknown>(filePath: string): Promise<TData>;
22
+ write<TData = unknown>(filePath: string, data: TData): Promise<void>;
23
+ watch<TData = unknown>(filePath: string): Promise<TData>;
17
24
  unwatch(filePath: string): Promise<void>;
18
- onChange(cb: (filePath: string, data: unknown) => void): () => void;
25
+ onChange<TData = unknown>(cb: (filePath: string, data: TData) => void): () => void;
19
26
  }
20
27
 
21
28
  export interface SeroAppAgentBridge {
@@ -26,26 +33,20 @@ export interface SeroAppAgentBridge {
26
33
  text: string,
27
34
  onDelta: (delta: string) => void,
28
35
  ): Promise<string>;
36
+ invokeTool?(
37
+ appId: string,
38
+ workspaceId: string,
39
+ toolName: string,
40
+ params: Record<string, unknown>,
41
+ ): Promise<AppToolResult>;
29
42
  }
30
43
 
31
- export interface SeroGitAppActionParams {
32
- action: string;
33
- file?: string;
34
- message?: string;
35
- branch?: string;
36
- hash?: string;
37
- staged?: boolean;
38
- all?: boolean;
39
- stashIndex?: number;
40
- }
41
-
42
- export interface SeroGitAppActionResult {
43
- ok: boolean;
44
- message: string;
44
+ export interface SeroGitAppBridge {
45
+ run(workspaceId: string, params: GitManagerRequest): Promise<GitActionResult>;
45
46
  }
46
47
 
47
- export interface SeroGitAppBridge {
48
- run(workspaceId: string, params: SeroGitAppActionParams): Promise<SeroGitAppActionResult>;
48
+ export interface SeroWebAppBridge {
49
+ run(workspaceId: string, params: WebAppRequest): Promise<WebAppActionResult>;
49
50
  }
50
51
 
51
52
  export interface SeroEditorExecResult {
@@ -61,39 +62,41 @@ export interface SeroEditorBridge {
61
62
  // ── Model types (subset of desktop's ipc types) ──────────────
62
63
 
63
64
  /** Serialisable model info for app modules. */
64
- export interface AppModelInfo {
65
- provider: string;
66
- modelId: string;
67
- name: string;
68
- reasoning: boolean;
69
- }
65
+ export type AppModelInfo = SharedModelInfo;
70
66
 
71
67
  /** A group of models under a single provider. */
72
- export interface AppModelGroup {
73
- provider: string;
74
- displayName: string;
75
- logo: string;
76
- models: AppModelInfo[];
77
- }
68
+ export type AppModelGroup = SharedAvailableModelGroup<AppModelInfo>;
78
69
 
79
70
  export interface SeroModelsBridge {
80
71
  list(): Promise<AppModelGroup[]>;
81
72
  }
82
73
 
83
74
  export interface SeroBridge {
84
- appState: SeroAppStateBridge;
75
+ appState: SeroWindowAppStateBridge;
85
76
  appAgent: SeroAppAgentBridge;
86
77
  gitApp?: SeroGitAppBridge;
78
+ webApp?: SeroWebAppBridge;
87
79
  editor?: SeroEditorBridge;
88
80
  models?: SeroModelsBridge;
89
81
  }
90
82
 
83
+ function readWindowSero(value: Window): unknown {
84
+ return Reflect.get(value, 'sero');
85
+ }
86
+
87
+ function isSeroBridge(value: unknown): value is SeroBridge {
88
+ return typeof value === 'object'
89
+ && value !== null
90
+ && 'appState' in value
91
+ && 'appAgent' in value;
92
+ }
93
+
91
94
  /**
92
95
  * Get the Sero preload bridge. Throws if not running inside the Sero shell.
93
96
  */
94
97
  export function getSeroApi(): SeroBridge {
95
- const sero = (window as unknown as { sero?: SeroBridge }).sero;
96
- if (!sero) {
98
+ const sero = readWindowSero(window);
99
+ if (!isSeroBridge(sero)) {
97
100
  throw new Error('[app-runtime] window.sero not available — must run inside Sero shell');
98
101
  }
99
102
  return sero;
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { useState, useEffect, useCallback, useContext, useRef } from 'react';
10
10
  import { AppContext } from './context';
11
- import { getSeroApi } from './sero-bridge';
11
+ import { getSeroApi, type SeroWindowAppStateBridge } from './sero-bridge';
12
12
 
13
13
  /**
14
14
  * File-backed reactive state hook.
@@ -24,55 +24,78 @@ export function useAppState<T>(defaultState: T): [T, (updater: (prev: T) => T) =
24
24
 
25
25
  const { stateFilePath } = ctx;
26
26
  const [state, setState] = useState<T>(defaultState);
27
+ const defaultStateRef = useRef<T>(defaultState);
27
28
  const stateRef = useRef<T>(defaultState);
29
+ const latestWriteIdRef = useRef(0);
28
30
 
29
- // Keep ref in sync for the updater closure
31
+ defaultStateRef.current = defaultState;
30
32
  stateRef.current = state;
31
33
 
32
- // Initial read + start watching
34
+ const applyState = useCallback((nextState: T) => {
35
+ stateRef.current = nextState;
36
+ setState(nextState);
37
+ }, []);
38
+
39
+ const recoverFromWriteFailure = useCallback(
40
+ async (api: SeroWindowAppStateBridge, writeId: number, fallbackState: T) => {
41
+ if (writeId !== latestWriteIdRef.current) return;
42
+
43
+ try {
44
+ const current = await api.read<T | null>(stateFilePath);
45
+ if (writeId !== latestWriteIdRef.current) return;
46
+ applyState(current ?? fallbackState);
47
+ } catch {
48
+ if (writeId !== latestWriteIdRef.current) return;
49
+ applyState(fallbackState);
50
+ }
51
+ },
52
+ [applyState, stateFilePath],
53
+ );
54
+
33
55
  useEffect(() => {
34
56
  const api = getSeroApi();
35
- let unsubChange: (() => void) | null = null;
57
+ let isActive = true;
36
58
 
37
- stateRef.current = defaultState;
38
- setState(defaultState);
59
+ const applyIfActive = (nextState: T) => {
60
+ if (!isActive) return;
61
+ applyState(nextState);
62
+ };
39
63
 
40
- // Subscribe to file changes from main process
41
- unsubChange = api.appState.onChange((fp: string, data: unknown) => {
42
- if (fp === stateFilePath && data != null) {
43
- const parsed = data as T;
44
- stateRef.current = parsed;
45
- setState(parsed);
46
- }
64
+ applyState(defaultStateRef.current);
65
+
66
+ const unsubscribe = api.appState.onChange<T | null>((filePath, data) => {
67
+ if (filePath !== stateFilePath || data == null) return;
68
+ applyIfActive(data);
47
69
  });
48
70
 
49
- // Start watching (also returns current state)
50
- api.appState.watch(stateFilePath).then((current) => {
51
- if (current != null) {
52
- const parsed = current as T;
53
- stateRef.current = parsed;
54
- setState(parsed);
55
- }
71
+ void api.appState.watch<T | null>(stateFilePath).then((current) => {
72
+ if (current == null) return;
73
+ applyIfActive(current);
56
74
  });
57
75
 
58
76
  return () => {
59
- unsubChange?.();
60
- api.appState.unwatch(stateFilePath);
77
+ isActive = false;
78
+ unsubscribe();
79
+ void api.appState.unwatch(stateFilePath);
61
80
  };
62
- }, [stateFilePath]);
81
+ }, [applyState, stateFilePath]);
63
82
 
64
- // Write updater
65
83
  const updateState = useCallback(
66
84
  (updater: (prev: T) => T) => {
67
- const next = updater(stateRef.current);
68
- stateRef.current = next;
69
- setState(next);
85
+ const previous = stateRef.current;
86
+ const next = updater(previous);
87
+ const writeId = latestWriteIdRef.current + 1;
88
+ latestWriteIdRef.current = writeId;
89
+ applyState(next);
70
90
 
71
- // Persist to disk (async, fire-and-forget — watcher will confirm)
72
91
  const api = getSeroApi();
73
- api.appState.write(stateFilePath, next);
92
+ void api.appState.write(stateFilePath, next).catch((error: unknown) => {
93
+ if (writeId !== latestWriteIdRef.current) return;
94
+ console.warn(`[app-runtime] Failed to persist app state for ${stateFilePath}`, error);
95
+ void recoverFromWriteFailure(api.appState, writeId, previous);
96
+ });
74
97
  },
75
- [stateFilePath],
98
+ [applyState, recoverFromWriteFailure, stateFilePath],
76
99
  );
77
100
 
78
101
  return [state, updateState];
@@ -0,0 +1,31 @@
1
+ import type { AppToolResult } from '@sero/common';
2
+ import { useCallback, useContext, useMemo } from 'react';
3
+
4
+ import { AppContext } from './context';
5
+ import { getSeroApi } from './sero-bridge';
6
+
7
+ export interface AppTools {
8
+ run(toolName: string, params?: Record<string, unknown>): Promise<AppToolResult>;
9
+ }
10
+
11
+ export function useAppTools(): AppTools {
12
+ const ctx = useContext(AppContext);
13
+
14
+ const run = useCallback<AppTools['run']>(
15
+ async (toolName, params): Promise<AppToolResult> => {
16
+ if (!ctx?.appId || !ctx?.workspaceId) {
17
+ throw new Error('[useAppTools] No app context — must be used inside a Sero app');
18
+ }
19
+
20
+ const { appAgent } = getSeroApi();
21
+ if (!appAgent.invokeTool) {
22
+ throw new Error('[useAppTools] App tool bridge unavailable');
23
+ }
24
+
25
+ return appAgent.invokeTool(ctx.appId, ctx.workspaceId, toolName, params ?? {});
26
+ },
27
+ [ctx],
28
+ );
29
+
30
+ return useMemo(() => ({ run }), [run]);
31
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * useWidgetRegistration — hook for apps to register widgets at runtime.
3
+ *
4
+ * Registers a widget component for the current renderer session.
5
+ * This complements static manifest declarations in package.json.
6
+ *
7
+ * Usage:
8
+ * ```tsx
9
+ * import { useWidgetRegistration } from '@sero-ai/app-runtime';
10
+ * import { MyWidget } from './widgets/MyWidget';
11
+ *
12
+ * export function MyApp() {
13
+ * useWidgetRegistration({
14
+ * widgetId: 'summary',
15
+ * name: 'Summary',
16
+ * component: MyWidget,
17
+ * defaultSize: { w: 2, h: 2 },
18
+ * });
19
+ * // ...
20
+ * }
21
+ * ```
22
+ */
23
+
24
+ import { useContext, useEffect } from 'react';
25
+ import type { ComponentType } from 'react';
26
+ import { AppContext } from './context';
27
+ import { registerWidget } from './widget-registry';
28
+
29
+ interface WidgetRegistrationOptions {
30
+ /** Unique widget identifier within the app. */
31
+ widgetId: string;
32
+ /** Display name. */
33
+ name: string;
34
+ /** The React component to render. */
35
+ component: ComponentType;
36
+ /** Default grid size. */
37
+ defaultSize: { w: number; h: number };
38
+ /** Minimum grid size. */
39
+ minSize?: { w: number; h: number };
40
+ /** Maximum grid size. */
41
+ maxSize?: { w: number; h: number };
42
+ /** Optional description. */
43
+ description?: string;
44
+ }
45
+
46
+ /**
47
+ * Register a widget component for the current app.
48
+ *
49
+ * Registration is intentionally sticky for the renderer session so a
50
+ * dashboard widget can keep rendering after the full app view unmounts.
51
+ */
52
+ export function useWidgetRegistration(options: WidgetRegistrationOptions): void {
53
+ const ctx = useContext(AppContext);
54
+ const appId = ctx?.appId;
55
+
56
+ // Acceptable useEffect — registration into a shared external registry
57
+ useEffect(() => {
58
+ if (!appId) return;
59
+
60
+ registerWidget({
61
+ appId,
62
+ widgetId: options.widgetId,
63
+ name: options.name,
64
+ component: options.component,
65
+ defaultSize: options.defaultSize,
66
+ minSize: options.minSize,
67
+ maxSize: options.maxSize,
68
+ description: options.description,
69
+ });
70
+ }, [
71
+ appId,
72
+ options.widgetId,
73
+ options.name,
74
+ options.component,
75
+ options.defaultSize.w,
76
+ options.defaultSize.h,
77
+ options.minSize?.w,
78
+ options.minSize?.h,
79
+ options.maxSize?.w,
80
+ options.maxSize?.h,
81
+ options.description,
82
+ ]);
83
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Widget registry — runtime registration of widgets from app modules.
3
+ *
4
+ * Apps can register widget components dynamically (in addition to static
5
+ * manifest declarations). The host dashboard subscribes to changes and
6
+ * renders registered widgets.
7
+ *
8
+ * Uses a globalThis singleton to ensure a single registry across all
9
+ * module federation copies, matching the AppContext pattern.
10
+ */
11
+
12
+ import type { ComponentType } from 'react';
13
+
14
+ /** A runtime-registered widget definition. */
15
+ export interface RuntimeWidget {
16
+ /** App ID that owns this widget. */
17
+ appId: string;
18
+ /** Unique widget identifier within the app. */
19
+ widgetId: string;
20
+ /** Display name. */
21
+ name: string;
22
+ /** The React component to render. */
23
+ component: ComponentType;
24
+ /** Default grid size (react-grid-layout units). */
25
+ defaultSize: { w: number; h: number };
26
+ /** Minimum grid size. */
27
+ minSize?: { w: number; h: number };
28
+ /** Maximum grid size. */
29
+ maxSize?: { w: number; h: number };
30
+ /** Optional description. */
31
+ description?: string;
32
+ }
33
+
34
+ type WidgetChangeListener = () => void;
35
+
36
+ interface WidgetRegistryState {
37
+ widgets: Map<string, RuntimeWidget>;
38
+ snapshot: RuntimeWidget[];
39
+ listeners: Set<WidgetChangeListener>;
40
+ }
41
+
42
+ declare global {
43
+ var __sero_widget_registry__: WidgetRegistryState | undefined;
44
+ }
45
+
46
+ function createRegistryState(): WidgetRegistryState {
47
+ return {
48
+ widgets: new Map<string, RuntimeWidget>(),
49
+ snapshot: [],
50
+ listeners: new Set<WidgetChangeListener>(),
51
+ };
52
+ }
53
+
54
+ function getRegistry(): WidgetRegistryState {
55
+ if (!globalThis.__sero_widget_registry__) {
56
+ globalThis.__sero_widget_registry__ = createRegistryState();
57
+ }
58
+ return globalThis.__sero_widget_registry__;
59
+ }
60
+
61
+ function makeKey(appId: string, widgetId: string): string {
62
+ return `${appId}:${widgetId}`;
63
+ }
64
+
65
+ function publishSnapshot(registry: WidgetRegistryState): void {
66
+ registry.snapshot = Array.from(registry.widgets.values());
67
+ registry.listeners.forEach((fn) => fn());
68
+ }
69
+
70
+ function sameGridSize(
71
+ left: RuntimeWidget['defaultSize'] | RuntimeWidget['minSize'] | RuntimeWidget['maxSize'],
72
+ right: RuntimeWidget['defaultSize'] | RuntimeWidget['minSize'] | RuntimeWidget['maxSize'],
73
+ ): boolean {
74
+ if (!left && !right) return true;
75
+ if (!left || !right) return false;
76
+ return left.w === right.w && left.h === right.h;
77
+ }
78
+
79
+ function sameWidget(existing: RuntimeWidget | undefined, next: RuntimeWidget): boolean {
80
+ if (!existing) return false;
81
+ return existing.appId === next.appId
82
+ && existing.widgetId === next.widgetId
83
+ && existing.name === next.name
84
+ && existing.component === next.component
85
+ && sameGridSize(existing.defaultSize, next.defaultSize)
86
+ && sameGridSize(existing.minSize, next.minSize)
87
+ && sameGridSize(existing.maxSize, next.maxSize)
88
+ && existing.description === next.description;
89
+ }
90
+
91
+ /**
92
+ * Register a widget component at runtime. Call from your app's root
93
+ * component or module initialisation.
94
+ *
95
+ * @returns An unregister function.
96
+ */
97
+ export function registerWidget(widget: RuntimeWidget): () => void {
98
+ const registry = getRegistry();
99
+ const key = makeKey(widget.appId, widget.widgetId);
100
+ const existing = registry.widgets.get(key);
101
+ if (!sameWidget(existing, widget)) {
102
+ registry.widgets.set(key, widget);
103
+ publishSnapshot(registry);
104
+ }
105
+
106
+ return () => {
107
+ registry.widgets.delete(key);
108
+ publishSnapshot(registry);
109
+ };
110
+ }
111
+
112
+ /** Get all runtime-registered widgets. */
113
+ export function getRuntimeWidgets(): RuntimeWidget[] {
114
+ return getRegistry().snapshot;
115
+ }
116
+
117
+ /** Subscribe to widget registration changes. Returns an unsubscribe function. */
118
+ export function onWidgetRegistryChange(listener: WidgetChangeListener): () => void {
119
+ const registry = getRegistry();
120
+ registry.listeners.add(listener);
121
+ return () => {
122
+ registry.listeners.delete(listener);
123
+ };
124
+ }