@hanzo/build 0.2.51 → 0.2.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -99,18 +99,18 @@ hanzo.id token for `Host.admin`. One left column, never two.
99
99
  | `-/automations` | repeating work, read from `/v1/auto/flows` |
100
100
  | `-/customize` · `-/customize/connectors` · `/plugins` · `/agents` | Customize: skills, connectors, plugins, agents |
101
101
  | `-/mcp` | Connectors → Discover, where the fleet's native MCP servers now live |
102
- | `-/codebases` | the forge's repositories |
103
- | `-/sync` | bring granted repositories onto the forge |
104
- | `-/projects` | the forge's boards |
102
+ | `-/projects` | the org's projects: its repositories on the forge |
103
+ | `-/sync` | link repositories from GitHub onto the forge |
105
104
  | `-/issues` | the forge's issues |
106
105
  | `-/artifacts` | what the org has built |
107
106
  | `-/templates` | the public starters |
108
107
  | `-/plans` | the plans this brand sells, and the move to another |
109
108
  | `-/settings/<section>` | Billing, Usage, Members, Integrations, Notifications, … |
110
- | `<slug>` | a project's workspace |
109
+ | `<org>/<repo>` | a project's workspace: the repository, and its site when it has one |
110
+ | `<slug>` | a deployed site's workspace |
111
111
 
112
- A project slug holds no `_` and never starts with `-`, so the forms cannot
113
- collide.
112
+ A project slug holds no `_` and never starts with `-`, and a repository is the
113
+ one address of two segments, so the forms cannot collide.
114
114
 
115
115
  ## What is on the screen
116
116
 
@@ -8,6 +8,7 @@
8
8
  * POST /v1/provider/github/user/complete {grant} → the connection
9
9
  * POST /v1/provider/github/user/disconnect
10
10
  * GET /v1/provider/github/installations the accounts this org has bound the App on
11
+ * POST /v1/provider/github/issues/backfill {state, repo?} → mirror GitHub issues onto the board
11
12
  *
12
13
  * Paged by an opaque `after` cursor; `next` is the cursor for the page after
13
14
  * this one, or empty on the last. Rows are read defensively: a field the
@@ -128,3 +129,20 @@ export declare function accounts(g: Grants): Account[];
128
129
  export declare function grants(t: Target, signal?: AbortSignal): Promise<Grants>;
129
130
  /** Queue a mirror of the named repositories onto the forge. The names are `owner/name`. */
130
131
  export declare function bring(t: Target, repos: string[]): Promise<number>;
132
+ /** What one issue sync did. */
133
+ export interface Synced {
134
+ repos: number;
135
+ issues: number;
136
+ created: number;
137
+ updated: number;
138
+ failed: number;
139
+ /** The pass stopped at its budget; syncing again continues where it stopped. */
140
+ truncated: boolean;
141
+ }
142
+ /**
143
+ * Mirror GitHub issues onto the board — open and closed, so a closed one reads as
144
+ * closed here too. `repo` (`name` or `owner/name`) syncs one project; empty syncs
145
+ * every repository the connection grants. Idempotent: a second sync updates and
146
+ * never duplicates, and the App's webhook keeps them live afterwards.
147
+ */
148
+ export declare function syncIssues(t: Target, repo?: string): Promise<Synced>;
package/lib/api/github.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * POST /v1/provider/github/user/complete {grant} → the connection
9
9
  * POST /v1/provider/github/user/disconnect
10
10
  * GET /v1/provider/github/installations the accounts this org has bound the App on
11
+ * POST /v1/provider/github/issues/backfill {state, repo?} → mirror GitHub issues onto the board
11
12
  *
12
13
  * Paged by an opaque `after` cursor; `next` is the cursor for the page after
13
14
  * this one, or empty on the last. Rows are read defensively: a field the
@@ -143,3 +144,21 @@ export async function bring(t, repos) {
143
144
  const raw = obj(await call(t, 'POST', '/v1/provider/github/repos/import', { repos: names }));
144
145
  return num(raw.queued);
145
146
  }
147
+ /**
148
+ * Mirror GitHub issues onto the board — open and closed, so a closed one reads as
149
+ * closed here too. `repo` (`name` or `owner/name`) syncs one project; empty syncs
150
+ * every repository the connection grants. Idempotent: a second sync updates and
151
+ * never duplicates, and the App's webhook keeps them live afterwards.
152
+ */
153
+ export async function syncIssues(t, repo = '') {
154
+ const name = repo.trim();
155
+ const raw = obj(await call(t, 'POST', '/v1/provider/github/issues/backfill', name ? { state: 'all', repo: name } : { state: 'all' }));
156
+ return {
157
+ repos: num(raw.repos),
158
+ issues: num(raw.issues),
159
+ created: num(raw.created),
160
+ updated: num(raw.updated),
161
+ failed: num(raw.failed),
162
+ truncated: raw.truncated === true,
163
+ };
164
+ }
package/lib/api/work.d.ts CHANGED
@@ -31,7 +31,21 @@ export interface Work {
31
31
  assignee: string;
32
32
  /** The repository the item is bound to, or ''. */
33
33
  repo: string;
34
+ /** Where it was opened: `git` (the forge, or mirrored from GitHub), `team`, `agent`, … */
35
+ source: string;
36
+ labels: string[];
37
+ /** Its page upstream (a GitHub issue for a mirrored one), https only, or ''. */
38
+ url: string;
39
+ /** Unix milliseconds, or 0 when the platform did not say. */
40
+ created: number;
41
+ updated: number;
34
42
  }
43
+ /** The project an item is filed under: its repository, else its board. */
44
+ export declare const projectOf: (w: Work) => string;
45
+ /** Done and canceled are closed; every other column is open work. */
46
+ export declare const closed: (w: Work) => boolean;
47
+ /** Whether `w` belongs to the project `key` — by repository or by board, either case. */
48
+ export declare function inProject(w: Work, key: string): boolean;
35
49
  export declare function board(raw: unknown): Board | null;
36
50
  export declare function work(raw: unknown): Work | null;
37
51
  export declare function boards(t: Target): Promise<Board[]>;
package/lib/api/work.js CHANGED
@@ -12,7 +12,19 @@
12
12
  * reads the work and opens a run against it.
13
13
  */
14
14
  import { call, seg } from './call.js';
15
+ /** The project an item is filed under: its repository, else its board. */
16
+ export const projectOf = (w) => w.repo || w.project;
17
+ /** Done and canceled are closed; every other column is open work. */
18
+ export const closed = (w) => w.status === 'done' || w.status === 'canceled';
19
+ /** Whether `w` belongs to the project `key` — by repository or by board, either case. */
20
+ export function inProject(w, key) {
21
+ const k = key.toLowerCase();
22
+ return w.repo.toLowerCase() === k || w.project.toLowerCase() === k;
23
+ }
15
24
  const str = (v) => (typeof v === 'string' ? v : '');
25
+ /** The platform states seconds; a value already in milliseconds is kept. */
26
+ const when = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? (v < 1e12 ? v * 1000 : v) : 0);
27
+ const https = (v) => (typeof v === 'string' && /^https:\/\//.test(v) ? v : '');
16
28
  const obj = (v) => v && typeof v === 'object' ? v : {};
17
29
  function rows(raw) {
18
30
  if (Array.isArray(raw))
@@ -49,6 +61,11 @@ export function work(raw) {
49
61
  priority: str(o.priority) || 'none',
50
62
  assignee: str(o.assignee),
51
63
  repo: str(o.repo),
64
+ source: str(o.source),
65
+ labels: Array.isArray(o.labels) ? o.labels.filter((l) => typeof l === 'string' && l !== '') : [],
66
+ url: https(o.url) || https(o.extRef),
67
+ created: when(o.createdAt),
68
+ updated: when(o.updatedAt),
52
69
  };
53
70
  }
54
71
  export async function boards(t) {
package/lib/builder.js CHANGED
@@ -5,14 +5,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  * / New — the empty state, a composer over the codebase it works on
6
6
  * /sess_… one run, live
7
7
  * /-/automations work that runs itself; /-/automations/<id> one, to edit and run
8
- /-/codebases the forge's repositories
9
- * /-/sync bring granted repositories onto the forge
10
- * /-/projects the forge's boards
8
+ * /-/projects the org's repositories on the forge: a project is a repository
9
+ * /-/sync link repositories from GitHub onto the forge
11
10
  * /-/issues the forge's issues
12
11
  * /-/artifacts what this org has built
13
12
  * /-/templates the public starters
14
13
  * /-/customize skills, connectors, plugins and agents
15
- * /<slug> a deployed project's workspace, in this same window
14
+ * /<org>/<repo> a repository's workspace, in this same window
15
+ * /<slug> a deployed site's workspace
16
16
  *
17
17
  * The rail is the sessions rail every Hanzo surface shares: New, the builder's
18
18
  * places with the rest under More, the org's coding runs newest first with a
@@ -20,15 +20,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
20
20
  * whose menu opens Settings, Usage, the plans, help and logging out (foot.tsx).
21
21
  * Its collapse is an explicit toggle kept per browser.
22
22
  *
23
- * A project's workspace takes the whole window — it has its own chat column,
24
- * bar and panes — and its mark leads back here.
23
+ * A workspace takes the whole window — it has its own chat column, bar and
24
+ * panes — and its mark leads back here.
25
25
  *
26
26
  * A host with a rail of its own mounts `<Builder rail={false}>` and draws the
27
27
  * builder's places (`nav`) and runs in that rail (`useSessions`, or `DevSection`,
28
28
  * section.tsx): one left column, never two.
29
29
  */
30
30
  import { SizableText, XStack, YStack } from '@hanzo/gui';
31
- import { Blocks, BookOpen, CircleDot, Cpu, FolderGit2, Kanban, LayoutTemplate, Menu, Search, SlidersHorizontal, Workflow } from '@hanzogui/lucide-icons-2';
31
+ import { Blocks, BookOpen, CircleDot, Cpu, FolderGit2, LayoutTemplate, Menu, Search, SlidersHorizontal, Workflow } from '@hanzogui/lucide-icons-2';
32
32
  import { Button } from '@hanzo/ui';
33
33
  import { SessionRail } from '@hanzo/ui/chat';
34
34
  import { HanzoMark } from '@hanzo/ui/product';
@@ -98,13 +98,18 @@ export function nav(host, go) {
98
98
  const r = route(host.path);
99
99
  const screen = r.kind === 'screen' ? r.screen : '';
100
100
  const links = [
101
- { id: 'projects', label: 'Projects', icon: _jsx(Kanban, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'projects' })), active: screen === 'projects' },
101
+ {
102
+ id: 'projects',
103
+ label: 'Projects',
104
+ icon: _jsx(FolderGit2, { size: 16 }),
105
+ onPress: () => go(path({ kind: 'screen', screen: 'projects' })),
106
+ active: screen === 'projects' || screen === 'sync' || r.kind === 'repo',
107
+ },
102
108
  { id: 'artifacts', label: 'Artifacts', icon: _jsx(Blocks, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'artifacts' })), active: screen === 'artifacts' },
103
109
  { id: 'customize', label: 'Customize', icon: _jsx(SlidersHorizontal, { size: 16 }), onPress: () => go(path({ kind: 'customize', tab: 'skills' })), active: r.kind === 'customize' || screen === 'mcp' },
104
110
  { id: 'automations', label: 'Automations', icon: _jsx(Workflow, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'automations' })), active: screen === 'automations' || r.kind === 'automation' },
105
111
  ];
106
112
  const more = [
107
- { id: 'codebases', label: 'Codebase', icon: _jsx(FolderGit2, { size: 16 }), onPress: () => go(path({ kind: 'screen', screen: 'codebases' })), active: screen === 'codebases' },
108
113
  {
109
114
  id: 'issues',
110
115
  label: 'Issues',
@@ -172,7 +177,9 @@ function Screens({ rail }) {
172
177
  const host = useHost();
173
178
  const r = route(host.path);
174
179
  if (r.kind === 'project')
175
- return _jsx(Project, { slug: r.slug }, r.slug);
180
+ return _jsx(Project, { at: { slug: r.slug } }, r.slug);
181
+ if (r.kind === 'repo')
182
+ return _jsx(Project, { at: { org: r.org, name: r.name } }, `${r.org}/${r.name}`);
176
183
  if (!rail)
177
184
  return _jsx(Pane, {});
178
185
  return _jsx(Shell, {});
package/lib/forge.js CHANGED
@@ -1,33 +1,30 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * Codebases, projects and issues, read from the forge and opened in this window.
3
+ * Projects and issues, read from the forge and opened in this window.
4
4
  *
5
- * A codebase is a repository. Choosing one is choosing what the next run works
6
- * on. A project is a board of issues on one of those repositories. Choosing an
7
- * issue opens New with that codebase and the issue as the ask.
5
+ * A project is a repository, linked from GitHub or started from a template;
6
+ * choosing one opens its workspace, where every run works on it. Issues are
7
+ * the forge's, filed under a board or a repository, and a repository's GitHub
8
+ * issues sync onto it. Choosing an issue opens New with that repository and
9
+ * the issue as the ask.
8
10
  */
9
11
  import { SizableText, XStack, YStack } from '@hanzo/gui';
10
- import { ChevronDown, GitBranch, Plus, Settings } from '@hanzogui/lucide-icons-2';
11
- import { Button, Dialog, DialogContent, DialogTitle, Input } from '@hanzo/ui';
12
+ import { ArrowRight, Check, ChevronDown, CircleCheck, CircleDot, CircleSlash, ExternalLink, FolderGit2, GitBranch, Github, GitPullRequest, LayoutTemplate, RefreshCw, } from '@hanzogui/lucide-icons-2';
13
+ import { Button, DropdownMenu, Input } from '@hanzo/ui';
12
14
  import { useEffect, useState } from 'react';
13
15
  import { ago } from './ago.js';
14
- import { codebases, create } from './api/codebases.js';
16
+ import { codebases } from './api/codebases.js';
15
17
  import { Automations } from './automations.js';
16
- import { environments } from './api/environment.js';
17
- import { boards, issues } from './api/work.js';
18
- import { boardKey, pinBoard, pinCodebase, readPending, writePending } from './choice.js';
18
+ import { boards, closed, inProject, issues, projectOf } from './api/work.js';
19
+ import { boardKey, pinCodebase, readPending, writePending } from './choice.js';
19
20
  import { useKept, useRead } from './data.js';
20
21
  import { useHost, useTarget } from './host.js';
21
22
  import { path } from './route.js';
22
23
  import { Customize } from './customize/index.js';
23
24
  import { Sync } from './sync.js';
24
- import { grants } from './api/github.js';
25
- function Head({ title, says }) {
26
- return (_jsxs(YStack, { gap: "$1", pb: "$4", children: [_jsx(SizableText, { render: "h1", size: "$6", color: "$ink", children: title }), _jsx(SizableText, { size: "$2", color: "$soft", children: says })] }));
27
- }
28
- function Row({ label, onPress, children }) {
29
- return (_jsx(XStack, { render: "button", onPress: onPress, "aria-label": label, items: "center", gap: "$3", px: "$3", py: "$2.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", bg: "$panel", hoverStyle: { borderColor: '$edge', bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: children }));
30
- }
25
+ import { connect, connection, grants, syncIssues } from './api/github.js';
26
+ import { here, useBack } from './back.js';
27
+ import { Out } from './out.js';
31
28
  function Note({ children }) {
32
29
  return (_jsx(SizableText, { size: "$2", color: "$soft", children: children }));
33
30
  }
@@ -46,34 +43,34 @@ export function Forge({ screen }) {
46
43
  // The fleet's native servers are Connectors → Discover now; the old address lands there.
47
44
  if (screen === 'mcp')
48
45
  return _jsx(Customize, { tab: "connectors", view: "discover" });
49
- if (screen === 'codebases')
50
- return _jsx(Codebases, {});
51
46
  if (screen === 'projects')
52
47
  return _jsx(Projects, {});
53
48
  return _jsx(Issues, {});
54
49
  }
55
50
  const PAGE = 25;
56
- function Codebases() {
51
+ /**
52
+ * The organization's projects: its repositories on the forge, which arrive one
53
+ * of two ways — linked from GitHub, or started from a template. A project is a
54
+ * repository; opening one opens its workspace at `<org>/<repo>`, and every run
55
+ * there works on that repository. There is no project without one.
56
+ */
57
+ function Projects() {
57
58
  const host = useHost();
58
59
  const t = useTarget();
59
60
  const signed = Boolean(host.person);
60
61
  const [q, setQ] = useState('');
61
- const [making, setMaking] = useState(false);
62
- const [sort, setSort] = useState('name');
63
- const [filter, setFilter] = useState('all');
62
+ const [sort, setSort] = useState('updated');
64
63
  const [page, setPage] = useState(0);
65
64
  const [landed, setLanded] = useState(0);
66
65
  useEffect(() => {
67
66
  setPage(0);
68
- }, [q, sort, filter]);
69
- const list = useRead(signed ? () => codebases(t) : null, [], [t, signed, making, landed]);
70
- // Which codebases have an environment. A codebase absent here has none.
71
- const envs = useRead(signed ? () => environments(t) : null, [], [t, signed]);
72
- const envOf = new Map(envs.value.map((e) => [e.repo, e.state]));
67
+ }, [q, sort]);
68
+ const list = useRead(signed ? () => codebases(t) : null, [], [t, signed, landed]);
73
69
  const needle = q.trim().toLowerCase();
74
70
  const pending = readPending(host.org);
75
71
  const pendingKey = pending.map((p) => p.fullName).join('\n');
76
72
  const syncing = new Set(pending.map((p) => p.name));
73
+ // A repository asked for from GitHub is listed as syncing until the forge holds it.
77
74
  useEffect(() => {
78
75
  const waiting = readPending(host.org);
79
76
  if (!signed || waiting.length === 0)
@@ -110,19 +107,12 @@ function Codebases() {
110
107
  branches: ['main'],
111
108
  })),
112
109
  ]
113
- .filter((c) => {
114
- if (filter === 'ready')
115
- return envOf.get(c.name) === 'ready';
116
- if (filter === 'syncing')
117
- return syncing.has(c.name);
118
- return true;
119
- })
120
110
  .filter((c) => !needle || `${c.org}/${c.name} ${c.description}`.toLowerCase().includes(needle))
121
111
  .slice()
122
112
  .sort((a, b) => {
123
113
  if (sort === 'updated') {
124
- const d = Date.parse(b.updated) - Date.parse(a.updated);
125
- if (Number.isFinite(d) && d !== 0)
114
+ const d = (Date.parse(b.updated) || 0) - (Date.parse(a.updated) || 0);
115
+ if (d !== 0)
126
116
  return d;
127
117
  }
128
118
  return a.name.localeCompare(b.name);
@@ -132,69 +122,158 @@ function Codebases() {
132
122
  const slice = shown.slice(safePage * PAGE, safePage * PAGE + PAGE);
133
123
  const from = shown.length === 0 ? 0 : safePage * PAGE + 1;
134
124
  const to = safePage * PAGE + slice.length;
135
- const open = (c) => {
136
- pinCodebase(host.org, c);
137
- host.go('');
138
- };
139
- return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: [_jsxs(YStack, { width: "100%", maxW: 1040, mx: "auto", gap: "$4", children: [_jsxs(XStack, { justify: "space-between", items: "flex-start", gap: "$4", children: [_jsxs(YStack, { gap: "$1", flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$6", fontWeight: "500", color: "$ink", children: host.org || 'Forge' }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Create and browse this organization\u2019s repositories." })] }), _jsxs(XStack, { render: "button", "aria-label": "Settings", shrink: 0, items: "center", gap: "$1.5", px: "$2", py: "$1", rounded: "$3", hoverStyle: { bg: '$hover' }, onPress: () => host.go('-/settings/environments'), children: [_jsx(Settings, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Settings" })] })] }), _jsxs(XStack, { gap: "$2", items: "center", children: [_jsx(XStack, { render: "button", "aria-label": "Filter repositories", onPress: () => setFilter((f) => (f === 'all' ? 'ready' : f === 'ready' ? 'syncing' : 'all')), px: "$2.5", py: "$1.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", shrink: 0, hoverStyle: { bg: '$hover' }, "$max-md": { display: 'none' }, children: _jsx(SizableText, { size: "$2", color: "$ink", children: filter === 'ready' ? 'Ready env' : filter === 'syncing' ? 'Syncing' : 'All repos' }) }), _jsx(YStack, { flex: 1, "$max-md": { display: 'none' } }), _jsx(YStack, { width: 240, minW: 0, shrink: 1, "$max-md": { width: 'auto', flex: 1 }, children: _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Find repo\u2026", "aria-label": "Find a repository", disabled: !signed }) }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'sync' })), children: "Sync" }), _jsxs(Button, { size: "sm", disabled: !signed, onPress: () => setMaking(true), children: [_jsx(Plus, { size: 14 }), "New"] })] }), list.error ? (_jsx(Note, { children: list.error.message })) : !signed ? (_jsx(Note, { children: "Sign in to see this organization's repositories." })) : list.loading && list.value.length === 0 ? (_jsx(Note, { children: "Reading the forge\u2026" })) : shown.length === 0 ? (_jsxs(YStack, { gap: "$3", py: "$2", children: [_jsx(Note, { children: list.value.length === 0 ? 'No repositories yet.' : 'Nothing matches.' }), list.value.length === 0 ? (_jsxs(XStack, { gap: "$2", pt: "$1", children: [_jsxs(Button, { size: "sm", disabled: !signed, onPress: () => setMaking(true), children: [_jsx(Plus, { size: 14 }), "Create repository"] }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'sync' })), children: "Sync from GitHub" }), _jsx(Button, { size: "sm", variant: "ghost", onPress: () => host.go('-/templates'), children: "Start from template" })] })) : null] })) : (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", items: "center", children: [_jsxs(XStack, { flex: 1, render: "button", "aria-label": "Sort by name", items: "center", gap: "$1", onPress: () => setSort('name'), children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Name" }), sort === 'name' ? _jsx(ChevronDown, { size: 12 }) : null] }), _jsx(SizableText, { size: "$1", color: "$soft", width: 96, "$max-md": { display: 'none' }, children: "Environment" }), _jsxs(XStack, { render: "button", "aria-label": "Sort by last updated", items: "center", justify: "flex-end", gap: "$1", width: 140, onPress: () => setSort('updated'), children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Last updated" }), sort === 'updated' ? _jsx(ChevronDown, { size: 12 }) : null] })] }), slice.map((c) => (_jsxs(XStack, { render: "button", "aria-label": `Work on ${c.name}`, onPress: () => open(c), items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [_jsx(GitBranch, { size: 14 }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: c.name }), _jsx(SizableText, { size: "$1", color: "$soft", width: 96, "$max-md": { display: 'none' }, children: envOf.get(c.name) === 'ready' ? 'Ready' : envOf.get(c.name) === 'proposed' ? 'To review' : '—' }), _jsx(SizableText, { size: "$1", color: "$soft", width: 140, style: { textAlign: 'right' }, children: syncing.has(c.name) ? 'Syncing…' : ago(c.updated) })] }, `${c.org}/${c.name}`))), _jsxs(XStack, { px: "$3", py: "$2", justify: "space-between", items: "center", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: `Showing ${from}–${to} of ${shown.length}` }), _jsxs(XStack, { gap: "$3", items: "center", children: [_jsx(XStack, { render: "button", "aria-label": "Previous page", disabled: safePage === 0, onPress: () => setPage(safePage - 1), children: _jsx(SizableText, { size: "$1", color: safePage === 0 ? '$soft' : '$ink', children: "Prev" }) }), _jsx(SizableText, { size: "$1", color: "$soft", children: `${safePage + 1} / ${pages}` }), _jsx(XStack, { render: "button", "aria-label": "Next page", disabled: safePage >= pages - 1, onPress: () => setPage(safePage + 1), children: _jsx(SizableText, { size: "$1", color: safePage >= pages - 1 ? '$soft' : '$ink', children: "Next" }) })] })] })] }))] }), _jsx(NewRepo, { open: making, onOpenChange: setMaking, onCreate: async (name, description) => {
140
- const row = await create(t, name, description);
141
- pinCodebase(host.org, row);
142
- setMaking(false);
143
- host.go('');
144
- } })] }));
125
+ const none = !list.loading && !list.error && list.value.length === 0 && pending.length === 0;
126
+ const open = (c) => host.go(path({ kind: 'repo', org: c.org || host.org || '', name: c.name }));
127
+ const link = () => host.go(path({ kind: 'screen', screen: 'sync' }));
128
+ const template = () => host.go(path({ kind: 'screen', screen: 'templates' }));
129
+ return (_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", "$max-md": { px: '$4' }, children: _jsxs(YStack, { width: "100%", maxW: 1040, mx: "auto", gap: "$4", children: [_jsxs(XStack, { justify: "space-between", items: "flex-start", gap: "$4", flexWrap: "wrap", children: [_jsxs(YStack, { gap: "$1", flex: 1, minW: 240, children: [_jsx(SizableText, { render: "h1", size: "$6", fontWeight: "500", color: "$ink", children: "Projects" }), _jsx(SizableText, { size: "$2", color: "$soft", children: `${host.org ? `${host.org}’s` : 'This organization’s'} repositories on the forge. Open one to work on it.` })] }), _jsxs(XStack, { gap: "$2", items: "center", flexWrap: "wrap", children: [_jsxs(Button, { size: "sm", variant: "outline", onPress: template, children: [_jsx(LayoutTemplate, { size: 14 }), "Create from template"] }), _jsxs(Button, { size: "sm", variant: "primary", onPress: link, children: [_jsx(Github, { size: 14 }), "Link a GitHub repo"] })] })] }), signed ? _jsx(GitHubLink, { onSync: link }) : null, list.error ? (_jsx(Note, { children: list.error.message })) : !signed ? (_jsx(Note, { children: "Sign in to see this organization's projects." })) : list.loading && list.value.length === 0 && pending.length === 0 ? (_jsx(Note, { children: "Reading the forge\u2026" })) : none ? (_jsxs(YStack, { gap: "$3", py: "$2", children: [_jsx(Note, { children: "No projects yet. A project is a repository: link one from GitHub, or start one from a template." }), _jsxs(XStack, { gap: "$3", flexWrap: "wrap", children: [_jsx(Choice, { label: "Link a GitHub repo", icon: _jsx(Github, { size: 20 }), says: "Mirror repositories you already have. They stay in step with GitHub, and their issues can come too.", onPress: link }), _jsx(Choice, { label: "Create from template", icon: _jsx(LayoutTemplate, { size: 20 }), says: "Start from a finished product \u2014 a site, an app, a store \u2014 and make it yours.", onPress: template })] })] })) : (_jsxs(_Fragment, { children: [_jsxs(XStack, { gap: "$2", items: "center", children: [_jsx(YStack, { flex: 1, "$max-md": { display: 'none' } }), _jsx(YStack, { width: 320, minW: 0, shrink: 1, "$max-md": { width: 'auto', flex: 1 }, children: _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Find a project\u2026", "aria-label": "Find a project" }) })] }), shown.length === 0 ? (_jsx(Note, { children: "Nothing matches." })) : (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", items: "center", bg: "$panel", children: [_jsxs(XStack, { flex: 1, render: "button", "aria-label": "Sort by name", items: "center", gap: "$1", onPress: () => setSort('name'), children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Name" }), sort === 'name' ? _jsx(ChevronDown, { size: 12 }) : null] }), _jsx(SizableText, { size: "$1", color: "$soft", width: 120, "$max-md": { display: 'none' }, children: "Branch" }), _jsxs(XStack, { render: "button", "aria-label": "Sort by last updated", items: "center", justify: "flex-end", gap: "$1", width: 120, onPress: () => setSort('updated'), children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Updated" }), sort === 'updated' ? _jsx(ChevronDown, { size: 12 }) : null] })] }), slice.map((c) => (_jsxs(XStack, { render: "button", "aria-label": `Open ${c.org ? `${c.org}/` : ''}${c.name}`, onPress: () => open(c), style: { textAlign: 'left' }, items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [_jsx(FolderGit2, { size: 15, color: "$soft" }), _jsxs(YStack, { flex: 1, minW: 0, children: [_jsxs(XStack, { minW: 0, items: "baseline", children: [c.org ? (_jsx(SizableText, { size: "$2", color: "$soft", numberOfLines: 1, shrink: 0, children: `${c.org}/` })) : null, _jsx(SizableText, { size: "$2", color: "$ink", fontWeight: "500", numberOfLines: 1, shrink: 1, children: c.name })] }), c.description ? (_jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: c.description })) : null] }), _jsxs(XStack, { width: 120, items: "center", gap: "$1.5", minW: 0, "$max-md": { display: 'none' }, children: [_jsx(GitBranch, { size: 12, color: "$soft" }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: c.branch })] }), _jsx(SizableText, { size: "$1", color: "$soft", width: 120, style: { textAlign: 'right' }, children: syncing.has(c.name) ? 'Syncing…' : ago(c.updated) })] }, `${c.org}/${c.name}`))), _jsxs(XStack, { px: "$3", py: "$2", justify: "space-between", items: "center", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: `Showing ${from}–${to} of ${shown.length}` }), _jsxs(XStack, { gap: "$3", items: "center", children: [_jsx(XStack, { render: "button", "aria-label": "Previous page", disabled: safePage === 0, onPress: () => setPage(safePage - 1), children: _jsx(SizableText, { size: "$1", color: safePage === 0 ? '$soft' : '$ink', children: "Prev" }) }), _jsx(SizableText, { size: "$1", color: "$soft", children: `${safePage + 1} / ${pages}` }), _jsx(XStack, { render: "button", "aria-label": "Next page", disabled: safePage >= pages - 1, onPress: () => setPage(safePage + 1), children: _jsx(SizableText, { size: "$1", color: safePage >= pages - 1 ? '$soft' : '$ink', children: "Next" }) })] })] })] }))] }))] }) }));
130
+ }
131
+ /** One of the two ways in, as a card you press. */
132
+ function Choice({ label, icon, says, onPress }) {
133
+ return (_jsxs(YStack, { render: "button", "aria-label": label, onPress: onPress, flex: 1, minW: 240, gap: "$2", p: "$4", rounded: "$4", borderWidth: 1, borderColor: "$borderColor", bg: "$panel", items: "flex-start", hoverStyle: { borderColor: '$edge', bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [icon, _jsxs(XStack, { items: "center", gap: "$1.5", children: [_jsx(SizableText, { size: "$4", color: "$ink", fontWeight: "500", children: label }), _jsx(ArrowRight, { size: 14 })] }), _jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'left' }, children: says })] }));
145
134
  }
146
- function NewRepo({ open, onOpenChange, onCreate, }) {
147
- const [name, setName] = useState('');
148
- const [description, setDescription] = useState('');
135
+ /**
136
+ * The person's GitHub connection, in one line: who it is connected as and the
137
+ * way to sync, or the one press that connects it. GitHub returns here, and this
138
+ * page finishes the connect (../back.ts).
139
+ */
140
+ function GitHubLink({ onSync }) {
141
+ const host = useHost();
142
+ const t = useTarget();
143
+ const me = useRead(() => connection(t), null, [t]);
144
+ const [note, setNote] = useState('');
149
145
  const [busy, setBusy] = useState(false);
150
- const [error, setError] = useState('');
151
- useEffect(() => {
152
- if (!open) {
153
- setName('');
154
- setDescription('');
155
- setError('');
156
- }
157
- }, [open]);
158
- const submit = async () => {
146
+ useBack(t, true, setNote, me.reload);
147
+ const c = me.value;
148
+ // A connection that cannot be read says nothing here: Sync from GitHub above still works.
149
+ if (me.error || !c || !c.configured)
150
+ return note ? _jsx(Note, { children: note }) : null;
151
+ const link = async () => {
159
152
  setBusy(true);
160
- setError('');
153
+ setNote('');
161
154
  try {
162
- await onCreate(name, description);
163
- setName('');
164
- setDescription('');
155
+ host.open(await connect(t, here()));
165
156
  }
166
157
  catch (e) {
167
- setError(e.message);
168
- }
169
- finally {
158
+ setNote(e.message);
170
159
  setBusy(false);
171
160
  }
172
161
  };
173
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { children: [_jsx(DialogTitle, { children: "New repository" }), _jsxs(YStack, { gap: "$3", pt: "$3", children: [_jsx(Input, { value: name, onChangeText: setName, placeholder: "Name", "aria-label": "Repository name" }), _jsx(Input, { value: description, onChangeText: setDescription, placeholder: "Description", "aria-label": "Description" }), error ? _jsx(Note, { children: error }) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "outline", disabled: busy, onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: busy || !name.trim(), onPress: () => void submit(), children: busy ? 'Creating…' : 'Create' })] })] })] }) }));
162
+ return (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2.5", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", bg: "$panel", flexWrap: "wrap", children: [_jsx(Github, { size: 16 }), _jsxs(YStack, { flex: 1, minW: 200, children: [_jsx(SizableText, { size: "$2", color: "$ink", children: c.connected ? `GitHub connected as @${c.login}` : 'Connect GitHub to bring your repositories here' }), _jsx(SizableText, { size: "$1", color: "$soft", children: note || (c.connected ? 'Choose repositories to mirror; each stays in step with GitHub.' : 'One step on GitHub, then pick the repositories to sync.') })] }), c.connected ? (_jsxs(Button, { size: "sm", variant: "outline", onPress: onSync, children: [_jsx(RefreshCw, { size: 14 }), "Sync repositories"] })) : (_jsxs(Button, { size: "sm", variant: "primary", disabled: busy, onPress: () => void link(), children: [_jsx(Github, { size: 14 }), busy ? 'Opening GitHub…' : 'Connect GitHub'] }))] }));
174
163
  }
175
- function Projects() {
176
- const host = useHost();
177
- const t = useTarget();
178
- const signed = Boolean(host.person);
179
- const list = useRead(signed ? () => boards(t) : null, [], [t, signed]);
180
- const open = (b) => {
181
- pinBoard(host.org, b.key);
182
- host.go(path({ kind: 'screen', screen: 'issues' }));
183
- };
184
- return (_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", items: "center", children: _jsxs(YStack, { width: "100%", maxW: 720, gap: "$3", children: [_jsx(Head, { title: "Projects", says: "Boards on the forge. A board is a repository that has work on it." }), list.error ? (_jsx(Note, { children: list.error.message })) : !signed ? (_jsx(Note, { children: "Sign in to see this organization's boards." })) : list.loading && list.value.length === 0 ? (_jsx(Note, { children: "Reading the forge\u2026" })) : list.value.length === 0 ? (_jsxs(YStack, { gap: "$3", py: "$2", children: [_jsx(Note, { children: "No boards yet. A repository shows up here once it has an issue." }), _jsxs(XStack, { gap: "$2", pt: "$1", children: [_jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'codebases' })), children: "View repositories" }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'sync' })), children: "Sync from GitHub" })] })] })) : (_jsx(YStack, { gap: "$2", children: list.value.map((b) => (_jsx(Row, { label: `Open ${b.name}`, onPress: () => open(b), children: _jsxs(YStack, { flex: 1, minW: 0, gap: "$1", children: [_jsx(SizableText, { size: "$3", color: "$ink", numberOfLines: 1, children: b.name }), b.description ? (_jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: b.description })) : (_jsx(SizableText, { size: "$1", color: "$soft", children: b.key }))] }) }, b.key))) }))] }) }));
164
+ const KINDS = { all: 'Issues and pull requests', issue: 'Issues', pr: 'Pull requests' };
165
+ const ORIGINS = { all: 'Every source', github: 'GitHub', hanzo: 'Hanzo' };
166
+ const ORDERS = { newest: 'Newest', oldest: 'Oldest', updated: 'Recently updated' };
167
+ /** Mirrored from GitHub: filed by the forge's `git` source with its page on github.com. */
168
+ const fromGitHub = (w) => /^https:\/\/github\.com\//.test(w.url);
169
+ /** A dropdown that picks one of `options`, its trigger naming the choice. */
170
+ function Pick({ label, value, options, onPick }) {
171
+ return (_jsx(DropdownMenu, { trigger: _jsxs(XStack, { render: "button", "aria-label": label, items: "center", gap: "$1", px: "$2", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { size: "$2", color: "$soft", numberOfLines: 1, children: options[value] }), _jsx(ChevronDown, { size: 12 })] }), items: Object.keys(options).map((k) => ({
172
+ key: k,
173
+ label: options[k],
174
+ icon: k === value ? _jsx(Check, { size: 14 }) : _jsx(YStack, { width: 14 }),
175
+ onSelect: () => onPick(k),
176
+ })) }));
177
+ }
178
+ function Mark({ w }) {
179
+ if (w.kind === 'pr')
180
+ return _jsx(GitPullRequest, { size: 16, color: closed(w) ? '$soft' : '$ink' });
181
+ if (w.status === 'canceled')
182
+ return _jsx(CircleSlash, { size: 16, color: "$soft" });
183
+ if (w.status === 'done')
184
+ return _jsx(CircleCheck, { size: 16, color: "$soft" });
185
+ return _jsx(CircleDot, { size: 16, color: "$ink" });
185
186
  }
187
+ function Tag({ children }) {
188
+ return (_jsx(XStack, { px: "$2", py: 1, rounded: 999, borderWidth: 1, borderColor: "$borderColor", shrink: 0, children: _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: children }) }));
189
+ }
190
+ /**
191
+ * Every project's issues, read the way GitHub reads them: open or closed, one
192
+ * project or all of them, narrowed by words, kind, source and order. Each project
193
+ * syncs its own issues from GitHub; choosing an issue starts the next run on its
194
+ * codebase with the issue as the ask.
195
+ */
186
196
  function Issues() {
187
197
  const host = useHost();
188
198
  const t = useTarget();
189
199
  const signed = Boolean(host.person);
190
200
  const [board, setBoard] = useKept(boardKey(host.org), '');
191
- const list = useRead(signed ? () => issues(t, board) : null, [], [t, signed, board]);
201
+ const [state, setState] = useState('open');
202
+ const [kind, setKind] = useState('all');
203
+ const [origin, setOrigin] = useState('all');
204
+ const [order, setOrder] = useState('newest');
205
+ const [q, setQ] = useState('');
206
+ const [pulled, setPulled] = useState(0);
207
+ const [busy, setBusy] = useState(false);
208
+ const [said, setSaid] = useState(null);
209
+ const list = useRead(signed ? () => issues(t) : null, [], [t, signed, pulled]);
210
+ const known = useRead(signed ? () => boards(t) : null, [], [t, signed]);
192
211
  // Another screen chose the board (the rail's Issues chooses every board) while this one is open.
193
212
  useEffect(() => {
194
213
  const on = (e) => setBoard(e.detail);
195
214
  window.addEventListener('hanzo-board', on);
196
215
  return () => window.removeEventListener('hanzo-board', on);
197
216
  }, [setBoard]);
217
+ useEffect(() => setSaid(null), [board]);
218
+ // The projects: every board the forge names, and every repository an issue is filed under.
219
+ const projects = new Map();
220
+ for (const b of known.value)
221
+ projects.set(b.key.toLowerCase(), { key: b.key, name: b.name, open: 0 });
222
+ for (const w of list.value) {
223
+ const key = projectOf(w);
224
+ if (!key)
225
+ continue;
226
+ const p = projects.get(key.toLowerCase()) ?? { key, name: key, open: 0 };
227
+ if (!closed(w))
228
+ p.open++;
229
+ projects.set(key.toLowerCase(), p);
230
+ }
231
+ const rows = [...projects.values()].sort((a, b) => a.name.localeCompare(b.name));
232
+ const current = board ? projects.get(board.toLowerCase()) : undefined;
233
+ const title = current?.name ?? board;
234
+ const needle = q.trim().toLowerCase();
235
+ const scoped = list.value
236
+ .filter((w) => !board || inProject(w, board))
237
+ .filter((w) => kind === 'all' || (kind === 'pr' ? w.kind === 'pr' : w.kind !== 'pr'))
238
+ .filter((w) => origin === 'all' || (origin === 'github') === fromGitHub(w))
239
+ .filter((w) => !needle || [w.title, w.identifier, w.assignee, projectOf(w), ...w.labels].join(' ').toLowerCase().includes(needle));
240
+ const opened = scoped.filter((w) => !closed(w));
241
+ const shut = scoped.filter(closed);
242
+ const shown = (state === 'open' ? opened : shut).slice().sort((a, b) => {
243
+ if (order === 'updated')
244
+ return (b.updated || b.created) - (a.updated || a.created);
245
+ if (order === 'oldest')
246
+ return a.created - b.created;
247
+ return b.created - a.created;
248
+ });
249
+ const pull = async () => {
250
+ setBusy(true);
251
+ setSaid(null);
252
+ try {
253
+ const s = await syncIssues(t, board);
254
+ const where = board ? title : `${s.repos} ${s.repos === 1 ? 'repository' : 'repositories'}`;
255
+ const parts = [`${s.issues} ${s.issues === 1 ? 'issue' : 'issues'} synced from ${where}`, `${s.created} new`, `${s.updated} updated`];
256
+ if (s.failed)
257
+ parts.push(`${s.failed} failed`);
258
+ setSaid({ text: `${parts.join(' · ')}.${s.truncated ? ' More remain — sync again to continue.' : ''}` });
259
+ setPulled((n) => n + 1);
260
+ }
261
+ catch (e) {
262
+ const text = e.message;
263
+ setSaid({ text, connect: /not connected/i.test(text) });
264
+ }
265
+ finally {
266
+ setBusy(false);
267
+ }
268
+ };
269
+ const link = async () => {
270
+ try {
271
+ host.open(await connect(t, here()));
272
+ }
273
+ catch (e) {
274
+ setSaid({ text: e.message });
275
+ }
276
+ };
198
277
  const open = (w) => {
199
278
  const name = w.repo || w.project;
200
279
  if (name) {
@@ -211,5 +290,21 @@ function Issues() {
211
290
  }
212
291
  host.go('');
213
292
  };
214
- return (_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", items: "center", children: _jsxs(YStack, { width: "100%", maxW: 720, gap: "$3", children: [_jsx(Head, { title: "Issues", says: board ? `Open work on ${board}. Choosing one starts the next run on that codebase.` : 'Open work across every board. Choosing one starts the next run on that codebase.' }), board ? (_jsx(XStack, { render: "button", onPress: () => setBoard(''), "aria-label": "Show every board", self: "flex-start", children: _jsx(SizableText, { size: "$1", color: "$soft", children: "All boards" }) })) : null, list.error ? (_jsx(Note, { children: list.error.message })) : !signed ? (_jsx(Note, { children: "Sign in to see this organization's issues." })) : list.loading && list.value.length === 0 ? (_jsx(Note, { children: "Reading the forge\u2026" })) : list.value.length === 0 ? (_jsxs(YStack, { gap: "$3", py: "$2", children: [_jsx(Note, { children: "Nothing open." }), _jsxs(XStack, { gap: "$2", pt: "$1", children: [_jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'codebases' })), children: "View repositories" }), _jsx(Button, { size: "sm", onPress: () => host.go(''), children: "Start a new run" })] })] })) : (_jsx(YStack, { gap: "$2", children: list.value.map((w) => (_jsxs(Row, { label: `Build ${w.identifier || w.title}`, onPress: () => open(w), children: [_jsx(SizableText, { size: "$1", color: "$soft", width: 88, numberOfLines: 1, children: w.identifier || w.kind }), _jsx(SizableText, { size: "$3", color: "$ink", flex: 1, minW: 0, numberOfLines: 1, children: w.title }), _jsx(SizableText, { size: "$1", color: "$soft", children: STATUS[w.status] ?? w.status })] }, w.id))) }))] }) }));
293
+ const chip = (key, name, count) => {
294
+ const on = key.toLowerCase() === board.toLowerCase();
295
+ return (_jsxs(XStack, { render: "button", "aria-label": key ? `Show ${name}` : 'Show every project', "aria-pressed": on, onPress: () => setBoard(key), items: "center", gap: "$2", px: "$2.5", py: "$1.5", rounded: "$3", bg: on ? '$hover' : 'transparent', hoverStyle: { bg: '$hover' }, shrink: 0, children: [_jsx(SizableText, { flex: 1, size: "$2", color: on ? '$ink' : '$soft', numberOfLines: 1, style: { textAlign: 'left' }, children: name }), count ? (_jsx(SizableText, { size: "$1", color: "$soft", children: count })) : null] }, key || '*'));
296
+ };
297
+ return (_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", "$max-md": { px: '$4' }, children: _jsxs(YStack, { width: "100%", maxW: 1120, mx: "auto", gap: "$4", children: [_jsxs(XStack, { justify: "space-between", items: "flex-start", gap: "$4", flexWrap: "wrap", children: [_jsxs(YStack, { gap: "$1", flex: 1, minW: 240, children: [_jsx(SizableText, { render: "h1", size: "$6", fontWeight: "500", color: "$ink", children: board ? `Issues · ${title}` : 'Issues' }), _jsx(SizableText, { size: "$2", color: "$soft", children: board ? `Work on ${title}. Choosing an issue starts the next run on that codebase.` : 'Work across every project. Choosing an issue starts the next run on that codebase.' })] }), _jsxs(Button, { size: "sm", variant: "outline", disabled: !signed || busy, onPress: () => void pull(), children: [_jsx(Github, { size: 14 }), busy ? 'Syncing…' : board ? `Sync ${title} from GitHub` : 'Sync all from GitHub'] })] }), said ? (_jsxs(XStack, { items: "center", gap: "$3", flexWrap: "wrap", children: [_jsx(Note, { children: said.text }), said.connect ? (_jsxs(Button, { size: "sm", variant: "primary", onPress: () => void link(), children: [_jsx(Github, { size: 14 }), "Connect GitHub"] })) : null] })) : null, _jsxs(XStack, { gap: "$5", items: "flex-start", "$max-md": { flexDirection: 'column', gap: '$3' }, children: [_jsxs(YStack, { render: "nav", "aria-label": "Projects", width: 220, shrink: 0, gap: "$0.5", "$max-md": { width: '100%', flexDirection: 'row', overflow: 'scroll', gap: '$1' }, children: [_jsx(SizableText, { size: "$1", color: "$soft", px: "$2.5", pb: "$1", "$max-md": { display: 'none' }, children: "Projects" }), chip('', 'All projects', list.value.filter((w) => !closed(w)).length), rows.map((p) => chip(p.key, p.name, p.open))] }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$3", width: "100%", children: [_jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search issues", "aria-label": "Search issues", disabled: !signed }), list.error ? (_jsx(Note, { children: list.error.message })) : !signed ? (_jsx(Note, { children: "Sign in to see this organization's issues." })) : list.loading && list.value.length === 0 ? (_jsx(Note, { children: "Reading the forge\u2026" })) : (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", items: "center", bg: "$panel", flexWrap: "wrap", children: [_jsxs(XStack, { gap: "$3", items: "center", flex: 1, minW: 0, children: [_jsxs(XStack, { render: "button", "aria-label": "Open issues", "aria-pressed": state === 'open', items: "center", gap: "$1.5", onPress: () => setState('open'), children: [_jsx(CircleDot, { size: 14, color: state === 'open' ? '$ink' : '$soft' }), _jsx(SizableText, { size: "$2", color: state === 'open' ? '$ink' : '$soft', fontWeight: state === 'open' ? '500' : '400', children: `${opened.length} Open` })] }), _jsxs(XStack, { render: "button", "aria-label": "Closed issues", "aria-pressed": state === 'closed', items: "center", gap: "$1.5", onPress: () => setState('closed'), children: [_jsx(Check, { size: 14, color: state === 'closed' ? '$ink' : '$soft' }), _jsx(SizableText, { size: "$2", color: state === 'closed' ? '$ink' : '$soft', fontWeight: state === 'closed' ? '500' : '400', children: `${shut.length} Closed` })] })] }), _jsxs(XStack, { gap: "$1", items: "center", flexWrap: "wrap", shrink: 1, minW: 0, children: [_jsx(Pick, { label: "Kind", value: kind, options: KINDS, onPick: setKind }), _jsx(Pick, { label: "Source", value: origin, options: ORIGINS, onPick: setOrigin }), _jsx(Pick, { label: "Sort", value: order, options: ORDERS, onPick: setOrder })] })] }), shown.length === 0 ? (_jsxs(YStack, { px: "$3", py: "$6", gap: "$3", items: "center", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(Note, { children: state === 'open'
298
+ ? needle || kind !== 'all' || origin !== 'all'
299
+ ? 'No open issues match.'
300
+ : 'Nothing open.'
301
+ : 'Nothing closed.' }), list.value.length === 0 ? (_jsxs(XStack, { gap: "$2", flexWrap: "wrap", justify: "center", children: [_jsxs(Button, { size: "sm", variant: "outline", disabled: busy, onPress: () => void pull(), children: [_jsx(Github, { size: 14 }), "Sync from GitHub"] }), _jsx(Button, { size: "sm", onPress: () => host.go(''), children: "Start a new run" })] })) : null] })) : (shown.map((w) => (_jsxs(XStack, { items: "center", gap: "$2", pr: "$3", borderTopWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsxs(XStack, { render: "button", "aria-label": `Build ${w.identifier || w.title}`, onPress: () => open(w), flex: 1, minW: 0, items: "flex-start", gap: "$3", pl: "$3", py: "$2.5", focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [_jsx(YStack, { pt: 2, children: _jsx(Mark, { w: w }) }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$1", items: "flex-start", children: [_jsxs(XStack, { gap: "$2", items: "center", flexWrap: "wrap", maxW: "100%", children: [_jsx(SizableText, { size: "$3", color: "$ink", fontWeight: "500", numberOfLines: 1, shrink: 1, children: w.title }), STATUS[w.status] && !closed(w) && w.status !== 'todo' ? _jsx(Tag, { children: STATUS[w.status] }) : null, !STATUS[w.status] ? _jsx(Tag, { children: w.status }) : null, w.priority !== 'none' && w.priority ? _jsx(Tag, { children: w.priority }) : null, w.labels.slice(0, 4).map((l) => (_jsx(Tag, { children: l }, l)))] }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [
302
+ w.identifier || w.kind,
303
+ !board && projectOf(w) ? projectOf(w) : '',
304
+ w.created ? `opened ${ago(new Date(w.created).toISOString())}` : '',
305
+ w.assignee ? `assigned to ${w.assignee}` : '',
306
+ fromGitHub(w) ? 'GitHub' : '',
307
+ ]
308
+ .filter(Boolean)
309
+ .join(' · ') })] })] }), w.url ? (_jsx(Out, { href: w.url, label: `Open ${w.identifier || w.title} on ${fromGitHub(w) ? 'GitHub' : 'the forge'}`, children: _jsx(ExternalLink, { size: 14, opacity: 0.6 }) })) : null] }, w.id))))] }))] })] })] }) }));
215
310
  }
package/lib/host.d.ts CHANGED
@@ -39,7 +39,7 @@ export interface Host {
39
39
  person: Person | null;
40
40
  /** Whether the person administers `org` (its owner or an admin): they publish to main and change its money; anyone else opens a review and reads. */
41
41
  admin: boolean;
42
- /** The address under the mount: '' | a session id | a slug | '-/codebases' | '-/projects' | '-/issues' | '-/artifacts' | '-/templates'. */
42
+ /** The address under the mount, in route.ts's grammar: '' | a session id | `<org>/<repo>` | a slug | '-/projects' | '-/issues' | … */
43
43
  path: string;
44
44
  /** Move under the mount. */
45
45
  go: (path: string, how?: {
package/lib/landing.js CHANGED
@@ -6,11 +6,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  * which repository and branch it starts from, and the ask itself.
7
7
  * Under it: attach, dictate, the mode, and the model and effort on the right.
8
8
  *
9
- * Sending starts a coding run and opens it. The codebase is optional: in the
10
- * sandbox a run with none starts a new project named from the ask, as the
11
- * platform does for a run that names no repository; a machine works in its own
12
- * checkout, so a run there names one. The choices a person made here are kept in
13
- * this browser, per org, so the next New starts where the last one did.
9
+ * Sending starts a coding run on a project and opens it. A project is a
10
+ * repository — the org's on the forge, or one its GitHub grants — so a run
11
+ * names one, and an ask with none chosen opens the picker rather than making a
12
+ * project up from its words. The choices a person made here are kept in this
13
+ * browser, per org, so the next New starts where the last one did.
14
14
  */
15
15
  import { SizableText, XStack, YStack } from '@hanzo/gui';
16
16
  import { Cloud, Monitor } from '@hanzogui/lucide-icons-2';
@@ -144,8 +144,9 @@ export function Landing({ onStarted }) {
144
144
  }
145
145
  const repo = isForge(kept.repo) ? kept.repo : null;
146
146
  const hub = isHub(kept.repo) ? kept.repo : null;
147
- if (!repo && !hub && place.id) {
148
- setNote(`A run on ${place.label} works in its own checkout: choose the codebase, or run it in Cloud.`);
147
+ if (!repo && !hub) {
148
+ setNote('Choose the project this run works on: a repository of this organization, or one on its GitHub.');
149
+ setPicking(true);
149
150
  return;
150
151
  }
151
152
  setBusy(true);
@@ -154,7 +155,6 @@ export function Landing({ onStarted }) {
154
155
  const run = await start(t, {
155
156
  prompt: compose(draft.trim(), files),
156
157
  // The name alone. The org rides the request, and a slash is not a repo name.
157
- // No codebase is not sent, and the run starts a new project.
158
158
  // An empty base or place is not sent: the default branch, and the sandbox.
159
159
  repo: repo?.name ?? hub?.full_name,
160
160
  base: repo ? kept.branch : hub ? kept.branch || hub.default_branch : undefined,
@@ -227,7 +227,7 @@ export function Landing({ onStarted }) {
227
227
  name: r.name,
228
228
  });
229
229
  },
230
- }, placeholder: "New project", disabled: !signed }, round), isForge(kept.repo) ? _jsx(BranchSelect, { value: branch, onChange: (b) => set({ branch: b }), load: loadBranches }) : null, _jsx(Files, { files: files, onFiles: setFiles })] }));
230
+ }, placeholder: "Choose a project", disabled: !signed }, round), isForge(kept.repo) ? _jsx(BranchSelect, { value: branch, onChange: (b) => set({ branch: b }), load: loadBranches }) : null, _jsx(Files, { files: files, onFiles: setFiles })] }));
231
231
  const foot = (_jsxs(XStack, { flex: 1, items: "center", gap: "$2", flexWrap: "wrap", rowGap: "$1", children: [_jsx(Attach, { files: files, onFiles: setFiles, onNote: setNote }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote }), _jsx(ModeSelect, { modes: MODES, value: kept.mode, onChange: (m) => set({ mode: m }), bg: "transparent", minH: 24, px: "$1.5", self: "center" }), _jsx(XStack, { flex: 1 }), _jsx(ModelPicker, { quiet: true, size: "sm", name: "Model", models: catalog.value, scope: "chat", limits: limits, value: model, onChange: pickModel, loading: catalog.loading, error: catalog.error?.message ?? null }), _jsx(ChipSelect, { quiet: true, name: "Effort", label: EFFORTS.find((e) => e.id === kept.effort)?.label ?? 'Medium', chosen: EFFORTS.find((e) => e.id === kept.effort) ?? null, items: EFFORTS, onChange: (e) => set({ effort: e.id }), placement: "top-end", width: 180 })] }));
232
232
  return (_jsxs(YStack, { flex: 1, minH: 0, minW: 0, items: "center", justify: "center", px: "$4", pb: "$10", overflow: "scroll", children: [_jsxs(YStack, { width: "100%", maxW: COLUMN, gap: "$5", children: [_jsxs(XStack, { role: "heading", "aria-level": 1, justify: "center", items: "center", gap: "$3", flexWrap: "wrap", children: [_jsx(HanzoMark, { size: 26 }), _jsx(SizableText, { size: "$8", color: "$ink", style: { textAlign: 'center' }, children: prefs.callName ? `What’s up next, ${prefs.callName}?` : 'What’s up next?' })] }), _jsxs(YStack, { gap: "$2", children: [env.value && env.value.state !== 'ready' && !place.id ? (_jsxs(XStack, { items: "center", justify: "space-between", gap: "$3", px: "$3", py: "$2", rounded: "$10", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { size: "$2", color: "$ink", flex: 1, minW: 0, children: env.value.state === 'proposed'
233
233
  ? `A proposed environment for ${codebase} is waiting for review.`
package/lib/project.d.ts CHANGED
@@ -1,3 +1,18 @@
1
- export declare function Project({ slug }: {
1
+ import { type Project as Row } from './api/projects.ts';
2
+ /** What a workspace is opened on: a repository on the forge, or a deployed site by its slug. */
3
+ export type Where = {
2
4
  slug: string;
5
+ } | {
6
+ org: string;
7
+ name: string;
8
+ };
9
+ /** Whether the site `p` is the repository `r`'s: built from it, or named as it is. */
10
+ export declare function siteOf(p: Row, r: {
11
+ org: string;
12
+ name: string;
13
+ }): boolean;
14
+ /** A site's slug for a repository's name: lowercase letters, digits and hyphens, at most 40. */
15
+ export declare function slugOf(name: string): string;
16
+ export declare function Project({ at }: {
17
+ at: Where;
3
18
  }): import("react").JSX.Element;
package/lib/project.js CHANGED
@@ -3,6 +3,11 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  * A project's workspace, in the same window: the chat on the left, what it
4
4
  * built on the right.
5
5
  *
6
+ * A project is a repository on the forge, addressed `<org>/<repo>`; the site it
7
+ * publishes to, when it has one, is the org's project row on that repository
8
+ * (or of the same name). A deployed site with no repository on the forge is
9
+ * opened by its slug (Artifacts), and works the same way.
10
+ *
6
11
  * bar mark · project · history · chat toggle | Preview Files Code Layers |
7
12
  * Share · Publish
8
13
  * left the project's runs as a conversation, suggestions, and the ask
@@ -41,10 +46,11 @@ import { Button, Dialog, DialogContent, DialogTitle } from '@hanzo/ui';
41
46
  import { HanzoMark } from '@hanzo/ui/product';
42
47
  import { useEffect, useMemo, useRef, useState } from 'react';
43
48
  import { read as pushed } from './api/changes.js';
49
+ import { codebases, one } from './api/codebases.js';
44
50
  import { start } from './api/coding.js';
45
51
  import { blob, tree } from './api/git.js';
46
52
  import { declare } from './api/platform.js';
47
- import { deployments, name as repoName, ours, templates } from './api/projects.js';
53
+ import { address, deployments, name as repoName, ours, templates } from './api/projects.js';
48
54
  import { list, message, stop } from './api/sessions.js';
49
55
  import { decode, outcome, pull, said, who } from './api/turn.js';
50
56
  import { verdict as record } from './api/verdict.js';
@@ -54,8 +60,8 @@ import { Grip } from './grip.js';
54
60
  import { useHost, useTarget } from './host.js';
55
61
  import { MODES } from './landing.js';
56
62
  import { Out } from './out.js';
57
- import { pane as cut } from './pane.js';
58
63
  import { Publish } from './publish.js';
64
+ import { path } from './route.js';
59
65
  import { Attach, compose, Dictate, Files } from './tools.js';
60
66
  const LIVE = new Set(['running', 'paused']);
61
67
  /** How often a project being published is read again, ms. */
@@ -70,6 +76,20 @@ const CHAT_CEIL = 640;
70
76
  const GUTTER = 12;
71
77
  /** What the work keeps beside the chat however wide the chat was asked to be, px. */
72
78
  const WORK = 400 + 2 * GUTTER;
79
+ /** Whether the site `p` is the repository `r`'s: built from it, or named as it is. */
80
+ export function siteOf(p, r) {
81
+ const want = `${r.org}/${r.name}`.toLowerCase();
82
+ return (p.repo !== '' && address(p.repo).toLowerCase() === want) || p.slug === r.name.toLowerCase();
83
+ }
84
+ /** A site's slug for a repository's name: lowercase letters, digits and hyphens, at most 40. */
85
+ export function slugOf(name) {
86
+ return name
87
+ .toLowerCase()
88
+ .replace(/[^a-z0-9]+/g, '-')
89
+ .replace(/^-+|-+$/g, '')
90
+ .slice(0, 40)
91
+ .replace(/-+$/, '');
92
+ }
73
93
  /** One run in the conversation: what was asked, and what it came to. */
74
94
  function Turn({ run, open, onOpen, project }) {
75
95
  const t = useTarget();
@@ -83,12 +103,18 @@ function Turn({ run, open, onOpen, project }) {
83
103
  });
84
104
  } }), note ? (_jsx(SizableText, { size: "$1", color: "$soft", children: note })) : null, !open ? (_jsx(XStack, { render: "button", onPress: onOpen, hitSlop: 8, children: _jsx(SizableText, { size: "$1", color: "$soft", textDecorationLine: "underline", children: "Show steps" }) })) : null] }), children: _jsx(SizableText, { size: "$2", color: "$ink", children: run.status === 'done' ? 'Done.' : run.status === 'error' ? 'This run hit an error.' : run.status === 'stopped' ? 'Stopped.' : 'Working…' }) })] }));
85
105
  }
86
- export function Project({ slug }) {
106
+ export function Project({ at }) {
87
107
  const host = useHost();
88
108
  const t = useTarget();
89
109
  const signed = Boolean(host.person);
90
110
  const all = useProjects(t, signed);
91
- const project = all.value.find((p) => p.slug === slug) ?? null;
111
+ // The repository the address names, and the forge's record of it.
112
+ const named = 'slug' in at ? null : at;
113
+ const forge = useRead(named && signed ? () => one(t, named.name) : null, null, [t, signed, named?.name]);
114
+ const project = all.value.find((p) => (named ? siteOf(p, named) : 'slug' in at && p.slug === at.slug)) ?? null;
115
+ // The site this workspace publishes to: the project's, or the repository's name as one.
116
+ const slug = project?.slug ?? ('slug' in at ? at.slug : slugOf(named?.name ?? ''));
117
+ const title = project?.name ?? named?.name ?? slug;
92
118
  // A project that has never served reads `building` while it is published, and
93
119
  // is read again until it is live or its build failed.
94
120
  const building = project?.status === 'building';
@@ -113,12 +139,15 @@ export function Project({ slug }) {
113
139
  const stalled = building && ran > STALL;
114
140
  // Publish again: the project's repository, built at its branch, the way Publish builds it.
115
141
  const [redo, setRedo] = useState({ busy: false, error: '' });
142
+ // The repository's clone address: the forge's for a repository, the site's own otherwise.
143
+ const clone = named ? (forge.value?.clone ?? '') : (project?.repo ?? '');
144
+ const home = named ? (forge.value?.branch ?? 'main') : project?.branch || 'main';
116
145
  const retry = async () => {
117
- if (!project?.repo || redo.busy)
146
+ if (!clone || redo.busy)
118
147
  return;
119
148
  setRedo({ busy: true, error: '' });
120
149
  try {
121
- await declare(t, { repo: project.repo, ref: project.branch || 'main', name: slug, project: slug, mode: 'branch' });
150
+ await declare(t, { repo: clone, ref: home, name: slug, project: slug, mode: 'branch' });
122
151
  setNow(Date.now());
123
152
  again();
124
153
  setRedo({ busy: false, error: '' });
@@ -127,11 +156,14 @@ export function Project({ slug }) {
127
156
  setRedo({ busy: false, error: e.message });
128
157
  }
129
158
  };
130
- const listed = useRead(signed ? () => list(t, { kind: 'coding', project: slug, limit: 50 }) : null, [], [t, signed, slug]);
159
+ // The site's runs, or the org's when the repository has no site yet.
160
+ const sited = !named || Boolean(project);
161
+ const listed = useRead(signed ? () => list(t, sited ? { kind: 'coding', project: slug, limit: 50 } : { kind: 'coding', limit: 50 }) : null, [], [t, signed, slug, sited]);
131
162
  // Only the runs on this project's own repository: a record can be moved into
132
163
  // any project, and a run's branch is what Files, Code and Publish read.
133
- const runs = { ...listed, value: listed.value.filter((r) => ours(r.repo, project?.repo ?? '')) };
134
- const ordered = useMemo(() => [...runs.value].reverse(), [listed.value, project?.repo]);
164
+ const mine = (r) => (named ? ours(r.repo, `${named.org}/${named.name}`) && Boolean(r.repo) : ours(r.repo, project?.repo ?? ''));
165
+ const runs = { ...listed, value: listed.value.filter(mine) };
166
+ const ordered = useMemo(() => [...runs.value].reverse(), [listed.value, project?.repo, named?.org, named?.name]);
135
167
  const [chosen, setChosen] = useState(null);
136
168
  const current = chosen ?? runs.value[0]?.id ?? null;
137
169
  const run = useRun(t, current);
@@ -149,6 +181,12 @@ export function Project({ slug }) {
149
181
  // Below md the chat and the work take turns, and Chat is one more view to switch back to.
150
182
  const [pane, setPane] = useState('chat');
151
183
  const wide = useMedia().md;
184
+ // A workspace too narrow for the chat at its floor and the work at its room
185
+ // (a tablet beside the host's column) shows the work, and the chat when asked:
186
+ // never both cut. Asked here is for this visit; the kept choice is the wide one's.
187
+ const cramped = wide && room > 0 && room < CHAT_FLOOR + WORK;
188
+ const [asked, setAsked] = useState(false);
189
+ const shut = cramped ? !asked : collapsed;
152
190
  const [dock, setDock] = useKept('hanzo.build.dock', 36);
153
191
  const [dismissed, setDismissed] = useKept(`hanzo.build.hints.${slug}`, false);
154
192
  const [mode, setMode] = useKept('hanzo.build.mode', 'build');
@@ -164,6 +202,7 @@ export function Project({ slug }) {
164
202
  const [history, setHistory] = useState(false);
165
203
  const [publish, setPublish] = useState(null);
166
204
  const [switcher, setSwitcher] = useState(false);
205
+ const repos = useRead(switcher && signed ? () => codebases(t) : null, [], [t, switcher, signed]);
167
206
  const frame = useRef(null);
168
207
  // What was published may be new files at the same address: the project is read
169
208
  // for its address, and the frame loads again.
@@ -196,8 +235,8 @@ export function Project({ slug }) {
196
235
  // Files is drawn again after a merge, from what the forge holds now.
197
236
  const [round, setRound] = useState(0);
198
237
  // Files and Code: native git, at the branch the open run pushed (or the project's own).
199
- const repo = project?.repo ? repoName(project.repo) : '';
200
- const ref = run.record?.branch || project?.branch || 'main';
238
+ const repo = named ? named.name : project?.repo ? repoName(project.repo) : '';
239
+ const ref = run.record?.branch || home;
201
240
  const [files, setFiles] = useState([]);
202
241
  const [file, setFile] = useState(null);
203
242
  useEffect(() => {
@@ -294,9 +333,10 @@ export function Project({ slug }) {
294
333
  // A project with no repository yet sends neither, and its first run makes one.
295
334
  const next = await start(t, {
296
335
  prompt: compose(`${ask}${context}`, attached),
297
- project: slug,
336
+ // A repository with no site yet runs on the repository alone.
337
+ project: project?.slug,
298
338
  repo,
299
- base: project?.branch,
339
+ base: named ? forge.value?.branch : project?.branch,
300
340
  after: current ?? undefined,
301
341
  mode,
302
342
  });
@@ -337,10 +377,21 @@ export function Project({ slug }) {
337
377
  if (!signed) {
338
378
  return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", children: [_jsx(SizableText, { size: "$4", color: "$ink", children: "Sign in to open this project." }), _jsx(Button, { onPress: () => host.signIn?.(), children: "Sign in" })] }));
339
379
  }
340
- if (!all.loading && !project && !all.error) {
341
- return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", px: "$4", children: [_jsx(SizableText, { size: "$4", color: "$ink", children: host.org ? `${host.org} has no project named ${slug}.` : `There is no project named ${slug}.` }), _jsx(Button, { variant: "outline", onPress: () => host.go('-/artifacts'), children: "See what this organization has built" })] }));
380
+ // A repository of another organization is opened in that one.
381
+ if (named && host.org && named.org.toLowerCase() !== host.org.toLowerCase()) {
382
+ const member = host.memberships?.some((m) => m.toLowerCase() === named.org.toLowerCase()) && host.chooseOrg;
383
+ return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", px: "$4", children: [_jsx(SizableText, { size: "$4", color: "$ink", text: "center", children: `${named.org}/${named.name} is in ${named.org}, and you are working in ${host.org}.` }), member ? (_jsx(Button, { variant: "outline", onPress: () => host.chooseOrg?.(named.org), children: `Switch to ${named.org}` })) : (_jsx(Button, { variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'projects' })), children: "See this organization\u2019s projects" }))] }));
384
+ }
385
+ if (named ? !forge.loading && !forge.value : !all.loading && !project && !all.error) {
386
+ return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", px: "$4", children: [_jsx(SizableText, { size: "$4", color: "$ink", text: "center", children: named
387
+ ? forge.error && !/not found|404/i.test(forge.error.message)
388
+ ? forge.error.message
389
+ : `${named.org} has no repository named ${named.name}.`
390
+ : host.org
391
+ ? `${host.org} has no project named ${slug}.`
392
+ : `There is no project named ${slug}.` }), _jsx(Button, { variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: named ? 'projects' : 'artifacts' })), children: named ? 'See this organization’s projects' : 'See what this organization has built' })] }));
342
393
  }
343
- const chat = (_jsxs(YStack, { flex: 1, minH: 0, position: "relative", children: [_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$3", py: "$4", gap: "$4", children: ordered.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$ink", px: "$2", children: runs.loading
394
+ const chat = (_jsxs(YStack, { "data-slot": "project-chat", flex: 1, minH: 0, position: "relative", children: [_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$3", pt: "$2", pb: "$4", gap: "$4", $md: { pr: 0 }, children: ordered.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$ink", children: runs.loading
344
395
  ? 'Reading this project’s runs…'
345
396
  : project?.live
346
397
  ? `${project.name} is loaded — it is in the preview, and every run on it is under the clock above. Say what to change and it gets built.`
@@ -354,12 +405,14 @@ export function Project({ slug }) {
354
405
  ? `${project?.name} is loaded — the preview shows the ${starter.title} starter until your copy is published. Say what to change and it gets built.`
355
406
  : project
356
407
  ? `${project.name} is loaded and nothing is published yet. Say what to change and it gets built.`
357
- : `${slug} is loaded. Say what to change and it gets built.` })) : (ordered.map((r) => (_jsxs(YStack, { gap: "$2", children: [_jsx(Turn, { run: r, open: r.id === current, onOpen: () => setChosen(r.id), project: slug }), r.id === current ? (_jsxs(YStack, { gap: "$1", pl: "$2", borderLeftWidth: 1, borderColor: "$borderColor", children: [fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode) })).filter((b) => b.text)).map((b) => (_jsx(SizableText, { size: "$1", color: "$soft", children: b.text }, b.key))), pr.href ? (_jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [_jsxs(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: [_jsxs(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: ["Pull request ", pr.label] }), _jsx(ExternalLink, { size: 11 })] }), _jsx(Merge, { session: r.id, open: proposing, onMerged: merged })] })) : null] })) : null] }, r.id)))) }), _jsxs(YStack, { px: "$3", pb: "$3", gap: "$2", children: [!dismissed && !running ? _jsx(Suggestions, { items: SUGGESTIONS, onPick: (s) => void send(s), onDismiss: () => setDismissed(true) }) : null, note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, picked.length ? _jsx(Attachments, { items: picked, onRemove: () => setPicked([]) }) : null, attached.length ? (_jsx(XStack, { gap: 6, flexWrap: "wrap", children: _jsx(Files, { files: attached, onFiles: setAttached }) })) : null, _jsxs(Composer, { ...cut, value: draft, onChange: setDraft, onSend: () => void (current && running ? steer(current) : send()), onStop: current
408
+ : named
409
+ ? `${named.org}/${named.name} is open on ${home}. Say what to change and it gets built there.`
410
+ : `${slug} is loaded. Say what to change and it gets built.` })) : (ordered.map((r) => (_jsxs(YStack, { gap: "$2", children: [_jsx(Turn, { run: r, open: r.id === current, onOpen: () => setChosen(r.id), project: slug }), r.id === current ? (_jsxs(YStack, { gap: "$1", pl: "$2", borderLeftWidth: 1, borderColor: "$borderColor", children: [fold(run.events.map((e) => ({ kind: e.kind, actor: who(e.actor), seq: e.seq, id: e.id, text: said(e, run.record?.mode) })).filter((b) => b.text)).map((b) => (_jsx(SizableText, { size: "$1", color: "$soft", children: b.text }, b.key))), pr.href ? (_jsxs(XStack, { items: "center", gap: "$2", flexWrap: "wrap", children: [_jsxs(Out, { href: pr.href, label: `Open pull request ${pr.label}`, children: [_jsxs(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: ["Pull request ", pr.label] }), _jsx(ExternalLink, { size: 11 })] }), _jsx(Merge, { session: r.id, open: proposing, onMerged: merged })] })) : null] })) : null] }, r.id)))) }), _jsxs(YStack, { "data-slot": "project-ask", px: "$3", pb: "$3", gap: "$2", minW: 0, $md: { pr: 0 }, children: [!dismissed && !running ? _jsx(Suggestions, { items: SUGGESTIONS, onPick: (s) => void send(s), onDismiss: () => setDismissed(true) }) : null, note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, picked.length ? _jsx(Attachments, { items: picked, onRemove: () => setPicked([]) }) : null, attached.length ? (_jsx(XStack, { gap: 6, flexWrap: "wrap", children: _jsx(Files, { files: attached, onFiles: setAttached }) })) : null, _jsxs(Composer, { value: draft, onChange: setDraft, onSend: () => void (current && running ? steer(current) : send()), onStop: current
358
411
  ? () => void stop(t, current).then(() => setNote('Stop requested — the run keeps its work on its branch'), (e) => setNote(e.message))
359
- : undefined, busy: busy || (running && !draft.trim()), rows: 3, placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", children: [_jsx(Attach, { files: attached, onFiles: setAttached, onNote: setNote }), _jsx(XStack, { flex: 1 }), _jsx(ModeSelect, { modes: MODES, value: mode, onChange: (m) => setMode(m), self: "center" }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote })] })] }), wide && !collapsed ? (_jsx(Grip, { side: "left", span: across, floor: CHAT_FLOOR, ceil: most, reset: CHAT_WIDTH, onSpan: setSplit, label: "Resize the chat", r: -GUTTER, width: GUTTER })) : null] }));
412
+ : undefined, busy: busy || (running && !draft.trim()), rows: 3, placeholder: running ? 'Steer this run' : 'Ask Hanzo for edits', label: "Ask Hanzo for edits", children: [_jsx(Attach, { files: attached, onFiles: setAttached, onNote: setNote }), _jsx(XStack, { flex: 1 }), _jsx(ModeSelect, { modes: MODES, value: mode, onChange: (m) => setMode(m), self: "center" }), _jsx(Dictate, { onText: (said) => setDraft((d) => (d ? `${d} ${said}` : said)), onNote: setNote })] })] }), wide && !shut ? (_jsx(Grip, { side: "left", span: across, floor: CHAT_FLOOR, ceil: most, reset: CHAT_WIDTH, onSpan: setSplit, label: "Resize the chat", r: -GUTTER, width: GUTTER })) : null] }));
360
413
  // The preview stays loaded while another view is open, so coming back to it
361
414
  // is the page as it was left.
362
- const preview = (_jsxs(YStack, { "data-slot": "preview", flex: 1, minH: 0, minW: 0, gap: "$2", display: view === 'preview' ? 'flex' : 'none', children: [_jsxs(XStack, { "data-slot": "preview-bar", items: "center", gap: "$1.5", minW: 0, shrink: 0, "$max-md": { px: '$2' }, children: [_jsx(XStack, { render: "button", "aria-label": "Reload the preview", onPress: () => frame.current?.reload(), p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(RefreshCcw, { size: 16 }) }), _jsx(PageSelect, { pages: pages, value: page, onChange: setPage, shrink: 1 }), _jsx(XStack, { flex: 1 }), _jsx(Views, { views: DEVICES, value: device, onChange: (d) => setDevice(d), label: "Device", labels: "none" }), src ? (_jsx(Out, { href: src, label: "Open in a new tab", children: _jsx(XStack, { p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 16 }) }) })) : null] }), _jsx(PreviewFrame, { ref: frame, src: src, title: `${project?.name ?? slug} preview`, device: device, onBridge: onBridge, empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: stalled
415
+ const preview = (_jsxs(YStack, { "data-slot": "preview", flex: 1, minH: 0, minW: 0, gap: "$2", display: view === 'preview' ? 'flex' : 'none', children: [_jsxs(XStack, { "data-slot": "preview-bar", items: "center", gap: "$1.5", minW: 0, shrink: 0, "$max-md": { px: '$2' }, children: [_jsx(XStack, { render: "button", "aria-label": "Reload the preview", onPress: () => frame.current?.reload(), p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(RefreshCcw, { size: 16 }) }), _jsx(PageSelect, { pages: pages, value: page, onChange: setPage, shrink: 1 }), _jsx(XStack, { flex: 1 }), _jsx(Views, { views: DEVICES, value: device, onChange: (d) => setDevice(d), label: "Device", labels: "none" }), src ? (_jsx(Out, { href: src, label: "Open in a new tab", children: _jsx(XStack, { p: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 16 }) }) })) : null] }), _jsx(PreviewFrame, { ref: frame, src: src, title: `${title} preview`, device: device, onBridge: onBridge, empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: stalled
363
416
  ? 'Publishing is taking too long'
364
417
  : building
365
418
  ? project?.forked
@@ -373,29 +426,34 @@ export function Project({ slug }) {
373
426
  ? 'Its page appears here when the build finishes.'
374
427
  : failed
375
428
  ? why
376
- : 'Publish this project and its page appears here.' }), (stalled || failed) && project?.repo ? (_jsx(Button, { variant: "outline", disabled: redo.busy, onPress: () => void retry(), children: "Publish again" })) : null, redo.error ? (_jsx(SizableText, { size: "$2", color: "$soft", text: "center", role: "alert", children: redo.error })) : null] }), toolbar: bridge ? (_jsx(XStack, { render: "button", "aria-pressed": editing, "aria-label": editing ? 'Stop picking elements' : 'Pick an element to edit', onPress: () => {
429
+ : 'Publish this project and its page appears here.' }), (stalled || failed) && clone ? (_jsx(Button, { variant: "outline", disabled: redo.busy, onPress: () => void retry(), children: "Publish again" })) : null, redo.error ? (_jsx(SizableText, { size: "$2", color: "$soft", text: "center", role: "alert", children: redo.error })) : null] }), toolbar: bridge ? (_jsx(XStack, { render: "button", "aria-pressed": editing, "aria-label": editing ? 'Stop picking elements' : 'Pick an element to edit', onPress: () => {
377
430
  const next = !editing;
378
431
  setEditing(next);
379
432
  frame.current?.post({ type: 'preview:editable', active: next });
380
433
  }, p: "$2", rounded: "$10", children: _jsx(MousePointerClick, { size: 16 }) })) : undefined })] }));
381
- const body = view === 'preview' ? null : view === 'files' ? (repo ? (_jsx(FileTree, { load: async (dir) => (await tree(t, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? 'dir' : 'file' })), value: file, onSelect: (p) => void openFile(p), label: `${repo} at ${ref}` }, round)) : (_jsx(SizableText, { size: "$2", color: "$soft", p: "$4", children: "This project has no repository yet. Its first run creates one." }))) : view === 'code' ? (_jsx(FileTabs, { files: files, value: file, onSelect: setFile, onClose: (p) => {
434
+ const body = view === 'preview' ? null : view === 'files' ? (repo && root.error ? (_jsx(SizableText, { size: "$2", color: "$soft", p: "$4", role: "status", children: `Nothing to show for ${repo} at ${ref}: the forge answered “${root.error.message}”.` })) : repo ? (_jsx(FileTree, { load: async (dir) => (await tree(t, repo, ref, dir)).map((e) => ({ path: e.path, kind: e.dir ? 'dir' : 'file' })), value: file, onSelect: (p) => void openFile(p), label: `${repo} at ${ref}` }, round)) : (_jsx(SizableText, { size: "$2", color: "$soft", p: "$4", children: "This project has no repository yet. Its first run creates one." }))) : view === 'code' ? (_jsx(FileTabs, { files: files, value: file, onSelect: setFile, onClose: (p) => {
382
435
  setFiles((was) => was.filter((f) => f.path !== p));
383
436
  if (file === p)
384
437
  setFile(null);
385
438
  }, empty: _jsxs(SizableText, { size: "$2", color: "$soft", p: "$4", children: ["Open a file from Files. Code here is read at ", ref, "; ask in the chat to change it."] }) })) : (_jsx(YStack, { p: "$4", gap: "$2", children: picked.length ? (picked.map((p) => (_jsxs(SizableText, { size: "$2", color: "$ink", children: [p.label, " \u2014 ", p.id] }, p.id)))) : (_jsx(SizableText, { size: "$2", color: "$soft", children: bridge
386
439
  ? 'Pick an element in the preview to see it here and attach it to your next ask.'
387
440
  : 'Layers are read through the preview bridge, and this page does not run it. Its elements cannot be listed from here.' })) }));
388
- return (_jsxs(_Fragment, { children: [_jsxs(Workspace, { start: _jsxs(XStack, { items: "center", gap: "$2", minW: 0, shrink: 1, children: [_jsx(XStack, { render: "button", "aria-label": "All runs", onPress: () => host.go(''), p: "$1", shrink: 0, children: _jsx(HanzoMark, { size: 20 }) }), _jsx(ProjectChip, { name: project?.name ?? slug, onPress: () => setSwitcher(true) }), _jsx(XStack, { render: "button", "aria-label": "History", onPress: () => setHistory(true), p: "$1.5", rounded: "$3", shrink: 0, hoverStyle: { bg: '$hover' }, children: _jsx(Clock, { size: 16 }) }), _jsx(XStack, { render: "button", "aria-label": collapsed ? 'Show the chat' : 'Hide the chat', "aria-pressed": !collapsed, onPress: () => setCollapsed(!collapsed), p: "$1.5", rounded: "$3", shrink: 0, hoverStyle: { bg: '$hover' }, "$max-md": { display: 'none' }, children: _jsx(PanelLeft, { size: 16 }) })] }), middle: _jsx(Views, { views: [...VIEWS, CHAT], value: pane === 'chat' && !wide ? 'chat' : view, onChange: (v) => {
441
+ return (_jsxs(_Fragment, { children: [_jsxs(Workspace, { start: _jsxs(XStack, { items: "center", gap: "$2", minW: 0, shrink: 1, children: [_jsx(XStack, { render: "button", "aria-label": "All runs", onPress: () => host.go(''), p: "$1", shrink: 0, children: _jsx(HanzoMark, { size: 20 }) }), _jsx(ProjectChip, { name: title, onPress: () => setSwitcher(true) }), _jsx(XStack, { render: "button", "aria-label": "History", onPress: () => setHistory(true), p: "$1.5", rounded: "$3", shrink: 0, hoverStyle: { bg: '$hover' }, children: _jsx(Clock, { size: 16 }) }), _jsx(XStack, { render: "button", "aria-label": shut ? 'Show the chat' : 'Hide the chat', "aria-pressed": !shut, onPress: () => (cramped ? setAsked(!asked) : setCollapsed(!collapsed)), p: "$1.5", rounded: "$3", shrink: 0, hoverStyle: { bg: '$hover' }, "$max-md": { display: 'none' }, children: _jsx(PanelLeft, { size: 16 }) })] }), middle: _jsx(Views, { views: [CHAT, ...VIEWS], value: pane === 'chat' && !wide ? 'chat' : view, onChange: (v) => {
389
442
  if (v === 'chat')
390
443
  return setPane('chat');
391
444
  setView(v);
392
445
  setPane('view');
393
- }, label: "View" }), end: _jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(XStack, { display: "none", $md: { display: 'flex' }, children: _jsxs(Button, { variant: "outline", size: "sm", onPress: () => void share(), children: [_jsx(Share2, { size: 14 }), " Share"] }) }), _jsxs(Button, { size: "sm", disabled: !project?.repo, onPress: () => project &&
394
- setPublish({ repo: project.repo, title: project.name, ref, name: project.slug, project: project.slug }), children: [_jsx(Upload, { size: 14 }), " Publish"] })] }), chat: chat, dock: _jsx(Console, { lines: lines, height: dock, onHeight: setDock, onClear: () => setPageLines([]), status: run.status || end.status || undefined, label: "Console" }), collapsed: collapsed, pane: pane, width: across, onLayout: (e) => setRoom(e.nativeEvent.layout.width), children: [preview, body] }), _jsx(Dialog, { open: history, onOpenChange: setHistory, children: _jsxs(DialogContent, { maxW: 480, children: [_jsxs(DialogTitle, { children: ["Runs on ", project?.name ?? slug] }), _jsx(YStack, { gap: "$1", children: runs.value.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "No runs yet." })) : (runs.value.map((r) => (_jsxs(XStack, { render: "button", onPress: () => {
446
+ }, label: "View" }), end: _jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(XStack, { display: "none", $md: { display: 'flex' }, children: _jsxs(Button, { variant: "outline", size: "sm", onPress: () => void share(), children: [_jsx(Share2, { size: 14 }), " Share"] }) }), _jsxs(Button, { size: "sm", "aria-label": "Publish", disabled: !clone || !slug, onPress: () => setPublish({ repo: clone, title, ref, name: slug, project: slug }), children: [_jsx(Upload, { size: 14 }), _jsx(SizableText, { size: "$2", color: "inherit", "$max-md": { display: 'none' }, children: "Publish" })] })] }), chat: chat, dock: _jsx(Console, { lines: lines, height: dock, onHeight: setDock, onClear: () => setPageLines([]), status: run.status || end.status || undefined, label: "Console" }), collapsed: shut, pane: pane, width: across, onLayout: (e) => setRoom(e.nativeEvent.layout.width), children: [preview, body] }), _jsx(Dialog, { open: history, onOpenChange: setHistory, children: _jsxs(DialogContent, { maxW: 480, children: [_jsxs(DialogTitle, { children: ["Runs on ", title] }), _jsx(YStack, { gap: "$1", children: runs.value.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "No runs yet." })) : (runs.value.map((r) => (_jsxs(XStack, { render: "button", onPress: () => {
395
447
  setChosen(r.id);
396
448
  setHistory(false);
397
- }, justify: "space-between", px: "$2", py: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, flex: 1, children: r.title || 'Untitled run' }), _jsx(SizableText, { size: "$1", color: "$soft", children: r.status })] }, r.id)))) })] }) }), _jsx(Dialog, { open: switcher, onOpenChange: setSwitcher, children: _jsxs(DialogContent, { maxW: 420, children: [_jsx(DialogTitle, { children: "Projects" }), _jsx(YStack, { gap: "$1", maxH: 420, overflow: "scroll", children: all.value.map((p) => (_jsx(XStack, { render: "button", onPress: () => {
398
- setSwitcher(false);
399
- host.go(p.slug);
400
- }, px: "$2", py: "$1.5", rounded: "$3", bg: p.slug === slug ? '$hover' : undefined, hoverStyle: { bg: '$hover' }, children: _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: p.name }) }, p.slug))) })] }) }), _jsx(Publish, { source: publish, onClose: () => setPublish(null), onLive: fresh })] }));
449
+ }, justify: "space-between", px: "$2", py: "$1.5", rounded: "$3", hoverStyle: { bg: '$hover' }, children: [_jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, flex: 1, children: r.title || 'Untitled run' }), _jsx(SizableText, { size: "$1", color: "$soft", children: r.status })] }, r.id)))) })] }) }), _jsx(Dialog, { open: switcher, onOpenChange: setSwitcher, children: _jsxs(DialogContent, { maxW: 420, children: [_jsx(DialogTitle, { children: "Projects" }), _jsx(YStack, { gap: "$1", maxH: 420, overflow: "scroll", children: repos.error ? (_jsx(SizableText, { size: "$2", color: "$soft", children: repos.error.message })) : repos.loading && repos.value.length === 0 ? (_jsx(SizableText, { size: "$2", color: "$soft", children: "Reading the forge\u2026" })) : (repos.value.map((c) => {
450
+ const here = Boolean(named) && c.name === named?.name;
451
+ return (_jsx(XStack, { render: "button", "aria-label": `Open ${c.org}/${c.name}`, "aria-current": here ? 'page' : undefined, onPress: () => {
452
+ setSwitcher(false);
453
+ host.go(path({ kind: 'repo', org: c.org || host.org || '', name: c.name }));
454
+ }, px: "$2", py: "$1.5", rounded: "$3", bg: here ? '$hover' : undefined, hoverStyle: { bg: '$hover' }, children: _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, children: c.name }) }, `${c.org}/${c.name}`));
455
+ })) }), _jsx(Button, { variant: "outline", size: "sm", onPress: () => {
456
+ setSwitcher(false);
457
+ host.go(path({ kind: 'screen', screen: 'projects' }));
458
+ }, children: "All projects" })] }) }), _jsx(Publish, { source: publish, onClose: () => setPublish(null), onLive: fresh })] }));
401
459
  }
package/lib/route.d.ts CHANGED
@@ -8,13 +8,15 @@
8
8
  * -/settings[/<section>] a section of Settings
9
9
  * -/customize[/<tab>] skills, connectors and plugins: what the agent brings to a run
10
10
  * -/plans the plans an organization can be on
11
- * <slug> a project's workspace
11
+ * <org>/<repo> a repository's workspace: a project is a repository on the forge
12
+ * <slug> a deployed site's workspace (Artifacts)
12
13
  *
13
14
  * Unambiguous by construction: a project slug is `^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$`,
14
- * so it can hold neither the `_` a session id carries nor start with `-`.
15
- * Anything else is not an address here and reads as the empty state.
15
+ * so it can hold neither the `_` a session id carries nor start with `-`, and a
16
+ * repository is the one address with two segments, each starting with a letter
17
+ * or a digit. Anything else is not an address here and reads as the empty state.
16
18
  */
17
- export type Screen = 'artifacts' | 'templates' | 'codebases' | 'projects' | 'issues' | 'automations' | 'sync' | 'mcp' | 'plans';
19
+ export type Screen = 'artifacts' | 'templates' | 'projects' | 'issues' | 'automations' | 'sync' | 'mcp' | 'plans';
18
20
  /** Settings, one section each. Every setting the builder has lives on this page, none elsewhere. */
19
21
  export declare const SECTIONS: readonly ['general', 'account', 'privacy', 'billing', 'usage', 'capabilities', 'memory', 'code', 'environments', 'machines', 'keys', 'members', 'integrations', 'notifications'];
20
22
  export type Section = (typeof SECTIONS)[number];
@@ -38,12 +40,18 @@ export type Route = {
38
40
  } | {
39
41
  kind: 'project';
40
42
  slug: string;
43
+ } | {
44
+ kind: 'repo';
45
+ org: string;
46
+ name: string;
41
47
  } | {
42
48
  kind: 'automation';
43
49
  id: string;
44
50
  };
45
51
  export declare const SESSION: RegExp;
46
52
  export declare const SLUG: RegExp;
53
+ /** An organization or a repository name on the forge (api/codebases.ts `NAME`). */
54
+ export declare const NAME: RegExp;
47
55
  /** An automation's id, or `new` for one not yet written. */
48
56
  export declare const AUTOMATION: RegExp;
49
57
  export declare function route(path: string): Route;
package/lib/route.js CHANGED
@@ -19,9 +19,11 @@ export const SECTIONS = [
19
19
  export const TABS = ['skills', 'connectors', 'plugins', 'agents'];
20
20
  export const SESSION = /^sess_[0-9a-f]{32}$/;
21
21
  export const SLUG = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/;
22
+ /** An organization or a repository name on the forge (api/codebases.ts `NAME`). */
23
+ export const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
22
24
  /** An automation's id, or `new` for one not yet written. */
23
25
  export const AUTOMATION = /^(flow_[0-9a-f]{32}|new)$/;
24
- const SCREENS = ['artifacts', 'templates', 'codebases', 'projects', 'issues', 'automations', 'sync', 'mcp', 'plans'];
26
+ const SCREENS = ['artifacts', 'templates', 'projects', 'issues', 'automations', 'sync', 'mcp', 'plans'];
25
27
  export function route(path) {
26
28
  const p = path.replace(/^\/+|\/+$/g, '');
27
29
  if (SESSION.test(p))
@@ -44,6 +46,9 @@ export function route(path) {
44
46
  }
45
47
  if (SLUG.test(p))
46
48
  return { kind: 'project', slug: p };
49
+ const [org, name, ...rest] = p.split('/');
50
+ if (!rest.length && org && name && NAME.test(org) && NAME.test(name))
51
+ return { kind: 'repo', org, name };
47
52
  return { kind: 'new' };
48
53
  }
49
54
  /** The path for a route — the inverse of `route`. */
@@ -61,6 +66,8 @@ export function path(r) {
61
66
  return r.tab === 'skills' ? '-/customize' : `-/customize/${r.tab}`;
62
67
  case 'project':
63
68
  return r.slug;
69
+ case 'repo':
70
+ return `${r.org}/${r.name}`;
64
71
  case 'automation':
65
72
  return `-/automations/${r.id}`;
66
73
  }
package/lib/section.d.ts CHANGED
@@ -8,7 +8,7 @@ export declare const DOTS: Record<string, SessionStatus>;
8
8
  /** The org's coding runs, newest first, kept live by the org's feed. */
9
9
  export declare function useSessions(host: Host): Read<Session[]>;
10
10
  /**
11
- * New, Automations, the forge (Codebase, Projects, Issues), Artifacts, Templates, then the
11
+ * New, Automations, the forge (Projects, Issues), Artifacts, Templates, then the
12
12
  * runs. `children` sit between the places and the runs — where a host lists its
13
13
  * own. `onPick` fires after a move, so a host that draws its rail as a drawer
14
14
  * on a phone can close it.
package/lib/section.js CHANGED
@@ -11,7 +11,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
11
11
  * host's own `go`.
12
12
  */
13
13
  import { SizableText } from '@hanzo/gui';
14
- import { Blocks, CircleDot, FolderGit2, Kanban, LayoutTemplate, SlidersHorizontal, SquarePen, Workflow } from '@hanzogui/lucide-icons-2';
14
+ import { Blocks, CircleDot, FolderGit2, LayoutTemplate, SlidersHorizontal, SquarePen, Workflow } from '@hanzogui/lucide-icons-2';
15
15
  import { SidebarItem, SidebarSection, StatusDot } from '@hanzo/ui/chat';
16
16
  import { useMemo } from 'react';
17
17
  import { pinBoard } from './choice.js';
@@ -32,7 +32,7 @@ export function useSessions(host) {
32
32
  }
33
33
  const Note = ({ children }) => (_jsx(SizableText, { size: "$1", color: "$soft", px: "$2", py: "$1", children: children }));
34
34
  /**
35
- * New, Automations, the forge (Codebase, Projects, Issues), Artifacts, Templates, then the
35
+ * New, Automations, the forge (Projects, Issues), Artifacts, Templates, then the
36
36
  * runs. `children` sit between the places and the runs — where a host lists its
37
37
  * own. `onPick` fires after a move, so a host that draws its rail as a drawer
38
38
  * on a phone can close it.
@@ -45,7 +45,7 @@ export function DevSection({ host, onPick, label = 'Runs', children, }) {
45
45
  onPick?.();
46
46
  };
47
47
  const screen = r.kind === 'screen' ? r.screen : '';
48
- return (_jsxs(_Fragment, { children: [_jsx(SidebarItem, { icon: _jsx(SquarePen, { size: 16, "aria-hidden": true }), active: r.kind === 'new', onPress: () => go(''), children: "New run" }), _jsx(SidebarItem, { icon: _jsx(Workflow, { size: 16, "aria-hidden": true }), active: screen === 'automations' || r.kind === 'automation', onPress: () => go(path({ kind: 'screen', screen: 'automations' })), children: "Automations" }), _jsx(SidebarItem, { icon: _jsx(FolderGit2, { size: 16, "aria-hidden": true }), active: screen === 'codebases', onPress: () => go(path({ kind: 'screen', screen: 'codebases' })), children: "Codebase" }), _jsx(SidebarItem, { icon: _jsx(Kanban, { size: 16, "aria-hidden": true }), active: screen === 'projects', onPress: () => go(path({ kind: 'screen', screen: 'projects' })), children: "Projects" }), _jsx(SidebarItem, { icon: _jsx(CircleDot, { size: 16, "aria-hidden": true }), active: screen === 'issues', onPress: () => {
48
+ return (_jsxs(_Fragment, { children: [_jsx(SidebarItem, { icon: _jsx(SquarePen, { size: 16, "aria-hidden": true }), active: r.kind === 'new', onPress: () => go(''), children: "New run" }), _jsx(SidebarItem, { icon: _jsx(Workflow, { size: 16, "aria-hidden": true }), active: screen === 'automations' || r.kind === 'automation', onPress: () => go(path({ kind: 'screen', screen: 'automations' })), children: "Automations" }), _jsx(SidebarItem, { icon: _jsx(FolderGit2, { size: 16, "aria-hidden": true }), active: screen === 'projects' || screen === 'sync' || r.kind === 'repo', onPress: () => go(path({ kind: 'screen', screen: 'projects' })), children: "Projects" }), _jsx(SidebarItem, { icon: _jsx(CircleDot, { size: 16, "aria-hidden": true }), active: screen === 'issues', onPress: () => {
49
49
  pinBoard(host.org, '');
50
50
  go(path({ kind: 'screen', screen: 'issues' }));
51
51
  }, children: "Issues" }), _jsx(SidebarItem, { icon: _jsx(SlidersHorizontal, { size: 16, "aria-hidden": true }), active: r.kind === 'customize', onPress: () => go(path({ kind: 'customize', tab: 'connectors' })), children: "Customize" }), _jsx(SidebarItem, { icon: _jsx(Blocks, { size: 16, "aria-hidden": true }), active: screen === 'artifacts', onPress: () => go(path({ kind: 'screen', screen: 'artifacts' })), children: "Artifacts" }), _jsx(SidebarItem, { icon: _jsx(LayoutTemplate, { size: 16, "aria-hidden": true }), active: screen === 'templates', onPress: () => go(path({ kind: 'screen', screen: 'templates' })), children: "Templates" }), children, _jsx(SidebarSection, { label: label, children: !host.person ? (_jsx(Note, { children: "Sign in to see your runs." })) : sessions.value.length ? (sessions.value.map((s) => (_jsx(SidebarItem, { icon: _jsx(StatusDot, { status: DOTS[s.status] ?? 'idle' }), active: r.kind === 'run' && r.id === s.id, onPress: () => go(s.id), children: s.title || 'Untitled run' }, s.id)))) : (_jsx(Note, { children: sessions.error ? sessions.error.message : sessions.loading ? 'Reading…' : 'No runs yet. Say what to build and it lands here.' })) })] }));
package/lib/sync.js CHANGED
@@ -77,7 +77,7 @@ export function Sync() {
77
77
  }
78
78
  };
79
79
  const back = () => {
80
- host.go(path({ kind: 'screen', screen: 'codebases' }));
80
+ host.go(path({ kind: 'screen', screen: 'projects' }));
81
81
  };
82
82
  useEffect(() => {
83
83
  if (!syncing)
@@ -102,7 +102,7 @@ export function Sync() {
102
102
  window.clearInterval(id);
103
103
  };
104
104
  }, [syncing, t, host.org, host.go, picked]);
105
- return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", children: [_jsxs(XStack, { px: "$6", pt: "$5", pb: "$2", gap: "$2", items: "center", children: [_jsx(XStack, { render: "button", "aria-label": "Back to codebases", onPress: back, children: _jsx(SizableText, { size: "$2", color: "$soft", children: "Codebase" }) }), _jsx(SizableText, { size: "$2", color: "$soft", children: "/" }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Sync" })] }), _jsxs(YStack, { flex: 1, items: "center", justify: "center", px: "$6", pb: "$10", gap: "$5", children: [_jsxs(XStack, { justify: "center", gap: "$6", children: [_jsx(Step, { label: "Select repositories", done: step === 'access', current: step === 'pick' }), _jsx(Step, { label: "Repository access", done: false, current: step === 'access' })] }), _jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$5", bg: "$panel", px: "$6", py: "$6", gap: "$4", width: "100%", maxW: 460, children: [step === 'pick' && !owner ? (_jsxs(_Fragment, { children: [_jsxs(YStack, { gap: "$1", items: "center", children: [_jsx(SizableText, { size: "$5", color: "$ink", children: "Select repositories" }), _jsxs(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: ["Choose repositories to bring into ", dest] })] }), _jsxs(Panel, { children: [_jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search organizations", "aria-label": "Search organizations", disabled: !signed }), signed ? (_jsx(SizableText, { size: "$1", color: "$soft", children: `${orgs.length} ${orgs.length === 1 ? 'organization' : 'organizations'}` })) : null, list.error ? _jsx(Soft, { children: list.error.message }) : null, !signed ? (_jsx(Soft, { children: "Sign in to choose repositories the GitHub connection can read." })) : list.loading && rows.length === 0 && orgs.length === 0 ? (_jsx(Soft, { children: "Reading the connection\u2026" })) : shownOrgs.length === 0 ? (_jsx(Soft, { children: orgs.length === 0 ? 'No organizations on this connection yet.' : 'Nothing matches.' })) : (shownOrgs.map((a) => (_jsxs(XStack, { render: "button", "aria-label": a.blocked ? `${a.name} needs an organization admin` : `Open ${a.name}`, disabled: a.blocked, items: "center", gap: "$2", py: "$2", borderTopWidth: 1, borderColor: "$borderColor", onPress: () => {
105
+ return (_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", children: [_jsxs(XStack, { px: "$6", pt: "$5", pb: "$2", gap: "$2", items: "center", children: [_jsx(XStack, { render: "button", "aria-label": "Back to projects", onPress: back, children: _jsx(SizableText, { size: "$2", color: "$soft", children: "Projects" }) }), _jsx(SizableText, { size: "$2", color: "$soft", children: "/" }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Sync" })] }), _jsxs(YStack, { flex: 1, items: "center", justify: "center", px: "$6", pb: "$10", gap: "$5", children: [_jsxs(XStack, { justify: "center", gap: "$6", children: [_jsx(Step, { label: "Select repositories", done: step === 'access', current: step === 'pick' }), _jsx(Step, { label: "Repository access", done: false, current: step === 'access' })] }), _jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$5", bg: "$panel", px: "$6", py: "$6", gap: "$4", width: "100%", maxW: 460, children: [step === 'pick' && !owner ? (_jsxs(_Fragment, { children: [_jsxs(YStack, { gap: "$1", items: "center", children: [_jsx(SizableText, { size: "$5", color: "$ink", children: "Select repositories" }), _jsxs(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: ["Choose repositories to bring into ", dest] })] }), _jsxs(Panel, { children: [_jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search organizations", "aria-label": "Search organizations", disabled: !signed }), signed ? (_jsx(SizableText, { size: "$1", color: "$soft", children: `${orgs.length} ${orgs.length === 1 ? 'organization' : 'organizations'}` })) : null, list.error ? _jsx(Soft, { children: list.error.message }) : null, !signed ? (_jsx(Soft, { children: "Sign in to choose repositories the GitHub connection can read." })) : list.loading && rows.length === 0 && orgs.length === 0 ? (_jsx(Soft, { children: "Reading the connection\u2026" })) : shownOrgs.length === 0 ? (_jsx(Soft, { children: orgs.length === 0 ? 'No organizations on this connection yet.' : 'Nothing matches.' })) : (shownOrgs.map((a) => (_jsxs(XStack, { render: "button", "aria-label": a.blocked ? `${a.name} needs an organization admin` : `Open ${a.name}`, disabled: a.blocked, items: "center", gap: "$2", py: "$2", borderTopWidth: 1, borderColor: "$borderColor", onPress: () => {
106
106
  setOwner(a.name);
107
107
  setQ('');
108
108
  }, children: [_jsx(SizableText, { flex: 1, size: "$2", color: a.blocked ? '$soft' : '$ink', numberOfLines: 1, children: a.name }), a.blocked ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "Needs organization admin" })) : (_jsx(ChevronRight, { size: 14 }))] }, a.name)))), _jsxs(XStack, { render: "button", "aria-label": "Add organization", items: "center", gap: "$2", py: "$2", onPress: () => void addOrg(), disabled: !signed, children: [_jsx(Plus, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Add organization" })] })] })] })) : null, step === 'pick' && owner ? (_jsxs(_Fragment, { children: [_jsxs(YStack, { gap: "$1", items: "center", children: [_jsx(SizableText, { size: "$5", color: "$ink", children: "Select repositories" }), _jsxs(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: ["Choose repositories to bring into ", dest] })] }), _jsxs(Panel, { children: [_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(XStack, { render: "button", "aria-label": "All organizations", onPress: () => setOwner(''), children: _jsx(ChevronLeft, { size: 16 }) }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: owner })] }), _jsx(Input, { value: q, onChangeText: setQ, placeholder: "Search repositories", "aria-label": "Search repositories" }), _jsxs(XStack, { justify: "space-between", items: "center", children: [_jsxs(SizableText, { size: "$1", color: "$soft", children: [ofOwner.length, " ", ofOwner.length === 1 ? 'repository' : 'repositories'] }), _jsx(XStack, { render: "button", "aria-label": "Select all", onPress: selectShown, disabled: shownRepos.length === 0, children: _jsx(SizableText, { size: "$1", color: "$ink", children: "Select all" }) })] }), shownRepos.length === 0 ? (_jsx(Soft, { children: "Nothing matches." })) : (shownRepos.map((r) => (_jsx(RepoRow, { repo: r, on: picked.includes(r.fullName), onPress: () => toggle(r.fullName) }, r.fullName)))), _jsxs(XStack, { render: "button", "aria-label": "Grant more repositories", items: "center", gap: "$2", py: "$2", onPress: () => void addOrg(), children: [_jsx(Plus, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", children: "Add repositories" })] })] })] })) : null, step === 'access' ? (_jsxs(_Fragment, { children: [_jsxs(YStack, { gap: "$1", items: "center", children: [_jsx(SizableText, { size: "$5", color: "$ink", children: "Repository access" }), _jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: "Review who can read these repositories" })] }), _jsxs(XStack, { gap: "$2", items: "center", children: [_jsxs(XStack, { flex: 1, items: "center", gap: "$2", px: "$3", py: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", children: [_jsx(XStack, { render: "button", "aria-label": "Clear the selection", onPress: () => { setPicked([]); setStep('pick'); }, children: _jsx(X, { size: 12 }) }), _jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: more ? `${lead}, +${more} more` : lead })] }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => setStep('pick'), children: "Edit" })] }), _jsxs(XStack, { justify: "space-between", gap: "$4", children: [_jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Who gets access" }), _jsxs(SizableText, { size: "$2", color: "$ink", children: [host.person?.name || host.person?.email || 'You', " \u00B7 You"] })] }), _jsxs(YStack, { gap: "$1", items: "flex-end", children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "Role" }), _jsx(SizableText, { size: "$2", color: "$ink", children: host.admin ? 'Repository admin' : 'Member' })] })] }), _jsxs(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: ["Anyone with access to ", dest] })] })) : null, error ? _jsx(Soft, { children: error }) : null, step === 'pick' ? (_jsx(Button, { width: "100%", disabled: !owner || picked.length === 0, onPress: () => setStep('access'), children: "Continue" })) : (_jsxs(YStack, { gap: "$3", items: "center", width: "100%", children: [_jsx(Button, { width: "100%", disabled: busy || syncing || picked.length === 0, onPress: () => void send(), children: syncing || busy ? 'Syncing…' : `Sync ${picked.length} ${picked.length === 1 ? 'repository' : 'repositories'} to ${dest}` }), syncing ? null : (_jsx(XStack, { render: "button", "aria-label": "Sync later", onPress: back, children: _jsx(SizableText, { size: "$2", color: "$soft", children: "Sync later" }) }))] })), !signed ? (_jsx(Button, { width: "100%", onPress: () => host.signIn?.(), children: "Sign in" })) : null] })] })] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.51",
3
+ "version": "0.2.52",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",