@xemahq/ui-kernel 0.2.0 → 0.4.0

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.
Files changed (54) hide show
  1. package/dist/lib/biome-host/composition-validation.d.ts +22 -0
  2. package/dist/lib/biome-host/composition-validation.d.ts.map +1 -0
  3. package/dist/lib/biome-host/composition-validation.js +127 -0
  4. package/dist/lib/biome-host/composition-validation.js.map +1 -0
  5. package/dist/lib/biome-host/define-web-biome.d.ts +30 -0
  6. package/dist/lib/biome-host/define-web-biome.d.ts.map +1 -0
  7. package/dist/lib/biome-host/define-web-biome.js +42 -0
  8. package/dist/lib/biome-host/define-web-biome.js.map +1 -0
  9. package/dist/lib/biome-host/errors.d.ts +2 -0
  10. package/dist/lib/biome-host/errors.d.ts.map +1 -0
  11. package/dist/lib/biome-host/errors.js +146 -0
  12. package/dist/lib/biome-host/errors.js.map +1 -0
  13. package/dist/lib/biome-host/frontend-biome.d.ts +1 -0
  14. package/dist/lib/biome-host/frontend-biome.d.ts.map +1 -1
  15. package/dist/lib/biome-host/host-bridge.d.ts +6 -0
  16. package/dist/lib/biome-host/host-bridge.d.ts.map +1 -1
  17. package/dist/lib/biome-host/host-bridge.js.map +1 -1
  18. package/dist/lib/biome-host/index.d.ts +5 -0
  19. package/dist/lib/biome-host/index.d.ts.map +1 -1
  20. package/dist/lib/biome-host/index.js +5 -0
  21. package/dist/lib/biome-host/index.js.map +1 -1
  22. package/dist/lib/biome-host/realtime-hooks.d.ts +5 -0
  23. package/dist/lib/biome-host/realtime-hooks.d.ts.map +1 -0
  24. package/dist/lib/biome-host/realtime-hooks.js +28 -0
  25. package/dist/lib/biome-host/realtime-hooks.js.map +1 -0
  26. package/dist/lib/biome-host/realtime-port.d.ts +30 -0
  27. package/dist/lib/biome-host/realtime-port.d.ts.map +1 -0
  28. package/dist/lib/biome-host/realtime-port.js +3 -0
  29. package/dist/lib/biome-host/realtime-port.js.map +1 -0
  30. package/dist/lib/biome-host/response-envelope.d.ts +3 -0
  31. package/dist/lib/biome-host/response-envelope.d.ts.map +1 -0
  32. package/dist/lib/biome-host/response-envelope.js +25 -0
  33. package/dist/lib/biome-host/response-envelope.js.map +1 -0
  34. package/dist/registry/lib/composition-validation-host.d.ts +3 -0
  35. package/dist/registry/lib/composition-validation-host.d.ts.map +1 -0
  36. package/dist/registry/lib/composition-validation-host.js +10 -0
  37. package/dist/registry/lib/composition-validation-host.js.map +1 -0
  38. package/dist/session/shell/SessionWorkspaceShell.d.ts.map +1 -1
  39. package/dist/session/shell/SessionWorkspaceShell.js +9 -6
  40. package/dist/session/shell/SessionWorkspaceShell.js.map +1 -1
  41. package/dist/ui/chrome/ErrorCard.d.ts.map +1 -1
  42. package/dist/ui/chrome/ErrorCard.js +2 -9
  43. package/dist/ui/chrome/ErrorCard.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/lib/biome-host/define-web-biome.ts +161 -0
  46. package/src/lib/biome-host/errors.ts +220 -0
  47. package/src/lib/biome-host/frontend-biome.ts +20 -2
  48. package/src/lib/biome-host/host-bridge.ts +44 -0
  49. package/src/lib/biome-host/index.ts +5 -0
  50. package/src/lib/biome-host/realtime-hooks.ts +74 -0
  51. package/src/lib/biome-host/realtime-port.ts +109 -0
  52. package/src/lib/biome-host/response-envelope.ts +69 -0
  53. package/src/session/shell/SessionWorkspaceShell.tsx +19 -13
  54. package/src/ui/chrome/ErrorCard.tsx +8 -13
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Kernel-side realtime hooks. Biomes call these INSTEAD of importing the host's
3
+ * realtime client (`@xemahq/realtime-client`) directly, so biome code stays
4
+ * decoupled from the SSE transport — the same decoupling `useHostBridge`
5
+ * already provides for navigation / auth / capabilities.
6
+ *
7
+ * Each hook reads the host-injected {@link RealtimeSource} off
8
+ * `bridge.realtime` and delegates straight through. The host owns the transport
9
+ * (connection, token refresh, org header, scope refcounting); the kernel owns
10
+ * only the contract + this thin delegation.
11
+ */
12
+
13
+ import { useHostBridge } from './host-bridge';
14
+ import type {
15
+ RealtimeCloudEvent,
16
+ RealtimeConnectionState,
17
+ RealtimeScope,
18
+ RealtimeSource,
19
+ } from './realtime-port';
20
+
21
+ /**
22
+ * Resolve the host-injected realtime transport, failing fast with an actionable
23
+ * message if the host has not wired `bridge.realtime`. `bridge.realtime` is an
24
+ * optional port (a host without a realtime transport omits it), so the kernel
25
+ * hooks must not silently no-op — a biome that calls them expects live events.
26
+ */
27
+ function requireRealtime(realtime: RealtimeSource | undefined): RealtimeSource {
28
+ if (!realtime) {
29
+ throw new Error(
30
+ 'useCloudEvent/useRealtimeStatus/useEventScope require a realtime ' +
31
+ 'transport, but the host has not wired `bridge.realtime`. The host ' +
32
+ 'must provide a RealtimeSource (e.g. backed by @xemahq/realtime-client) ' +
33
+ 'on the HostBridge to use kernel realtime hooks.',
34
+ );
35
+ }
36
+ return realtime;
37
+ }
38
+
39
+ /**
40
+ * Subscribe to delivered CloudEvents of a given `type` for the lifetime of the
41
+ * calling component. Pass a stable `handler` (e.g. via `useCallback`) — the
42
+ * host re-subscribes when the handler identity changes. Narrow `event.data`
43
+ * inside the handler; the kernel does not parse payloads.
44
+ *
45
+ * @example
46
+ * useCloudEvent('session.lifecycle.changed', (event) => {
47
+ * const data = event.data as { sessionId?: string } | undefined;
48
+ * if (data?.sessionId !== sessionId) return;
49
+ * refetch();
50
+ * });
51
+ */
52
+ export function useCloudEvent(
53
+ eventType: string,
54
+ handler: (event: RealtimeCloudEvent) => void,
55
+ ): void {
56
+ const bridge = useHostBridge();
57
+ requireRealtime(bridge.realtime).useCloudEvent(eventType, handler);
58
+ }
59
+
60
+ /** Coarse realtime connection state for a status indicator. */
61
+ export function useRealtimeStatus(): RealtimeConnectionState {
62
+ const bridge = useHostBridge();
63
+ return requireRealtime(bridge.realtime).useRealtimeStatus();
64
+ }
65
+
66
+ /**
67
+ * Subscribe the connection to a project/session {@link RealtimeScope} while the
68
+ * calling component is mounted. Scopes are reference-counted by the host, so
69
+ * multiple components asking for the same scope share one server subscription.
70
+ */
71
+ export function useEventScope(scope: RealtimeScope): void {
72
+ const bridge = useHostBridge();
73
+ requireRealtime(bridge.realtime).useEventScope(scope);
74
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Host-agnostic realtime (CloudEvents over SSE) port.
3
+ *
4
+ * Frontend biomes need to subscribe to server-pushed CloudEvents (session
5
+ * lifecycle, tool calls, notifications, …). The transport — the unified SSE
6
+ * connection, the Keycloak-minted bearer, the active-org header, cross-tab
7
+ * leader election, server-side scope subscriptions — is HOST-owned (today
8
+ * `@xemahq/realtime-client` wrapped by the host's `RealtimeProvider`). Biomes
9
+ * must NOT import that client or `EventSource` directly, exactly as they must
10
+ * not import `next/navigation` or `sonner`.
11
+ *
12
+ * Instead the host injects a concrete {@link RealtimeSource} when it builds the
13
+ * {@link HostBridge}; it is hung off `bridge.realtime`. Biomes reach it through
14
+ * the {@link useCloudEvent} / {@link useRealtimeStatus} / {@link useEventScope}
15
+ * kernel hooks — the same decoupling pattern as `bridge.navigation` /
16
+ * `bridge.capabilities`.
17
+ *
18
+ * Framework-agnostic: pure interface contracts, no React, no SSE, no fetch.
19
+ */
20
+
21
+ /**
22
+ * The subset of the CloudEvents-shape envelope a biome handler receives.
23
+ *
24
+ * Locally typed in the kernel (deliberately NOT imported from
25
+ * `@xemahq/realtime-client` / `@xemahq/events`) so `@xemahq/ui-kernel` stays
26
+ * transport-free and host-agnostic. The host's realtime client emits an object
27
+ * that is structurally assignable to this — value-identical to the client's
28
+ * `DeliveredEvent`, kept here as the kernel's stable contract.
29
+ */
30
+ export interface RealtimeCloudEvent {
31
+ /** CloudEvents `id` — unique per delivered event, when the server stamps it. */
32
+ id?: string;
33
+ /** CloudEvents `type`, e.g. `session.lifecycle.changed`. The subscription key. */
34
+ type: string;
35
+ /** CloudEvents `source`, e.g. `/services/activity-feed-api` or `/biomes/<id>`. */
36
+ source?: string;
37
+ /** CloudEvents `subject`, e.g. `session/<session-id>`. */
38
+ subject?: string;
39
+ /** CloudEvents `data` payload. Biomes narrow this themselves. */
40
+ data?: unknown;
41
+ /** Org-id header echoed onto every delivered envelope. */
42
+ ehorgid: string;
43
+ /** Project-id header, when the event is project-scoped. */
44
+ ehprojectid?: string;
45
+ /** User-id header, when the event is user-scoped. */
46
+ ehuserid?: string;
47
+ /** Global delivery cursor. */
48
+ ehglobalseq?: string;
49
+ /** Per-org delivery cursor. */
50
+ ehorgseq?: string;
51
+ /** Per-project delivery cursor. */
52
+ ehprojectseq?: string;
53
+ /** Per-session delivery cursor. */
54
+ ehsessionseq?: string;
55
+ /** Frontend-only invalidation hint carried alongside the envelope. */
56
+ __hint?: unknown;
57
+ }
58
+
59
+ /** Coarse realtime connection status for status indicators. */
60
+ export type RealtimeStatus =
61
+ | 'idle'
62
+ | 'connecting'
63
+ | 'connected'
64
+ | 'reconnecting'
65
+ | 'closed';
66
+
67
+ /** Coarse connection state surfaced to status UIs. */
68
+ export interface RealtimeConnectionState {
69
+ readonly status: RealtimeStatus;
70
+ /** Latest connectionId returned by the upstream `CONNECTED` frame. */
71
+ readonly connectionId?: string;
72
+ }
73
+
74
+ /**
75
+ * A server-side scope a connection can subscribe to so it only receives the
76
+ * events for one project or session, rather than the full org firehose. The
77
+ * host's transport reference-counts identical scopes across mounts.
78
+ */
79
+ export interface RealtimeScope {
80
+ readonly kind: 'project' | 'session';
81
+ readonly id: string;
82
+ }
83
+
84
+ /**
85
+ * Host-implemented realtime source. The host wires each method against its
86
+ * realtime transport (`@xemahq/realtime-client`'s `RealtimeContext`) and passes
87
+ * it in when building the {@link HostBridge}.
88
+ *
89
+ * Every method is a React hook (it reads transport context / state), so it
90
+ * MUST be called from a biome component's render — the kernel `useCloudEvent` /
91
+ * `useRealtimeStatus` / `useEventScope` hooks delegate straight through.
92
+ */
93
+ export interface RealtimeSource {
94
+ /**
95
+ * Subscribe `handler` to every delivered CloudEvent whose `type` matches
96
+ * `eventType`, for the lifetime of the calling component. Per-`data` filtering
97
+ * (e.g. by `sessionId`) is the handler's responsibility. Mirrors the host
98
+ * client's `useCloudEvent`.
99
+ */
100
+ useCloudEvent(eventType: string, handler: (event: RealtimeCloudEvent) => void): void;
101
+ /** Coarse connection state for a status indicator. */
102
+ useRealtimeStatus(): RealtimeConnectionState;
103
+ /**
104
+ * Subscribe the connection's unified SSE to a project/session scope while the
105
+ * calling component is mounted; auto-unsubscribes on unmount. Server-side
106
+ * subscriptions are reference-counted by the host transport.
107
+ */
108
+ useEventScope(scope: RealtimeScope): void;
109
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Frontend mirror of the backend `ResponseEnvelopeInterceptor`.
3
+ *
4
+ * Every 2xx body the platform returns is wrapped as `{ data: T }`, or
5
+ * `{ data: T[], pagination }` for a paginated list (single source of truth:
6
+ * `@xemahq/platform-common`'s `ResponseEnvelopeInterceptor`). Orval's
7
+ * `custom-fetch` mutators usually peel that envelope at the HTTP boundary, but
8
+ * spec drift (a controller typed `T[]` that emits `{ data }`), public clients
9
+ * that DON'T peel, and transient partial responses leave a `.data`-wrapped or
10
+ * mis-shaped value where biome code expects a bare value/array — and a raw
11
+ * `.map` on it white-screens the route.
12
+ *
13
+ * These are the ONE shared, pure unwrap helpers — no host context, no fetch.
14
+ * Biomes (and the host shell's own `unwrap-list.ts`) should adopt these instead
15
+ * of re-implementing the guard. This is the shared helper only; adopters are
16
+ * migrated incrementally, not in a blanket rewrite.
17
+ */
18
+
19
+ /**
20
+ * Resilient list unwrap for DISPLAY surfaces — normalises a React-Query `.data`
21
+ * to an array so the page renders its empty/error state instead of crashing:
22
+ *
23
+ * - already an array → returned as-is
24
+ * - `{ data: T[] }` envelope → `value.data`
25
+ * - anything else → `[]`
26
+ *
27
+ * Deliberately NON-throwing: the query's own `isError`/`isLoading` flags drive
28
+ * the visible error/empty UI; this only guards the render path. For
29
+ * mutation/single-resource flows where an unexpected shape is a real bug, use
30
+ * {@link unwrapData} (fail-fast) instead.
31
+ */
32
+ export function unwrapList<T>(value: unknown): T[] {
33
+ if (Array.isArray(value)) {
34
+ return value as T[];
35
+ }
36
+ if (
37
+ value !== null &&
38
+ typeof value === 'object' &&
39
+ Array.isArray((value as { data?: unknown }).data)
40
+ ) {
41
+ return (value as { data: T[] }).data;
42
+ }
43
+ return [];
44
+ }
45
+
46
+ /**
47
+ * Fail-fast single-resource unwrap. Returns `value.data` when the value is a
48
+ * `{ data }` envelope; returns the value unchanged when it is already the
49
+ * unwrapped resource (an Orval client that peeled the envelope at the boundary).
50
+ *
51
+ * Use this on mutation / single-resource flows where the response SHOULD be a
52
+ * concrete object — unlike {@link unwrapList}, it does not silently coerce, so
53
+ * a genuinely-unexpected shape surfaces as the value rather than being masked.
54
+ *
55
+ * A paginated envelope (`{ data, pagination }`) is returned INTACT — callers
56
+ * that need both fields read them off the returned object; peeling only `data`
57
+ * would drop the pagination cursor.
58
+ */
59
+ export function unwrapData<T>(value: unknown): T {
60
+ if (
61
+ value !== null &&
62
+ typeof value === 'object' &&
63
+ 'data' in (value as Record<string, unknown>) &&
64
+ !('pagination' in (value as Record<string, unknown>))
65
+ ) {
66
+ return (value as { data: T }).data;
67
+ }
68
+ return value as T;
69
+ }
@@ -1,4 +1,4 @@
1
- import type { ReactNode, MouseEvent, RefObject } from 'react';
1
+ import type { CSSProperties, ReactNode, MouseEvent, RefObject } from 'react';
2
2
 
3
3
  import { cn } from '../lib/cn';
4
4
 
@@ -79,28 +79,34 @@ export function SessionWorkspaceShell({
79
79
  // surface (Document Buddy). Hide the right pane + resize handle and
80
80
  // let the chat pane occupy the full width.
81
81
  const hasPreviewSlot = previewSlot !== null;
82
+ // Below `lg` the two panes stack vertically (flex-col) and each takes the
83
+ // full width; the chat pane's fixed width / minWidth split only applies at
84
+ // `lg`+ where the resize handle is available. The width values ride on CSS
85
+ // variables so the inline style never forces a side-by-side split on a
86
+ // phone (Tailwind applies them only at the `lg` breakpoint).
87
+ const leftPaneStyle: CSSProperties | undefined = hasPreviewSlot
88
+ ? ({
89
+ '--sws-min-left': `${minLeftPaneWidth}px`,
90
+ '--sws-chat-pct': `${chatPanePercent}%`,
91
+ } as CSSProperties)
92
+ : undefined;
82
93
  return (
83
94
  <div
84
95
  ref={splitContainerRef}
85
96
  className={cn(
86
- 'relative flex h-full w-full overflow-hidden',
97
+ 'relative flex h-full w-full flex-col overflow-hidden lg:flex-row',
87
98
  isResizing && 'cursor-col-resize',
88
99
  )}
89
100
  >
90
101
  {!previewFullscreen && (
91
102
  <div
92
103
  className={cn(
93
- 'flex h-full w-full flex-col overflow-hidden bg-paper',
94
- hasPreviewSlot ? 'lg:flex-none' : '',
95
- )}
96
- style={
104
+ 'flex w-full min-h-0 flex-col overflow-hidden bg-paper',
97
105
  hasPreviewSlot
98
- ? {
99
- minWidth: `${minLeftPaneWidth}px`,
100
- width: `${chatPanePercent}%`,
101
- }
102
- : undefined
103
- }
106
+ ? 'flex-1 lg:h-full lg:w-[var(--sws-chat-pct)] lg:min-w-[var(--sws-min-left)] lg:flex-none'
107
+ : 'h-full',
108
+ )}
109
+ style={leftPaneStyle}
104
110
  >
105
111
  <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
106
112
  <div className="relative min-h-0 min-w-0 flex-1">{chatSlot}</div>
@@ -131,7 +137,7 @@ export function SessionWorkspaceShell({
131
137
  )}
132
138
 
133
139
  {hasPreviewSlot && (
134
- <div className="flex min-w-0 flex-1 flex-col overflow-hidden bg-paper">
140
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-paper">
135
141
  {rightHeaderSlot}
136
142
  <div className="min-h-0 flex-1 overflow-hidden">{previewSlot}</div>
137
143
  </div>
@@ -1,25 +1,20 @@
1
1
  import { AlertCircle, RefreshCw } from 'lucide-react';
2
2
 
3
+ import { getUserFacingErrorMessage } from '../../lib/biome-host/errors';
3
4
  import { Button } from '../primitives/button';
4
5
  import { Card, CardContent } from '../primitives/card';
5
6
 
6
7
  /**
7
- * Host-agnostic error formatter. The host shell owns rich error decoding
8
- * (Orval envelopes, workflow error codes, …); pass its formatter via
9
- * `formatError` to preserve that behaviour. The default extracts a plain
10
- * message and is safe in any consumer.
8
+ * Host-agnostic error formatter. The default is the canonical, envelope-aware
9
+ * {@link getUserFacingErrorMessage} decoder biomes get rich error copy with
10
+ * zero wiring. Pass `formatError` only to override with a host-specific decoder
11
+ * (extra codes, i18n); the canonical default already handles Orval envelopes,
12
+ * `ApiClientError`, workflow error codes, and "failed to fetch".
11
13
  */
12
14
  export type ErrorMessageFormatter = (error: unknown, fallback: string) => string;
13
15
 
14
- const defaultFormatError: ErrorMessageFormatter = (error, fallback) => {
15
- if (typeof error === 'string') {
16
- return error || fallback;
17
- }
18
- if (error instanceof Error && error.message) {
19
- return error.message;
20
- }
21
- return fallback;
22
- };
16
+ const defaultFormatError: ErrorMessageFormatter = (error, fallback) =>
17
+ getUserFacingErrorMessage(error, fallback);
23
18
 
24
19
  interface ErrorCardProps {
25
20
  error: Error | string | unknown;