@unoverse-platform/studio 0.1.56 → 0.1.58
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/local/plugin.ts +5 -66
- package/local/sample-template/chat/components/assistant-turn.yaml +40 -0
- package/local/sample-template/chat/components/core-focus.yaml +46 -0
- package/local/sample-template/chat/components/core.yaml +41 -0
- package/local/sample-template/chat/components/focus-overlay.yaml +20 -0
- package/local/sample-template/chat/components/header.yaml +53 -0
- package/local/sample-template/chat/components/suggestions.yaml +142 -0
- package/local/sample-template/chat/components/thinking-dots.yaml +38 -0
- package/local/sample-template/chat/components/user-turn.yaml +22 -0
- package/local/sample-template/chat/layouts/focused.yaml +12 -0
- package/local/sample-template/chat/layouts/standard.yaml +9 -0
- package/local/sample-template/chat/manifest.yaml +41 -0
- package/local/sample-template/chat/states/conversation.yaml +21 -0
- package/local/sample-template/chat/states/welcome.yaml +119 -0
- package/local/scaffold.mjs +15 -2
- package/package.json +1 -1
- package/screens/DesignSystemView.tsx +4 -1
- package/screens/NativeAppHost.tsx +2 -2
- package/screens/TemplatesView.tsx +28 -4
- package/src/App.tsx +14 -70
- package/src/OfflineSession.tsx +50 -0
- package/vendor/sdk/appEngine.tsx +15 -2
- package/vendor/sdk/lib/core/connection.ts +11 -0
- package/vendor/sdk/lib/core/store.ts +17 -0
- package/src/ConnectUniverse.tsx +0 -232
- package/src/PublishDialog.tsx +0 -309
- package/src/UniverseSession.tsx +0 -121
- package/src/universes.ts +0 -210
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
type: Box
|
|
2
|
+
visibleWhen: isEmpty
|
|
3
|
+
style:
|
|
4
|
+
direction: column
|
|
5
|
+
flex: 1
|
|
6
|
+
minHeight: "0"
|
|
7
|
+
overflow: hidden
|
|
8
|
+
justify: end
|
|
9
|
+
gap: "6"
|
|
10
|
+
padding: [ "8", "8", "4", "8" ]
|
|
11
|
+
children:
|
|
12
|
+
- type: Box
|
|
13
|
+
style:
|
|
14
|
+
direction: column
|
|
15
|
+
align: start
|
|
16
|
+
gap: "2"
|
|
17
|
+
maxWidth: reading
|
|
18
|
+
padding: [ "0", "8", "0", "0" ]
|
|
19
|
+
children:
|
|
20
|
+
- type: Text
|
|
21
|
+
style:
|
|
22
|
+
font: display
|
|
23
|
+
color: text.primary
|
|
24
|
+
lineHeight: none
|
|
25
|
+
align: start
|
|
26
|
+
value: How can I help?
|
|
27
|
+
- type: Text
|
|
28
|
+
style:
|
|
29
|
+
font: body.lg
|
|
30
|
+
color: text.secondary
|
|
31
|
+
align: start
|
|
32
|
+
value: Ask a question, or pick a place to start.
|
|
33
|
+
- type: Each
|
|
34
|
+
style:
|
|
35
|
+
direction: row
|
|
36
|
+
align: start
|
|
37
|
+
gap: "4"
|
|
38
|
+
width: full
|
|
39
|
+
padding: [ "2", "0", "2", "0" ]
|
|
40
|
+
template:
|
|
41
|
+
type: Button
|
|
42
|
+
action: sendMessage
|
|
43
|
+
style:
|
|
44
|
+
position: relative
|
|
45
|
+
flex: 1 1 0
|
|
46
|
+
minWidth: 0
|
|
47
|
+
height: "90"
|
|
48
|
+
padding: "0"
|
|
49
|
+
radius: 2xl
|
|
50
|
+
border: none
|
|
51
|
+
overflow: hidden
|
|
52
|
+
cursor: pointer
|
|
53
|
+
transition: all 200ms ease
|
|
54
|
+
shadow: lg
|
|
55
|
+
hover:
|
|
56
|
+
shadow: xl
|
|
57
|
+
transform: scale(1.02)
|
|
58
|
+
children:
|
|
59
|
+
- type: Image
|
|
60
|
+
bind:
|
|
61
|
+
src: image
|
|
62
|
+
alt: title
|
|
63
|
+
style:
|
|
64
|
+
position: absolute
|
|
65
|
+
inset: "0"
|
|
66
|
+
width: full
|
|
67
|
+
height: full
|
|
68
|
+
fit: cover
|
|
69
|
+
- type: Box
|
|
70
|
+
style:
|
|
71
|
+
position: absolute
|
|
72
|
+
inset: "0"
|
|
73
|
+
direction: column
|
|
74
|
+
align: start
|
|
75
|
+
justify: end
|
|
76
|
+
gap: "2"
|
|
77
|
+
padding: "4"
|
|
78
|
+
background: overlay.scrim
|
|
79
|
+
textAlign: start
|
|
80
|
+
children:
|
|
81
|
+
- type: Text
|
|
82
|
+
bind:
|
|
83
|
+
value: tag
|
|
84
|
+
style:
|
|
85
|
+
font: caption
|
|
86
|
+
color: white
|
|
87
|
+
lineHeight: tight
|
|
88
|
+
- type: Text
|
|
89
|
+
bind:
|
|
90
|
+
value: title
|
|
91
|
+
style:
|
|
92
|
+
font: title
|
|
93
|
+
color: white
|
|
94
|
+
lineHeight: tight
|
|
95
|
+
align: start
|
|
96
|
+
- type: Text
|
|
97
|
+
bind:
|
|
98
|
+
value: subtitle
|
|
99
|
+
style:
|
|
100
|
+
font: body.sm
|
|
101
|
+
color: white
|
|
102
|
+
lineHeight: snug
|
|
103
|
+
align: start
|
|
104
|
+
items:
|
|
105
|
+
- image: https://picsum.photos/seed/explore/840/1160
|
|
106
|
+
tag: Explore
|
|
107
|
+
title: What can you do?
|
|
108
|
+
subtitle: See what this assistant can help with.
|
|
109
|
+
text: What can you help me with?
|
|
110
|
+
- image: https://picsum.photos/seed/learn/840/1160
|
|
111
|
+
tag: Learn
|
|
112
|
+
title: Show me around
|
|
113
|
+
subtitle: A quick tour of how this works.
|
|
114
|
+
text: Give me a quick tour of what I can do here
|
|
115
|
+
- image: https://picsum.photos/seed/start/840/1160
|
|
116
|
+
tag: Start
|
|
117
|
+
title: Jump right in
|
|
118
|
+
subtitle: Describe what you need in your own words.
|
|
119
|
+
text: I would like some help getting started
|
package/local/scaffold.mjs
CHANGED
|
@@ -43,7 +43,11 @@ export function checkProjectName(name) {
|
|
|
43
43
|
const WELCOME = [
|
|
44
44
|
"unoverse: '1.0'",
|
|
45
45
|
"kind: component",
|
|
46
|
-
|
|
46
|
+
// name === filename (the item-key rule the publish lint enforces), and the closed
|
|
47
|
+
// style vocabulary has `font`, not `fontSize` — both caught by linting the scaffold
|
|
48
|
+
// output itself, which is the guard: the first component a developer ever sees must
|
|
49
|
+
// lint clean, or every rule the platform states is undermined by its own starter.
|
|
50
|
+
"name: welcome",
|
|
47
51
|
"category: Content",
|
|
48
52
|
"description: A first component. Edit this file and Studio updates as you save.",
|
|
49
53
|
"root:",
|
|
@@ -52,7 +56,7 @@ const WELCOME = [
|
|
|
52
56
|
" children:",
|
|
53
57
|
" - type: Text",
|
|
54
58
|
" value: Hello from your first component",
|
|
55
|
-
" style: {
|
|
59
|
+
" style: { font: 'body.lg', color: 'text.primary' }",
|
|
56
60
|
"",
|
|
57
61
|
].join("\n");
|
|
58
62
|
|
|
@@ -81,6 +85,15 @@ export function createProject(home, name) {
|
|
|
81
85
|
if (existsSync(sampleStyles) && readdirSync(join(root, "styles")).length === 0) {
|
|
82
86
|
cpSync(sampleStyles, join(root, "styles"), { recursive: true });
|
|
83
87
|
}
|
|
88
|
+
|
|
89
|
+
// A worked example TEMPLATE (a generic chat: welcome hero, suggestions, focus
|
|
90
|
+
// overlay — the bpp-chat-layout shape, neutralized to foundation tokens), so
|
|
91
|
+
// templates/ is never an empty folder and the first app is a rename away. Same
|
|
92
|
+
// rule as the styles: copied only into an empty folder, then the developer's.
|
|
93
|
+
const sampleTemplate = join(dirname(fileURLToPath(import.meta.url)), "sample-template/chat");
|
|
94
|
+
if (existsSync(sampleTemplate) && readdirSync(join(root, "templates")).length === 0) {
|
|
95
|
+
cpSync(sampleTemplate, join(root, "templates/chat"), { recursive: true });
|
|
96
|
+
}
|
|
84
97
|
return root;
|
|
85
98
|
}
|
|
86
99
|
|
package/package.json
CHANGED
|
@@ -167,7 +167,10 @@ export function DesignSystemView({
|
|
|
167
167
|
{isOverride("text", name) && <OverrideMark />}
|
|
168
168
|
</div>
|
|
169
169
|
<div className="min-w-0 flex-1">
|
|
170
|
-
|
|
170
|
+
{/* A typography token carries family/size/weight/line-height only — never a
|
|
171
|
+
colour — so the specimen would otherwise inherit Studio's ambient text
|
|
172
|
+
colour and wash out. Paint it in the THEME's ink, like the family name below. */}
|
|
173
|
+
<div style={{ ...(style as React.CSSProperties), color: ink }}>The quick brown fox</div>
|
|
171
174
|
<div className="mt-0.5 text-[10px]" style={{ color: sub }}>
|
|
172
175
|
{fams.length ? (
|
|
173
176
|
<>
|
|
@@ -33,7 +33,7 @@ export function NativeAppHost({
|
|
|
33
33
|
* (appEngine) and this relays it, so the host's badge can tell the truth.
|
|
34
34
|
* `refused` carries the server's terminal refusal text (register_session refused
|
|
35
35
|
* the session — public-entry gate, requires.role, stale token); undefined otherwise. */
|
|
36
|
-
onStatus?: (s: { ready: boolean; connected: boolean; refused?: string }) => void;
|
|
36
|
+
onStatus?: (s: { ready: boolean; connected: boolean; refused?: string; failure?: { nodeIds: string[]; errors: Record<string, string> } }) => void;
|
|
37
37
|
}) {
|
|
38
38
|
const [html, setHtml] = useState<string | null>(null);
|
|
39
39
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -47,7 +47,7 @@ export function NativeAppHost({
|
|
|
47
47
|
if (e.source !== frame.current?.contentWindow) return;
|
|
48
48
|
if (e.data?.type === "unoverse:size" && e.data.width) setAppWidth(String(e.data.width));
|
|
49
49
|
if (e.data?.type === "unoverse:status")
|
|
50
|
-
onStatus?.({ ready: !!e.data.ready, connected: !!e.data.connected, refused: typeof e.data.refused === "string" ? e.data.refused : undefined });
|
|
50
|
+
onStatus?.({ ready: !!e.data.ready, connected: !!e.data.connected, refused: typeof e.data.refused === "string" ? e.data.refused : undefined, failure: e.data.failure ?? undefined });
|
|
51
51
|
};
|
|
52
52
|
window.addEventListener("message", onSize);
|
|
53
53
|
return () => window.removeEventListener("message", onSize);
|
|
@@ -217,7 +217,7 @@ export function TemplatesView({
|
|
|
217
217
|
// connection and posts `unoverse:status` up; the in-page `conn` below is never
|
|
218
218
|
// opened in native mode, so a badge reading it said "Offline" while the app
|
|
219
219
|
// streamed (observed live). Native status wins whenever the iframe has reported.
|
|
220
|
-
const [nativeStatus, setNativeStatus] = useState<{ ready: boolean; connected: boolean; refused?: string } | null>(null);
|
|
220
|
+
const [nativeStatus, setNativeStatus] = useState<{ ready: boolean; connected: boolean; refused?: string; failure?: { nodeIds: string[]; errors: Record<string, string> } } | null>(null);
|
|
221
221
|
useEffect(() => setNativeStatus(null), [selected, live]);
|
|
222
222
|
// LIVE output inspector — the last submitted app output, projected through the
|
|
223
223
|
// component's declared `outputs` (the same object the held tool call returns to
|
|
@@ -519,6 +519,11 @@ export function TemplatesView({
|
|
|
519
519
|
nodeId: `${c.name}-${i}`,
|
|
520
520
|
component: {
|
|
521
521
|
type: c.name,
|
|
522
|
+
// The ADDRESS, exactly as a real server arrival carries it: the type alone
|
|
523
|
+
// cannot rebuild it (names are unique only within a tier — store.ts), so a
|
|
524
|
+
// mock seed without it resolved bare `unoverse://components/<name>` and the
|
|
525
|
+
// resolver rightly refused to guess between two orgs' clones.
|
|
526
|
+
componentUrl: `unoverse://components/${c.org ? `${c.org}/` : ""}${c.name}`,
|
|
522
527
|
props: { ...defaultsOf(c), ...restState, defaultState: "inline" },
|
|
523
528
|
},
|
|
524
529
|
});
|
|
@@ -545,9 +550,12 @@ export function TemplatesView({
|
|
|
545
550
|
Promise.all([
|
|
546
551
|
// The header's org scope filters the list ("all" → every org's apps, each
|
|
547
552
|
// tagged); the selected app's org still picks its own preview theme.
|
|
548
|
-
// Components are universal
|
|
553
|
+
// Components are SCOPED like the templates: an org sees "universal + its own".
|
|
554
|
+
// Fetched unscoped, a cloned org's same-named component (design/lyceum cloned
|
|
555
|
+
// from design/bpp → two bare `journey-finder`s) sat beside the original and
|
|
556
|
+
// Mock seeding picked whichever listed first.
|
|
549
557
|
authedFetch(`/dev/templates${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`).then((r) => (r.ok ? r.json() : { templates: [] })),
|
|
550
|
-
authedFetch(
|
|
558
|
+
authedFetch(`/dev/components${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`).then((r) => (r.ok ? r.json() : { components: [] })),
|
|
551
559
|
])
|
|
552
560
|
.then(([t, c]) => {
|
|
553
561
|
const tl: Def[] = t.templates ?? [];
|
|
@@ -599,7 +607,14 @@ export function TemplatesView({
|
|
|
599
607
|
const wantComponent = Boolean(layoutSel) || Boolean((activeState?.where as { field?: string } | undefined)?.field);
|
|
600
608
|
let seededSet: Def[] = [];
|
|
601
609
|
if (!wantEmpty) {
|
|
602
|
-
|
|
610
|
+
// ORG-AWARE: names are unique only within a tier, so a bare preview ref must
|
|
611
|
+
// resolve to the SELECTED APP's own org first, then the universal tier — never
|
|
612
|
+
// to another org's same-named clone (which the "all" scope still lists).
|
|
613
|
+
const appOrg = (selectedDef?.org ?? "").toLowerCase();
|
|
614
|
+
const byName = (name: string) => {
|
|
615
|
+
const hits = components.filter((c) => c.name.toLowerCase() === name.toLowerCase());
|
|
616
|
+
return hits.find((c) => (c.org ?? "").toLowerCase() === appOrg) ?? hits.find((c) => !c.org) ?? hits[0];
|
|
617
|
+
};
|
|
603
618
|
const set = entry
|
|
604
619
|
? entry.map(byName).filter((c): c is Def => Boolean(c))
|
|
605
620
|
: wantComponent
|
|
@@ -816,6 +831,15 @@ export function TemplatesView({
|
|
|
816
831
|
{nativeStatus.refused}
|
|
817
832
|
</div>
|
|
818
833
|
)}
|
|
834
|
+
{nativeStatus?.failure && (
|
|
835
|
+
/* The last run's WORKFLOW_ERROR, verbatim per node — developer surface
|
|
836
|
+
only; a production app never renders this. */
|
|
837
|
+
<div className="mb-2 rounded border px-2 py-1.5 text-[11px] leading-snug" style={{ borderColor: "#ef4444", color: "#ef4444" }}>
|
|
838
|
+
{Object.entries(nativeStatus.failure.errors).map(([nodeId, err]) => (
|
|
839
|
+
<div key={nodeId}><span className="font-medium">{nodeId}</span>: {err}</div>
|
|
840
|
+
))}
|
|
841
|
+
</div>
|
|
842
|
+
)}
|
|
819
843
|
{/* Read-only: the app decides. Server is environment; Workflow + Trigger
|
|
820
844
|
come from the selected app's manifest binding (no manual entry). */}
|
|
821
845
|
{(
|
package/src/App.tsx
CHANGED
|
@@ -18,11 +18,8 @@ import {
|
|
|
18
18
|
PromptBlocksView,
|
|
19
19
|
} from "@unoverse/studio";
|
|
20
20
|
import { client, usePersistentState } from "./host";
|
|
21
|
-
import {
|
|
22
|
-
import { ConnectUniverse } from "./ConnectUniverse";
|
|
23
|
-
import { PublishDialog } from "./PublishDialog";
|
|
21
|
+
import { OfflineSession } from "./OfflineSession";
|
|
24
22
|
import { NewProject } from "./NewProject";
|
|
25
|
-
import { loadUniverses, type Universe } from "./universes";
|
|
26
23
|
// Local-only screens. Nodes are AUTHORED and TESTED here and nowhere else: Canvas keeps
|
|
27
24
|
// the node library (using nodes), the marketplace acquires them (a universe's job).
|
|
28
25
|
import { NodesView } from "./NodesView";
|
|
@@ -40,37 +37,23 @@ const NAV: { key: View; label: string; group: string }[] = [
|
|
|
40
37
|
];
|
|
41
38
|
|
|
42
39
|
/**
|
|
43
|
-
* The shell,
|
|
40
|
+
* The shell, inside the permanently signed-out session.
|
|
44
41
|
*
|
|
45
|
-
* The provider must sit ABOVE every screen, because a screen deep in the tree
|
|
46
|
-
* (TemplatesView)
|
|
47
|
-
*
|
|
42
|
+
* The provider must sit ABOVE every screen, because a shared screen deep in the tree
|
|
43
|
+
* (TemplatesView) calls `useAuth()` unconditionally. Local Studio has no login and no
|
|
44
|
+
* publish button — identity lives in the CLI at publish time (`unoverse publish`;
|
|
45
|
+
* LOCAL_STUDIO.md §Publish is terminal-only) — so the session it provides is the
|
|
46
|
+
* signed-out one, always.
|
|
48
47
|
*/
|
|
49
48
|
export function App() {
|
|
50
|
-
// Which universe is selected, remembered across refreshes. The list itself lives in
|
|
51
|
-
// localStorage (universes.ts); this is only the pointer into it.
|
|
52
|
-
const [activeUrl, setActiveUrl] = usePersistentState<string | null>("studio:universe", null);
|
|
53
|
-
const active = loadUniverses().find((u) => u.url === activeUrl) ?? null;
|
|
54
|
-
|
|
55
49
|
return (
|
|
56
|
-
<
|
|
57
|
-
<Shell
|
|
58
|
-
|
|
59
|
-
onSelectUniverse={(u: Universe | null) => setActiveUrl(u?.url ?? null)}
|
|
60
|
-
/>
|
|
61
|
-
</UniverseSession>
|
|
50
|
+
<OfflineSession>
|
|
51
|
+
<Shell />
|
|
52
|
+
</OfflineSession>
|
|
62
53
|
);
|
|
63
54
|
}
|
|
64
55
|
|
|
65
|
-
function Shell({
|
|
66
|
-
active,
|
|
67
|
-
onSelectUniverse,
|
|
68
|
-
}: {
|
|
69
|
-
active: Universe | null;
|
|
70
|
-
onSelectUniverse: (u: Universe | null) => void;
|
|
71
|
-
}) {
|
|
72
|
-
const [connectOpen, setConnectOpen] = useState(false);
|
|
73
|
-
const [publishOpen, setPublishOpen] = useState(false);
|
|
56
|
+
function Shell() {
|
|
74
57
|
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
|
75
58
|
const [view, setView] = usePersistentState<View>("studio:view", "components");
|
|
76
59
|
const [org, setOrg] = usePersistentState<string>("studio:org", "all");
|
|
@@ -168,43 +151,12 @@ function Shell({
|
|
|
168
151
|
>
|
|
169
152
|
{variant}
|
|
170
153
|
</button>
|
|
171
|
-
{/*
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
onClick={() => setConnectOpen(true)}
|
|
175
|
-
className="rounded border border-gray-700 px-2 py-1 text-gray-300 hover:text-gray-100"
|
|
176
|
-
title="Where your work publishes to"
|
|
177
|
-
>
|
|
178
|
-
{active ? active.label : "Not connected"}
|
|
179
|
-
</button>
|
|
180
|
-
{/* PUBLISHES THE SELECTED PROJECT. Disabled rather than hidden when there is
|
|
181
|
-
nowhere to send or nothing selected: a missing button reads as a missing
|
|
182
|
-
feature, a disabled one with a reason reads as a next step. */}
|
|
183
|
-
<button
|
|
184
|
-
onClick={() => setPublishOpen(true)}
|
|
185
|
-
disabled={!active || org === "all"}
|
|
186
|
-
title={
|
|
187
|
-
!active
|
|
188
|
-
? "Connect a universe first"
|
|
189
|
-
: org === "all"
|
|
190
|
-
? "Pick one project to publish"
|
|
191
|
-
: `Publish ${org} to ${active.label}`
|
|
192
|
-
}
|
|
193
|
-
className={
|
|
194
|
-
active && org !== "all"
|
|
195
|
-
? "rounded bg-emerald-500 px-3 py-1 font-medium text-emerald-950 hover:bg-emerald-400"
|
|
196
|
-
: "rounded border border-gray-700 px-2 py-1 text-gray-500 disabled:cursor-not-allowed"
|
|
197
|
-
}
|
|
198
|
-
>
|
|
199
|
-
Publish
|
|
200
|
-
</button>
|
|
154
|
+
{/* NO PUBLISH BUTTON, DELIBERATELY (LOCAL_STUDIO.md §Publish is terminal-only).
|
|
155
|
+
Studio renders; shipping is `unoverse publish` in the terminal the developer
|
|
156
|
+
already has open, which signs in by itself when it needs to. */}
|
|
201
157
|
</div>
|
|
202
158
|
</header>
|
|
203
159
|
|
|
204
|
-
{publishOpen && active && org !== "all" && (
|
|
205
|
-
<PublishDialog project={org} universe={active} onClose={() => setPublishOpen(false)} />
|
|
206
|
-
)}
|
|
207
|
-
|
|
208
160
|
{/* Created, then SELECTED. Making a project and leaving the developer looking at the
|
|
209
161
|
old one would be a step they have to discover. */}
|
|
210
162
|
{newProjectOpen && (
|
|
@@ -218,14 +170,6 @@ function Shell({
|
|
|
218
170
|
/>
|
|
219
171
|
)}
|
|
220
172
|
|
|
221
|
-
{connectOpen && (
|
|
222
|
-
<ConnectUniverse
|
|
223
|
-
active={active}
|
|
224
|
-
onSelect={onSelectUniverse}
|
|
225
|
-
onClose={() => setConnectOpen(false)}
|
|
226
|
-
/>
|
|
227
|
-
)}
|
|
228
|
-
|
|
229
173
|
{/* The shared screens size themselves with `flex-1`, so the host must be a flex
|
|
230
174
|
column. As a grid cell they would collapse to their content height. */}
|
|
231
175
|
<main className="flex min-h-0 flex-col overflow-hidden">
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The permanently signed-out session local Studio hands the shared screens.
|
|
3
|
+
*
|
|
4
|
+
* Local Studio has NO auth chrome and NO publish path (LOCAL_STUDIO.md §Publish is
|
|
5
|
+
* terminal-only, 2026-08-21): authoring is offline, and identity exists only in the CLI
|
|
6
|
+
* at publish time (`unoverse login`). But the shared screens are also Canvas's, where a
|
|
7
|
+
* real session exists, so they call `useAuth()` unconditionally — and react-oidc-context
|
|
8
|
+
* warns loudly whenever the context VALUE is falsy. So the host supplies a real value
|
|
9
|
+
* meaning SIGNED OUT, permanently.
|
|
10
|
+
*
|
|
11
|
+
* The sign-in actions throw rather than no-op: reaching one from local Studio means a
|
|
12
|
+
* shared screen forgot which host it is in (the `hasAuth` capability flag is the check
|
|
13
|
+
* it should have made), and silence would hide that.
|
|
14
|
+
*/
|
|
15
|
+
import type { ReactNode } from "react";
|
|
16
|
+
import { AuthContext, type AuthContextProps } from "react-oidc-context";
|
|
17
|
+
|
|
18
|
+
function offline(name: string) {
|
|
19
|
+
return async () => {
|
|
20
|
+
throw new Error(`${name} is not available in local Studio. Publishing signs in from the terminal: unoverse publish.`);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cast because `AuthContextProps` also carries the oidc-client-ts settings and event
|
|
26
|
+
* surface, none of which exists without a provider and none of which the screens touch.
|
|
27
|
+
*/
|
|
28
|
+
const OFFLINE_SESSION = {
|
|
29
|
+
isAuthenticated: false,
|
|
30
|
+
isLoading: false,
|
|
31
|
+
user: undefined,
|
|
32
|
+
error: undefined,
|
|
33
|
+
activeNavigator: undefined,
|
|
34
|
+
signinRedirect: offline("signinRedirect"),
|
|
35
|
+
signinPopup: offline("signinPopup"),
|
|
36
|
+
signinSilent: offline("signinSilent"),
|
|
37
|
+
signoutRedirect: offline("signoutRedirect"),
|
|
38
|
+
signoutPopup: offline("signoutPopup"),
|
|
39
|
+
signoutSilent: offline("signoutSilent"),
|
|
40
|
+
removeUser: offline("removeUser"),
|
|
41
|
+
clearStaleState: offline("clearStaleState"),
|
|
42
|
+
querySessionStatus: offline("querySessionStatus"),
|
|
43
|
+
revokeTokens: offline("revokeTokens"),
|
|
44
|
+
startSilentRenew: () => {},
|
|
45
|
+
stopSilentRenew: () => {},
|
|
46
|
+
} as unknown as AuthContextProps;
|
|
47
|
+
|
|
48
|
+
export function OfflineSession({ children }: { children: ReactNode }) {
|
|
49
|
+
return <AuthContext.Provider value={OFFLINE_SESSION}>{children}</AuthContext.Provider>;
|
|
50
|
+
}
|
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -188,11 +188,24 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
188
188
|
// connection, so a host badge watching its own (never-opened) in-page connection
|
|
189
189
|
// reads "offline" forever while this session streams (observed live). Report the
|
|
190
190
|
// truth the same way size is reported; hosts that don't listen lose nothing.
|
|
191
|
+
//
|
|
192
|
+
// `failure` is the last run's WORKFLOW_ERROR detail (which node, what error) —
|
|
193
|
+
// DEVELOPER information. A production embed host doesn't listen and its users see
|
|
194
|
+
// nothing; Studio's preview listens and shows it, so a dead run stops being a
|
|
195
|
+
// silence only the server logs could explain.
|
|
196
|
+
const [failure, setFailure] = useState(store.getLastFailure());
|
|
197
|
+
useEffect(() => store.subscribe(() => setFailure(store.getLastFailure())), [store]);
|
|
191
198
|
useEffect(() => {
|
|
192
199
|
// `refused` carries the server's terminal refusal text (register_session isError) —
|
|
193
200
|
// the one state a "Connecting…" badge must not sit on forever.
|
|
194
|
-
postToEmbedHost({
|
|
195
|
-
|
|
201
|
+
postToEmbedHost({
|
|
202
|
+
type: "unoverse:status",
|
|
203
|
+
ready: !!conn.isReady,
|
|
204
|
+
connected: !!conn.isConnected,
|
|
205
|
+
refused: conn.refusal ?? undefined,
|
|
206
|
+
failure: failure ?? undefined,
|
|
207
|
+
});
|
|
208
|
+
}, [conn.isReady, conn.isConnected, conn.refusal, failure]);
|
|
196
209
|
|
|
197
210
|
// VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
|
|
198
211
|
// in the shared renderer (StreamedUnoverseTemplate), identical to Studio. The host only
|
|
@@ -114,8 +114,19 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
|
|
|
114
114
|
// born WITH its first component, so the streaming-and-empty window never exists.
|
|
115
115
|
if (m.state === "WORKFLOW_STARTED" || m.state === "THINKING" || m.state === "RESPONDING" || m.state === "WAITING") {
|
|
116
116
|
if (m.chatId) store.startResponse(m.chatId);
|
|
117
|
+
// A new run supersedes the previous run's failure detail.
|
|
118
|
+
if (m.state === "WORKFLOW_STARTED") store.setLastFailure(null);
|
|
117
119
|
} else if (m.state === "WORKFLOW_COMPLETED" || m.state === "COMPLETE") {
|
|
118
120
|
store.completeResponse(m.chatId);
|
|
121
|
+
} else if (m.state === "ERROR" || m.state === "WORKFLOW_ERROR") {
|
|
122
|
+
// SETTLE the turn exactly as completion does — a failed run must not leave the
|
|
123
|
+
// thinking state open forever (the eternal dots). What DIFFERS is invisible to
|
|
124
|
+
// a production user: the failure detail is stored for a developer host to
|
|
125
|
+
// read (Studio's preview); the SDK renders none of it.
|
|
126
|
+
store.completeResponse(m.chatId);
|
|
127
|
+
const failure = (m.metadata as { failure?: { nodeIds: string[]; errors: Record<string, string> } } | undefined)
|
|
128
|
+
?.failure;
|
|
129
|
+
if (failure) store.setLastFailure(failure);
|
|
119
130
|
}
|
|
120
131
|
// Lifecycle machine — the global turn status (projected for visibleWhen).
|
|
121
132
|
if (m.state === "WORKFLOW_STARTED" || m.state === "THINKING") store.setLifecycle("thinking");
|
|
@@ -274,6 +274,23 @@ export class ComponentStore {
|
|
|
274
274
|
this.bump();
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// --- lastFailure: DEVELOPER detail from WORKFLOW_ERROR, stored and never rendered ---
|
|
278
|
+
//
|
|
279
|
+
// The SDK is a dumb renderer: a production app's experience does not change on a
|
|
280
|
+
// failed run (the turn settles exactly as on success). The failure detail is HELD
|
|
281
|
+
// here so a developer HOST that chooses to look (Studio's app preview) can show
|
|
282
|
+
// which node died and why. No SDK surface reads it.
|
|
283
|
+
private failure: { nodeIds: string[]; errors: Record<string, string> } | null = null;
|
|
284
|
+
|
|
285
|
+
/** The last run's failure detail, or null. Cleared when a new run starts. */
|
|
286
|
+
getLastFailure(): { nodeIds: string[]; errors: Record<string, string> } | null {
|
|
287
|
+
return this.failure;
|
|
288
|
+
}
|
|
289
|
+
setLastFailure(f: { nodeIds: string[]; errors: Record<string, string> } | null): void {
|
|
290
|
+
this.failure = f;
|
|
291
|
+
this.bump();
|
|
292
|
+
}
|
|
293
|
+
|
|
277
294
|
/** Apply a streamed event (COMPONENT_INIT or COMPONENT_DATA). */
|
|
278
295
|
apply(event: UnoverseEvent): void {
|
|
279
296
|
const key = `${event.chatId}:${event.nodeId}`;
|