wao 0.40.0 → 0.40.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cjs/ao.js +4 -4
- package/cjs/create.js +32 -27
- package/cjs/workspace/.claude/agents/builder.md +4 -2
- package/cjs/workspace/.claude/skills/build/SKILL.md +34 -12
- package/cjs/workspace/.claude/skills/build-aos/SKILL.md +10 -1
- package/cjs/workspace/.claude/skills/build-device/SKILL.md +37 -23
- package/cjs/workspace/.claude/skills/build-frontend/SKILL.md +17 -14
- package/cjs/workspace/.claude/skills/build-module/SKILL.md +10 -1
- package/cjs/workspace/.claude/skills/plan/SKILL.md +12 -4
- package/cjs/workspace/.claude/skills/readme/SKILL.md +151 -45
- package/cjs/workspace/.claude/skills/report/SKILL.md +2 -1
- package/cjs/workspace/dashboard/index.html +154 -3
- package/cjs/workspace/dashboard/public/favicon.ico +0 -0
- package/cjs/workspace/dashboard/public/favicon.png +0 -0
- package/cjs/workspace/dashboard/server.js +93 -12
- package/cjs/workspace/dashboard/src/App.jsx +2297 -372
- package/cjs/workspace/docs/wao-sdk.md +1 -1
- package/cjs/workspace/package.json +1 -1
- package/esm/ao.js +3 -3
- package/esm/create.js +3 -0
- package/esm/workspace/.claude/agents/builder.md +4 -2
- package/esm/workspace/.claude/skills/build/SKILL.md +34 -12
- package/esm/workspace/.claude/skills/build-aos/SKILL.md +10 -1
- package/esm/workspace/.claude/skills/build-device/SKILL.md +37 -23
- package/esm/workspace/.claude/skills/build-frontend/SKILL.md +17 -14
- package/esm/workspace/.claude/skills/build-module/SKILL.md +10 -1
- package/esm/workspace/.claude/skills/plan/SKILL.md +12 -4
- package/esm/workspace/.claude/skills/readme/SKILL.md +151 -45
- package/esm/workspace/.claude/skills/report/SKILL.md +2 -1
- package/esm/workspace/dashboard/index.html +154 -3
- package/esm/workspace/dashboard/public/favicon.ico +0 -0
- package/esm/workspace/dashboard/public/favicon.png +0 -0
- package/esm/workspace/dashboard/server.js +93 -12
- package/esm/workspace/dashboard/src/App.jsx +2297 -372
- package/esm/workspace/docs/wao-sdk.md +1 -1
- package/esm/workspace/package.json +1 -1
- package/package.json +1 -1
|
@@ -1,425 +1,2350 @@
|
|
|
1
1
|
import { useState, useEffect } from "react"
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════
|
|
4
|
+
// Demo data
|
|
5
|
+
// ═══════════════════════════════════════════════════════════════
|
|
6
|
+
|
|
7
|
+
const DEMO_DATA = {
|
|
8
|
+
feature: "Token Transfer App",
|
|
9
|
+
tasks: [
|
|
10
|
+
{ id: 1, name: "Plan feature", type: "plan", status: "done", skill: "/plan", done_when: "plan.md and tasks.json created", files: ["plan.md", "tasks.json"], started_at: "2026-02-15T10:00:00Z", completed_at: "2026-02-15T10:02:14Z" },
|
|
11
|
+
{ id: 2, name: "Enhance AOS token handlers", type: "aos", status: "done", skill: "/build-aos", done_when: "Mint, transfer, balance handlers pass all tests", files: ["src/token.lua", "src/registry.lua"], started_at: "2026-02-15T10:02:14Z", completed_at: "2026-02-15T10:08:47Z" },
|
|
12
|
+
{ id: 3, name: "Write in-memory AOS token tests", type: "aos-test", status: "done", skill: "/build-aos", done_when: "All token unit tests pass", files: ["test/token.test.js", "test/registry.test.js"], started_at: "2026-02-15T10:08:47Z", completed_at: "2026-02-15T10:14:22Z" },
|
|
13
|
+
{ id: 4, name: "Write AOS HyperBEAM integration tests for token", type: "aos-integration", status: "done", skill: "/test-hb", done_when: "Token operations work on HyperBEAM", files: ["test/hyperbeam-token.test.js"], started_at: "2026-02-15T10:14:22Z", completed_at: "2026-02-15T10:19:05Z" },
|
|
14
|
+
{ id: 5, name: "Build HyperBEAM token device + eunit tests", type: "device", status: "done", skill: "/build-device", done_when: "Device compiles and eunit passes", files: ["HyperBEAM/src/dev_token.erl"], started_at: "2026-02-15T10:19:05Z", completed_at: "2026-02-15T10:28:33Z" },
|
|
15
|
+
{ id: 6, name: "Write device JS integration tests", type: "device-integration", status: "done", skill: "/test-device", done_when: "JS SDK can call device via HTTP", files: ["test/token-device.test.js"], started_at: "2026-02-15T10:28:33Z", completed_at: "2026-02-15T10:33:41Z" },
|
|
16
|
+
{ id: 7, name: "Build frontend components", type: "frontend", status: "done", skill: "/build-frontend", done_when: "All components render correctly", files: ["frontend/src/App.jsx", "frontend/src/components/TransferForm.jsx"], started_at: "2026-02-15T10:33:41Z", completed_at: "2026-02-15T10:41:09Z" },
|
|
17
|
+
{ id: 8, name: "Write frontend unit tests — debugging 1 failure", type: "frontend-test", status: "in_progress", skill: "/build-frontend", done_when: "Vitest passes 100%", files: ["frontend/src/__tests__/App.test.jsx", "frontend/src/__tests__/TransferForm.test.jsx"], started_at: "2026-02-15T10:41:09Z" },
|
|
18
|
+
{ id: 9, name: "Write frontend E2E integration tests", type: "frontend-integration", status: "pending", skill: "/test-e2e", done_when: "Playwright E2E passes with live HyperBEAM", files: ["frontend/e2e/token-transfer.spec.js"] },
|
|
19
|
+
{ id: 10, name: "Generate README", type: "readme", status: "pending", skill: "/readme", done_when: "README.md covers setup, usage, and API" },
|
|
20
|
+
{ id: 11, name: "Final validation", type: "validate", status: "pending", skill: "/validate", done_when: "All gates pass, no Lua pitfalls" },
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEMO_FILES = [
|
|
25
|
+
{ path: "package.json", size: 687 }, { path: "plan.md", size: 2948 }, { path: "README.md", size: 1843 }, { path: "tasks.json", size: 6541 },
|
|
26
|
+
{ path: "frontend/e2e/debug.spec.js", size: 0 }, { path: "frontend/e2e/token-transfer.spec.js", size: 9216 },
|
|
27
|
+
{ path: "frontend/src/App.css", size: 3072 }, { path: "frontend/src/App.jsx", size: 2048 }, { path: "frontend/src/index.css", size: 417 },
|
|
28
|
+
{ path: "frontend/src/main.jsx", size: 129 }, { path: "frontend/src/test-setup.js", size: 0 },
|
|
29
|
+
{ path: "frontend/src/__tests__/App.test.jsx", size: 1621 }, { path: "frontend/src/__tests__/TransferForm.test.jsx", size: 4136 },
|
|
30
|
+
{ path: "frontend/src/components/BalanceDisplay.jsx", size: 640 }, { path: "frontend/src/components/TokenInfo.jsx", size: 730 },
|
|
31
|
+
{ path: "frontend/src/components/TransactionStatus.jsx", size: 159 }, { path: "frontend/src/components/TransferForm.jsx", size: 0 },
|
|
32
|
+
{ path: "frontend/src/components/WalletConnect.jsx", size: 610 },
|
|
33
|
+
{ path: "frontend/src/hooks/useToken.js", size: 3148 }, { path: "frontend/src/hooks/useWallet.js", size: 1440 },
|
|
34
|
+
{ path: "HyperBEAM/src/dev_token.erl", size: 4800 },
|
|
35
|
+
{ path: "scripts/deploy.js", size: 783 },
|
|
36
|
+
{ path: "src/counter.lua", size: 318 }, { path: "src/registry.lua", size: 1900 }, { path: "src/token.lua", size: 2948 },
|
|
37
|
+
{ path: "test/aos.test.js", size: 2700 }, { path: "test/hyperbeam-token.test.js", size: 2700 }, { path: "test/hyperbeam.test.js", size: 3534 },
|
|
38
|
+
{ path: "test/registry.test.js", size: 4200 }, { path: "test/token-device.test.js", size: 1228 }, { path: "test/token.test.js", size: 1900 },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const DEMO_PLAN = `# Token Transfer App
|
|
42
|
+
|
|
43
|
+
## Overview
|
|
44
|
+
A decentralized token transfer application built on AOS and HyperBEAM.
|
|
45
|
+
|
|
46
|
+
## AOS Scripts
|
|
47
|
+
- \`src/token.lua\` — Token handler with mint, transfer, balance
|
|
48
|
+
- \`src/registry.lua\` — Token registry for discoverability
|
|
49
|
+
|
|
50
|
+
## Frontend
|
|
51
|
+
- React SPA with Vite
|
|
52
|
+
- WalletConnect component for ArConnect
|
|
53
|
+
- TransferForm with validation
|
|
54
|
+
- BalanceDisplay with real-time updates
|
|
55
|
+
|
|
56
|
+
## Test Plan
|
|
57
|
+
- Unit tests: in-memory AOS token operations
|
|
58
|
+
- Integration: HyperBEAM token device
|
|
59
|
+
- E2E: Full browser flow with Playwright
|
|
60
|
+
`
|
|
61
|
+
|
|
62
|
+
const DEMO_CONTENT = {
|
|
63
|
+
"package.json": `{
|
|
64
|
+
"name": "token-transfer-app",
|
|
65
|
+
"version": "0.0.1",
|
|
66
|
+
"type": "module",
|
|
67
|
+
"scripts": {
|
|
68
|
+
"test": "node --experimental-wasm-memory64 --test --test-concurrency=1",
|
|
69
|
+
"deploy": "node scripts/deploy.js",
|
|
70
|
+
"start": "trap 'kill $(jobs -p)' EXIT; node dashboard/server.js & cd dashboard && npx vite",
|
|
71
|
+
"keygen": "node scripts/keygen.js"
|
|
72
|
+
},
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"hbsig": "0.3.0",
|
|
75
|
+
"wao": "0.40.0"
|
|
76
|
+
}
|
|
77
|
+
}`,
|
|
78
|
+
"plan.md": DEMO_PLAN,
|
|
79
|
+
"README.md": `# Token Transfer App
|
|
80
|
+
|
|
81
|
+
A decentralized token transfer application built on AOS and HyperBEAM.
|
|
82
|
+
|
|
83
|
+
## Quick Start
|
|
84
|
+
|
|
85
|
+
\`\`\`bash
|
|
86
|
+
yarn install
|
|
87
|
+
yarn keygen # generate wallet
|
|
88
|
+
yarn test # run all tests
|
|
89
|
+
yarn deploy src/token.lua # deploy to testnet
|
|
90
|
+
\`\`\`
|
|
91
|
+
|
|
92
|
+
## Architecture
|
|
93
|
+
|
|
94
|
+
- **src/token.lua** — Token handler (mint, transfer, balance)
|
|
95
|
+
- **src/registry.lua** — Token registry for discoverability
|
|
96
|
+
- **src/counter.lua** — Simple counter example
|
|
97
|
+
- **frontend/** — React SPA with ArConnect wallet integration
|
|
98
|
+
- **test/** — Unit and integration tests
|
|
99
|
+
|
|
100
|
+
## Testing
|
|
101
|
+
|
|
102
|
+
\`\`\`bash
|
|
103
|
+
yarn test # all unit tests
|
|
104
|
+
yarn test test/aos.test.js # AOS integration
|
|
105
|
+
yarn test test/hyperbeam.test.js # HyperBEAM integration
|
|
106
|
+
\`\`\`
|
|
107
|
+
|
|
108
|
+
## Deployment
|
|
109
|
+
|
|
110
|
+
\`\`\`bash
|
|
111
|
+
yarn deploy src/token.lua # AO testnet
|
|
112
|
+
yarn deploy --local-hb src/token.lua # local HyperBEAM
|
|
113
|
+
yarn deploy --mainnet src/token.lua # production
|
|
114
|
+
\`\`\`
|
|
115
|
+
`,
|
|
116
|
+
"tasks.json": JSON.stringify(DEMO_DATA, null, 2),
|
|
117
|
+
"frontend/e2e/debug.spec.js": "",
|
|
118
|
+
"frontend/e2e/token-transfer.spec.js": `import { test, expect } from "@playwright/test"
|
|
119
|
+
|
|
120
|
+
test.describe("Token Transfer E2E", () => {
|
|
121
|
+
test.beforeEach(async ({ page }) => {
|
|
122
|
+
await page.goto("http://localhost:5173")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("should display app title", async ({ page }) => {
|
|
126
|
+
await expect(page.locator("h1")).toHaveText("Token Transfer")
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test("should show connect wallet prompt", async ({ page }) => {
|
|
130
|
+
await expect(page.locator("text=Connecting to ArConnect")).toBeVisible()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test("should display balance after connection", async ({ page }) => {
|
|
134
|
+
// Mock ArConnect wallet
|
|
135
|
+
await page.evaluate(() => {
|
|
136
|
+
window.arweaveWallet = {
|
|
137
|
+
connect: async () => {},
|
|
138
|
+
getActiveAddress: async () => "test-addr-123",
|
|
139
|
+
sign: async (tx) => tx,
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
await page.reload()
|
|
143
|
+
await expect(page.locator("[data-testid=balance]")).toBeVisible({ timeout: 10000 })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("should submit transfer form", async ({ page }) => {
|
|
147
|
+
await page.evaluate(() => {
|
|
148
|
+
window.arweaveWallet = {
|
|
149
|
+
connect: async () => {},
|
|
150
|
+
getActiveAddress: async () => "test-addr-123",
|
|
151
|
+
sign: async (tx) => tx,
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
await page.reload()
|
|
155
|
+
await page.fill("[data-testid=recipient]", "recipient-addr-456")
|
|
156
|
+
await page.fill("[data-testid=amount]", "100")
|
|
157
|
+
await page.click("[data-testid=transfer-btn]")
|
|
158
|
+
await expect(page.locator("[data-testid=status]")).toContainText("Transfer")
|
|
159
|
+
})
|
|
160
|
+
})`,
|
|
161
|
+
"frontend/src/App.css": `* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
162
|
+
|
|
163
|
+
.app {
|
|
164
|
+
max-width: 640px;
|
|
165
|
+
margin: 0 auto;
|
|
166
|
+
padding: 2rem;
|
|
167
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
h1 { font-size: 1.5rem; margin-bottom: 1.5rem; }
|
|
171
|
+
|
|
172
|
+
.card {
|
|
173
|
+
border: 1px solid #e1e4e8;
|
|
174
|
+
border-radius: 8px;
|
|
175
|
+
padding: 1rem;
|
|
176
|
+
margin-bottom: 1rem;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.balance { font-size: 2rem; font-weight: 600; }
|
|
180
|
+
.balance-label { color: #586069; font-size: 0.875rem; }
|
|
181
|
+
|
|
182
|
+
.form-group { margin-bottom: 1rem; }
|
|
183
|
+
.form-group label { display: block; margin-bottom: 0.25rem; font-size: 0.875rem; color: #586069; }
|
|
184
|
+
.form-group input { width: 100%; padding: 0.5rem; border: 1px solid #e1e4e8; border-radius: 6px; font-size: 1rem; }
|
|
185
|
+
|
|
186
|
+
.btn { padding: 0.5rem 1rem; border-radius: 6px; border: none; cursor: pointer; font-size: 1rem; font-weight: 500; }
|
|
187
|
+
.btn-primary { background: #2ea44f; color: white; }
|
|
188
|
+
.btn-primary:hover { background: #2c974b; }
|
|
189
|
+
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
190
|
+
|
|
191
|
+
.status { padding: 0.75rem; border-radius: 6px; margin-top: 1rem; font-size: 0.875rem; }
|
|
192
|
+
.status-success { background: #dcffe4; color: #22863a; }
|
|
193
|
+
.status-error { background: #ffeef0; color: #cb2431; }`,
|
|
194
|
+
"frontend/src/App.jsx": `import { useState, useEffect } from "react"
|
|
195
|
+
import { AO } from "wao/web"
|
|
196
|
+
import TransferForm from "./components/TransferForm"
|
|
197
|
+
import BalanceDisplay from "./components/BalanceDisplay"
|
|
198
|
+
|
|
199
|
+
export default function App() {
|
|
200
|
+
const [ao, setAo] = useState(null)
|
|
201
|
+
const [connected, setConnected] = useState(false)
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
const init = async () => {
|
|
205
|
+
const ao = new AO()
|
|
206
|
+
await ao.init()
|
|
207
|
+
setAo(ao)
|
|
208
|
+
setConnected(true)
|
|
209
|
+
}
|
|
210
|
+
init().catch(console.error)
|
|
211
|
+
}, [])
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div className="app">
|
|
215
|
+
<h1>Token Transfer</h1>
|
|
216
|
+
{connected ? (
|
|
217
|
+
<>
|
|
218
|
+
<BalanceDisplay ao={ao} />
|
|
219
|
+
<TransferForm ao={ao} />
|
|
220
|
+
</>
|
|
221
|
+
) : (
|
|
222
|
+
<p>Connecting to ArConnect...</p>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
)
|
|
226
|
+
}`,
|
|
227
|
+
"frontend/src/index.css": `body {
|
|
228
|
+
margin: 0;
|
|
229
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
|
230
|
+
Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
|
231
|
+
-webkit-font-smoothing: antialiased;
|
|
232
|
+
-moz-osx-font-smoothing: grayscale;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
code {
|
|
236
|
+
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
|
237
|
+
}`,
|
|
238
|
+
"frontend/src/main.jsx": `import React from "react"
|
|
239
|
+
import ReactDOM from "react-dom/client"
|
|
240
|
+
import App from "./App"
|
|
241
|
+
import "./index.css"
|
|
242
|
+
|
|
243
|
+
ReactDOM.createRoot(document.getElementById("root")).render(
|
|
244
|
+
<React.StrictMode>
|
|
245
|
+
<App />
|
|
246
|
+
</React.StrictMode>
|
|
247
|
+
)`,
|
|
248
|
+
"frontend/src/test-setup.js": "",
|
|
249
|
+
"frontend/src/__tests__/App.test.jsx": `import { describe, it, expect } from "vitest"
|
|
250
|
+
import { render, screen, waitFor } from "@testing-library/react"
|
|
251
|
+
import App from "../App"
|
|
252
|
+
|
|
253
|
+
describe("App", () => {
|
|
254
|
+
it("renders title", () => {
|
|
255
|
+
render(<App />)
|
|
256
|
+
expect(screen.getByText("Token Transfer")).toBeInTheDocument()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("shows connecting message initially", () => {
|
|
260
|
+
render(<App />)
|
|
261
|
+
expect(screen.getByText("Connecting to ArConnect...")).toBeInTheDocument()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it("renders balance after connection", async () => {
|
|
265
|
+
window.arweaveWallet = {
|
|
266
|
+
connect: async () => {},
|
|
267
|
+
getActiveAddress: async () => "test-addr",
|
|
268
|
+
}
|
|
269
|
+
render(<App />)
|
|
270
|
+
await waitFor(() => {
|
|
271
|
+
expect(screen.queryByText("Connecting")).not.toBeInTheDocument()
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
})`,
|
|
275
|
+
"frontend/src/__tests__/TransferForm.test.jsx": `import { describe, it, expect, vi } from "vitest"
|
|
276
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
|
277
|
+
import TransferForm from "../components/TransferForm"
|
|
278
|
+
|
|
279
|
+
const mockAo = {
|
|
280
|
+
p: (pid) => ({
|
|
281
|
+
m: vi.fn().mockResolvedValue({ out: "Transfer successful" })
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
describe("TransferForm", () => {
|
|
286
|
+
it("renders form fields", () => {
|
|
287
|
+
render(<TransferForm ao={mockAo} />)
|
|
288
|
+
expect(screen.getByLabelText(/recipient/i)).toBeInTheDocument()
|
|
289
|
+
expect(screen.getByLabelText(/amount/i)).toBeInTheDocument()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it("validates empty fields", async () => {
|
|
293
|
+
render(<TransferForm ao={mockAo} />)
|
|
294
|
+
fireEvent.click(screen.getByRole("button", { name: /transfer/i }))
|
|
295
|
+
await waitFor(() => {
|
|
296
|
+
expect(screen.getByText(/required/i)).toBeInTheDocument()
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it("validates negative amounts", async () => {
|
|
301
|
+
render(<TransferForm ao={mockAo} />)
|
|
302
|
+
fireEvent.change(screen.getByLabelText(/amount/i), { target: { value: "-10" } })
|
|
303
|
+
fireEvent.click(screen.getByRole("button", { name: /transfer/i }))
|
|
304
|
+
await waitFor(() => {
|
|
305
|
+
expect(screen.getByText(/positive/i)).toBeInTheDocument()
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it("submits transfer successfully", async () => {
|
|
310
|
+
render(<TransferForm ao={mockAo} />)
|
|
311
|
+
fireEvent.change(screen.getByLabelText(/recipient/i), { target: { value: "addr-123" } })
|
|
312
|
+
fireEvent.change(screen.getByLabelText(/amount/i), { target: { value: "50" } })
|
|
313
|
+
fireEvent.click(screen.getByRole("button", { name: /transfer/i }))
|
|
314
|
+
await waitFor(() => {
|
|
315
|
+
expect(screen.getByText(/successful/i)).toBeInTheDocument()
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
})`,
|
|
319
|
+
"frontend/src/components/BalanceDisplay.jsx": `import { useState, useEffect } from "react"
|
|
320
|
+
|
|
321
|
+
export default function BalanceDisplay({ ao }) {
|
|
322
|
+
const [balance, setBalance] = useState(null)
|
|
323
|
+
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
if (!ao) return
|
|
326
|
+
const fetchBalance = async () => {
|
|
327
|
+
try {
|
|
328
|
+
const { out } = await ao.p(import.meta.env.VITE_PROCESS_ID).m("Balance")
|
|
329
|
+
setBalance(out)
|
|
330
|
+
} catch (err) {
|
|
331
|
+
console.error("Failed to fetch balance:", err)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
fetchBalance()
|
|
335
|
+
const id = setInterval(fetchBalance, 10000)
|
|
336
|
+
return () => clearInterval(id)
|
|
337
|
+
}, [ao])
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<div className="card" data-testid="balance">
|
|
341
|
+
<div className="balance-label">Your Balance</div>
|
|
342
|
+
<div className="balance">{balance ?? "..."} TKN</div>
|
|
343
|
+
</div>
|
|
344
|
+
)
|
|
345
|
+
}`,
|
|
346
|
+
"frontend/src/components/TokenInfo.jsx": `import { useState, useEffect } from "react"
|
|
347
|
+
|
|
348
|
+
export default function TokenInfo({ ao }) {
|
|
349
|
+
const [info, setInfo] = useState(null)
|
|
350
|
+
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (!ao) return
|
|
353
|
+
const fetch = async () => {
|
|
354
|
+
const { out } = await ao.p(import.meta.env.VITE_PROCESS_ID).m("Info")
|
|
355
|
+
setInfo(JSON.parse(out))
|
|
356
|
+
}
|
|
357
|
+
fetch().catch(console.error)
|
|
358
|
+
}, [ao])
|
|
359
|
+
|
|
360
|
+
if (!info) return <div className="card">Loading token info...</div>
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<div className="card">
|
|
364
|
+
<h3>{info.Name} ({info.Ticker})</h3>
|
|
365
|
+
<p>Denomination: {info.Denomination}</p>
|
|
366
|
+
</div>
|
|
367
|
+
)
|
|
368
|
+
}`,
|
|
369
|
+
"frontend/src/components/TransactionStatus.jsx": `export default function TransactionStatus({ status, error }) {
|
|
370
|
+
if (!status && !error) return null
|
|
371
|
+
return (
|
|
372
|
+
<div className={\`status \${error ? "status-error" : "status-success"}\`} data-testid="status">
|
|
373
|
+
{error || status}
|
|
374
|
+
</div>
|
|
375
|
+
)
|
|
376
|
+
}`,
|
|
377
|
+
"frontend/src/components/TransferForm.jsx": "",
|
|
378
|
+
"frontend/src/components/WalletConnect.jsx": `import { useState } from "react"
|
|
379
|
+
|
|
380
|
+
export default function WalletConnect({ onConnect }) {
|
|
381
|
+
const [connecting, setConnecting] = useState(false)
|
|
382
|
+
|
|
383
|
+
const handleConnect = async () => {
|
|
384
|
+
setConnecting(true)
|
|
385
|
+
try {
|
|
386
|
+
await window.arweaveWallet.connect(["ACCESS_ADDRESS", "SIGN_TRANSACTION"])
|
|
387
|
+
const addr = await window.arweaveWallet.getActiveAddress()
|
|
388
|
+
onConnect(addr)
|
|
389
|
+
} catch (err) {
|
|
390
|
+
console.error("Wallet connection failed:", err)
|
|
391
|
+
} finally {
|
|
392
|
+
setConnecting(false)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return (
|
|
397
|
+
<button className="btn btn-primary" onClick={handleConnect} disabled={connecting}>
|
|
398
|
+
{connecting ? "Connecting..." : "Connect Wallet"}
|
|
399
|
+
</button>
|
|
400
|
+
)
|
|
401
|
+
}`,
|
|
402
|
+
"frontend/src/hooks/useToken.js": `import { useState, useEffect, useCallback } from "react"
|
|
403
|
+
|
|
404
|
+
export function useToken(ao, processId) {
|
|
405
|
+
const [balance, setBalance] = useState(null)
|
|
406
|
+
const [info, setInfo] = useState(null)
|
|
407
|
+
const [loading, setLoading] = useState(false)
|
|
408
|
+
|
|
409
|
+
const fetchBalance = useCallback(async () => {
|
|
410
|
+
if (!ao || !processId) return
|
|
411
|
+
try {
|
|
412
|
+
const { out } = await ao.p(processId).m("Balance")
|
|
413
|
+
setBalance(out)
|
|
414
|
+
} catch (err) {
|
|
415
|
+
console.error("Balance fetch failed:", err)
|
|
416
|
+
}
|
|
417
|
+
}, [ao, processId])
|
|
418
|
+
|
|
419
|
+
const fetchInfo = useCallback(async () => {
|
|
420
|
+
if (!ao || !processId) return
|
|
421
|
+
try {
|
|
422
|
+
const { out } = await ao.p(processId).m("Info")
|
|
423
|
+
setInfo(JSON.parse(out))
|
|
424
|
+
} catch (err) {
|
|
425
|
+
console.error("Info fetch failed:", err)
|
|
426
|
+
}
|
|
427
|
+
}, [ao, processId])
|
|
428
|
+
|
|
429
|
+
const transfer = useCallback(async (recipient, quantity) => {
|
|
430
|
+
if (!ao || !processId) throw new Error("Not connected")
|
|
431
|
+
setLoading(true)
|
|
432
|
+
try {
|
|
433
|
+
const { out } = await ao.p(processId).m("Transfer", {
|
|
434
|
+
Recipient: recipient,
|
|
435
|
+
Quantity: String(quantity),
|
|
436
|
+
})
|
|
437
|
+
await fetchBalance()
|
|
438
|
+
return out
|
|
439
|
+
} finally {
|
|
440
|
+
setLoading(false)
|
|
441
|
+
}
|
|
442
|
+
}, [ao, processId, fetchBalance])
|
|
443
|
+
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
fetchBalance()
|
|
446
|
+
fetchInfo()
|
|
447
|
+
}, [fetchBalance, fetchInfo])
|
|
448
|
+
|
|
449
|
+
return { balance, info, loading, transfer, refresh: fetchBalance }
|
|
450
|
+
}`,
|
|
451
|
+
"frontend/src/hooks/useWallet.js": `import { useState, useEffect } from "react"
|
|
452
|
+
|
|
453
|
+
export function useWallet() {
|
|
454
|
+
const [address, setAddress] = useState(null)
|
|
455
|
+
const [connected, setConnected] = useState(false)
|
|
456
|
+
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
const checkConnection = async () => {
|
|
459
|
+
try {
|
|
460
|
+
if (window.arweaveWallet) {
|
|
461
|
+
const addr = await window.arweaveWallet.getActiveAddress()
|
|
462
|
+
setAddress(addr)
|
|
463
|
+
setConnected(true)
|
|
464
|
+
}
|
|
465
|
+
} catch {}
|
|
466
|
+
}
|
|
467
|
+
checkConnection()
|
|
468
|
+
window.addEventListener("arweaveWalletLoaded", checkConnection)
|
|
469
|
+
return () => window.removeEventListener("arweaveWalletLoaded", checkConnection)
|
|
470
|
+
}, [])
|
|
471
|
+
|
|
472
|
+
const connect = async () => {
|
|
473
|
+
await window.arweaveWallet.connect(["ACCESS_ADDRESS", "SIGN_TRANSACTION"])
|
|
474
|
+
const addr = await window.arweaveWallet.getActiveAddress()
|
|
475
|
+
setAddress(addr)
|
|
476
|
+
setConnected(true)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const disconnect = () => {
|
|
480
|
+
setAddress(null)
|
|
481
|
+
setConnected(false)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return { address, connected, connect, disconnect }
|
|
485
|
+
}`,
|
|
486
|
+
"HyperBEAM/src/dev_token.erl": `%%% @doc Token device for HyperBEAM.
|
|
487
|
+
%%% Manages token balances with mint, transfer, and balance queries.
|
|
488
|
+
%%% Tests are inline (HyperBEAM convention — device + eunit in same file).
|
|
489
|
+
-module(dev_token).
|
|
490
|
+
-export([info/0, init/3, execute/3]).
|
|
491
|
+
-include("include/hb.hrl").
|
|
492
|
+
-ifdef(TEST).
|
|
493
|
+
-include_lib("eunit/include/eunit.hrl").
|
|
494
|
+
-endif.
|
|
495
|
+
|
|
496
|
+
info() ->
|
|
497
|
+
#{
|
|
498
|
+
name => <<"Token">>,
|
|
499
|
+
description => <<"Token transfer device">>,
|
|
500
|
+
version => <<"1.0.0">>
|
|
501
|
+
}.
|
|
502
|
+
|
|
503
|
+
init(_ID, _Params, State) ->
|
|
504
|
+
{ok, State#{balances => #{}}}.
|
|
505
|
+
|
|
506
|
+
execute(<<"Mint">>, Msg, State = #{balances := Bals}) ->
|
|
507
|
+
Sender = hb_message:get(<<"From">>, Msg),
|
|
508
|
+
Qty = binary_to_integer(hb_message:get(<<"Quantity">>, Msg)),
|
|
509
|
+
Current = maps:get(Sender, Bals, 0),
|
|
510
|
+
NewBals = maps:put(Sender, Current + Qty, Bals),
|
|
511
|
+
{ok, #{data => <<"Minted">>}, State#{balances => NewBals}};
|
|
512
|
+
|
|
513
|
+
execute(<<"Transfer">>, Msg, State = #{balances := Bals}) ->
|
|
514
|
+
Sender = hb_message:get(<<"From">>, Msg),
|
|
515
|
+
Recipient = hb_message:get(<<"Recipient">>, Msg),
|
|
516
|
+
Qty = binary_to_integer(hb_message:get(<<"Quantity">>, Msg)),
|
|
517
|
+
SenderBal = maps:get(Sender, Bals, 0),
|
|
518
|
+
case SenderBal >= Qty of
|
|
519
|
+
true ->
|
|
520
|
+
NewBals = maps:put(Sender, SenderBal - Qty,
|
|
521
|
+
maps:put(Recipient, maps:get(Recipient, Bals, 0) + Qty, Bals)),
|
|
522
|
+
{ok, #{data => <<"Transferred">>}, State#{balances => NewBals}};
|
|
523
|
+
false ->
|
|
524
|
+
{error, <<"Insufficient balance">>}
|
|
525
|
+
end;
|
|
526
|
+
|
|
527
|
+
execute(<<"Balance">>, Msg, State = #{balances := Bals}) ->
|
|
528
|
+
Target = hb_message:get(<<"Target">>, Msg, hb_message:get(<<"From">>, Msg)),
|
|
529
|
+
Bal = maps:get(Target, Bals, 0),
|
|
530
|
+
{ok, #{data => integer_to_binary(Bal)}, State};
|
|
531
|
+
|
|
532
|
+
execute(_Action, _Msg, State) ->
|
|
533
|
+
{ok, #{data => <<"Unknown action">>}, State}.
|
|
534
|
+
|
|
535
|
+
%%%===================================================================
|
|
536
|
+
%%% EUnit Tests
|
|
537
|
+
%%%===================================================================
|
|
538
|
+
-ifdef(TEST).
|
|
539
|
+
|
|
540
|
+
mint_test() ->
|
|
541
|
+
{ok, State0} = init(<<"test">>, #{}, #{}),
|
|
542
|
+
Msg = #{<<"From">> => <<"alice">>, <<"Quantity">> => <<"1000">>},
|
|
543
|
+
{ok, #{data := <<"Minted">>}, State1} = execute(<<"Mint">>, Msg, State0),
|
|
544
|
+
BalMsg = #{<<"From">> => <<"alice">>},
|
|
545
|
+
{ok, #{data := <<"1000">>}, _} = execute(<<"Balance">>, BalMsg, State1).
|
|
546
|
+
|
|
547
|
+
transfer_test() ->
|
|
548
|
+
{ok, S0} = init(<<"test">>, #{}, #{}),
|
|
549
|
+
{ok, _, S1} = execute(<<"Mint">>,
|
|
550
|
+
#{<<"From">> => <<"alice">>, <<"Quantity">> => <<"500">>}, S0),
|
|
551
|
+
{ok, #{data := <<"Transferred">>}, S2} = execute(<<"Transfer">>,
|
|
552
|
+
#{<<"From">> => <<"alice">>, <<"Recipient">> => <<"bob">>,
|
|
553
|
+
<<"Quantity">> => <<"200">>}, S1),
|
|
554
|
+
{ok, #{data := <<"300">>}, _} = execute(<<"Balance">>,
|
|
555
|
+
#{<<"From">> => <<"alice">>}, S2),
|
|
556
|
+
{ok, #{data := <<"200">>}, _} = execute(<<"Balance">>,
|
|
557
|
+
#{<<"From">> => <<"bob">>}, S2).
|
|
558
|
+
|
|
559
|
+
insufficient_balance_test() ->
|
|
560
|
+
{ok, S0} = init(<<"test">>, #{}, #{}),
|
|
561
|
+
{ok, _, S1} = execute(<<"Mint">>,
|
|
562
|
+
#{<<"From">> => <<"alice">>, <<"Quantity">> => <<"100">>}, S0),
|
|
563
|
+
{error, <<"Insufficient balance">>} = execute(<<"Transfer">>,
|
|
564
|
+
#{<<"From">> => <<"alice">>, <<"Recipient">> => <<"bob">>,
|
|
565
|
+
<<"Quantity">> => <<"200">>}, S1).
|
|
566
|
+
|
|
567
|
+
-endif.`,
|
|
568
|
+
|
|
569
|
+
"scripts/deploy.js": `import { readFileSync } from "node:fs"
|
|
31
570
|
import { AO } from "wao"
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
571
|
+
|
|
572
|
+
const src = process.argv[2]
|
|
573
|
+
if (!src) { console.error("Usage: yarn deploy <file.lua>"); process.exit(1) }
|
|
574
|
+
|
|
575
|
+
const jwk = JSON.parse(readFileSync(".wallet.json", "utf8"))
|
|
576
|
+
const ao = await new AO().init(jwk)
|
|
577
|
+
const src_data = readFileSync(src, "utf8")
|
|
578
|
+
const { pid } = await ao.deploy({ src_data })
|
|
579
|
+
console.log("Deployed:", pid)`,
|
|
580
|
+
"src/counter.lua": `-- Simple Counter for AOS
|
|
581
|
+
Count = Count or 0
|
|
582
|
+
|
|
583
|
+
Handlers.add("increment",
|
|
584
|
+
Handlers.utils.hasMatchingTag("Action", "Increment"),
|
|
585
|
+
function(msg)
|
|
586
|
+
Count = Count + 1
|
|
587
|
+
msg.reply({ Data = tostring(Count) })
|
|
588
|
+
end
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
Handlers.add("get",
|
|
592
|
+
Handlers.utils.hasMatchingTag("Action", "Get"),
|
|
593
|
+
function(msg)
|
|
594
|
+
msg.reply({ Data = tostring(Count) })
|
|
595
|
+
end
|
|
596
|
+
)`,
|
|
597
|
+
"src/registry.lua": `-- Token Registry for AOS
|
|
598
|
+
local json = require("json")
|
|
599
|
+
|
|
600
|
+
Registry = Registry or {}
|
|
601
|
+
|
|
602
|
+
Handlers.add("register",
|
|
603
|
+
Handlers.utils.hasMatchingTag("Action", "Register"),
|
|
604
|
+
function(msg)
|
|
605
|
+
local id = msg.Tags.ProcessId
|
|
606
|
+
local name = msg.Tags.Name or "Unknown"
|
|
607
|
+
Registry[id] = { name = name, owner = msg.From }
|
|
608
|
+
msg.reply({ Data = "Registered: " .. name })
|
|
609
|
+
end
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
Handlers.add("list",
|
|
613
|
+
Handlers.utils.hasMatchingTag("Action", "List"),
|
|
614
|
+
function(msg)
|
|
615
|
+
msg.reply({ Data = json.encode(Registry) })
|
|
616
|
+
end
|
|
617
|
+
)`,
|
|
618
|
+
"src/token.lua": `-- Token Handler for AOS
|
|
619
|
+
local json = require("json")
|
|
620
|
+
|
|
621
|
+
Balances = Balances or {}
|
|
622
|
+
Name = Name or "Token"
|
|
623
|
+
Ticker = Ticker or "TKN"
|
|
624
|
+
Denomination = Denomination or 12
|
|
625
|
+
|
|
626
|
+
Handlers.add("info",
|
|
627
|
+
Handlers.utils.hasMatchingTag("Action", "Info"),
|
|
628
|
+
function(msg)
|
|
629
|
+
msg.reply({
|
|
630
|
+
Data = json.encode({ Name = Name, Ticker = Ticker, Denomination = Denomination })
|
|
631
|
+
})
|
|
632
|
+
end
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
Handlers.add("balance",
|
|
636
|
+
Handlers.utils.hasMatchingTag("Action", "Balance"),
|
|
637
|
+
function(msg)
|
|
638
|
+
local target = msg.Tags.Target or msg.From
|
|
639
|
+
local bal = Balances[target] or "0"
|
|
640
|
+
msg.reply({ Data = bal, Tags = { Balance = bal, Target = target } })
|
|
641
|
+
end
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
Handlers.add("transfer",
|
|
645
|
+
Handlers.utils.hasMatchingTag("Action", "Transfer"),
|
|
646
|
+
function(msg)
|
|
647
|
+
local qty = tonumber(msg.Tags.Quantity)
|
|
648
|
+
local from = msg.From
|
|
649
|
+
local to = msg.Tags.Recipient
|
|
650
|
+
assert(qty > 0, "Quantity must be positive")
|
|
651
|
+
assert(tonumber(Balances[from] or "0") >= qty, "Insufficient balance")
|
|
652
|
+
Balances[from] = tostring(tonumber(Balances[from]) - qty)
|
|
653
|
+
Balances[to] = tostring(tonumber(Balances[to] or "0") + qty)
|
|
654
|
+
msg.reply({ Data = "Transfer successful" })
|
|
655
|
+
ao.send({ Target = to, Action = "Credit-Notice", Quantity = tostring(qty), Sender = from })
|
|
656
|
+
end
|
|
657
|
+
)`,
|
|
658
|
+
"test/aos.test.js": `import { describe, it } from "node:test"
|
|
659
|
+
import assert from "node:assert"
|
|
660
|
+
import { readFileSync } from "node:fs"
|
|
661
|
+
import { AO, acc } from "wao/test"
|
|
662
|
+
|
|
663
|
+
describe("AOS Integration", () => {
|
|
664
|
+
it("should deploy and interact with counter", async () => {
|
|
665
|
+
const ao = await new AO().init(acc[0])
|
|
666
|
+
const src = readFileSync("src/counter.lua", "utf8")
|
|
667
|
+
const { p } = await ao.deploy({ src_data: src })
|
|
668
|
+
await p.m("Increment")
|
|
669
|
+
await p.m("Increment")
|
|
670
|
+
const { out } = await p.m("Get")
|
|
671
|
+
assert.equal(out, "2")
|
|
672
|
+
})
|
|
673
|
+
})`,
|
|
674
|
+
"test/hyperbeam-token.test.js": `import { describe, it } from "node:test"
|
|
675
|
+
import assert from "node:assert"
|
|
676
|
+
import { readFileSync } from "node:fs"
|
|
677
|
+
import { AO, HyperBEAM, acc } from "wao/test"
|
|
678
|
+
|
|
679
|
+
describe("HyperBEAM Token Integration", () => {
|
|
680
|
+
let hbeam, ao, p
|
|
681
|
+
|
|
682
|
+
it("should start HyperBEAM", async () => {
|
|
683
|
+
hbeam = await new HyperBEAM({ reset: true }).ready()
|
|
684
|
+
ao = await new AO({ hb: hbeam.url }).init(acc[0])
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
it("should deploy token to HyperBEAM", async () => {
|
|
688
|
+
const src = readFileSync("src/token.lua", "utf8")
|
|
689
|
+
const result = await ao.deploy({ src_data: src })
|
|
690
|
+
p = result.p
|
|
691
|
+
assert.ok(p)
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it("should mint tokens via HyperBEAM", async () => {
|
|
695
|
+
await p.m("Mint", { Quantity: "5000" })
|
|
696
|
+
const { out } = await p.m("Balance")
|
|
697
|
+
assert.equal(out, "5000")
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it("should transfer via HyperBEAM", async () => {
|
|
701
|
+
await p.m("Transfer", { Recipient: acc[1].addr, Quantity: "200" })
|
|
702
|
+
const { out } = await p.m("Balance")
|
|
703
|
+
assert.equal(out, "4800")
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
it("should cleanup", () => hbeam?.kill())
|
|
707
|
+
})`,
|
|
708
|
+
"test/hyperbeam.test.js": `import { describe, it } from "node:test"
|
|
709
|
+
import assert from "node:assert"
|
|
710
|
+
import { readFileSync } from "node:fs"
|
|
711
|
+
import { AO, HB, HyperBEAM, acc } from "wao/test"
|
|
712
|
+
|
|
713
|
+
describe("HyperBEAM", () => {
|
|
714
|
+
let hbeam
|
|
715
|
+
|
|
716
|
+
it("should start HyperBEAM node", async () => {
|
|
717
|
+
hbeam = await new HyperBEAM({ reset: true }).ready()
|
|
718
|
+
assert.ok(hbeam.port)
|
|
719
|
+
assert.ok(hbeam.url)
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
it("should respond to info endpoint", async () => {
|
|
723
|
+
const hb = new HB({ url: hbeam.url })
|
|
724
|
+
const info = await hb.info()
|
|
725
|
+
assert.ok(info.address)
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it("should deploy and run AOS process", async () => {
|
|
729
|
+
const ao = await new AO({ hb: hbeam.url }).init(acc[0])
|
|
730
|
+
const src = readFileSync("src/counter.lua", "utf8")
|
|
731
|
+
const { p } = await ao.deploy({ src_data: src })
|
|
732
|
+
await p.m("Increment")
|
|
733
|
+
const { out } = await p.m("Get")
|
|
734
|
+
assert.equal(out, "1")
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
it("should cleanup", () => hbeam?.kill())
|
|
738
|
+
})`,
|
|
739
|
+
"test/registry.test.js": `import { describe, it } from "node:test"
|
|
740
|
+
import assert from "node:assert"
|
|
741
|
+
import { readFileSync } from "node:fs"
|
|
742
|
+
import { AO, acc } from "wao/test"
|
|
743
|
+
|
|
744
|
+
describe("Registry", () => {
|
|
745
|
+
let ao, p
|
|
746
|
+
|
|
747
|
+
it("should deploy registry", async () => {
|
|
748
|
+
ao = await new AO().init(acc[0])
|
|
749
|
+
const src = readFileSync("src/registry.lua", "utf8")
|
|
750
|
+
const result = await ao.deploy({ src_data: src })
|
|
751
|
+
p = result.p
|
|
752
|
+
assert.ok(p)
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
it("should register a token", async () => {
|
|
756
|
+
const { out } = await p.m("Register", {
|
|
757
|
+
ProcessId: "test-process-123",
|
|
758
|
+
Name: "TestToken",
|
|
759
|
+
})
|
|
760
|
+
assert.match(out, /Registered/)
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it("should list registered tokens", async () => {
|
|
764
|
+
const { out } = await p.m("List")
|
|
765
|
+
const registry = JSON.parse(out)
|
|
766
|
+
assert.ok(registry["test-process-123"])
|
|
767
|
+
assert.equal(registry["test-process-123"].name, "TestToken")
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
it("should register multiple tokens", async () => {
|
|
771
|
+
await p.m("Register", { ProcessId: "proc-a", Name: "Alpha" })
|
|
772
|
+
await p.m("Register", { ProcessId: "proc-b", Name: "Beta" })
|
|
773
|
+
const { out } = await p.m("List")
|
|
774
|
+
const registry = JSON.parse(out)
|
|
775
|
+
assert.equal(Object.keys(registry).length, 3)
|
|
776
|
+
})
|
|
777
|
+
})`,
|
|
778
|
+
"test/token-device.test.js": `import { describe, it } from "node:test"
|
|
779
|
+
import assert from "node:assert"
|
|
780
|
+
import { HB, HyperBEAM, acc } from "wao/test"
|
|
781
|
+
|
|
782
|
+
describe("Token Device", () => {
|
|
783
|
+
let hbeam, hb
|
|
784
|
+
|
|
785
|
+
it("should start HyperBEAM with token device", async () => {
|
|
786
|
+
hbeam = await new HyperBEAM({ reset: true }).ready()
|
|
787
|
+
hb = new HB({ url: hbeam.url })
|
|
788
|
+
assert.ok(hb)
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
it("should call token device info", async () => {
|
|
792
|
+
const res = await hb.get({ path: "/~token@1.0/info" })
|
|
793
|
+
assert.ok(res)
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it("should cleanup", () => hbeam?.kill())
|
|
797
|
+
})`,
|
|
798
|
+
"test/token.test.js": `import { describe, it } from "node:test"
|
|
799
|
+
import assert from "node:assert"
|
|
800
|
+
import { readFileSync } from "node:fs"
|
|
801
|
+
import { AO, acc } from "wao/test"
|
|
802
|
+
|
|
803
|
+
describe("Token", () => {
|
|
804
|
+
let ao, p
|
|
805
|
+
|
|
806
|
+
it("should deploy token process", async () => {
|
|
807
|
+
ao = await new AO().init(acc[0])
|
|
808
|
+
const src = readFileSync("src/token.lua", "utf8")
|
|
809
|
+
const result = await ao.deploy({ src_data: src })
|
|
810
|
+
p = result.p
|
|
811
|
+
assert.ok(p)
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
it("should return token info", async () => {
|
|
815
|
+
const { out } = await p.m("Info")
|
|
816
|
+
const info = JSON.parse(out)
|
|
817
|
+
assert.equal(info.Name, "Token")
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
it("should transfer tokens", async () => {
|
|
821
|
+
await p.m("Mint", { Quantity: "1000" })
|
|
822
|
+
await p.m("Transfer", { Recipient: acc[1].addr, Quantity: "100" })
|
|
823
|
+
const { out } = await p.m("Balance")
|
|
824
|
+
assert.equal(out, "900")
|
|
825
|
+
})
|
|
826
|
+
})`,
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ═══════════════════════════════════════════════════════════════
|
|
830
|
+
// Constants
|
|
831
|
+
// ═══════════════════════════════════════════════════════════════
|
|
832
|
+
|
|
833
|
+
const TYPE_COLORS = {
|
|
834
|
+
plan: "#8b5cf6", aos: "#3b82f6", "aos-test": "#14b8a6", "aos-integration": "#06b6d4",
|
|
835
|
+
device: "#22c55e", "device-integration": "#059669",
|
|
836
|
+
frontend: "#f97316", "frontend-test": "#ea580c", "frontend-integration": "#eab308",
|
|
837
|
+
"module-lua": "#a855f7", "module-wasm": "#9333ea", "module-test": "#7c3aed",
|
|
838
|
+
readme: "#6b7280", validate: "#84cc16",
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const BADGE_COLORS = {
|
|
842
|
+
orchestrator: "#16a34a", plan: "#8b5cf6", validate: "#ca8a04", generate: "#2563eb",
|
|
843
|
+
build: "#ea580c", test: "#16a34a", info: "#6b7280", deploy: "#2563eb",
|
|
844
|
+
scaffold: "#0d9488", debug: "#dc2626", dev: "#ea580c", team: "#7c3aed",
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const FILE_BADGES = {
|
|
848
|
+
json: { label: "JSON", color: "#3b82f6" }, md: { label: "MD", color: "#14b8a6" },
|
|
849
|
+
css: { label: "CSS", color: "#8b5cf6" }, lua: { label: "Lua", color: "#3b82f6" },
|
|
850
|
+
js: { label: "JS", color: "#eab308" }, jsx: { label: "JSX", color: "#eab308" },
|
|
851
|
+
ts: { label: "TS", color: "#3b82f6" }, tsx: { label: "TSX", color: "#3b82f6" },
|
|
852
|
+
erl: { label: "Erl", color: "#ef4444" }, rs: { label: "Rust", color: "#f97316" },
|
|
853
|
+
toml: { label: "TOML", color: "#6b7280" }, html: { label: "HTML", color: "#f97316" },
|
|
854
|
+
sh: { label: "Shell", color: "#22c55e" },
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const EXT_TO_LANG = {
|
|
858
|
+
js: "javascript", jsx: "javascript", ts: "typescript", tsx: "typescript",
|
|
859
|
+
lua: "lua", json: "json", md: "markdown", css: "css", html: "xml",
|
|
860
|
+
erl: "erlang", rs: "rust", sh: "bash", toml: "ini",
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const SKILLS = {
|
|
864
|
+
"Build Workflow": [
|
|
865
|
+
{ cmd: "/build", badge: "orchestrator", desc: "Full build workflow \u2014 plan, build, test, validate, README. Orchestrates all steps." },
|
|
866
|
+
{ cmd: "/plan", badge: "plan", desc: "Plan a feature \u2014 writes plan.md + tasks.json for persistent workflow." },
|
|
867
|
+
{ cmd: "/validate", badge: "validate", desc: "Post-build validation \u2014 tests, Lua pitfalls, handler coverage." },
|
|
868
|
+
{ cmd: "/readme", badge: "generate", desc: "Generate comprehensive README.md from plan, code, and tests." },
|
|
869
|
+
],
|
|
870
|
+
"Build Steps": [
|
|
871
|
+
{ cmd: "/build-aos", badge: "build", desc: "Build AOS Lua scripts + in-memory tests, iterate until 100% pass." },
|
|
872
|
+
{ cmd: "/build-module", badge: "build", desc: "Build custom WASM64 (Rust) or standalone Lua modules + HyperBEAM integration tests." },
|
|
873
|
+
{ cmd: "/build-device", badge: "build", desc: "Build Erlang device + inline eunit tests, iterate until 100% pass." },
|
|
874
|
+
{ cmd: "/build-frontend", badge: "build", desc: "Build Vite + React components + vitest tests, iterate until 100% pass." },
|
|
875
|
+
],
|
|
876
|
+
"Test Steps": [
|
|
877
|
+
{ cmd: "/test", badge: "test", desc: "Run in-memory AOS unit tests." },
|
|
878
|
+
{ cmd: "/test-hb", badge: "test", desc: "Run HyperBEAM integration tests with real Erlang node." },
|
|
879
|
+
{ cmd: "/test-device", badge: "test", desc: "WAO SDK integration tests for Erlang devices via HTTP." },
|
|
880
|
+
{ cmd: "/test-e2e", badge: "test", desc: "Playwright E2E tests with live HyperBEAM backend." },
|
|
881
|
+
],
|
|
882
|
+
Utilities: [
|
|
883
|
+
{ cmd: "/report", badge: "info", desc: "Show progress on current plan \u2014 task status, test results." },
|
|
884
|
+
{ cmd: "/deploy", badge: "deploy", desc: "Deploy Lua source to testnet, local HB, or remote HB." },
|
|
885
|
+
{ cmd: "/create-aos", badge: "scaffold", desc: "Scaffold new AOS Lua script + test file." },
|
|
886
|
+
{ cmd: "/create-module", badge: "scaffold", desc: "Scaffold custom module (WASM64 Rust or standalone Lua) + test." },
|
|
887
|
+
{ cmd: "/create-device", badge: "scaffold", desc: "Scaffold new HyperBEAM Erlang device + test." },
|
|
888
|
+
{ cmd: "/debug", badge: "debug", desc: "Troubleshoot issues \u2014 port conflicts, WASM errors, compilation." },
|
|
889
|
+
{ cmd: "/dev", badge: "dev", desc: "Start Vite dev server for frontend development." },
|
|
890
|
+
{ cmd: "/team", badge: "team", desc: "Set up an agent team for parallel development." },
|
|
891
|
+
],
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function deriveCommands(data) {
|
|
895
|
+
const tasks = data?.tasks || []
|
|
896
|
+
const luaFiles = []
|
|
897
|
+
const testFiles = []
|
|
898
|
+
const hbTestFiles = []
|
|
899
|
+
const moduleTestFiles = []
|
|
900
|
+
const frontendTestFiles = []
|
|
901
|
+
const e2eFiles = []
|
|
902
|
+
const hasDevice = tasks.some(t => t.type === "device")
|
|
903
|
+
for (const t of tasks) {
|
|
904
|
+
for (const f of (t.files || [])) {
|
|
905
|
+
if (f.startsWith("src/") && f.endsWith(".lua")) luaFiles.push(f)
|
|
906
|
+
if (f.startsWith("test/") && f.endsWith(".test.js")) {
|
|
907
|
+
if (t.type === "aos-test") testFiles.push(f)
|
|
908
|
+
else if (t.type === "aos-integration" || t.type === "device-integration") hbTestFiles.push(f)
|
|
909
|
+
else if (t.type === "module-test") moduleTestFiles.push(f)
|
|
910
|
+
}
|
|
911
|
+
if (f.startsWith("frontend/") && f.endsWith(".test.jsx")) frontendTestFiles.push(f)
|
|
912
|
+
if (f.startsWith("frontend/e2e/")) e2eFiles.push(f)
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
const cmds = {}
|
|
916
|
+
const testing = [{ cmd: "yarn test", desc: "Run all unit tests" }]
|
|
917
|
+
for (const f of testFiles) testing.push({ cmd: `yarn test ${f}`, desc: `AOS unit tests \u2014 ${f.split("/").pop()}` })
|
|
918
|
+
for (const f of moduleTestFiles) testing.push({ cmd: `yarn test ${f}`, desc: `Custom module tests \u2014 ${f.split("/").pop()}` })
|
|
919
|
+
for (const f of hbTestFiles) testing.push({ cmd: `yarn test ${f}`, desc: `HyperBEAM integration \u2014 ${f.split("/").pop()}` })
|
|
920
|
+
if (hasDevice) {
|
|
921
|
+
const devFiles = tasks.filter(t => t.type === "device").flatMap(t => (t.files || []).filter(f => f.endsWith(".erl")))
|
|
922
|
+
for (const f of devFiles) {
|
|
923
|
+
const mod = f.split("/").pop().replace(".erl", "")
|
|
924
|
+
testing.push({ cmd: `cd $HB_DIR && rebar3 eunit --module=${mod}`, desc: `Erlang eunit \u2014 ${mod}` })
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
cmds["Testing"] = testing
|
|
928
|
+
const deploy = []
|
|
929
|
+
const luaArg = luaFiles.length === 1 ? ` ${luaFiles[0]}` : ""
|
|
930
|
+
deploy.push({ cmd: `yarn deploy${luaArg}`, desc: `Deploy ${luaFiles.length > 1 ? "all src/*.lua" : luaArg.trim() || "scripts"} to AO testnet` })
|
|
931
|
+
deploy.push({ cmd: `yarn deploy --local-hb${luaArg}`, desc: "Deploy to local HyperBEAM (genesis-wasm)" })
|
|
932
|
+
deploy.push({ cmd: `yarn deploy --mainnet${luaArg}`, desc: "Deploy to remote HyperBEAM" })
|
|
933
|
+
cmds["Deployment"] = deploy
|
|
934
|
+
cmds["Development"] = [
|
|
935
|
+
{ cmd: "yarn start", desc: "Start dashboard (API :3333 + Vite :5174)" },
|
|
936
|
+
{ cmd: "yarn start:api", desc: "Start API server only (:3333)" },
|
|
937
|
+
{ cmd: "yarn keygen", desc: "Generate Arweave wallet (.wallet.json)" },
|
|
938
|
+
]
|
|
939
|
+
const hasFrontend = tasks.some(t => t.type.startsWith("frontend"))
|
|
940
|
+
if (hasFrontend) {
|
|
941
|
+
const fe = [
|
|
942
|
+
{ cmd: "cd frontend && npm run dev", desc: "Start Vite dev server (port 5173)" },
|
|
943
|
+
]
|
|
944
|
+
for (const f of frontendTestFiles) fe.push({ cmd: `cd frontend && npx vitest run ${f.replace("frontend/", "")}`, desc: `Vitest \u2014 ${f.split("/").pop()}` })
|
|
945
|
+
if (frontendTestFiles.length === 0) fe.push({ cmd: "cd frontend && npm run test:unit", desc: "Run vitest component tests" })
|
|
946
|
+
for (const f of e2eFiles) fe.push({ cmd: `cd frontend && npx playwright test ${f.replace("frontend/", "")}`, desc: `E2E \u2014 ${f.split("/").pop()}` })
|
|
947
|
+
if (e2eFiles.length === 0) fe.push({ cmd: "cd frontend && npm run test:e2e", desc: "Run Playwright E2E tests" })
|
|
948
|
+
fe.push({ cmd: "cd frontend && npm run build", desc: "Production build" })
|
|
949
|
+
cmds["Frontend"] = fe
|
|
950
|
+
}
|
|
951
|
+
return cmds
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const ENV_VARS = [
|
|
955
|
+
{ name: "PORT", desc: "HyperBEAM HTTP port", def: "10001" },
|
|
956
|
+
{ name: "MESSENGER_URL", desc: "Messenger unit URL", def: "(AO default)" },
|
|
957
|
+
{ name: "CU_URL", desc: "Compute unit URL", def: "(AO default)" },
|
|
958
|
+
{ name: "HB_URL", desc: "HyperBEAM URL", def: "http://localhost:10001" },
|
|
959
|
+
{ name: "WALLET_PATH", desc: "Arweave JWK path", def: ".wallet.json" },
|
|
960
|
+
]
|
|
961
|
+
|
|
962
|
+
// ═══════════════════════════════════════════════════════════════
|
|
963
|
+
// Helpers
|
|
964
|
+
// ═══════════════════════════════════════════════════════════════
|
|
965
|
+
|
|
966
|
+
function getTrackForType(type) {
|
|
967
|
+
if (/^aos/.test(type)) return "AOS"
|
|
968
|
+
if (/^device/.test(type)) return "Device"
|
|
969
|
+
if (/^frontend/.test(type)) return "Frontend"
|
|
970
|
+
if (/^module/.test(type)) return "Modules"
|
|
971
|
+
if (/^(readme|validate)$/.test(type)) return "Validate"
|
|
972
|
+
return null
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function deriveTrackCards(tasks) {
|
|
976
|
+
const tracks = {}
|
|
977
|
+
for (const t of tasks) {
|
|
978
|
+
const track = getTrackForType(t.type)
|
|
979
|
+
if (!track) continue
|
|
980
|
+
if (!tracks[track]) tracks[track] = { total: 0, done: 0, inProgress: 0 }
|
|
981
|
+
tracks[track].total++
|
|
982
|
+
if (t.status === "done") tracks[track].done++
|
|
983
|
+
if (t.status === "in_progress") tracks[track].inProgress++
|
|
984
|
+
}
|
|
985
|
+
const order = ["AOS", "Modules", "Device", "Frontend", "Validate"]
|
|
986
|
+
return order.filter(n => tracks[n]).map(name => {
|
|
987
|
+
const t = tracks[name]
|
|
988
|
+
const status = t.done === t.total ? "Done" : t.inProgress > 0 ? "In Progress" : "Pending"
|
|
989
|
+
return { name, status }
|
|
990
|
+
})
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function formatDuration(ms) {
|
|
994
|
+
if (ms < 0) return "0s"
|
|
995
|
+
const s = Math.floor(ms / 1000) % 60
|
|
996
|
+
const m = Math.floor(ms / 60000) % 60
|
|
997
|
+
const h = Math.floor(ms / 3600000)
|
|
998
|
+
if (h > 0) return `${h}h ${m}m`
|
|
999
|
+
if (m > 0) return `${m}m ${s}s`
|
|
1000
|
+
return `${s}s`
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function useElapsed(startedAt) {
|
|
1004
|
+
const [now, setNow] = useState(Date.now())
|
|
1005
|
+
useEffect(() => {
|
|
1006
|
+
if (!startedAt) return
|
|
1007
|
+
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
1008
|
+
return () => clearInterval(id)
|
|
1009
|
+
}, [startedAt])
|
|
1010
|
+
if (!startedAt) return null
|
|
1011
|
+
return formatDuration(now - new Date(startedAt).getTime())
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const formatSize = b => b < 1024 ? b + " B" : (b / 1024).toFixed(1) + " KB"
|
|
1015
|
+
const getFileExt = p => { const d = p.lastIndexOf("."); return d > -1 ? p.slice(d + 1).toLowerCase() : "" }
|
|
1016
|
+
const escapeHtml = t => t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
1017
|
+
|
|
1018
|
+
function groupFilesByDir(files) {
|
|
1019
|
+
const groups = {}
|
|
1020
|
+
for (const f of files) {
|
|
1021
|
+
const slash = f.path.lastIndexOf("/")
|
|
1022
|
+
const dir = slash > -1 ? f.path.slice(0, slash) + "/" : "{root}/"
|
|
1023
|
+
if (!groups[dir]) groups[dir] = []
|
|
1024
|
+
groups[dir].push(f)
|
|
1025
|
+
}
|
|
1026
|
+
return groups
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Build tree entries for current directory level
|
|
1030
|
+
function getTreeEntries(files, currentPath) {
|
|
1031
|
+
const prefix = currentPath || ""
|
|
1032
|
+
const entries = []
|
|
1033
|
+
const seenDirs = new Set()
|
|
1034
|
+
|
|
1035
|
+
for (const f of files) {
|
|
1036
|
+
const rel = prefix ? (f.path.startsWith(prefix) ? f.path.slice(prefix.length) : null) : f.path
|
|
1037
|
+
if (rel == null) continue
|
|
1038
|
+
const slashIdx = rel.indexOf("/")
|
|
1039
|
+
if (slashIdx > -1) {
|
|
1040
|
+
const dirName = rel.slice(0, slashIdx)
|
|
1041
|
+
if (!seenDirs.has(dirName)) {
|
|
1042
|
+
seenDirs.add(dirName)
|
|
1043
|
+
const dirPath = prefix + dirName + "/"
|
|
1044
|
+
const count = files.filter(ff => ff.path.startsWith(dirPath)).length
|
|
1045
|
+
entries.push({ type: "dir", name: dirName, path: dirPath, count })
|
|
1046
|
+
}
|
|
1047
|
+
} else {
|
|
1048
|
+
entries.push({ type: "file", name: rel, file: f })
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
// Sort: dirs first, then files, alphabetical within each
|
|
1052
|
+
entries.sort((a, b) => {
|
|
1053
|
+
if (a.type !== b.type) return a.type === "dir" ? -1 : 1
|
|
1054
|
+
return a.name.localeCompare(b.name)
|
|
1055
|
+
})
|
|
1056
|
+
return entries
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const FolderIcon = () => (
|
|
1060
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="#54aeff">
|
|
1061
|
+
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1Z" />
|
|
1062
|
+
</svg>
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
function renderInline(text) {
|
|
1066
|
+
const re = /\*\*(.+?)\*\*|`([^`]+)`|\[(.+?)\]\((.+?)\)/g
|
|
1067
|
+
const parts = []; let last = 0, m, k = 0
|
|
1068
|
+
while ((m = re.exec(text))) {
|
|
1069
|
+
if (m.index > last) parts.push(text.slice(last, m.index))
|
|
1070
|
+
if (m[1] != null) parts.push(<strong key={k++}>{m[1]}</strong>)
|
|
1071
|
+
else if (m[2] != null) parts.push(<code key={k++}>{m[2]}</code>)
|
|
1072
|
+
else if (m[3] != null) parts.push(<a key={k++} href={m[4]}>{m[3]}</a>)
|
|
1073
|
+
last = m.index + m[0].length
|
|
1074
|
+
}
|
|
1075
|
+
if (last < text.length) parts.push(text.slice(last))
|
|
1076
|
+
return parts.length === 1 && typeof parts[0] === "string" ? parts[0] : parts
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1080
|
+
// Icons (SVG)
|
|
1081
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1082
|
+
|
|
1083
|
+
const MoonIcon = () => (
|
|
1084
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
1085
|
+
<path d="M14.53 10.53a7 7 0 0 1-9.058-9.058A7.003 7.003 0 0 0 8 15a7.002 7.002 0 0 0 6.53-4.47z"/>
|
|
1086
|
+
</svg>
|
|
1087
|
+
)
|
|
1088
|
+
const SunIcon = () => (
|
|
1089
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
1090
|
+
<circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/>
|
|
1091
|
+
<line x1="8" y1="1" x2="8" y2="3"/><line x1="8" y1="13" x2="8" y2="15"/>
|
|
1092
|
+
<line x1="1" y1="8" x2="3" y2="8"/><line x1="13" y1="8" x2="15" y2="8"/>
|
|
1093
|
+
<line x1="3.05" y1="3.05" x2="4.46" y2="4.46"/><line x1="11.54" y1="11.54" x2="12.95" y2="12.95"/>
|
|
1094
|
+
<line x1="12.95" y1="3.05" x2="11.54" y2="4.46"/><line x1="4.46" y1="11.54" x2="3.05" y2="12.95"/>
|
|
1095
|
+
</svg>
|
|
1096
|
+
)
|
|
1097
|
+
const BackIcon = () => (
|
|
1098
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
1099
|
+
<path d="M7.78 12.53a.75.75 0 0 1-1.06 0L2.47 8.28a.75.75 0 0 1 0-1.06l4.25-4.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L4.81 7h7.44a.75.75 0 0 1 0 1.5H4.81l2.97 2.97a.75.75 0 0 1 0 1.06Z"/>
|
|
1100
|
+
</svg>
|
|
1101
|
+
)
|
|
1102
|
+
const FileIcon = ({ size = 14 }) => (
|
|
1103
|
+
<svg className="flex-shrink-0 color-fg-muted" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
1104
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
|
1105
|
+
</svg>
|
|
1106
|
+
)
|
|
1107
|
+
const ChevronIcon = ({ open }) => (
|
|
1108
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 color-fg-muted" style={{ transform: open ? "rotate(180deg)" : "none", transition: "transform 0.15s" }}>
|
|
1109
|
+
<path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z"/>
|
|
1110
|
+
</svg>
|
|
1111
|
+
)
|
|
1112
|
+
const CopyIcon = () => (
|
|
1113
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
1114
|
+
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/>
|
|
1115
|
+
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>
|
|
1116
|
+
</svg>
|
|
1117
|
+
)
|
|
1118
|
+
const CheckIcon = () => (
|
|
1119
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="var(--color-success-fg, #1a7f37)">
|
|
1120
|
+
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>
|
|
1121
|
+
</svg>
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1125
|
+
// Highlighted code block
|
|
1126
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1127
|
+
|
|
1128
|
+
function CodeBlock({ code, lang }) {
|
|
1129
|
+
const [copied, setCopied] = useState(false)
|
|
1130
|
+
let html = escapeHtml(code)
|
|
1131
|
+
if (window.hljs && lang) {
|
|
1132
|
+
try { html = window.hljs.highlight(code, { language: lang }).value } catch {}
|
|
1133
|
+
}
|
|
1134
|
+
const handleCopy = () => {
|
|
1135
|
+
navigator.clipboard.writeText(code).then(() => {
|
|
1136
|
+
setCopied(true)
|
|
1137
|
+
setTimeout(() => setCopied(false), 2000)
|
|
1138
|
+
}).catch(() => {})
|
|
1139
|
+
}
|
|
1140
|
+
return (
|
|
1141
|
+
<div className="code-block-wrap">
|
|
1142
|
+
<button className="code-copy-btn" type="button" onClick={handleCopy} aria-label="Copy code">
|
|
1143
|
+
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
1144
|
+
</button>
|
|
1145
|
+
<pre><code className={lang ? `language-${lang} hljs` : ""} dangerouslySetInnerHTML={{ __html: html }} /></pre>
|
|
1146
|
+
</div>
|
|
1147
|
+
)
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1151
|
+
// Layout components
|
|
1152
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1153
|
+
|
|
1154
|
+
function Header({ dark, setDark, connected }) {
|
|
1155
|
+
return (
|
|
1156
|
+
<header className="Header" style={{ paddingLeft: 0, paddingRight: 0 }}>
|
|
1157
|
+
<div className="d-flex flex-items-center width-full px-4" style={{ maxWidth: 1012, margin: "0 auto", gap: 0 }}>
|
|
1158
|
+
<div className="Header-item mr-0">
|
|
1159
|
+
<img src="/favicon.png" alt="WAO" width="24" height="24" className="logo-invert" style={{ marginRight: 10 }} />
|
|
1160
|
+
<span className="Header-link f4 text-bold" style={{ color: "inherit", cursor: "default" }}>HyperADD</span>
|
|
1161
|
+
<span className="color-fg-muted d-none d-md-inline" style={{ marginLeft: 4, fontSize: 14 }}>/</span>
|
|
1162
|
+
<span className="color-fg-muted d-none d-md-inline" style={{ marginLeft: 4, fontSize: 14 }}>Agent Driven Development for AO & HyperBEAM</span>
|
|
1163
|
+
</div>
|
|
1164
|
+
<div className="Header-item Header-item--full" />
|
|
1165
|
+
<div className="Header-item">
|
|
1166
|
+
<span className={`d-flex flex-items-center f6 ${connected ? "color-fg-success" : "color-fg-muted"}`} style={{ gap: 6 }}>
|
|
1167
|
+
<span className="d-inline-block" style={{ width: 8, height: 8, borderRadius: "50%", backgroundColor: "currentColor" }} />
|
|
1168
|
+
{connected ? "live" : "offline"}
|
|
1169
|
+
</span>
|
|
1170
|
+
</div>
|
|
1171
|
+
<div className="Header-item mr-0">
|
|
1172
|
+
<button className="dark-toggle" type="button" onClick={() => setDark(!dark)} aria-label="Toggle dark mode">
|
|
1173
|
+
{dark ? <SunIcon /> : <MoonIcon />}
|
|
1174
|
+
</button>
|
|
1175
|
+
</div>
|
|
1176
|
+
</div>
|
|
1177
|
+
</header>
|
|
1178
|
+
)
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// GitHub repo icon (octicon-repo)
|
|
1182
|
+
function RepoIcon() {
|
|
1183
|
+
return (
|
|
1184
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style={{ flexShrink: 0 }}>
|
|
1185
|
+
<path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z" />
|
|
1186
|
+
</svg>
|
|
1187
|
+
)
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function ProgressSection({ data }) {
|
|
1191
|
+
const tasks = data?.tasks || []
|
|
1192
|
+
const done = tasks.filter(t => t.status === "done").length
|
|
1193
|
+
const total = tasks.length
|
|
1194
|
+
const pct = total > 0 ? Math.round((done / total) * 100) : 0
|
|
1195
|
+
const current = tasks.find(t => t.status === "in_progress")
|
|
1196
|
+
const remaining = total - done
|
|
1197
|
+
|
|
1198
|
+
if (!data?.feature) {
|
|
1199
|
+
return <div className="px-4 pt-4"><p className="color-fg-muted m-0">No build in progress. Run /build to start.</p></div>
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
return (
|
|
1203
|
+
<div className="px-4 pt-4">
|
|
1204
|
+
<div className="d-flex mb-2" style={{ gap: 8, alignItems: "center" }}>
|
|
1205
|
+
<span className="color-fg-muted d-flex" style={{ alignItems: "center" }}><RepoIcon /></span>
|
|
1206
|
+
<span className="f3 text-bold color-fg-default" style={{ lineHeight: 1 }}>{data.feature}</span>
|
|
1207
|
+
<span className={`Label ${done === total ? "Label--success" : "Label--attention"}`} style={{ fontSize: 12, fontWeight: 500 }}>{done === total ? "complete" : "building"}</span>
|
|
1208
|
+
</div>
|
|
1209
|
+
<div className="d-flex flex-items-center" style={{ gap: 8 }}>
|
|
1210
|
+
<span className="f6 color-fg-muted no-wrap">{done} / {total}</span>
|
|
1211
|
+
<div className="d-flex flex-1" style={{ gap: 2, height: 8 }}>
|
|
1212
|
+
{tasks.map(t => (
|
|
1213
|
+
<div key={t.id} style={{
|
|
1214
|
+
flex: 1,
|
|
1215
|
+
borderRadius: 2,
|
|
1216
|
+
backgroundColor: t.status === "done" ? "#8250df" : t.status === "in_progress" ? "#bf8700" : "#d0d7de",
|
|
1217
|
+
}} />
|
|
1218
|
+
))}
|
|
1219
|
+
</div>
|
|
1220
|
+
<span className="f6 color-fg-muted no-wrap">{pct}%</span>
|
|
1221
|
+
</div>
|
|
1222
|
+
{current && (
|
|
1223
|
+
<p className="f6 color-fg-muted m-0 mt-2">
|
|
1224
|
+
<span className="anim-spin d-inline-block mr-1">↻</span>
|
|
1225
|
+
{current.name} · {done} done, {remaining} remaining
|
|
1226
|
+
</p>
|
|
1227
|
+
)}
|
|
1228
|
+
{!current && total > 0 && (
|
|
1229
|
+
<p className="f6 color-fg-muted m-0 mt-2">
|
|
1230
|
+
{done === total ? `All ${total} tasks complete` : `${done} done, ${remaining} remaining`}
|
|
1231
|
+
</p>
|
|
1232
|
+
)}
|
|
1233
|
+
</div>
|
|
1234
|
+
)
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const TRACK_LABEL = {
|
|
1238
|
+
Done: "Label Label--success",
|
|
1239
|
+
"In Progress": "Label Label--attention",
|
|
1240
|
+
Pending: "Label Label--secondary",
|
|
1241
|
+
}
|
|
1242
|
+
const TRACK_COLORS = {
|
|
1243
|
+
Done: { light: { bg: "#dafbe1", border: "rgba(26,127,55,0.4)" }, dark: { bg: "rgba(35,134,54,0.15)", border: "rgba(46,160,67,0.4)" } },
|
|
1244
|
+
"In Progress": { light: { bg: "#fff8c5", border: "rgba(154,103,0,0.4)" }, dark: { bg: "rgba(187,128,9,0.15)", border: "rgba(187,128,9,0.4)" } },
|
|
1245
|
+
Pending: { light: { bg: "transparent", border: "#d0d7de" }, dark: { bg: "transparent", border: "#30363d" } },
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function TrackCards({ data, dark }) {
|
|
1249
|
+
const cards = deriveTrackCards(data?.tasks || [])
|
|
1250
|
+
if (!cards.length) return null
|
|
1251
|
+
const mode = dark ? "dark" : "light"
|
|
1252
|
+
return (
|
|
1253
|
+
<div className="d-flex flex-wrap px-4 pt-3" style={{ gap: 8 }}>
|
|
1254
|
+
{cards.map(c => {
|
|
1255
|
+
const colors = TRACK_COLORS[c.status]?.[mode] || TRACK_COLORS.Pending[mode]
|
|
1256
|
+
return (
|
|
1257
|
+
<div key={c.name} className="d-flex flex-items-center flex-justify-between rounded-2 p-3" style={{ flex: "1 1 0%", minWidth: 140, border: `1px solid ${colors.border}`, background: colors.bg }}>
|
|
1258
|
+
<span className="f5 text-bold">{c.name}</span>
|
|
1259
|
+
<span className={TRACK_LABEL[c.status]}>{c.status}</span>
|
|
1260
|
+
</div>
|
|
1261
|
+
)
|
|
1262
|
+
})}
|
|
1263
|
+
</div>
|
|
1264
|
+
)
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function TabBar({ active, setActive, taskCount }) {
|
|
1268
|
+
const tabs = [
|
|
1269
|
+
{ id: "tasks", label: "Tasks", counter: taskCount, icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" /><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" /></svg> },
|
|
1270
|
+
{ id: "tests", label: "Tests", icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M5.75 7.5a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5a.75.75 0 0 1 .75-.75Zm5.25.75a.75.75 0 0 0-1.5 0v1.5a.75.75 0 0 0 1.5 0ZM8 7.5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 7.5Z" /><path d="M4.25 1h2.5a.75.75 0 0 1 0 1.5h-.19l1.658 3.316A5.508 5.508 0 0 1 13.5 11.25a5.5 5.5 0 1 1-11 0 5.508 5.508 0 0 1 5.282-5.434L9.44 2.5h-.19a.75.75 0 0 1 0-1.5h2.5a.75.75 0 0 1 0 1.5h-.19L9.623 5.87A5.45 5.45 0 0 1 12 11.25a4 4 0 1 0-8 0 5.45 5.45 0 0 1 2.377-5.38L4.44 2.5h-.19a.75.75 0 0 1 0-1.5ZM8 7.25a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z" /></svg> },
|
|
1271
|
+
{ id: "plan", label: "Plan", icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Z" /></svg> },
|
|
1272
|
+
{ id: "code", label: "Code", icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z" /></svg> },
|
|
1273
|
+
{ id: "readme", label: "README", icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z" /></svg> },
|
|
1274
|
+
{ id: "commands", label: "Commands", icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25Zm7.25 8a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5a.75.75 0 0 1-.75-.75Zm-7.25-6a.75.75 0 0 1 .75-.75h2a.75.75 0 0 1 0 1.5h-2A.75.75 0 0 1 1.75 4.5ZM4.22 6.22a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L5.94 8.5 4.22 6.78a.75.75 0 0 1 0-1.06Z" /></svg> },
|
|
1275
|
+
{ id: "skills", label: "Skills", icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M7.998 14.5c2.832 0 5-1.98 5-4.5 0-1.463-.68-2.19-1.879-3.383l-.036-.037c-1.013-1.008-2.3-2.29-2.834-4.434-.322.256-.63.579-.864.953-.432.696-.621 1.58-.046 2.73.473.947.67 2.284-.278 3.232-.61.61-1.545.84-2.403.508a2.18 2.18 0 0 1-.727-.467 2.2 2.2 0 0 1-.61-1.15c-.208.208-.401.397-.571.566C1.558 9.882 1 10.56 1 11.994 1 14.476 3.842 16 7.999 16c4.157 0 6.999-1.524 6.999-4.006 0-1.434-.558-2.112-1.749-3.298l-.037-.036c-1.175-1.168-2.665-2.646-3.28-5.348a.96.96 0 0 0-.109-.218.756.756 0 0 0-1.283.065c-.571.922-1.378 2.016-2.498 2.772-.484.326-1.037.504-1.592.504-.345 0-.69-.072-1.016-.22a2.52 2.52 0 0 1-.657-.428l-.122-.126c-.276.28-.543.574-.786.894A6.11 6.11 0 0 0 1 11.994c0 2.483 2.842 4.006 6.999 4.006Z" /><path d="M3.635 10.326c.199.63.758 1.024 1.363 1.024.474 0 .943-.227 1.255-.611a.755.755 0 0 0-.008-1.003c-.327-.375-.327-.94 0-1.316a2.078 2.078 0 0 0 .417-1.987c-1.08.591-1.833 1.376-2.484 2.253a5.38 5.38 0 0 0-.543 1.64Z" /></svg> },
|
|
1276
|
+
{ id: "deploy", label: "Deploy", icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01a.753.753 0 0 1-.07.063 3.04 3.04 0 0 1-.39.276 3.555 3.555 0 0 1-1.862.497c-.786 0-1.4-.227-1.862-.497a3.04 3.04 0 0 1-.39-.276.749.749 0 0 1-.07-.063l-.01-.01-.006-.005-.004-.004-.004-.004a.75.75 0 0 1-.154-.838L13.823 4.5h-.427a1.681 1.681 0 0 1-.497-.078l-1.29-.736a.164.164 0 0 0-.072-.019H8.75v8.172a2.332 2.332 0 0 1 1.422 1.161H11.5a.75.75 0 0 1 0 1.5h-1.328a2.343 2.343 0 0 1-4.344 0H4.5a.75.75 0 0 1 0-1.5h1.328A2.332 2.332 0 0 1 7.25 11.84V3.667h-.985a.164.164 0 0 0-.072.019l-1.29.736a1.68 1.68 0 0 1-.497.078h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01a.756.756 0 0 1-.07.063 3.04 3.04 0 0 1-.39.276 3.555 3.555 0 0 1-1.862.497c-.786 0-1.4-.227-1.862-.497a3.04 3.04 0 0 1-.39-.276.749.749 0 0 1-.07-.063l-.01-.01-.006-.005-.004-.004-.004-.004a.75.75 0 0 1-.154-.838L4.823 4.5H2.25a.75.75 0 0 1 0-1.5h2.234c.044 0 .086-.011.124-.033l1.29-.736A1.68 1.68 0 0 1 6.765 2H7.25V.75a.75.75 0 0 1 1.5 0Zm-4.384 8.892h.68l-.34-.754Zm6.588 0h.68l-.34-.754ZM8 13.5a.843.843 0 1 0 0-1.686.843.843 0 0 0 0 1.686Z" /></svg> },
|
|
1277
|
+
]
|
|
1278
|
+
return (
|
|
1279
|
+
<nav className="UnderlineNav mx-4 mt-3">
|
|
1280
|
+
<div className="UnderlineNav-body">
|
|
1281
|
+
{tabs.map(t => (
|
|
1282
|
+
<button key={t.id} className="UnderlineNav-item" role="tab" type="button" aria-selected={active === t.id} onClick={() => setActive(t.id)}>
|
|
1283
|
+
<span className="d-flex flex-items-center" style={{ gap: 6 }}>
|
|
1284
|
+
<span className="d-flex color-fg-muted">{t.icon}</span>
|
|
1285
|
+
{t.label}
|
|
1286
|
+
{t.counter != null && <span className="Counter">{t.counter}</span>}
|
|
1287
|
+
</span>
|
|
1288
|
+
</button>
|
|
1289
|
+
))}
|
|
1290
|
+
</div>
|
|
1291
|
+
</nav>
|
|
1292
|
+
)
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1296
|
+
// Tab: Tasks (GitHub Issues-style)
|
|
1297
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1298
|
+
|
|
1299
|
+
// GitHub-style issue state icons
|
|
1300
|
+
const IssueOpenIcon = () => (
|
|
1301
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="#1a7f37">
|
|
1302
|
+
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/>
|
|
1303
|
+
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"/>
|
|
1304
|
+
</svg>
|
|
1305
|
+
)
|
|
1306
|
+
const IssueClosedIcon = () => (
|
|
1307
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="#8250df">
|
|
1308
|
+
<path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z"/>
|
|
1309
|
+
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z"/>
|
|
1310
|
+
</svg>
|
|
1311
|
+
)
|
|
1312
|
+
const IssueInProgressIcon = () => (
|
|
1313
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="#bf8700">
|
|
1314
|
+
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/>
|
|
1315
|
+
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"/>
|
|
1316
|
+
</svg>
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
function TaskRow({ t, isOpen, onToggle }) {
|
|
1320
|
+
const isDone = t.status === "done"
|
|
1321
|
+
const isActive = t.status === "in_progress"
|
|
1322
|
+
const color = TYPE_COLORS[t.type] || "#6b7280"
|
|
1323
|
+
const elapsed = useElapsed(isActive ? t.started_at : null)
|
|
1324
|
+
const duration = isDone && t.started_at && t.completed_at
|
|
1325
|
+
? formatDuration(new Date(t.completed_at).getTime() - new Date(t.started_at).getTime())
|
|
1326
|
+
: null
|
|
1327
|
+
|
|
1328
|
+
const timeAgo = t.completed_at
|
|
1329
|
+
? `completed ${formatDuration(Date.now() - new Date(t.completed_at).getTime())} ago`
|
|
1330
|
+
: t.started_at
|
|
1331
|
+
? `started ${formatDuration(Date.now() - new Date(t.started_at).getTime())} ago`
|
|
1332
|
+
: null
|
|
1333
|
+
|
|
1334
|
+
return (
|
|
1335
|
+
<>
|
|
1336
|
+
<div className="Box-row d-flex flex-items-start px-3 py-2" style={{ gap: 8, cursor: "pointer" }} onClick={onToggle}>
|
|
1337
|
+
<div className="flex-shrink-0" style={{ paddingTop: 3 }}>
|
|
1338
|
+
{isDone && <IssueClosedIcon />}
|
|
1339
|
+
{isActive && <IssueInProgressIcon />}
|
|
1340
|
+
{!isDone && !isActive && <IssueOpenIcon />}
|
|
1341
|
+
</div>
|
|
1342
|
+
<div className="flex-1" style={{ minWidth: 0 }}>
|
|
1343
|
+
<div className="d-flex flex-items-center flex-wrap" style={{ gap: 6 }}>
|
|
1344
|
+
<a className={`f4 text-bold ${isDone ? "color-fg-muted" : "color-fg-default"}`} style={{ cursor: "pointer", textDecoration: "none" }}>
|
|
1345
|
+
{t.name}
|
|
1346
|
+
</a>
|
|
1347
|
+
<span className="Label" style={{ color, borderColor: color + "44", backgroundColor: color + "18", fontSize: 12, verticalAlign: "middle" }}>{t.type}</span>
|
|
1348
|
+
</div>
|
|
1349
|
+
<div className="f6 color-fg-muted" style={{ marginTop: 4 }}>
|
|
1350
|
+
#{t.id}
|
|
1351
|
+
{t.skill && <> · <code style={{ fontSize: 11 }}>{t.skill}</code></>}
|
|
1352
|
+
{isActive && elapsed && <> · <span className="color-fg-attention"><span className="anim-spin d-inline-block mr-1" style={{ fontSize: 10 }}>↻</span>{elapsed}</span></>}
|
|
1353
|
+
{duration && <> · took {duration}</>}
|
|
1354
|
+
{timeAgo && <> · {timeAgo}</>}
|
|
1355
|
+
</div>
|
|
1356
|
+
</div>
|
|
1357
|
+
<ChevronIcon open={isOpen} />
|
|
1358
|
+
</div>
|
|
1359
|
+
{isOpen && (
|
|
1360
|
+
<div className="color-bg-subtle border-bottom" style={{ padding: "12px 16px 12px 40px" }}>
|
|
1361
|
+
<table className="f6" style={{ lineHeight: "22px", wordBreak: "break-word" }}>
|
|
1362
|
+
<tbody>
|
|
1363
|
+
<tr>
|
|
1364
|
+
<td className="color-fg-muted pr-3 no-wrap v-align-top">Status</td>
|
|
1365
|
+
<td><span className={`Label ${isDone ? "Label--success" : isActive ? "Label--attention" : "Label--secondary"}`}>{t.status.replace("_", " ")}</span></td>
|
|
1366
|
+
</tr>
|
|
1367
|
+
{t.done_when && <tr>
|
|
1368
|
+
<td className="color-fg-muted pr-3 no-wrap v-align-top">Done when</td>
|
|
1369
|
+
<td>{t.done_when}</td>
|
|
1370
|
+
</tr>}
|
|
1371
|
+
{t.files?.length > 0 && <tr>
|
|
1372
|
+
<td className="color-fg-muted pr-3 no-wrap v-align-top">Files</td>
|
|
1373
|
+
<td>{t.files.map(f => <code key={f} className="f6 mr-1">{f}</code>)}</td>
|
|
1374
|
+
</tr>}
|
|
1375
|
+
{t.started_at && <tr>
|
|
1376
|
+
<td className="color-fg-muted pr-3 no-wrap v-align-top">Started</td>
|
|
1377
|
+
<td>{new Date(t.started_at).toLocaleTimeString()}</td>
|
|
1378
|
+
</tr>}
|
|
1379
|
+
{t.completed_at && <tr>
|
|
1380
|
+
<td className="color-fg-muted pr-3 no-wrap v-align-top">Completed</td>
|
|
1381
|
+
<td>{new Date(t.completed_at).toLocaleTimeString()} ({duration})</td>
|
|
1382
|
+
</tr>}
|
|
1383
|
+
{isActive && elapsed && <tr>
|
|
1384
|
+
<td className="color-fg-muted pr-3 no-wrap v-align-top">Elapsed</td>
|
|
1385
|
+
<td className="color-fg-attention text-bold">{elapsed}</td>
|
|
1386
|
+
</tr>}
|
|
1387
|
+
</tbody>
|
|
1388
|
+
</table>
|
|
1389
|
+
</div>
|
|
1390
|
+
)}
|
|
1391
|
+
</>
|
|
1392
|
+
)
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Group tasks by track (like GitHub Projects sections)
|
|
1396
|
+
const TASK_GROUPS = [
|
|
1397
|
+
{ key: "plan", label: "Planning", match: t => t.type === "plan" },
|
|
1398
|
+
{ key: "aos", label: "AOS", match: t => t.type.startsWith("aos") },
|
|
1399
|
+
{ key: "device", label: "Device", match: t => t.type.startsWith("device") },
|
|
1400
|
+
{ key: "frontend", label: "Frontend", match: t => t.type.startsWith("frontend") },
|
|
1401
|
+
{ key: "module", label: "Custom Module", match: t => t.type.startsWith("module") },
|
|
1402
|
+
{ key: "validation", label: "Validation", match: t => ["readme", "validate", "deploy"].includes(t.type) },
|
|
1403
|
+
]
|
|
1404
|
+
|
|
1405
|
+
function groupTasks(tasks) {
|
|
1406
|
+
const groups = []
|
|
1407
|
+
const used = new Set()
|
|
1408
|
+
for (const g of TASK_GROUPS) {
|
|
1409
|
+
const items = tasks.filter((t, i) => { if (used.has(i)) return false; if (g.match(t)) { used.add(i); return true } return false })
|
|
1410
|
+
if (items.length) groups.push({ ...g, tasks: items })
|
|
1411
|
+
}
|
|
1412
|
+
// Catch-all for any unmatched
|
|
1413
|
+
const rest = tasks.filter((_, i) => !used.has(i))
|
|
1414
|
+
if (rest.length) groups.push({ key: "other", label: "Other", tasks: rest })
|
|
1415
|
+
return groups
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function TaskGroupHeader({ label, count, done, collapsed, onToggle }) {
|
|
1419
|
+
return (
|
|
1420
|
+
<div
|
|
1421
|
+
className="d-flex flex-items-center color-bg-subtle px-3 py-2 border-bottom"
|
|
1422
|
+
style={{ cursor: "pointer", userSelect: "none", gap: 8 }}
|
|
1423
|
+
onClick={onToggle}
|
|
1424
|
+
>
|
|
1425
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="color-fg-muted" style={{ transform: collapsed ? "rotate(-90deg)" : "rotate(0deg)", transition: "transform 0.15s" }}>
|
|
1426
|
+
<path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z" />
|
|
1427
|
+
</svg>
|
|
1428
|
+
<span className="text-bold f5">{label}</span>
|
|
1429
|
+
<span className="Counter">{count}</span>
|
|
1430
|
+
<span className="f6 color-fg-muted">{done === count ? "Complete" : `${done} / ${count}`}</span>
|
|
1431
|
+
</div>
|
|
1432
|
+
)
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function TasksTab({ data }) {
|
|
1436
|
+
const [expanded, setExpanded] = useState(null)
|
|
1437
|
+
const [collapsed, setCollapsed] = useState({})
|
|
1438
|
+
const tasks = data?.tasks || []
|
|
1439
|
+
if (!tasks.length) return <p className="p-4 color-fg-muted">No tasks yet. Run /build to create a task list.</p>
|
|
1440
|
+
|
|
1441
|
+
const groups = groupTasks(tasks)
|
|
1442
|
+
|
|
1443
|
+
return (
|
|
1444
|
+
<div className="Box mx-4 mt-3 mb-4">
|
|
1445
|
+
{groups.map(g => {
|
|
1446
|
+
const done = g.tasks.filter(t => t.status === "done").length
|
|
1447
|
+
const isCollapsed = collapsed[g.key]
|
|
1448
|
+
return (
|
|
1449
|
+
<div key={g.key}>
|
|
1450
|
+
<TaskGroupHeader
|
|
1451
|
+
label={g.label}
|
|
1452
|
+
count={g.tasks.length}
|
|
1453
|
+
done={done}
|
|
1454
|
+
collapsed={isCollapsed}
|
|
1455
|
+
onToggle={() => setCollapsed(prev => ({ ...prev, [g.key]: !prev[g.key] }))}
|
|
1456
|
+
/>
|
|
1457
|
+
{!isCollapsed && g.tasks.map(t => (
|
|
1458
|
+
<TaskRow key={t.id} t={t} isOpen={expanded === t.id} onToggle={() => setExpanded(expanded === t.id ? null : t.id)} />
|
|
1459
|
+
))}
|
|
1460
|
+
</div>
|
|
1461
|
+
)
|
|
1462
|
+
})}
|
|
1463
|
+
</div>
|
|
1464
|
+
)
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1468
|
+
// Tab: Tests
|
|
1469
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1470
|
+
|
|
1471
|
+
const DEMO_TESTS = [
|
|
1472
|
+
{
|
|
1473
|
+
file: "test/aos.test.js",
|
|
1474
|
+
group: "AOS",
|
|
1475
|
+
tests: [
|
|
1476
|
+
{ name: "should deploy AOS process", status: "pass", duration: 742 },
|
|
1477
|
+
{ name: "should increment counter", status: "pass", duration: 118 },
|
|
1478
|
+
{ name: "should return count via dry-run", status: "pass", duration: 85 },
|
|
60
1479
|
],
|
|
61
1480
|
},
|
|
62
1481
|
{
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
module: moduleId
|
|
127
|
-
})`,
|
|
128
|
-
notes: "Upload Lua to Arweave first, then spawn with module ID on remote node.",
|
|
129
|
-
},
|
|
1482
|
+
file: "test/token.test.js",
|
|
1483
|
+
group: "AOS",
|
|
1484
|
+
tests: [
|
|
1485
|
+
{ name: "should mint initial supply", status: "pass", duration: 856 },
|
|
1486
|
+
{ name: "should transfer tokens between accounts", status: "pass", duration: 234 },
|
|
1487
|
+
{ name: "should reject transfer exceeding balance", status: "pass", duration: 112 },
|
|
1488
|
+
{ name: "should return correct balance", status: "pass", duration: 91 },
|
|
1489
|
+
],
|
|
1490
|
+
},
|
|
1491
|
+
{
|
|
1492
|
+
file: "test/registry.test.js",
|
|
1493
|
+
group: "AOS",
|
|
1494
|
+
tests: [
|
|
1495
|
+
{ name: "should register token", status: "pass", duration: 678 },
|
|
1496
|
+
{ name: "should list registered tokens", status: "pass", duration: 145 },
|
|
1497
|
+
{ name: "should reject duplicate registration", status: "pass", duration: 98 },
|
|
1498
|
+
{ name: "should deregister token", status: "pass", duration: 134 },
|
|
1499
|
+
{ name: "should query token metadata", status: "pass", duration: 107 },
|
|
1500
|
+
],
|
|
1501
|
+
},
|
|
1502
|
+
{
|
|
1503
|
+
file: "HyperBEAM/src/dev_token.erl",
|
|
1504
|
+
group: "Device",
|
|
1505
|
+
tests: [
|
|
1506
|
+
{ name: "mint_test", status: "pass", duration: 12 },
|
|
1507
|
+
{ name: "transfer_test", status: "pass", duration: 15 },
|
|
1508
|
+
{ name: "insufficient_balance_test", status: "pass", duration: 8 },
|
|
1509
|
+
],
|
|
1510
|
+
},
|
|
1511
|
+
{
|
|
1512
|
+
file: "test/hyperbeam-token.test.js",
|
|
1513
|
+
group: "Device",
|
|
1514
|
+
tests: [
|
|
1515
|
+
{ name: "should deploy token on HyperBEAM", status: "pass", duration: 2341 },
|
|
1516
|
+
{ name: "should mint via HyperBEAM", status: "pass", duration: 1456 },
|
|
1517
|
+
{ name: "should transfer via HyperBEAM", status: "pass", duration: 1289 },
|
|
1518
|
+
],
|
|
1519
|
+
},
|
|
1520
|
+
{
|
|
1521
|
+
file: "test/token-device.test.js",
|
|
1522
|
+
group: "Device",
|
|
1523
|
+
tests: [
|
|
1524
|
+
{ name: "should call device via HTTP", status: "pass", duration: 1823 },
|
|
1525
|
+
{ name: "should return device state", status: "pass", duration: 945 },
|
|
1526
|
+
],
|
|
1527
|
+
},
|
|
1528
|
+
{
|
|
1529
|
+
file: "test/hyperbeam.test.js",
|
|
1530
|
+
group: "Device",
|
|
1531
|
+
tests: [
|
|
1532
|
+
{ name: "should start HyperBEAM node", status: "pass", duration: 3210 },
|
|
1533
|
+
{ name: "should deploy AOS on HyperBEAM", status: "pass", duration: 1876 },
|
|
1534
|
+
{ name: "should send message via slot", status: "pass", duration: 567 },
|
|
1535
|
+
{ name: "should read state via dry-run", status: "pass", duration: 412 },
|
|
1536
|
+
],
|
|
1537
|
+
},
|
|
1538
|
+
{
|
|
1539
|
+
file: "frontend/src/__tests__/App.test.jsx",
|
|
1540
|
+
group: "Frontend",
|
|
1541
|
+
tests: [
|
|
1542
|
+
{ name: "renders app without crashing", status: "pass", duration: 45 },
|
|
1543
|
+
{ name: "shows wallet connect button", status: "pass", duration: 32 },
|
|
1544
|
+
{ name: "displays token info after connect", status: "pass", duration: 67 },
|
|
130
1545
|
],
|
|
131
1546
|
},
|
|
132
1547
|
{
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// 2. Register in HyperBEAM/src/hb_opts.erl preloaded_devices
|
|
143
|
-
// #{<<"name">> => <<"mydevice@1.0">>, <<"module">> => dev_mydevice}
|
|
144
|
-
|
|
145
|
-
// 3. Test via WAO SDK
|
|
146
|
-
import { HyperBEAM } from "wao/test"
|
|
147
|
-
const hbeam = await new HyperBEAM({ reset: true }).ready()
|
|
148
|
-
const hb = hbeam.hb
|
|
149
|
-
const { out } = await hb.g("/~mydevice@1.0/compute", { action: "get" })`,
|
|
150
|
-
notes: "Compile with rebar3, register in preloaded_devices, start local node.",
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
target: "Remote HyperBEAM",
|
|
154
|
-
command: null,
|
|
155
|
-
code: `// 1. Compile the device
|
|
156
|
-
// cd HyperBEAM && rebar3 as genesis_wasm compile
|
|
157
|
-
|
|
158
|
-
// 2. Deploy compiled .beam files to production HyperBEAM node
|
|
159
|
-
// Copy _build/genesis_wasm/lib/hb/ebin/dev_*.beam to remote node
|
|
160
|
-
|
|
161
|
-
// 3. Register device in remote node's preloaded_devices
|
|
162
|
-
// #{<<"name">> => <<"mydevice@1.0">>, <<"module">> => dev_mydevice}
|
|
163
|
-
|
|
164
|
-
// 4. Connect via WAO SDK
|
|
165
|
-
import { HB } from "wao"
|
|
166
|
-
const hb = await new HB({
|
|
167
|
-
url: "https://push-1.forward.computer"
|
|
168
|
-
}).init(jwk)
|
|
169
|
-
const { out } = await hb.g("/~mydevice@1.0/compute", { action: "get" })`,
|
|
170
|
-
notes: "Deploy .beam files to production node and register in preloaded_devices.",
|
|
171
|
-
},
|
|
1548
|
+
file: "frontend/src/__tests__/TransferForm.test.jsx",
|
|
1549
|
+
group: "Frontend",
|
|
1550
|
+
tests: [
|
|
1551
|
+
{ name: "renders transfer form", status: "pass", duration: 28 },
|
|
1552
|
+
{ name: "validates recipient address", status: "pass", duration: 19 },
|
|
1553
|
+
{ name: "validates amount field", status: "pass", duration: 21 },
|
|
1554
|
+
{ name: "submits transfer", status: "pass", duration: 156 },
|
|
1555
|
+
{ name: "shows error on failed transfer", status: "fail", duration: 203 },
|
|
172
1556
|
],
|
|
173
1557
|
},
|
|
1558
|
+
{
|
|
1559
|
+
file: "frontend/e2e/token-transfer.spec.js",
|
|
1560
|
+
group: "Frontend",
|
|
1561
|
+
tests: [
|
|
1562
|
+
{ name: "connects wallet and loads balance", status: "in_progress", duration: null },
|
|
1563
|
+
{ name: "sends token transfer end-to-end", status: "pending", duration: null },
|
|
1564
|
+
{ name: "shows transaction confirmation", status: "pending", duration: null },
|
|
1565
|
+
],
|
|
1566
|
+
},
|
|
1567
|
+
]
|
|
1568
|
+
|
|
1569
|
+
const TEST_GROUPS = [
|
|
1570
|
+
{ key: "aos", label: "AOS", match: f => f.group === "AOS" },
|
|
1571
|
+
{ key: "device", label: "Device", match: f => f.group === "Device" },
|
|
1572
|
+
{ key: "frontend", label: "Frontend", match: f => f.group === "Frontend" },
|
|
174
1573
|
]
|
|
175
1574
|
|
|
176
|
-
function
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
1575
|
+
function groupTestFiles(files) {
|
|
1576
|
+
const groups = []
|
|
1577
|
+
const used = new Set()
|
|
1578
|
+
for (const g of TEST_GROUPS) {
|
|
1579
|
+
const items = files.filter((f, i) => { if (used.has(i)) return false; if (g.match(f)) { used.add(i); return true } return false })
|
|
1580
|
+
if (items.length) groups.push({ ...g, files: items })
|
|
1581
|
+
}
|
|
1582
|
+
const rest = files.filter((_, i) => !used.has(i))
|
|
1583
|
+
if (rest.length) groups.push({ key: "other", label: "Other", files: rest })
|
|
1584
|
+
return groups
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function TestFileRow({ file, isOpen, onToggle }) {
|
|
1588
|
+
const total = file.tests.length
|
|
1589
|
+
const passed = file.tests.filter(t => t.status === "pass").length
|
|
1590
|
+
const failed = file.tests.filter(t => t.status === "fail").length
|
|
1591
|
+
const running = file.tests.filter(t => t.status === "in_progress").length
|
|
1592
|
+
const allPass = passed === total
|
|
1593
|
+
const totalDuration = file.tests.reduce((s, t) => s + (t.duration || 0), 0)
|
|
181
1594
|
|
|
182
1595
|
return (
|
|
183
|
-
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
style={i === activeDeploy ? { ...styles.tab, ...styles.tabActive } : styles.tab}
|
|
203
|
-
>
|
|
204
|
-
{d.target}
|
|
205
|
-
</button>
|
|
206
|
-
))}
|
|
1596
|
+
<>
|
|
1597
|
+
<div className="Box-row d-flex flex-items-center px-3 py-2" style={{ gap: 8, cursor: "pointer" }} onClick={onToggle}>
|
|
1598
|
+
<div className="flex-shrink-0" style={{ width: 22, display: "flex", justifyContent: "center" }}>
|
|
1599
|
+
{failed > 0 ? (
|
|
1600
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="#cf222e"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z" /></svg>
|
|
1601
|
+
) : running > 0 ? (
|
|
1602
|
+
<span className="anim-spin d-inline-block color-fg-attention" style={{ fontSize: 14 }}>↻</span>
|
|
1603
|
+
) : allPass ? (
|
|
1604
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="#1a7f37"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z" /></svg>
|
|
1605
|
+
) : (
|
|
1606
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="color-fg-muted"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" strokeWidth="2" /></svg>
|
|
1607
|
+
)}
|
|
1608
|
+
</div>
|
|
1609
|
+
<div className="flex-1" style={{ minWidth: 0 }}>
|
|
1610
|
+
<span className="f5 text-bold color-fg-default" style={{ fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace", fontSize: 13 }}>{file.file}</span>
|
|
1611
|
+
</div>
|
|
1612
|
+
<span className="f6 color-fg-muted no-wrap">{totalDuration > 0 ? formatDuration(totalDuration) : ""}</span>
|
|
1613
|
+
<span className={`Label ${allPass ? "Label--success" : failed > 0 ? "Label--danger" : "Label--secondary"}`} style={{ fontSize: 12 }}>{passed}/{total}</span>
|
|
1614
|
+
<ChevronIcon open={isOpen} />
|
|
207
1615
|
</div>
|
|
208
|
-
|
|
209
|
-
{
|
|
210
|
-
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
1616
|
+
{isOpen && (
|
|
1617
|
+
<div className="color-bg-subtle border-bottom" style={{ padding: "4px 0" }}>
|
|
1618
|
+
{file.tests.map((t, i) => (
|
|
1619
|
+
<div key={i} className="d-flex flex-items-center px-3" style={{ gap: 8, padding: "4px 16px 4px 48px" }}>
|
|
1620
|
+
<div style={{ width: 16, display: "flex", justifyContent: "center", flexShrink: 0 }}>
|
|
1621
|
+
{t.status === "pass" && <svg width="12" height="12" viewBox="0 0 16 16" fill="#1a7f37"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" /></svg>}
|
|
1622
|
+
{t.status === "fail" && <svg width="12" height="12" viewBox="0 0 16 16" fill="#cf222e"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" /></svg>}
|
|
1623
|
+
{t.status === "in_progress" && <span className="anim-spin d-inline-block color-fg-attention" style={{ fontSize: 10 }}>↻</span>}
|
|
1624
|
+
{t.status === "pending" && <span className="d-inline-block" style={{ width: 8, height: 8, borderRadius: "50%", border: "2px solid var(--color-border-default)" }} />}
|
|
1625
|
+
</div>
|
|
1626
|
+
<span className={`f6 flex-1 ${t.status === "pass" ? "color-fg-muted" : t.status === "fail" ? "color-fg-danger" : "color-fg-default"}`}>{t.name}</span>
|
|
1627
|
+
{t.duration != null && <span className="f6 color-fg-muted no-wrap">{t.duration}ms</span>}
|
|
1628
|
+
</div>
|
|
1629
|
+
))}
|
|
1630
|
+
</div>
|
|
1631
|
+
)}
|
|
1632
|
+
</>
|
|
1633
|
+
)
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
function TestGroupHeader({ label, count, passed, failed, collapsed, onToggle }) {
|
|
1637
|
+
return (
|
|
1638
|
+
<div
|
|
1639
|
+
className="d-flex flex-items-center color-bg-subtle px-3 py-2 border-bottom"
|
|
1640
|
+
style={{ cursor: "pointer", userSelect: "none", gap: 8 }}
|
|
1641
|
+
onClick={onToggle}
|
|
1642
|
+
>
|
|
1643
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="color-fg-muted" style={{ transform: collapsed ? "rotate(-90deg)" : "rotate(0deg)", transition: "transform 0.15s" }}>
|
|
1644
|
+
<path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z" />
|
|
1645
|
+
</svg>
|
|
1646
|
+
<span className="text-bold f5">{label}</span>
|
|
1647
|
+
<span className="Counter">{count} tests</span>
|
|
1648
|
+
{failed > 0
|
|
1649
|
+
? <span className="f6 color-fg-danger">{failed} failed</span>
|
|
1650
|
+
: <span className="f6 color-fg-muted">{passed === count ? "All passing" : `${passed} / ${count}`}</span>
|
|
1651
|
+
}
|
|
1652
|
+
</div>
|
|
1653
|
+
)
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function TestsTab() {
|
|
1657
|
+
const [testData, setTestData] = useState(DEMO_TESTS)
|
|
1658
|
+
const [expanded, setExpanded] = useState(null)
|
|
1659
|
+
const [collapsed, setCollapsed] = useState({})
|
|
1660
|
+
|
|
1661
|
+
useEffect(() => {
|
|
1662
|
+
fetch("/api/tests").then(r => r.json()).then(d => { if (d?.length) setTestData(d) }).catch(() => {})
|
|
1663
|
+
}, [])
|
|
1664
|
+
|
|
1665
|
+
const groups = groupTestFiles(testData)
|
|
1666
|
+
const allTests = testData.flatMap(f => f.tests)
|
|
1667
|
+
const totalPass = allTests.filter(t => t.status === "pass").length
|
|
1668
|
+
const totalFail = allTests.filter(t => t.status === "fail").length
|
|
1669
|
+
const totalCount = allTests.length
|
|
1670
|
+
const pct = totalCount > 0 ? Math.round((totalPass / totalCount) * 100) : 0
|
|
1671
|
+
|
|
1672
|
+
return (
|
|
1673
|
+
<div className="mx-4 mt-3 mb-4">
|
|
1674
|
+
<div className="d-flex flex-items-center mb-3 px-1" style={{ gap: 12 }}>
|
|
1675
|
+
<span className="f5 text-bold">{totalPass} / {totalCount} passing</span>
|
|
1676
|
+
<span className="f6 color-fg-muted">({pct}%)</span>
|
|
1677
|
+
{totalFail > 0 && <span className="Label Label--danger">{totalFail} failed</span>}
|
|
217
1678
|
</div>
|
|
218
|
-
<div
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
1679
|
+
<div className="Box">
|
|
1680
|
+
{groups.map(g => {
|
|
1681
|
+
const tests = g.files.flatMap(f => f.tests)
|
|
1682
|
+
const passed = tests.filter(t => t.status === "pass").length
|
|
1683
|
+
const failed = tests.filter(t => t.status === "fail").length
|
|
1684
|
+
const isCollapsed = collapsed[g.key]
|
|
1685
|
+
return (
|
|
1686
|
+
<div key={g.key}>
|
|
1687
|
+
<TestGroupHeader
|
|
1688
|
+
label={g.label}
|
|
1689
|
+
count={tests.length}
|
|
1690
|
+
passed={passed}
|
|
1691
|
+
failed={failed}
|
|
1692
|
+
collapsed={isCollapsed}
|
|
1693
|
+
onToggle={() => setCollapsed(prev => ({ ...prev, [g.key]: !prev[g.key] }))}
|
|
1694
|
+
/>
|
|
1695
|
+
{!isCollapsed && g.files.map(f => (
|
|
1696
|
+
<TestFileRow key={f.file} file={f} isOpen={expanded === f.file} onToggle={() => setExpanded(expanded === f.file ? null : f.file)} />
|
|
1697
|
+
))}
|
|
1698
|
+
</div>
|
|
1699
|
+
)
|
|
1700
|
+
})}
|
|
224
1701
|
</div>
|
|
225
1702
|
</div>
|
|
226
1703
|
)
|
|
227
1704
|
}
|
|
228
1705
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
1706
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1707
|
+
// Tab: Plan
|
|
1708
|
+
// ═══════════════════════════════════════════════════════════════
|
|
232
1709
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
1710
|
+
function PlanTab() {
|
|
1711
|
+
const [content, setContent] = useState(null)
|
|
1712
|
+
const [loading, setLoading] = useState(true)
|
|
236
1713
|
|
|
237
|
-
|
|
238
|
-
fetch("/api/
|
|
1714
|
+
useEffect(() => {
|
|
1715
|
+
fetch("/api/plan")
|
|
239
1716
|
.then(r => r.json())
|
|
240
|
-
.then(
|
|
241
|
-
.catch(() =>
|
|
1717
|
+
.then(d => { setContent(d.content || DEMO_PLAN); setLoading(false) })
|
|
1718
|
+
.catch(() => { setContent(DEMO_PLAN); setLoading(false) })
|
|
1719
|
+
}, [])
|
|
242
1720
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
1721
|
+
if (loading) return <p className="p-4 color-fg-muted">Loading plan...</p>
|
|
1722
|
+
if (!content) return <p className="p-4 color-fg-muted">No plan.md found. Run /plan to create one.</p>
|
|
1723
|
+
|
|
1724
|
+
const lines = content.split("\n")
|
|
1725
|
+
const elements = []
|
|
1726
|
+
let inCode = false, codeBlock = [], codeLang = ""
|
|
1727
|
+
|
|
1728
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1729
|
+
const line = lines[i]
|
|
1730
|
+
if (line.startsWith("```")) {
|
|
1731
|
+
if (inCode) {
|
|
1732
|
+
elements.push(<pre key={`c${i}`}><code className={codeLang ? `language-${codeLang}` : ""}>{codeBlock.join("\n")}</code></pre>)
|
|
1733
|
+
codeBlock = []; inCode = false; codeLang = ""
|
|
1734
|
+
} else { inCode = true; codeLang = line.slice(3).trim() }
|
|
1735
|
+
continue
|
|
1736
|
+
}
|
|
1737
|
+
if (inCode) { codeBlock.push(line); continue }
|
|
1738
|
+
if (line.startsWith("# ")) elements.push(<h1 key={i}>{renderInline(line.slice(2))}</h1>)
|
|
1739
|
+
else if (line.startsWith("## ")) elements.push(<h2 key={i}>{renderInline(line.slice(3))}</h2>)
|
|
1740
|
+
else if (line.startsWith("### ")) elements.push(<h3 key={i}>{renderInline(line.slice(4))}</h3>)
|
|
1741
|
+
else if (line.startsWith("- ") || line.startsWith("* ")) elements.push(<li key={i}>{renderInline(line.slice(2))}</li>)
|
|
1742
|
+
else if (line.trim() !== "") elements.push(<p key={i}>{renderInline(line)}</p>)
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
return <div className="markdown-body markdown-compact p-4">{elements}</div>
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1749
|
+
// Tab: Code (GitHub-style inline file viewer)
|
|
1750
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1751
|
+
|
|
1752
|
+
function MarkdownPreview({ content }) {
|
|
1753
|
+
const lines = content.split("\n")
|
|
1754
|
+
const elements = []
|
|
1755
|
+
let inCode = false, codeBlock = [], codeLang = ""
|
|
1756
|
+
let listItems = [], listKey = 0
|
|
1757
|
+
|
|
1758
|
+
const flushList = () => {
|
|
1759
|
+
if (listItems.length) {
|
|
1760
|
+
elements.push(<ul key={`ul${listKey}`}>{listItems}</ul>)
|
|
1761
|
+
listItems = []
|
|
273
1762
|
}
|
|
1763
|
+
}
|
|
274
1764
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
1765
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1766
|
+
const line = lines[i]
|
|
1767
|
+
if (line.startsWith("```")) {
|
|
1768
|
+
if (inCode) {
|
|
1769
|
+
flushList()
|
|
1770
|
+
const lang = codeLang || undefined
|
|
1771
|
+
let html = escapeHtml(codeBlock.join("\n"))
|
|
1772
|
+
if (window.hljs && lang) {
|
|
1773
|
+
try { html = window.hljs.highlight(codeBlock.join("\n"), { language: lang }).value } catch {}
|
|
1774
|
+
}
|
|
1775
|
+
elements.push(<pre key={`c${i}`}><code className={lang ? `language-${lang} hljs` : ""} dangerouslySetInnerHTML={{ __html: html }} /></pre>)
|
|
1776
|
+
codeBlock = []; inCode = false; codeLang = ""
|
|
1777
|
+
} else { inCode = true; codeLang = line.slice(3).trim() }
|
|
1778
|
+
continue
|
|
278
1779
|
}
|
|
1780
|
+
if (inCode) { codeBlock.push(line); continue }
|
|
1781
|
+
if (line.startsWith("# ")) { flushList(); elements.push(<h1 key={i}>{renderInline(line.slice(2))}</h1>) }
|
|
1782
|
+
else if (line.startsWith("## ")) { flushList(); elements.push(<h2 key={i}>{renderInline(line.slice(3))}</h2>) }
|
|
1783
|
+
else if (line.startsWith("### ")) { flushList(); elements.push(<h3 key={i}>{renderInline(line.slice(4))}</h3>) }
|
|
1784
|
+
else if (line.startsWith("#### ")) { flushList(); elements.push(<h4 key={i}>{renderInline(line.slice(5))}</h4>) }
|
|
1785
|
+
else if (line.startsWith("- ") || line.startsWith("* ")) { listItems.push(<li key={i}>{renderInline(line.slice(2))}</li>); listKey = i }
|
|
1786
|
+
else if (/^\d+\.\s/.test(line)) { listItems.push(<li key={i}>{renderInline(line.replace(/^\d+\.\s/, ""))}</li>); listKey = i }
|
|
1787
|
+
else if (line.startsWith("> ")) { flushList(); elements.push(<blockquote key={i}><p>{renderInline(line.slice(2))}</p></blockquote>) }
|
|
1788
|
+
else if (line.startsWith("---") || line.startsWith("***")) { flushList(); elements.push(<hr key={i} />) }
|
|
1789
|
+
else if (line.trim() !== "") { flushList(); elements.push(<p key={i}>{renderInline(line)}</p>) }
|
|
1790
|
+
else { flushList() }
|
|
1791
|
+
}
|
|
1792
|
+
flushList()
|
|
1793
|
+
|
|
1794
|
+
return <div className="markdown-body markdown-compact p-4">{elements}</div>
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function CodeTab() {
|
|
1798
|
+
const [files, setFiles] = useState(null)
|
|
1799
|
+
const [filter, setFilter] = useState("")
|
|
1800
|
+
const [viewing, setViewing] = useState(null)
|
|
1801
|
+
const [currentDir, setCurrentDir] = useState("")
|
|
1802
|
+
const [fileContent, setFileContent] = useState(null)
|
|
1803
|
+
const [hlLines, setHlLines] = useState([])
|
|
1804
|
+
const [fileLoading, setFileLoading] = useState(false)
|
|
1805
|
+
const [viewMode, setViewMode] = useState("code")
|
|
1806
|
+
|
|
1807
|
+
useEffect(() => {
|
|
1808
|
+
fetch("/api/files")
|
|
1809
|
+
.then(r => r.json())
|
|
1810
|
+
.then(d => setFiles(d.files?.length ? d.files : DEMO_FILES))
|
|
1811
|
+
.catch(() => setFiles(DEMO_FILES))
|
|
279
1812
|
}, [])
|
|
280
1813
|
|
|
281
|
-
|
|
282
|
-
|
|
1814
|
+
useEffect(() => {
|
|
1815
|
+
if (!viewing) { setFileContent(null); setHlLines([]); setViewMode("code"); return }
|
|
1816
|
+
setFileLoading(true)
|
|
1817
|
+
const ext = getFileExt(viewing.path)
|
|
1818
|
+
setViewMode(ext === "md" ? "preview" : "code")
|
|
1819
|
+
fetch(`/api/file?path=${encodeURIComponent(viewing.path)}`)
|
|
1820
|
+
.then(r => { if (!r.ok) throw new Error("not found"); return r.json() })
|
|
1821
|
+
.then(d => { setFileContent(d.content ?? ""); setFileLoading(false) })
|
|
1822
|
+
.catch(() => {
|
|
1823
|
+
const demo = DEMO_CONTENT[viewing.path]
|
|
1824
|
+
setFileContent(demo ?? `// ${viewing.path}`)
|
|
1825
|
+
setFileLoading(false)
|
|
1826
|
+
})
|
|
1827
|
+
}, [viewing])
|
|
283
1828
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
1829
|
+
useEffect(() => {
|
|
1830
|
+
if (fileContent == null) return
|
|
1831
|
+
const ext = getFileExt(viewing?.path || "")
|
|
1832
|
+
const lang = EXT_TO_LANG[ext]
|
|
1833
|
+
if (window.hljs && lang) {
|
|
1834
|
+
try {
|
|
1835
|
+
const result = window.hljs.highlight(fileContent, { language: lang })
|
|
1836
|
+
setHlLines(result.value.split("\n"))
|
|
1837
|
+
} catch { setHlLines(fileContent.split("\n").map(escapeHtml)) }
|
|
1838
|
+
} else {
|
|
1839
|
+
setHlLines(fileContent.split("\n").map(escapeHtml))
|
|
1840
|
+
}
|
|
1841
|
+
}, [fileContent])
|
|
1842
|
+
|
|
1843
|
+
if (!files) return <p className="p-4 color-fg-muted">Loading files...</p>
|
|
1844
|
+
|
|
1845
|
+
// File viewer
|
|
1846
|
+
if (viewing) {
|
|
1847
|
+
const ext = getFileExt(viewing.path)
|
|
1848
|
+
const isMd = ext === "md"
|
|
1849
|
+
const badge = FILE_BADGES[ext]
|
|
1850
|
+
return (
|
|
1851
|
+
<div className="px-4 pt-3 pb-4">
|
|
1852
|
+
<div className="d-flex flex-items-center mb-2" style={{ gap: 12 }}>
|
|
1853
|
+
<button className="back-btn" type="button" onClick={() => setViewing(null)}>
|
|
1854
|
+
<BackIcon /> Back
|
|
1855
|
+
</button>
|
|
1856
|
+
</div>
|
|
1857
|
+
<div className="Box">
|
|
1858
|
+
<div className="Box-header d-flex flex-items-center py-2 px-3" style={{ gap: 8 }}>
|
|
1859
|
+
<FileIcon size={16} />
|
|
1860
|
+
<span className="f6 text-bold text-mono">{viewing.path}</span>
|
|
1861
|
+
{badge && <span className="Label" style={{ color: badge.color, borderColor: badge.color + "44", backgroundColor: badge.color + "18" }}>{badge.label}</span>}
|
|
1862
|
+
<span className="flex-1" />
|
|
1863
|
+
{isMd && (
|
|
1864
|
+
<div className="BtnGroup" style={{ marginRight: 8 }}>
|
|
1865
|
+
<button type="button" className={`BtnGroup-item btn btn-sm ${viewMode === "code" ? "selected" : ""}`} onClick={() => setViewMode("code")}>Code</button>
|
|
1866
|
+
<button type="button" className={`BtnGroup-item btn btn-sm ${viewMode === "preview" ? "selected" : ""}`} onClick={() => setViewMode("preview")}>Preview</button>
|
|
1867
|
+
</div>
|
|
1868
|
+
)}
|
|
1869
|
+
<span className="f6 color-fg-muted">{hlLines.length} lines · {formatSize(viewing.size)}</span>
|
|
1870
|
+
</div>
|
|
1871
|
+
{fileLoading ? (
|
|
1872
|
+
<div className="p-4 color-fg-muted"><span className="anim-spin d-inline-block mr-1">↻</span> Loading...</div>
|
|
1873
|
+
) : viewMode === "preview" && isMd && fileContent != null ? (
|
|
1874
|
+
<MarkdownPreview content={fileContent} />
|
|
1875
|
+
) : (
|
|
1876
|
+
<div className="code-view">
|
|
1877
|
+
<table>
|
|
1878
|
+
<tbody>
|
|
1879
|
+
{hlLines.map((html, i) => (
|
|
1880
|
+
<tr key={i}>
|
|
1881
|
+
<td className="ln">{i + 1}</td>
|
|
1882
|
+
<td className="lc" dangerouslySetInnerHTML={{ __html: html || " " }} />
|
|
1883
|
+
</tr>
|
|
1884
|
+
))}
|
|
1885
|
+
</tbody>
|
|
1886
|
+
</table>
|
|
1887
|
+
</div>
|
|
1888
|
+
)}
|
|
1889
|
+
</div>
|
|
1890
|
+
</div>
|
|
1891
|
+
)
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// File tree with breadcrumbs
|
|
1895
|
+
const filtered = filter ? files.filter(f => f.path.toLowerCase().includes(filter.toLowerCase())) : files
|
|
1896
|
+
const entries = filter ? getTreeEntries(filtered, "") : getTreeEntries(files, currentDir)
|
|
1897
|
+
|
|
1898
|
+
// Breadcrumb segments
|
|
1899
|
+
const breadcrumbs = currentDir ? currentDir.replace(/\/$/, "").split("/") : []
|
|
288
1900
|
|
|
289
1901
|
return (
|
|
290
|
-
<div
|
|
291
|
-
<
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
1902
|
+
<div className="px-4 pt-3 pb-4">
|
|
1903
|
+
<input className="form-control width-full mb-2" type="text" placeholder="Filter files..." value={filter} onChange={e => setFilter(e.target.value)} />
|
|
1904
|
+
|
|
1905
|
+
{/* Breadcrumb */}
|
|
1906
|
+
<div className="d-flex flex-items-center f5 mb-2" style={{ gap: 4 }}>
|
|
1907
|
+
<a className={`${currentDir ? "color-fg-accent" : "text-bold color-fg-default"}`} style={{ cursor: "pointer", textDecoration: "none" }} onClick={() => { setCurrentDir(""); setFilter("") }}>
|
|
1908
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style={{ verticalAlign: "text-bottom", marginRight: 4 }}>
|
|
1909
|
+
<path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z" />
|
|
1910
|
+
</svg>
|
|
1911
|
+
root
|
|
1912
|
+
</a>
|
|
1913
|
+
{breadcrumbs.map((seg, idx) => {
|
|
1914
|
+
const path = breadcrumbs.slice(0, idx + 1).join("/") + "/"
|
|
1915
|
+
const isLast = idx === breadcrumbs.length - 1
|
|
1916
|
+
return (
|
|
1917
|
+
<span key={idx} className="d-flex flex-items-center" style={{ gap: 4 }}>
|
|
1918
|
+
<span className="color-fg-muted">/</span>
|
|
1919
|
+
<a className={isLast ? "text-bold color-fg-default" : "color-fg-accent"} style={{ cursor: "pointer", textDecoration: "none" }} onClick={() => setCurrentDir(path)}>{seg}</a>
|
|
1920
|
+
</span>
|
|
1921
|
+
)
|
|
1922
|
+
})}
|
|
1923
|
+
</div>
|
|
1924
|
+
|
|
1925
|
+
<div className="Box">
|
|
1926
|
+
{/* Go up row */}
|
|
1927
|
+
{currentDir && !filter && (
|
|
1928
|
+
<div className="Box-row file-row d-flex flex-items-center px-3" style={{ gap: 8, paddingTop: 8, paddingBottom: 8, cursor: "pointer" }}
|
|
1929
|
+
onClick={() => {
|
|
1930
|
+
const parts = currentDir.replace(/\/$/, "").split("/")
|
|
1931
|
+
parts.pop()
|
|
1932
|
+
setCurrentDir(parts.length ? parts.join("/") + "/" : "")
|
|
1933
|
+
}}>
|
|
1934
|
+
<span className="color-fg-muted">..</span>
|
|
1935
|
+
</div>
|
|
1936
|
+
)}
|
|
1937
|
+
{entries.map(e => {
|
|
1938
|
+
if (e.type === "dir") {
|
|
1939
|
+
return (
|
|
1940
|
+
<div key={e.path} className="Box-row file-row d-flex flex-items-center px-3" style={{ gap: 8, paddingTop: 8, paddingBottom: 8, cursor: "pointer" }} onClick={() => { setCurrentDir(e.path); setFilter("") }}>
|
|
1941
|
+
<FolderIcon />
|
|
1942
|
+
<span className="flex-1 f5 text-bold">{e.name}</span>
|
|
1943
|
+
<span className="f6 color-fg-subtle">{e.count} items</span>
|
|
1944
|
+
</div>
|
|
1945
|
+
)
|
|
1946
|
+
}
|
|
1947
|
+
const ext = getFileExt(e.name)
|
|
1948
|
+
const badge = FILE_BADGES[ext]
|
|
1949
|
+
return (
|
|
1950
|
+
<div key={e.file.path} className="Box-row file-row d-flex flex-items-center px-3" style={{ gap: 8, paddingTop: 8, paddingBottom: 8, cursor: "pointer" }} onClick={() => setViewing(e.file)}>
|
|
1951
|
+
<FileIcon />
|
|
1952
|
+
<span className="flex-1 f5">{e.name}</span>
|
|
1953
|
+
{badge && <span className="Label" style={{ color: badge.color, borderColor: badge.color + "44", backgroundColor: badge.color + "18" }}>{badge.label}</span>}
|
|
1954
|
+
<span className="f6 color-fg-subtle text-right" style={{ minWidth: 48 }}>{formatSize(e.file.size)}</span>
|
|
1955
|
+
</div>
|
|
1956
|
+
)
|
|
1957
|
+
})}
|
|
295
1958
|
</div>
|
|
296
|
-
<p style={styles.progressText}>{done}/{total} tasks done{inProgress > 0 ? `, ${inProgress} in progress` : ""}</p>
|
|
297
|
-
{tasks.tracks && (
|
|
298
|
-
<div style={styles.trackBadges}>
|
|
299
|
-
{tasks.tracks.map(t => (
|
|
300
|
-
<span key={t} style={styles.badge}>{t}</span>
|
|
301
|
-
))}
|
|
302
|
-
</div>
|
|
303
|
-
)}
|
|
304
|
-
<table style={styles.table}>
|
|
305
|
-
<thead>
|
|
306
|
-
<tr>
|
|
307
|
-
<th style={styles.th}>Status</th>
|
|
308
|
-
<th style={styles.th}>Task</th>
|
|
309
|
-
<th style={styles.th}>Type</th>
|
|
310
|
-
<th style={styles.th}>Done When</th>
|
|
311
|
-
</tr>
|
|
312
|
-
</thead>
|
|
313
|
-
<tbody>
|
|
314
|
-
{tasks.tasks?.map(t => (
|
|
315
|
-
<tr key={t.id}>
|
|
316
|
-
<td style={styles.td}>
|
|
317
|
-
<span style={t.status === "done" ? styles.statusDone : t.status === "in_progress" ? styles.statusProgress : styles.statusPending}>
|
|
318
|
-
{t.status === "done" ? "\u2714" : t.status === "in_progress" ? "\u25B6" : "\u00B7"}
|
|
319
|
-
</span>
|
|
320
|
-
</td>
|
|
321
|
-
<td style={styles.td}>{t.name}</td>
|
|
322
|
-
<td style={styles.td}><span style={styles.typeBadge}>{t.type}</span></td>
|
|
323
|
-
<td style={{ ...styles.td, color: "#888", fontSize: "0.8rem" }}>{t.done_when}</td>
|
|
324
|
-
</tr>
|
|
325
|
-
))}
|
|
326
|
-
</tbody>
|
|
327
|
-
</table>
|
|
328
1959
|
</div>
|
|
329
1960
|
)
|
|
330
1961
|
}
|
|
331
1962
|
|
|
332
|
-
|
|
1963
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1964
|
+
// Tab: Commands
|
|
1965
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1966
|
+
|
|
1967
|
+
function CommandsTab({ data }) {
|
|
1968
|
+
const commands = deriveCommands(data)
|
|
333
1969
|
return (
|
|
334
|
-
<div
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
<tbody>
|
|
347
|
-
<tr>
|
|
348
|
-
<td style={styles.td}><code style={styles.code}>plan.md</code></td>
|
|
349
|
-
<td style={styles.td}>/plan wizard</td>
|
|
350
|
-
<td style={styles.td}>All wizards, dashboard</td>
|
|
351
|
-
<td style={styles.td}>Architecture, components, edge cases</td>
|
|
352
|
-
</tr>
|
|
353
|
-
<tr>
|
|
354
|
-
<td style={styles.td}><code style={styles.code}>tasks.json</code></td>
|
|
355
|
-
<td style={styles.td}>Every wizard</td>
|
|
356
|
-
<td style={styles.td}>All wizards, dashboard, hooks</td>
|
|
357
|
-
<td style={styles.td}>Task status, current step, completion criteria</td>
|
|
358
|
-
</tr>
|
|
359
|
-
<tr>
|
|
360
|
-
<td style={styles.td}><code style={styles.code}>CLAUDE.md</code></td>
|
|
361
|
-
<td style={styles.td}>Scaffolder</td>
|
|
362
|
-
<td style={styles.td}>Every wizard on start</td>
|
|
363
|
-
<td style={styles.td}>Project context, commands, constraints</td>
|
|
364
|
-
</tr>
|
|
365
|
-
</tbody>
|
|
366
|
-
</table>
|
|
1970
|
+
<div className="markdown-body markdown-compact p-4">
|
|
1971
|
+
{Object.entries(commands).map(([section, cmds]) => (
|
|
1972
|
+
<div key={section} className="mb-4">
|
|
1973
|
+
<h3>{section}</h3>
|
|
1974
|
+
{cmds.map(c => (
|
|
1975
|
+
<div key={c.cmd} className="mb-2">
|
|
1976
|
+
<CodeBlock code={`$ ${c.cmd}`} lang="bash" />
|
|
1977
|
+
<p className="f6 color-fg-muted mt-1">{c.desc}</p>
|
|
1978
|
+
</div>
|
|
1979
|
+
))}
|
|
1980
|
+
</div>
|
|
1981
|
+
))}
|
|
367
1982
|
</div>
|
|
368
1983
|
)
|
|
369
1984
|
}
|
|
370
1985
|
|
|
371
|
-
|
|
1986
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1987
|
+
// Tab: Skills
|
|
1988
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1989
|
+
|
|
1990
|
+
function SkillsTab() {
|
|
372
1991
|
return (
|
|
373
|
-
<div
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
1992
|
+
<div className="markdown-body markdown-compact p-4">
|
|
1993
|
+
{Object.entries(SKILLS).map(([section, skills]) => (
|
|
1994
|
+
<div key={section} className="mb-4">
|
|
1995
|
+
<h3>{section}</h3>
|
|
1996
|
+
<table>
|
|
1997
|
+
<thead><tr><th>Command</th><th>Type</th><th>Description</th></tr></thead>
|
|
1998
|
+
<tbody>
|
|
1999
|
+
{skills.map(s => {
|
|
2000
|
+
const c = BADGE_COLORS[s.badge] || "#6b7280"
|
|
2001
|
+
return (
|
|
2002
|
+
<tr key={s.cmd}>
|
|
2003
|
+
<td><code className="text-bold">{s.cmd}</code></td>
|
|
2004
|
+
<td><span className="skill-badge" style={{ backgroundColor: c }}>{s.badge}</span></td>
|
|
2005
|
+
<td>{s.desc}</td>
|
|
2006
|
+
</tr>
|
|
2007
|
+
)
|
|
2008
|
+
})}
|
|
2009
|
+
</tbody>
|
|
2010
|
+
</table>
|
|
2011
|
+
</div>
|
|
2012
|
+
))}
|
|
2013
|
+
<div className="Box color-bg-subtle p-3 f6 color-fg-muted mt-3">
|
|
2014
|
+
Type any skill name in the Claude Code conversation to invoke it. Skills with arguments: <code>/build "feature name"</code>, <code>/test test/aos.test.js</code>
|
|
2015
|
+
</div>
|
|
378
2016
|
</div>
|
|
379
2017
|
)
|
|
380
2018
|
}
|
|
381
2019
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
2020
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2021
|
+
// Tab: Deploy
|
|
2022
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2023
|
+
|
|
2024
|
+
const DEPLOY_STEPS = [
|
|
2025
|
+
{
|
|
2026
|
+
title: "1. Generate Wallet",
|
|
2027
|
+
desc: "Create an Arweave JWK wallet for signing deploys. Saved to .wallet.json (gitignored).",
|
|
2028
|
+
cmds: [
|
|
2029
|
+
{ cmd: "yarn keygen", desc: "Generate .wallet.json in project root" },
|
|
2030
|
+
],
|
|
2031
|
+
},
|
|
2032
|
+
{
|
|
2033
|
+
title: "2. Test Lua Scripts",
|
|
2034
|
+
desc: "Run your Lua scripts against in-memory AOS to verify correctness before deploying.",
|
|
2035
|
+
cmds: [
|
|
2036
|
+
{ cmd: "yarn test", desc: "Run all unit tests (in-memory AOS)" },
|
|
2037
|
+
{ cmd: "yarn test test/<name>.test.js", desc: "Run a specific test file" },
|
|
2038
|
+
],
|
|
393
2039
|
},
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
2040
|
+
{
|
|
2041
|
+
title: "3. Deploy",
|
|
2042
|
+
desc: "yarn deploy runs scripts/deploy.js — it reads all src/*.lua files (or specific files you pass), spawns a process for each, and loads the Lua code. On testnet this uses Eval (sends the source as a message with Action: \"Eval\"). On HyperBEAM it uses ao.deploy() which bundles spawn + source in one call.",
|
|
2043
|
+
cmds: [
|
|
2044
|
+
{ cmd: "yarn deploy", desc: "Deploy all src/*.lua to AO testnet" },
|
|
2045
|
+
{ cmd: "yarn deploy src/token.lua", desc: "Deploy a single script to testnet" },
|
|
2046
|
+
{ cmd: "yarn deploy --local-hb", desc: "Deploy to local HyperBEAM (genesis-wasm)" },
|
|
2047
|
+
{ cmd: "yarn deploy --local-hb --lua", desc: "Deploy to local HyperBEAM (Lua mode — faster, no receive())" },
|
|
2048
|
+
{ cmd: "yarn deploy --mainnet", desc: "Deploy to remote HyperBEAM (push-1.forward.computer)" },
|
|
2049
|
+
{ cmd: "yarn deploy --mainnet --lua", desc: "Deploy to remote HyperBEAM (Lua mode)" },
|
|
2050
|
+
],
|
|
401
2051
|
},
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
fontSize: "0.85rem", lineHeight: 1.5, color: "#d4d4d4", margin: "0.5rem 0",
|
|
2052
|
+
{
|
|
2053
|
+
title: "4. Verify on Explorers",
|
|
2054
|
+
desc: "After deploying, the CLI prints each process ID. Use these explorers to inspect your processes:",
|
|
2055
|
+
cmds: [
|
|
2056
|
+
{ cmd: "https://aolink.ar.io/#/entity/<PROCESS_ID>", desc: "aolink — AOS process explorer (messages, state, tags)" },
|
|
2057
|
+
{ cmd: "https://lunar.ar.io/#/process/<PROCESS_ID>", desc: "lunar — HyperBEAM explorer (slots, devices, compute logs)" },
|
|
2058
|
+
],
|
|
410
2059
|
},
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
2060
|
+
]
|
|
2061
|
+
|
|
2062
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2063
|
+
// Tab: README
|
|
2064
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2065
|
+
|
|
2066
|
+
const DEMO_README = `# Token Transfer App
|
|
2067
|
+
|
|
2068
|
+
A decentralized token transfer application built on [AO](https://ao.arweave.dev) and [HyperBEAM](https://docs.wao.eco). Users can mint tokens, transfer them between Arweave wallets, and query balances — all running as permanent, verifiable processes on the AO computer.
|
|
2069
|
+
|
|
2070
|
+
## What It Does
|
|
2071
|
+
|
|
2072
|
+
- **Mint** — Create new tokens with an initial supply assigned to the minting wallet
|
|
2073
|
+
- **Transfer** — Send tokens between any two Arweave addresses with atomic balance updates
|
|
2074
|
+
- **Balance** — Query the balance of any address via dry-run (no gas, no state mutation)
|
|
2075
|
+
- **Registry** — Register tokens for discoverability so other apps can find and interact with them
|
|
2076
|
+
- **Frontend** — React SPA with ArConnect wallet integration for browser-based transfers
|
|
2077
|
+
|
|
2078
|
+
## How It Works
|
|
2079
|
+
|
|
2080
|
+
AOS scripts run as permanent processes on the AO network. Each process has its own state (balances, registry entries) and responds to messages. The frontend connects via ArConnect and sends signed messages to the AOS process.
|
|
2081
|
+
|
|
2082
|
+
On HyperBEAM, the same scripts deploy as genesis-wasm devices with slot-based message scheduling. This gives you a local development environment identical to production.
|
|
2083
|
+
|
|
2084
|
+
## Project Structure
|
|
2085
|
+
|
|
2086
|
+
\`\`\`
|
|
2087
|
+
src/token.lua # Token handler — mint, transfer, balance
|
|
2088
|
+
src/registry.lua # Token registry — register, list, query
|
|
2089
|
+
test/aos.test.js # In-memory AOS unit tests
|
|
2090
|
+
test/token.test.js # Token-specific unit tests
|
|
2091
|
+
test/registry.test.js # Registry unit tests
|
|
2092
|
+
test/hyperbeam-token.test.js # HyperBEAM integration tests
|
|
2093
|
+
test/token-device.test.js # Device HTTP integration tests
|
|
2094
|
+
frontend/src/App.jsx # Main app with wallet connect
|
|
2095
|
+
frontend/src/components/ # TransferForm, BalanceDisplay, etc.
|
|
2096
|
+
frontend/src/hooks/ # useToken, useWallet
|
|
2097
|
+
scripts/deploy.js # Multi-target deploy script
|
|
2098
|
+
\`\`\`
|
|
2099
|
+
|
|
2100
|
+
## Setup
|
|
2101
|
+
|
|
2102
|
+
\`\`\`bash
|
|
2103
|
+
yarn install
|
|
2104
|
+
yarn keygen # generate .wallet.json (Arweave JWK)
|
|
2105
|
+
\`\`\`
|
|
2106
|
+
|
|
2107
|
+
## Test
|
|
2108
|
+
|
|
2109
|
+
\`\`\`bash
|
|
2110
|
+
yarn test test/aos.test.js # in-memory AOS — fast, no server
|
|
2111
|
+
yarn test test/token.test.js # token unit tests
|
|
2112
|
+
yarn test test/registry.test.js # registry unit tests
|
|
2113
|
+
yarn test test/hyperbeam.test.js # HyperBEAM integration (requires local HB)
|
|
2114
|
+
cd frontend && npm run test:unit # frontend vitest
|
|
2115
|
+
cd frontend && npm run test:e2e # Playwright E2E with live backend
|
|
2116
|
+
\`\`\`
|
|
2117
|
+
|
|
2118
|
+
## Deploy
|
|
2119
|
+
|
|
2120
|
+
\`\`\`bash
|
|
2121
|
+
yarn deploy # all src/*.lua to AO testnet
|
|
2122
|
+
yarn deploy src/token.lua # single script to testnet
|
|
2123
|
+
yarn deploy --local-hb # local HyperBEAM (genesis-wasm)
|
|
2124
|
+
yarn deploy --local-hb --lua # local HyperBEAM (Lua mode, faster)
|
|
2125
|
+
yarn deploy --mainnet # remote HyperBEAM (push-1.forward.computer)
|
|
2126
|
+
\`\`\`
|
|
2127
|
+
|
|
2128
|
+
Each script gets its own AOS process. On testnet, the source is loaded via Eval message. On HyperBEAM, spawn and source are bundled in a single deploy call.
|
|
2129
|
+
|
|
2130
|
+
## AOS Script API
|
|
2131
|
+
|
|
2132
|
+
### Token (\`src/token.lua\`)
|
|
2133
|
+
|
|
2134
|
+
- \`Action: "Mint"\` — Mint tokens. Tags: \`Quantity\`
|
|
2135
|
+
- \`Action: "Transfer"\` — Transfer tokens. Tags: \`Recipient\`, \`Quantity\`
|
|
2136
|
+
- \`Action: "Balance"\` — Query balance. Tags: \`Target\` (optional, defaults to sender)
|
|
2137
|
+
- \`Action: "Balances"\` — Returns all balances as JSON
|
|
2138
|
+
|
|
2139
|
+
### Registry (\`src/registry.lua\`)
|
|
2140
|
+
|
|
2141
|
+
- \`Action: "Register"\` — Register a token. Tags: \`ProcessId\`, \`Name\`, \`Ticker\`
|
|
2142
|
+
- \`Action: "Deregister"\` — Remove a token. Tags: \`ProcessId\`
|
|
2143
|
+
- \`Action: "List"\` — List all registered tokens
|
|
2144
|
+
- \`Action: "Info"\` — Query token metadata. Tags: \`ProcessId\`
|
|
2145
|
+
|
|
2146
|
+
## Explorers
|
|
2147
|
+
|
|
2148
|
+
After deploying, the CLI prints each process ID. Use these to inspect your processes:
|
|
2149
|
+
|
|
2150
|
+
- [aolink](https://aolink.ar.io) — AOS process explorer (messages, state, tags)
|
|
2151
|
+
- [lunar](https://lunar.ar.io) — HyperBEAM explorer (slots, devices, compute logs)
|
|
2152
|
+
|
|
2153
|
+
## Built With
|
|
2154
|
+
|
|
2155
|
+
- [WAO](https://docs.wao.eco) — SDK for AO and HyperBEAM
|
|
2156
|
+
- [AOS](https://ao.arweave.dev) — Lua processes on the AO computer
|
|
2157
|
+
- [HyperBEAM](https://github.com/permaweb/HyperBEAM) — Erlang runtime for AO
|
|
2158
|
+
- [ArConnect](https://www.arconnect.io) — Arweave wallet browser extension
|
|
2159
|
+
`
|
|
2160
|
+
|
|
2161
|
+
function ReadmeTab() {
|
|
2162
|
+
const [content, setContent] = useState(null)
|
|
2163
|
+
const [loading, setLoading] = useState(true)
|
|
2164
|
+
|
|
2165
|
+
useEffect(() => {
|
|
2166
|
+
fetch("/api/readme")
|
|
2167
|
+
.then(r => r.json())
|
|
2168
|
+
.then(d => { setContent(d.content || DEMO_README); setLoading(false) })
|
|
2169
|
+
.catch(() => { setContent(DEMO_README); setLoading(false) })
|
|
2170
|
+
}, [])
|
|
2171
|
+
|
|
2172
|
+
if (loading) return <p className="p-4 color-fg-muted">Loading README...</p>
|
|
2173
|
+
if (!content) return <p className="p-4 color-fg-muted">No README.md yet. Run /readme to generate one.</p>
|
|
2174
|
+
|
|
2175
|
+
const lines = content.split("\n")
|
|
2176
|
+
const elements = []
|
|
2177
|
+
let inCode = false, codeBlock = [], codeLang = ""
|
|
2178
|
+
|
|
2179
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2180
|
+
const line = lines[i]
|
|
2181
|
+
if (line.startsWith("```")) {
|
|
2182
|
+
if (inCode) {
|
|
2183
|
+
elements.push(<CodeBlock key={i} code={codeBlock.join("\n")} lang={codeLang} />)
|
|
2184
|
+
codeBlock = []; inCode = false; codeLang = ""
|
|
2185
|
+
} else {
|
|
2186
|
+
inCode = true; codeLang = line.slice(3).trim()
|
|
2187
|
+
}
|
|
2188
|
+
} else if (inCode) {
|
|
2189
|
+
codeBlock.push(line)
|
|
2190
|
+
} else if (line.startsWith("# ")) {
|
|
2191
|
+
elements.push(<h1 key={i}>{line.slice(2)}</h1>)
|
|
2192
|
+
} else if (line.startsWith("## ")) {
|
|
2193
|
+
elements.push(<h2 key={i}>{line.slice(3)}</h2>)
|
|
2194
|
+
} else if (line.startsWith("### ")) {
|
|
2195
|
+
elements.push(<h3 key={i}>{line.slice(4)}</h3>)
|
|
2196
|
+
} else if (line.startsWith("- ")) {
|
|
2197
|
+
const parts = []
|
|
2198
|
+
let j = i
|
|
2199
|
+
while (j < lines.length && lines[j].startsWith("- ")) {
|
|
2200
|
+
parts.push(<li key={j}>{renderInline(lines[j].slice(2))}</li>)
|
|
2201
|
+
j++
|
|
2202
|
+
}
|
|
2203
|
+
elements.push(<ul key={i}>{parts}</ul>)
|
|
2204
|
+
i = j - 1
|
|
2205
|
+
} else if (line.trim()) {
|
|
2206
|
+
elements.push(<p key={i}>{renderInline(line)}</p>)
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
return <div className="markdown-body markdown-compact p-4">{elements}</div>
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2214
|
+
// Tab: Deploy
|
|
2215
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2216
|
+
|
|
2217
|
+
function DeployTab() {
|
|
2218
|
+
const [info, setInfo] = useState(null)
|
|
2219
|
+
|
|
2220
|
+
useEffect(() => {
|
|
2221
|
+
fetch("/api/deploy").then(r => r.json()).then(setInfo).catch(() => setInfo(null))
|
|
2222
|
+
}, [])
|
|
2223
|
+
|
|
2224
|
+
const hbPort = info?.hyperbeam?.port || "10001"
|
|
2225
|
+
|
|
2226
|
+
return (
|
|
2227
|
+
<div className="markdown-body markdown-compact p-4">
|
|
2228
|
+
<div className="d-flex flex-items-center flex-justify-between mb-3">
|
|
2229
|
+
<h2 style={{ margin: 0, border: "none", paddingBottom: 0 }}>Deploy</h2>
|
|
2230
|
+
<span className="f6 color-fg-muted d-flex flex-items-center" style={{ gap: 8 }}>
|
|
2231
|
+
<span className="d-flex flex-items-center" style={{ gap: 4 }}>
|
|
2232
|
+
<span className="d-inline-block" style={{ width: 8, height: 8, borderRadius: "50%", background: info?.wallet ? "#1a7f37" : "#cf222e" }} />
|
|
2233
|
+
{info?.wallet ? "wallet found" : "no wallet"}
|
|
2234
|
+
</span>
|
|
2235
|
+
·
|
|
2236
|
+
<span className="d-flex flex-items-center" style={{ gap: 4 }}>
|
|
2237
|
+
<span className="d-inline-block" style={{ width: 8, height: 8, borderRadius: "50%", background: info?.hyperbeam?.configured ? "#1a7f37" : "#bf8700" }} />
|
|
2238
|
+
{info?.hyperbeam?.configured ? `HyperBEAM :${hbPort}` : "HyperBEAM not configured"}
|
|
2239
|
+
</span>
|
|
2240
|
+
</span>
|
|
2241
|
+
</div>
|
|
2242
|
+
|
|
2243
|
+
{DEPLOY_STEPS.map(step => (
|
|
2244
|
+
<div key={step.title} className="mb-4">
|
|
2245
|
+
<h3>{step.title}</h3>
|
|
2246
|
+
<p className="f6 color-fg-muted mb-2">{step.desc}</p>
|
|
2247
|
+
{step.cmds.map(c => (
|
|
2248
|
+
<div key={c.cmd} className="mb-2">
|
|
2249
|
+
<CodeBlock code={c.cmd.startsWith("http") ? c.cmd : `$ ${c.cmd}`} lang={c.cmd.startsWith("http") ? "" : "bash"} />
|
|
2250
|
+
<p className="f6 color-fg-muted mt-1">{c.desc}</p>
|
|
2251
|
+
</div>
|
|
2252
|
+
))}
|
|
2253
|
+
</div>
|
|
2254
|
+
))}
|
|
2255
|
+
|
|
2256
|
+
<h3>How Multi-Script Deploy Works</h3>
|
|
2257
|
+
<p className="f6 color-fg-muted mb-2">
|
|
2258
|
+
When you run <code>yarn deploy</code> without specifying files, <code>scripts/deploy.js</code> reads all <code>src/*.lua</code> files and deploys each one as a separate AOS process:
|
|
2259
|
+
</p>
|
|
2260
|
+
<ol className="f6 color-fg-muted">
|
|
2261
|
+
<li>Loads your wallet from <code>.wallet.json</code></li>
|
|
2262
|
+
<li>For each <code>.lua</code> file, spawns a new AOS process</li>
|
|
2263
|
+
<li>On <strong>testnet</strong>: sends the Lua source as an <code>Eval</code> message (Action: "Eval", data = source code)</li>
|
|
2264
|
+
<li>On <strong>HyperBEAM</strong>: calls <code>ao.deploy()</code> which bundles spawn + source in one HTTP call</li>
|
|
2265
|
+
<li>Prints the process ID for each deployed script</li>
|
|
2266
|
+
</ol>
|
|
2267
|
+
|
|
2268
|
+
<h3>Remote HyperBEAM Nodes</h3>
|
|
2269
|
+
<p className="f6 color-fg-muted mb-2">Use <code>push-1</code> through <code>push-10</code> for full compute. <code>push.forward.computer</code> is push-only (no compute).</p>
|
|
2270
|
+
|
|
2271
|
+
</div>
|
|
2272
|
+
)
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2276
|
+
// Main App
|
|
2277
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2278
|
+
|
|
2279
|
+
export default function App() {
|
|
2280
|
+
const [data, setData] = useState(DEMO_DATA)
|
|
2281
|
+
const [tab, setTab] = useState("tasks")
|
|
2282
|
+
const [dark, setDark] = useState(false)
|
|
2283
|
+
const [connected, setConnected] = useState(false)
|
|
2284
|
+
|
|
2285
|
+
useEffect(() => {
|
|
2286
|
+
const mode = dark ? "dark" : "light"
|
|
2287
|
+
document.documentElement.setAttribute("data-color-mode", mode)
|
|
2288
|
+
const ls = document.getElementById("hljs-light")
|
|
2289
|
+
const ds = document.getElementById("hljs-dark")
|
|
2290
|
+
if (ls) ls.disabled = dark
|
|
2291
|
+
if (ds) ds.disabled = !dark
|
|
2292
|
+
}, [dark])
|
|
2293
|
+
|
|
2294
|
+
useEffect(() => {
|
|
2295
|
+
let interval = null
|
|
2296
|
+
let es = null
|
|
2297
|
+
|
|
2298
|
+
fetch("/api/progress").then(r => r.json()).then(d => { if (d?.feature) { setData(d); setConnected(true) } }).catch(() => {})
|
|
2299
|
+
|
|
2300
|
+
try {
|
|
2301
|
+
es = new EventSource("/api/events")
|
|
2302
|
+
es.addEventListener("progress", e => {
|
|
2303
|
+
try { const d = JSON.parse(e.data); if (d?.feature) setData(d) } catch {}
|
|
2304
|
+
})
|
|
2305
|
+
es.onopen = () => setConnected(true)
|
|
2306
|
+
es.onerror = () => {
|
|
2307
|
+
setConnected(false); es.close(); es = null
|
|
2308
|
+
if (!interval) interval = setInterval(() => {
|
|
2309
|
+
fetch("/api/progress").then(r => r.json()).then(d => { if (d?.feature) setData(d) }).catch(() => {})
|
|
2310
|
+
}, 3000)
|
|
2311
|
+
}
|
|
2312
|
+
} catch {
|
|
2313
|
+
interval = setInterval(() => {
|
|
2314
|
+
fetch("/api/progress").then(r => r.json()).then(d => { if (d?.feature) setData(d) }).catch(() => {})
|
|
2315
|
+
}, 3000)
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
return () => { if (es) es.close(); if (interval) clearInterval(interval) }
|
|
2319
|
+
}, [])
|
|
2320
|
+
|
|
2321
|
+
return (
|
|
2322
|
+
<div style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}>
|
|
2323
|
+
<Header dark={dark} setDark={setDark} connected={connected} />
|
|
2324
|
+
<div style={{ maxWidth: 1012, margin: "0 auto", flex: 1, width: "100%" }}>
|
|
2325
|
+
<ProgressSection data={data} />
|
|
2326
|
+
<TrackCards data={data} dark={dark} />
|
|
2327
|
+
<TabBar active={tab} setActive={setTab} taskCount={data?.tasks?.length || 0} />
|
|
2328
|
+
{tab === "tasks" && <TasksTab data={data} />}
|
|
2329
|
+
{tab === "tests" && <TestsTab />}
|
|
2330
|
+
{tab === "plan" && <PlanTab />}
|
|
2331
|
+
{tab === "code" && <CodeTab />}
|
|
2332
|
+
{tab === "readme" && <ReadmeTab />}
|
|
2333
|
+
{tab === "commands" && <CommandsTab data={data} />}
|
|
2334
|
+
{tab === "skills" && <SkillsTab />}
|
|
2335
|
+
{tab === "deploy" && <DeployTab />}
|
|
2336
|
+
</div>
|
|
2337
|
+
<footer style={{ marginTop: 40 }}>
|
|
2338
|
+
<div className="d-flex flex-items-center flex-justify-center flex-wrap f6 color-fg-muted" style={{ maxWidth: 1012, margin: "0 auto", padding: "24px 16px", gap: 16 }}>
|
|
2339
|
+
<span>© {new Date().getFullYear()} <a href="https://docs.wao.eco/" className="color-fg-muted" style={{ textDecoration: "none" }}>WizardAO</a></span>
|
|
2340
|
+
<span>·</span>
|
|
2341
|
+
<a href="https://docs.wao.eco/add/overview" className="color-fg-muted" style={{ textDecoration: "none" }}>Docs</a>
|
|
2342
|
+
<span>·</span>
|
|
2343
|
+
<a href="https://aolink.ar.io" className="color-fg-muted" style={{ textDecoration: "none" }}>aolink</a>
|
|
2344
|
+
<span>·</span>
|
|
2345
|
+
<a href="https://lunar.ar.io" className="color-fg-muted" style={{ textDecoration: "none" }}>lunar</a>
|
|
2346
|
+
</div>
|
|
2347
|
+
</footer>
|
|
2348
|
+
</div>
|
|
2349
|
+
)
|
|
425
2350
|
}
|