@unoverse-platform/studio 0.1.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.
- package/bin/studio.mjs +124 -0
- package/index.html +12 -0
- package/local/catalog.ts +105 -0
- package/local/keys.ts +118 -0
- package/local/nodes.ts +77 -0
- package/local/plugin.ts +401 -0
- package/local/scaffold.d.mts +13 -0
- package/local/scaffold.mjs +85 -0
- package/package.json +43 -0
- package/public/logo.png +0 -0
- package/screens/AtomsView.tsx +206 -0
- package/screens/ComponentsView.tsx +551 -0
- package/screens/ConfigForm.tsx +217 -0
- package/screens/DesignSystemView.tsx +324 -0
- package/screens/IdentityHeader.tsx +88 -0
- package/screens/PromptBlocksView.tsx +137 -0
- package/screens/SkillsView.tsx +167 -0
- package/screens/TemplatesView.tsx +1005 -0
- package/screens/index.ts +26 -0
- package/screens/states.ts +44 -0
- package/src/App.tsx +252 -0
- package/src/ConnectUniverse.tsx +232 -0
- package/src/NewProject.tsx +89 -0
- package/src/NodeKeys.tsx +98 -0
- package/src/NodesView.tsx +443 -0
- package/src/PublishDialog.tsx +309 -0
- package/src/UniverseSession.tsx +121 -0
- package/src/host.ts +165 -0
- package/src/index.css +7 -0
- package/src/main.tsx +11 -0
- package/src/universes.ts +210 -0
- package/vendor/sdk/appEngine.tsx +219 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
- package/vendor/sdk/lib/connection.tsx +302 -0
- package/vendor/sdk/lib/core/actions.ts +84 -0
- package/vendor/sdk/lib/core/client.ts +134 -0
- package/vendor/sdk/lib/core/conditions.ts +43 -0
- package/vendor/sdk/lib/core/connection.ts +142 -0
- package/vendor/sdk/lib/core/index.ts +32 -0
- package/vendor/sdk/lib/core/store.ts +455 -0
- package/vendor/sdk/lib/core/types.ts +194 -0
- package/vendor/sdk/lib/index.ts +59 -0
- package/vendor/sdk/lib/isolate.tsx +70 -0
- package/vendor/sdk/lib/primitives.tsx +453 -0
- package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
- package/vendor/sdk/lib/realtime/types.ts +45 -0
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
- package/vendor/sdk/lib/render.tsx +178 -0
- package/vendor/sdk/lib/streamed.tsx +65 -0
- package/vendor/sdk/lib/style.ts +227 -0
- package/vendor/sdk/lib/template.tsx +521 -0
- package/vendor/sdk/lib/theme.ts +55 -0
- package/vendor/sdk/lib/voice.tsx +311 -0
- package/vendor/sdk/main.tsx +96 -0
- package/vite.config.ts +70 -0
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Templates view — renders a TEMPLATE definition (layout) and fills its
|
|
3
|
+
* ComponentSlots from a mock conversation seeded into the store.
|
|
4
|
+
*
|
|
5
|
+
* This is the visual proof of the slot system: select KeyService → its
|
|
6
|
+
* Card slot fills with a real card; slots whose component type isn't
|
|
7
|
+
* migrated yet (StreamingText, KenBurnsImage) show their Skeleton fallback.
|
|
8
|
+
* Toggle "complete" to flip streamingState; "clear" to see the all-skeleton state.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
11
|
+
import { useAuth } from "react-oidc-context";
|
|
12
|
+
import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
|
|
13
|
+
import { ComponentStore } from "@unoverse/sdk";
|
|
14
|
+
import { StreamedUnoverseTemplate, useUnoverseConnection, computeAppWidth, resolveActiveLayout, type ResolvedTheme, type UseVoiceServiceConfig } from "@unoverse/sdk";
|
|
15
|
+
import { client, authedFetch, hasAuth, UNOVERSE_BASE } from "studio-host";
|
|
16
|
+
|
|
17
|
+
// Per-APP test conversation: each app chats in its own conversation id, so testing
|
|
18
|
+
// one client's app never inherits another's agent history/memory (the "BPP answers
|
|
19
|
+
// about SAB" trap — same workflow, polluted shared context).
|
|
20
|
+
// …and a fresh conversation per PAGE LOAD: the nonce is minted once per module load,
|
|
21
|
+
// so refreshing the page naturally starts a NEW conversation (fresh agent thread,
|
|
22
|
+
// fresh durable surfaces) — history never outlives the screen it happened on.
|
|
23
|
+
const SESSION_NONCE = Math.random().toString(36).slice(2, 8);
|
|
24
|
+
const chatIdFor = (app: string) => `demo-${app.toLowerCase()}-${SESSION_NONCE}`;
|
|
25
|
+
|
|
26
|
+
// Dev "channel" config — the workbench stands in for a real channel. In LIVE mode
|
|
27
|
+
// it opens the Gravity data plane (WS component stream + REST execute) against the
|
|
28
|
+
// configured server/workflow/node; in MOCK mode it seeds a fake conversation.
|
|
29
|
+
// These are editable in the toolbar (LIVE mode) and persisted across refreshes.
|
|
30
|
+
// Dev workbench serves /api + /ws/gravity + /stream on the merged uWS at :4205
|
|
31
|
+
// (the retired gateway was :4100). Prod uses the page origin.
|
|
32
|
+
const CONN_DEFAULTS = { server: "localhost:4205", workflowId: "wf-r4jzo7", triggerNode: "inputtrigger1", token: "" };
|
|
33
|
+
const CONN_KEY = "unoverse.workbench.conn";
|
|
34
|
+
|
|
35
|
+
function loadConn(): typeof CONN_DEFAULTS {
|
|
36
|
+
try {
|
|
37
|
+
const saved = JSON.parse(localStorage.getItem(CONN_KEY) ?? "{}");
|
|
38
|
+
// Migration: drop the retired gateway port so the new default applies.
|
|
39
|
+
if (saved.server === "localhost:4100") delete saved.server;
|
|
40
|
+
return { ...CONN_DEFAULTS, ...saved };
|
|
41
|
+
} catch {
|
|
42
|
+
return { ...CONN_DEFAULTS };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface PropDef {
|
|
47
|
+
default?: unknown;
|
|
48
|
+
}
|
|
49
|
+
interface Def {
|
|
50
|
+
name: string;
|
|
51
|
+
// The client org this app belongs to (server-injected) — names its theme (`<org>/light`).
|
|
52
|
+
org?: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
// A native service the template binds (UNOVERSE_SPEC §2e-1) — e.g. "voice". The channel
|
|
55
|
+
// instantiates the matching service and feeds its neutral state + actions to the template.
|
|
56
|
+
service?: string;
|
|
57
|
+
props?: Record<string, PropDef>;
|
|
58
|
+
// Present when this template is an MCP app: the workflow it calls (from its manifest).
|
|
59
|
+
binding?: { workflow?: string; trigger?: string };
|
|
60
|
+
// true = on load, also fire the workflow (Mode B); false/absent = just load (Mode A).
|
|
61
|
+
autoTrigger?: boolean;
|
|
62
|
+
// MOCK-preview hint: the component(s) this app's workflow streams. When present, the
|
|
63
|
+
// mock seeds ONLY these (so a template previews the real app). Absent → seed NOTHING:
|
|
64
|
+
// the template renders its own default/welcome state (the default view), not a dump of
|
|
65
|
+
// the whole component catalog.
|
|
66
|
+
previewComponents?: string[];
|
|
67
|
+
// Layout height mode. Absent/true = FLUID (the template fills the stage height — chat /
|
|
68
|
+
// assistant / voice). false = size to CONTENT (the frame shrinks to the rendered
|
|
69
|
+
// component — focused widget apps like Bank Transfer).
|
|
70
|
+
fluidHeight?: boolean;
|
|
71
|
+
// The template's layer views, enumerated from its `states/` folder (UNOVERSE_LAYERS.md §7).
|
|
72
|
+
// `when` = the state's root visibleWhen selector; `where` = a reaction-contract surface's
|
|
73
|
+
// component-state selector (STATE_MODEL §5b) — the viewer lists AND activates by them.
|
|
74
|
+
states?: { name: string; when?: unknown; where?: unknown }[];
|
|
75
|
+
// TEMPLATE LAYOUTS (name-sync, docs/design/05): the composed arrangements of a
|
|
76
|
+
// multi-layout template + its default. The viewer shows these as a pill row (the
|
|
77
|
+
// template-tier twin of a component's faces); activation = the same selector a
|
|
78
|
+
// reaction surface uses (put a component into the view of the layout's name).
|
|
79
|
+
layouts?: Record<string, unknown>;
|
|
80
|
+
defaultLayout?: string;
|
|
81
|
+
// The definition tree (components: used to resolve a state's Switch discriminant).
|
|
82
|
+
root?: unknown;
|
|
83
|
+
// The full MCP-app manifest (present when the template folder carries manifest.json).
|
|
84
|
+
app?: {
|
|
85
|
+
name: string;
|
|
86
|
+
description?: string;
|
|
87
|
+
// Spatial-discovery selection text — ranked against user intent by findIntent.
|
|
88
|
+
whenToUse?: string;
|
|
89
|
+
category?: string;
|
|
90
|
+
version?: string;
|
|
91
|
+
binding?: { workflow?: string; trigger?: string };
|
|
92
|
+
autoTrigger?: boolean;
|
|
93
|
+
default?: boolean;
|
|
94
|
+
inputSchema?: Record<string, unknown>;
|
|
95
|
+
// Per-STATE mock seeding: `{ "<state>": ["coursecard", "coursecard"] }` — the
|
|
96
|
+
// author decides what each state's preview shows (a repeated name = several
|
|
97
|
+
// instances). Supersedes the legacy flat `previewComponents`.
|
|
98
|
+
preview?: Record<string, string[]>;
|
|
99
|
+
previewComponents?: string[];
|
|
100
|
+
fluidHeight?: boolean;
|
|
101
|
+
// The app's named default state on load (the single authored field, OPEN name):
|
|
102
|
+
// "template" = fluid · "focus" = fit content + opens in focus on load · "component" =
|
|
103
|
+
// inline card · anything future. `fluidHeight` above is derived from it.
|
|
104
|
+
// `mode` = pre-rename alias, read as fallback.
|
|
105
|
+
defaultState?: string;
|
|
106
|
+
mode?: string;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const VIEWPORTS: { id: string; label: string; w: number | null }[] = [
|
|
111
|
+
{ id: "mobile", label: "Mobile", w: 390 },
|
|
112
|
+
{ id: "tablet", label: "Tablet", w: 768 },
|
|
113
|
+
{ id: "desktop", label: "Desktop", w: 1280 },
|
|
114
|
+
{ id: "full", label: "Full", w: null },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
export function TemplatesView({ theme: defaultTheme, themeName, org = "all" }: { theme: ResolvedTheme; themeName: string; org?: string }) {
|
|
118
|
+
// MOCK (default, offline) vs LIVE (real Gravity data plane). A fresh store per
|
|
119
|
+
// mode keeps the two paths from bleeding into each other.
|
|
120
|
+
const [live, setLive] = useState(false);
|
|
121
|
+
const [templates, setTemplates] = useState<Def[]>([]);
|
|
122
|
+
const [components, setComponents] = useState<Def[]>([]);
|
|
123
|
+
const [selected, setSelected] = usePersistentState<string | null>("wb:templates:selected", null);
|
|
124
|
+
// A fresh store per mode AND per app selection: a catch-all template accumulates
|
|
125
|
+
// whatever's seeded, so switching apps must start clean (no stale components linger).
|
|
126
|
+
// The active layer view (Storybook-style state picker). Recreating the store on change
|
|
127
|
+
// starts it clean so the seed effect re-derives the state (UNOVERSE_LAYERS.md §7).
|
|
128
|
+
const [activeState, setActiveState] = useState<{ name: string; when?: unknown; where?: unknown } | null>(null);
|
|
129
|
+
// Selecting a template lands on its FIRST state — the def's natural default (a voice
|
|
130
|
+
// layout's idle, a chat layout's welcome). No synthetic "default" entry: the first
|
|
131
|
+
// authored layer IS the default view.
|
|
132
|
+
// The LAYOUT axis (multi-layout templates): the picked non-default layout pill, or
|
|
133
|
+
// null = the default layout. Independent of the local-state axis — any layout can
|
|
134
|
+
// combine with any local state (a voice List layout while agent-speaking).
|
|
135
|
+
const [activeLayout, setActiveLayout] = useState<string | null>(null);
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const t = templates.find((x) => x.name === selected);
|
|
138
|
+
setActiveState(t?.states?.filter((s) => !(t.layouts && s.name in t.layouts!))[0] ?? null);
|
|
139
|
+
setActiveLayout(null);
|
|
140
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
141
|
+
}, [selected, templates]);
|
|
142
|
+
useEffect(() => { if (live) { setActiveState(null); setActiveLayout(null); } }, [live]); // states are mock-only — clear when going live
|
|
143
|
+
// PICKING a state = jump there and seed its preview scenario (seedKey forces a fresh
|
|
144
|
+
// store). MOVING between states organically (clicking inside the preview) keeps the
|
|
145
|
+
// store — the template simply transitions, exactly like the runtime.
|
|
146
|
+
const [seedKey, setSeedKey] = useState(0);
|
|
147
|
+
const store = useMemo(() => new ComponentStore(), [live, selected, seedKey]);
|
|
148
|
+
// THE HOST'S JOB: the stage wrapper's width is DERIVED from component views
|
|
149
|
+
// (computeAppWidth), so this view must re-render on every store write — otherwise a
|
|
150
|
+
// panel surface opens (a card's setValue) and the app doesn't widen, clipping the
|
|
151
|
+
// panel. The SDK subtree has its own subscriptions; this one is for the host chrome.
|
|
152
|
+
const storeVersion = useSyncExternalStore(
|
|
153
|
+
useCallback((cb) => store.subscribe(cb), [store]),
|
|
154
|
+
useCallback(() => store.getVersion(), [store]),
|
|
155
|
+
);
|
|
156
|
+
const spatial = useRegistryToggles("template");
|
|
157
|
+
const [seeded, setSeeded] = useState<string[]>([]);
|
|
158
|
+
const [metaOpen, setMetaOpen] = usePersistentState<boolean>("wb:templates:meta", false); // MCP details panel
|
|
159
|
+
const [copiedUri, setCopiedUri] = useState(false);
|
|
160
|
+
const [version, setVersion] = useState(0); // local bump to reflect store changes in the toolbar
|
|
161
|
+
const [stageWidth, setStageWidth] = useState<number | null>(null); // default Full — testing should see the real channel width
|
|
162
|
+
const [connOpen, setConnOpen] = useState(false); // Live connection popover
|
|
163
|
+
// LIVE output inspector — the last submitted app output, projected through the
|
|
164
|
+
// component's declared `outputs` (the same object the held tool call returns to
|
|
165
|
+
// the calling LLM). Shown as a subtle dismissible card over the stage.
|
|
166
|
+
const [lastOutput, setLastOutput] = useState<{ at: string; data: Record<string, unknown> } | null>(null);
|
|
167
|
+
useEffect(() => setLastOutput(null), [selected, live]);
|
|
168
|
+
|
|
169
|
+
// Editable connection target (server/workflow/node), persisted across refreshes.
|
|
170
|
+
// `conn*` is the APPLIED value the hook uses; inputs commit on blur/Enter so we
|
|
171
|
+
// don't reconnect on every keystroke.
|
|
172
|
+
const [connCfg, setConnCfg] = useState(loadConn);
|
|
173
|
+
const applyConn = (patch: Partial<typeof CONN_DEFAULTS>) => {
|
|
174
|
+
setConnCfg((c) => {
|
|
175
|
+
const next = { ...c, ...patch };
|
|
176
|
+
try {
|
|
177
|
+
localStorage.setItem(CONN_KEY, JSON.stringify(next));
|
|
178
|
+
} catch {
|
|
179
|
+
/* ignore */
|
|
180
|
+
}
|
|
181
|
+
return next;
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// OIDC session (undefined when no AuthProvider is mounted — env unset).
|
|
186
|
+
const auth = useAuth();
|
|
187
|
+
// Token source: prefer the real OIDC access token; fall back to a pasted JWT.
|
|
188
|
+
const authToken = auth?.user?.access_token;
|
|
189
|
+
const token = authToken || connCfg.token;
|
|
190
|
+
|
|
191
|
+
const config = useMemo(
|
|
192
|
+
() => ({
|
|
193
|
+
apiUrl: `http://${connCfg.server}`,
|
|
194
|
+
// Component stream rides the Unoverse `/stream` MCP endpoint (§5b). In the canvas
|
|
195
|
+
// the path that reaches Unoverse is UNOVERSE_BASE (/unoverse-mcp proxy in dev, the
|
|
196
|
+
// explicit gated URL in prod) — always absolute there. In local Studio UNOVERSE_BASE
|
|
197
|
+
// is "" (a folder, not a universe; its origin serves no /stream), so the stream goes
|
|
198
|
+
// to the CONNECTED universe — same base the apiUrl above uses. An empty string here
|
|
199
|
+
// threw `Invalid URL` inside the SDK and killed the stream silently.
|
|
200
|
+
streamUrl: UNOVERSE_BASE || `http://${connCfg.server}`,
|
|
201
|
+
// A real JWT makes the workbench a proper authenticated channel: it rides the
|
|
202
|
+
// MCP stream's `Authorization` header and the REST `Authorization` header, so the
|
|
203
|
+
// workflow gets full user context (incl. the email claim → CRM join). See
|
|
204
|
+
// docs/AUTH_TOKEN_FLOW.md. Blank → no token (server must run with DISABLE_AUTH=true).
|
|
205
|
+
getAccessToken: token ? async () => token : undefined,
|
|
206
|
+
}),
|
|
207
|
+
[connCfg.server, token],
|
|
208
|
+
);
|
|
209
|
+
// The APP decides which workflow runs: the selected template's manifest binding
|
|
210
|
+
// (workflow + trigger) drives the connection. No manual entry — MCP decides.
|
|
211
|
+
const selectedDef = useMemo(() => templates.find((t) => t.name === selected), [templates, selected]);
|
|
212
|
+
// THE TEMPLATE'S ACTIVE STATE, derived live (docs/design/04: one state at a time —
|
|
213
|
+
// the latest surfaced view; else the conversation/welcome base). The nav highlights
|
|
214
|
+
// THIS, not the picked seed: clicking a card inside the preview IS moving to the
|
|
215
|
+
// other state, and Studio simply follows the same transition the runtime makes.
|
|
216
|
+
const liveStateName = useMemo(() => {
|
|
217
|
+
void storeVersion; // recompute on every store write
|
|
218
|
+
const states = selectedDef?.states ?? [];
|
|
219
|
+
const claims = new Map<string, string>(); // view -> state name
|
|
220
|
+
for (const s of states) {
|
|
221
|
+
const eq = (s.where as { eq?: unknown } | undefined)?.eq;
|
|
222
|
+
if (typeof eq === "string") claims.set(eq, s.name);
|
|
223
|
+
}
|
|
224
|
+
let view = "";
|
|
225
|
+
let at = -1;
|
|
226
|
+
for (const turn of store.getResponses())
|
|
227
|
+
for (const p of turn.components) {
|
|
228
|
+
const { chatId, nodeId } = store.split(p);
|
|
229
|
+
const v = (store.get(chatId, nodeId) as Record<string, unknown>)?.defaultState;
|
|
230
|
+
if (typeof v === "string" && claims.has(v) && store.touchOf(p) > at) {
|
|
231
|
+
at = store.touchOf(p);
|
|
232
|
+
view = v;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (view) return claims.get(view) ?? null;
|
|
236
|
+
const base = store.getTimeline().length === 0 ? "isEmpty" : "hasMessages";
|
|
237
|
+
return states.find((s) => s.when === base)?.name ?? null;
|
|
238
|
+
}, [storeVersion, store, selectedDef]);
|
|
239
|
+
// The preview renders under the SELECTED APP'S ORG theme — a SAB app is SAB-red, a
|
|
240
|
+
// BPP app cobalt, whatever the header org scope shows. The header toggle picks the
|
|
241
|
+
// light/dark variant; the app's org picks whose brand. Falls back to the app-level
|
|
242
|
+
// theme until the fetch resolves (or for org-less templates).
|
|
243
|
+
const [orgTheme, setOrgTheme] = useState<ResolvedTheme | null>(null);
|
|
244
|
+
const themeOrg = selectedDef?.org ?? "default";
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
let alive = true;
|
|
247
|
+
setOrgTheme(null);
|
|
248
|
+
client
|
|
249
|
+
.readTheme(`${themeOrg}/${themeName}`)
|
|
250
|
+
.then((t) => alive && setOrgTheme(t))
|
|
251
|
+
.catch(() => {});
|
|
252
|
+
return () => {
|
|
253
|
+
alive = false;
|
|
254
|
+
};
|
|
255
|
+
}, [themeOrg, themeName]);
|
|
256
|
+
const theme = orgTheme ?? defaultTheme;
|
|
257
|
+
const binding = selectedDef?.binding;
|
|
258
|
+
// The session userId MUST be the token's `sub` when a token rides the connection —
|
|
259
|
+
// the server binds the audio socket to the verified identity and REJECTS an
|
|
260
|
+
// INIT_SESSION claiming anyone else (AUTH_TOKEN_FLOW.md; the June audit's WS
|
|
261
|
+
// finding). "dev" is only for a universe with auth off, where no token exists.
|
|
262
|
+
const tokenSub = useMemo(() => {
|
|
263
|
+
if (!token) return undefined;
|
|
264
|
+
try {
|
|
265
|
+
return JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))).sub as string | undefined;
|
|
266
|
+
} catch {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
}, [token]);
|
|
270
|
+
const session = useMemo(
|
|
271
|
+
() => ({
|
|
272
|
+
workflowId: binding?.workflow ?? "",
|
|
273
|
+
targetTriggerNode: binding?.trigger ?? "",
|
|
274
|
+
userId: tokenSub ?? "dev",
|
|
275
|
+
conversationId: chatIdFor(selected ?? "app"),
|
|
276
|
+
chatId: chatIdFor(selected ?? "app"),
|
|
277
|
+
}),
|
|
278
|
+
[binding?.workflow, binding?.trigger, tokenSub],
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// The data-plane connection. Open it only when: Live mode, authenticated (or no OIDC),
|
|
282
|
+
// AND the selected app actually has a workflow binding (a plain template with no app
|
|
283
|
+
// has nothing to call).
|
|
284
|
+
const canConnect = live && Boolean(binding?.workflow) && (!hasAuth || Boolean(auth?.isAuthenticated));
|
|
285
|
+
const rawConn = useUnoverseConnection(config, session, store, { enabled: canConnect });
|
|
286
|
+
// HARD INVARIANT (workbench doc Mode A — "Mock does not connect with server"): a Mock
|
|
287
|
+
// session must NEVER reach the backend. The SDK only gates the inbound SSE stream on
|
|
288
|
+
// `enabled`; its outbound trigger/sendMessage/sendAction (REST `…/execute`) do NOT — so
|
|
289
|
+
// a stale/racing/future caller could still fire the workflow. Neutralise the outbound
|
|
290
|
+
// calls whenever we're not connecting. Belt-and-braces over the per-call `live` guards.
|
|
291
|
+
const conn = useMemo(
|
|
292
|
+
() => (canConnect ? rawConn : { ...rawConn, trigger: () => {}, sendMessage: () => {}, sendAction: () => {}, resolveElicitation: () => false }),
|
|
293
|
+
[canConnect, rawConn],
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// The `voice` native service (§2e-1) now lives INSIDE the SDK renderer (shared by every
|
|
297
|
+
// host — see StreamedUnoverseTemplate). The host only DECLARES the app is voice (off its
|
|
298
|
+
// manifest `service`) and hands the renderer the connection identity it already holds;
|
|
299
|
+
// the renderer owns the service + answers startCall/endCall/toggleMute. No host wiring.
|
|
300
|
+
const isVoice = selectedDef?.service === "voice";
|
|
301
|
+
const voiceConfig = useMemo<UseVoiceServiceConfig | undefined>(
|
|
302
|
+
() =>
|
|
303
|
+
isVoice
|
|
304
|
+
? {
|
|
305
|
+
// START_CALL REST triggers the workflow on the gateway. The audio WS lane is
|
|
306
|
+
// derived by the SDK from the client's server origin (see the renderer) — the
|
|
307
|
+
// host passes NO wsUrl, so every host (incl. srcdoc iframes) is identical.
|
|
308
|
+
apiUrl: `http://${connCfg.server}`,
|
|
309
|
+
getAccessToken: token ? async () => token : undefined,
|
|
310
|
+
session: {
|
|
311
|
+
userId: session.userId,
|
|
312
|
+
conversationId: session.conversationId,
|
|
313
|
+
chatId: session.chatId,
|
|
314
|
+
workflowId: session.workflowId,
|
|
315
|
+
targetTriggerNode: session.targetTriggerNode,
|
|
316
|
+
},
|
|
317
|
+
// Producer wiring (STATE_MODEL §4): the service writes its state into TEMPLATE
|
|
318
|
+
// STATE on this store — templates read callState/isMuted/… from the global bag.
|
|
319
|
+
store,
|
|
320
|
+
}
|
|
321
|
+
: undefined,
|
|
322
|
+
[isVoice, connCfg.server, token, session, store],
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
// "Load the app → it calls its workflow" — but ONLY for apps with autoTrigger (Mode B).
|
|
326
|
+
// Mode A apps (autoTrigger false/absent) just load the template and wait. Fires once the
|
|
327
|
+
// live connection is ready, no user turn; resets on app/Live change so re-selecting re-runs.
|
|
328
|
+
const [triggered, setTriggered] = useState(false);
|
|
329
|
+
useEffect(() => setTriggered(false), [selected, live]);
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (live && conn.isReady && binding?.workflow && selectedDef?.autoTrigger && !triggered) {
|
|
332
|
+
setTriggered(true);
|
|
333
|
+
conn.trigger();
|
|
334
|
+
}
|
|
335
|
+
}, [live, conn.isReady, conn, binding?.workflow, selectedDef?.autoTrigger, triggered]);
|
|
336
|
+
|
|
337
|
+
// Turn-complete detection (the "state" the StreamingText streaming preamble reads).
|
|
338
|
+
// The MCP data plane streams components but carries NO turn-complete signal, so the
|
|
339
|
+
// store's streamingState never flips off "streaming" on its own → the streaming
|
|
340
|
+
// preamble (dots + progressText) would show forever. We derive completion from the
|
|
341
|
+
// answer TEXT settling: once a response's text is non-empty and stops growing for a
|
|
342
|
+
// beat, mark it complete. Keying on text (not on any store event) means the agent's
|
|
343
|
+
// tool-call/thinking PAUSES — when no text exists yet — never false-complete it.
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
if (!live) return;
|
|
346
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
347
|
+
let lastText = "";
|
|
348
|
+
const clear = () => {
|
|
349
|
+
if (timer) {
|
|
350
|
+
clearTimeout(timer);
|
|
351
|
+
timer = null;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
const unsub = store.subscribe(() => {
|
|
355
|
+
const resp = store.latestResponse();
|
|
356
|
+
if (!resp || resp.streamingState !== "streaming") {
|
|
357
|
+
clear();
|
|
358
|
+
lastText = "";
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// Aggregate the streamed prose across the turn's text-bearing components.
|
|
362
|
+
let text = "";
|
|
363
|
+
for (const p of resp.components) {
|
|
364
|
+
const { chatId, nodeId } = store.split(p);
|
|
365
|
+
const d = store.get(chatId, nodeId);
|
|
366
|
+
if (typeof d.text === "string") text += d.text;
|
|
367
|
+
if (typeof d.markdown === "string") text += d.markdown;
|
|
368
|
+
}
|
|
369
|
+
if (!text) {
|
|
370
|
+
// No answer text yet → still thinking/tool-calling. Keep streaming.
|
|
371
|
+
clear();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (text !== lastText) {
|
|
375
|
+
// Text is still growing → reset the settle window.
|
|
376
|
+
lastText = text;
|
|
377
|
+
clear();
|
|
378
|
+
timer = setTimeout(() => store.completeResponse(chatIdFor(selected ?? "app")), 1200);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
return () => {
|
|
382
|
+
clear();
|
|
383
|
+
unsub();
|
|
384
|
+
};
|
|
385
|
+
}, [store, live]);
|
|
386
|
+
|
|
387
|
+
const defaultsOf = (d: Def) =>
|
|
388
|
+
Object.fromEntries(Object.entries(d.props ?? {}).map(([k, v]) => [k, v.default]));
|
|
389
|
+
|
|
390
|
+
// Seed the given components into the latest response, so any slot that matches fills;
|
|
391
|
+
// unmatched slots skeleton. The SET is chosen by the caller — scoped to the selected
|
|
392
|
+
// app's `previewComponents` when declared (see the seed effect below).
|
|
393
|
+
// ARRIVAL CONTRACT (STATE_MODEL §5b): a component streams in WITH its state already
|
|
394
|
+
// set — the seed carries the def's `state` block (its initial values). EXCEPT
|
|
395
|
+
// `defaultState`: in the preview the SELECTED STATE CHIP is the state controller —
|
|
396
|
+
// seeding always lands inline (the universal default), and the template's focus
|
|
397
|
+
// state chip writes `focused` into the slice (the where-activation below), exactly
|
|
398
|
+
// what a real arrival-in-focus or a component's own expand button would do.
|
|
399
|
+
function seed(comps: Def[]) {
|
|
400
|
+
// nodeId is per-INSTANCE (`name-i`), so previewComponents may repeat a name to
|
|
401
|
+
// seed several instances — e.g. ["coursecard", "coursecard"] previews a rail of cards.
|
|
402
|
+
comps.forEach((c, i) =>
|
|
403
|
+
store.apply({
|
|
404
|
+
type: "COMPONENT_INIT",
|
|
405
|
+
chatId: chatIdFor(selected ?? "app"),
|
|
406
|
+
nodeId: `${c.name}-${i}`,
|
|
407
|
+
component: {
|
|
408
|
+
type: c.name,
|
|
409
|
+
props: { ...defaultsOf(c), ...((c as { state?: Record<string, unknown> }).state ?? {}), defaultState: "inline" },
|
|
410
|
+
},
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
setSeeded(comps.map((c) => c.name));
|
|
414
|
+
setVersion((v) => v + 1);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Which components to mock for the current selection: ONLY an app's declared
|
|
418
|
+
// previewComponents. No declaration → seed nothing, so the template renders its own
|
|
419
|
+
// default/welcome state (the default view) instead of a dump of the whole catalog.
|
|
420
|
+
const seedSet = (tmpls: Def[], comps: Def[], sel: string | null): Def[] => {
|
|
421
|
+
const t = tmpls.find((t) => t.name === sel);
|
|
422
|
+
// The declaration lives in the app manifest (app.previewComponents); the def-level
|
|
423
|
+
// field is the legacy envelope home. Names are case-insensitive like every rx name,
|
|
424
|
+
// and each ENTRY seeds one instance — a repeated name previews several (a card rail).
|
|
425
|
+
const pc = t?.app?.previewComponents ?? t?.previewComponents ?? [];
|
|
426
|
+
return pc
|
|
427
|
+
.map((name) => comps.find((c) => c.name.toLowerCase() === name.toLowerCase()))
|
|
428
|
+
.filter((c): c is Def => Boolean(c));
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
Promise.all([
|
|
433
|
+
// The header's org scope filters the list ("all" → every org's apps, each
|
|
434
|
+
// tagged); the selected app's org still picks its own preview theme.
|
|
435
|
+
// Components are universal — one catalog.
|
|
436
|
+
authedFetch(`/dev/templates${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`).then((r) => (r.ok ? r.json() : { templates: [] })),
|
|
437
|
+
authedFetch("/dev/components").then((r) => (r.ok ? r.json() : { components: [] })),
|
|
438
|
+
])
|
|
439
|
+
.then(([t, c]) => {
|
|
440
|
+
const tl: Def[] = t.templates ?? [];
|
|
441
|
+
const cl: Def[] = c.components ?? [];
|
|
442
|
+
setTemplates(tl);
|
|
443
|
+
setComponents(cl);
|
|
444
|
+
if (live) setSeeded([]);
|
|
445
|
+
// Restore the persisted selection across reloads if it still exists; else first.
|
|
446
|
+
// MOCK seeding is handled by the scoped seed effect below (keyed on the selection),
|
|
447
|
+
// so the preview shows just the selected app's component(s), not the whole catalog.
|
|
448
|
+
if (tl[0]) setSelected((s) => (s && tl.some((t) => t.name === s) ? s : tl[0].name));
|
|
449
|
+
})
|
|
450
|
+
.catch(() => {
|
|
451
|
+
setTemplates([]);
|
|
452
|
+
setComponents([]);
|
|
453
|
+
});
|
|
454
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
455
|
+
}, [live, org]);
|
|
456
|
+
|
|
457
|
+
// MOCK seeding — scoped to the selected app's `preview` map (manifest): the author
|
|
458
|
+
// declares what each STATE's preview shows. The store is fresh per selection (keyed
|
|
459
|
+
// on [live, selected]), so each app starts clean. No declaration for the picked
|
|
460
|
+
// state → the legacy flat previewComponents, else the sample fallback below.
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
if (live || !components.length || !selected) return;
|
|
463
|
+
const when = activeState?.when;
|
|
464
|
+
// TWO AXES compose (name-sync, docs/design/05): the LAYOUT pill picks the surfaced
|
|
465
|
+
// view (seeds components into it) while the LOCAL state picks the template's own
|
|
466
|
+
// condition (callState, hasMessages) — any layout × any state. `activeLayout` is
|
|
467
|
+
// the non-default layout pill, or null (default layout, no surfaced component).
|
|
468
|
+
const layoutSel = activeLayout ? { field: "defaultState", eq: activeLayout } : undefined;
|
|
469
|
+
// A picked state activates by its selector (UNOVERSE_LAYERS.md §7):
|
|
470
|
+
// • "isEmpty" → leave the store pristine (the template's empty/welcome layer)
|
|
471
|
+
// • "hasMessages" / a component state / default → seed the mock conversation
|
|
472
|
+
// • { field, eq } (template-state selector, e.g. defaultState="focus") → set it after seeding
|
|
473
|
+
// An explicit preview entry for the state OVERRIDES all of it — the author decided.
|
|
474
|
+
const previewMap = selectedDef?.app?.preview;
|
|
475
|
+
const entry = activeLayout ? previewMap?.[activeLayout] : activeState ? previewMap?.[activeState.name] : undefined;
|
|
476
|
+
const wantEmpty = !entry && !activeLayout && typeof when === "string" && when === "isEmpty";
|
|
477
|
+
// A COMPONENT is only seeded for a reaction-contract state (select.where) — the
|
|
478
|
+
// surface needs one to grab (Focus). Plain template states (welcome, conversation)
|
|
479
|
+
// preview the TEMPLATE's own chrome; a component in the transcript there is noise.
|
|
480
|
+
const wantComponent = Boolean(layoutSel) || Boolean((activeState?.where as { field?: string } | undefined)?.field);
|
|
481
|
+
let seededSet: Def[] = [];
|
|
482
|
+
if (!wantEmpty) {
|
|
483
|
+
const byName = (name: string) => components.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
|
484
|
+
const set = entry
|
|
485
|
+
? entry.map(byName).filter((c): c is Def => Boolean(c))
|
|
486
|
+
: wantComponent
|
|
487
|
+
? seedSet(templates, components, selected)
|
|
488
|
+
: [];
|
|
489
|
+
// Seed when there's preview content OR a picked state that needs a live conversation.
|
|
490
|
+
if (set.length || activeState) {
|
|
491
|
+
store.addUserMessage(chatIdFor(selected ?? "app"), "Show me this app");
|
|
492
|
+
// Component states need a body to grab; app declares no previewComponents →
|
|
493
|
+
// fall back to the SAMPLE ANSWER (the Markdown component's own default content)
|
|
494
|
+
// so the conversation still previews a real assistant turn.
|
|
495
|
+
const sample = components.filter((c) => c.name.toLowerCase() === "markdownrenderer");
|
|
496
|
+
seededSet = set.length ? set : sample;
|
|
497
|
+
seed(seededSet);
|
|
498
|
+
// A mock preview is a FINISHED turn — complete it so streaming chrome
|
|
499
|
+
// ("Writing…", dots) doesn't hang forever (mock has no settle watcher).
|
|
500
|
+
store.completeResponse(chatIdFor(selected ?? "app"));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (when && typeof when === "object" && "field" in (when as object)) {
|
|
504
|
+
const c = when as { field: string; eq?: unknown };
|
|
505
|
+
store.mergeTemplateState({ [c.field]: c.eq ?? true });
|
|
506
|
+
} else if (typeof when === "string" && when !== "isEmpty" && when !== "hasMessages") {
|
|
507
|
+
store.mergeTemplateState({ [when]: true });
|
|
508
|
+
}
|
|
509
|
+
// Reaction-contract activation (STATE_MODEL §5b): the surface (or the layout pill —
|
|
510
|
+
// same selector) reacts to COMPONENT state, so activate it the way the runtime
|
|
511
|
+
// does — put a seeded component INTO that view. Same write the component's own
|
|
512
|
+
// buttons perform.
|
|
513
|
+
const whereSel = layoutSel ?? (activeState?.where as { field?: string; eq?: unknown } | undefined);
|
|
514
|
+
if (whereSel?.field) {
|
|
515
|
+
// Every seeded instance enters the previewed state — the author's `preview`
|
|
516
|
+
// map already said exactly what belongs here (a multi-instance surface like a
|
|
517
|
+
// card rail previews full; a limit:1 surface shows the newest).
|
|
518
|
+
seededSet.forEach((c, i) => store.mergeComponentState(chatIdFor(selected ?? "app"), `${c.name}-${i}`, { [whereSel.field!]: whereSel.eq ?? true }));
|
|
519
|
+
}
|
|
520
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
521
|
+
}, [store, selected, components, templates, live, activeState, activeLayout]);
|
|
522
|
+
|
|
523
|
+
return (
|
|
524
|
+
<div className="grid min-h-0 flex-1 grid-cols-[190px_1fr] overflow-hidden">
|
|
525
|
+
{/* NAVIGATOR */}
|
|
526
|
+
<nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#2c2c2e] p-2">
|
|
527
|
+
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400">Apps</div>
|
|
528
|
+
{templates.length === 0 && (
|
|
529
|
+
<div className="p-2 text-xs text-gray-400">No apps yet — add one under rx/orgs/<client>/templates/.</div>
|
|
530
|
+
)}
|
|
531
|
+
{templates.map((t) => (
|
|
532
|
+
<div key={`${t.org ?? ""}/${t.name}`}>
|
|
533
|
+
<button
|
|
534
|
+
onClick={() => setSelected(t.name)}
|
|
535
|
+
className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition ${
|
|
536
|
+
selected === t.name ? "bg-emerald-500/15 text-emerald-300" : "text-gray-300 hover:bg-white/5"
|
|
537
|
+
}`}
|
|
538
|
+
>
|
|
539
|
+
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
|
540
|
+
{/* Org badge only in the cross-org ("all") view — scoped, it's redundant */}
|
|
541
|
+
{org === "all" && t.org && (
|
|
542
|
+
<span className="shrink-0 rounded bg-white/10 px-1.5 py-px text-[9px] font-bold uppercase tracking-wider text-gray-400">
|
|
543
|
+
{t.org}
|
|
544
|
+
</span>
|
|
545
|
+
)}
|
|
546
|
+
</button>
|
|
547
|
+
{/* Storybook-style layer views — the template's states/ folder (UNOVERSE_LAYERS.md §7).
|
|
548
|
+
Mock view only: states are a preview affordance, not a live control. LAYOUTS are
|
|
549
|
+
NOT listed here — they get the toolbar pill row (the component-faces treatment);
|
|
550
|
+
the rail keeps only the template's LOCAL states. */}
|
|
551
|
+
{!live && selected === t.name && (t.states?.length ?? 0) > 0 && (
|
|
552
|
+
<div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
|
|
553
|
+
{t.states!.filter((s) => !(t.layouts && s.name in t.layouts!)).map((s) => (
|
|
554
|
+
<button
|
|
555
|
+
key={s.name}
|
|
556
|
+
onClick={() => {
|
|
557
|
+
setActiveState(s);
|
|
558
|
+
setSeedKey((k) => k + 1); // picking = jump + reseed that scenario
|
|
559
|
+
}}
|
|
560
|
+
title={`state: ${s.name}`}
|
|
561
|
+
className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${
|
|
562
|
+
(liveStateName ?? activeState?.name) === s.name ? "bg-emerald-500/10 text-emerald-300" : "text-gray-400 hover:bg-white/5 hover:text-gray-200"
|
|
563
|
+
}`}
|
|
564
|
+
>
|
|
565
|
+
{s.name}
|
|
566
|
+
</button>
|
|
567
|
+
))}
|
|
568
|
+
</div>
|
|
569
|
+
)}
|
|
570
|
+
{/* A seeded component's OWN states are its business — design/step them in the
|
|
571
|
+
COMPONENTS view. The template rail lists only the TEMPLATE's states. */}
|
|
572
|
+
</div>
|
|
573
|
+
))}
|
|
574
|
+
</nav>
|
|
575
|
+
|
|
576
|
+
{/* MAIN */}
|
|
577
|
+
<div className="flex min-h-0 min-w-0 flex-col">
|
|
578
|
+
{/* toolbar */}
|
|
579
|
+
<div className="flex items-center gap-3 border-b px-4 py-2 text-[11px]" style={{ borderColor: theme.color["border.subtle"], color: theme.color["text.secondary"], background: theme.color["surface.base"] }}>
|
|
580
|
+
<span className="font-medium uppercase tracking-wider opacity-60">{selected ?? "template"}</span>
|
|
581
|
+
{/* TEMPLATE LAYOUTS (name-sync) — the faces pill row, template tier: the default
|
|
582
|
+
layout first, then each named layout. Picking one runs the SAME activation a
|
|
583
|
+
reaction surface uses (its served `where` selector seeds a component into the
|
|
584
|
+
view of that name), so the preview machinery needs zero special-casing. */}
|
|
585
|
+
{!live && selectedDef?.layouts && (
|
|
586
|
+
<div className="flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
|
|
587
|
+
{[selectedDef.defaultLayout ?? "main",
|
|
588
|
+
// AUTHORED ORDER: the served states list is already stateOrder-ranked —
|
|
589
|
+
// rank layout pills by it; anything unranked falls to the end.
|
|
590
|
+
...(selectedDef.states ?? []).map((s) => s.name).filter((n) => n !== (selectedDef.defaultLayout ?? "main") && selectedDef.layouts && n in selectedDef.layouts!),
|
|
591
|
+
...Object.keys(selectedDef.layouts).filter((n) => n !== (selectedDef.defaultLayout ?? "main") && !(selectedDef.states ?? []).some((s) => s.name === n)),
|
|
592
|
+
].map((n) => {
|
|
593
|
+
const isDefault = n === (selectedDef.defaultLayout ?? "main");
|
|
594
|
+
const on = isDefault ? !activeLayout : activeLayout === n;
|
|
595
|
+
return (
|
|
596
|
+
<button
|
|
597
|
+
key={n}
|
|
598
|
+
onClick={() => {
|
|
599
|
+
setActiveLayout(isDefault ? null : n); // the layout AXIS only — the local state stays put
|
|
600
|
+
setSeedKey((k) => k + 1);
|
|
601
|
+
}}
|
|
602
|
+
className="rounded px-2 py-0.5 font-medium capitalize transition"
|
|
603
|
+
style={on ? { background: theme.color["surface.base"], color: theme.color["text.primary"] } : { color: theme.color["text.secondary"] }}
|
|
604
|
+
>
|
|
605
|
+
{n}
|
|
606
|
+
</button>
|
|
607
|
+
);
|
|
608
|
+
})}
|
|
609
|
+
</div>
|
|
610
|
+
)}
|
|
611
|
+
{selected && <ToggleSwitch on={spatial.isEnabled(selected)} onClick={() => spatial.toggle(selected)} label="Spatial" />}
|
|
612
|
+
{selected && (
|
|
613
|
+
<button
|
|
614
|
+
onClick={() => setMetaOpen((o) => !o)}
|
|
615
|
+
className="flex items-center gap-1 rounded border px-2 py-0.5 font-medium"
|
|
616
|
+
style={{ borderColor: theme.color["border.subtle"], color: metaOpen ? theme.color["text.primary"] : theme.color["text.secondary"], background: metaOpen ? theme.color["surface.sunken"] : "transparent" }}
|
|
617
|
+
title="Show the MCP app manifest"
|
|
618
|
+
>
|
|
619
|
+
{selectedDef?.app ? "ⓘ MCP app" : "ⓘ Details"} <span className="opacity-60">{metaOpen ? "▴" : "▾"}</span>
|
|
620
|
+
</button>
|
|
621
|
+
)}
|
|
622
|
+
|
|
623
|
+
{/* MOCK ⇄ LIVE — live opens the Gravity data plane against the dev workflow */}
|
|
624
|
+
<div className="flex items-center gap-1 rounded p-0.5" style={{ background: theme.color["border.subtle"] }}>
|
|
625
|
+
{[
|
|
626
|
+
{ id: "mock", label: "Mock", on: !live },
|
|
627
|
+
{ id: "live", label: "Live", on: live },
|
|
628
|
+
].map((m) => (
|
|
629
|
+
<button
|
|
630
|
+
key={m.id}
|
|
631
|
+
onClick={() => setLive(m.id === "live")}
|
|
632
|
+
className="rounded px-2 py-0.5 font-medium transition"
|
|
633
|
+
style={m.on ? { background: theme.color["surface.base"], color: theme.color["text.primary"] } : { color: theme.color["text.secondary"] }}
|
|
634
|
+
>
|
|
635
|
+
{m.label}
|
|
636
|
+
</button>
|
|
637
|
+
))}
|
|
638
|
+
</div>
|
|
639
|
+
{live ? (
|
|
640
|
+
// Live connection: one status chip → popover with the labeled target fields.
|
|
641
|
+
// Identity/sign-in lives in the HEADER (AuthButton); the token flows from the
|
|
642
|
+
// same session, so it's NOT repeated here. The pasted-JWT field appears only
|
|
643
|
+
// when OIDC isn't configured (the no-header-login fallback).
|
|
644
|
+
<div className="relative">
|
|
645
|
+
<button
|
|
646
|
+
onClick={() => setConnOpen((o) => !o)}
|
|
647
|
+
className="flex items-center gap-1.5 rounded border px-2 py-0.5 font-medium"
|
|
648
|
+
style={{ borderColor: theme.color["border.subtle"], color: theme.color["text.secondary"] }}
|
|
649
|
+
title="Live connection settings"
|
|
650
|
+
>
|
|
651
|
+
<span
|
|
652
|
+
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
|
653
|
+
style={{ background: conn.isReady ? "#22c55e" : conn.isConnected ? "#eab308" : "#9ca3af" }}
|
|
654
|
+
/>
|
|
655
|
+
<span className="font-medium">{conn.isReady ? "Connected" : conn.isConnected ? "Connecting…" : "Offline"}</span>
|
|
656
|
+
{binding?.workflow && <span className="font-mono text-[10px] opacity-60">· {binding.workflow}</span>}
|
|
657
|
+
<span className="opacity-60">▾</span>
|
|
658
|
+
</button>
|
|
659
|
+
{connOpen && (
|
|
660
|
+
<>
|
|
661
|
+
<div className="fixed inset-0 z-10" onClick={() => setConnOpen(false)} />
|
|
662
|
+
<div
|
|
663
|
+
className="absolute left-0 top-full z-20 mt-1 w-64 rounded border p-3 shadow-xl"
|
|
664
|
+
style={{ background: theme.color["surface.base"], borderColor: theme.color["border.subtle"] }}
|
|
665
|
+
>
|
|
666
|
+
<div className="mb-2 flex items-center justify-between">
|
|
667
|
+
<span className="text-[10px] font-semibold uppercase tracking-wider opacity-60">Live connection</span>
|
|
668
|
+
<span className="text-[10px]" style={{ color: conn.isReady ? "#22c55e" : conn.isConnected ? "#eab308" : theme.color["text.tertiary"] }}>
|
|
669
|
+
{conn.isReady ? "ready" : conn.isConnected ? "connecting…" : "offline"}
|
|
670
|
+
</span>
|
|
671
|
+
</div>
|
|
672
|
+
{/* Read-only: the app decides. Server is environment; Workflow + Trigger
|
|
673
|
+
come from the selected app's manifest binding (no manual entry). */}
|
|
674
|
+
{(
|
|
675
|
+
[
|
|
676
|
+
{ label: "Server", value: connCfg.server, note: "environment" },
|
|
677
|
+
{ label: "Workflow", value: binding?.workflow ?? "— no app binding", note: "from app" },
|
|
678
|
+
{ label: "Trigger node", value: binding?.trigger ?? "—", note: "from app" },
|
|
679
|
+
]
|
|
680
|
+
).map((f) => (
|
|
681
|
+
<div key={f.label} className="mb-2">
|
|
682
|
+
<span className="mb-0.5 block text-[10px] opacity-70">
|
|
683
|
+
{f.label} <span className="opacity-50">· {f.note}</span>
|
|
684
|
+
</span>
|
|
685
|
+
<div
|
|
686
|
+
className="w-full rounded border bg-transparent px-1.5 py-1 font-mono text-[10px]"
|
|
687
|
+
style={{ borderColor: theme.color["border.subtle"], color: theme.color["text.primary"] }}
|
|
688
|
+
>
|
|
689
|
+
{f.value}
|
|
690
|
+
</div>
|
|
691
|
+
</div>
|
|
692
|
+
))}
|
|
693
|
+
{!hasAuth && (
|
|
694
|
+
<label className="block">
|
|
695
|
+
<span className="mb-0.5 block text-[10px] opacity-70">JWT token</span>
|
|
696
|
+
<input
|
|
697
|
+
type="password"
|
|
698
|
+
defaultValue={connCfg.token}
|
|
699
|
+
placeholder="paste a JWT (or DISABLE_AUTH=true)"
|
|
700
|
+
onBlur={(e) => e.target.value !== connCfg.token && applyConn({ token: e.target.value.trim() })}
|
|
701
|
+
onKeyDown={(e) => {
|
|
702
|
+
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
|
703
|
+
}}
|
|
704
|
+
className="w-full rounded border bg-transparent px-1.5 py-1 font-mono text-[10px] focus:outline-none"
|
|
705
|
+
style={{ borderColor: theme.color["border.subtle"], color: theme.color["text.primary"] }}
|
|
706
|
+
/>
|
|
707
|
+
</label>
|
|
708
|
+
)}
|
|
709
|
+
</div>
|
|
710
|
+
</>
|
|
711
|
+
)}
|
|
712
|
+
</div>
|
|
713
|
+
) : (
|
|
714
|
+
<span className="opacity-60">seeded: {seeded.length ? seeded.join(", ") : "—"}</span>
|
|
715
|
+
)}
|
|
716
|
+
|
|
717
|
+
{/* Responsive viewport presets — simulate the channel/device width */}
|
|
718
|
+
<div className="ml-auto flex items-center gap-1 rounded p-0.5" style={{ background: theme.color["border.subtle"] }}>
|
|
719
|
+
{VIEWPORTS.map((v) => (
|
|
720
|
+
<button
|
|
721
|
+
key={v.id}
|
|
722
|
+
onClick={() => setStageWidth(v.w)}
|
|
723
|
+
className="rounded px-2 py-0.5 font-medium transition"
|
|
724
|
+
style={
|
|
725
|
+
stageWidth === v.w
|
|
726
|
+
? { background: theme.color["surface.base"], color: theme.color["text.primary"] }
|
|
727
|
+
: { color: theme.color["text.secondary"] }
|
|
728
|
+
}
|
|
729
|
+
>
|
|
730
|
+
{v.label}
|
|
731
|
+
</button>
|
|
732
|
+
))}
|
|
733
|
+
</div>
|
|
734
|
+
<span className="tabular-nums" style={{ color: theme.color["text.tertiary"] }}>{stageWidth ? `${stageWidth}px` : "100%"}</span>
|
|
735
|
+
</div>
|
|
736
|
+
|
|
737
|
+
{/* MCP APP DETAILS — the selected template's manifest (discovery meta + binding). */}
|
|
738
|
+
{metaOpen && selected && (() => {
|
|
739
|
+
const app = selectedDef?.app;
|
|
740
|
+
const accent = theme.color["action.primary"] ?? "#6366f1";
|
|
741
|
+
const warn = theme.color["status.warning"] ?? "#d97706";
|
|
742
|
+
const ok = theme.color["status.success"] ?? "#22c55e";
|
|
743
|
+
const sub = theme.color["text.secondary"];
|
|
744
|
+
const muted = theme.color["text.tertiary"];
|
|
745
|
+
const uri = `unoverse://apps/${selectedDef?.org ? `${selectedDef.org}/` : ""}${selected.toLowerCase()}`;
|
|
746
|
+
const initials = (app?.name ?? selected).split(/\s+/).map((w) => w[0]).slice(0, 2).join("").toUpperCase();
|
|
747
|
+
const fields = app
|
|
748
|
+
? [
|
|
749
|
+
{ label: "Workflow", value: app.binding?.workflow || "unbound", mono: true, tone: app.binding?.workflow ? undefined : warn },
|
|
750
|
+
{ label: "Trigger", value: app.binding?.trigger || "—", mono: true },
|
|
751
|
+
{ label: "Auto-trigger", value: app.autoTrigger ? "On load" : "On message", dot: app.autoTrigger ? ok : muted },
|
|
752
|
+
{ label: "Layout", value: selected, mono: true },
|
|
753
|
+
{ label: "Default state", value: (() => { const v = app.defaultState ?? app.mode ?? "template"; return v.charAt(0).toUpperCase() + v.slice(1); })(), dot: (app.defaultState ?? app.mode ?? "template") === "template" ? ok : muted },
|
|
754
|
+
{ label: "Input params", value: Object.keys(((app.inputSchema as { properties?: Record<string, unknown> })?.properties) ?? {}).join(", ") || "—", mono: true },
|
|
755
|
+
]
|
|
756
|
+
: [];
|
|
757
|
+
return (
|
|
758
|
+
<div className="border-b px-5 py-4" style={{ borderColor: theme.color["border.subtle"], background: theme.color["surface.base"] }}>
|
|
759
|
+
{app ? (
|
|
760
|
+
<div className="mx-auto flex max-w-5xl flex-col gap-4">
|
|
761
|
+
{/* Identity row */}
|
|
762
|
+
<div className="flex items-start gap-3.5">
|
|
763
|
+
<div
|
|
764
|
+
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[13px] font-bold tracking-tight"
|
|
765
|
+
style={{ background: `${accent}1a`, color: accent, border: `1px solid ${accent}33` }}
|
|
766
|
+
>
|
|
767
|
+
{initials}
|
|
768
|
+
</div>
|
|
769
|
+
<div className="min-w-0 flex-1">
|
|
770
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
771
|
+
<h2 className="text-[17px] font-semibold leading-tight tracking-tight" style={{ color: theme.color["text.primary"] }}>{app.name}</h2>
|
|
772
|
+
{app.category && (
|
|
773
|
+
<span className="rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style={{ background: `${accent}14`, color: accent }}>
|
|
774
|
+
{app.category}
|
|
775
|
+
</span>
|
|
776
|
+
)}
|
|
777
|
+
{app.version && <span className="font-mono text-[10px]" style={{ color: muted }}>v{app.version}</span>}
|
|
778
|
+
</div>
|
|
779
|
+
{app.description && (
|
|
780
|
+
<p className="mt-1 max-w-3xl text-[13px] leading-relaxed" style={{ color: sub }}>{app.description}</p>
|
|
781
|
+
)}
|
|
782
|
+
{app.whenToUse && (
|
|
783
|
+
<div
|
|
784
|
+
className="mt-2 max-w-3xl rounded-r pl-3 py-1.5"
|
|
785
|
+
style={{ borderLeft: `3px solid ${accent}`, background: theme.color["surface.sunken"] }}
|
|
786
|
+
>
|
|
787
|
+
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wider" style={{ color: accent }}>
|
|
788
|
+
When to use
|
|
789
|
+
</div>
|
|
790
|
+
<p className="text-[15px] font-medium leading-relaxed" style={{ color: theme.color["text.primary"] }}>{app.whenToUse}</p>
|
|
791
|
+
</div>
|
|
792
|
+
)}
|
|
793
|
+
</div>
|
|
794
|
+
<button
|
|
795
|
+
onClick={() => { void navigator.clipboard?.writeText(uri); setCopiedUri(true); setTimeout(() => setCopiedUri(false), 1200); }}
|
|
796
|
+
className="group flex shrink-0 items-center gap-1.5 rounded-lg px-2.5 py-1.5 font-mono text-[10px] transition"
|
|
797
|
+
style={{ background: theme.color["surface.sunken"], border: `1px solid ${theme.color["border.subtle"]}`, color: muted }}
|
|
798
|
+
title="Copy app URI"
|
|
799
|
+
>
|
|
800
|
+
{copiedUri ? <span style={{ color: ok }}>✓ copied</span> : <><span style={{ color: muted }}>{uri}</span><span className="opacity-50 group-hover:opacity-100">⧉</span></>}
|
|
801
|
+
</button>
|
|
802
|
+
</div>
|
|
803
|
+
|
|
804
|
+
{/* Stat grid */}
|
|
805
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
|
|
806
|
+
{fields.map((f) => (
|
|
807
|
+
<div
|
|
808
|
+
key={f.label}
|
|
809
|
+
className="flex flex-col gap-1 rounded-lg px-3 py-2.5"
|
|
810
|
+
style={{ background: theme.color["surface.sunken"], border: `1px solid ${theme.color["border.subtle"]}` }}
|
|
811
|
+
>
|
|
812
|
+
<span className="text-[9.5px] font-semibold uppercase tracking-[0.08em]" style={{ color: muted }}>{f.label}</span>
|
|
813
|
+
<span className="flex items-center gap-1.5 truncate" title={String(f.value)}>
|
|
814
|
+
{"dot" in f && f.dot && <span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: f.dot }} />}
|
|
815
|
+
<span className={`truncate ${f.mono ? "font-mono text-[11px]" : "text-[12px]"}`} style={{ color: f.tone ?? theme.color["text.primary"] }}>{f.value}</span>
|
|
816
|
+
</span>
|
|
817
|
+
</div>
|
|
818
|
+
))}
|
|
819
|
+
</div>
|
|
820
|
+
</div>
|
|
821
|
+
) : (
|
|
822
|
+
<div className="mx-auto flex max-w-5xl items-start gap-3.5">
|
|
823
|
+
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[13px] font-bold" style={{ background: theme.color["surface.sunken"], color: muted, border: `1px solid ${theme.color["border.subtle"]}` }}>
|
|
824
|
+
{initials}
|
|
825
|
+
</div>
|
|
826
|
+
<div className="text-[13px] leading-relaxed" style={{ color: sub }}>
|
|
827
|
+
<span className="font-semibold" style={{ color: theme.color["text.primary"] }}>Plain template</span> — not yet a discoverable MCP app.
|
|
828
|
+
<br />Add a <code className="rounded px-1 font-mono text-[11px]" style={{ background: theme.color["surface.sunken"] }}>manifest.json</code> beside{" "}
|
|
829
|
+
<code className="rounded px-1 font-mono text-[11px]" style={{ background: theme.color["surface.sunken"] }}>{selected.toLowerCase()}.json</code> to give it a name, description, and workflow binding.
|
|
830
|
+
</div>
|
|
831
|
+
</div>
|
|
832
|
+
)}
|
|
833
|
+
</div>
|
|
834
|
+
);
|
|
835
|
+
})()}
|
|
836
|
+
|
|
837
|
+
{/* STAGE — templates are full-screen layouts; render in a centered device frame */}
|
|
838
|
+
<section className="flex min-h-0 flex-1 justify-center overflow-auto p-6 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden" style={{ background: theme.color["surface.sunken"] ?? "#f3f4f6" }} data-store-version={version} data-store-width={storeVersion}>
|
|
839
|
+
{selected && (
|
|
840
|
+
<div
|
|
841
|
+
// The host just provides the DEVICE BOX; the template's own layout DATA fills it
|
|
842
|
+
// (a template surface fills its container — see StreamedUnoverseTemplate). Per-
|
|
843
|
+
// template height mode (manifest `fluidHeight`, default true): FLUID gives a
|
|
844
|
+
// full-height box (chat/voice fill it); CONTENT gives an auto-height box that
|
|
845
|
+
// wraps the rendered component (widget apps like Bank Transfer) — `self-start`
|
|
846
|
+
// so the flex stage doesn't stretch it.
|
|
847
|
+
className={`shrink-0 overflow-hidden transition-[width] duration-200 ${
|
|
848
|
+
(selectedDef?.fluidHeight ?? true) ? "" : "self-start"
|
|
849
|
+
}`}
|
|
850
|
+
style={{
|
|
851
|
+
width: stageWidth ?? "100%",
|
|
852
|
+
maxWidth: "100%",
|
|
853
|
+
...((selectedDef?.fluidHeight ?? true) ? { height: "100%" } : {}),
|
|
854
|
+
background: theme.color["surface.base"],
|
|
855
|
+
border: `1px solid ${theme.color["border.subtle"]}`,
|
|
856
|
+
borderRadius: 8,
|
|
857
|
+
boxShadow: theme.shadow["lg"],
|
|
858
|
+
}}
|
|
859
|
+
>
|
|
860
|
+
{/* THE HOST'S JOB (twin of the embed page's unoverse:size handler): render the
|
|
861
|
+
app at its STATE-OWNED width — the layout root's `appWidth` (the core
|
|
862
|
+
surface's constant width) plus any occupied panel slot's `appWidth`
|
|
863
|
+
(computeAppWidth; manifest width/focusWidth = legacy fallback). Raw CSS —
|
|
864
|
+
min()/vw OK; vw resolves against the real viewport, so maxWidth clamps it
|
|
865
|
+
to the device box. Without this the preview lies about the channel width. */}
|
|
866
|
+
<div
|
|
867
|
+
style={{
|
|
868
|
+
// TRUE width, never clamped: a panel is its declared width always, so
|
|
869
|
+
// an app wider than the device box CLIPS at the edge (like a real
|
|
870
|
+
// too-small screen) — it must never squeeze into garbage.
|
|
871
|
+
width: computeAppWidth(resolveActiveLayout(selectedDef as never, store), store, theme?.appSize) ?? "100%",
|
|
872
|
+
margin: "0 auto",
|
|
873
|
+
transition: "width 200ms",
|
|
874
|
+
...((selectedDef?.fluidHeight ?? true) ? { height: "100%" } : {}),
|
|
875
|
+
}}
|
|
876
|
+
>
|
|
877
|
+
<StreamedUnoverseTemplate
|
|
878
|
+
client={client}
|
|
879
|
+
store={store}
|
|
880
|
+
// The workflow can SELECT the template (WORKFLOW_STARTED metadata.template →
|
|
881
|
+
// store.activeTemplate); that overrides the manual pick. Loaded via the
|
|
882
|
+
// official resources/read unoverse://templates/{name} (templates = MCP apps).
|
|
883
|
+
uri={`unoverse://templates/${selectedDef?.org ? `${selectedDef.org}/` : ""}${conn.activeTemplate ?? selected}`}
|
|
884
|
+
theme={theme}
|
|
885
|
+
// Voice state arrives via TEMPLATE STATE (the service is a producer inside
|
|
886
|
+
// the renderer); startCall/endCall/toggleMute are answered there too.
|
|
887
|
+
props={undefined}
|
|
888
|
+
voice={voiceConfig}
|
|
889
|
+
onAction={(action, data, meta) => {
|
|
890
|
+
// Controlled composer: the Input emits "input" on every keystroke → the
|
|
891
|
+
// shared draft. A send button (or Enter) emits "sendMessage".
|
|
892
|
+
if (action === "input") {
|
|
893
|
+
store.setDraft(typeof data?.text === "string" ? data.text : "");
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
// Disclosure (FAQ pill, etc.) is a self-contained COMPONENT — it holds its own
|
|
897
|
+
// open/closed state via setValue on its slice. No channel glue, no store plane.
|
|
898
|
+
if (action !== "sendMessage") {
|
|
899
|
+
// Any OTHER named server action (submit, selectCourse…) goes over the
|
|
900
|
+
// DATA PLANE (user_action) with the firing component's identity +
|
|
901
|
+
// submitted state. A submission RESOLVES the component's WAITING node
|
|
902
|
+
// execution — the node returns the answers as standard promise output
|
|
903
|
+
// and the workflow continues (UNOVERSE_NODE_RUNTIME § Component
|
|
904
|
+
// outputs). No new workflow run is fired.
|
|
905
|
+
const t = typeof action === "string" ? action : (action as { type?: string })?.type;
|
|
906
|
+
if (!t || !live) return;
|
|
907
|
+
// Native first: if the server is ELICITING on this session (§3.3),
|
|
908
|
+
// the submitted state answers it — one accept completes the waiting
|
|
909
|
+
// node and the held tool call. Else fall back to user_action.
|
|
910
|
+
const state = (meta?.state ?? data) as Record<string, unknown>;
|
|
911
|
+
// Inspector capture (BEFORE the elicitation branch so both rungs
|
|
912
|
+
// record): project through the app component's declared `outputs`;
|
|
913
|
+
// no declaration → show the full submitted state.
|
|
914
|
+
{
|
|
915
|
+
const pcName = (selectedDef?.app?.previewComponents ?? selectedDef?.previewComponents ?? [])[0];
|
|
916
|
+
const outs = pcName
|
|
917
|
+
? (components.find((c) => c.name.toLowerCase() === pcName.toLowerCase()) as (Def & { outputs?: Record<string, unknown> }) | undefined)?.outputs
|
|
918
|
+
: undefined;
|
|
919
|
+
const keys = outs ? Object.keys(outs) : null;
|
|
920
|
+
setLastOutput({
|
|
921
|
+
at: new Date().toLocaleTimeString(),
|
|
922
|
+
data: keys ? Object.fromEntries(keys.filter((k) => state[k] !== undefined).map((k) => [k, state[k]])) : state,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
if (conn.resolveElicitation(state)) return;
|
|
926
|
+
conn.sendAction(t, {
|
|
927
|
+
componentNodeId: meta?.nodeId,
|
|
928
|
+
// The component's OWN chatId is the TURN id — the waiting node
|
|
929
|
+
// execution is keyed `<turn>:<nodeId>`.
|
|
930
|
+
componentChatId: meta?.chatId,
|
|
931
|
+
componentState: state,
|
|
932
|
+
});
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
// A suggestion naming an APP launches it instead of sending its text (same
|
|
936
|
+
// contract as the embedded channel): look up that app's manifest binding in
|
|
937
|
+
// the loaded list, pre-open the focus surface as the loading state, and fire
|
|
938
|
+
// ITS trigger — no user turn, nothing printed to the conversation.
|
|
939
|
+
const appRef = typeof (data as Record<string, unknown>)?.app === "string" ? ((data as Record<string, unknown>).app as string).toLowerCase() : "";
|
|
940
|
+
if (appRef) {
|
|
941
|
+
const target = templates.find((t) => `${(t.org ?? "").toLowerCase()}/${t.name.toLowerCase()}` === appRef || t.name.toLowerCase() === appRef);
|
|
942
|
+
const b = target?.app?.binding ?? target?.binding;
|
|
943
|
+
if (!b?.trigger) {
|
|
944
|
+
console.error("[workbench] app launch: no binding for", appRef);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
// Write the app's declared default state (open NAME) into template state
|
|
948
|
+
// on load — templates branch on `defaultState` by name. "template" apps
|
|
949
|
+
// ARE the surface, so there's nothing to write.
|
|
950
|
+
const declared = target?.app?.defaultState ?? target?.app?.mode ?? "template";
|
|
951
|
+
if (declared !== "template") store.mergeTemplateState({ defaultState: declared });
|
|
952
|
+
if (live) conn.trigger({ app: appRef, metadata: { targetTriggerNode: b.trigger } });
|
|
953
|
+
else console.warn("[workbench] app launch is LIVE-only (mock has no workflow):", appRef);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const text = (typeof data?.text === "string" && data.text) || store.getDraft();
|
|
957
|
+
if (!text.trim()) return;
|
|
958
|
+
if (live) {
|
|
959
|
+
// LIVE: optimistic user turn + REST workflow trigger (components stream back over WS).
|
|
960
|
+
conn.sendMessage(text);
|
|
961
|
+
} else {
|
|
962
|
+
// MOCK: append a user turn locally + open the assistant turn so the
|
|
963
|
+
// avatar + thinking dots show immediately (mirrors the live path).
|
|
964
|
+
store.addUserMessage(chatIdFor(selected ?? "app"), text);
|
|
965
|
+
store.startResponse(chatIdFor(selected ?? "app"));
|
|
966
|
+
}
|
|
967
|
+
store.setDraft(""); // clear the composer after sending
|
|
968
|
+
setVersion((v) => v + 1);
|
|
969
|
+
}}
|
|
970
|
+
/>
|
|
971
|
+
</div>
|
|
972
|
+
</div>
|
|
973
|
+
)}
|
|
974
|
+
|
|
975
|
+
{/* LIVE OUTPUT INSPECTOR — subtle, right edge. The last submitted app output:
|
|
976
|
+
exactly what the waiting node returned and the held tool call carried back
|
|
977
|
+
to the calling LLM. Dev chrome only — never part of the rendered app. */}
|
|
978
|
+
{live && lastOutput && (
|
|
979
|
+
<div
|
|
980
|
+
className="fixed bottom-5 right-5 z-50 w-80 rounded-xl border shadow-lg"
|
|
981
|
+
style={{ background: theme.color["surface.base"], borderColor: theme.color["border.subtle"] }}
|
|
982
|
+
>
|
|
983
|
+
<div className="flex items-center justify-between px-3.5 pt-2.5">
|
|
984
|
+
<div className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: theme.color["text.tertiary"] }}>
|
|
985
|
+
Output · {lastOutput.at}
|
|
986
|
+
</div>
|
|
987
|
+
<button
|
|
988
|
+
className="cursor-pointer border-0 bg-transparent p-0 text-xs leading-none"
|
|
989
|
+
style={{ color: theme.color["text.tertiary"] }}
|
|
990
|
+
onClick={() => setLastOutput(null)}
|
|
991
|
+
aria-label="Dismiss output"
|
|
992
|
+
>
|
|
993
|
+
✕
|
|
994
|
+
</button>
|
|
995
|
+
</div>
|
|
996
|
+
<pre className="m-0 max-h-64 overflow-auto px-3.5 py-2.5 text-[11px] leading-relaxed" style={{ color: theme.color["text.primary"] }}>
|
|
997
|
+
{JSON.stringify(lastOutput.data, null, 2)}
|
|
998
|
+
</pre>
|
|
999
|
+
</div>
|
|
1000
|
+
)}
|
|
1001
|
+
</section>
|
|
1002
|
+
</div>
|
|
1003
|
+
</div>
|
|
1004
|
+
);
|
|
1005
|
+
}
|