Chandra Kiran commited on
Commit
d686d47
·
unverified ·
1 Parent(s): a4ef305

Add a web app for testers

Browse files

The server's root page is now a simple workspace: sign in with your key, then
describe a world on the left (pick a robot, press Enter, Run simulation, watch the
video, stress test) or a ROS 2 node on the right (pick Humble/Jazzy and
Python/C++, read the files, Build & run, download the zip). History, the month's
budget and 👍/😐/👎 feedback are built in. Plain HTML/CSS/JS served from
api/static/app, light and dark, keyboard and screen-reader friendly, and laid out
for phones too. New API calls behind it: /me/history, /generations/{id},
/code/{id}/zip, and video links on /jobs/{id}.

README.md CHANGED
@@ -244,6 +244,10 @@ also set `ROSDIFF_API_KEYS="name:key,other:key2"` in the environment.
244
 
245
  ## For testers
246
 
 
 
 
 
247
  ```bash
248
  export ROSDIFF_KEY=rd_... # your key
249
  export ROSDIFF_URL=https://... # the address you were given
 
244
 
245
  ## For testers
246
 
247
+ **Use the web app:** open the server's address in a browser (e.g. `http://<server>:8000/`) and sign in with your
248
+ key. Simulation is on the left, ROS 2 code on the right: describe what you want, press Enter, then **Run
249
+ simulation** or **Build & run**. Everything you make is under History. The same works from the command line:
250
+
251
  ```bash
252
  export ROSDIFF_KEY=rd_... # your key
253
  export ROSDIFF_URL=https://... # the address you were given
api/main.py CHANGED
@@ -19,7 +19,7 @@ from pathlib import Path
19
  from typing import Literal
20
 
21
  from fastapi import Depends, FastAPI, Header, HTTPException, Request
22
- from fastapi.responses import HTMLResponse
23
  from fastapi.staticfiles import StaticFiles
24
  from pydantic import BaseModel, Field
25
 
@@ -631,8 +631,101 @@ def create_app(services: Services | None = None) -> FastAPI:
631
  "summary": output.get("summary"),
632
  "files": sorted((output.get("files") or {}).keys()),
633
  "report_url": _report_url(request, s, job_id),
 
634
  }
635
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
  @app.post("/feedback/{gen_id}")
637
  def feedback(gen_id: str, req: FeedbackRequest, t: Tester = Depends(tester), s: Services = Depends(svc)) -> dict:
638
  """Tell us whether a generation actually worked when you ran it."""
 
19
  from typing import Literal
20
 
21
  from fastapi import Depends, FastAPI, Header, HTTPException, Request
22
+ from fastapi.responses import HTMLResponse, Response
23
  from fastapi.staticfiles import StaticFiles
24
  from pydantic import BaseModel, Field
25
 
 
631
  "summary": output.get("summary"),
632
  "files": sorted((output.get("files") or {}).keys()),
633
  "report_url": _report_url(request, s, job_id),
634
+ "media": _media(s, job),
635
  }
636
 
637
+ def _media(s: Services, job: dict) -> dict:
638
+ """Presigned links for what the web app shows inline (videos), once a job has finished."""
639
+ files = (job.get("output") or {}).get("files") or {}
640
+ if job["status"] != "COMPLETED" or s.storage is None:
641
+ return {}
642
+ names = {"video": "video", "video_webm": "video_webm", "isaac.mp4": "isaac_video"}
643
+ return {label: s.storage.url(files[key]) for key, label in names.items() if key in files}
644
+
645
+ @app.get("/me/history")
646
+ def history(t: Tester = Depends(tester), s: Services = Depends(svc), limit: int = 60) -> dict:
647
+ """Your recent scenes and packages, newest first (for the web app's sidebar)."""
648
+ items = []
649
+ for g in s.logs.list(tester=t.name, limit=min(max(limit, 1), 200)):
650
+ req = g.get("request") or {}
651
+ text = req.get("description") or req.get("request") or ""
652
+ items.append(
653
+ {
654
+ "id": g["id"],
655
+ "kind": g["kind"],
656
+ "created_at": g["created_at"],
657
+ "valid": bool(g["valid"]) if g.get("valid") is not None else None,
658
+ "title": text[:90],
659
+ "robot": req.get("robot"),
660
+ "distro": req.get("distro"),
661
+ "language": req.get("language"),
662
+ "rating": g.get("tester_rating"),
663
+ }
664
+ )
665
+ return {"tester": t.name, "items": items}
666
+
667
+ @app.get("/generations/{gen_id}")
668
+ def generation(gen_id: str, request: Request, t: Tester = Depends(tester), s: Services = Depends(svc)) -> dict:
669
+ """One of your scenes or packages, with its runs and checks (to reopen it in the web app)."""
670
+ g = s.logs.get(gen_id)
671
+ if not g or g["tester"] != t.name:
672
+ raise HTTPException(status_code=404, detail="no generation with that id for this key")
673
+ req = g.get("request") or {}
674
+ output = g.get("output")
675
+ if g["kind"] == "code":
676
+ output = json.loads(output) if output else {}
677
+ jobs = [
678
+ {
679
+ "id": j["id"],
680
+ "kind": j.get("kind") or "sim",
681
+ "status": j["status"],
682
+ "created_at": j["created_at"],
683
+ "report_url": _report_url(request, s, j["id"]),
684
+ }
685
+ for j in s.logs.list_runs(t.name, 200)
686
+ if j.get("generation_id") == gen_id
687
+ ]
688
+ return {
689
+ "id": gen_id,
690
+ "kind": g["kind"],
691
+ "created_at": g["created_at"],
692
+ "request": req,
693
+ "valid": bool(g["valid"]) if g.get("valid") is not None else None,
694
+ "validation": g.get("validation") or {},
695
+ "output": output,
696
+ "error": g.get("error"),
697
+ "model": g.get("model"),
698
+ "rating": g.get("tester_rating"),
699
+ "jobs": list(reversed(jobs)),
700
+ }
701
+
702
+ @app.get("/code/{gen_id}/zip")
703
+ def code_zip(gen_id: str, t: Tester = Depends(tester), s: Services = Depends(svc)) -> Response:
704
+ """The generated package as a .zip, ready to unpack into a colcon workspace's src/."""
705
+ import io
706
+ import zipfile
707
+
708
+ g = s.logs.get(gen_id)
709
+ if not g or g["tester"] != t.name or g["kind"] != "code" or not g.get("output"):
710
+ raise HTTPException(status_code=404, detail="no code generation with that id for this key")
711
+ buf = io.BytesIO()
712
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
713
+ for path, content in json.loads(g["output"]).items():
714
+ z.writestr(path, content)
715
+ return Response(
716
+ buf.getvalue(),
717
+ media_type="application/zip",
718
+ headers={"Content-Disposition": f'attachment; filename="rosdiff-{gen_id}.zip"'},
719
+ )
720
+
721
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
722
+ def web_app() -> HTMLResponse:
723
+ """The web app for testers (api/static/app)."""
724
+ return HTMLResponse(
725
+ (Path(__file__).with_name("static") / "app" / "index.html").read_text(),
726
+ headers={"Cache-Control": "no-cache"},
727
+ )
728
+
729
  @app.post("/feedback/{gen_id}")
730
  def feedback(gen_id: str, req: FeedbackRequest, t: Tester = Depends(tester), s: Services = Depends(svc)) -> dict:
731
  """Tell us whether a generation actually worked when you ran it."""
api/static/app/app.css ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* RosDiff web app: calm, Cursor-like workspace. Tokens first; everything else uses them. */
2
+ :root {
3
+ --bg: #f7f7f8;
4
+ --panel: #ffffff;
5
+ --panel-2: #f2f2f4;
6
+ --border: #e4e4e8;
7
+ --text: #17171c;
8
+ --muted: #6b6b76;
9
+ --accent: #6d5efc;
10
+ --accent-soft: #efedff;
11
+ --accent-text: #ffffff;
12
+ --ok: #178a4f;
13
+ --ok-soft: #e6f5ed;
14
+ --warn: #9a6200;
15
+ --warn-soft: #fdf3e1;
16
+ --bad: #c9352b;
17
+ --bad-soft: #fcebea;
18
+ --sim: #0f8f86;
19
+ --sim-soft: #e2f5f3;
20
+ --code: #6d5efc;
21
+ --code-soft: #efedff;
22
+ --code-bg: #fbfbfc;
23
+ --shadow: 0 1px 2px rgb(0 0 0 / 5%), 0 8px 24px rgb(0 0 0 / 6%);
24
+ --radius: 12px;
25
+ --font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
26
+ --mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
27
+ --hl-key: #7b3fe4;
28
+ --hl-str: #16794a;
29
+ --hl-com: #8a8a94;
30
+ --hl-num: #b45309;
31
+ --hl-tag: #0e7490;
32
+ color-scheme: light;
33
+ }
34
+ @media (prefers-color-scheme: dark) {
35
+ :root:not([data-theme="light"]) {
36
+ --bg: #0e0e11;
37
+ --panel: #16161a;
38
+ --panel-2: #1d1d22;
39
+ --border: #2a2a31;
40
+ --text: #ececf1;
41
+ --muted: #9a9aa6;
42
+ --accent: #8b80ff;
43
+ --accent-soft: #25224a;
44
+ --accent-text: #0e0e11;
45
+ --ok: #4cc38a;
46
+ --ok-soft: #13291f;
47
+ --warn: #e0a84a;
48
+ --warn-soft: #2d2413;
49
+ --bad: #f07167;
50
+ --bad-soft: #321817;
51
+ --sim: #3cc8bd;
52
+ --sim-soft: #10292a;
53
+ --code: #8b80ff;
54
+ --code-soft: #25224a;
55
+ --code-bg: #121216;
56
+ --shadow: 0 1px 2px rgb(0 0 0 / 40%), 0 8px 24px rgb(0 0 0 / 30%);
57
+ --hl-key: #c4a5ff;
58
+ --hl-str: #7ee0a8;
59
+ --hl-com: #6f6f7a;
60
+ --hl-num: #f5b15c;
61
+ --hl-tag: #67d4e8;
62
+ color-scheme: dark;
63
+ }
64
+ }
65
+
66
+ * { box-sizing: border-box; }
67
+ html, body { height: 100%; }
68
+ body {
69
+ margin: 0;
70
+ background: var(--bg);
71
+ color: var(--text);
72
+ font: 15px/1.5 var(--font);
73
+ -webkit-font-smoothing: antialiased;
74
+ }
75
+ [hidden] { display: none !important; }
76
+ button { font: inherit; color: inherit; }
77
+ code, pre, textarea.mono { font-family: var(--mono); }
78
+ a { color: var(--accent); }
79
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
80
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
81
+ .muted { color: var(--muted); }
82
+ .small { font-size: 13px; }
83
+ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
84
+
85
+ /* ---------- brand, buttons, inputs ---------- */
86
+ .brand { display: flex; align-items: center; gap: 8px; font-weight: 650; letter-spacing: -0.01em; }
87
+ .brand-lg { font-size: 20px; justify-content: center; margin-bottom: 18px; }
88
+ .logo {
89
+ display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 8px;
90
+ background: var(--accent); color: #fff; font-weight: 700; font-size: 14px;
91
+ }
92
+ .brand-lg .logo { width: 34px; height: 34px; font-size: 18px; border-radius: 10px; }
93
+ .tag { font-size: 11px; font-weight: 600; color: var(--muted); background: var(--panel-2); border: 1px solid var(--border); padding: 1px 7px; border-radius: 99px; }
94
+
95
+ .btn {
96
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
97
+ border: 1px solid var(--border); background: var(--panel); color: var(--text);
98
+ padding: 8px 14px; border-radius: 9px; font-weight: 550; cursor: pointer; text-decoration: none;
99
+ transition: background .12s, border-color .12s, transform .06s;
100
+ }
101
+ .btn:hover { background: var(--panel-2); }
102
+ .btn:active { transform: translateY(1px); }
103
+ .btn:disabled { opacity: .55; cursor: default; }
104
+ .btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
105
+ .btn-primary:hover { filter: brightness(1.06); background: var(--accent); }
106
+ .btn-ghost { background: transparent; border-color: transparent; color: var(--muted); }
107
+ .btn-ghost:hover { color: var(--text); background: var(--panel-2); }
108
+ .btn-sm { padding: 5px 10px; font-size: 13px; }
109
+ .btn-block { width: 100%; }
110
+ .icon-btn {
111
+ display: inline-grid; place-items: center; width: 32px; height: 32px; border-radius: 8px;
112
+ border: 0; background: transparent; color: var(--muted); cursor: pointer;
113
+ }
114
+ .icon-btn:hover { background: var(--panel-2); color: var(--text); }
115
+ .input {
116
+ width: 100%; padding: 11px 13px; border-radius: 10px; border: 1px solid var(--border);
117
+ background: var(--panel); color: var(--text); font: 15px var(--mono); margin: 14px 0 10px;
118
+ }
119
+ .input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
120
+ .error-text { color: var(--bad); font-size: 14px; margin: 8px 0 0; }
121
+
122
+ /* ---------- sign in ---------- */
123
+ .login { min-height: 100%; display: grid; place-items: center; padding: 24px 16px; }
124
+ .login-card {
125
+ width: min(420px, 100%); background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
126
+ padding: 32px 28px; box-shadow: var(--shadow); text-align: center;
127
+ }
128
+ .login-card h1 { font-size: 21px; line-height: 1.3; margin: 0 0 8px; letter-spacing: -0.015em; }
129
+
130
+ /* ---------- layout ---------- */
131
+ .app {
132
+ height: 100vh; height: 100dvh;
133
+ display: grid; grid-template-columns: 260px 1fr; grid-template-rows: 54px 1fr;
134
+ grid-template-areas: "top top" "side main";
135
+ }
136
+ .topbar {
137
+ grid-area: top; display: flex; align-items: center; gap: 12px; padding: 0 14px;
138
+ border-bottom: 1px solid var(--border); background: var(--panel);
139
+ }
140
+ .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
141
+ .budget { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--muted); }
142
+ .budget-bar { width: 80px; height: 6px; border-radius: 99px; background: var(--panel-2); border: 1px solid var(--border); overflow: hidden; }
143
+ .budget-bar span { display: block; height: 100%; width: 0; background: var(--ok); transition: width .4s; }
144
+ .budget.high .budget-bar span { background: var(--warn); }
145
+ .budget.full .budget-bar span { background: var(--bad); }
146
+ .user { position: relative; }
147
+ .user-chip { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border); background: var(--panel); padding: 3px 10px 3px 3px; border-radius: 99px; cursor: pointer; font-size: 13.5px; }
148
+ .avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); display: grid; place-items: center; font-weight: 700; font-size: 12px; text-transform: uppercase; }
149
+ .menu { position: absolute; right: 0; top: calc(100% + 6px); background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow); padding: 5px; min-width: 170px; z-index: 30; }
150
+ .menu button { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 8px 10px; border-radius: 7px; cursor: pointer; font-size: 14px; }
151
+ .menu button:hover { background: var(--panel-2); }
152
+
153
+ .sidebar { grid-area: side; border-right: 1px solid var(--border); background: var(--panel); display: flex; flex-direction: column; min-height: 0; }
154
+ .sidebar-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 10px 6px 16px; }
155
+ .section-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
156
+ .history { overflow-y: auto; padding: 4px 8px 16px; flex: 1; }
157
+ .history-group { font-size: 11.5px; font-weight: 600; color: var(--muted); padding: 12px 8px 4px; }
158
+ .h-item {
159
+ display: flex; align-items: flex-start; gap: 9px; width: 100%; text-align: left; border: 0; background: none;
160
+ padding: 8px; border-radius: 8px; cursor: pointer; font-size: 13.5px; line-height: 1.35;
161
+ }
162
+ .h-item:hover, .h-item.active { background: var(--panel-2); }
163
+ .h-icon { flex: none; width: 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; margin-top: 1px; }
164
+ .h-icon svg { width: 13px; height: 13px; }
165
+ .h-icon.sim { background: var(--sim-soft); color: var(--sim); }
166
+ .h-icon.code { background: var(--code-soft); color: var(--code); }
167
+ .h-text { min-width: 0; flex: 1; }
168
+ .h-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
169
+ .h-meta { font-size: 12px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
170
+ .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; background: var(--muted); }
171
+ .dot.ok { background: var(--ok); } .dot.bad { background: var(--bad); }
172
+ .history-empty { color: var(--muted); font-size: 13.5px; padding: 10px 8px; }
173
+
174
+ .workspace { grid-area: main; display: grid; grid-template-columns: 1fr 1fr; min-height: 0; min-width: 0; }
175
+ .pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
176
+ .pane + .pane { border-left: 1px solid var(--border); }
177
+ .pane-head { display: flex; align-items: center; gap: 11px; padding: 14px 18px 12px; border-bottom: 1px solid var(--border); }
178
+ .pane-head h2 { font-size: 15.5px; margin: 0; letter-spacing: -0.01em; }
179
+ .pane-head p { margin: 0; }
180
+ .pane-head .btn { margin-left: auto; }
181
+ .pane-icon { width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; flex: none; }
182
+ .pane-icon.sim { background: var(--sim-soft); color: var(--sim); }
183
+ .pane-icon.code { background: var(--code-soft); color: var(--code); }
184
+
185
+ /* ---------- thread ---------- */
186
+ .thread { flex: 1; overflow-y: auto; padding: 18px; display: flex; flex-direction: column; gap: 14px; scroll-behavior: smooth; }
187
+ .empty { margin: auto 0; text-align: center; padding: 20px 6px; }
188
+ .empty h3 { margin: 0 0 4px; font-size: 16px; }
189
+ .examples { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
190
+ .example {
191
+ text-align: left; border: 1px solid var(--border); background: var(--panel); border-radius: 10px;
192
+ padding: 10px 12px; cursor: pointer; font-size: 14px; display: flex; gap: 8px; align-items: center;
193
+ }
194
+ .example:hover { border-color: var(--accent); background: var(--accent-soft); }
195
+ .example span { color: var(--muted); }
196
+
197
+ .msg-user {
198
+ align-self: flex-end; max-width: 88%; background: var(--accent-soft); color: var(--text);
199
+ border-radius: 14px 14px 4px 14px; padding: 9px 13px; white-space: pre-wrap; overflow-wrap: anywhere;
200
+ }
201
+ .msg-user .meta { display: block; font-size: 12px; color: var(--muted); margin-top: 3px; }
202
+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
203
+ .card-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; }
204
+ .card-head .title { font-weight: 600; }
205
+ .card-head .sub { font-size: 13px; color: var(--muted); }
206
+ .card-body { padding: 0 14px 14px; }
207
+ .card-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 14px 12px; border-top: 1px solid var(--border); align-items: center; }
208
+ .card-actions .spacer { flex: 1; }
209
+ .status-icon { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; flex: none; }
210
+ .status-icon svg { width: 15px; height: 15px; stroke-width: 2.4; }
211
+ .status-icon.ok { background: var(--ok-soft); color: var(--ok); }
212
+ .status-icon.bad { background: var(--bad-soft); color: var(--bad); }
213
+ .status-icon.warn { background: var(--warn-soft); color: var(--warn); }
214
+ .status-icon.busy { background: var(--accent-soft); }
215
+
216
+ .spinner { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--accent); border-right-color: transparent; animation: spin .8s linear infinite; }
217
+ @keyframes spin { to { transform: rotate(360deg); } }
218
+ .working { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 14px; }
219
+ .shimmer { background: linear-gradient(90deg, var(--muted) 0%, var(--text) 50%, var(--muted) 100%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: shimmer 1.8s linear infinite; }
220
+ @keyframes shimmer { to { background-position: -200% 0; } }
221
+ @media (prefers-reduced-motion: reduce) { .spinner, .shimmer { animation: none; } .shimmer { color: var(--muted); } }
222
+
223
+ .facts { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 10px; }
224
+ .pill { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; padding: 3px 9px; border-radius: 99px; background: var(--panel-2); border: 1px solid var(--border); }
225
+ .pill.ok { background: var(--ok-soft); border-color: transparent; color: var(--ok); }
226
+ .pill.bad { background: var(--bad-soft); border-color: transparent; color: var(--bad); }
227
+ .pill.warn { background: var(--warn-soft); border-color: transparent; color: var(--warn); }
228
+ .error-box { background: var(--bad-soft); color: var(--bad); border-radius: 9px; padding: 10px 12px; font-size: 13.5px; white-space: pre-wrap; overflow-wrap: anywhere; }
229
+ .note { font-size: 13.5px; color: var(--muted); }
230
+
231
+ video { width: 100%; display: block; border-radius: 10px; background: #000; aspect-ratio: 16 / 9; }
232
+
233
+ /* ---------- code viewer ---------- */
234
+ .code-view { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--code-bg); }
235
+ .tabs { display: flex; overflow-x: auto; border-bottom: 1px solid var(--border); background: var(--panel-2); scrollbar-width: thin; }
236
+ .tab { border: 0; background: none; padding: 7px 12px; font: 12.5px var(--mono); color: var(--muted); cursor: pointer; white-space: nowrap; border-bottom: 2px solid transparent; }
237
+ .tab.active { color: var(--text); border-bottom-color: var(--accent); background: var(--code-bg); }
238
+ .code-scroll { max-height: 360px; overflow: auto; }
239
+ pre.code { margin: 0; padding: 10px 0; font-size: 12.5px; line-height: 1.6; counter-reset: line; }
240
+ pre.code .ln { display: block; padding: 0 14px 0 0; white-space: pre; }
241
+ pre.code .ln::before { counter-increment: line; content: counter(line); display: inline-block; width: 3.2em; padding-right: 12px; text-align: right; color: var(--muted); opacity: .55; user-select: none; }
242
+ .hl-k { color: var(--hl-key); } .hl-s { color: var(--hl-str); } .hl-c { color: var(--hl-com); font-style: italic; } .hl-n { color: var(--hl-num); } .hl-t { color: var(--hl-tag); }
243
+ details.xml > summary { cursor: pointer; font-size: 13.5px; color: var(--muted); padding: 4px 0 8px; }
244
+
245
+ /* ---------- feedback ---------- */
246
+ .feedback { display: inline-flex; gap: 2px; align-items: center; }
247
+ .feedback .label { font-size: 12.5px; color: var(--muted); margin-right: 4px; }
248
+ .fb { border: 1px solid transparent; background: none; padding: 4px 7px; border-radius: 8px; cursor: pointer; font-size: 15px; line-height: 1; }
249
+ .fb:hover { background: var(--panel-2); }
250
+ .fb.chosen { background: var(--accent-soft); border-color: var(--accent); }
251
+
252
+ /* ---------- composer ---------- */
253
+ .composer { padding: 10px 14px 14px; border-top: 1px solid var(--border); background: var(--bg); }
254
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
255
+ .chip { border: 1px solid var(--border); background: var(--panel); border-radius: 99px; padding: 4px 11px; font-size: 13px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
256
+ .chip:hover { border-color: var(--sim); }
257
+ .chip.active { background: var(--sim-soft); border-color: var(--sim); color: var(--sim); font-weight: 600; }
258
+ .chip .emoji { font-size: 14px; }
259
+ .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 99px; padding: 2px; background: var(--panel); }
260
+ .seg-btn { border: 0; background: none; padding: 3px 11px; border-radius: 99px; font-size: 13px; cursor: pointer; color: var(--muted); }
261
+ .seg-btn.active { background: var(--code-soft); color: var(--code); font-weight: 600; }
262
+ .composer-box { position: relative; background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s; }
263
+ .composer-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
264
+ .composer textarea {
265
+ width: 100%; border: 0; background: transparent; color: var(--text); resize: none; font: 15px/1.5 var(--font);
266
+ padding: 12px 52px 12px 14px; max-height: 200px; outline: none; display: block;
267
+ }
268
+ .composer textarea::placeholder { color: var(--muted); }
269
+ .send {
270
+ position: absolute; right: 8px; bottom: 8px; width: 34px; height: 34px; border-radius: 10px; border: 0; cursor: pointer;
271
+ background: var(--accent); color: var(--accent-text); display: grid; place-items: center;
272
+ }
273
+ .send:disabled { opacity: .45; cursor: default; }
274
+ .send svg { stroke-width: 2.4; }
275
+
276
+ /* ---------- dialog & toast ---------- */
277
+ .dialog { border: 1px solid var(--border); border-radius: 16px; background: var(--panel); color: var(--text); padding: 24px; max-width: 520px; box-shadow: var(--shadow); }
278
+ .dialog::backdrop { background: rgb(0 0 0 / 40%); }
279
+ .dialog h2 { margin: 0 0 10px; font-size: 18px; }
280
+ .steps { padding-left: 20px; margin: 0 0 12px; } .steps li { margin-bottom: 8px; }
281
+ .toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: 10px 16px; border-radius: 10px; font-size: 14px; box-shadow: var(--shadow); z-index: 50; max-width: calc(100% - 32px); }
282
+
283
+ /* ---------- small screens ---------- */
284
+ .only-mobile { display: none !important; }
285
+ .scrim { display: none; }
286
+ @media (max-width: 1080px) {
287
+ .app { grid-template-columns: 220px 1fr; }
288
+ .budget-bar { display: none; }
289
+ }
290
+ @media (max-width: 860px) {
291
+ .only-mobile { display: inline-flex !important; }
292
+ .app { grid-template-columns: 1fr; grid-template-areas: "top" "main"; }
293
+ .sidebar { position: fixed; z-index: 40; top: 54px; bottom: 0; left: 0; width: min(300px, 85vw); transform: translateX(-100%); transition: transform .2s; box-shadow: var(--shadow); }
294
+ .sidebar.open { transform: none; }
295
+ .scrim { display: block; position: fixed; inset: 54px 0 0; background: rgb(0 0 0 / 30%); z-index: 35; }
296
+ .workspace { grid-template-columns: 1fr; }
297
+ .pane { display: none; } .pane.active { display: flex; }
298
+ .pane + .pane { border-left: 0; }
299
+ .brand .tag, .budget-label, .user-chip #user-name { display: none; }
300
+ .mobile-tabs { gap: 2px; background: var(--panel-2); padding: 3px; border-radius: 99px; border: 1px solid var(--border); }
301
+ .mtab { border: 0; background: none; padding: 4px 12px; border-radius: 99px; font-size: 13px; cursor: pointer; color: var(--muted); }
302
+ .mtab.active { background: var(--panel); color: var(--text); font-weight: 600; box-shadow: var(--shadow); }
303
+ .brand > span:not(.logo) { display: none; }
304
+ .thread { padding: 14px 12px; }
305
+ .composer { padding: 8px 10px 12px; }
306
+ }
api/static/app/app.js ADDED
@@ -0,0 +1,755 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // RosDiff web app. Plain JS, no build step: talks to the same API the docs describe.
2
+
3
+ const $ = (sel, root = document) => root.querySelector(sel);
4
+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
5
+
6
+ // ------------------------------------------------------------------ helpers
7
+
8
+ /** h("div", {class: "x", onclick: fn}, child, "text") -> element. Text is always set safely. */
9
+ function h(tag, attrs = {}, ...children) {
10
+ const el = document.createElement(tag);
11
+ for (const [k, v] of Object.entries(attrs || {})) {
12
+ if (v === null || v === undefined || v === false) continue;
13
+ if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
14
+ else if (k === "class") el.className = v;
15
+ else if (k === "html") el.innerHTML = v; // only ever used with our own static SVG strings
16
+ else el.setAttribute(k, v === true ? "" : v);
17
+ }
18
+ for (const c of children.flat()) {
19
+ if (c === null || c === undefined || c === false) continue;
20
+ el.append(c instanceof Node ? c : document.createTextNode(String(c)));
21
+ }
22
+ return el;
23
+ }
24
+
25
+ const ICON = {
26
+ check: '<svg viewBox="0 0 24 24"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>',
27
+ cross: '<svg viewBox="0 0 24 24"><path d="M7 7l10 10M17 7 7 17"/></svg>',
28
+ warn: '<svg viewBox="0 0 24 24"><path d="M12 8v5m0 3.5v.01"/></svg>',
29
+ sim: '<svg viewBox="0 0 24 24"><path d="M12 3 4 7.5v9L12 21l8-4.5v-9L12 3Zm0 0v18M4 7.5l8 4.5 8-4.5"/></svg>',
30
+ code: '<svg viewBox="0 0 24 24"><path d="m8 7-5 5 5 5m8-10 5 5-5 5"/></svg>',
31
+ play: '<svg viewBox="0 0 24 24"><path d="M8 5.5v13l10-6.5-10-6.5Z"/></svg>',
32
+ download: '<svg viewBox="0 0 24 24"><path d="M12 4v11m0 0 4.5-4.5M12 15l-4.5-4.5M5 19h14"/></svg>',
33
+ external: '<svg viewBox="0 0 24 24"><path d="M14 5h5v5M19 5l-8 8M17 14v5H5V7h5"/></svg>',
34
+ copy: '<svg viewBox="0 0 24 24"><path d="M9 9h10v10H9zM5 15V5h10"/></svg>',
35
+ bolt: '<svg viewBox="0 0 24 24"><path d="M13 3 5 13.5h6L10 21l8-10.5h-6L13 3Z"/></svg>',
36
+ wrench: '<svg viewBox="0 0 24 24"><path d="M14.5 6.5a4 4 0 0 0-5.3 5.3L4 17l3 3 5.2-5.2a4 4 0 0 0 5.3-5.3l-2.6 2.6-2.4-.6-.6-2.4 2.6-2.6Z"/></svg>',
37
+ };
38
+ const icon = (name) => h("span", { html: ICON[name], "aria-hidden": "true", style: "display:inline-flex" });
39
+
40
+ const ROBOTS = [
41
+ { id: "unitree_g1", label: "G1 humanoid", emoji: "🧍" },
42
+ { id: "unitree_h1", label: "H1 humanoid", emoji: "🧍‍♂️" },
43
+ { id: "unitree_go2", label: "Go2 robot dog", emoji: "🐕" },
44
+ { id: "franka_emika_panda", label: "Panda arm", emoji: "🦾" },
45
+ ];
46
+ const robotLabel = (id) => (ROBOTS.find((r) => r.id === id) || { label: id || "no robot" }).label;
47
+
48
+ const EXAMPLES = {
49
+ sim: [
50
+ ["unitree_g1", "The robot stands 1.5 m in front of a staircase with 3 steps, each 15 cm high and 30 cm deep."],
51
+ ["unitree_go2", "The dog is on a floor with three low boxes (10 cm tall) spread 1–2 m ahead of it."],
52
+ ["franka_emika_panda", "The arm stands next to a table 0.4 m high with three small coloured cubes on it."],
53
+ ["unitree_h1", "The humanoid faces a doorway 0.9 m wide between two walls, with a 2 kg box just past it."],
54
+ ],
55
+ code: [
56
+ "Stop the robot when the laser scanner sees something closer than 0.5 m, by publishing zero velocity on /cmd_vel.",
57
+ "A service that adds two integers, plus a small client node that calls it once a second.",
58
+ "Read the IMU on /imu and publish a smoothed (low-pass filtered) version on /imu/filtered.",
59
+ "Record the robot's /odom positions and publish them as a nav_msgs/Path on /trail.",
60
+ ],
61
+ };
62
+
63
+ const TERMINAL = new Set(["COMPLETED", "FAILED", "CANCELLED", "TIMED_OUT"]);
64
+
65
+ class ApiError extends Error {
66
+ constructor(status, detail) {
67
+ super(typeof detail === "string" ? detail : JSON.stringify(detail));
68
+ this.status = status;
69
+ this.detail = detail;
70
+ }
71
+ }
72
+
73
+ function friendly(err) {
74
+ if (!(err instanceof ApiError)) return "Can't reach the RosDiff server. Check your connection and try again.";
75
+ const d = err.detail;
76
+ const msg = typeof d === "string" ? d : d?.message || err.message;
77
+ switch (err.status) {
78
+ case 401: return "Your key isn't recognised. Sign out and sign in again.";
79
+ case 402: return "This month's shared compute budget is used up. Please tell the Orcanion team.";
80
+ case 429: return "Today's limit for this is reached. Try again tomorrow.";
81
+ case 503: return "This part isn't switched on on the server yet.";
82
+ case 507: return "Result storage is full for now. Old results are cleared automatically; try again later.";
83
+ case 502: return "The AI or the cloud GPU had a problem: " + msg;
84
+ case 422:
85
+ if (Array.isArray(d)) return "Please check what you typed: " + d.map((x) => x.msg).join("; ");
86
+ return msg;
87
+ default: return msg || "Something went wrong.";
88
+ }
89
+ }
90
+
91
+ // ------------------------------------------------------------------ API
92
+
93
+ const state = {
94
+ key: localStorage.getItem("rosdiff_key") || "",
95
+ tester: "",
96
+ robot: localStorage.getItem("rosdiff_robot") || "unitree_g1",
97
+ distro: localStorage.getItem("rosdiff_distro") || "humble",
98
+ lang: localStorage.getItem("rosdiff_lang") || "python",
99
+ busy: { sim: false, code: false },
100
+ };
101
+ function remember(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } }
102
+
103
+ async function api(path, { method = "GET", body, raw = false } = {}) {
104
+ let res;
105
+ try {
106
+ res = await fetch(path, {
107
+ method,
108
+ headers: { "X-API-Key": state.key, ...(body ? { "content-type": "application/json" } : {}) },
109
+ body: body ? JSON.stringify(body) : undefined,
110
+ });
111
+ } catch {
112
+ throw new Error("network");
113
+ }
114
+ if (!res.ok) {
115
+ let detail = res.statusText;
116
+ try { detail = (await res.json()).detail ?? detail; } catch { /* not json */ }
117
+ if (res.status === 401 && !path.startsWith("/me/history")) signOut(true);
118
+ throw new ApiError(res.status, detail);
119
+ }
120
+ return raw ? res : res.json();
121
+ }
122
+
123
+ function toast(text) {
124
+ const t = $("#toast");
125
+ t.textContent = text;
126
+ t.hidden = false;
127
+ clearTimeout(toast.timer);
128
+ toast.timer = setTimeout(() => (t.hidden = true), 3200);
129
+ }
130
+
131
+ function download(name, blob) {
132
+ const a = h("a", { href: URL.createObjectURL(blob), download: name });
133
+ document.body.append(a);
134
+ a.click();
135
+ a.remove();
136
+ setTimeout(() => URL.revokeObjectURL(a.href), 2000);
137
+ }
138
+
139
+ // ------------------------------------------------------------------ syntax highlighting (small, safe)
140
+
141
+ const KW = {
142
+ python: "def class import from return if elif else for while in not and or is None True False self with as try except finally raise lambda pass yield super",
143
+ cpp: "include class struct public private protected return if else for while auto const void int double float bool true false nullptr namespace using std new delete override virtual static template typename this",
144
+ cmake: "cmake_minimum_required project find_package add_executable ament_target_dependencies install ament_package if endif set target_link_libraries ament_lint_auto_find_test_dependencies DESTINATION TARGETS DIRECTORY REQUIRED",
145
+ };
146
+ function langFor(path) {
147
+ if (/\.py$/.test(path)) return "python";
148
+ if (/\.(cpp|hpp|cc|h)$/.test(path)) return "cpp";
149
+ if (/CMakeLists\.txt$|\.cmake$/.test(path)) return "cmake";
150
+ if (/\.(xml|urdf|xacro|launch)$/.test(path)) return "xml";
151
+ if (/\.ya?ml$|\.cfg$/.test(path)) return "yaml";
152
+ return "text";
153
+ }
154
+ function tokenize(code, lang) {
155
+ let re;
156
+ if (lang === "xml") re = /(<!--[\s\S]*?-->)|("[^"]*"|'[^']*')|(<\/?[\w:.-]+|\/?>)()/g;
157
+ else if (lang === "text") return [{ cls: "", text: code }];
158
+ else {
159
+ const comment = lang === "cpp" ? String.raw`//.*$|/\*[\s\S]*?\*/` : String.raw`#.*$`;
160
+ const kw = KW[lang] ? String.raw`\b(?:${KW[lang].split(" ").join("|")})\b` : "(?!x)x";
161
+ re = new RegExp(String.raw`(${comment})|("""[\s\S]*?"""|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')|(${kw})|(\b\d+(?:\.\d+)?\b)`, "gm");
162
+ }
163
+ const out = [];
164
+ let last = 0;
165
+ for (const m of code.matchAll(re)) {
166
+ if (m.index > last) out.push({ cls: "", text: code.slice(last, m.index) });
167
+ const cls = m[1] ? "hl-c" : m[2] ? "hl-s" : m[3] ? (lang === "xml" ? "hl-t" : "hl-k") : "hl-n";
168
+ out.push({ cls, text: m[0] });
169
+ last = m.index + m[0].length;
170
+ if (m[0].length === 0) re.lastIndex++;
171
+ }
172
+ if (last < code.length) out.push({ cls: "", text: code.slice(last) });
173
+ return out;
174
+ }
175
+ function codeBlock(code, lang) {
176
+ const pre = h("pre", { class: "code" });
177
+ let line = h("span", { class: "ln" });
178
+ for (const tok of tokenize(code.replace(/\n$/, ""), lang)) {
179
+ const parts = tok.text.split("\n");
180
+ parts.forEach((part, i) => {
181
+ if (i > 0) {
182
+ pre.append(line);
183
+ line = h("span", { class: "ln" });
184
+ }
185
+ if (part) line.append(tok.cls ? h("span", { class: tok.cls }, part) : document.createTextNode(part));
186
+ });
187
+ }
188
+ pre.append(line);
189
+ return h("div", { class: "code-scroll" }, pre);
190
+ }
191
+
192
+ // ------------------------------------------------------------------ cards
193
+
194
+ function statusIcon(kind) {
195
+ if (kind === "busy") return h("span", { class: "status-icon busy" }, h("span", { class: "spinner" }));
196
+ return h("span", { class: `status-icon ${kind}`, html: kind === "ok" ? ICON.check : kind === "bad" ? ICON.cross : ICON.warn });
197
+ }
198
+
199
+ function workingCard(steps) {
200
+ const text = h("span", { class: "shimmer" }, steps[0]);
201
+ const card = h("div", { class: "card" }, h("div", { class: "card-head" }, statusIcon("busy"), h("div", { class: "working" }, text)));
202
+ let i = 0;
203
+ card._timer = setInterval(() => { i = (i + 1) % steps.length; text.textContent = steps[i]; }, 5000);
204
+ card.stop = () => clearInterval(card._timer);
205
+ return card;
206
+ }
207
+
208
+ function feedbackBar(genId, current) {
209
+ const options = [["works", "👍", "It worked"], ["partly", "😐", "Partly worked"], ["broken", "👎", "It didn't work"]];
210
+ const wrap = h("div", { class: "feedback" }, h("span", { class: "label" }, "Did it work?"));
211
+ for (const [rating, emoji, label] of options) {
212
+ const b = h("button", { class: "fb" + (current === rating ? " chosen" : ""), type: "button", "aria-label": label, title: label, "aria-pressed": String(current === rating) }, emoji);
213
+ b.addEventListener("click", async () => {
214
+ try {
215
+ await api(`/feedback/${genId}`, { method: "POST", body: { rating, note: "" } });
216
+ $$(".fb", wrap).forEach((x) => { x.classList.toggle("chosen", x === b); x.setAttribute("aria-pressed", String(x === b)); });
217
+ toast("Thanks, that helps a lot!");
218
+ } catch (e) { toast(friendly(e)); }
219
+ });
220
+ wrap.append(b);
221
+ }
222
+ return wrap;
223
+ }
224
+
225
+ function errorCard(message) {
226
+ return h("div", { class: "card" }, h("div", { class: "card-head" }, statusIcon("bad"), h("div", {}, h("div", { class: "title" }, "That didn't work"), h("div", { class: "sub" }, message))));
227
+ }
228
+
229
+ function userMessage(text, meta) {
230
+ return h("div", { class: "msg-user" }, text, meta ? h("span", { class: "meta" }, meta) : null);
231
+ }
232
+
233
+ // --- simulation
234
+
235
+ function sceneCard(gen) {
236
+ const ok = gen.valid;
237
+ const val = gen.validation || {};
238
+ const stats = val.stats || {};
239
+ const head = h("div", { class: "card-head" }, statusIcon(ok ? "ok" : "bad"),
240
+ h("div", {},
241
+ h("div", { class: "title" }, ok ? "Scene ready" : "Couldn't build a working scene"),
242
+ h("div", { class: "sub" }, ok ? `Loads and simulates in MuJoCo · ${robotLabel(gen.robot)}` : "MuJoCo rejected every attempt")));
243
+ const body = h("div", { class: "card-body" });
244
+ if (ok) {
245
+ body.append(h("div", { class: "facts" },
246
+ h("span", { class: "pill" }, `${stats.nbody ?? "?"} bodies`),
247
+ h("span", { class: "pill" }, `${stats.ngeom ?? "?"} shapes`),
248
+ (val.warnings || []).length ? h("span", { class: "pill warn" }, `${val.warnings.length} warning(s)`) : h("span", { class: "pill ok" }, "no warnings")));
249
+ } else {
250
+ body.append(h("div", { class: "error-box" }, val.error || gen.error || "Unknown problem"));
251
+ }
252
+ if (gen.mjcf) {
253
+ body.append(h("details", { class: "xml" }, h("summary", {}, "Show the scene file (MJCF XML)"),
254
+ h("div", { class: "code-view" }, codeBlock(gen.mjcf, "xml"))));
255
+ }
256
+ const actions = h("div", { class: "card-actions" });
257
+ const runs = h("div", { class: "runs" });
258
+ if (ok) {
259
+ const run = h("button", { class: "btn btn-primary btn-sm", type: "button" }, icon("play"), "Run simulation");
260
+ run.addEventListener("click", () => startRun(gen, runs, run));
261
+ actions.append(run);
262
+ }
263
+ if (gen.mjcf) {
264
+ const dl = h("button", { class: "btn btn-sm", type: "button" }, icon("download"), "Download");
265
+ dl.addEventListener("click", () => download(`scene_${gen.id}.xml`, new Blob([gen.mjcf], { type: "application/xml" })));
266
+ actions.append(dl);
267
+ }
268
+ actions.append(h("span", { class: "spacer" }), feedbackBar(gen.id, gen.rating));
269
+ const wrap = h("div", { style: "display:flex;flex-direction:column;gap:14px" }, h("div", { class: "card" }, head, body, actions), runs);
270
+ return wrap;
271
+ }
272
+
273
+ async function startRun(gen, container, button) {
274
+ button.disabled = true;
275
+ try {
276
+ const job = await api("/simulate/run", { method: "POST", body: { generation_id: gen.id, duration_s: 10, controller: "hold" } });
277
+ container.append(jobCard({ id: job.run_id, kind: "sim", status: job.status, report_url: job.report_url }));
278
+ button.lastChild.textContent = "Run again";
279
+ scrollDown("sim");
280
+ } catch (e) {
281
+ container.append(errorCard(friendly(e)));
282
+ } finally {
283
+ setTimeout(() => (button.disabled = false), 1500);
284
+ }
285
+ }
286
+
287
+ // --- code
288
+
289
+ function packageCard(gen) {
290
+ const ok = gen.valid;
291
+ const val = gen.validation || {};
292
+ const files = gen.files || {};
293
+ const names = Object.keys(files);
294
+ const head = h("div", { class: "card-head" }, statusIcon(ok ? "ok" : names.length ? "warn" : "bad"),
295
+ h("div", {},
296
+ h("div", { class: "title" }, ok ? "Package ready" : names.length ? "Package written, with problems" : "Couldn't write the package"),
297
+ h("div", { class: "sub" }, `${names.length} file(s) · ROS 2 ${gen.distro || ""} · ${gen.language === "cpp" ? "C++" : "Python"}`)));
298
+ const body = h("div", { class: "card-body" });
299
+ const errors = val.errors || [];
300
+ if (errors.length) body.append(h("div", { class: "error-box" }, errors.slice(0, 6).join("\n")));
301
+ if ((val.warnings || []).length) body.append(h("p", { class: "note" }, `${val.warnings.length} note(s): ${val.warnings.slice(0, 2).join("; ")}`));
302
+ if (names.length) {
303
+ const tabs = h("div", { class: "tabs", role: "tablist" });
304
+ const view = h("div");
305
+ const show = (name) => {
306
+ $$(".tab", tabs).forEach((t) => { const on = t.dataset.name === name; t.classList.toggle("active", on); t.setAttribute("aria-selected", String(on)); });
307
+ view.replaceChildren(codeBlock(files[name], langFor(name)));
308
+ view.dataset.current = name;
309
+ };
310
+ const order = names.slice().sort((a, b) => score(a) - score(b) || a.localeCompare(b));
311
+ for (const name of order) {
312
+ tabs.append(h("button", { class: "tab", type: "button", role: "tab", "data-name": name, title: name, onclick: () => show(name) }, name.split("/").slice(-1)[0]));
313
+ }
314
+ body.append(h("div", { class: "code-view" }, tabs, view));
315
+ show(order[0]);
316
+ body._view = view;
317
+ }
318
+ const actions = h("div", { class: "card-actions" });
319
+ const checks = h("div", { class: "runs" });
320
+ if (names.length) {
321
+ const check = h("button", { class: "btn btn-primary btn-sm", type: "button" }, icon("wrench"), "Build & run");
322
+ check.addEventListener("click", () => startCheck(gen, checks, check));
323
+ const zip = h("button", { class: "btn btn-sm", type: "button" }, icon("download"), "Download .zip");
324
+ zip.addEventListener("click", async () => {
325
+ try { download(`rosdiff-${gen.id}.zip`, await (await api(`/code/${gen.id}/zip`, { raw: true })).blob()); }
326
+ catch (e) { toast(friendly(e)); }
327
+ });
328
+ const copy = h("button", { class: "btn btn-sm btn-ghost", type: "button", title: "Copy the open file" }, icon("copy"), "Copy");
329
+ copy.addEventListener("click", async () => {
330
+ const name = body._view?.dataset.current;
331
+ try { await navigator.clipboard.writeText(files[name]); toast(`Copied ${name.split("/").pop()}`); } catch { toast("Couldn't copy"); }
332
+ });
333
+ actions.append(check, zip, copy);
334
+ }
335
+ actions.append(h("span", { class: "spacer" }), feedbackBar(gen.id, gen.rating));
336
+ return h("div", { style: "display:flex;flex-direction:column;gap:14px" }, h("div", { class: "card" }, head, body, actions), checks);
337
+ }
338
+ function score(path) {
339
+ if (/launch/.test(path)) return 1;
340
+ if (/\.(py|cpp)$/.test(path) && !/(setup|__init__)\.py$/.test(path)) return 0;
341
+ if (/package\.xml$/.test(path)) return 2;
342
+ return 3;
343
+ }
344
+
345
+ async function startCheck(gen, container, button) {
346
+ button.disabled = true;
347
+ try {
348
+ const job = await api("/code/check", { method: "POST", body: { generation_id: gen.id } });
349
+ container.append(jobCard({ id: job.id, kind: "ros_check", status: job.status, report_url: job.report_url }));
350
+ scrollDown("code");
351
+ } catch (e) {
352
+ container.append(errorCard(friendly(e)));
353
+ } finally {
354
+ setTimeout(() => (button.disabled = false), 1500);
355
+ }
356
+ }
357
+
358
+ // --- background jobs (runs, checks, stress tests) with live status
359
+
360
+ const JOB_TEXT = {
361
+ sim: { wait: "Waiting for a cloud GPU… (the first run can take a minute or two)", run: "Simulating and recording the video…", title: "Simulation" },
362
+ sweep: { wait: "Starting the stress test…", run: "Running many variations at once on the GPU…", title: "Stress test" },
363
+ ros_check: { wait: "Starting a ROS 2 machine…", run: "Building, testing and running your package for 10 s…", title: "Build & run" },
364
+ review: { wait: "Waiting for the video reviewer…", run: "Watching the video…", title: "AI review" },
365
+ isaac: { wait: "Starting Isaac Sim…", run: "Rendering with Isaac Sim…", title: "Isaac Sim render" },
366
+ export: { wait: "Starting the export…", run: "Building the dataset…", title: "Dataset export" },
367
+ };
368
+
369
+ function jobCard(job) {
370
+ const card = h("div", { class: "card" });
371
+ card.dataset.job = job.id;
372
+ const render = (j) => {
373
+ const text = JOB_TEXT[j.kind] || JOB_TEXT.sim;
374
+ card.replaceChildren();
375
+ if (!TERMINAL.has(j.status)) {
376
+ card.append(h("div", { class: "card-head" }, statusIcon("busy"),
377
+ h("div", {}, h("div", { class: "title" }, text.title), h("div", { class: "working" },
378
+ h("span", { class: "shimmer" }, j.status === "IN_PROGRESS" ? text.run : text.wait)))));
379
+ return;
380
+ }
381
+ if (j.status !== "COMPLETED") {
382
+ card.append(h("div", { class: "card-head" }, statusIcon("bad"),
383
+ h("div", {}, h("div", { class: "title" }, `${text.title} failed`), h("div", { class: "sub" }, j.error ? String(j.error).split("\n")[0].slice(0, 220) : j.status.replace("_", " ").toLowerCase()))),
384
+ reportActions(j));
385
+ return;
386
+ }
387
+ if (j.kind === "sim") card.append(...simResult(j));
388
+ else if (j.kind === "ros_check") card.append(...checkResult(j));
389
+ else if (j.kind === "sweep") card.append(...sweepResult(j));
390
+ else card.append(h("div", { class: "card-head" }, statusIcon("ok"), h("div", {}, h("div", { class: "title" }, text.title), h("div", { class: "sub" }, j.summary?.headline || "Done"))), reportActions(j));
391
+ };
392
+ render(job);
393
+ poll(job.id, render);
394
+ return card;
395
+ }
396
+
397
+ async function poll(id, render) {
398
+ let delay = 3000;
399
+ for (;;) {
400
+ await new Promise((r) => setTimeout(r, delay));
401
+ if (!document.querySelector(`[data-job="${id}"]`)) return; // card was cleared
402
+ try {
403
+ const j = await api(`/jobs/${id}`);
404
+ render(j);
405
+ if (TERMINAL.has(j.status)) { refreshBudget(); return; }
406
+ } catch { /* keep trying quietly */ }
407
+ delay = Math.min(delay + 1000, 8000);
408
+ }
409
+ }
410
+
411
+ function reportActions(j, ...extra) {
412
+ const bar = h("div", { class: "card-actions" }, ...extra);
413
+ if (j.report_url) bar.append(h("a", { class: "btn btn-sm", href: j.report_url, target: "_blank", rel: "noopener" }, icon("external"), "Full report"));
414
+ return bar;
415
+ }
416
+
417
+ function simResult(j) {
418
+ const s = j.summary || {};
419
+ const media = j.media || {};
420
+ const fell = s.fell;
421
+ const head = h("div", { class: "card-head" }, statusIcon(fell ? "warn" : "ok"),
422
+ h("div", {}, h("div", { class: "title" }, fell ? `The robot fell at ${s.fell_at} s` : "Simulation finished"),
423
+ h("div", { class: "sub" }, `${s.simulated_seconds ?? "?"} s simulated${s.realtime_factor ? ` · ${s.realtime_factor}× real time` : ""}`)));
424
+ const body = h("div", { class: "card-body" });
425
+ if (media.video || media.video_webm) {
426
+ const v = h("video", { controls: true, autoplay: true, muted: true, loop: true, playsinline: true, preload: "metadata" });
427
+ if (media.video_webm) v.append(h("source", { src: media.video_webm, type: "video/webm" }));
428
+ if (media.video) v.append(h("source", { src: media.video, type: "video/mp4" }));
429
+ body.append(v);
430
+ } else if (s.render_error) {
431
+ body.append(h("p", { class: "note" }, "No video this time: " + s.render_error));
432
+ }
433
+ const facts = h("div", { class: "facts", style: "margin-top:10px" });
434
+ if (fell === false) facts.append(h("span", { class: "pill ok" }, "Stayed up"));
435
+ if (fell === true) facts.append(h("span", { class: "pill bad" }, "Fell over"));
436
+ if (s.unstable_at != null) facts.append(h("span", { class: "pill bad" }, `Unstable at ${s.unstable_at} s`));
437
+ if (s.max_contacts != null) facts.append(h("span", { class: "pill" }, `${s.max_contacts} contacts max`));
438
+ body.append(facts);
439
+ const stress = h("button", { class: "btn btn-sm", type: "button", title: "Run 64 slightly different versions (friction, weights, a push) and count how often it falls" }, icon("bolt"), "Stress test");
440
+ const extra = h("div", { class: "runs" });
441
+ stress.addEventListener("click", async () => {
442
+ stress.disabled = true;
443
+ try {
444
+ const job = await api("/simulate/sweep", { method: "POST", body: { run_id: j.id, n_worlds: 64 } });
445
+ extra.append(jobCard({ id: job.id, kind: "sweep", status: job.status, report_url: job.report_url }));
446
+ } catch (e) { extra.append(errorCard(friendly(e))); stress.disabled = false; }
447
+ });
448
+ const wrap = h("div", { style: "display:contents" });
449
+ return [head, body, reportActions(j, stress), extra, wrap];
450
+ }
451
+
452
+ function checkResult(j) {
453
+ const r = j.summary || {};
454
+ const b = r.build || {};
455
+ const la = r.launch;
456
+ const te = r.test;
457
+ const good = r.passed;
458
+ const head = h("div", { class: "card-head" }, statusIcon(good ? "ok" : b.ok ? "warn" : "bad"),
459
+ h("div", {}, h("div", { class: "title" }, good ? "Builds and runs" : b.ok ? "Builds, but didn't run cleanly" : "Doesn't build"),
460
+ h("div", { class: "sub" }, r.headline || "")));
461
+ const body = h("div", { class: "card-body" });
462
+ const facts = h("div", { class: "facts" },
463
+ h("span", { class: "pill " + (b.ok ? "ok" : "bad") }, b.ok ? "✓ Build" : "✗ Build"),
464
+ la ? h("span", { class: "pill " + (la.ok ? "ok" : "bad") }, la.ok ? "✓ Runs" : "✗ Runs") : null,
465
+ te && te.ran ? h("span", { class: "pill " + (te.ok ? "ok" : "warn") }, `Tests ${te.tests - te.failures - te.errors}/${te.tests}`) : null,
466
+ r.lint ? h("span", { class: "pill" + (r.lint.total ? " warn" : " ok") }, r.lint.total ? `${r.lint.total} style note(s)` : "Style clean") : null);
467
+ body.append(facts);
468
+ const errs = [...(b.errors || []), ...((la && la.errors) || [])];
469
+ if (errs.length) body.append(h("div", { class: "error-box" }, errs.slice(0, 6).join("\n")));
470
+ const missing = [...(b.missing_dependencies || []), ...((la && la.missing_dependencies) || [])];
471
+ if (missing.length) body.append(h("p", { class: "note" }, "Missing: " + [...new Set(missing)].join(", ")));
472
+ if (la && la.crashed?.length) body.append(h("p", { class: "note" }, "Crashed: " + la.crashed.join(", ")));
473
+ if (la && (la.nodes?.length || la.topics?.length)) {
474
+ body.append(h("p", { class: "note", style: "margin:10px 0 4px" }, "While it ran:"),
475
+ h("div", { class: "facts" }, ...(la.nodes || []).map((n) => h("span", { class: "pill" }, "● " + n)),
476
+ ...(la.topics || []).slice(0, 12).map((t) => h("span", { class: "pill" }, t.split(" ")[0]))));
477
+ }
478
+ return [head, body, reportActions(j)];
479
+ }
480
+
481
+ function sweepResult(j) {
482
+ const s = j.summary || {};
483
+ const rate = s.fall_rate;
484
+ const head = h("div", { class: "card-head" }, statusIcon(rate == null ? "ok" : rate < 0.2 ? "ok" : rate < 0.6 ? "warn" : "bad"),
485
+ h("div", {}, h("div", { class: "title" }, "Stress test"), h("div", { class: "sub" }, s.headline || "")));
486
+ const body = h("div", { class: "card-body" }, h("p", { class: "note" },
487
+ rate == null ? "" : `Out of ${s.worlds} slightly different versions (friction, object weights and a push), the robot fell in ${s.fell}.`));
488
+ return [head, body, reportActions(j)];
489
+ }
490
+
491
+ // ------------------------------------------------------------------ threads
492
+
493
+ function thread(pane) { return $(`#thread-${pane}`); }
494
+ function scrollDown(pane) { const t = thread(pane); requestAnimationFrame(() => (t.scrollTop = t.scrollHeight)); }
495
+
496
+ function showEmpty(pane) {
497
+ const t = thread(pane);
498
+ const examples = h("div", { class: "examples" });
499
+ if (pane === "sim") {
500
+ for (const [robot, text] of EXAMPLES.sim) {
501
+ examples.append(h("button", { class: "example", type: "button", onclick: () => { setRobot(robot); fillComposer("sim", text); } },
502
+ h("span", {}, ROBOTS.find((r) => r.id === robot).emoji), text));
503
+ }
504
+ } else {
505
+ for (const text of EXAMPLES.code) {
506
+ examples.append(h("button", { class: "example", type: "button", onclick: () => fillComposer("code", text) }, h("span", {}, "›"), text));
507
+ }
508
+ }
509
+ t.replaceChildren(h("div", { class: "empty" },
510
+ h("h3", {}, pane === "sim" ? "What world should the robot be in?" : "What should your ROS 2 node do?"),
511
+ h("p", { class: "muted small" }, pane === "sim" ? "Pick a robot below, then describe the scene in plain words. Or start from an example:" : "Choose the ROS 2 version and language below, then describe it. Or start from an example:"),
512
+ examples));
513
+ }
514
+
515
+ function clearPane(pane) {
516
+ thread(pane).replaceChildren();
517
+ showEmpty(pane);
518
+ $$(".h-item.active").forEach((x) => x.classList.remove("active"));
519
+ }
520
+
521
+ function fillComposer(pane, text) {
522
+ const input = $(`#input-${pane}`);
523
+ input.value = text;
524
+ autosize(input);
525
+ input.focus();
526
+ }
527
+
528
+ function autosize(el) { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 200) + "px"; }
529
+
530
+ async function submit(pane) {
531
+ if (state.busy[pane]) return;
532
+ const input = $(`#input-${pane}`);
533
+ const text = input.value.trim();
534
+ if (text.length < 10) { toast("Please describe it in a bit more detail (at least 10 characters)."); input.focus(); return; }
535
+ state.busy[pane] = true;
536
+ $(`#composer-${pane} .send`).disabled = true;
537
+ const t = thread(pane);
538
+ if (t.querySelector(".empty")) t.replaceChildren();
539
+ const meta = pane === "sim" ? robotLabel(state.robot) : `ROS 2 ${cap(state.distro)} · ${state.lang === "cpp" ? "C++" : "Python"}`;
540
+ t.append(userMessage(text, meta));
541
+ input.value = "";
542
+ autosize(input);
543
+ const steps = pane === "sim"
544
+ ? ["Reading the MuJoCo documentation…", "Writing the scene…", "Testing it in MuJoCo…", "Fixing anything MuJoCo complained about…", "Almost there…"]
545
+ : ["Looking up the ROS 2 messages it needs…", "Writing the package…", "Checking every message type exists…", "Tidying up…", "Almost there…"];
546
+ const working = workingCard(steps);
547
+ t.append(working);
548
+ scrollDown(pane);
549
+ try {
550
+ if (pane === "sim") {
551
+ const r = await api("/simulate/generate", { method: "POST", body: { description: text, robot: state.robot } });
552
+ working.replaceWith(sceneCard({ ...r, rating: null }));
553
+ } else {
554
+ const r = await api("/code/generate", { method: "POST", body: { request: text, distro: state.distro, language: state.lang } });
555
+ working.replaceWith(packageCard({ ...r, distro: state.distro, language: state.lang, rating: null }));
556
+ }
557
+ loadHistory();
558
+ } catch (e) {
559
+ working.replaceWith(errorCard(friendly(e)));
560
+ } finally {
561
+ working.stop();
562
+ state.busy[pane] = false;
563
+ $(`#composer-${pane} .send`).disabled = false;
564
+ scrollDown(pane);
565
+ }
566
+ }
567
+ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
568
+
569
+ // ------------------------------------------------------------------ history
570
+
571
+ function groupLabel(iso) {
572
+ const d = new Date(iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z");
573
+ const today = new Date();
574
+ const days = Math.floor((new Date(today.toDateString()) - new Date(d.toDateString())) / 86400000);
575
+ return days <= 0 ? "Today" : days === 1 ? "Yesterday" : days < 7 ? "This week" : "Earlier";
576
+ }
577
+ function timeLabel(iso) {
578
+ const d = new Date(iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z");
579
+ return d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
580
+ }
581
+
582
+ async function loadHistory() {
583
+ const box = $("#history");
584
+ try {
585
+ const { items } = await api("/me/history");
586
+ box.replaceChildren();
587
+ if (!items.length) { box.append(h("div", { class: "history-empty" }, "Nothing yet. What you make shows up here.")); return; }
588
+ let group = "";
589
+ for (const it of items) {
590
+ const g = groupLabel(it.created_at);
591
+ if (g !== group) { box.append(h("div", { class: "history-group" }, g)); group = g; }
592
+ const kind = it.kind === "code" ? "code" : "sim";
593
+ const meta = kind === "sim" ? robotLabel(it.robot) : `${cap(it.distro || "")} · ${it.language === "cpp" ? "C++" : "Python"}`;
594
+ const item = h("button", { class: "h-item", type: "button", role: "listitem", "data-id": it.id, title: timeLabel(it.created_at) },
595
+ h("span", { class: `h-icon ${kind}`, html: ICON[kind] }),
596
+ h("span", { class: "h-text" }, h("span", { class: "h-title" }, it.title || "(untitled)"),
597
+ h("span", { class: "h-meta" }, h("span", { class: "dot " + (it.valid ? "ok" : it.valid === false ? "bad" : "") }), meta)));
598
+ item.addEventListener("click", () => openGeneration(it.id, kind, item));
599
+ box.append(item);
600
+ }
601
+ } catch (e) {
602
+ if (!(e instanceof ApiError && e.status === 401)) box.replaceChildren(h("div", { class: "history-empty" }, friendly(e)));
603
+ }
604
+ }
605
+
606
+ async function openGeneration(id, kind, item) {
607
+ closeSidebar();
608
+ switchPane(kind);
609
+ $$(".h-item").forEach((x) => x.classList.toggle("active", x === item));
610
+ const t = thread(kind);
611
+ t.replaceChildren(workingCard(["Opening…"]));
612
+ try {
613
+ const g = await api(`/generations/${id}`);
614
+ const req = g.request || {};
615
+ const content = [];
616
+ if (g.kind === "code") {
617
+ content.push(userMessage(req.request || "", `ROS 2 ${cap(req.distro || "")} · ${req.language === "cpp" ? "C++" : "Python"}`));
618
+ const card = packageCard({ id: g.id, valid: g.valid, validation: g.validation, files: g.output || {}, distro: req.distro, language: req.language, rating: g.rating });
619
+ content.push(card);
620
+ for (const j of g.jobs) card.querySelector(".runs").append(jobCard(j));
621
+ } else {
622
+ content.push(userMessage(req.description || "", robotLabel(req.robot)));
623
+ const card = sceneCard({ id: g.id, valid: g.valid, validation: g.validation, mjcf: g.output, robot: req.robot, error: g.error, rating: g.rating });
624
+ content.push(card);
625
+ for (const j of g.jobs) card.querySelector(".runs").append(jobCard(j));
626
+ }
627
+ t.replaceChildren(...content);
628
+ } catch (e) {
629
+ t.replaceChildren(errorCard(friendly(e)));
630
+ }
631
+ }
632
+
633
+ // ------------------------------------------------------------------ budget
634
+
635
+ async function refreshBudget() {
636
+ try {
637
+ const { budget } = await api("/jobs");
638
+ const used = budget.month_spend_usd_estimate || 0;
639
+ const limit = budget.monthly_limit_usd;
640
+ const el = $("#budget");
641
+ if (!limit) { $("#budget-label").textContent = `$${used.toFixed(2)} used this month`; return; }
642
+ const pct = Math.min(100, (100 * used) / limit);
643
+ $("#budget-label").textContent = `$${used.toFixed(2)} of $${limit.toFixed(0)} this month`;
644
+ $("#budget-fill").style.width = pct + "%";
645
+ el.classList.toggle("high", pct >= 70 && pct < 100);
646
+ el.classList.toggle("full", pct >= 100);
647
+ el.title = "Cloud compute used this month by all testers together";
648
+ } catch { /* not important */ }
649
+ }
650
+
651
+ // ------------------------------------------------------------------ layout bits
652
+
653
+ function setRobot(id) {
654
+ state.robot = id;
655
+ remember("rosdiff_robot", id);
656
+ $$("#robot-chips .chip").forEach((c) => { const on = c.dataset.robot === id; c.classList.toggle("active", on); c.setAttribute("aria-checked", String(on)); });
657
+ }
658
+
659
+ function setupComposer() {
660
+ const chips = $("#robot-chips");
661
+ for (const r of ROBOTS) {
662
+ chips.append(h("button", { class: "chip", type: "button", role: "radio", "data-robot": r.id, onclick: () => setRobot(r.id) }, h("span", { class: "emoji", "aria-hidden": "true" }, r.emoji), r.label));
663
+ }
664
+ setRobot(state.robot);
665
+ for (const [attr, key, stateKey] of [["distro", "rosdiff_distro", "distro"], ["lang", "rosdiff_lang", "lang"]]) {
666
+ const btns = $$(`[data-${attr}]`);
667
+ const set = (v) => {
668
+ state[stateKey] = v;
669
+ remember(key, v);
670
+ btns.forEach((b) => { const on = b.dataset[attr] === v; b.classList.toggle("active", on); b.setAttribute("aria-checked", String(on)); });
671
+ };
672
+ btns.forEach((b) => b.addEventListener("click", () => set(b.dataset[attr])));
673
+ set(state[stateKey]);
674
+ }
675
+ for (const pane of ["sim", "code"]) {
676
+ const input = $(`#input-${pane}`);
677
+ input.addEventListener("input", () => autosize(input));
678
+ input.addEventListener("keydown", (e) => {
679
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit(pane); }
680
+ });
681
+ $(`#composer-${pane}`).addEventListener("submit", (e) => { e.preventDefault(); submit(pane); });
682
+ }
683
+ $$("[data-clear]").forEach((b) => b.addEventListener("click", () => clearPane(b.dataset.clear)));
684
+ }
685
+
686
+ function switchPane(pane) {
687
+ $$(".pane").forEach((p) => p.classList.toggle("active", p.id === `pane-${pane}`));
688
+ $$(".mtab").forEach((b) => b.classList.toggle("active", b.dataset.pane === pane));
689
+ }
690
+ function openSidebar() { $("#sidebar").classList.add("open"); $("#scrim").hidden = false; $("#menu-btn").setAttribute("aria-expanded", "true"); }
691
+ function closeSidebar() { $("#sidebar").classList.remove("open"); $("#scrim").hidden = true; $("#menu-btn").setAttribute("aria-expanded", "false"); }
692
+
693
+ function setupChrome() {
694
+ $$(".mtab").forEach((b) => b.addEventListener("click", () => switchPane(b.dataset.pane)));
695
+ $("#menu-btn").addEventListener("click", () => ($("#sidebar").classList.contains("open") ? closeSidebar() : openSidebar()));
696
+ $("#scrim").addEventListener("click", closeSidebar);
697
+ $("#refresh-history").addEventListener("click", loadHistory);
698
+ const menu = $("#user-menu");
699
+ $("#user-btn").addEventListener("click", (e) => { e.stopPropagation(); menu.hidden = !menu.hidden; $("#user-btn").setAttribute("aria-expanded", String(!menu.hidden)); });
700
+ document.addEventListener("click", () => { menu.hidden = true; $("#user-btn").setAttribute("aria-expanded", "false"); });
701
+ $("#logout-btn").addEventListener("click", () => signOut(false));
702
+ $("#help-btn").addEventListener("click", () => $("#help").showModal());
703
+ }
704
+
705
+ // ------------------------------------------------------------------ sign in / out
706
+
707
+ function signOut(expired) {
708
+ state.key = "";
709
+ try { localStorage.removeItem("rosdiff_key"); } catch { /* ignore */ }
710
+ $("#app").hidden = true;
711
+ $("#login").hidden = false;
712
+ if (expired) { const e = $("#login-error"); e.textContent = "Please sign in again."; e.hidden = false; }
713
+ $("#key-input").focus();
714
+ }
715
+
716
+ async function enter() {
717
+ const { tester } = await api("/me/history?limit=1");
718
+ state.tester = tester;
719
+ $("#user-name").textContent = tester;
720
+ $("#avatar").textContent = tester.slice(0, 1);
721
+ $("#login").hidden = true;
722
+ $("#app").hidden = false;
723
+ showEmpty("sim");
724
+ showEmpty("code");
725
+ loadHistory();
726
+ refreshBudget();
727
+ setInterval(refreshBudget, 60000);
728
+ if (!localStorage.getItem("rosdiff_seen_help")) { remember("rosdiff_seen_help", "1"); $("#help").showModal(); }
729
+ }
730
+
731
+ function setupLogin() {
732
+ $("#login-form").addEventListener("submit", async (e) => {
733
+ e.preventDefault();
734
+ const err = $("#login-error");
735
+ err.hidden = true;
736
+ state.key = $("#key-input").value.trim();
737
+ try {
738
+ await enter();
739
+ remember("rosdiff_key", state.key);
740
+ } catch (ex) {
741
+ err.textContent = ex instanceof ApiError && ex.status === 401 ? "That key isn't recognised. Check it and try again." : friendly(ex);
742
+ err.hidden = false;
743
+ }
744
+ });
745
+ }
746
+
747
+ setupLogin();
748
+ setupChrome();
749
+ setupComposer();
750
+ if (state.key) {
751
+ enter().catch(() => signOut(false));
752
+ } else {
753
+ $("#login").hidden = false;
754
+ $("#key-input").focus();
755
+ }
api/static/app/index.html ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>RosDiff</title>
7
+ <meta name="description" content="Describe a robot world or a ROS 2 package; RosDiff builds it and tests it.">
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236d5efc'/%3E%3Cpath d='M9 22V10h7a4 4 0 0 1 0 8h-7m7 0 5 4' stroke='white' stroke-width='3' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
12
+ <link rel="stylesheet" href="/static/app/app.css">
13
+ </head>
14
+ <body>
15
+ <!-- Sign in -->
16
+ <div id="login" class="login" hidden>
17
+ <form class="login-card" id="login-form" autocomplete="off">
18
+ <div class="brand brand-lg"><span class="logo" aria-hidden="true">R</span><span>RosDiff</span></div>
19
+ <h1>Build robot worlds and ROS 2 code by describing them</h1>
20
+ <p class="muted">Enter the tester key you were given. It starts with <code>rd_</code>.</p>
21
+ <label class="sr-only" for="key-input">Tester key</label>
22
+ <input id="key-input" class="input" type="password" placeholder="rd_…" spellcheck="false" required>
23
+ <button class="btn btn-primary btn-block" type="submit">Sign in</button>
24
+ <p id="login-error" class="error-text" role="alert" hidden></p>
25
+ <p class="muted small">No key yet? Ask your Orcanion contact.</p>
26
+ </form>
27
+ </div>
28
+
29
+ <!-- App -->
30
+ <div id="app" class="app" hidden>
31
+ <header class="topbar">
32
+ <button class="icon-btn only-mobile" id="menu-btn" aria-label="Show history" aria-expanded="false">
33
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
34
+ </button>
35
+ <div class="brand"><span class="logo" aria-hidden="true">R</span><span>RosDiff</span><span class="tag">testing</span></div>
36
+ <nav class="mobile-tabs only-mobile" aria-label="Workspace">
37
+ <button class="mtab active" data-pane="sim">Simulation</button>
38
+ <button class="mtab" data-pane="code">Code</button>
39
+ </nav>
40
+ <div class="topbar-right">
41
+ <div class="budget" id="budget" title="Compute used this month by all testers">
42
+ <span class="budget-label" id="budget-label">—</span>
43
+ <span class="budget-bar"><span id="budget-fill"></span></span>
44
+ </div>
45
+ <div class="user">
46
+ <button class="user-chip" id="user-btn" aria-haspopup="menu" aria-expanded="false">
47
+ <span class="avatar" id="avatar" aria-hidden="true">?</span><span id="user-name">tester</span>
48
+ </button>
49
+ <div class="menu" id="user-menu" role="menu" hidden>
50
+ <button role="menuitem" id="help-btn">How it works</button>
51
+ <button role="menuitem" id="logout-btn">Sign out</button>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </header>
56
+
57
+ <aside class="sidebar" id="sidebar" aria-label="History">
58
+ <div class="sidebar-head">
59
+ <span class="section-title">History</span>
60
+ <button class="icon-btn" id="refresh-history" aria-label="Refresh history" title="Refresh">
61
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6"/></svg>
62
+ </button>
63
+ </div>
64
+ <div class="history" id="history" role="list"></div>
65
+ </aside>
66
+ <div class="scrim" id="scrim" hidden></div>
67
+
68
+ <main class="workspace">
69
+ <!-- Simulation pane -->
70
+ <section class="pane active" id="pane-sim" aria-labelledby="sim-title">
71
+ <div class="pane-head">
72
+ <span class="pane-icon sim" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 4 7.5v9L12 21l8-4.5v-9L12 3Zm0 0v18M4 7.5l8 4.5 8-4.5"/></svg></span>
73
+ <div>
74
+ <h2 id="sim-title">Simulation</h2>
75
+ <p class="muted small">Describe a world. We build it in MuJoCo and test it.</p>
76
+ </div>
77
+ <button class="btn btn-ghost btn-sm" data-clear="sim" aria-label="Start a new simulation thread">New</button>
78
+ </div>
79
+ <div class="thread" id="thread-sim" aria-live="polite"></div>
80
+ <form class="composer" id="composer-sim">
81
+ <div class="chips" role="radiogroup" aria-label="Robot" id="robot-chips"></div>
82
+ <div class="composer-box">
83
+ <label class="sr-only" for="input-sim">Describe the scene</label>
84
+ <textarea id="input-sim" rows="2" placeholder="e.g. The robot stands in front of three steps, 15 cm high, one metre ahead…"></textarea>
85
+ <button class="send" type="submit" aria-label="Build scene" title="Build scene (Enter)">
86
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
87
+ </button>
88
+ </div>
89
+ </form>
90
+ </section>
91
+
92
+ <!-- Code pane -->
93
+ <section class="pane" id="pane-code" aria-labelledby="code-title">
94
+ <div class="pane-head">
95
+ <span class="pane-icon code" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m8 7-5 5 5 5m8-10 5 5-5 5M14 4l-4 16"/></svg></span>
96
+ <div>
97
+ <h2 id="code-title">ROS 2 code</h2>
98
+ <p class="muted small">Describe a node. We write the package and can build and run it.</p>
99
+ </div>
100
+ <button class="btn btn-ghost btn-sm" data-clear="code" aria-label="Start a new code thread">New</button>
101
+ </div>
102
+ <div class="thread" id="thread-code" aria-live="polite"></div>
103
+ <form class="composer" id="composer-code">
104
+ <div class="chips">
105
+ <div class="seg" role="radiogroup" aria-label="ROS 2 version">
106
+ <button type="button" class="seg-btn active" data-distro="humble" role="radio" aria-checked="true">Humble</button>
107
+ <button type="button" class="seg-btn" data-distro="jazzy" role="radio" aria-checked="false">Jazzy</button>
108
+ </div>
109
+ <div class="seg" role="radiogroup" aria-label="Language">
110
+ <button type="button" class="seg-btn active" data-lang="python" role="radio" aria-checked="true">Python</button>
111
+ <button type="button" class="seg-btn" data-lang="cpp" role="radio" aria-checked="false">C++</button>
112
+ </div>
113
+ </div>
114
+ <div class="composer-box">
115
+ <label class="sr-only" for="input-code">Describe the ROS 2 package</label>
116
+ <textarea id="input-code" rows="2" placeholder="e.g. Stop the robot when the laser sees something closer than 0.5 m…"></textarea>
117
+ <button class="send" type="submit" aria-label="Write package" title="Write package (Enter)">
118
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
119
+ </button>
120
+ </div>
121
+ </form>
122
+ </section>
123
+ </main>
124
+ </div>
125
+
126
+ <!-- How it works -->
127
+ <dialog id="help" class="dialog" aria-labelledby="help-title">
128
+ <h2 id="help-title">How RosDiff works</h2>
129
+ <ol class="steps">
130
+ <li><b>Simulation (left):</b> pick a robot, describe a world in plain words and press Enter. RosDiff writes the scene and checks that it loads in MuJoCo.</li>
131
+ <li>Press <b>Run simulation</b> to watch the robot in that world on a cloud GPU. The video appears right in the chat after a minute or two.</li>
132
+ <li><b>ROS 2 code (right):</b> describe a node or package. RosDiff writes it, then <b>Build &amp; run</b> compiles it and starts it for 10 seconds in a real ROS 2 setup.</li>
133
+ <li>Tell us if a result actually worked with 👍 / 👎. That feedback is the whole point of this test.</li>
134
+ </ol>
135
+ <p class="muted small">Everything you make is saved under History. Results are kept for 30 days.</p>
136
+ <form method="dialog"><button class="btn btn-primary">Got it</button></form>
137
+ </dialog>
138
+
139
+ <div class="toast" id="toast" role="status" hidden></div>
140
+ <script src="/static/app/app.js" type="module"></script>
141
+ </body>
142
+ </html>
tests/test_webapp.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import json
3
+ import zipfile
4
+
5
+ from conftest import complete
6
+ from ros_fixtures import INTERFACES, as_file_blocks, python_package
7
+
8
+ from api.ros_check import load_interfaces
9
+
10
+
11
+ def test_app_page_and_assets_are_served(run_env):
12
+ client = run_env[0]
13
+ page = client.get("/")
14
+ assert page.status_code == 200 and "RosDiff" in page.text and "/static/app/app.js" in page.text
15
+ assert client.get("/static/app/app.js").status_code == 200
16
+ assert client.get("/static/app/app.css").status_code == 200
17
+
18
+
19
+ def test_history_generation_and_media(run_env):
20
+ client, keys_, backend, services, gen_id = run_env
21
+ h = {"X-API-Key": keys_["alice"]}
22
+ hist = client.get("/me/history", headers=h).json()
23
+ assert hist["tester"] == "alice" and hist["items"][0]["id"] == gen_id
24
+ assert hist["items"][0]["kind"] == "simulate" and hist["items"][0]["title"].startswith("G1 near a slope")
25
+ assert client.get("/me/history", headers={"X-API-Key": keys_["bob"]}).json()["items"] == []
26
+
27
+ run_id = client.post("/simulate/run", json={"generation_id": gen_id}, headers=h).json()["run_id"]
28
+ complete(backend, "job-1", run_id)
29
+ job = client.get(f"/jobs/{run_id}", headers=h).json()
30
+ assert job["media"]["video"].endswith(f"runs/{run_id}/video.mp4") and "video_webm" in job["media"]
31
+
32
+ g = client.get(f"/generations/{gen_id}", headers=h).json()
33
+ assert g["kind"] == "simulate" and g["valid"] and "<mujoco" in g["output"]
34
+ assert [j["id"] for j in g["jobs"]] == [run_id] and "/report/" in g["jobs"][0]["report_url"]
35
+ assert client.get(f"/generations/{gen_id}", headers={"X-API-Key": keys_["bob"]}).status_code == 404
36
+
37
+
38
+ def test_code_zip(run_env):
39
+ client, keys_, backend, services, _ = run_env
40
+ services.settings.data_dir.mkdir(parents=True, exist_ok=True)
41
+ services.settings.interfaces_file.write_text(json.dumps(INTERFACES))
42
+ load_interfaces.cache_clear()
43
+ services.llm_factory().responses[:] = [as_file_blocks(python_package())]
44
+ h = {"X-API-Key": keys_["alice"]}
45
+ gen = client.post("/code/generate", json={"request": "Stop on a close LaserScan reading"}, headers=h).json()
46
+ r = client.get(f"/code/{gen['id']}/zip", headers=h)
47
+ assert r.status_code == 200 and r.headers["content-type"] == "application/zip"
48
+ names = zipfile.ZipFile(io.BytesIO(r.content)).namelist()
49
+ assert "safety_stop/package.xml" in names
50
+ assert client.get(f"/code/{gen['id']}/zip", headers={"X-API-Key": keys_["bob"]}).status_code == 404
51
+ g = client.get(f"/generations/{gen['id']}", headers=h).json()
52
+ assert g["kind"] == "code" and "safety_stop/package.xml" in g["output"]