The workspace has one home and more than one host

tabs.hanzo.ai is one host for it. The desktop shell is another, and
hanzo.ai a third, and the alternative to exporting it is three pane trees
that look alike — a second set of answers to what a split does, which
then drift.

Almost nothing had to change, because the component was already pure: it
takes `hosts` and `mint` as props, holds no token and calls no server. So
this is packaging. Its own `@/lib` imports become relative, since a
consumer has no such alias; the control plane's origin becomes a
parameter defaulting to where the web app already pointed, so a host aims
it at whichever cloud its identity belongs to without module state a
second host must remember to set; and `index.ts` names what a host
mounts.

Source is published rather than a build. Both hosts compile TypeScript,
so a build step here would be a second toolchain making something neither
needs and one more thing to be stale. React, @hanzo/ui, @hanzo/gui and
lucide are peers for the same reason a shell must not get a second copy
of a context in one tree.

Two things were already broken on this branch and are fixed with it.
@hanzo/ui hard-imports react-hook-form, which it declares an OPTIONAL
peer — a bundler resolves it statically either way, so the module has to
be there and `next build` failed on every path without it. And Next 16
builds with Turbopack by default, which dies here with a worker error
while webpack compiles in 9.6s; Next's own message asks callers to name
the bundler, so the script names the one that works.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-14 20:57:21 -07:00
co-authored by Hanzo Dev
parent b98a61df9e
commit 1b2abd099f
6 changed files with 178 additions and 41 deletions
+3 -3
View File
@@ -73,12 +73,12 @@ import {
splitPane,
stableOrder,
stackFor,
} from '@/lib/tiles';
} from '../lib/tiles';
// One import, and it is a PURE READING of an error — no origin, no token, no
// request. The workspace still holds no credential; what it gains is the
// platform's own word for "no money", which is the one failure whose remedy
// differs from every other.
import { unfunded } from '@/lib/api';
import { unfunded } from '../lib/api';
import {
DEADLINE,
DOT,
@@ -91,7 +91,7 @@ import {
rescued,
web,
restore,
} from '@/lib/panes';
} from '../lib/panes';
/** The stack's own props, taken from the stack. @hanzo/ui is the one import
* source, so a local alias beats reaching past it to @hanzo/gui for a type. */
+73
View File
@@ -0,0 +1,73 @@
/**
* The workspace, for whoever is hosting it.
*
* tabs.hanzo.ai is one host. The desktop shell is another, and hanzo.ai a
* third — the same tiling of terminals, desktops and pages in each, because it
* is the same component rather than three that look alike. That is the whole
* reason this file exists: a second copy of a pane tree is a second set of
* answers to what a split does, and they drift.
*
* WHAT A HOST SUPPLIES is the machines and a way to mint a pane's url. The
* `Workspace` component holds no token and calls no server: `hosts` and `mint`
* come in as props, so a host points them at whichever control plane its
* identity belongs to. The functions in `lib/api` are the ones tabs.hanzo.ai
* uses for that, offered here because another host most likely wants the same
* ones — each taking its origin as a parameter.
*
* SOURCE IS PUBLISHED, not a build. Both hosts compile TypeScript already, so a
* build step here would be a second toolchain producing something neither
* needs, and one more thing to be stale.
*/
export { Workspace } from './components/workspace';
export {
DEFAULT_SHELL,
DEADLINE,
DOT,
READY,
type Binding,
type Shell,
host,
isReady,
label,
machineOf,
mintName,
proves,
rescued,
restore,
safeName,
shellUrl,
web,
} from './lib/panes';
export {
type Dir,
type Geometry,
type Path,
type Tile,
closePane,
geometry,
pageGeometry,
pane,
paneIds,
setRatio,
splitPane,
} from './lib/tiles';
export {
API,
type Class,
type Door,
type Machine,
type SandboxMachine,
type Session,
Refusal,
createSandbox,
frameUrl,
grant,
machineName,
machines,
sandboxes,
sessions,
unfunded,
} from './lib/api';
+36 -13
View File
@@ -7,7 +7,23 @@
* party to a conversation that has two, and one more thing to keep running.
*/
export const API = process.env.NEXT_PUBLIC_HANZO_API ?? 'https://api.hanzo.ai';
/**
* Where the control plane answers.
*
* A DEFAULT, not a constant, because this module is read by two hosts now: the
* web app at tabs.hanzo.ai, and the desktop shell, which points at whichever
* cloud its identity belongs to. Every function here already takes a token, so
* it takes the origin the same way — a parameter with this as its default,
* rather than module state a second host would have to remember to set before
* the first call.
*
* `process.env` is read defensively: a bundler that does not define it leaves
* the identifier undefined rather than an empty object, and reading a property
* off that throws at module load.
*/
export const API =
(typeof process !== 'undefined' ? process.env?.NEXT_PUBLIC_HANZO_API : undefined) ??
'https://api.hanzo.ai';
export interface Machine {
id: string;
@@ -68,8 +84,8 @@ async function refusal(res: Response, fallback: string): Promise<Refusal> {
: new Refusal(res.status, '', e || fallback);
}
async function read<T>(path: string, token: string): Promise<T> {
const res = await fetch(`${API}${path}`, {
async function read<T>(path: string, token: string, origin = API): Promise<T> {
const res = await fetch(`${origin}${path}`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
@@ -77,11 +93,13 @@ async function read<T>(path: string, token: string): Promise<T> {
return (await res.json()) as T;
}
export const machines = (token: string) =>
read<{ targets: Machine[] }>('/v1/agents/targets', token).then((r) => r.targets ?? []);
export const machines = (token: string, origin = API) =>
read<{ targets: Machine[] }>('/v1/agents/targets', token, origin).then((r) => r.targets ?? []);
export const sessions = (token: string) =>
read<{ sessions: Session[] }>('/v1/agents/sessions?limit=200', token).then((r) => r.sessions ?? []);
export const sessions = (token: string, origin = API) =>
read<{ sessions: Session[] }>('/v1/agents/sessions?limit=200', token, origin).then(
(r) => r.sessions ?? [],
);
/**
* Sandboxes — the machines we start for you, rather than the ones you linked.
@@ -109,8 +127,8 @@ export interface SandboxMachine {
class?: string;
}
export const sandboxes = (token: string) =>
read<{ sandboxes?: SandboxMachine[] }>('/v1/sandboxes?status=running', token).then((r) =>
export const sandboxes = (token: string, origin = API) =>
read<{ sandboxes?: SandboxMachine[] }>('/v1/sandboxes?status=running', token, origin).then((r) =>
(r.sandboxes ?? []).filter((s) => s.status === 'running'),
);
@@ -147,7 +165,11 @@ export type Class = 'dev' | 'desktop';
* should live, and a `ttlSec` here would be a second answer to drift from the one
* in the class table.
*/
export const createSandbox = async (token: string, kind: Class = 'dev'): Promise<SandboxMachine> => {
export const createSandbox = async (
token: string,
kind: Class = 'dev',
origin = API,
): Promise<SandboxMachine> => {
const taken = new Set((await sandboxes(token)).map(machineName));
// Every machine carries its number, the first one included: `cloud` beside
// `cloud-2` reads as a different kind of thing rather than the one before it.
@@ -155,7 +177,7 @@ export const createSandbox = async (token: string, kind: Class = 'dev'): Promise
const stem = STEM[kind];
let project = `${stem}-1`;
for (let n = 2; taken.has(project); n++) project = `${stem}-${n}`;
const res = await fetch(`${API}/v1/sandboxes`, {
const res = await fetch(`${origin}/v1/sandboxes`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ class: kind, project }),
@@ -229,8 +251,9 @@ export const frameUrl = async (
id: string,
door: Door,
arg?: string,
origin = API,
): Promise<string> => {
const res = await fetch(`${API}/v1/sandboxes/${encodeURIComponent(id)}/${door}/ticket`, {
const res = await fetch(`${origin}/v1/sandboxes/${encodeURIComponent(id)}/${door}/ticket`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
@@ -238,5 +261,5 @@ export const frameUrl = async (
if (!res.ok) throw new Error(`${door} ticket → ${res.status}`);
const body = (await res.json()) as { url?: string };
if (!body.url) throw new Error(`${door} ticket → empty answer`);
return `${API}${body.url}` + (arg ? `&arg=${encodeURIComponent(arg)}` : '');
return `${origin}${body.url}` + (arg ? `&arg=${encodeURIComponent(arg)}` : '');
};
+33 -10
View File
@@ -1,35 +1,58 @@
{
"name": "@hanzo/tabs",
"version": "0.1.1",
"private": true,
"license": "MIT",
"description": "Keep tabs on your agents. A browser terminal workspace for machines you have linked.",
"description": "Keep tabs on your agents. A browser terminal workspace for machines you have linked \u2014 the app at tabs.hanzo.ai, and the component its other hosts mount.",
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build --webpack",
"start": "next start",
"test": "jest",
"typecheck": "tsc --noEmit",
"test:surface": "next build && playwright test"
"test:surface": "next build --webpack && playwright test"
},
"dependencies": {
"@hanzo/gui": "catalog:",
"@hanzo/iam": "catalog:",
"@hanzo/ui": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"react-native": "catalog:",
"react-native-web": "catalog:"
},
"devDependencies": {
"@hanzo/gui": "catalog:",
"@hanzo/ui": "catalog:",
"@playwright/test": "catalog:",
"@types/jest": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"jest": "catalog:",
"lucide-react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:"
},
"packageManager": "[email protected]"
"packageManager": "[email protected]",
"types": "./index.ts",
"exports": {
".": "./index.ts",
"./workspace": "./components/workspace.tsx",
"./panes": "./lib/panes.ts",
"./tiles": "./lib/tiles.ts",
"./api": "./lib/api.ts"
},
"files": [
"index.ts",
"components",
"lib",
"README.md",
"LICENSE"
],
"sideEffects": false,
"peerDependencies": {
"@hanzo/gui": "catalog:",
"@hanzo/ui": "catalog:",
"lucide-react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
}
}
+32 -15
View File
@@ -42,6 +42,9 @@ catalogs:
react-dom:
specifier: 19.2.8
version: 19.2.8
react-hook-form:
specifier: ^7.71.0
version: 7.88.0
react-native:
specifier: ^0.87.1
version: 0.87.1
@@ -60,27 +63,15 @@ importers:
.:
dependencies:
'@hanzo/gui':
specifier: 'catalog:'
version: 8.3.1([email protected]([email protected]))([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected])
'@hanzo/iam':
specifier: 'catalog:'
version: 0.21.17([email protected]([email protected]))([email protected])
'@hanzo/ui':
specifier: 'catalog:'
version: 8.27.2(bebf53e2256154e0b6f66411048aeaaf)
lucide-react:
specifier: 'catalog:'
version: 1.44.0([email protected])
next:
specifier: 'catalog:'
version: 16.3.3(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
react:
react-hook-form:
specifier: 'catalog:'
version: 19.2.8
react-dom:
specifier: 'catalog:'
version: 19.2.8([email protected])
version: 7.88.0([email protected])
react-native:
specifier: 'catalog:'
version: 0.87.1(@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected])
@@ -88,6 +79,12 @@ importers:
specifier: 'catalog:'
version: 0.21.2([email protected]([email protected]))([email protected])
devDependencies:
'@hanzo/gui':
specifier: 'catalog:'
version: 8.3.1([email protected]([email protected]))([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected])
'@hanzo/ui':
specifier: 'catalog:'
version: 8.27.2(2a4766a00c25c043c2e3b25c728d239e)
'@playwright/test':
specifier: 'catalog:'
version: 1.62.1
@@ -103,6 +100,15 @@ importers:
jest:
specifier: 'catalog:'
version: 30.5.1(@types/[email protected])([email protected])
lucide-react:
specifier: 'catalog:'
version: 1.44.0([email protected])
react:
specifier: 'catalog:'
version: 19.2.8
react-dom:
specifier: 'catalog:'
version: 19.2.8([email protected])
typescript:
specifier: 'catalog:'
version: 7.0.2
@@ -3236,6 +3242,12 @@ packages:
peerDependencies:
react: '>=17.0.0'
[email protected]:
resolution: {integrity: sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
[email protected]:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -4336,7 +4348,7 @@ snapshots:
'@hanzogui/lucide-icons-2': 8.3.1([email protected]([email protected]))([email protected]([email protected](@babel/[email protected]([email protected]))(@react-native/[email protected](@babel/[email protected]([email protected]))([email protected]))(@types/[email protected])([email protected])([email protected]))([email protected]))([email protected])
react: 19.2.8
'@hanzo/[email protected](bebf53e2256154e0b6f66411048aeaaf)':
'@hanzo/[email protected](2a4766a00c25c043c2e3b25c728d239e)':
dependencies:
'@hanzo/appearance': 0.2.2([email protected])
'@hanzo/brand': 1.4.8([email protected])
@@ -4358,6 +4370,7 @@ snapshots:
react-native-web: 0.21.2([email protected]([email protected]))([email protected])
optionalDependencies:
next: 16.3.3(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
react-hook-form: 7.88.0([email protected])
transitivePeerDependencies:
- encoding
- prop-types
@@ -7768,6 +7781,10 @@ snapshots:
dependencies:
react: 19.2.8
[email protected]([email protected]):
dependencies:
react: 19.2.8
[email protected]: {}
[email protected]: {}
+1
View File
@@ -14,6 +14,7 @@ catalog:
'next': '16.3.3'
'react': '19.2.8'
'react-dom': '19.2.8'
'react-hook-form': '^7.71.0'
'react-native': '^0.87.1'
'react-native-web': '^0.21.2'
'typescript': '7.0.2'