summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-09-18 10:59:01 -0400
committerFrank <[email protected]>2025-09-18 10:59:01 -0400
commit4ceabdffa07b1af8d99eb73622a4d549d99ec6d2 (patch)
tree72e2ae62084a9e24cc76caffbd1f30dafc69ea56 /packages/console/app
parentc87480cf931a6f8f8b55552558ef521f1918b578 (diff)
downloadopencode-4ceabdffa07b1af8d99eb73622a4d549d99ec6d2.tar.gz
opencode-4ceabdffa07b1af8d99eb73622a4d549d99ec6d2.zip
wip: zen
Diffstat (limited to 'packages/console/app')
-rw-r--r--packages/console/app/.gitignore28
-rw-r--r--packages/console/app/.opencode/agent/css.md149
-rw-r--r--packages/console/app/README.md32
-rw-r--r--packages/console/app/app.config.ts23
-rw-r--r--packages/console/app/package.json25
-rw-r--r--packages/console/app/public/favicon.svg5
-rw-r--r--packages/console/app/public/robots.txt5
-rw-r--r--packages/console/app/public/social-share.pngbin0 -> 17520 bytes
-rw-r--r--packages/console/app/public/theme.json182
-rw-r--r--packages/console/app/src/app.css1
-rw-r--r--packages/console/app/src/app.tsx23
-rw-r--r--packages/console/app/src/asset/lander/check.svg2
-rw-r--r--packages/console/app/src/asset/lander/copy.svg2
-rw-r--r--packages/console/app/src/asset/lander/screenshot-github.pngbin0 -> 924094 bytes
-rw-r--r--packages/console/app/src/asset/lander/screenshot-splash.pngbin0 -> 467281 bytes
-rw-r--r--packages/console/app/src/asset/lander/screenshot-vscode.pngbin0 -> 1022418 bytes
-rw-r--r--packages/console/app/src/asset/lander/screenshot.pngbin0 -> 606051 bytes
-rw-r--r--packages/console/app/src/asset/logo-ornate-dark.svg19
-rw-r--r--packages/console/app/src/asset/logo-ornate-light.svg18
-rw-r--r--packages/console/app/src/asset/logo.svg12
-rw-r--r--packages/console/app/src/component/icon.tsx82
-rw-r--r--packages/console/app/src/component/workspace/billing-section.module.css114
-rw-r--r--packages/console/app/src/component/workspace/billing-section.tsx193
-rw-r--r--packages/console/app/src/component/workspace/common.tsx25
-rw-r--r--packages/console/app/src/component/workspace/key-section.module.css172
-rw-r--r--packages/console/app/src/component/workspace/key-section.tsx182
-rw-r--r--packages/console/app/src/component/workspace/monthly-limit-section.module.css102
-rw-r--r--packages/console/app/src/component/workspace/monthly-limit-section.tsx139
-rw-r--r--packages/console/app/src/component/workspace/new-user-section.module.css163
-rw-r--r--packages/console/app/src/component/workspace/new-user-section.tsx97
-rw-r--r--packages/console/app/src/component/workspace/payment-section.module.css72
-rw-r--r--packages/console/app/src/component/workspace/payment-section.tsx113
-rw-r--r--packages/console/app/src/component/workspace/usage-section.module.css88
-rw-r--r--packages/console/app/src/component/workspace/usage-section.tsx128
-rw-r--r--packages/console/app/src/context/auth.session.ts23
-rw-r--r--packages/console/app/src/context/auth.ts83
-rw-r--r--packages/console/app/src/context/auth.withActor.ts7
-rw-r--r--packages/console/app/src/entry-client.tsx4
-rw-r--r--packages/console/app/src/entry-server.tsx28
-rw-r--r--packages/console/app/src/global.d.ts1
-rw-r--r--packages/console/app/src/middleware.ts5
-rw-r--r--packages/console/app/src/routes/[...404].css130
-rw-r--r--packages/console/app/src/routes/[...404].tsx38
-rw-r--r--packages/console/app/src/routes/auth/authorize.ts7
-rw-r--r--packages/console/app/src/routes/auth/callback.ts31
-rw-r--r--packages/console/app/src/routes/auth/index.ts13
-rw-r--r--packages/console/app/src/routes/debug/index.ts13
-rw-r--r--packages/console/app/src/routes/discord.ts5
-rw-r--r--packages/console/app/src/routes/docs/[...path].ts20
-rw-r--r--packages/console/app/src/routes/docs/index.ts20
-rw-r--r--packages/console/app/src/routes/index.css504
-rw-r--r--packages/console/app/src/routes/index.tsx183
-rw-r--r--packages/console/app/src/routes/s/[id].ts20
-rw-r--r--packages/console/app/src/routes/stripe/webhook.ts98
-rw-r--r--packages/console/app/src/routes/workspace.css127
-rw-r--r--packages/console/app/src/routes/workspace.tsx67
-rw-r--r--packages/console/app/src/routes/workspace/[id].css115
-rw-r--r--packages/console/app/src/routes/workspace/[id].tsx50
-rw-r--r--packages/console/app/src/routes/workspace/index.tsx0
-rw-r--r--packages/console/app/src/routes/zen/handler.ts594
-rw-r--r--packages/console/app/src/routes/zen/v1/chat/completions.ts54
-rw-r--r--packages/console/app/src/routes/zen/v1/messages.ts61
-rw-r--r--packages/console/app/src/routes/zen/v1/responses.ts52
-rw-r--r--packages/console/app/src/style/base.css9
-rw-r--r--packages/console/app/src/style/component/button.css102
-rw-r--r--packages/console/app/src/style/index.css8
-rw-r--r--packages/console/app/src/style/reset.css76
-rw-r--r--packages/console/app/src/style/token/color.css91
-rw-r--r--packages/console/app/src/style/token/font.css20
-rw-r--r--packages/console/app/src/style/token/space.css46
-rw-r--r--packages/console/app/sst-env.d.ts9
-rw-r--r--packages/console/app/tsconfig.json21
72 files changed, 4931 insertions, 0 deletions
diff --git a/packages/console/app/.gitignore b/packages/console/app/.gitignore
new file mode 100644
index 000000000..751513ce1
--- /dev/null
+++ b/packages/console/app/.gitignore
@@ -0,0 +1,28 @@
+dist
+.wrangler
+.output
+.vercel
+.netlify
+.vinxi
+app.config.timestamp_*.js
+
+# Environment
+.env
+.env*.local
+
+# dependencies
+/node_modules
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+*.launch
+.settings/
+
+# Temp
+gitignore
+
+# System Files
+.DS_Store
+Thumbs.db
diff --git a/packages/console/app/.opencode/agent/css.md b/packages/console/app/.opencode/agent/css.md
new file mode 100644
index 000000000..d0ec43a48
--- /dev/null
+++ b/packages/console/app/.opencode/agent/css.md
@@ -0,0 +1,149 @@
+---
+description: use whenever you are styling a ui with css
+---
+
+you are very good at writing clean maintainable css using modern techniques
+
+css is structured like this
+
+```css
+[data-page="home"] {
+ [data-component="header"] {
+ [data-slot="logo"] {
+ }
+ }
+}
+```
+
+top level pages are scoped using `data-page`
+
+pages can break down into components using `data-component`
+
+components can break down into slots using `data-slot`
+
+structure things so that this hierarchy is followed IN YOUR CSS - you should rarely need to
+nest components inside other components. you should NEVER nest components inside
+slots. you should NEVER nest slots inside other slots.
+
+**IMPORTANT: This hierarchy rule applies to CSS structure, NOT JSX/DOM structure.**
+
+The hierarchy in css file does NOT have to match the hierarchy in the dom - you
+can put components or slots at the same level in CSS even if one goes inside another in the DOM.
+
+Your JSX can nest however makes semantic sense - components can be inside slots,
+slots can contain components, etc. The DOM structure should be whatever makes the most
+semantic and functional sense.
+
+It is more important to follow the pages -> components -> slots structure IN YOUR CSS,
+while keeping your JSX/DOM structure logical and semantic.
+
+use data attributes to represent different states of the component
+
+```css
+[data-component="modal"] {
+ opacity: 0;
+
+ &[data-state="open"] {
+ opacity: 1;
+ }
+}
+```
+
+this will allow jsx to control the syling
+
+avoid selectors that just target an element type like `> span` you should assign
+it a slot name. it's ok to do this sometimes where it makes sense semantically
+like targeting `li` elements in a list
+
+in terms of file structure `./src/style/` contains all universal styling rules.
+these should not contain anything specific to a page
+
+`./src/style/token` contains all the tokens used in the project
+
+`./src/style/component` is for reusable components like buttons or inputs
+
+page specific styles should go next to the page they are styling so
+`./src/routes/about.tsx` should have its styles in `./src/routes/about.css`
+
+`about.css` should be scoped using `data-page="about"`
+
+## Example of correct implementation
+
+JSX can nest however makes sense semantically:
+
+```jsx
+<div data-slot="left">
+ <div data-component="title">Section Title</div>
+ <div data-slot="content">Content here</div>
+</div>
+```
+
+CSS maintains clean hierarchy regardless of DOM nesting:
+
+```css
+[data-page="home"] {
+ [data-component="screenshots"] {
+ [data-slot="left"] {
+ /* styles */
+ }
+ [data-slot="content"] {
+ /* styles */
+ }
+ }
+
+ [data-component="title"] {
+ /* can be at same level even though nested in DOM */
+ }
+}
+```
+
+## Reusable Components
+
+If a component is reused across multiple sections of the same page, define it at the page level:
+
+```jsx
+<!-- Used in multiple places on the same page -->
+<section data-component="install">
+ <div data-component="method">
+ <h3 data-component="title">npm</h3>
+ </div>
+ <div data-component="method">
+ <h3 data-component="title">bun</h3>
+ </div>
+</section>
+
+<section data-component="screenshots">
+ <div data-slot="left">
+ <div data-component="title">Screenshot Title</div>
+ </div>
+</section>
+```
+
+```css
+[data-page="home"] {
+ /* Reusable title component defined at page level since it's used in multiple components */
+ [data-component="title"] {
+ text-transform: uppercase;
+ font-weight: 400;
+ }
+
+ [data-component="install"] {
+ /* install-specific styles */
+ }
+
+ [data-component="screenshots"] {
+ /* screenshots-specific styles */
+ }
+}
+```
+
+This is correct because the `title` component has consistent styling and behavior across the page.
+
+## Key Clarifications
+
+1. **JSX Nesting is Flexible**: Components can be nested inside slots, slots can contain components - whatever makes semantic sense
+2. **CSS Hierarchy is Strict**: Follow pages → components → slots structure in CSS
+3. **Reusable Components**: Define at the appropriate level where they're shared (page level if used across the page, component level if only used within that component)
+4. **DOM vs CSS Structure**: These don't need to match - optimize each for its purpose
+
+See ./src/routes/index.css and ./src/routes/index.tsx for a complete example.
diff --git a/packages/console/app/README.md b/packages/console/app/README.md
new file mode 100644
index 000000000..9337430cf
--- /dev/null
+++ b/packages/console/app/README.md
@@ -0,0 +1,32 @@
+# SolidStart
+
+Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
+
+## Creating a project
+
+```bash
+# create a new project in the current directory
+npm init solid@latest
+
+# create a new project in my-app
+npm init solid@latest my-app
+```
+
+## Developing
+
+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+
+```bash
+npm run dev
+
+# or start the server and open the app in a new browser tab
+npm run dev -- --open
+```
+
+## Building
+
+Solid apps are built with _presets_, which optimise your project for deployment to different environments.
+
+By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
+
+## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
diff --git a/packages/console/app/app.config.ts b/packages/console/app/app.config.ts
new file mode 100644
index 000000000..af013bc81
--- /dev/null
+++ b/packages/console/app/app.config.ts
@@ -0,0 +1,23 @@
+import { defineConfig } from "@solidjs/start/config"
+
+export default defineConfig({
+ middleware: "./src/middleware.ts",
+ vite: {
+ server: {
+ allowedHosts: true,
+ },
+ build: {
+ rollupOptions: {
+ external: ["cloudflare:workers"],
+ },
+ minify: false,
+ },
+ },
+ server: {
+ compatibilityDate: "2024-09-19",
+ preset: "cloudflare_module",
+ cloudflare: {
+ nodeCompat: true,
+ },
+ },
+})
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
new file mode 100644
index 000000000..2a9d9a98b
--- /dev/null
+++ b/packages/console/app/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@opencode/console-app",
+ "type": "module",
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "dev": "vinxi dev --host 0.0.0.0",
+ "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
+ "build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
+ "start": "vinxi start",
+ "version": "0.9.11"
+ },
+ "dependencies": {
+ "@ibm/plex": "6.4.1",
+ "@openauthjs/openauth": "0.0.0-20250322224806",
+ "@solidjs/meta": "^0.29.4",
+ "@solidjs/router": "^0.15.0",
+ "@solidjs/start": "^1.1.0",
+ "solid-js": "catalog:",
+ "vinxi": "^0.5.7",
+ "@opencode/console-core": "workspace:*"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+}
diff --git a/packages/console/app/public/favicon.svg b/packages/console/app/public/favicon.svg
new file mode 100644
index 000000000..3c81bbdb4
--- /dev/null
+++ b/packages/console/app/public/favicon.svg
@@ -0,0 +1,5 @@
+<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="600" height="600" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M115 180H300V420H115V180ZM253.75 229.044H161.25V370.405H253.75V229.044Z" fill="white"/>
+<path d="M346.25 180H485V229.044H392.5V370.405H485V419.449H346.25V180Z" fill="white"/>
+</svg>
diff --git a/packages/console/app/public/robots.txt b/packages/console/app/public/robots.txt
new file mode 100644
index 000000000..f88eb1790
--- /dev/null
+++ b/packages/console/app/public/robots.txt
@@ -0,0 +1,5 @@
+User-agent: *
+Allow: /
+
+# Disallow shared content pages
+Disallow: /s/ \ No newline at end of file
diff --git a/packages/console/app/public/social-share.png b/packages/console/app/public/social-share.png
new file mode 100644
index 000000000..97f67994d
--- /dev/null
+++ b/packages/console/app/public/social-share.png
Binary files differ
diff --git a/packages/console/app/public/theme.json b/packages/console/app/public/theme.json
new file mode 100644
index 000000000..b3e97f7ca
--- /dev/null
+++ b/packages/console/app/public/theme.json
@@ -0,0 +1,182 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {
+ "$schema": {
+ "type": "string",
+ "description": "JSON schema reference for configuration validation"
+ },
+ "defs": {
+ "type": "object",
+ "description": "Color definitions that can be referenced in the theme",
+ "patternProperties": {
+ "^[a-zA-Z][a-zA-Z0-9_]*$": {
+ "oneOf": [
+ {
+ "type": "string",
+ "pattern": "^#[0-9a-fA-F]{6}$",
+ "description": "Hex color value"
+ },
+ {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255,
+ "description": "ANSI color code (0-255)"
+ },
+ {
+ "type": "string",
+ "enum": ["none"],
+ "description": "No color (uses terminal default)"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "theme": {
+ "type": "object",
+ "description": "Theme color definitions",
+ "properties": {
+ "primary": { "$ref": "#/definitions/colorValue" },
+ "secondary": { "$ref": "#/definitions/colorValue" },
+ "accent": { "$ref": "#/definitions/colorValue" },
+ "error": { "$ref": "#/definitions/colorValue" },
+ "warning": { "$ref": "#/definitions/colorValue" },
+ "success": { "$ref": "#/definitions/colorValue" },
+ "info": { "$ref": "#/definitions/colorValue" },
+ "text": { "$ref": "#/definitions/colorValue" },
+ "textMuted": { "$ref": "#/definitions/colorValue" },
+ "background": { "$ref": "#/definitions/colorValue" },
+ "backgroundPanel": { "$ref": "#/definitions/colorValue" },
+ "backgroundElement": { "$ref": "#/definitions/colorValue" },
+ "border": { "$ref": "#/definitions/colorValue" },
+ "borderActive": { "$ref": "#/definitions/colorValue" },
+ "borderSubtle": { "$ref": "#/definitions/colorValue" },
+ "diffAdded": { "$ref": "#/definitions/colorValue" },
+ "diffRemoved": { "$ref": "#/definitions/colorValue" },
+ "diffContext": { "$ref": "#/definitions/colorValue" },
+ "diffHunkHeader": { "$ref": "#/definitions/colorValue" },
+ "diffHighlightAdded": { "$ref": "#/definitions/colorValue" },
+ "diffHighlightRemoved": { "$ref": "#/definitions/colorValue" },
+ "diffAddedBg": { "$ref": "#/definitions/colorValue" },
+ "diffRemovedBg": { "$ref": "#/definitions/colorValue" },
+ "diffContextBg": { "$ref": "#/definitions/colorValue" },
+ "diffLineNumber": { "$ref": "#/definitions/colorValue" },
+ "diffAddedLineNumberBg": { "$ref": "#/definitions/colorValue" },
+ "diffRemovedLineNumberBg": { "$ref": "#/definitions/colorValue" },
+ "markdownText": { "$ref": "#/definitions/colorValue" },
+ "markdownHeading": { "$ref": "#/definitions/colorValue" },
+ "markdownLink": { "$ref": "#/definitions/colorValue" },
+ "markdownLinkText": { "$ref": "#/definitions/colorValue" },
+ "markdownCode": { "$ref": "#/definitions/colorValue" },
+ "markdownBlockQuote": { "$ref": "#/definitions/colorValue" },
+ "markdownEmph": { "$ref": "#/definitions/colorValue" },
+ "markdownStrong": { "$ref": "#/definitions/colorValue" },
+ "markdownHorizontalRule": { "$ref": "#/definitions/colorValue" },
+ "markdownListItem": { "$ref": "#/definitions/colorValue" },
+ "markdownListEnumeration": { "$ref": "#/definitions/colorValue" },
+ "markdownImage": { "$ref": "#/definitions/colorValue" },
+ "markdownImageText": { "$ref": "#/definitions/colorValue" },
+ "markdownCodeBlock": { "$ref": "#/definitions/colorValue" },
+ "syntaxComment": { "$ref": "#/definitions/colorValue" },
+ "syntaxKeyword": { "$ref": "#/definitions/colorValue" },
+ "syntaxFunction": { "$ref": "#/definitions/colorValue" },
+ "syntaxVariable": { "$ref": "#/definitions/colorValue" },
+ "syntaxString": { "$ref": "#/definitions/colorValue" },
+ "syntaxNumber": { "$ref": "#/definitions/colorValue" },
+ "syntaxType": { "$ref": "#/definitions/colorValue" },
+ "syntaxOperator": { "$ref": "#/definitions/colorValue" },
+ "syntaxPunctuation": { "$ref": "#/definitions/colorValue" }
+ },
+ "required": ["primary", "secondary", "accent", "text", "textMuted", "background"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["theme"],
+ "additionalProperties": false,
+ "definitions": {
+ "colorValue": {
+ "oneOf": [
+ {
+ "type": "string",
+ "pattern": "^#[0-9a-fA-F]{6}$",
+ "description": "Hex color value (same for dark and light)"
+ },
+ {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255,
+ "description": "ANSI color code (0-255, same for dark and light)"
+ },
+ {
+ "type": "string",
+ "enum": ["none"],
+ "description": "No color (uses terminal default)"
+ },
+ {
+ "type": "string",
+ "pattern": "^[a-zA-Z][a-zA-Z0-9_]*$",
+ "description": "Reference to another color in the theme or defs"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "dark": {
+ "oneOf": [
+ {
+ "type": "string",
+ "pattern": "^#[0-9a-fA-F]{6}$",
+ "description": "Hex color value for dark mode"
+ },
+ {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255,
+ "description": "ANSI color code for dark mode"
+ },
+ {
+ "type": "string",
+ "enum": ["none"],
+ "description": "No color (uses terminal default)"
+ },
+ {
+ "type": "string",
+ "pattern": "^[a-zA-Z][a-zA-Z0-9_]*$",
+ "description": "Reference to another color for dark mode"
+ }
+ ]
+ },
+ "light": {
+ "oneOf": [
+ {
+ "type": "string",
+ "pattern": "^#[0-9a-fA-F]{6}$",
+ "description": "Hex color value for light mode"
+ },
+ {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255,
+ "description": "ANSI color code for light mode"
+ },
+ {
+ "type": "string",
+ "enum": ["none"],
+ "description": "No color (uses terminal default)"
+ },
+ {
+ "type": "string",
+ "pattern": "^[a-zA-Z][a-zA-Z0-9_]*$",
+ "description": "Reference to another color for light mode"
+ }
+ ]
+ }
+ },
+ "required": ["dark", "light"],
+ "additionalProperties": false,
+ "description": "Separate colors for dark and light modes"
+ }
+ ]
+ }
+ }
+}
diff --git a/packages/console/app/src/app.css b/packages/console/app/src/app.css
new file mode 100644
index 000000000..c0261c422
--- /dev/null
+++ b/packages/console/app/src/app.css
@@ -0,0 +1 @@
+@import "./style/index.css";
diff --git a/packages/console/app/src/app.tsx b/packages/console/app/src/app.tsx
new file mode 100644
index 000000000..bc3961214
--- /dev/null
+++ b/packages/console/app/src/app.tsx
@@ -0,0 +1,23 @@
+import { MetaProvider, Title, Meta } from "@solidjs/meta"
+import { Router } from "@solidjs/router"
+import { FileRoutes } from "@solidjs/start/router"
+import { ErrorBoundary, Suspense } from "solid-js"
+import "@ibm/plex/css/ibm-plex.css"
+import "./app.css"
+
+export default function App() {
+ return (
+ <Router
+ explicitLinks={true}
+ root={(props) => (
+ <MetaProvider>
+ <Title>opencode</Title>
+ <Meta name="description" content="opencode - The AI coding agent built for the terminal." />
+ <Suspense>{props.children}</Suspense>
+ </MetaProvider>
+ )}
+ >
+ <FileRoutes />
+ </Router>
+ )
+}
diff --git a/packages/console/app/src/asset/lander/check.svg b/packages/console/app/src/asset/lander/check.svg
new file mode 100644
index 000000000..22de6f2a8
--- /dev/null
+++ b/packages/console/app/src/asset/lander/check.svg
@@ -0,0 +1,2 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z"/></svg>
+
diff --git a/packages/console/app/src/asset/lander/copy.svg b/packages/console/app/src/asset/lander/copy.svg
new file mode 100644
index 000000000..f1baac30a
--- /dev/null
+++ b/packages/console/app/src/asset/lander/copy.svg
@@ -0,0 +1,2 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><rect width="336" height="336" x="128" y="128" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32" rx="57" ry="57"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="m383.5 128l.5-24a56.16 56.16 0 0 0-56-56H112a64.19 64.19 0 0 0-64 64v216a56.16 56.16 0 0 0 56 56h24"/></svg>
+
diff --git a/packages/console/app/src/asset/lander/screenshot-github.png b/packages/console/app/src/asset/lander/screenshot-github.png
new file mode 100644
index 000000000..fda74e641
--- /dev/null
+++ b/packages/console/app/src/asset/lander/screenshot-github.png
Binary files differ
diff --git a/packages/console/app/src/asset/lander/screenshot-splash.png b/packages/console/app/src/asset/lander/screenshot-splash.png
new file mode 100644
index 000000000..e900673ef
--- /dev/null
+++ b/packages/console/app/src/asset/lander/screenshot-splash.png
Binary files differ
diff --git a/packages/console/app/src/asset/lander/screenshot-vscode.png b/packages/console/app/src/asset/lander/screenshot-vscode.png
new file mode 100644
index 000000000..b8966a6b8
--- /dev/null
+++ b/packages/console/app/src/asset/lander/screenshot-vscode.png
Binary files differ
diff --git a/packages/console/app/src/asset/lander/screenshot.png b/packages/console/app/src/asset/lander/screenshot.png
new file mode 100644
index 000000000..feb617585
--- /dev/null
+++ b/packages/console/app/src/asset/lander/screenshot.png
Binary files differ
diff --git a/packages/console/app/src/asset/logo-ornate-dark.svg b/packages/console/app/src/asset/logo-ornate-dark.svg
new file mode 100644
index 000000000..2efda934d
--- /dev/null
+++ b/packages/console/app/src/asset/logo-ornate-dark.svg
@@ -0,0 +1,19 @@
+<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.5 16.5H24.5V33H8.5V16.5Z" fill="white" fill-opacity="0.2"/>
+<path d="M48.5 16.5H64.5V33H48.5V16.5Z" fill="white" fill-opacity="0.2"/>
+<path d="M120.5 16.5H136.5V33H120.5V16.5Z" fill="white" fill-opacity="0.2"/>
+<path d="M160.5 16.5H176.5V33H160.5V16.5Z" fill="white" fill-opacity="0.2"/>
+<path d="M192.5 16.5H208.5V33H192.5V16.5Z" fill="white" fill-opacity="0.2"/>
+<path d="M232.5 16.5H248.5V33H232.5V16.5Z" fill="white" fill-opacity="0.2"/>
+<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="white" fill-opacity="0.95"/>
+<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="white" fill-opacity="0.95"/>
+<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="white" fill-opacity="0.95"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="white" fill-opacity="0.95"/>
+<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="white" fill-opacity="0.5"/>
+<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="white" fill-opacity="0.5"/>
+<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="white" fill-opacity="0.5"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="white" fill-opacity="0.5"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="white" fill-opacity="0.5"/>
+<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="white" fill-opacity="0.95"/>
+</svg>
+
diff --git a/packages/console/app/src/asset/logo-ornate-light.svg b/packages/console/app/src/asset/logo-ornate-light.svg
new file mode 100644
index 000000000..789223bc4
--- /dev/null
+++ b/packages/console/app/src/asset/logo-ornate-light.svg
@@ -0,0 +1,18 @@
+<svg width="288" height="50" viewBox="0 0 288 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 16.5H24V33H8V16.5Z" fill="black" fill-opacity="0.15"/>
+<path d="M48 16.5H64V33H48V16.5Z" fill="black" fill-opacity="0.15"/>
+<path d="M120 16.5H136V33H120V16.5Z" fill="black" fill-opacity="0.15"/>
+<path d="M160 16.5H176V33H160V16.5Z" fill="black" fill-opacity="0.15"/>
+<path d="M192 16.5H208V33H192V16.5Z" fill="black" fill-opacity="0.15"/>
+<path d="M232 16.5H248V33H232V16.5Z" fill="black" fill-opacity="0.15"/>
+<path d="M264 0H288V8.5H272V16.5H288V25H272V33H288V41.5H264V0Z" fill="black" fill-opacity="0.95"/>
+<path d="M248 0H224V41.5H248V33H232V8.5H248V0Z" fill="black" fill-opacity="0.95"/>
+<path d="M256 8.5H248V33H256V8.5Z" fill="black" fill-opacity="0.95"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M184 0H216V41.5H184V0ZM208 8.5H192V33H208V8.5Z" fill="black" fill-opacity="0.95"/>
+<path d="M144 8.5H136V41.5H144V8.5Z" fill="black" fill-opacity="0.55"/>
+<path d="M136 0H112V41.5H120V8.5H136V0Z" fill="black" fill-opacity="0.55"/>
+<path d="M80 0H104V8.5H88V16.5H104V25H88V33H104V41.5H80V0Z" fill="black" fill-opacity="0.55"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M40 0H72V41.5H48V49.5H40V0ZM64 8.5H48V33H64V8.5Z" fill="black" fill-opacity="0.55"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0H32V41.5955H0V0ZM24 8.5H8V33H24V8.5Z" fill="black" fill-opacity="0.55"/>
+<path d="M152 0H176V8.5H160V33H176V41.5H152V0Z" fill="black" fill-opacity="0.95"/>
+</svg>
diff --git a/packages/console/app/src/asset/logo.svg b/packages/console/app/src/asset/logo.svg
new file mode 100644
index 000000000..cbfcccf51
--- /dev/null
+++ b/packages/console/app/src/asset/logo.svg
@@ -0,0 +1,12 @@
+<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="black"/>
+<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="black"/>
+<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="black"/>
+<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="black"/>
+<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="black"/>
+<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="black"/>
+<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="black"/>
+</svg>
diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx
new file mode 100644
index 000000000..a82572e62
--- /dev/null
+++ b/packages/console/app/src/component/icon.tsx
@@ -0,0 +1,82 @@
+import { JSX } from "solid-js"
+
+export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="currentColor" />
+ <path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="currentColor" />
+ <path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="currentColor" />
+ <path
+ fill-rule="evenodd"
+ clip-rule="evenodd"
+ d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z"
+ fill="currentColor"
+ />
+ <path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="currentColor" />
+ <path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="currentColor" />
+ <path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="currentColor" />
+ <path
+ fill-rule="evenodd"
+ clip-rule="evenodd"
+ d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z"
+ fill="currentColor"
+ />
+ <path
+ fill-rule="evenodd"
+ clip-rule="evenodd"
+ d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z"
+ fill="currentColor"
+ />
+ <path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="currentColor" />
+ </svg>
+ )
+}
+
+export function IconCopy(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} viewBox="0 0 512 512">
+ <rect
+ width="336"
+ height="336"
+ x="128"
+ y="128"
+ fill="none"
+ stroke="currentColor"
+ stroke-linejoin="round"
+ stroke-width="32"
+ rx="57"
+ ry="57"
+ ></rect>
+ <path
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="32"
+ d="m383.5 128l.5-24a56.16 56.16 0 0 0-56-56H112a64.19 64.19 0 0 0-64 64v216a56.16 56.16 0 0 0 56 56h24"
+ ></path>
+ </svg>
+ )
+}
+
+export function IconCheck(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} viewBox="0 0 24 24">
+ <path
+ fill="currentColor"
+ d="M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z"
+ ></path>
+ </svg>
+ )
+}
+
+export function IconCreditCard(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} viewBox="0 0 24 24">
+ <path
+ fill="currentColor"
+ d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V8h16v10z"
+ />
+ </svg>
+ )
+}
diff --git a/packages/console/app/src/component/workspace/billing-section.module.css b/packages/console/app/src/component/workspace/billing-section.module.css
new file mode 100644
index 000000000..0bb5709cb
--- /dev/null
+++ b/packages/console/app/src/component/workspace/billing-section.module.css
@@ -0,0 +1,114 @@
+.root {
+ [data-slot="section-content"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ }
+
+ [data-slot="reload-error"] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+
+ p {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ margin: 0;
+ flex: 1;
+ }
+
+ [data-slot="create-form"] {
+ display: flex;
+ gap: var(--space-2);
+ margin: 0;
+ flex-shrink: 0;
+ }
+ }
+ [data-slot="payment"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ min-width: 14.5rem;
+ width: fit-content;
+
+ @media (max-width: 30rem) {
+ width: 100%;
+ }
+
+ [data-slot="credit-card"] {
+ padding: var(--space-3-5) var(--space-4);
+ background-color: var(--color-bg-surface);
+ border-radius: var(--border-radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ [data-slot="card-icon"] {
+ display: flex;
+ align-items: center;
+ color: var(--color-text-muted);
+ }
+
+ [data-slot="card-details"] {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-1);
+
+ [data-slot="secret"] {
+ position: relative;
+ bottom: 2px;
+ font-size: var(--font-size-lg);
+ color: var(--color-text-muted);
+ font-weight: 400;
+ }
+
+ [data-slot="number"] {
+ font-size: var(--font-size-3xl);
+ font-weight: 500;
+ color: var(--color-text);
+ }
+ }
+ }
+
+ [data-slot="button-row"] {
+ display: flex;
+ gap: var(--space-2);
+ align-items: center;
+
+ @media (max-width: 30rem) {
+ flex-direction: column;
+
+ > button {
+ width: 100%;
+ }
+ }
+
+ [data-slot="create-form"] {
+ margin: 0;
+ }
+
+ /* Make Enable Billing button full width when it's the only button */
+ > button {
+ flex: 1;
+ }
+ }
+ }
+ [data-slot="usage"] {
+ p {
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+ color: var(--color-text-secondary);
+ b {
+ font-weight: 600;
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/component/workspace/billing-section.tsx b/packages/console/app/src/component/workspace/billing-section.tsx
new file mode 100644
index 000000000..57316e208
--- /dev/null
+++ b/packages/console/app/src/component/workspace/billing-section.tsx
@@ -0,0 +1,193 @@
+import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
+import { createMemo, Show } from "solid-js"
+import { Billing } from "@opencode/console-core/billing.js"
+import { withActor } from "~/context/auth.withActor"
+import { IconCreditCard } from "~/component/icon"
+import styles from "./billing-section.module.css"
+
+const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
+ "use server"
+ return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
+}, "checkoutUrl")
+
+const reload = action(async (form: FormData) => {
+ "use server"
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key })
+}, "billing.reload")
+
+const disableReload = action(async (form: FormData) => {
+ "use server"
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(await withActor(() => Billing.disableReload(), workspaceID), { revalidate: getBillingInfo.key })
+}, "billing.disableReload")
+
+const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
+ "use server"
+ return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
+}, "sessionUrl")
+
+const getBillingInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.get()
+ }, workspaceID)
+}, "billing.get")
+
+export function BillingSection() {
+ const params = useParams()
+ // ORIGINAL CODE - COMMENTED OUT FOR TESTING
+ const balanceInfo = createAsync(() => getBillingInfo(params.id))
+ const createCheckoutUrlAction = useAction(createCheckoutUrl)
+ const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
+ const createSessionUrlAction = useAction(createSessionUrl)
+ const createSessionUrlSubmission = useSubmission(createSessionUrl)
+ const disableReloadSubmission = useSubmission(disableReload)
+ const reloadSubmission = useSubmission(reload)
+
+ // DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
+
+ // Scenario 1: User has not added billing details and has no balance
+ // const balanceInfo = () => ({
+ // balance: 0,
+ // paymentMethodLast4: null as string | null,
+ // reload: false,
+ // reloadError: null as string | null,
+ // timeReloadError: null as Date | null,
+ // })
+
+ // Scenario 2: User has not added billing details but has a balance
+ // const balanceInfo = () => ({
+ // balance: 1500000000, // $15.00
+ // paymentMethodLast4: null as string | null,
+ // reload: false,
+ // reloadError: null as string | null,
+ // timeReloadError: null as Date | null
+ // })
+
+ // Scenario 3: User has added billing details (reload enabled)
+ // const balanceInfo = () => ({
+ // balance: 750000000, // $7.50
+ // paymentMethodLast4: "4242",
+ // reload: true,
+ // reloadError: null as string | null,
+ // timeReloadError: null as Date | null
+ // })
+
+ // Scenario 4: User has billing details but reload failed
+ // const balanceInfo = () => ({
+ // balance: 250000000, // $2.50
+ // paymentMethodLast4: "4242",
+ // reload: true,
+ // reloadError: "Your card was declined." as string,
+ // timeReloadError: new Date(Date.now() - 3600000) as Date // 1 hour ago
+ // })
+
+ const balanceAmount = createMemo(() => {
+ return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
+ })
+
+ return (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>Billing</h2>
+ <p>
+ Manage payments methods. <a href="mailto:[email protected]">Contact us</a> if you have any questions.
+ </p>
+ </div>
+ <div data-slot="section-content">
+ <Show when={balanceInfo()?.reloadError}>
+ <div data-slot="reload-error">
+ <p>
+ Reload failed at{" "}
+ {balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ })}
+ . Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
+ again.
+ </p>
+ <form action={reload} method="post" data-slot="create-form">
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <button data-color="primary" type="submit" disabled={reloadSubmission.pending}>
+ {reloadSubmission.pending ? "Reloading..." : "Reload"}
+ </button>
+ </form>
+ </div>
+ </Show>
+ <div data-slot="payment">
+ <div data-slot="credit-card">
+ <div data-slot="card-icon">
+ <IconCreditCard style={{ width: "32px", height: "32px" }} />
+ </div>
+ <div data-slot="card-details">
+ <Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
+ <span data-slot="secret">••••</span>
+ <span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
+ </Show>
+ </div>
+ </div>
+ <div data-slot="button-row">
+ <Show
+ when={balanceInfo()?.reload}
+ fallback={
+ <button
+ data-color="primary"
+ disabled={createCheckoutUrlSubmission.pending}
+ onClick={async () => {
+ const baseUrl = window.location.href
+ const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
+ if (checkoutUrl) {
+ window.location.href = checkoutUrl
+ }
+ }}
+ >
+ {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
+ </button>
+ }
+ >
+ <button
+ data-color="primary"
+ disabled={createSessionUrlSubmission.pending}
+ onClick={async () => {
+ const baseUrl = window.location.href
+ const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
+ if (sessionUrl) {
+ window.location.href = sessionUrl
+ }
+ }}
+ >
+ {createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
+ </button>
+ <form action={disableReload} method="post" data-slot="create-form">
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <button data-color="ghost" type="submit" disabled={disableReloadSubmission.pending}>
+ {disableReloadSubmission.pending ? "Disabling..." : "Disable"}
+ </button>
+ </form>
+ </Show>
+ </div>
+ </div>
+ <div data-slot="usage">
+ <Show when={!balanceInfo()?.reload && !(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}>
+ <p>
+ You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
+ your account. You can continue using the API with your remaining balance.
+ </p>
+ </Show>
+ <Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
+ <p>
+ Your current balance is <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
+ . We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches <b>$5</b>.
+ </p>
+ </Show>
+ </div>
+ </div>
+ </section>
+ )
+}
diff --git a/packages/console/app/src/component/workspace/common.tsx b/packages/console/app/src/component/workspace/common.tsx
new file mode 100644
index 000000000..f85fd8423
--- /dev/null
+++ b/packages/console/app/src/component/workspace/common.tsx
@@ -0,0 +1,25 @@
+export function formatDateForTable(date: Date) {
+ const options: Intl.DateTimeFormatOptions = {
+ day: "numeric",
+ month: "short",
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: true,
+ }
+ return date.toLocaleDateString("en-GB", options).replace(",", ",")
+}
+
+export function formatDateUTC(date: Date) {
+ const options: Intl.DateTimeFormatOptions = {
+ weekday: "short",
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ timeZoneName: "short",
+ timeZone: "UTC",
+ }
+ return date.toLocaleDateString("en-US", options)
+}
diff --git a/packages/console/app/src/component/workspace/key-section.module.css b/packages/console/app/src/component/workspace/key-section.module.css
new file mode 100644
index 000000000..6a1d0c85f
--- /dev/null
+++ b/packages/console/app/src/component/workspace/key-section.module.css
@@ -0,0 +1,172 @@
+.root {
+ [data-component="empty-state"] {
+ padding: var(--space-20) var(--space-6);
+ text-align: center;
+ border: 1px dashed var(--color-border);
+ border-radius: var(--border-radius-sm);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+
+ p {
+ line-height: 1.5;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ }
+ }
+
+ [data-slot="create-form"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+
+ [data-slot="input-container"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ }
+
+ @media (max-width: 30rem) {
+ gap: var(--space-2);
+ }
+
+ input {
+ flex: 1;
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-mono);
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ }
+
+ &::placeholder {
+ color: var(--color-text-disabled);
+ }
+ }
+
+ [data-slot="form-actions"] {
+ display: flex;
+ gap: var(--space-2);
+ }
+
+ [data-slot="form-error"] {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ margin-top: var(--space-1);
+ line-height: 1.4;
+ }
+ }
+
+ [data-slot="api-keys-table"] {
+ overflow-x: auto;
+ }
+
+ [data-slot="api-keys-table-element"] {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-sm);
+
+ thead {
+ border-bottom: 1px solid var(--color-border);
+ }
+
+ th {
+ padding: var(--space-3) var(--space-4);
+ text-align: left;
+ font-weight: normal;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ }
+
+ td {
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--color-border-muted);
+ color: var(--color-text-muted);
+ font-family: var(--font-mono);
+
+ &[data-slot="key-name"] {
+ color: var(--color-text);
+ font-family: var(--font-sans);
+ font-weight: 500;
+ }
+
+ &[data-slot="key-value"] {
+ font-family: var(--font-mono);
+
+ button {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-sm);
+ font-weight: 400;
+ border: none;
+ background-color: transparent;
+ color: var(--color-text-muted);
+ font-family: var(--font-mono);
+ border-radius: var(--border-radius-sm);
+ cursor: pointer;
+ transition: all 0.15s ease;
+ text-transform: none;
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-bg-surface);
+ color: var(--color-text);
+ }
+
+ &:disabled {
+ cursor: default;
+ color: var(--color-text);
+ }
+
+ span {
+ font-family: inherit;
+ }
+ }
+ }
+
+ &[data-slot="key-date"] {
+ color: var(--color-text);
+ }
+
+ &[data-slot="key-actions"] {
+ font-family: var(--font-sans);
+ }
+ }
+
+ tbody tr {
+ &:last-child td {
+ border-bottom: none;
+ }
+ }
+
+ @media (max-width: 40rem) {
+ th,
+ td {
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-xs);
+ }
+
+ th {
+ &:nth-child(3) /* Date */ {
+ display: none;
+ }
+ }
+
+ td {
+ &:nth-child(3) /* Date */ {
+ display: none;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/component/workspace/key-section.tsx b/packages/console/app/src/component/workspace/key-section.tsx
new file mode 100644
index 000000000..a2bd380ea
--- /dev/null
+++ b/packages/console/app/src/component/workspace/key-section.tsx
@@ -0,0 +1,182 @@
+import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
+import { createEffect, createSignal, For, Show } from "solid-js"
+import { IconCopy, IconCheck } from "~/component/icon"
+import { Key } from "@opencode/console-core/key.js"
+import { withActor } from "~/context/auth.withActor"
+import { createStore } from "solid-js/store"
+import { formatDateUTC, formatDateForTable } from "./common"
+import styles from "./key-section.module.css"
+
+const removeKey = action(async (form: FormData) => {
+ "use server"
+ const id = form.get("id")?.toString()
+ if (!id) return { error: "ID is required" }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
+}, "key.remove")
+
+const createKey = action(async (form: FormData) => {
+ "use server"
+ const name = form.get("name")?.toString().trim()
+ if (!name) return { error: "Name is required" }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(
+ await withActor(
+ () =>
+ Key.create({ name })
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({ error: e.message as string })),
+ workspaceID,
+ ),
+ { revalidate: listKeys.key },
+ )
+}, "key.create")
+
+const listKeys = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(() => Key.list(), workspaceID)
+}, "key.list")
+
+export function KeyCreateForm() {
+ const params = useParams()
+ const submission = useSubmission(createKey)
+ const [store, setStore] = createStore({ show: false })
+
+ let input: HTMLInputElement
+
+ createEffect(() => {
+ if (!submission.pending && submission.result && !submission.result.error) {
+ hide()
+ }
+ })
+
+ function show() {
+ // submission.clear() does not clear the result in some cases, ie.
+ // 1. Create key with empty name => error shows
+ // 2. Put in a key name and creates the key => form hides
+ // 3. Click add key button again => form shows with the same error if
+ // submission.clear() is called only once
+ while (true) {
+ submission.clear()
+ if (!submission.result) break
+ }
+ setStore("show", true)
+ input.focus()
+ }
+
+ function hide() {
+ setStore("show", false)
+ }
+
+ return (
+ <Show
+ when={store.show}
+ fallback={
+ <button data-color="primary" onClick={() => show()}>
+ Create API Key
+ </button>
+ }
+ >
+ <form action={createKey} method="post" data-slot="create-form">
+ <div data-slot="input-container">
+ <input ref={(r) => (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" />
+ <Show when={submission.result && submission.result.error}>
+ {(err) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ </div>
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <div data-slot="form-actions">
+ <button type="reset" data-color="ghost" onClick={() => hide()}>
+ Cancel
+ </button>
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? "Creating..." : "Create"}
+ </button>
+ </div>
+ </form>
+ </Show>
+ )
+}
+
+export function KeySection() {
+ const params = useParams()
+ const keys = createAsync(() => listKeys(params.id))
+
+ function formatKey(key: string) {
+ if (key.length <= 11) return key
+ return `${key.slice(0, 7)}...${key.slice(-4)}`
+ }
+
+ return (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>API Keys</h2>
+ <p>Manage your API keys for accessing opencode services.</p>
+ </div>
+ <KeyCreateForm />
+ <div data-slot="api-keys-table">
+ <Show
+ when={keys()?.length}
+ fallback={
+ <div data-component="empty-state">
+ <p>Create an opencode Gateway API key</p>
+ </div>
+ }
+ >
+ <table data-slot="api-keys-table-element">
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>Key</th>
+ <th>Created</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <For each={keys()!}>
+ {(key) => {
+ const [copied, setCopied] = createSignal(false)
+ // const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id)
+ return (
+ <tr>
+ <td data-slot="key-name">{key.name}</td>
+ <td data-slot="key-value">
+ <button
+ data-color="ghost"
+ disabled={copied()}
+ onClick={async () => {
+ await navigator.clipboard.writeText(key.key)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1000)
+ }}
+ title="Copy API key"
+ >
+ <span>{formatKey(key.key)}</span>
+ <Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
+ <IconCheck style={{ width: "14px", height: "14px" }} />
+ </Show>
+ </button>
+ </td>
+ <td data-slot="key-date" title={formatDateUTC(key.timeCreated)}>
+ {formatDateForTable(key.timeCreated)}
+ </td>
+ <td data-slot="key-actions">
+ <form action={removeKey} method="post">
+ <input type="hidden" name="id" value={key.id} />
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <button data-color="ghost">Delete</button>
+ </form>
+ </td>
+ </tr>
+ )
+ }}
+ </For>
+ </tbody>
+ </table>
+ </Show>
+ </div>
+ </section>
+ )
+}
diff --git a/packages/console/app/src/component/workspace/monthly-limit-section.module.css b/packages/console/app/src/component/workspace/monthly-limit-section.module.css
new file mode 100644
index 000000000..02de058e4
--- /dev/null
+++ b/packages/console/app/src/component/workspace/monthly-limit-section.module.css
@@ -0,0 +1,102 @@
+.root {
+ [data-slot="section-content"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ }
+
+ [data-slot="balance"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ min-width: 15rem;
+ width: fit-content;
+
+ @media (max-width: 30rem) {
+ width: 100%;
+ }
+
+ [data-slot="amount"] {
+ padding: var(--space-3-5) var(--space-4);
+ background-color: var(--color-bg-surface);
+ border-radius: var(--border-radius-sm);
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-1);
+ justify-content: flex-end;
+
+ [data-slot="currency"] {
+ position: relative;
+ bottom: 2px;
+ font-size: var(--font-size-lg);
+ color: var(--color-text-muted);
+ font-weight: 400;
+ }
+
+ [data-slot="value"] {
+ font-size: var(--font-size-3xl);
+ font-weight: 500;
+ color: var(--color-text);
+ }
+ }
+
+ [data-slot="create-form"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ margin-top: var(--space-1);
+
+ [data-slot="input-container"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ }
+
+ @media (max-width: 30rem) {
+ gap: var(--space-2);
+ }
+
+ input {
+ flex: 1;
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-mono);
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ }
+
+ &::placeholder {
+ color: var(--color-text-disabled);
+ }
+ }
+
+ [data-slot="form-actions"] {
+ display: flex;
+ gap: var(--space-2);
+ justify-content: flex-end;
+ }
+
+ [data-slot="form-error"] {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ }
+ }
+ }
+
+ [data-slot="usage-status"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ margin: 0;
+ line-height: 1.4;
+ }
+}
diff --git a/packages/console/app/src/component/workspace/monthly-limit-section.tsx b/packages/console/app/src/component/workspace/monthly-limit-section.tsx
new file mode 100644
index 000000000..35da774d0
--- /dev/null
+++ b/packages/console/app/src/component/workspace/monthly-limit-section.tsx
@@ -0,0 +1,139 @@
+import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
+import { createEffect, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { withActor } from "~/context/auth.withActor"
+import { Billing } from "@opencode/console-core/billing.js"
+import styles from "./monthly-limit-section.module.css"
+
+const getBillingInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.get()
+ }, workspaceID)
+}, "billing.get")
+
+const setMonthlyLimit = action(async (form: FormData) => {
+ "use server"
+ const limit = form.get("limit")?.toString()
+ if (!limit) return { error: "Limit is required." }
+ const numericLimit = parseInt(limit)
+ if (numericLimit < 0) return { error: "Set a valid monthly limit." }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required." }
+ return json(
+ await withActor(
+ () =>
+ Billing.setMonthlyLimit(numericLimit)
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({ error: e.message as string })),
+ workspaceID,
+ ),
+ { revalidate: getBillingInfo.key },
+ )
+}, "billing.setMonthlyLimit")
+
+export function MonthlyLimitSection() {
+ const params = useParams()
+ const submission = useSubmission(setMonthlyLimit)
+ const [store, setStore] = createStore({ show: false })
+ const balanceInfo = createAsync(() => getBillingInfo(params.id))
+
+ let input: HTMLInputElement
+
+ createEffect(() => {
+ if (!submission.pending && submission.result && !submission.result.error) {
+ hide()
+ }
+ })
+
+ function show() {
+ // submission.clear() does not clear the result in some cases, ie.
+ // 1. Create key with empty name => error shows
+ // 2. Put in a key name and creates the key => form hides
+ // 3. Click add key button again => form shows with the same error if
+ // submission.clear() is called only once
+ while (true) {
+ submission.clear()
+ if (!submission.result) break
+ }
+ setStore("show", true)
+ input.focus()
+ }
+
+ function hide() {
+ setStore("show", false)
+ }
+
+ return (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>Monthly Limit</h2>
+ <p>Set a monthly spending limit for your account.</p>
+ </div>
+ <div data-slot="section-content">
+ <div data-slot="balance">
+ <div data-slot="amount">
+ {balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
+ <span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
+ </div>
+ <Show
+ when={!store.show}
+ fallback={
+ <form action={setMonthlyLimit} method="post" data-slot="create-form">
+ <div data-slot="input-container">
+ <input
+ required
+ ref={(r) => (input = r)}
+ data-component="input"
+ name="limit"
+ type="number"
+ placeholder="50"
+ />
+ <Show when={submission.result && submission.result.error}>
+ {(err) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ </div>
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <div data-slot="form-actions">
+ <button type="reset" data-color="ghost" onClick={() => hide()}>
+ Cancel
+ </button>
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? "Setting..." : "Set"}
+ </button>
+ </div>
+ </form>
+ }
+ >
+ <button data-color="primary" onClick={() => show()}>
+ {balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
+ </button>
+ </Show>
+ </div>
+ <Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}>
+ <p data-slot="usage-status">
+ Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
+ {(() => {
+ const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
+ if (!dateLastUsed) return "0"
+
+ const current = new Date().toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ timeZone: "UTC",
+ })
+ const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ timeZone: "UTC",
+ })
+ if (current !== lastUsed) return "0"
+ return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
+ })()}
+ .
+ </p>
+ </Show>
+ </div>
+ </section>
+ )
+}
diff --git a/packages/console/app/src/component/workspace/new-user-section.module.css b/packages/console/app/src/component/workspace/new-user-section.module.css
new file mode 100644
index 000000000..2edc7cc14
--- /dev/null
+++ b/packages/console/app/src/component/workspace/new-user-section.module.css
@@ -0,0 +1,163 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-8);
+ padding: var(--space-6);
+ background-color: var(--color-bg-surface);
+ border: 1px dashed var(--color-border);
+ border-radius: var(--border-radius-sm);
+
+ @media (max-width: 30rem) {
+ gap: var(--space-8);
+ padding: var(--space-4);
+ }
+
+ [data-component="feature-grid"] {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: var(--space-6);
+
+ @media (max-width: 30rem) {
+ grid-template-columns: 1fr;
+ gap: var(--space-4);
+ }
+
+ [data-slot="feature"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+
+ h3 {
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ margin: 0;
+ color: var(--color-text);
+ text-transform: uppercase;
+ letter-spacing: -0.025rem;
+ }
+
+ p {
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+ margin: 0;
+ color: var(--color-text-muted);
+ }
+ }
+ }
+
+ [data-component="api-key-highlight"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+
+ [data-slot="section-title"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+
+ h2 {
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ line-height: 1.2;
+ letter-spacing: -0.03125rem;
+ margin: 0;
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+
+ @media (max-width: 30rem) {
+ font-size: var(--font-size-md);
+ }
+ }
+ }
+
+ [data-slot="key-display"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+
+ [data-slot="key-container"] {
+ display: flex;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ border: 2px solid var(--color-accent);
+ border-radius: var(--border-radius-sm);
+ align-items: center;
+
+ @media (max-width: 40rem) {
+ flex-direction: column;
+ gap: var(--space-3);
+ align-items: stretch;
+ }
+
+ [data-slot="key-value"] {
+ flex: 1;
+ font-family: var(--font-mono);
+ font-size: var(--font-size-sm);
+ color: var(--color-text);
+ background-color: var(--color-bg);
+ padding: var(--space-3);
+ border-radius: var(--border-radius-sm);
+ border: 1px solid var(--color-border);
+ word-break: break-all;
+ line-height: 1.4;
+
+ @media (max-width: 40rem) {
+ font-size: var(--font-size-xs);
+ padding: var(--space-2-5);
+ }
+ }
+
+ button {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-4);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ white-space: nowrap;
+ min-width: 130px;
+
+ @media (max-width: 40rem) {
+ justify-content: center;
+ padding: var(--space-2-5) var(--space-3);
+ font-size: var(--font-size-xs);
+ min-width: 96px;
+ }
+ }
+ }
+ }
+ }
+
+ [data-component="next-steps"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+
+ ol {
+ margin: 0;
+ padding-left: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ list-style-position: inside;
+
+ li {
+ font-size: var(--font-size-md);
+ line-height: 1.5;
+ color: var(--color-text-secondary);
+
+ code {
+ font-family: var(--font-mono);
+ font-size: var(--font-size-sm);
+ padding: var(--space-1) var(--space-2);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ color: var(--color-text);
+ }
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/component/workspace/new-user-section.tsx b/packages/console/app/src/component/workspace/new-user-section.tsx
new file mode 100644
index 000000000..5909072dd
--- /dev/null
+++ b/packages/console/app/src/component/workspace/new-user-section.tsx
@@ -0,0 +1,97 @@
+import { query, useParams, createAsync } from "@solidjs/router"
+import { createMemo, createSignal, Show } from "solid-js"
+import { IconCopy, IconCheck } from "~/component/icon"
+import { Key } from "@opencode/console-core/key.js"
+import { Billing } from "@opencode/console-core/billing.js"
+import { withActor } from "~/context/auth.withActor"
+import styles from "./new-user-section.module.css"
+
+const getUsageInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.usages()
+ }, workspaceID)
+}, "usage.list")
+
+const listKeys = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(() => Key.list(), workspaceID)
+}, "key.list")
+
+export function NewUserSection() {
+ const params = useParams()
+ const [copiedKey, setCopiedKey] = createSignal(false)
+ const keys = createAsync(() => listKeys(params.id))
+ const usage = createAsync(() => getUsageInfo(params.id))
+ const isNew = createMemo(() => {
+ const keysList = keys()
+ const usageList = usage()
+ return keysList?.length === 1 && (!usageList || usageList.length === 0)
+ })
+ const defaultKey = createMemo(() => keys()?.at(-1)?.key)
+
+ return (
+ <Show when={isNew()}>
+ <div class={styles.root}>
+ <div data-component="feature-grid">
+ <div data-slot="feature">
+ <h3>Tested & Verified Models</h3>
+ <p>We've benchmarked and tested models specifically for coding agents to ensure the best performance.</p>
+ </div>
+ <div data-slot="feature">
+ <h3>Highest Quality</h3>
+ <p>Access models configured for optimal performance - no downgrades or routing to cheaper providers.</p>
+ </div>
+ <div data-slot="feature">
+ <h3>No Lock-in</h3>
+ <p>Use Zen with any coding agent, and continue using other providers with opencode whenever you want.</p>
+ </div>
+ </div>
+
+ <div data-component="api-key-highlight">
+ <Show when={defaultKey()}>
+ <div data-slot="key-display">
+ <div data-slot="key-container">
+ <code data-slot="key-value">{defaultKey()}</code>
+ <button
+ data-color="primary"
+ disabled={copiedKey()}
+ onClick={async () => {
+ await navigator.clipboard.writeText(defaultKey() ?? "")
+ setCopiedKey(true)
+ setTimeout(() => setCopiedKey(false), 2000)
+ }}
+ title="Copy API key"
+ >
+ <Show
+ when={copiedKey()}
+ fallback={
+ <>
+ <IconCopy style={{ width: "16px", height: "16px" }} /> Copy Key
+ </>
+ }
+ >
+ <IconCheck style={{ width: "16px", height: "16px" }} /> Copied!
+ </Show>
+ </button>
+ </div>
+ </div>
+ </Show>
+ </div>
+
+ <div data-component="next-steps">
+ <ol>
+ <li>Enable billing</li>
+ <li>
+ Run <code>opencode auth login</code> and select opencode
+ </li>
+ <li>Paste your API key</li>
+ <li>
+ Start opencode and run <code>/models</code> to select a model
+ </li>
+ </ol>
+ </div>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/console/app/src/component/workspace/payment-section.module.css b/packages/console/app/src/component/workspace/payment-section.module.css
new file mode 100644
index 000000000..ea8e2ed42
--- /dev/null
+++ b/packages/console/app/src/component/workspace/payment-section.module.css
@@ -0,0 +1,72 @@
+.root {
+ [data-slot="payments-table"] {
+ overflow-x: auto;
+ }
+
+ [data-slot="payments-table-element"] {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-sm);
+
+ thead {
+ border-bottom: 1px solid var(--color-border);
+ }
+
+ th {
+ padding: var(--space-3) var(--space-4);
+ text-align: left;
+ font-weight: normal;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ }
+
+ td {
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--color-border-muted);
+ color: var(--color-text-muted);
+ font-family: var(--font-mono);
+
+ &[data-slot="payment-date"] {
+ color: var(--color-text);
+ }
+
+ &[data-slot="payment-id"] {
+ font-family: var(--font-mono);
+ font-weight: 400;
+ color: var(--color-text-muted);
+ max-width: 200px;
+ word-break: break-word;
+ }
+
+ &[data-slot="payment-amount"] {
+ color: var(--color-text);
+ }
+ }
+
+ tbody tr {
+ &:last-child td {
+ border-bottom: none;
+ }
+ }
+
+ @media (max-width: 40rem) {
+ th,
+ td {
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-xs);
+ }
+
+ th {
+ &:nth-child(2) /* Payment ID */ {
+ display: none;
+ }
+ }
+
+ td {
+ &:nth-child(2) /* Payment ID */ {
+ display: none;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/component/workspace/payment-section.tsx b/packages/console/app/src/component/workspace/payment-section.tsx
new file mode 100644
index 000000000..7be51a581
--- /dev/null
+++ b/packages/console/app/src/component/workspace/payment-section.tsx
@@ -0,0 +1,113 @@
+import { Billing } from "@opencode/console-core/billing.js"
+import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
+import { For } from "solid-js"
+import { withActor } from "~/context/auth.withActor"
+import { formatDateUTC, formatDateForTable } from "./common"
+import styles from "./payment-section.module.css"
+
+const getPaymentsInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.payments()
+ }, workspaceID)
+}, "payment.list")
+
+const downloadReceipt = action(async (workspaceID: string, paymentID: string) => {
+ "use server"
+ return withActor(() => Billing.generateReceiptUrl({ paymentID }), workspaceID)
+}, "receipt.download")
+
+export function PaymentSection() {
+ const params = useParams()
+ // ORIGINAL CODE - COMMENTED OUT FOR TESTING
+ const payments = createAsync(() => getPaymentsInfo(params.id))
+ const downloadReceiptAction = useAction(downloadReceipt)
+
+ // DUMMY DATA FOR TESTING
+ // const payments = () => [
+ // {
+ // id: "pi_3QK1x2FT9vXn4A6r1234567890",
+ // paymentID: "pi_3QK1x2FT9vXn4A6r1234567890",
+ // timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // 1 day ago
+ // amount: 2100000000, // $21.00 ($20 + $1 fee)
+ // },
+ // {
+ // id: "pi_3QJ8k7FT9vXn4A6r0987654321",
+ // paymentID: "pi_3QJ8k7FT9vXn4A6r0987654321",
+ // timeCreated: new Date(Date.now() - 86400000 * 15).toISOString(), // 15 days ago
+ // amount: 2100000000, // $21.00
+ // },
+ // {
+ // id: "pi_3QI5m1FT9vXn4A6r5678901234",
+ // paymentID: "pi_3QI5m1FT9vXn4A6r5678901234",
+ // timeCreated: new Date(Date.now() - 86400000 * 32).toISOString(), // 32 days ago
+ // amount: 2100000000, // $21.00
+ // },
+ // {
+ // id: "pi_3QH2n9FT9vXn4A6r3456789012",
+ // paymentID: "pi_3QH2n9FT9vXn4A6r3456789012",
+ // timeCreated: new Date(Date.now() - 86400000 * 47).toISOString(), // 47 days ago
+ // amount: 2100000000, // $21.00
+ // },
+ // {
+ // id: "pi_3QG7p4FT9vXn4A6r7890123456",
+ // paymentID: "pi_3QG7p4FT9vXn4A6r7890123456",
+ // timeCreated: new Date(Date.now() - 86400000 * 63).toISOString(), // 63 days ago
+ // amount: 2100000000, // $21.00
+ // },
+ // ]
+
+ return (
+ payments() &&
+ payments()!.length > 0 && (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>Payments History</h2>
+ <p>Recent payment transactions.</p>
+ </div>
+ <div data-slot="payments-table">
+ <table data-slot="payments-table-element">
+ <thead>
+ <tr>
+ <th>Date</th>
+ <th>Payment ID</th>
+ <th>Amount</th>
+ <th>Receipt</th>
+ </tr>
+ </thead>
+ <tbody>
+ <For each={payments()!}>
+ {(payment) => {
+ const date = new Date(payment.timeCreated)
+ return (
+ <tr>
+ <td data-slot="payment-date" title={formatDateUTC(date)}>
+ {formatDateForTable(date)}
+ </td>
+ <td data-slot="payment-id">{payment.id}</td>
+ <td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td>
+ <td data-slot="payment-receipt">
+ <button
+ onClick={async () => {
+ const receiptUrl = await downloadReceiptAction(params.id, payment.paymentID!)
+ if (receiptUrl) {
+ window.open(receiptUrl, "_blank")
+ }
+ }}
+ data-slot="receipt-button"
+ style="cursor: pointer;"
+ >
+ view
+ </button>
+ </td>
+ </tr>
+ )
+ }}
+ </For>
+ </tbody>
+ </table>
+ </div>
+ </section>
+ )
+ )
+}
diff --git a/packages/console/app/src/component/workspace/usage-section.module.css b/packages/console/app/src/component/workspace/usage-section.module.css
new file mode 100644
index 000000000..1a772ba87
--- /dev/null
+++ b/packages/console/app/src/component/workspace/usage-section.module.css
@@ -0,0 +1,88 @@
+.root {
+ [data-component="empty-state"] {
+ padding: var(--space-20) var(--space-6);
+ text-align: center;
+ border: 1px dashed var(--color-border);
+ border-radius: var(--border-radius-sm);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+
+ p {
+ line-height: 1.5;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ }
+ }
+
+ [data-slot="usage-table"] {
+ overflow-x: auto;
+ }
+
+ [data-slot="usage-table-element"] {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-sm);
+
+ thead {
+ border-bottom: 1px solid var(--color-border);
+ }
+
+ th {
+ padding: var(--space-3) var(--space-4);
+ text-align: left;
+ font-weight: normal;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ }
+
+ td {
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--color-border-muted);
+ color: var(--color-text-muted);
+ font-family: var(--font-mono);
+
+ &[data-slot="usage-date"] {
+ color: var(--color-text);
+ }
+
+ &[data-slot="usage-model"] {
+ font-family: var(--font-sans);
+ font-weight: 400;
+ color: var(--color-text-secondary);
+ max-width: 200px;
+ word-break: break-word;
+ }
+
+ &[data-slot="usage-cost"] {
+ color: var(--color-text);
+ }
+ }
+
+ tbody tr {
+ &:last-child td {
+ border-bottom: none;
+ }
+ }
+
+ @media (max-width: 40rem) {
+ th,
+ td {
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-xs);
+ }
+
+ th {
+ &:nth-child(2) /* Model */ {
+ display: none;
+ }
+ }
+
+ td {
+ &:nth-child(2) /* Model */ {
+ display: none;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/component/workspace/usage-section.tsx b/packages/console/app/src/component/workspace/usage-section.tsx
new file mode 100644
index 000000000..e68670c6d
--- /dev/null
+++ b/packages/console/app/src/component/workspace/usage-section.tsx
@@ -0,0 +1,128 @@
+import { Billing } from "@opencode/console-core/billing.js"
+import { query, useParams, createAsync } from "@solidjs/router"
+import { createMemo, For, Show } from "solid-js"
+import { formatDateUTC, formatDateForTable } from "./common"
+import { withActor } from "~/context/auth.withActor"
+import styles from "./usage-section.module.css"
+
+const getUsageInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.usages()
+ }, workspaceID)
+}, "usage.list")
+
+export function UsageSection() {
+ const params = useParams()
+ // ORIGINAL CODE - COMMENTED OUT FOR TESTING
+ const usage = createAsync(() => getUsageInfo(params.id))
+
+ // DUMMY DATA FOR TESTING
+ // const usage = () => [
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 0).toISOString(), // Today
+ // model: "claude-3-5-sonnet-20241022",
+ // inputTokens: 1247,
+ // outputTokens: 423,
+ // cost: 125400000, // $1.254
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 0.5).toISOString(), // 12 hours ago
+ // model: "claude-3-haiku-20240307",
+ // inputTokens: 892,
+ // outputTokens: 156,
+ // cost: 23500000, // $0.235
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // Yesterday
+ // model: "claude-3-5-sonnet-20241022",
+ // inputTokens: 2134,
+ // outputTokens: 687,
+ // cost: 234700000, // $2.347
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 1.3).toISOString(), // 1.3 days ago
+ // model: "gpt-4o-mini",
+ // inputTokens: 567,
+ // outputTokens: 234,
+ // cost: 8900000, // $0.089
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 2).toISOString(), // 2 days ago
+ // model: "claude-3-opus-20240229",
+ // inputTokens: 1893,
+ // outputTokens: 945,
+ // cost: 445600000, // $4.456
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 2.7).toISOString(), // 2.7 days ago
+ // model: "gpt-4o",
+ // inputTokens: 1456,
+ // outputTokens: 532,
+ // cost: 156800000, // $1.568
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 3).toISOString(), // 3 days ago
+ // model: "claude-3-haiku-20240307",
+ // inputTokens: 634,
+ // outputTokens: 89,
+ // cost: 12300000, // $0.123
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 4).toISOString(), // 4 days ago
+ // model: "claude-3-5-sonnet-20241022",
+ // inputTokens: 3245,
+ // outputTokens: 1123,
+ // cost: 387200000, // $3.872
+ // },
+ // ]
+
+ return (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>Usage History</h2>
+ <p>Recent API usage and costs.</p>
+ </div>
+ <div data-slot="usage-table">
+ <Show
+ when={usage() && usage()!.length > 0}
+ fallback={
+ <div data-component="empty-state">
+ <p>Make your first API call to get started.</p>
+ </div>
+ }
+ >
+ <table data-slot="usage-table-element">
+ <thead>
+ <tr>
+ <th>Date</th>
+ <th>Model</th>
+ <th>Input</th>
+ <th>Output</th>
+ <th>Cost</th>
+ </tr>
+ </thead>
+ <tbody>
+ <For each={usage()!}>
+ {(usage) => {
+ const date = createMemo(() => new Date(usage.timeCreated))
+ return (
+ <tr>
+ <td data-slot="usage-date" title={formatDateUTC(date())}>
+ {formatDateForTable(date())}
+ </td>
+ <td data-slot="usage-model">{usage.model}</td>
+ <td data-slot="usage-tokens">{usage.inputTokens}</td>
+ <td data-slot="usage-tokens">{usage.outputTokens}</td>
+ <td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
+ </tr>
+ )
+ }}
+ </For>
+ </tbody>
+ </table>
+ </Show>
+ </div>
+ </section>
+ )
+}
diff --git a/packages/console/app/src/context/auth.session.ts b/packages/console/app/src/context/auth.session.ts
new file mode 100644
index 000000000..609bc364b
--- /dev/null
+++ b/packages/console/app/src/context/auth.session.ts
@@ -0,0 +1,23 @@
+import { useSession } from "vinxi/http"
+
+export interface AuthSession {
+ account?: Record<
+ string,
+ {
+ id: string
+ email: string
+ }
+ >
+ current?: string
+}
+
+export function useAuthSession() {
+ return useSession<AuthSession>({
+ password: "0".repeat(32),
+ name: "auth",
+ cookie: {
+ secure: false,
+ httpOnly: true,
+ },
+ })
+}
diff --git a/packages/console/app/src/context/auth.ts b/packages/console/app/src/context/auth.ts
new file mode 100644
index 000000000..027885241
--- /dev/null
+++ b/packages/console/app/src/context/auth.ts
@@ -0,0 +1,83 @@
+import { getRequestEvent } from "solid-js/web"
+import { and, Database, eq, inArray } from "@opencode/console-core/drizzle/index.js"
+import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
+import { UserTable } from "@opencode/console-core/schema/user.sql.js"
+import { redirect } from "@solidjs/router"
+import { AccountTable } from "@opencode/console-core/schema/account.sql.js"
+import { Actor } from "@opencode/console-core/actor.js"
+
+import { createClient } from "@openauthjs/openauth/client"
+import { useAuthSession } from "./auth.session"
+
+export const AuthClient = createClient({
+ clientID: "app",
+ issuer: import.meta.env.VITE_AUTH_URL,
+})
+
+export const getActor = async (workspace?: string): Promise<Actor.Info> => {
+ "use server"
+ const evt = getRequestEvent()
+ if (!evt) throw new Error("No request event")
+ if (evt.locals.actor) return evt.locals.actor
+ evt.locals.actor = (async () => {
+ const auth = await useAuthSession()
+ if (!workspace) {
+ const account = auth.data.account ?? {}
+ const current = account[auth.data.current ?? ""]
+ if (current) {
+ return {
+ type: "account",
+ properties: {
+ email: current.email,
+ accountID: current.id,
+ },
+ }
+ }
+ if (Object.keys(account).length > 0) {
+ const current = Object.values(account)[0]
+ await auth.update((val) => ({
+ ...val,
+ current: current.id,
+ }))
+ return {
+ type: "account",
+ properties: {
+ email: current.email,
+ accountID: current.id,
+ },
+ }
+ }
+ return {
+ type: "public",
+ properties: {},
+ }
+ }
+ const accounts = Object.keys(auth.data.account ?? {})
+ if (accounts.length) {
+ const result = await Database.transaction(async (tx) => {
+ return await tx
+ .select({
+ user: UserTable,
+ })
+ .from(AccountTable)
+ .innerJoin(UserTable, and(eq(UserTable.email, AccountTable.email)))
+ .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
+ .where(and(inArray(AccountTable.id, accounts), eq(WorkspaceTable.id, workspace)))
+ .limit(1)
+ .execute()
+ .then((x) => x[0])
+ })
+ if (result) {
+ return {
+ type: "user",
+ properties: {
+ userID: result.user.id,
+ workspaceID: result.user.workspaceID,
+ },
+ }
+ }
+ }
+ throw redirect("/auth/authorize")
+ })()
+ return evt.locals.actor
+}
diff --git a/packages/console/app/src/context/auth.withActor.ts b/packages/console/app/src/context/auth.withActor.ts
new file mode 100644
index 000000000..2cb970269
--- /dev/null
+++ b/packages/console/app/src/context/auth.withActor.ts
@@ -0,0 +1,7 @@
+import { Actor } from "@opencode/console-core/actor.js"
+import { getActor } from "./auth"
+
+export async function withActor<T>(fn: () => T, workspace?: string) {
+ const actor = await getActor(workspace)
+ return Actor.provide(actor.type, actor.properties, fn)
+}
diff --git a/packages/console/app/src/entry-client.tsx b/packages/console/app/src/entry-client.tsx
new file mode 100644
index 000000000..642deacf7
--- /dev/null
+++ b/packages/console/app/src/entry-client.tsx
@@ -0,0 +1,4 @@
+// @refresh reload
+import { mount, StartClient } from "@solidjs/start/client"
+
+mount(() => <StartClient />, document.getElementById("app")!)
diff --git a/packages/console/app/src/entry-server.tsx b/packages/console/app/src/entry-server.tsx
new file mode 100644
index 000000000..d5fca6aa5
--- /dev/null
+++ b/packages/console/app/src/entry-server.tsx
@@ -0,0 +1,28 @@
+// @refresh reload
+import { createHandler, StartServer } from "@solidjs/start/server"
+
+export default createHandler(
+ () => (
+ <StartServer
+ document={({ assets, children, scripts }) => (
+ <html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <link rel="icon" href="/favicon.svg" />
+ <meta property="og:image" content="/social-share.png" />
+ <meta property="twitter:image" content="/social-share.png" />
+ {assets}
+ </head>
+ <body>
+ <div id="app">{children}</div>
+ {scripts}
+ </body>
+ </html>
+ )}
+ />
+ ),
+ {
+ mode: "async",
+ },
+)
diff --git a/packages/console/app/src/global.d.ts b/packages/console/app/src/global.d.ts
new file mode 100644
index 000000000..dc6f10c22
--- /dev/null
+++ b/packages/console/app/src/global.d.ts
@@ -0,0 +1 @@
+/// <reference types="@solidjs/start/env" />
diff --git a/packages/console/app/src/middleware.ts b/packages/console/app/src/middleware.ts
new file mode 100644
index 000000000..b49473cbe
--- /dev/null
+++ b/packages/console/app/src/middleware.ts
@@ -0,0 +1,5 @@
+import { defineMiddleware } from "vinxi/http"
+
+export default defineMiddleware({
+ onBeforeResponse() {},
+})
diff --git a/packages/console/app/src/routes/[...404].css b/packages/console/app/src/routes/[...404].css
new file mode 100644
index 000000000..1edbd0a5a
--- /dev/null
+++ b/packages/console/app/src/routes/[...404].css
@@ -0,0 +1,130 @@
+[data-page="not-found"] {
+ --color-text: hsl(224, 10%, 10%);
+ --color-text-secondary: hsl(224, 7%, 46%);
+ --color-text-dimmed: hsl(224, 6%, 63%);
+ --color-text-inverted: hsl(0, 0%, 100%);
+
+ --color-border: hsl(224, 6%, 77%);
+}
+
+[data-page="not-found"] {
+ @media (prefers-color-scheme: dark) {
+ --color-text: hsl(0, 0%, 100%);
+ --color-text-secondary: hsl(224, 6%, 66%);
+ --color-text-dimmed: hsl(224, 7%, 46%);
+ --color-text-inverted: hsl(224, 10%, 10%);
+
+ --color-border: hsl(224, 6%, 36%);
+ }
+}
+
+[data-page="not-found"] {
+ --padding: 3rem;
+ --vertical-padding: 1.5rem;
+ --heading-font-size: 1.375rem;
+
+ @media (max-width: 30rem) {
+ --padding: 1rem;
+ --vertical-padding: 0.75rem;
+ --heading-font-size: 1rem;
+ }
+
+ font-family: var(--font-mono);
+ color: var(--color-text);
+ padding: calc(var(--padding) + 1rem);
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ a {
+ color: var(--color-text);
+ text-decoration: underline;
+ text-underline-offset: var(--space-0-75);
+ text-decoration-thickness: 1px;
+ }
+
+ [data-component="content"] {
+ max-width: 40rem;
+ width: 100%;
+ border: 1px solid var(--color-border);
+ }
+
+ [data-component="top"] {
+ padding: var(--padding);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: calc(var(--vertical-padding) / 2);
+ text-align: center;
+
+ [data-slot="logo-link"] {
+ text-decoration: none;
+ }
+
+ img {
+ height: auto;
+ width: clamp(200px, 85vw, 400px);
+ }
+
+ [data-slot="logo dark"] {
+ display: none;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ [data-slot="logo light"] {
+ display: none;
+ }
+ [data-slot="logo dark"] {
+ display: block;
+ }
+ }
+
+ [data-slot="title"] {
+ line-height: 1.25;
+ font-weight: 500;
+ text-align: center;
+ font-size: var(--heading-font-size);
+ color: var(--color-text);
+ text-transform: uppercase;
+ margin: 0;
+ }
+ }
+
+ [data-component="actions"] {
+ border-top: 1px solid var(--color-border);
+ display: flex;
+
+ [data-slot="action"] {
+ flex: 1;
+ text-align: center;
+ line-height: 1.4;
+ padding: var(--vertical-padding) 1rem;
+ text-transform: uppercase;
+ font-size: 1rem;
+
+ a {
+ display: block;
+ width: 100%;
+ height: 100%;
+ color: var(--color-text);
+ text-decoration: underline;
+ text-underline-offset: var(--space-0-75);
+ text-decoration-thickness: 1px;
+ }
+ }
+
+ [data-slot="action"] + [data-slot="action"] {
+ border-left: 1px solid var(--color-border);
+ }
+
+ @media (max-width: 40rem) {
+ flex-direction: column;
+
+ [data-slot="action"] + [data-slot="action"] {
+ border-left: none;
+ border-top: 1px solid var(--color-border);
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/routes/[...404].tsx b/packages/console/app/src/routes/[...404].tsx
new file mode 100644
index 000000000..ba2842b5a
--- /dev/null
+++ b/packages/console/app/src/routes/[...404].tsx
@@ -0,0 +1,38 @@
+import "./[...404].css"
+import { Title } from "@solidjs/meta"
+import { HttpStatusCode } from "@solidjs/start"
+import logoLight from "../asset/logo-ornate-light.svg"
+import logoDark from "../asset/logo-ornate-dark.svg"
+
+export default function NotFound() {
+ return (
+ <main data-page="not-found">
+ <Title>Not Found | opencode</Title>
+ <HttpStatusCode code={404} />
+ <div data-component="content">
+ <section data-component="top">
+ <a href="/" data-slot="logo-link">
+ <img data-slot="logo light" src={logoLight} alt="opencode logo light" />
+ <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
+ </a>
+ <h1 data-slot="title">404 - Page Not Found</h1>
+ </section>
+
+ <section data-component="actions">
+ <div data-slot="action">
+ <a href="/">Home</a>
+ </div>
+ <div data-slot="action">
+ <a href="/docs">Docs</a>
+ </div>
+ <div data-slot="action">
+ <a href="https://github.com/sst/opencode">GitHub</a>
+ </div>
+ <div data-slot="action">
+ <a href="/discord">Discord</a>
+ </div>
+ </section>
+ </div>
+ </main>
+ )
+}
diff --git a/packages/console/app/src/routes/auth/authorize.ts b/packages/console/app/src/routes/auth/authorize.ts
new file mode 100644
index 000000000..166466ef8
--- /dev/null
+++ b/packages/console/app/src/routes/auth/authorize.ts
@@ -0,0 +1,7 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { AuthClient } from "~/context/auth"
+
+export async function GET(input: APIEvent) {
+ const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code")
+ return Response.redirect(result.url, 302)
+}
diff --git a/packages/console/app/src/routes/auth/callback.ts b/packages/console/app/src/routes/auth/callback.ts
new file mode 100644
index 000000000..23025b54d
--- /dev/null
+++ b/packages/console/app/src/routes/auth/callback.ts
@@ -0,0 +1,31 @@
+import { redirect } from "@solidjs/router"
+import type { APIEvent } from "@solidjs/start/server"
+import { AuthClient } from "~/context/auth"
+import { useAuthSession } from "~/context/auth.session"
+
+export async function GET(input: APIEvent) {
+ const url = new URL(input.request.url)
+ const code = url.searchParams.get("code")
+ if (!code) throw new Error("No code found")
+ const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
+ if (result.err) {
+ throw new Error(result.err.message)
+ }
+ const decoded = AuthClient.decode(result.tokens.access, {} as any)
+ if (decoded.err) throw new Error(decoded.err.message)
+ const session = await useAuthSession()
+ const id = decoded.subject.properties.accountID
+ await session.update((value) => {
+ return {
+ ...value,
+ account: {
+ [id]: {
+ id,
+ email: decoded.subject.properties.email,
+ },
+ },
+ current: id,
+ }
+ })
+ return redirect("/auth")
+}
diff --git a/packages/console/app/src/routes/auth/index.ts b/packages/console/app/src/routes/auth/index.ts
new file mode 100644
index 000000000..2c893185f
--- /dev/null
+++ b/packages/console/app/src/routes/auth/index.ts
@@ -0,0 +1,13 @@
+import { Account } from "@opencode/console-core/account.js"
+import { redirect } from "@solidjs/router"
+import type { APIEvent } from "@solidjs/start/server"
+import { withActor } from "~/context/auth.withActor"
+
+export async function GET(input: APIEvent) {
+ try {
+ const workspaces = await withActor(async () => Account.workspaces())
+ return redirect(`/workspace/${workspaces[0].id}`)
+ } catch {
+ return redirect("/auth/authorize")
+ }
+}
diff --git a/packages/console/app/src/routes/debug/index.ts b/packages/console/app/src/routes/debug/index.ts
new file mode 100644
index 000000000..39fa33d90
--- /dev/null
+++ b/packages/console/app/src/routes/debug/index.ts
@@ -0,0 +1,13 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { json } from "@solidjs/router"
+import { Database } from "@opencode/console-core/drizzle/index.js"
+import { UserTable } from "@opencode/console-core/schema/user.sql.js"
+
+export async function GET(evt: APIEvent) {
+ return json({
+ data: await Database.use(async (tx) => {
+ const result = await tx.$count(UserTable)
+ return result
+ }),
+ })
+}
diff --git a/packages/console/app/src/routes/discord.ts b/packages/console/app/src/routes/discord.ts
new file mode 100644
index 000000000..7088295da
--- /dev/null
+++ b/packages/console/app/src/routes/discord.ts
@@ -0,0 +1,5 @@
+import { redirect } from "@solidjs/router"
+
+export async function GET() {
+ return redirect("https://discord.gg/opencode")
+}
diff --git a/packages/console/app/src/routes/docs/[...path].ts b/packages/console/app/src/routes/docs/[...path].ts
new file mode 100644
index 000000000..f07781583
--- /dev/null
+++ b/packages/console/app/src/routes/docs/[...path].ts
@@ -0,0 +1,20 @@
+import type { APIEvent } from "@solidjs/start/server"
+
+async function handler(evt: APIEvent) {
+ const req = evt.request.clone()
+ const url = new URL(req.url)
+ const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}`
+ const response = await fetch(targetUrl, {
+ method: req.method,
+ headers: req.headers,
+ body: req.body,
+ })
+ return response
+}
+
+export const GET = handler
+export const POST = handler
+export const PUT = handler
+export const DELETE = handler
+export const OPTIONS = handler
+export const PATCH = handler
diff --git a/packages/console/app/src/routes/docs/index.ts b/packages/console/app/src/routes/docs/index.ts
new file mode 100644
index 000000000..f07781583
--- /dev/null
+++ b/packages/console/app/src/routes/docs/index.ts
@@ -0,0 +1,20 @@
+import type { APIEvent } from "@solidjs/start/server"
+
+async function handler(evt: APIEvent) {
+ const req = evt.request.clone()
+ const url = new URL(req.url)
+ const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}`
+ const response = await fetch(targetUrl, {
+ method: req.method,
+ headers: req.headers,
+ body: req.body,
+ })
+ return response
+}
+
+export const GET = handler
+export const POST = handler
+export const PUT = handler
+export const DELETE = handler
+export const OPTIONS = handler
+export const PATCH = handler
diff --git a/packages/console/app/src/routes/index.css b/packages/console/app/src/routes/index.css
new file mode 100644
index 000000000..fe95bb7ea
--- /dev/null
+++ b/packages/console/app/src/routes/index.css
@@ -0,0 +1,504 @@
+[data-page="home"] {
+ --color-text: hsl(224, 10%, 10%);
+ --color-text-secondary: hsl(224, 7%, 46%);
+ --color-text-dimmed: hsl(224, 6%, 63%);
+ --color-text-inverted: hsl(0, 0%, 100%);
+
+ --color-border: hsl(224, 6%, 77%);
+}
+
+[data-page="home"] {
+ @media (prefers-color-scheme: dark) {
+ --color-text: hsl(0, 0%, 100%);
+ --color-text-secondary: hsl(224, 6%, 66%);
+ --color-text-dimmed: hsl(224, 7%, 46%);
+ --color-text-inverted: hsl(224, 10%, 10%);
+
+ --color-border: hsl(224, 6%, 36%);
+ }
+}
+
+[data-page="home"] {
+ --padding: 3rem;
+ --vertical-padding: 1.5rem;
+ --heading-font-size: 1.375rem;
+
+ @media (max-width: 30rem) {
+ --padding: 1rem;
+ --vertical-padding: 0.75rem;
+ --heading-font-size: 1rem;
+ }
+
+ display: flex;
+ gap: var(--vertical-padding);
+ flex-direction: column;
+ font-family: var(--font-mono);
+ color: var(--color-text);
+ padding: calc(var(--padding) + 1rem);
+
+ a {
+ color: var(--color-text);
+ text-decoration: underline;
+ text-underline-offset: var(--space-0-75);
+ text-decoration-thickness: 1px;
+ }
+
+ [data-component="content"] {
+ max-width: 67.5rem;
+ margin: 0 auto;
+ border: 1px solid var(--color-border);
+ }
+
+ [data-component="top"] {
+ padding: calc(var(--padding) * 1.5) var(--padding) var(--padding);
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: calc(var(--vertical-padding) / 2);
+
+ img {
+ height: auto;
+ width: clamp(200px, 85vw, 552px);
+ }
+
+ [data-slot="logo dark"] {
+ display: none;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ [data-slot="logo light"] {
+ display: none;
+ }
+ [data-slot="logo dark"] {
+ display: block;
+ }
+ }
+
+ [data-slot="title"] {
+ line-height: 1.25;
+ font-weight: 500;
+ text-align: center;
+ font-size: var(--heading-font-size);
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ }
+
+ [data-slot="login"] {
+ position: absolute;
+ top: 0;
+ right: 0;
+ border-width: 0 0 1px 1px;
+ border-style: solid;
+ border-color: var(--color-border);
+ background-color: var(--color-bg);
+
+ @media (max-width: 30rem) {
+ display: none;
+ }
+
+ a {
+ display: block;
+ padding: 0.5rem 1rem calc(0.5rem + 4px);
+ }
+ }
+ }
+
+ [data-component="cta"] {
+ border-top: 1px solid var(--color-border);
+ display: flex;
+
+ & > div + div {
+ border-left: 1px solid var(--color-border);
+ }
+
+ [data-slot="left"] {
+ flex: 0 0 auto;
+ text-align: center;
+ line-height: 1.4;
+ padding: var(--vertical-padding) 2rem;
+ text-transform: uppercase;
+ font-size: 1.125rem;
+
+ @media (max-width: 30rem) {
+ font-size: 1rem;
+ padding-bottom: calc(var(--vertical-padding) + 4px);
+ }
+
+ @media (max-width: 30rem) {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+ }
+ }
+
+ [data-slot="center"] {
+ display: none;
+
+ @media (max-width: 30rem) {
+ display: block;
+ flex: 1;
+ text-align: center;
+ padding: var(--vertical-padding) 0.5rem;
+ border-top: 1px solid var(--color-border);
+ border-left: none;
+ }
+ }
+
+ [data-slot="right"] {
+ flex: 1;
+ padding: var(--vertical-padding) 1rem;
+ }
+
+ @media (max-width: 50rem) {
+ flex-direction: column;
+
+ [data-slot="right"] {
+ border-left: none;
+ border-top: 1px solid var(--color-border);
+ }
+ }
+
+ [data-slot="command"] {
+ all: unset;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: var(--color-text-secondary);
+ font-size: 1.125rem;
+ font-family: var(--font-mono);
+ gap: var(--space-2);
+ width: 100%;
+
+ & > span {
+ @media (max-width: 24rem) {
+ font-size: 0.875rem;
+ }
+ @media (max-width: 56rem) {
+ [data-slot="protocol"] {
+ display: none;
+ }
+ }
+ @media (max-width: 38rem) {
+ text-align: center;
+ span:first-child {
+ display: block;
+ }
+ }
+ }
+ }
+
+ [data-slot="highlight"] {
+ color: var(--color-text);
+ font-weight: 500;
+ }
+ }
+
+ [data-component="features"] {
+ border-top: 1px solid var(--color-border);
+ padding: var(--padding);
+
+ [data-slot="list"] {
+ padding-left: var(--space-4);
+ margin: 0;
+ list-style: disc;
+
+ li {
+ margin-bottom: var(--space-4);
+ line-height: 1.6;
+
+ strong {
+ text-transform: uppercase;
+ font-weight: 600;
+ }
+
+ label {
+ line-height: 1;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ letter-spacing: 0.03125rem;
+ background: var(--color-border);
+ padding: 0.125rem 0.375rem;
+ color: var(--color-text-inverted);
+ }
+ }
+
+ li:last-child {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ [data-component="install"] {
+ border-top: 1px solid var(--color-border);
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+
+ @media (max-width: 40rem) {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto;
+ }
+ }
+
+ [data-component="method"] {
+ display: flex;
+ padding: calc(var(--vertical-padding) / 2) calc(var(--padding) / 2) calc(var(--vertical-padding) / 2 + 0.125rem);
+ flex-direction: column;
+ text-align: left;
+ gap: var(--space-2-5);
+
+ @media (max-width: 30rem) {
+ gap: 0.3125rem;
+ }
+
+ @media (max-width: 40rem) {
+ text-align: left;
+ }
+
+ &:nth-child(2) {
+ border-left: 1px solid var(--color-border);
+
+ @media (max-width: 40rem) {
+ border-left: none;
+ border-top: 1px solid var(--color-border);
+ }
+ }
+
+ &:nth-child(3) {
+ border-top: 1px solid var(--color-border);
+ }
+
+ &:nth-child(4) {
+ border-top: 1px solid var(--color-border);
+ border-left: 1px solid var(--color-border);
+
+ @media (max-width: 40rem) {
+ border-left: none;
+ }
+ }
+
+ [data-component="title"] {
+ letter-spacing: -0.03125rem;
+ text-transform: uppercase;
+ font-weight: normal;
+ font-size: 1rem;
+ flex-shrink: 0;
+ color: var(--color-text-dimmed);
+
+ @media (max-width: 30rem) {
+ font-size: 0.75rem;
+ }
+ }
+
+ [data-slot="button"] {
+ all: unset;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ color: var(--color-text-secondary);
+ gap: var(--space-2-5);
+ font-size: 1rem;
+
+ @media (max-width: 24rem) {
+ font-size: 0.875rem;
+ }
+
+ strong {
+ color: var(--color-text);
+ font-weight: 500;
+ }
+
+ @media (max-width: 40rem) {
+ justify-content: flex-start;
+ }
+
+ @media (max-width: 30rem) {
+ justify-content: center;
+ }
+ }
+ }
+
+ [data-component="screenshots"] {
+ border-top: 1px solid var(--color-border);
+
+ figure {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--padding) / 4);
+ padding: calc(var(--padding) / 2);
+ border-width: 0;
+ border-style: solid;
+ border-color: var(--color-border);
+ min-height: 0;
+ overflow: hidden;
+
+ & > div,
+ figcaption {
+ display: flex;
+ align-items: center;
+ }
+
+ & > div {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ a {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ }
+
+ figcaption {
+ letter-spacing: -0.03125rem;
+ text-transform: uppercase;
+ color: var(--color-text-dimmed);
+ flex-shrink: 0;
+
+ @media (max-width: 30rem) {
+ font-size: 0.75rem;
+ }
+ }
+ }
+
+ & > [data-slot="left"] figure {
+ height: var(--images-height);
+ box-sizing: border-box;
+ }
+
+ & > [data-slot="right"] figure {
+ height: calc(var(--images-height) / 2);
+ box-sizing: border-box;
+ }
+
+ & > [data-slot="left"] img {
+ width: 100%;
+ height: 100%;
+ min-width: 0;
+ object-fit: contain;
+ }
+
+ & > [data-slot="right"] img {
+ width: 100%;
+ height: calc(100% - 2rem);
+ object-fit: contain;
+ display: block;
+ }
+
+ @media (max-width: 30rem) {
+ & {
+ --images-height: auto;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto;
+ }
+
+ & > [data-slot="left"] {
+ grid-row: 1;
+ grid-column: 1;
+ }
+
+ & > [data-slot="right"] {
+ grid-row: 2;
+ grid-column: 1;
+ border-left: none;
+ border-top: 1px solid var(--color-border);
+
+ & > [data-slot="row1"],
+ & > [data-slot="row2"] {
+ height: auto;
+ }
+ }
+
+ & > [data-slot="left"] figure,
+ & > [data-slot="right"] figure {
+ height: auto;
+ }
+
+ & > [data-slot="left"] img,
+ & > [data-slot="right"] img {
+ width: 100%;
+ height: auto;
+ max-height: none;
+ }
+ }
+ }
+
+ [data-component="copy-status"] {
+ @media (max-width: 38rem) {
+ display: none;
+ }
+
+ [data-slot="copy"] {
+ display: block;
+ width: var(--space-4);
+ height: var(--space-4);
+ color: var(--color-text-dimmed);
+
+ [data-copied] & {
+ display: none;
+ }
+ }
+
+ [data-slot="check"] {
+ display: none;
+ width: var(--space-4);
+ height: var(--space-4);
+ color: var(--color-text);
+
+ [data-copied] & {
+ display: block;
+ }
+ }
+ }
+
+ [data-component="footer"] {
+ border-top: 1px solid var(--color-border);
+ display: flex;
+ flex-direction: row;
+
+ [data-slot="cell"] {
+ flex: 1;
+ text-align: center;
+ text-transform: uppercase;
+ padding: var(--vertical-padding) 0.5rem;
+ }
+
+ [data-slot="cell"] + [data-slot="cell"] {
+ border-left: 1px solid var(--color-border);
+ }
+
+ /* Mobile: third column on its own row */
+ @media (max-width: 30rem) {
+ flex-wrap: wrap;
+
+ [data-slot="cell"]:nth-child(1),
+ [data-slot="cell"]:nth-child(2) {
+ flex: 1;
+ }
+
+ [data-slot="cell"]:nth-child(3) {
+ flex: 1 0 100%;
+ border-left: none;
+ border-top: 1px solid var(--color-border);
+ }
+ }
+ }
+
+ [data-component="legal"] {
+ color: var(--color-text-dimmed);
+ text-align: center;
+
+ a {
+ color: var(--color-text-dimmed);
+ }
+ }
+}
diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx
new file mode 100644
index 000000000..e8c1998ae
--- /dev/null
+++ b/packages/console/app/src/routes/index.tsx
@@ -0,0 +1,183 @@
+import "./index.css"
+import { Title } from "@solidjs/meta"
+import { onCleanup, onMount } from "solid-js"
+import logoLight from "../asset/logo-ornate-light.svg"
+import logoDark from "../asset/logo-ornate-dark.svg"
+import IMG_SPLASH from "../asset/lander/screenshot-splash.png"
+import { IconCopy, IconCheck } from "../component/icon"
+import { createAsync, query } from "@solidjs/router"
+import { getActor } from "~/context/auth"
+import { withActor } from "~/context/auth.withActor"
+import { Account } from "@opencode/console-core/account.js"
+
+function CopyStatus() {
+ return (
+ <div data-component="copy-status">
+ <IconCopy data-slot="copy" />
+ <IconCheck data-slot="check" />
+ </div>
+ )
+}
+
+const defaultWorkspace = query(async () => {
+ "use server"
+ const actor = await getActor()
+ if (actor.type === "account") {
+ const workspaces = await withActor(() => Account.workspaces())
+ return workspaces[0].id
+ }
+}, "defaultWorkspace")
+
+export default function Home() {
+ const workspace = createAsync(() => defaultWorkspace())
+ onMount(() => {
+ const commands = document.querySelectorAll("[data-copy]")
+ for (const button of commands) {
+ const callback = () => {
+ const text = button.textContent
+ if (text) {
+ navigator.clipboard.writeText(text)
+ button.setAttribute("data-copied", "")
+ setTimeout(() => {
+ button.removeAttribute("data-copied")
+ }, 1500)
+ }
+ }
+ button.addEventListener("click", callback)
+ onCleanup(() => {
+ button.removeEventListener("click", callback)
+ })
+ }
+ })
+
+ return (
+ <main data-page="home">
+ <Title>opencode | AI coding agent built for the terminal</Title>
+
+ <div data-component="content">
+ <section data-component="top">
+ <img data-slot="logo light" src={logoLight} alt="opencode logo light" />
+ <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
+ <h1 data-slot="title">The AI coding agent built for the terminal</h1>
+ <div data-slot="login">
+ <a href="/auth">opencode zen</a>
+ </div>
+ </section>
+
+ <section data-component="cta">
+ <div data-slot="left">
+ <a href="/docs">Get Started</a>
+ </div>
+ <div data-slot="center">
+ <a href="/auth">opencode zen</a>
+ </div>
+ <div data-slot="right">
+ <button data-copy data-slot="command">
+ <span>
+ <span>curl -fsSL </span>
+ <span data-slot="protocol">https://</span>
+ <span data-slot="highlight">opencode.ai/install</span>
+ <span> | bash</span>
+ </span>
+ <CopyStatus />
+ </button>
+ </div>
+ </section>
+
+ <section data-component="features">
+ <ul data-slot="list">
+ <li>
+ <strong>Native TUI</strong> A responsive, native, themeable terminal UI
+ </li>
+ <li>
+ <strong>LSP enabled</strong> Automatically loads the right LSPs for the LLM
+ </li>
+ <li>
+ <strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a> provided by opencode{" "}
+ <label>New</label>
+ </li>
+ <li>
+ <strong>Multi-session</strong> Start multiple agents in parallel on the same project
+ </li>
+ <li>
+ <strong>Shareable links</strong> Share a link to any sessions for reference or to debug
+ </li>
+ <li>
+ <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account
+ </li>
+ <li>
+ <strong>Use any model</strong> Supports 75+ LLM providers through{" "}
+ <a href="https://models.dev">Models.dev</a>, including local models
+ </li>
+ </ul>
+ </section>
+
+ <section data-component="install">
+ <div data-component="method">
+ <h3 data-component="title">npm</h3>
+ <button data-copy data-slot="button">
+ <span>
+ npm install -g <strong>opencode-ai</strong>
+ </span>
+ <CopyStatus />
+ </button>
+ </div>
+ <div data-component="method">
+ <h3 data-component="title">bun</h3>
+ <button data-copy data-slot="button">
+ <span>
+ bun install -g <strong>opencode-ai</strong>
+ </span>
+ <CopyStatus />
+ </button>
+ </div>
+ <div data-component="method">
+ <h3 data-component="title">homebrew</h3>
+ <button data-copy data-slot="button">
+ <span>
+ brew install <strong>sst/tap/opencode</strong>
+ </span>
+ <CopyStatus />
+ </button>
+ </div>
+ <div data-component="method">
+ <h3 data-component="title">paru</h3>
+ <button data-copy data-slot="button">
+ <span>
+ paru -S <strong>opencode-bin</strong>
+ </span>
+ <CopyStatus />
+ </button>
+ </div>
+ </section>
+
+ <section data-component="screenshots">
+ <figure>
+ <figcaption>opencode TUI with the tokyonight theme</figcaption>
+ <a href="/docs/cli">
+ <img src={IMG_SPLASH} alt="opencode TUI with tokyonight theme" />
+ </a>
+ </figure>
+ </section>
+
+ <footer data-component="footer">
+ <div data-slot="cell">
+ <a href="https://x.com/opencode">X.com</a>
+ </div>
+ <div data-slot="cell">
+ <a href="https://github.com/sst/opencode">GitHub</a>
+ </div>
+ <div data-slot="cell">
+ <a href="https://opencode.ai/discord">Discord</a>
+ </div>
+ </footer>
+ </div>
+
+ <div data-component="legal">
+ <span>
+ ©2025 <a href="https://anoma.ly">Anomaly Innovations</a>
+ </span>
+ </div>
+ </main>
+ )
+}
diff --git a/packages/console/app/src/routes/s/[id].ts b/packages/console/app/src/routes/s/[id].ts
new file mode 100644
index 000000000..3fd1305a0
--- /dev/null
+++ b/packages/console/app/src/routes/s/[id].ts
@@ -0,0 +1,20 @@
+import type { APIEvent } from "@solidjs/start/server"
+
+async function handler(evt: APIEvent) {
+ const req = evt.request.clone()
+ const url = new URL(req.url)
+ const targetUrl = `https://docs.opencode.ai/docs${url.pathname}${url.search}`
+ const response = await fetch(targetUrl, {
+ method: req.method,
+ headers: req.headers,
+ body: req.body,
+ })
+ return response
+}
+
+export const GET = handler
+export const POST = handler
+export const PUT = handler
+export const DELETE = handler
+export const OPTIONS = handler
+export const PATCH = handler
diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts
new file mode 100644
index 000000000..920966286
--- /dev/null
+++ b/packages/console/app/src/routes/stripe/webhook.ts
@@ -0,0 +1,98 @@
+import { Billing } from "@opencode/console-core/billing.js"
+import type { APIEvent } from "@solidjs/start/server"
+import { Database, eq, sql } from "@opencode/console-core/drizzle/index.js"
+import { BillingTable, PaymentTable } from "@opencode/console-core/schema/billing.sql.js"
+import { Identifier } from "@opencode/console-core/identifier.js"
+import { centsToMicroCents } from "@opencode/console-core/util/price.js"
+import { Actor } from "@opencode/console-core/actor.js"
+import { Resource } from "@opencode/console-resource"
+
+export async function POST(input: APIEvent) {
+ const body = await Billing.stripe().webhooks.constructEventAsync(
+ await input.request.text(),
+ input.request.headers.get("stripe-signature")!,
+ Resource.STRIPE_WEBHOOK_SECRET.value,
+ )
+
+ console.log(body.type, JSON.stringify(body, null, 2))
+ if (body.type === "customer.updated") {
+ // check default payment method changed
+ const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {}
+ if (!("default_payment_method" in prevInvoiceSettings)) return
+
+ const customerID = body.data.object.id
+ const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string
+
+ if (!customerID) throw new Error("Customer ID not found")
+ if (!paymentMethodID) throw new Error("Payment method ID not found")
+
+ const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID)
+ await Database.use(async (tx) => {
+ await tx
+ .update(BillingTable)
+ .set({
+ paymentMethodID,
+ paymentMethodLast4: paymentMethod.card!.last4,
+ })
+ .where(eq(BillingTable.customerID, customerID))
+ })
+ }
+ if (body.type === "checkout.session.completed") {
+ const workspaceID = body.data.object.metadata?.workspaceID
+ const customerID = body.data.object.customer as string
+ const paymentID = body.data.object.payment_intent as string
+ const amount = body.data.object.amount_total
+
+ if (!workspaceID) throw new Error("Workspace ID not found")
+ if (!customerID) throw new Error("Customer ID not found")
+ if (!amount) throw new Error("Amount not found")
+ if (!paymentID) throw new Error("Payment ID not found")
+
+ await Actor.provide("system", { workspaceID }, async () => {
+ const customer = await Billing.get()
+ if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
+
+ // set customer metadata
+ if (!customer?.customerID) {
+ await Billing.stripe().customers.update(customerID, {
+ metadata: {
+ workspaceID,
+ },
+ })
+ }
+
+ // get payment method for the payment intent
+ const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
+ expand: ["payment_method"],
+ })
+ const paymentMethod = paymentIntent.payment_method
+ if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
+
+ await Database.transaction(async (tx) => {
+ await tx
+ .update(BillingTable)
+ .set({
+ balance: sql`${BillingTable.balance} + ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
+ customerID,
+ paymentMethodID: paymentMethod.id,
+ paymentMethodLast4: paymentMethod.card!.last4,
+ reload: true,
+ reloadError: null,
+ timeReloadError: null,
+ })
+ .where(eq(BillingTable.workspaceID, workspaceID))
+ await tx.insert(PaymentTable).values({
+ workspaceID,
+ id: Identifier.create("payment"),
+ amount: centsToMicroCents(Billing.CHARGE_AMOUNT),
+ paymentID,
+ customerID,
+ })
+ })
+ })
+ }
+
+ console.log("finished handling")
+
+ return Response.json("ok", { status: 200 })
+}
diff --git a/packages/console/app/src/routes/workspace.css b/packages/console/app/src/routes/workspace.css
new file mode 100644
index 000000000..ed94365f0
--- /dev/null
+++ b/packages/console/app/src/routes/workspace.css
@@ -0,0 +1,127 @@
+[data-page="workspace"] {
+ line-height: 1;
+
+ /* Common elements */
+ button {
+ padding: var(--space-3) var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-sans);
+ font-weight: 500;
+ text-transform: uppercase;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-surface-hover);
+ border-color: var(--color-accent);
+ }
+
+ &:active {
+ transform: translateY(1px);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ transform: none;
+ }
+
+ &[data-color="primary"] {
+ background-color: var(--color-primary);
+ border-color: var(--color-primary);
+ color: var(--color-primary-text);
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-primary-hover);
+ border-color: var(--color-primary-hover);
+ }
+ }
+
+ &[data-color="ghost"] {
+ background-color: transparent;
+ border-color: transparent;
+ color: var(--color-text-muted);
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-surface-hover);
+ border-color: var(--color-border);
+ color: var(--color-text);
+ }
+ }
+ }
+
+ a {
+ color: var(--color-text);
+ text-decoration: underline;
+ text-underline-offset: var(--space-0-75);
+ text-decoration-thickness: 1px;
+ }
+
+ /* Workspace Header */
+ [data-component="workspace-header"] {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-4) var(--space-4);
+ border-bottom: 1px solid var(--color-border);
+ background-color: var(--color-bg);
+
+ @media (max-width: 30rem) {
+ padding: var(--space-4) var(--space-4);
+ }
+ }
+
+ [data-slot="header-brand"] {
+ flex: 0 0 auto;
+ padding-top: 4px;
+
+ svg {
+ width: 138px;
+ }
+
+ [data-component="site-title"] {
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ color: var(--color-text);
+ text-decoration: none;
+ letter-spacing: -0.02em;
+ }
+ }
+
+ [data-slot="header-actions"] {
+ display: flex;
+ gap: var(--space-4);
+ align-items: center;
+ font-size: var(--font-size-sm);
+
+ [data-slot="user"] {
+ color: var(--color-text-muted);
+ }
+
+ @media (max-width: 30rem) {
+ [data-slot="user"] {
+ display: none;
+ }
+ }
+
+ a,
+ button {
+ appearance: none;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ color: var(--color-text);
+ text-decoration: underline;
+ text-underline-offset: var(--space-0-75);
+ text-decoration-thickness: 1px;
+ text-transform: uppercase;
+ }
+ }
+}
diff --git a/packages/console/app/src/routes/workspace.tsx b/packages/console/app/src/routes/workspace.tsx
new file mode 100644
index 000000000..3aa3f20d3
--- /dev/null
+++ b/packages/console/app/src/routes/workspace.tsx
@@ -0,0 +1,67 @@
+import "./workspace.css"
+import { useAuthSession } from "~/context/auth.session"
+import { IconLogo } from "../component/icon"
+import { withActor } from "~/context/auth.withActor"
+import {
+ query,
+ action,
+ redirect,
+ createAsync,
+ RouteSectionProps,
+ Navigate,
+ useNavigate,
+ useParams,
+ A,
+} from "@solidjs/router"
+import { User } from "@opencode/console-core/user.js"
+import { Actor } from "@opencode/console-core/actor.js"
+import { getRequestEvent } from "solid-js/web"
+
+const getUserInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ const actor = Actor.assert("user")
+ return await User.fromID(actor.properties.userID)
+ }, workspaceID)
+}, "userInfo")
+
+const logout = action(async () => {
+ "use server"
+ const auth = await useAuthSession()
+ const event = getRequestEvent()
+ const current = auth.data.current
+ if (current)
+ await auth.update((val) => {
+ delete val.account?.[current]
+ const first = Object.keys(val.account ?? {})[0]
+ val.current = first
+ event!.locals.actor = undefined
+ return val
+ })
+ throw redirect("/")
+})
+
+export default function WorkspaceLayout(props: RouteSectionProps) {
+ const params = useParams()
+ const userInfo = createAsync(() => getUserInfo(params.id))
+ return (
+ <main data-page="workspace">
+ <header data-component="workspace-header">
+ <div data-slot="header-brand">
+ <A href="/" data-component="site-title">
+ <IconLogo />
+ </A>
+ </div>
+ <div data-slot="header-actions">
+ <span data-slot="user">{userInfo()?.email}</span>
+ <form action={logout} method="post">
+ <button type="submit" formaction={logout}>
+ Logout
+ </button>
+ </form>
+ </div>
+ </header>
+ <div>{props.children}</div>
+ </main>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/[id].css b/packages/console/app/src/routes/workspace/[id].css
new file mode 100644
index 000000000..8b318a19f
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id].css
@@ -0,0 +1,115 @@
+[data-page="workspace-[id]"] {
+ max-width: 64rem;
+ padding: var(--space-10) var(--space-4);
+ margin: 0 auto;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-10);
+
+ @media (max-width: 30rem) {
+ padding-top: var(--space-4);
+ padding-bottom: var(--space-4);
+
+ gap: var(--space-8);
+ }
+
+ [data-slot="sections"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-16);
+
+ @media (max-width: 30rem) {
+ gap: var(--space-8);
+ }
+
+ section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-8);
+
+ @media (max-width: 30rem) {
+ gap: var(--space-6);
+ }
+
+ /* Section titles */
+ [data-slot="section-title"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+
+ h2 {
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ line-height: 1.2;
+ letter-spacing: -0.03125rem;
+ margin: 0;
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+
+ @media (max-width: 30rem) {
+ font-size: var(--font-size-md);
+ }
+ }
+
+ p {
+ line-height: 1.5;
+ font-size: var(--font-size-md);
+ color: var(--color-text-muted);
+
+ a {
+ color: var(--color-text-muted);
+ }
+
+ @media (max-width: 30rem) {
+ font-size: var(--font-size-sm);
+ }
+ }
+ }
+ }
+ section:not(:last-child) {
+ border-bottom: 1px solid var(--color-border);
+ padding-bottom: var(--space-16);
+
+ @media (max-width: 30rem) {
+ padding-bottom: var(--space-8);
+ }
+ }
+ }
+
+ /* Title section */
+ [data-component="title-section"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding-bottom: var(--space-8);
+ border-bottom: 1px solid var(--color-border);
+
+ @media (max-width: 30rem) {
+ padding-bottom: var(--space-6);
+ }
+
+ h1 {
+ font-size: var(--font-size-2xl);
+ font-weight: 500;
+ line-height: 1.2;
+ letter-spacing: -0.03125rem;
+ margin: 0;
+ text-transform: uppercase;
+
+ @media (max-width: 30rem) {
+ font-size: var(--font-size-xl);
+ }
+ }
+
+ p {
+ line-height: 1.5;
+ font-size: var(--font-size-md);
+ color: var(--color-text-muted);
+
+ a {
+ color: var(--color-text-muted);
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx
new file mode 100644
index 000000000..68a706d5d
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id].tsx
@@ -0,0 +1,50 @@
+import "./[id].css"
+import { Billing } from "@opencode/console-core/billing.js"
+import { query, useParams, createAsync } from "@solidjs/router"
+import { Show } from "solid-js"
+import { withActor } from "~/context/auth.withActor"
+import { MonthlyLimitSection } from "~/component/workspace/monthly-limit-section"
+import { NewUserSection } from "~/component/workspace/new-user-section"
+import { BillingSection } from "~/component/workspace/billing-section"
+import { PaymentSection } from "~/component/workspace/payment-section"
+import { UsageSection } from "~/component/workspace/usage-section"
+import { KeySection } from "~/component/workspace/key-section"
+
+const getBillingInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.get()
+ }, workspaceID)
+}, "billing.get")
+
+export default function () {
+ const params = useParams()
+ const balanceInfo = createAsync(() => getBillingInfo(params.id))
+
+ return (
+ <div data-page="workspace-[id]">
+ <section data-component="title-section">
+ <h1>Zen</h1>
+ <p>
+ Curated list of models provided by opencode.{" "}
+ <a target="_blank" href="/docs/zen">
+ Learn more
+ </a>
+ .
+ </p>
+ </section>
+
+ <div data-slot="sections">
+ <NewUserSection />
+ <KeySection />
+ <BillingSection />
+ <Show when={true}>
+ {/*<Show when={balanceInfo()?.reload}>*/}
+ <MonthlyLimitSection />
+ </Show>
+ <UsageSection />
+ <PaymentSection />
+ </div>
+ </div>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/index.tsx b/packages/console/app/src/routes/workspace/index.tsx
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/index.tsx
diff --git a/packages/console/app/src/routes/zen/handler.ts b/packages/console/app/src/routes/zen/handler.ts
new file mode 100644
index 000000000..6065e2f76
--- /dev/null
+++ b/packages/console/app/src/routes/zen/handler.ts
@@ -0,0 +1,594 @@
+import type { APIEvent } from "@solidjs/start/server"
+import path from "node:path"
+import { and, Database, eq, isNull, lt, or, sql } from "@opencode/console-core/drizzle/index.js"
+import { KeyTable } from "@opencode/console-core/schema/key.sql.js"
+import { BillingTable, PaymentTable, UsageTable } from "@opencode/console-core/schema/billing.sql.js"
+import { centsToMicroCents } from "@opencode/console-core/util/price.js"
+import { Identifier } from "@opencode/console-core/identifier.js"
+import { Resource } from "@opencode/console-resource"
+import { Billing } from "../../../../core/src/billing"
+import { Actor } from "@opencode/console-core/actor.js"
+
+type ModelCost = {
+ input: number
+ output: number
+ cacheRead?: number
+ cacheWrite5m?: number
+ cacheWrite1h?: number
+}
+
+type Model = {
+ id: string
+ auth: boolean
+ cost: ModelCost | ((usage: any) => ModelCost)
+ headerMappings: Record<string, string>
+ providers: Record<
+ string,
+ {
+ api: string
+ apiKey: string
+ model: string
+ weight?: number
+ }
+ >
+}
+
+export async function handler(
+ input: APIEvent,
+ opts: {
+ modifyBody?: (body: any) => any
+ setAuthHeader: (headers: Headers, apiKey: string) => void
+ parseApiKey: (headers: Headers) => string | undefined
+ onStreamPart: (chunk: string) => void
+ getStreamUsage: () => any
+ normalizeUsage: (body: any) => {
+ inputTokens: number
+ outputTokens: number
+ reasoningTokens?: number
+ cacheReadTokens?: number
+ cacheWrite5mTokens?: number
+ cacheWrite1hTokens?: number
+ }
+ },
+) {
+ class AuthError extends Error {}
+ class CreditsError extends Error {}
+ class MonthlyLimitError extends Error {}
+ class ModelError extends Error {}
+
+ const MODELS: Record<string, Model> = {
+ "claude-opus-4-1": {
+ id: "claude-opus-4-1" as const,
+ auth: true,
+ cost: {
+ input: 0.000015,
+ output: 0.000075,
+ cacheRead: 0.0000015,
+ cacheWrite5m: 0.00001875,
+ cacheWrite1h: 0.00003,
+ },
+ headerMappings: {},
+ providers: {
+ anthropic: {
+ api: "https://api.anthropic.com",
+ apiKey: Resource.ANTHROPIC_API_KEY.value,
+ model: "claude-opus-4-1-20250805",
+ },
+ },
+ },
+ "claude-sonnet-4": {
+ id: "claude-sonnet-4" as const,
+ auth: true,
+ cost: (usage: any) => {
+ const totalInputTokens =
+ usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens
+ return totalInputTokens <= 200_000
+ ? {
+ input: 0.000003,
+ output: 0.000015,
+ cacheRead: 0.0000003,
+ cacheWrite5m: 0.00000375,
+ cacheWrite1h: 0.000006,
+ }
+ : {
+ input: 0.000006,
+ output: 0.0000225,
+ cacheRead: 0.0000006,
+ cacheWrite5m: 0.0000075,
+ cacheWrite1h: 0.000012,
+ }
+ },
+ headerMappings: {},
+ providers: {
+ anthropic: {
+ api: "https://api.anthropic.com",
+ apiKey: Resource.ANTHROPIC_API_KEY.value,
+ model: "claude-sonnet-4-20250514",
+ },
+ },
+ },
+ "claude-3-5-haiku": {
+ id: "claude-3-5-haiku" as const,
+ auth: true,
+ cost: {
+ input: 0.0000008,
+ output: 0.000004,
+ cacheRead: 0.00000008,
+ cacheWrite5m: 0.000001,
+ cacheWrite1h: 0.0000016,
+ },
+ headerMappings: {},
+ providers: {
+ anthropic: {
+ api: "https://api.anthropic.com",
+ apiKey: Resource.ANTHROPIC_API_KEY.value,
+ model: "claude-3-5-haiku-20241022",
+ },
+ },
+ },
+ "gpt-5": {
+ id: "gpt-5" as const,
+ auth: true,
+ cost: {
+ input: 0.00000125,
+ output: 0.00001,
+ cacheRead: 0.000000125,
+ },
+ headerMappings: {},
+ providers: {
+ openai: {
+ api: "https://api.openai.com",
+ apiKey: Resource.OPENAI_API_KEY.value,
+ model: "gpt-5",
+ },
+ },
+ },
+ "qwen3-coder": {
+ id: "qwen3-coder" as const,
+ auth: true,
+ cost: {
+ input: 0.00000045,
+ output: 0.0000018,
+ },
+ headerMappings: {},
+ providers: {
+ baseten: {
+ api: "https://inference.baseten.co",
+ apiKey: Resource.BASETEN_API_KEY.value,
+ model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
+ weight: 4,
+ },
+ fireworks: {
+ api: "https://api.fireworks.ai/inference",
+ apiKey: Resource.FIREWORKS_API_KEY.value,
+ model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
+ weight: 1,
+ },
+ },
+ },
+ "kimi-k2": {
+ id: "kimi-k2" as const,
+ auth: true,
+ cost: {
+ input: 0.0000006,
+ output: 0.0000025,
+ },
+ headerMappings: {},
+ providers: {
+ baseten: {
+ api: "https://inference.baseten.co",
+ apiKey: Resource.BASETEN_API_KEY.value,
+ model: "moonshotai/Kimi-K2-Instruct-0905",
+ //weight: 4,
+ },
+ //fireworks: {
+ // api: "https://api.fireworks.ai/inference",
+ // apiKey: Resource.FIREWORKS_API_KEY.value,
+ // model: "accounts/fireworks/models/kimi-k2-instruct-0905",
+ // weight: 1,
+ //},
+ },
+ },
+ "grok-code": {
+ id: "grok-code" as const,
+ auth: false,
+ cost: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ },
+ headerMappings: {
+ "x-grok-conv-id": "x-opencode-session",
+ "x-grok-req-id": "x-opencode-request",
+ },
+ providers: {
+ xai: {
+ api: "https://api.x.ai",
+ apiKey: Resource.XAI_API_KEY.value,
+ model: "grok-code",
+ },
+ },
+ },
+ // deprecated
+ "qwen/qwen3-coder": {
+ id: "qwen/qwen3-coder" as const,
+ auth: true,
+ cost: {
+ input: 0.00000038,
+ output: 0.00000153,
+ },
+ headerMappings: {},
+ providers: {
+ baseten: {
+ api: "https://inference.baseten.co",
+ apiKey: Resource.BASETEN_API_KEY.value,
+ model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
+ weight: 5,
+ },
+ fireworks: {
+ api: "https://api.fireworks.ai/inference",
+ apiKey: Resource.FIREWORKS_API_KEY.value,
+ model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
+ weight: 1,
+ },
+ },
+ },
+ }
+
+ const FREE_WORKSPACES = [
+ "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
+ ]
+
+ const logger = {
+ metric: (values: Record<string, any>) => {
+ console.log(`_metric:${JSON.stringify(values)}`)
+ },
+ log: console.log,
+ debug: (message: string) => {
+ if (Resource.App.stage === "production") return
+ console.debug(message)
+ },
+ }
+
+ try {
+ const url = new URL(input.request.url)
+ const body = await input.request.json()
+ logger.debug(JSON.stringify(body))
+ logger.metric({
+ is_tream: !!body.stream,
+ session: input.request.headers.get("x-opencode-session"),
+ request: input.request.headers.get("x-opencode-request"),
+ })
+ const MODEL = validateModel()
+ const apiKey = await authenticate()
+ const isFree = FREE_WORKSPACES.includes(apiKey?.workspaceID ?? "")
+ await checkCreditsAndLimit()
+ const providerName = selectProvider()
+ const providerData = MODEL.providers[providerName]
+ logger.metric({ provider: providerName })
+
+ // Request to model provider
+ const startTimestamp = Date.now()
+ const res = await fetch(path.posix.join(providerData.api, url.pathname.replace(/^\/zen/, "") + url.search), {
+ method: "POST",
+ headers: (() => {
+ const headers = input.request.headers
+ headers.delete("host")
+ headers.delete("content-length")
+ opts.setAuthHeader(headers, providerData.apiKey)
+ Object.entries(MODEL.headerMappings ?? {}).forEach(([k, v]) => {
+ headers.set(k, headers.get(v)!)
+ })
+ return headers
+ })(),
+ body: JSON.stringify({
+ ...(opts.modifyBody?.(body) ?? body),
+ model: providerData.model,
+ }),
+ })
+
+ // Scrub response headers
+ const resHeaders = new Headers()
+ const keepHeaders = ["content-type", "cache-control"]
+ for (const [k, v] of res.headers.entries()) {
+ if (keepHeaders.includes(k.toLowerCase())) {
+ resHeaders.set(k, v)
+ }
+ }
+
+ // Handle non-streaming response
+ if (!body.stream) {
+ const json = await res.json()
+ const body = JSON.stringify(json)
+ logger.metric({ response_length: body.length })
+ logger.debug(body)
+ await trackUsage(json.usage)
+ await reload()
+ return new Response(body, {
+ status: res.status,
+ statusText: res.statusText,
+ headers: resHeaders,
+ })
+ }
+
+ // Handle streaming response
+ const stream = new ReadableStream({
+ start(c) {
+ const reader = res.body?.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ""
+ let responseLength = 0
+
+ function pump(): Promise<void> {
+ return (
+ reader?.read().then(async ({ done, value }) => {
+ if (done) {
+ logger.metric({ response_length: responseLength })
+ const usage = opts.getStreamUsage()
+ if (usage) {
+ await trackUsage(usage)
+ await reload()
+ }
+ c.close()
+ return
+ }
+
+ if (responseLength === 0) {
+ logger.metric({ time_to_first_byte: Date.now() - startTimestamp })
+ }
+ responseLength += value.length
+ buffer += decoder.decode(value, { stream: true })
+
+ const parts = buffer.split("\n\n")
+ buffer = parts.pop() ?? ""
+
+ for (const part of parts) {
+ logger.debug(part)
+ opts.onStreamPart(part.trim())
+ }
+
+ c.enqueue(value)
+
+ return pump()
+ }) || Promise.resolve()
+ )
+ }
+
+ return pump()
+ },
+ })
+
+ return new Response(stream, {
+ status: res.status,
+ statusText: res.statusText,
+ headers: resHeaders,
+ })
+
+ function validateModel() {
+ if (!(body.model in MODELS)) {
+ throw new ModelError(`Model ${body.model} not supported`)
+ }
+ const model = MODELS[body.model as keyof typeof MODELS]
+ logger.metric({ model: model.id })
+ return model
+ }
+
+ async function authenticate() {
+ try {
+ const apiKey = opts.parseApiKey(input.request.headers)
+ if (!apiKey) throw new AuthError("Missing API key.")
+
+ const key = await Database.use((tx) =>
+ tx
+ .select({
+ id: KeyTable.id,
+ workspaceID: KeyTable.workspaceID,
+ })
+ .from(KeyTable)
+ .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
+ .then((rows) => rows[0]),
+ )
+
+ if (!key) throw new AuthError("Invalid API key.")
+ logger.metric({
+ api_key: key.id,
+ workspace: key.workspaceID,
+ })
+ return key
+ } catch (e) {
+ // ignore error if model does not require authentication
+ if (!MODEL.auth) return
+ throw e
+ }
+ }
+
+ async function checkCreditsAndLimit() {
+ if (!apiKey || !MODEL.auth || isFree) return
+
+ const billing = await Database.use((tx) =>
+ tx
+ .select({
+ balance: BillingTable.balance,
+ paymentMethodID: BillingTable.paymentMethodID,
+ monthlyLimit: BillingTable.monthlyLimit,
+ monthlyUsage: BillingTable.monthlyUsage,
+ timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
+ })
+ .from(BillingTable)
+ .where(eq(BillingTable.workspaceID, apiKey.workspaceID))
+ .then((rows) => rows[0]),
+ )
+
+ if (!billing.paymentMethodID) throw new CreditsError("No payment method")
+ if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
+ if (
+ billing.monthlyLimit &&
+ billing.monthlyUsage &&
+ billing.timeMonthlyUsageUpdated &&
+ billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
+ ) {
+ const now = new Date()
+ const currentYear = now.getUTCFullYear()
+ const currentMonth = now.getUTCMonth()
+ const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
+ const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
+ if (currentYear === dateYear && currentMonth === dateMonth)
+ throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
+ }
+ }
+
+ function selectProvider() {
+ const picks = Object.entries(MODEL.providers).flatMap(([name, provider]) =>
+ Array<string>(provider.weight ?? 1).fill(name),
+ )
+ return picks[Math.floor(Math.random() * picks.length)]
+ }
+
+ async function trackUsage(usage: any) {
+ const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
+ opts.normalizeUsage(usage)
+
+ const modelCost = typeof MODEL.cost === "function" ? MODEL.cost(usage) : MODEL.cost
+
+ const inputCost = modelCost.input * inputTokens * 100
+ const outputCost = modelCost.output * outputTokens * 100
+ const reasoningCost = (() => {
+ if (!reasoningTokens) return undefined
+ return modelCost.output * reasoningTokens * 100
+ })()
+ const cacheReadCost = (() => {
+ if (!cacheReadTokens) return undefined
+ if (!modelCost.cacheRead) return undefined
+ return modelCost.cacheRead * cacheReadTokens * 100
+ })()
+ const cacheWrite5mCost = (() => {
+ if (!cacheWrite5mTokens) return undefined
+ if (!modelCost.cacheWrite5m) return undefined
+ return modelCost.cacheWrite5m * cacheWrite5mTokens * 100
+ })()
+ const cacheWrite1hCost = (() => {
+ if (!cacheWrite1hTokens) return undefined
+ if (!modelCost.cacheWrite1h) return undefined
+ return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
+ })()
+ const totalCostInCent =
+ inputCost +
+ outputCost +
+ (reasoningCost ?? 0) +
+ (cacheReadCost ?? 0) +
+ (cacheWrite5mCost ?? 0) +
+ (cacheWrite1hCost ?? 0)
+
+ logger.metric({
+ "tokens.input": inputTokens,
+ "tokens.output": outputTokens,
+ "tokens.reasoning": reasoningTokens,
+ "tokens.cache_read": cacheReadTokens,
+ "tokens.cache_write_5m": cacheWrite5mTokens,
+ "tokens.cache_write_1h": cacheWrite1hTokens,
+ "cost.input": Math.round(inputCost),
+ "cost.output": Math.round(outputCost),
+ "cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
+ "cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
+ "cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
+ "cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
+ "cost.total": Math.round(totalCostInCent),
+ })
+
+ if (!apiKey) return
+
+ const cost = isFree ? 0 : centsToMicroCents(totalCostInCent)
+ await Database.transaction(async (tx) => {
+ await tx.insert(UsageTable).values({
+ workspaceID: apiKey.workspaceID,
+ id: Identifier.create("usage"),
+ model: MODEL.id,
+ provider: providerName,
+ inputTokens,
+ outputTokens,
+ reasoningTokens,
+ cacheReadTokens,
+ cacheWrite5mTokens,
+ cacheWrite1hTokens,
+ cost,
+ })
+ await tx
+ .update(BillingTable)
+ .set({
+ balance: sql`${BillingTable.balance} - ${cost}`,
+ monthlyUsage: sql`
+ CASE
+ WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
+ ELSE ${cost}
+ END
+ `,
+ timeMonthlyUsageUpdated: sql`now()`,
+ })
+ .where(eq(BillingTable.workspaceID, apiKey.workspaceID))
+ })
+
+ await Database.use((tx) =>
+ tx
+ .update(KeyTable)
+ .set({ timeUsed: sql`now()` })
+ .where(eq(KeyTable.id, apiKey.id)),
+ )
+ }
+
+ async function reload() {
+ if (!apiKey) return
+
+ const lock = await Database.use((tx) =>
+ tx
+ .update(BillingTable)
+ .set({
+ timeReloadLockedTill: sql`now() + interval 1 minute`,
+ })
+ .where(
+ and(
+ eq(BillingTable.workspaceID, apiKey.workspaceID),
+ eq(BillingTable.reload, true),
+ lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
+ or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
+ ),
+ ),
+ )
+ if (lock.rowsAffected === 0) return
+
+ await Actor.provide("system", { workspaceID: apiKey.workspaceID }, async () => {
+ await Billing.reload()
+ })
+ }
+ } catch (error: any) {
+ logger.metric({
+ "error.type": error.constructor.name,
+ "error.message": error.message,
+ })
+
+ // Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
+ if (
+ error instanceof AuthError ||
+ error instanceof CreditsError ||
+ error instanceof MonthlyLimitError ||
+ error instanceof ModelError
+ )
+ return new Response(
+ JSON.stringify({
+ type: "error",
+ error: { type: error.constructor.name, message: error.message },
+ }),
+ { status: 401 },
+ )
+
+ return new Response(
+ JSON.stringify({
+ type: "error",
+ error: {
+ type: "error",
+ message: error.message,
+ },
+ }),
+ { status: 500 },
+ )
+ }
+}
diff --git a/packages/console/app/src/routes/zen/v1/chat/completions.ts b/packages/console/app/src/routes/zen/v1/chat/completions.ts
new file mode 100644
index 000000000..801557324
--- /dev/null
+++ b/packages/console/app/src/routes/zen/v1/chat/completions.ts
@@ -0,0 +1,54 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { handler } from "~/routes/zen/handler"
+
+type Usage = {
+ prompt_tokens?: number
+ completion_tokens?: number
+ total_tokens?: number
+ prompt_tokens_details?: {
+ text_tokens?: number
+ audio_tokens?: number
+ image_tokens?: number
+ cached_tokens?: number
+ }
+ completion_tokens_details?: {
+ reasoning_tokens?: number
+ audio_tokens?: number
+ accepted_prediction_tokens?: number
+ rejected_prediction_tokens?: number
+ }
+}
+
+export function POST(input: APIEvent) {
+ let usage: Usage
+ return handler(input, {
+ modifyBody: (body: any) => ({
+ ...body,
+ ...(body.stream ? { stream_options: { include_usage: true } } : {}),
+ }),
+ setAuthHeader: (headers: Headers, apiKey: string) => {
+ headers.set("authorization", `Bearer ${apiKey}`)
+ },
+ parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
+ onStreamPart: (chunk: string) => {
+ if (!chunk.startsWith("data: ")) return
+
+ let json
+ try {
+ json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
+ } catch (e) {
+ return
+ }
+
+ if (!json.usage) return
+ usage = json.usage
+ },
+ getStreamUsage: () => usage,
+ normalizeUsage: (usage: Usage) => ({
+ inputTokens: usage.prompt_tokens ?? 0,
+ outputTokens: usage.completion_tokens ?? 0,
+ reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? undefined,
+ cacheReadTokens: usage.prompt_tokens_details?.cached_tokens ?? undefined,
+ }),
+ })
+}
diff --git a/packages/console/app/src/routes/zen/v1/messages.ts b/packages/console/app/src/routes/zen/v1/messages.ts
new file mode 100644
index 000000000..1fd85d5c7
--- /dev/null
+++ b/packages/console/app/src/routes/zen/v1/messages.ts
@@ -0,0 +1,61 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { handler } from "~/routes/zen/handler"
+
+type Usage = {
+ cache_creation?: {
+ ephemeral_5m_input_tokens?: number
+ ephemeral_1h_input_tokens?: number
+ }
+ cache_creation_input_tokens?: number
+ cache_read_input_tokens?: number
+ input_tokens?: number
+ output_tokens?: number
+ server_tool_use?: {
+ web_search_requests?: number
+ }
+}
+
+export function POST(input: APIEvent) {
+ let usage: Usage
+ return handler(input, {
+ modifyBody: (body: any) => ({
+ ...body,
+ service_tier: "standard_only",
+ }),
+ setAuthHeader: (headers: Headers, apiKey: string) => headers.set("x-api-key", apiKey),
+ parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
+ onStreamPart: (chunk: string) => {
+ const data = chunk.split("\n")[1]
+ if (!data.startsWith("data: ")) return
+
+ let json
+ try {
+ json = JSON.parse(data.slice(6)) as { usage?: Usage }
+ } catch (e) {
+ return
+ }
+
+ if (!json.usage) return
+ usage = {
+ ...usage,
+ ...json.usage,
+ cache_creation: {
+ ...usage?.cache_creation,
+ ...json.usage.cache_creation,
+ },
+ server_tool_use: {
+ ...usage?.server_tool_use,
+ ...json.usage.server_tool_use,
+ },
+ }
+ },
+ getStreamUsage: () => usage,
+ normalizeUsage: (usage: Usage) => ({
+ inputTokens: usage.input_tokens ?? 0,
+ outputTokens: usage.output_tokens ?? 0,
+ cacheReadTokens: usage.cache_read_input_tokens ?? undefined,
+ cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined,
+ cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined,
+ }),
+ })
+}
diff --git a/packages/console/app/src/routes/zen/v1/responses.ts b/packages/console/app/src/routes/zen/v1/responses.ts
new file mode 100644
index 000000000..486c129b9
--- /dev/null
+++ b/packages/console/app/src/routes/zen/v1/responses.ts
@@ -0,0 +1,52 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { handler } from "~/routes/zen/handler"
+
+type Usage = {
+ input_tokens?: number
+ input_tokens_details?: {
+ cached_tokens?: number
+ }
+ output_tokens?: number
+ output_tokens_details?: {
+ reasoning_tokens?: number
+ }
+ total_tokens?: number
+}
+
+export function POST(input: APIEvent) {
+ let usage: Usage
+ return handler(input, {
+ setAuthHeader: (headers: Headers, apiKey: string) => {
+ headers.set("authorization", `Bearer ${apiKey}`)
+ },
+ parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
+ onStreamPart: (chunk: string) => {
+ const [event, data] = chunk.split("\n")
+ if (event !== "event: response.completed") return
+ if (!data.startsWith("data: ")) return
+
+ let json
+ try {
+ json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
+ } catch (e) {
+ return
+ }
+
+ if (!json.response?.usage) return
+ usage = json.response.usage
+ },
+ getStreamUsage: () => usage,
+ normalizeUsage: (usage: Usage) => {
+ const inputTokens = usage.input_tokens ?? 0
+ const outputTokens = usage.output_tokens ?? 0
+ const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
+ const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
+ return {
+ inputTokens: inputTokens - (cacheReadTokens ?? 0),
+ outputTokens: outputTokens - (reasoningTokens ?? 0),
+ reasoningTokens,
+ cacheReadTokens,
+ }
+ },
+ })
+}
diff --git a/packages/console/app/src/style/base.css b/packages/console/app/src/style/base.css
new file mode 100644
index 000000000..a4847ed43
--- /dev/null
+++ b/packages/console/app/src/style/base.css
@@ -0,0 +1,9 @@
+html {
+ line-height: 1;
+ background-color: var(--color-bg);
+ color: var(--color-text);
+}
+
+body {
+ font-family: var(--font-sans);
+}
diff --git a/packages/console/app/src/style/component/button.css b/packages/console/app/src/style/component/button.css
new file mode 100644
index 000000000..d10f7af53
--- /dev/null
+++ b/packages/console/app/src/style/component/button.css
@@ -0,0 +1,102 @@
+[data-component="button"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-4);
+ border: 1px solid transparent;
+ border-radius: var(--space-2);
+ font-family: var(--font-sans);
+ font-size: var(--font-size-md);
+ font-weight: 500;
+ line-height: 1.25;
+ cursor: pointer;
+ transition: all 0.2s ease-in-out;
+ text-decoration: none;
+ user-select: none;
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px var(--color-primary);
+ }
+
+ &[data-color="primary"] {
+ background-color: var(--color-primary);
+ color: var(--color-primary-text);
+ border-color: var(--color-primary);
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-primary-hover);
+ border-color: var(--color-primary-hover);
+ }
+
+ &:active:not(:disabled) {
+ background-color: var(--color-primary-active);
+ border-color: var(--color-primary-active);
+ }
+ }
+
+ &[data-color="danger"] {
+ background-color: var(--color-danger);
+ color: var(--color-danger-text);
+ border-color: var(--color-danger);
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-danger-hover);
+ border-color: var(--color-danger-hover);
+ }
+
+ &:active:not(:disabled) {
+ background-color: var(--color-danger-active);
+ border-color: var(--color-danger-active);
+ }
+
+ &:focus {
+ box-shadow: 0 0 0 2px var(--color-danger);
+ }
+ }
+
+ &[data-color="warning"] {
+ background-color: var(--color-warning);
+ color: var(--color-warning-text);
+ border-color: var(--color-warning);
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-warning-hover);
+ border-color: var(--color-warning-hover);
+ }
+
+ &:active:not(:disabled) {
+ background-color: var(--color-warning-active);
+ border-color: var(--color-warning-active);
+ }
+
+ &:focus {
+ box-shadow: 0 0 0 2px var(--color-warning);
+ }
+ }
+
+ &[data-size="small"] {
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-sm);
+ gap: var(--space-1-5);
+ }
+
+ &[data-size="large"] {
+ padding: var(--space-4) var(--space-6);
+ font-size: var(--font-size-lg);
+ gap: var(--space-3);
+ }
+
+ [data-slot="icon"] {
+ display: flex;
+ align-items: center;
+ width: 1em;
+ height: 1em;
+ }
+}
diff --git a/packages/console/app/src/style/index.css b/packages/console/app/src/style/index.css
new file mode 100644
index 000000000..832a901e8
--- /dev/null
+++ b/packages/console/app/src/style/index.css
@@ -0,0 +1,8 @@
+@import "./token/color.css";
+@import "./token/font.css";
+@import "./token/space.css";
+
+@import "./component/button.css";
+
+@import "./reset.css";
+@import "./base.css";
diff --git a/packages/console/app/src/style/reset.css b/packages/console/app/src/style/reset.css
new file mode 100644
index 000000000..d331ed724
--- /dev/null
+++ b/packages/console/app/src/style/reset.css
@@ -0,0 +1,76 @@
+/* 1. Use a more-intuitive box-sizing model */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+/* 2. Remove default margin */
+* {
+ margin: 0;
+}
+
+/* 3. Enable keyword animations */
+@media (prefers-reduced-motion: no-preference) {
+ html {
+ interpolate-size: allow-keywords;
+ }
+}
+
+body {
+ /* 4. Add accessible line-height */
+ line-height: 1.5;
+ /* 5. Improve text rendering */
+ -webkit-font-smoothing: antialiased;
+}
+
+/* 6. Improve media defaults */
+img,
+picture,
+video,
+canvas,
+svg {
+ display: block;
+ max-width: 100%;
+}
+
+/* 7. Inherit fonts for form controls */
+input,
+button,
+textarea,
+select {
+ font: inherit;
+}
+
+/* 8. Avoid text overflows */
+p,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ overflow-wrap: break-word;
+}
+
+/* 9. Improve line wrapping */
+p {
+ text-wrap: pretty;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ text-wrap: balance;
+}
+
+/*
+ 10. Create a root stacking context
+*/
+#root,
+#__next {
+ isolation: isolate;
+}
diff --git a/packages/console/app/src/style/token/color.css b/packages/console/app/src/style/token/color.css
new file mode 100644
index 000000000..f1a097d2f
--- /dev/null
+++ b/packages/console/app/src/style/token/color.css
@@ -0,0 +1,91 @@
+:root {
+ --color-white: #ffffff;
+ --color-black: #000000;
+
+ /* Default light theme colors */
+ --color-bg: #ffffff;
+ --color-bg-surface: #f5f5f7;
+ --color-bg-elevated: #ffffff;
+
+ --color-text: #1d1d1f;
+ --color-text-secondary: #424245;
+ --color-text-muted: #6e6e73;
+ --color-text-disabled: #86868b;
+
+ --color-accent: #007aff;
+ --color-accent-hover: #0056b3;
+ --color-accent-active: #004085;
+
+ --color-success: #30d158;
+ --color-warning: #ff9f0a;
+ --color-danger: #ff3b30;
+
+ --color-border: #d2d2d7;
+ --color-border-muted: #e5e5ea;
+
+ /* Button colors */
+ --color-primary: var(--color-accent);
+ --color-primary-hover: var(--color-accent-hover);
+ --color-primary-active: var(--color-accent-active);
+ --color-primary-text: #ffffff;
+
+ --color-danger: #ff3b30;
+ --color-danger-hover: #d70015;
+ --color-danger-active: #a50011;
+ --color-danger-text: #ffffff;
+
+ --color-warning: #ff9f0a;
+ --color-warning-hover: #cc7f08;
+ --color-warning-active: #995f06;
+ --color-warning-text: #000000;
+
+ /* Surface colors */
+ --color-surface: var(--color-bg-surface);
+ --color-surface-hover: var(--color-bg-elevated);
+ --color-surface-border: var(--color-border);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --color-bg: #0c0c0e;
+ --color-bg-surface: #161618;
+ --color-bg-elevated: #1c1c1f;
+
+ --color-text: #ffffff;
+ --color-text-secondary: #c7c7cc;
+ --color-text-muted: #a1a1a6;
+ --color-text-disabled: #68686f;
+
+ --color-accent: #007aff;
+ --color-accent-hover: #0056b3;
+ --color-accent-active: #004085;
+
+ --color-success: #30d158;
+ --color-warning: #ff9f0a;
+ --color-danger: #ff453a;
+
+ --color-border: #38383a;
+ --color-border-muted: #2c2c2e;
+
+ /* Button colors */
+ --color-primary: var(--color-accent);
+ --color-primary-hover: var(--color-accent-hover);
+ --color-primary-active: var(--color-accent-active);
+ --color-primary-text: #ffffff;
+
+ --color-danger: #ff453a;
+ --color-danger-hover: #d70015;
+ --color-danger-active: #a50011;
+ --color-danger-text: #ffffff;
+
+ --color-warning: #ff9f0a;
+ --color-warning-hover: #cc7f08;
+ --color-warning-active: #995f06;
+ --color-warning-text: #000000;
+
+ /* Surface colors */
+ --color-surface: var(--color-bg-surface);
+ --color-surface-hover: var(--color-bg-elevated);
+ --color-surface-border: var(--color-border);
+ }
+}
diff --git a/packages/console/app/src/style/token/font.css b/packages/console/app/src/style/token/font.css
new file mode 100644
index 000000000..67143e662
--- /dev/null
+++ b/packages/console/app/src/style/token/font.css
@@ -0,0 +1,20 @@
+body {
+ --font-size-2xs: 0.6875rem;
+ --font-size-xs: 0.75rem;
+ --font-size-sm: 0.8125rem;
+ --font-size-md: 0.9375rem;
+ --font-size-lg: 1.125rem;
+ --font-size-xl: 1.25rem;
+ --font-size-2xl: 1.5rem;
+ --font-size-3xl: 1.875rem;
+ --font-size-4xl: 2.25rem;
+ --font-size-5xl: 3rem;
+ --font-size-6xl: 3.75rem;
+ --font-size-7xl: 4.5rem;
+ --font-size-8xl: 6rem;
+ --font-size-9xl: 8rem;
+
+ --font-mono:
+ "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ --font-sans: var(--font-mono);
+}
diff --git a/packages/console/app/src/style/token/space.css b/packages/console/app/src/style/token/space.css
new file mode 100644
index 000000000..7e1a1b397
--- /dev/null
+++ b/packages/console/app/src/style/token/space.css
@@ -0,0 +1,46 @@
+body {
+ --space-0: 0;
+ --space-px: 1px;
+ --space-0-5: 0.125rem;
+ --space-0-75: 0.1875rem;
+ --space-1: 0.25rem;
+ --space-1-5: 0.375rem;
+ --space-2: 0.5rem;
+ --space-2-5: 0.625rem;
+ --space-3: 0.75rem;
+ --space-3-5: 0.875rem;
+ --space-4: 1rem;
+ --space-4-5: 1.125rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-7: 1.75rem;
+ --space-8: 2rem;
+ --space-9: 2.25rem;
+ --space-10: 2.5rem;
+ --space-11: 2.75rem;
+ --space-12: 3rem;
+ --space-14: 3.5rem;
+ --space-16: 4rem;
+ --space-17: 4.25rem;
+ --space-18: 4.5rem;
+ --space-19: 4.75rem;
+ --space-20: 5rem;
+ --space-24: 6rem;
+ --space-28: 7rem;
+ --space-32: 8rem;
+ --space-36: 9rem;
+ --space-40: 10rem;
+ --space-44: 11rem;
+ --space-48: 12rem;
+ --space-52: 13rem;
+ --space-56: 14rem;
+ --space-60: 15rem;
+ --space-64: 16rem;
+ --space-72: 18rem;
+ --space-80: 20rem;
+ --space-96: 24rem;
+
+ --border-radius-sm: 0.1875rem;
+ --border-radius-md: 0.3125rem;
+ --border-radius-lg: 0.5rem;
+}
diff --git a/packages/console/app/sst-env.d.ts b/packages/console/app/sst-env.d.ts
new file mode 100644
index 000000000..9b9de7327
--- /dev/null
+++ b/packages/console/app/sst-env.d.ts
@@ -0,0 +1,9 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+
+/// <reference path="../../../sst-env.d.ts" />
+
+import "sst"
+export {} \ No newline at end of file
diff --git a/packages/console/app/tsconfig.json b/packages/console/app/tsconfig.json
new file mode 100644
index 000000000..07148a458
--- /dev/null
+++ b/packages/console/app/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "allowJs": true,
+ "strict": true,
+ "noEmit": true,
+ "types": ["vinxi/types/client"],
+ "isolatedModules": true,
+ "paths": {
+ "~/*": ["./src/*"]
+ }
+ }
+}