@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.
@@ -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
- * url-state (useUrlState): the iframe reads/writes the wrapper's address bar
28
- * via the urlState.get/set ops; the wrapper pushes { type: "url-state",
29
- * params } (no id) on back/forward.
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;
@@ -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
- * url-state (useUrlState): the iframe reads/writes the wrapper's address bar
28
- * via the urlState.get/set ops; the wrapper pushes { type: "url-state",
29
- * params } (no id) on back/forward.
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
- iframe.src = VITE_URL + (VITE_URL.indexOf("?") >= 0 ? "&" : "?")
83
- + "lotics_host=" + encodeURIComponent(window.location.origin);
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
- // pushState/replaceState don't fire popstate, so no echo the app
201
- // updated itself optimistically on write.
202
- if (payload && payload.push) window.history.pushState(null, "", url);
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
- // Push the URL back into the app on back/forward so useUrlState
290
- // re-hydrates (the app's own writes use replace/pushState — no popstate —
291
- // so there's no echo). Matches the production host.
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.11.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
- // Welcome screen demonstrates the full-container layout pattern that
30569
- // real apps (dashboards, tables, full-page surfaces) rely on:
30570
- // - Outer <View flex:1> claims the full iframe height (works because
30571
- // index.html sets html/body/#root to 100% + #root is a flex column).
30572
- // - Inner View is content-sized + centered for a card layout.
30573
- //
30574
- // We deliberately do NOT use @lotics/ui/stack for the outer chain.
30575
- // Stack wraps each child in an unstyled <View>, which breaks `flex: 1`
30576
- // propagation to children that need to claim flexible height (e.g., a
30577
- // scrollable table or list that fills the remaining space below a
30578
- // header + search).
30579
- // Use plain <View> with `flexDirection` + `gap` for any outer layout
30580
- // that hosts a flex-filling child; Stack is fine for content-sized
30581
- // groupings inside such a chain.
30582
- content: `import { View } from "react-native";
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
- // Outer <View flex:1> claims the full iframe height \u2014 works because
30588
- // index.html sets html/body/#root to 100% and #root is a flex column.
30589
- // For real layouts with a fill-remaining-space child (a scrollable
30590
- // table/list etc.), keep this flex chain plain \u2014 @lotics/ui/stack wraps
30591
- // each child in an unstyled <View> that breaks \`flex: 1\` propagation.
30592
- export default function App() {
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
- flex: 1,
30597
- padding: 24,
30598
- justifyContent: "center",
30599
- alignItems: "center",
30600
- }}
30601
- >
30602
- <View style={{ maxWidth: 640, width: "100%", gap: 16 }}>
30603
- <Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
30604
- <Card>
30605
- <View style={{ padding: 16, gap: 8 }}>
30606
- <Text>This is your new Lotics app.</Text>
30607
- <Text color="muted">
30608
- Edit <Text weight="medium">src/App.tsx</Text> and run{" "}
30609
- <Text weight="medium">lotics app deploy</Text> to publish.
30610
- </Text>
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
- <Text color="muted" size="sm">
30614
- Hooks from @lotics/app-sdk: useQuery, useWorkflow.
30615
- </Text>
30616
- <Button title="Get started" onPress={() => {}} color="primary" />
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
- </View>
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 without crashing", () => {
30759
+ test("renders the default route", () => {
30700
30760
  const { container } = render(<App />);
30701
- expect(container).not.toBeNull();
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
- iframe.src = VITE_URL + (VITE_URL.indexOf("?") >= 0 ? "&" : "?")
30993
- + "lotics_host=" + encodeURIComponent(window.location.origin);
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
- // pushState/replaceState don't fire popstate, so no echo \u2014 the app
31111
- // updated itself optimistically on write.
31112
- if (payload && payload.push) window.history.pushState(null, "", url);
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
- // Push the URL back into the app on back/forward so useUrlState
31200
- // re-hydrates (the app's own writes use replace/pushState \u2014 no popstate \u2014
31201
- // so there's no echo). Matches the production host.
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.11.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
@@ -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
- export const STARTER_FALLBACK_SDK_VERSION = "0.11.0";
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
- // Welcome screen demonstrates the full-container layout pattern that
371
- // real apps (dashboards, tables, full-page surfaces) rely on:
372
- // - Outer <View flex:1> claims the full iframe height (works because
373
- // index.html sets html/body/#root to 100% + #root is a flex column).
374
- // - Inner View is content-sized + centered for a card layout.
375
- //
376
- // We deliberately do NOT use @lotics/ui/stack for the outer chain.
377
- // Stack wraps each child in an unstyled <View>, which breaks `flex: 1`
378
- // propagation to children that need to claim flexible height (e.g., a
379
- // scrollable table or list that fills the remaining space below a
380
- // header + search).
381
- // Use plain <View> with `flexDirection` + `gap` for any outer layout
382
- // that hosts a flex-filling child; Stack is fine for content-sized
383
- // groupings inside such a chain.
384
- content: `import { View } from "react-native";
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
- // Outer <View flex:1> claims the full iframe height works because
390
- // index.html sets html/body/#root to 100% and #root is a flex column.
391
- // For real layouts with a fill-remaining-space child (a scrollable
392
- // table/list etc.), keep this flex chain plain @lotics/ui/stack wraps
393
- // each child in an unstyled <View> that breaks \`flex: 1\` propagation.
394
- export default function App() {
400
+ // AppRouter makes the app's screens real URLswrite 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
- <View
397
- style={{
398
- flex: 1,
399
- padding: 24,
400
- justifyContent: "center",
401
- alignItems: "center",
402
- }}
403
- >
404
- <View style={{ maxWidth: 640, width: "100%", gap: 16 }}>
405
- <Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
406
- <Card>
407
- <View style={{ padding: 16, gap: 8 }}>
408
- <Text>This is your new Lotics app.</Text>
409
- <Text color="muted">
410
- Edit <Text weight="medium">src/App.tsx</Text> and run{" "}
411
- <Text weight="medium">lotics app deploy</Text> to publish.
412
- </Text>
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
- <Text color="muted" size="sm">
416
- Hooks from @lotics/app-sdk: useQuery, useWorkflow.
417
- </Text>
418
- <Button title="Get started" onPress={() => {}} color="primary" />
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
- </View>
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 without crashing", () => {
564
+ test("renders the default route", () => {
502
565
  const { container } = render(<App />);
503
- expect(container).not.toBeNull();
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.54.2",
3
+ "version": "0.56.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {