@lotics/cli 0.54.2 → 0.56.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/dist/dev/wrapper_page.d.ts +5 -3
- package/dist/dev/wrapper_page.js +33 -13
- package/dist/src/cli.js +146 -57
- package/dist/starter_template.d.ts +1 -1
- package/dist/starter_template.js +120 -46
- package/dist/starter_template.test.js +20 -0
- package/package.json +1 -1
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
* wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
|
|
25
25
|
* streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
|
|
26
26
|
* the iframe aborts with { id, type: "abort" }.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* params }
|
|
27
|
+
* urlState (useUrlState): the iframe reads/writes the wrapper's address bar via
|
|
28
|
+
* urlState.get/set; set writes in place (replaceState), and browser
|
|
29
|
+
* back/forward broadcast { type: "url-state", params } back to the iframe.
|
|
30
|
+
* (In-app routing isn't here — the app owns the iframe's own url; see
|
|
31
|
+
* @lotics/app-sdk/router.)
|
|
30
32
|
*/
|
|
31
33
|
export interface WrapperPageArgs {
|
|
32
34
|
app_name: string;
|
package/dist/dev/wrapper_page.js
CHANGED
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
* wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
|
|
25
25
|
* streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
|
|
26
26
|
* the iframe aborts with { id, type: "abort" }.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* params }
|
|
27
|
+
* urlState (useUrlState): the iframe reads/writes the wrapper's address bar via
|
|
28
|
+
* urlState.get/set; set writes in place (replaceState), and browser
|
|
29
|
+
* back/forward broadcast { type: "url-state", params } back to the iframe.
|
|
30
|
+
* (In-app routing isn't here — the app owns the iframe's own url; see
|
|
31
|
+
* @lotics/app-sdk/router.)
|
|
30
32
|
*/
|
|
31
33
|
export function buildWrapperPage(args) {
|
|
32
34
|
const { app_name, app_id, workspace_id, vite_url, api_url } = args;
|
|
@@ -78,9 +80,28 @@ export function buildWrapperPage(args) {
|
|
|
78
80
|
const iframe = document.getElementById("app");
|
|
79
81
|
|
|
80
82
|
// Pass the wrapper's own origin to the app via ?lotics_host= so the app
|
|
81
|
-
// SDK can origin-lock its postMessage bridge
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
// SDK can origin-lock its postMessage bridge, and bake the saved screen
|
|
84
|
+
// (the wrapper url's _loc, kept current by AppRouter's mirror) into the src
|
|
85
|
+
// path so a refresh boots the app at that screen — mirrors the production
|
|
86
|
+
// host. _loc is honoured only when it resolves same-origin to the Vite
|
|
87
|
+
// server (a url param flowing into an iframe src is a redirect vector);
|
|
88
|
+
// anything else falls back to the app root.
|
|
89
|
+
const appSrc = new URL(VITE_URL);
|
|
90
|
+
const savedLoc = new URLSearchParams(window.location.search).get("_loc");
|
|
91
|
+
if (savedLoc) {
|
|
92
|
+
try {
|
|
93
|
+
const resolved = new URL(savedLoc, VITE_ORIGIN);
|
|
94
|
+
if (resolved.origin === VITE_ORIGIN) {
|
|
95
|
+
appSrc.pathname = resolved.pathname;
|
|
96
|
+
appSrc.search = resolved.search;
|
|
97
|
+
appSrc.hash = resolved.hash;
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// malformed _loc -> app root
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
appSrc.searchParams.set("lotics_host", window.location.origin);
|
|
104
|
+
iframe.src = appSrc.toString();
|
|
84
105
|
|
|
85
106
|
async function rpc(op, payload) {
|
|
86
107
|
const res = await fetch("/_rpc", {
|
|
@@ -197,10 +218,9 @@ export function buildWrapperPage(args) {
|
|
|
197
218
|
});
|
|
198
219
|
const qs = sp.toString();
|
|
199
220
|
const url = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
else window.history.replaceState(null, "", url);
|
|
221
|
+
// View-state writes in place — never a history entry. replaceState doesn't
|
|
222
|
+
// fire popstate, so no echo. Mirrors the production host.
|
|
223
|
+
window.history.replaceState(null, "", url);
|
|
204
224
|
return undefined;
|
|
205
225
|
}
|
|
206
226
|
|
|
@@ -286,9 +306,9 @@ export function buildWrapperPage(args) {
|
|
|
286
306
|
}
|
|
287
307
|
});
|
|
288
308
|
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
309
|
+
// Browser back/forward → broadcast the new params so useUrlState re-hydrates
|
|
310
|
+
// (the app's own set writes use replaceState — no popstate — so there's no
|
|
311
|
+
// echo). Mirrors the production host.
|
|
292
312
|
window.addEventListener("popstate", function () {
|
|
293
313
|
iframe.contentWindow.postMessage(
|
|
294
314
|
{ type: "url-state", params: readUrlParams() },
|
package/dist/src/cli.js
CHANGED
|
@@ -30242,7 +30242,7 @@ import { tmpdir } from "node:os";
|
|
|
30242
30242
|
|
|
30243
30243
|
// src/starter_template.ts
|
|
30244
30244
|
var STARTER_FALLBACK_UI_VERSION = "6.1.0";
|
|
30245
|
-
var STARTER_FALLBACK_SDK_VERSION = "0.
|
|
30245
|
+
var STARTER_FALLBACK_SDK_VERSION = "0.40.0";
|
|
30246
30246
|
var STARTER_REACT_NATIVE_VERSION = "0.85.3";
|
|
30247
30247
|
function buildStarterTemplate(args) {
|
|
30248
30248
|
const uiVersion = args.ui_version ?? `^${STARTER_FALLBACK_UI_VERSION}`;
|
|
@@ -30284,7 +30284,10 @@ function buildStarterTemplate(args) {
|
|
|
30284
30284
|
"react-dom": "^19.0.0",
|
|
30285
30285
|
"react-native": STARTER_REACT_NATIVE_VERSION,
|
|
30286
30286
|
"react-native-svg": "^15.0.0",
|
|
30287
|
-
"react-native-web": "^0.21.0"
|
|
30287
|
+
"react-native-web": "^0.21.0",
|
|
30288
|
+
// In-app routing for the scaffolded list→detail example. The app owns
|
|
30289
|
+
// its routing; the SDK's `isEmbedded()` picks memory vs browser history.
|
|
30290
|
+
"react-router-dom": "^7.0.0"
|
|
30288
30291
|
},
|
|
30289
30292
|
devDependencies: {
|
|
30290
30293
|
"@testing-library/react": "^16.1.0",
|
|
@@ -30497,6 +30500,10 @@ export default defineConfig({
|
|
|
30497
30500
|
// the hooks dispatcher the instant a hook (useAgentRun / useQuery)
|
|
30498
30501
|
// runs in a rendered App tree.
|
|
30499
30502
|
"@lotics/app-sdk",
|
|
30503
|
+
// react-router-dom (App's router) also ships compiled dist JS \u2014 pin it
|
|
30504
|
+
// into the shared chunk so its hooks don't split the react instance
|
|
30505
|
+
// ("Invalid hook call") when a test renders the routed App tree.
|
|
30506
|
+
"react-router-dom",
|
|
30500
30507
|
],
|
|
30501
30508
|
esbuildOptions: {
|
|
30502
30509
|
resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".jsx", ".js", ".json"],
|
|
@@ -30565,59 +30572,112 @@ mount(
|
|
|
30565
30572
|
},
|
|
30566
30573
|
{
|
|
30567
30574
|
path: "src/App.tsx",
|
|
30568
|
-
//
|
|
30569
|
-
//
|
|
30570
|
-
// -
|
|
30571
|
-
//
|
|
30572
|
-
//
|
|
30573
|
-
//
|
|
30574
|
-
//
|
|
30575
|
-
//
|
|
30576
|
-
//
|
|
30577
|
-
//
|
|
30578
|
-
//
|
|
30579
|
-
//
|
|
30580
|
-
|
|
30581
|
-
|
|
30582
|
-
|
|
30575
|
+
// Default scaffold = a minimal in-app router example (a list screen and a
|
|
30576
|
+
// detail screen), so a new app starts with the recommended routing shape:
|
|
30577
|
+
// - The app uses react-router via `AppRouter` (from @lotics/app-sdk/router).
|
|
30578
|
+
// The app owns its own url — embedded, that's the iframe's own url (the
|
|
30579
|
+
// host never sees it, so navigation never reloads the app); standalone,
|
|
30580
|
+
// real path URLs. Browser Back/Forward walk app screens in both.
|
|
30581
|
+
// - The full-container layout pattern is preserved in `Screen`: an outer
|
|
30582
|
+
// <View flex:1> claims the iframe height (index.html sets
|
|
30583
|
+
// html/body/#root to 100% + #root is a flex column). Keep that chain
|
|
30584
|
+
// plain — @lotics/ui/stack wraps children in an unstyled <View> that
|
|
30585
|
+
// breaks `flex: 1` propagation to a fill-remaining-space child.
|
|
30586
|
+
// A single-screen app can delete the router and render one Screen directly.
|
|
30587
|
+
content: `import type { ReactNode } from "react";
|
|
30588
|
+
import { View } from "react-native";
|
|
30589
|
+
import { useNavigate, useParams } from "react-router-dom";
|
|
30590
|
+
import { AppRouter } from "@lotics/app-sdk/router";
|
|
30583
30591
|
import { Card } from "@lotics/ui/card";
|
|
30584
30592
|
import { Text } from "@lotics/ui/text";
|
|
30585
30593
|
import { Button } from "@lotics/ui/button";
|
|
30586
30594
|
|
|
30587
|
-
//
|
|
30588
|
-
//
|
|
30589
|
-
//
|
|
30590
|
-
//
|
|
30591
|
-
//
|
|
30592
|
-
|
|
30595
|
+
// AppRouter makes the app's screens real URLs \u2014 write plain react-router
|
|
30596
|
+
// (useNavigate / useParams / <Link>) and it handles both modes. The app owns its
|
|
30597
|
+
// own url, so navigation never reloads the app:
|
|
30598
|
+
// - Embedded in the Lotics host: the app drives the iframe's OWN url (invisible
|
|
30599
|
+
// to the user, never seen by the host). Browser Back / Forward walk app
|
|
30600
|
+
// screens; the screen is also mirrored to the host url, so it's shareable and
|
|
30601
|
+
// survives a full refresh (handled by AppRouter \u2014 no extra code).
|
|
30602
|
+
// - Standalone at <slug>.lotics.app: a normal browser router with real path URLs.
|
|
30603
|
+
|
|
30604
|
+
const ITEMS = [
|
|
30605
|
+
{ id: "1", name: "First item" },
|
|
30606
|
+
{ id: "2", name: "Second item" },
|
|
30607
|
+
{ id: "3", name: "Third item" },
|
|
30608
|
+
];
|
|
30609
|
+
|
|
30610
|
+
// Outer <View flex:1> claims the full iframe height \u2014 works because index.html
|
|
30611
|
+
// sets html/body/#root to 100% and #root is a flex column. Keep this flex chain
|
|
30612
|
+
// plain (not @lotics/ui/stack) so a fill-remaining-space child can claim height.
|
|
30613
|
+
function Screen({ children }: { children: ReactNode }) {
|
|
30593
30614
|
return (
|
|
30594
|
-
<View
|
|
30595
|
-
style={{
|
|
30596
|
-
|
|
30597
|
-
|
|
30598
|
-
|
|
30599
|
-
|
|
30600
|
-
|
|
30601
|
-
|
|
30602
|
-
|
|
30603
|
-
|
|
30604
|
-
|
|
30605
|
-
|
|
30606
|
-
|
|
30607
|
-
|
|
30608
|
-
|
|
30609
|
-
|
|
30610
|
-
|
|
30615
|
+
<View style={{ flex: 1, padding: 24, alignItems: "center" }}>
|
|
30616
|
+
<View style={{ maxWidth: 640, width: "100%", gap: 16 }}>{children}</View>
|
|
30617
|
+
</View>
|
|
30618
|
+
);
|
|
30619
|
+
}
|
|
30620
|
+
|
|
30621
|
+
function ListScreen() {
|
|
30622
|
+
const navigate = useNavigate();
|
|
30623
|
+
return (
|
|
30624
|
+
<Screen>
|
|
30625
|
+
<Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
|
|
30626
|
+
<Text color="muted">
|
|
30627
|
+
Tap an item to open its detail screen \u2014 the app routes itself. Edit{" "}
|
|
30628
|
+
<Text weight="medium">src/App.tsx</Text> and run{" "}
|
|
30629
|
+
<Text weight="medium">lotics app deploy</Text> to publish.
|
|
30630
|
+
</Text>
|
|
30631
|
+
{ITEMS.map((item) => (
|
|
30632
|
+
<Card key={item.id}>
|
|
30633
|
+
<View
|
|
30634
|
+
style={{
|
|
30635
|
+
padding: 16,
|
|
30636
|
+
flexDirection: "row",
|
|
30637
|
+
alignItems: "center",
|
|
30638
|
+
justifyContent: "space-between",
|
|
30639
|
+
gap: 12,
|
|
30640
|
+
}}
|
|
30641
|
+
>
|
|
30642
|
+
<Text weight="medium">{item.name}</Text>
|
|
30643
|
+
<Button title="Open" color="primary" onPress={() => navigate("/item/" + item.id)} />
|
|
30611
30644
|
</View>
|
|
30612
30645
|
</Card>
|
|
30613
|
-
|
|
30614
|
-
|
|
30615
|
-
|
|
30616
|
-
|
|
30646
|
+
))}
|
|
30647
|
+
</Screen>
|
|
30648
|
+
);
|
|
30649
|
+
}
|
|
30650
|
+
|
|
30651
|
+
function ItemDetailScreen() {
|
|
30652
|
+
const { id } = useParams();
|
|
30653
|
+
const navigate = useNavigate();
|
|
30654
|
+
const item = ITEMS.find((i) => i.id === id);
|
|
30655
|
+
return (
|
|
30656
|
+
<Screen>
|
|
30657
|
+
{/* An in-app Back control; navigate(-1) walks the history (the browser Back
|
|
30658
|
+
button walks app screens too). */}
|
|
30659
|
+
<View style={{ alignItems: "flex-start" }}>
|
|
30660
|
+
<Button title="Back" onPress={() => navigate(-1)} />
|
|
30617
30661
|
</View>
|
|
30618
|
-
|
|
30662
|
+
<Text size="xl" weight="semibold">{item ? item.name : "Not found"}</Text>
|
|
30663
|
+
<Card>
|
|
30664
|
+
<View style={{ padding: 16, gap: 8 }}>
|
|
30665
|
+
<Text>Detail for item {id}.</Text>
|
|
30666
|
+
<Text color="muted">Reached via in-app navigation, not a host route.</Text>
|
|
30667
|
+
</View>
|
|
30668
|
+
</Card>
|
|
30669
|
+
</Screen>
|
|
30619
30670
|
);
|
|
30620
30671
|
}
|
|
30672
|
+
|
|
30673
|
+
const routes = [
|
|
30674
|
+
{ path: "/", element: <ListScreen /> },
|
|
30675
|
+
{ path: "/item/:id", element: <ItemDetailScreen /> },
|
|
30676
|
+
];
|
|
30677
|
+
|
|
30678
|
+
export default function App() {
|
|
30679
|
+
return <AppRouter routes={routes} />;
|
|
30680
|
+
}
|
|
30621
30681
|
`
|
|
30622
30682
|
},
|
|
30623
30683
|
{
|
|
@@ -30696,9 +30756,9 @@ import { render } from "@testing-library/react";
|
|
|
30696
30756
|
import App from "./App";
|
|
30697
30757
|
|
|
30698
30758
|
describe("App", () => {
|
|
30699
|
-
test("renders
|
|
30759
|
+
test("renders the default route", () => {
|
|
30700
30760
|
const { container } = render(<App />);
|
|
30701
|
-
expect(container).
|
|
30761
|
+
expect(container.textContent).toContain("First item");
|
|
30702
30762
|
});
|
|
30703
30763
|
});
|
|
30704
30764
|
`
|
|
@@ -30782,6 +30842,17 @@ import { Text } from "@lotics/ui/text";
|
|
|
30782
30842
|
in this Vite app (the alias is preconfigured in \`vite.config.ts\`). See
|
|
30783
30843
|
the full export list at https://www.npmjs.com/package/@lotics/ui.
|
|
30784
30844
|
|
|
30845
|
+
## Routing
|
|
30846
|
+
|
|
30847
|
+
\`src/App.tsx\` ships a minimal in-app router. Write plain react-router and wrap
|
|
30848
|
+
your routes in \`AppRouter\` from \`@lotics/app-sdk/router\` \u2014 the app owns its own
|
|
30849
|
+
url, so navigation never reloads the app. Embedded in the Lotics host it drives
|
|
30850
|
+
the iframe's own url (invisible to the user, never seen by the host); browser
|
|
30851
|
+
Back/Forward walk app screens, and the screen is mirrored to the host url so it's
|
|
30852
|
+
shareable and survives a full refresh. Standalone (\`<slug>.lotics.app\`) it's a
|
|
30853
|
+
normal browser router with real path URLs. A single-screen app can drop the
|
|
30854
|
+
router and render one screen directly.
|
|
30855
|
+
|
|
30785
30856
|
See https://lotics.ai/docs/app-sdk for the SDK reference.
|
|
30786
30857
|
`
|
|
30787
30858
|
}
|
|
@@ -30988,9 +31059,28 @@ function buildWrapperPage(args) {
|
|
|
30988
31059
|
const iframe = document.getElementById("app");
|
|
30989
31060
|
|
|
30990
31061
|
// Pass the wrapper's own origin to the app via ?lotics_host= so the app
|
|
30991
|
-
// SDK can origin-lock its postMessage bridge
|
|
30992
|
-
|
|
30993
|
-
|
|
31062
|
+
// SDK can origin-lock its postMessage bridge, and bake the saved screen
|
|
31063
|
+
// (the wrapper url's _loc, kept current by AppRouter's mirror) into the src
|
|
31064
|
+
// path so a refresh boots the app at that screen \u2014 mirrors the production
|
|
31065
|
+
// host. _loc is honoured only when it resolves same-origin to the Vite
|
|
31066
|
+
// server (a url param flowing into an iframe src is a redirect vector);
|
|
31067
|
+
// anything else falls back to the app root.
|
|
31068
|
+
const appSrc = new URL(VITE_URL);
|
|
31069
|
+
const savedLoc = new URLSearchParams(window.location.search).get("_loc");
|
|
31070
|
+
if (savedLoc) {
|
|
31071
|
+
try {
|
|
31072
|
+
const resolved = new URL(savedLoc, VITE_ORIGIN);
|
|
31073
|
+
if (resolved.origin === VITE_ORIGIN) {
|
|
31074
|
+
appSrc.pathname = resolved.pathname;
|
|
31075
|
+
appSrc.search = resolved.search;
|
|
31076
|
+
appSrc.hash = resolved.hash;
|
|
31077
|
+
}
|
|
31078
|
+
} catch (e) {
|
|
31079
|
+
// malformed _loc -> app root
|
|
31080
|
+
}
|
|
31081
|
+
}
|
|
31082
|
+
appSrc.searchParams.set("lotics_host", window.location.origin);
|
|
31083
|
+
iframe.src = appSrc.toString();
|
|
30994
31084
|
|
|
30995
31085
|
async function rpc(op, payload) {
|
|
30996
31086
|
const res = await fetch("/_rpc", {
|
|
@@ -31107,10 +31197,9 @@ function buildWrapperPage(args) {
|
|
|
31107
31197
|
});
|
|
31108
31198
|
const qs = sp.toString();
|
|
31109
31199
|
const url = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
|
|
31110
|
-
//
|
|
31111
|
-
//
|
|
31112
|
-
|
|
31113
|
-
else window.history.replaceState(null, "", url);
|
|
31200
|
+
// View-state writes in place \u2014 never a history entry. replaceState doesn't
|
|
31201
|
+
// fire popstate, so no echo. Mirrors the production host.
|
|
31202
|
+
window.history.replaceState(null, "", url);
|
|
31114
31203
|
return undefined;
|
|
31115
31204
|
}
|
|
31116
31205
|
|
|
@@ -31196,9 +31285,9 @@ function buildWrapperPage(args) {
|
|
|
31196
31285
|
}
|
|
31197
31286
|
});
|
|
31198
31287
|
|
|
31199
|
-
//
|
|
31200
|
-
//
|
|
31201
|
-
//
|
|
31288
|
+
// Browser back/forward \u2192 broadcast the new params so useUrlState re-hydrates
|
|
31289
|
+
// (the app's own set writes use replaceState \u2014 no popstate \u2014 so there's no
|
|
31290
|
+
// echo). Mirrors the production host.
|
|
31202
31291
|
window.addEventListener("popstate", function () {
|
|
31203
31292
|
iframe.contentWindow.postMessage(
|
|
31204
31293
|
{ type: "url-state", params: readUrlParams() },
|
|
@@ -42,7 +42,7 @@ export interface StarterFile {
|
|
|
42
42
|
* fall back here when the lookup fails.
|
|
43
43
|
*/
|
|
44
44
|
export declare const STARTER_FALLBACK_UI_VERSION = "6.1.0";
|
|
45
|
-
export declare const STARTER_FALLBACK_SDK_VERSION = "0.
|
|
45
|
+
export declare const STARTER_FALLBACK_SDK_VERSION = "0.40.0";
|
|
46
46
|
/**
|
|
47
47
|
* react-native pin for scaffolded apps. Matches the monorepo frontend's pin so
|
|
48
48
|
* an app deep-typechecks `@lotics/ui`'s `.tsx` source against the SAME RN types
|
package/dist/starter_template.js
CHANGED
|
@@ -38,7 +38,10 @@
|
|
|
38
38
|
* fall back here when the lookup fails.
|
|
39
39
|
*/
|
|
40
40
|
export const STARTER_FALLBACK_UI_VERSION = "6.1.0";
|
|
41
|
-
|
|
41
|
+
// Must be ≥ the release that added `@lotics/app-sdk/router` (`AppRouter`, used by
|
|
42
|
+
// the scaffolded App.tsx) — an older offline pin won't resolve the subpath and
|
|
43
|
+
// the scaffold won't typecheck.
|
|
44
|
+
export const STARTER_FALLBACK_SDK_VERSION = "0.40.0";
|
|
42
45
|
/**
|
|
43
46
|
* react-native pin for scaffolded apps. Matches the monorepo frontend's pin so
|
|
44
47
|
* an app deep-typechecks `@lotics/ui`'s `.tsx` source against the SAME RN types
|
|
@@ -94,6 +97,9 @@ export function buildStarterTemplate(args) {
|
|
|
94
97
|
"react-native": STARTER_REACT_NATIVE_VERSION,
|
|
95
98
|
"react-native-svg": "^15.0.0",
|
|
96
99
|
"react-native-web": "^0.21.0",
|
|
100
|
+
// In-app routing for the scaffolded list→detail example. The app owns
|
|
101
|
+
// its routing; the SDK's `isEmbedded()` picks memory vs browser history.
|
|
102
|
+
"react-router-dom": "^7.0.0",
|
|
97
103
|
},
|
|
98
104
|
devDependencies: {
|
|
99
105
|
"@testing-library/react": "^16.1.0",
|
|
@@ -299,6 +305,10 @@ export default defineConfig({
|
|
|
299
305
|
// the hooks dispatcher the instant a hook (useAgentRun / useQuery)
|
|
300
306
|
// runs in a rendered App tree.
|
|
301
307
|
"@lotics/app-sdk",
|
|
308
|
+
// react-router-dom (App's router) also ships compiled dist JS — pin it
|
|
309
|
+
// into the shared chunk so its hooks don't split the react instance
|
|
310
|
+
// ("Invalid hook call") when a test renders the routed App tree.
|
|
311
|
+
"react-router-dom",
|
|
302
312
|
],
|
|
303
313
|
esbuildOptions: {
|
|
304
314
|
resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".jsx", ".js", ".json"],
|
|
@@ -367,59 +377,112 @@ mount(
|
|
|
367
377
|
},
|
|
368
378
|
{
|
|
369
379
|
path: "src/App.tsx",
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
// -
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
380
|
+
// Default scaffold = a minimal in-app router example (a list screen and a
|
|
381
|
+
// detail screen), so a new app starts with the recommended routing shape:
|
|
382
|
+
// - The app uses react-router via `AppRouter` (from @lotics/app-sdk/router).
|
|
383
|
+
// The app owns its own url — embedded, that's the iframe's own url (the
|
|
384
|
+
// host never sees it, so navigation never reloads the app); standalone,
|
|
385
|
+
// real path URLs. Browser Back/Forward walk app screens in both.
|
|
386
|
+
// - The full-container layout pattern is preserved in `Screen`: an outer
|
|
387
|
+
// <View flex:1> claims the iframe height (index.html sets
|
|
388
|
+
// html/body/#root to 100% + #root is a flex column). Keep that chain
|
|
389
|
+
// plain — @lotics/ui/stack wraps children in an unstyled <View> that
|
|
390
|
+
// breaks `flex: 1` propagation to a fill-remaining-space child.
|
|
391
|
+
// A single-screen app can delete the router and render one Screen directly.
|
|
392
|
+
content: `import type { ReactNode } from "react";
|
|
393
|
+
import { View } from "react-native";
|
|
394
|
+
import { useNavigate, useParams } from "react-router-dom";
|
|
395
|
+
import { AppRouter } from "@lotics/app-sdk/router";
|
|
385
396
|
import { Card } from "@lotics/ui/card";
|
|
386
397
|
import { Text } from "@lotics/ui/text";
|
|
387
398
|
import { Button } from "@lotics/ui/button";
|
|
388
399
|
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
400
|
+
// AppRouter makes the app's screens real URLs — write plain react-router
|
|
401
|
+
// (useNavigate / useParams / <Link>) and it handles both modes. The app owns its
|
|
402
|
+
// own url, so navigation never reloads the app:
|
|
403
|
+
// - Embedded in the Lotics host: the app drives the iframe's OWN url (invisible
|
|
404
|
+
// to the user, never seen by the host). Browser Back / Forward walk app
|
|
405
|
+
// screens; the screen is also mirrored to the host url, so it's shareable and
|
|
406
|
+
// survives a full refresh (handled by AppRouter — no extra code).
|
|
407
|
+
// - Standalone at <slug>.lotics.app: a normal browser router with real path URLs.
|
|
408
|
+
|
|
409
|
+
const ITEMS = [
|
|
410
|
+
{ id: "1", name: "First item" },
|
|
411
|
+
{ id: "2", name: "Second item" },
|
|
412
|
+
{ id: "3", name: "Third item" },
|
|
413
|
+
];
|
|
414
|
+
|
|
415
|
+
// Outer <View flex:1> claims the full iframe height — works because index.html
|
|
416
|
+
// sets html/body/#root to 100% and #root is a flex column. Keep this flex chain
|
|
417
|
+
// plain (not @lotics/ui/stack) so a fill-remaining-space child can claim height.
|
|
418
|
+
function Screen({ children }: { children: ReactNode }) {
|
|
419
|
+
return (
|
|
420
|
+
<View style={{ flex: 1, padding: 24, alignItems: "center" }}>
|
|
421
|
+
<View style={{ maxWidth: 640, width: "100%", gap: 16 }}>{children}</View>
|
|
422
|
+
</View>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function ListScreen() {
|
|
427
|
+
const navigate = useNavigate();
|
|
395
428
|
return (
|
|
396
|
-
<
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
429
|
+
<Screen>
|
|
430
|
+
<Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
|
|
431
|
+
<Text color="muted">
|
|
432
|
+
Tap an item to open its detail screen — the app routes itself. Edit{" "}
|
|
433
|
+
<Text weight="medium">src/App.tsx</Text> and run{" "}
|
|
434
|
+
<Text weight="medium">lotics app deploy</Text> to publish.
|
|
435
|
+
</Text>
|
|
436
|
+
{ITEMS.map((item) => (
|
|
437
|
+
<Card key={item.id}>
|
|
438
|
+
<View
|
|
439
|
+
style={{
|
|
440
|
+
padding: 16,
|
|
441
|
+
flexDirection: "row",
|
|
442
|
+
alignItems: "center",
|
|
443
|
+
justifyContent: "space-between",
|
|
444
|
+
gap: 12,
|
|
445
|
+
}}
|
|
446
|
+
>
|
|
447
|
+
<Text weight="medium">{item.name}</Text>
|
|
448
|
+
<Button title="Open" color="primary" onPress={() => navigate("/item/" + item.id)} />
|
|
413
449
|
</View>
|
|
414
450
|
</Card>
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
451
|
+
))}
|
|
452
|
+
</Screen>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function ItemDetailScreen() {
|
|
457
|
+
const { id } = useParams();
|
|
458
|
+
const navigate = useNavigate();
|
|
459
|
+
const item = ITEMS.find((i) => i.id === id);
|
|
460
|
+
return (
|
|
461
|
+
<Screen>
|
|
462
|
+
{/* An in-app Back control; navigate(-1) walks the history (the browser Back
|
|
463
|
+
button walks app screens too). */}
|
|
464
|
+
<View style={{ alignItems: "flex-start" }}>
|
|
465
|
+
<Button title="Back" onPress={() => navigate(-1)} />
|
|
419
466
|
</View>
|
|
420
|
-
|
|
467
|
+
<Text size="xl" weight="semibold">{item ? item.name : "Not found"}</Text>
|
|
468
|
+
<Card>
|
|
469
|
+
<View style={{ padding: 16, gap: 8 }}>
|
|
470
|
+
<Text>Detail for item {id}.</Text>
|
|
471
|
+
<Text color="muted">Reached via in-app navigation, not a host route.</Text>
|
|
472
|
+
</View>
|
|
473
|
+
</Card>
|
|
474
|
+
</Screen>
|
|
421
475
|
);
|
|
422
476
|
}
|
|
477
|
+
|
|
478
|
+
const routes = [
|
|
479
|
+
{ path: "/", element: <ListScreen /> },
|
|
480
|
+
{ path: "/item/:id", element: <ItemDetailScreen /> },
|
|
481
|
+
];
|
|
482
|
+
|
|
483
|
+
export default function App() {
|
|
484
|
+
return <AppRouter routes={routes} />;
|
|
485
|
+
}
|
|
423
486
|
`,
|
|
424
487
|
},
|
|
425
488
|
{
|
|
@@ -498,9 +561,9 @@ import { render } from "@testing-library/react";
|
|
|
498
561
|
import App from "./App";
|
|
499
562
|
|
|
500
563
|
describe("App", () => {
|
|
501
|
-
test("renders
|
|
564
|
+
test("renders the default route", () => {
|
|
502
565
|
const { container } = render(<App />);
|
|
503
|
-
expect(container).
|
|
566
|
+
expect(container.textContent).toContain("First item");
|
|
504
567
|
});
|
|
505
568
|
});
|
|
506
569
|
`,
|
|
@@ -584,6 +647,17 @@ import { Text } from "@lotics/ui/text";
|
|
|
584
647
|
in this Vite app (the alias is preconfigured in \`vite.config.ts\`). See
|
|
585
648
|
the full export list at https://www.npmjs.com/package/@lotics/ui.
|
|
586
649
|
|
|
650
|
+
## Routing
|
|
651
|
+
|
|
652
|
+
\`src/App.tsx\` ships a minimal in-app router. Write plain react-router and wrap
|
|
653
|
+
your routes in \`AppRouter\` from \`@lotics/app-sdk/router\` — the app owns its own
|
|
654
|
+
url, so navigation never reloads the app. Embedded in the Lotics host it drives
|
|
655
|
+
the iframe's own url (invisible to the user, never seen by the host); browser
|
|
656
|
+
Back/Forward walk app screens, and the screen is mirrored to the host url so it's
|
|
657
|
+
shareable and survives a full refresh. Standalone (\`<slug>.lotics.app\`) it's a
|
|
658
|
+
normal browser router with real path URLs. A single-screen app can drop the
|
|
659
|
+
router and render one screen directly.
|
|
660
|
+
|
|
587
661
|
See https://lotics.ai/docs/app-sdk for the SDK reference.
|
|
588
662
|
`,
|
|
589
663
|
},
|
|
@@ -77,4 +77,24 @@ describe("buildStarterTemplate", () => {
|
|
|
77
77
|
expect(pkg.dependencies["@lotics/ui"]).toBe(`^${STARTER_FALLBACK_UI_VERSION}`);
|
|
78
78
|
expect(pkg.dependencies["@lotics/app-sdk"]).toBe(`^${STARTER_FALLBACK_SDK_VERSION}`);
|
|
79
79
|
});
|
|
80
|
+
test("package.json includes react-router-dom for the in-app router example", () => {
|
|
81
|
+
const pkg = JSON.parse(fileNamed(buildStarterTemplate(baseArgs), "package.json"));
|
|
82
|
+
expect(pkg.dependencies["react-router-dom"]).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
test("App.tsx routes via the SDK's AppRouter (real addressable URLs, both modes)", () => {
|
|
85
|
+
// The scaffold demonstrates the recommended shape: the app uses react-router
|
|
86
|
+
// through `AppRouter`, which makes screens addressable URLs in both modes.
|
|
87
|
+
// Guards against the wiring being dropped back to a single screen.
|
|
88
|
+
const app = fileNamed(buildStarterTemplate(baseArgs), "src/App.tsx");
|
|
89
|
+
expect(app).toContain('from "react-router-dom"');
|
|
90
|
+
expect(app).toContain('from "@lotics/app-sdk/router"');
|
|
91
|
+
expect(app).toContain("<AppRouter routes={routes}");
|
|
92
|
+
});
|
|
93
|
+
test("vite.config.ts pins react-router-dom into the vitest web optimizer (else the routed App test splits the react instance)", () => {
|
|
94
|
+
// react-router-dom ships compiled dist JS; un-pinned, its hooks load a second
|
|
95
|
+
// react instance under vitest and the scaffold's App.test fails with "Invalid
|
|
96
|
+
// hook call" the moment it renders RouterProvider.
|
|
97
|
+
const config = fileNamed(buildStarterTemplate(baseArgs), "vite.config.ts");
|
|
98
|
+
expect(config).toMatch(/include:\s*\[[\s\S]*"react-router-dom"/);
|
|
99
|
+
});
|
|
80
100
|
});
|