@byokit/ui 0.0.0-stage → 0.7.0

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.
@@ -0,0 +1,23 @@
1
+ export type Phase = 'opening' | 'waiting' | 'code' | 'done' | 'work' | 'cancelled' | 'busy' | 'expired' | 'failed' | 'offline';
2
+ /** A sign-in in progress, as `@byokit/accounts` `view()` reports it. */
3
+ export type SignInView = {
4
+ state: 'waiting' | 'done' | 'failed';
5
+ via?: 'browser' | 'code';
6
+ url?: string;
7
+ code?: string;
8
+ error?: string;
9
+ why?: string;
10
+ };
11
+ /** One account as the app's back end reports it; `work` is set when the sign-in is a work plan. */
12
+ export type AccountView = {
13
+ ready?: boolean;
14
+ work?: string | boolean;
15
+ signIn?: SignInView | null;
16
+ };
17
+ export declare function phaseOf(a: AccountView | null, { offline, cancelled, keepWork }?: {
18
+ offline?: boolean | undefined;
19
+ cancelled?: boolean | undefined;
20
+ keepWork?: boolean | undefined;
21
+ }): Phase;
22
+ /** Where the three-step progress bar ("Open", "Say yes", "Done") stands. */
23
+ export declare const stepOf: (p: Phase) => 0 | 1 | 3;
package/dist/phase.js ADDED
@@ -0,0 +1,21 @@
1
+ export function phaseOf(a, { offline = false, cancelled = false, keepWork = false } = {}) {
2
+ const s = a?.signIn;
3
+ if (offline)
4
+ return 'offline';
5
+ if (cancelled)
6
+ return 'cancelled';
7
+ if (a?.ready)
8
+ return a.work && !keepWork ? 'work' : 'done';
9
+ if (s?.state === 'waiting')
10
+ return s.code ? 'code' : s.url ? 'waiting' : 'opening';
11
+ if (s?.state === 'failed') {
12
+ if (s.why === 'busy')
13
+ return 'busy';
14
+ if (s.why === 'declined')
15
+ return 'cancelled';
16
+ return s.why === 'expired' || s.why === 'tooLong' || /expired|too long/i.test(s.error ?? '') ? 'expired' : 'failed';
17
+ }
18
+ return 'opening';
19
+ }
20
+ /** Where the three-step progress bar ("Open", "Say yes", "Done") stands. */
21
+ export const stepOf = (p) => (p === 'done' || p === 'work' ? 3 : p === 'waiting' || p === 'code' ? 1 : 0);
@@ -0,0 +1,28 @@
1
+ export type Route = 'Tailscale' | 'Temporary public link' | 'Same Wi-Fi' | 'Private network' | 'Away from home' | 'Your own server';
2
+ export type RouteKind = 'tailscale' | 'private' | 'lan' | 'direct';
3
+ /** An onboarding route code. A superset of reach's `Via`: reach has no Cloudflare or own-server route. */
4
+ export type RouteCode = 'tailscale' | 'tailscale-direct' | 'private' | 'lan' | 'cloudflare' | 'external';
5
+ /** One onboarding route in everyday words: the list title, the sentence under it, and what it needs first. */
6
+ export type RouteChoice = {
7
+ code: RouteCode;
8
+ /** The list title, e.g. `Same Wi-Fi`. */
9
+ title: string;
10
+ /** One everyday sentence saying what it gives, e.g. where it works. */
11
+ sentence: string;
12
+ /** What has to be true first, in words, e.g. which app to install. */
13
+ needs: string;
14
+ };
15
+ /**
16
+ * Every onboarding route in everyday words, in recommendation order. The one word table: reach's `recommend()`
17
+ * takes `sentence` and `needs` from here and only adds availability, so the order and the copy live in one place.
18
+ */
19
+ export declare function routeChoices(): RouteChoice[];
20
+ /** The route `url` takes, or undefined when it isn't a URL. Loopback is the page on the computer itself, so it reads
21
+ * as the same-network route; this kit's `/link/v1/…` away address reads as away from home, whatever its host. */
22
+ export declare function describeRoute(url: string | undefined, kind?: RouteKind): Route | undefined;
23
+ /** The route as words inside a connected line (`same Wi-Fi`): lowercase, so a consumer renders one sentence
24
+ * (`Connected to Kitchen computer - ${routeShort(route)}.`). Tailscale keeps its capital: it is a name. */
25
+ export declare function routeShort(route: Route | undefined): string | undefined;
26
+ /** The connected line a consumer renders: the computer with the route just dialled
27
+ * (`Connected to Kitchen computer - same Wi-Fi.`), or plain (`Connected to Kitchen computer.`) with no route. */
28
+ export declare function connectedWords(hostName: string, url: string | undefined, kind?: RouteKind): string;
package/dist/route.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Every onboarding route in everyday words, in recommendation order. The one word table: reach's `recommend()`
3
+ * takes `sentence` and `needs` from here and only adds availability, so the order and the copy live in one place.
4
+ */
5
+ export function routeChoices() {
6
+ return [
7
+ {
8
+ code: 'tailscale',
9
+ title: 'Tailscale — works anywhere',
10
+ sentence: 'Your phone reaches this computer from anywhere.',
11
+ needs: 'The free Tailscale app on this computer and your phone, signed in to the same account.',
12
+ },
13
+ {
14
+ code: 'tailscale-direct',
15
+ title: 'Tailscale — direct (phone app only)',
16
+ sentence: 'Same as above without Tailscale Serve. Pick this if Serve is already used on this computer for something else.',
17
+ needs: 'The free Tailscale app on this computer and your phone, signed in to the same account. No Serve setup.',
18
+ },
19
+ {
20
+ code: 'private',
21
+ title: 'Private network you already use (NetBird, WireGuard, ZeroTier)',
22
+ sentence: 'Pick this if this computer and phone are already on one.',
23
+ needs: 'This computer and your phone on the same private network.',
24
+ },
25
+ {
26
+ code: 'lan',
27
+ title: 'Same Wi-Fi',
28
+ sentence: 'Easiest. Works while your phone is on the same Wi-Fi as this computer. Nothing else to install.',
29
+ needs: 'Your phone on the same Wi-Fi as this computer.',
30
+ },
31
+ {
32
+ code: 'cloudflare',
33
+ title: 'Temporary public link (Cloudflare)',
34
+ sentence: 'Works anywhere without a VPN, but the link changes when it restarts.',
35
+ needs: 'cloudflared installed on this computer.',
36
+ },
37
+ {
38
+ code: 'external',
39
+ title: 'Your own server',
40
+ sentence: "For people who already run their own secure server. You'll paste its address next.",
41
+ needs: 'A stable server address you manage.',
42
+ },
43
+ ];
44
+ }
45
+ const privateIp = (host) => /^(?:127\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|100\.(?:6[4-9]|[78]\d|9\d|1[01]\d|12[0-7])\.)/.test(host);
46
+ const ipv4 = (host) => /^(?:\d{1,3}\.){3}\d{1,3}$/.test(host) && host.split('.').every((part) => +part <= 255);
47
+ /** The scheme, host and path of a `scheme://authority…` address, without touching `URL`: the React Native polyfill has no
48
+ * `URL.canParse`, and leaves `hostname` empty for that scheme. */
49
+ function schemeHost(url) {
50
+ const m = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^\/?#]*)([^?#]*)/.exec(url);
51
+ if (!m)
52
+ return undefined;
53
+ let host = m[2];
54
+ if (!host)
55
+ return undefined;
56
+ const at = host.lastIndexOf('@');
57
+ if (at >= 0)
58
+ host = host.slice(at + 1);
59
+ if (host.startsWith('[')) {
60
+ const end = host.indexOf(']');
61
+ if (end < 0)
62
+ return undefined;
63
+ host = host.slice(1, end);
64
+ }
65
+ else {
66
+ const colon = host.indexOf(':');
67
+ if (colon >= 0)
68
+ host = host.slice(0, colon);
69
+ }
70
+ host = host.toLowerCase();
71
+ if (!host)
72
+ return undefined;
73
+ return { scheme: m[1].toLowerCase(), hostname: host, path: m[3] ?? '' };
74
+ }
75
+ /** The route `url` takes, or undefined when it isn't a URL. Loopback is the page on the computer itself, so it reads
76
+ * as the same-network route; this kit's `/link/v1/…` away address reads as away from home, whatever its host. */
77
+ export function describeRoute(url, kind) {
78
+ if (url === undefined)
79
+ return undefined;
80
+ const parsed = schemeHost(url);
81
+ if (!parsed)
82
+ return undefined;
83
+ const { scheme, hostname, path } = parsed;
84
+ if (kind === 'tailscale' || kind === 'direct' || hostname.endsWith('.ts.net'))
85
+ return 'Tailscale';
86
+ if (kind === 'private')
87
+ return 'Private network';
88
+ if (kind === 'lan')
89
+ return 'Same Wi-Fi';
90
+ if (hostname.endsWith('.trycloudflare.com'))
91
+ return 'Temporary public link';
92
+ if ((scheme === 'ws' || scheme === 'wss') && path.startsWith('/link/v1/'))
93
+ return 'Away from home';
94
+ if ((scheme === 'ws' || scheme === 'wss') && ipv4(hostname) && privateIp(hostname)) {
95
+ return hostname.startsWith('100.') ? 'Private network' : 'Same Wi-Fi';
96
+ }
97
+ if ((scheme === 'ws' || scheme === 'wss') && (hostname === 'localhost' || hostname === '::1'))
98
+ return 'Same Wi-Fi';
99
+ return 'Your own server';
100
+ }
101
+ /** The route as words inside a connected line (`same Wi-Fi`): lowercase, so a consumer renders one sentence
102
+ * (`Connected to Kitchen computer - ${routeShort(route)}.`). Tailscale keeps its capital: it is a name. */
103
+ export function routeShort(route) {
104
+ return route === undefined ? undefined : {
105
+ 'Tailscale': 'Tailscale',
106
+ 'Temporary public link': 'a temporary public link',
107
+ 'Same Wi-Fi': 'same Wi-Fi',
108
+ 'Private network': 'a private network',
109
+ 'Away from home': 'away from home',
110
+ 'Your own server': 'your own server',
111
+ }[route];
112
+ }
113
+ /** The connected line a consumer renders: the computer with the route just dialled
114
+ * (`Connected to Kitchen computer - same Wi-Fi.`), or plain (`Connected to Kitchen computer.`) with no route. */
115
+ export function connectedWords(hostName, url, kind) {
116
+ const short = routeShort(describeRoute(url, kind));
117
+ return short ? `Connected to ${hostName} - ${short}.` : `Connected to ${hostName}.`;
118
+ }
package/dist/run.d.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { type Store } from './follow.ts';
2
+ export type RunEvent = {
3
+ type: 'started';
4
+ } | {
5
+ type: 'thinking';
6
+ tokens: number;
7
+ } | {
8
+ type: 'text';
9
+ text: string;
10
+ } | {
11
+ type: 'tool';
12
+ name: string;
13
+ phase: 'start' | 'end';
14
+ };
15
+ export type RunEnd = {
16
+ ok: true;
17
+ text: string;
18
+ } | {
19
+ ok: false;
20
+ aborted: true;
21
+ } | {
22
+ ok: false;
23
+ kind: 'signed-out' | 'resting' | 'plan' | 'network' | 'other' | 'output';
24
+ until?: number;
25
+ message: string;
26
+ };
27
+ /** One frame of the run stream: an event, then one `end`. */
28
+ export type RunFrame = RunEvent | {
29
+ type: 'end';
30
+ end: RunEnd;
31
+ };
32
+ export type RunPhase = 'idle' | 'running' | 'done' | 'stopped' | 'failed';
33
+ export type RunState = {
34
+ phase: RunPhase;
35
+ /** The reply so far (cumulative), the final reply once done. */
36
+ text: string;
37
+ /** Every tool the run used, in the order it started them; `done` once it finished. */
38
+ tools: {
39
+ name: string;
40
+ done: boolean;
41
+ }[];
42
+ end?: RunEnd;
43
+ /** The host's own words when the stream ended without an end (e.g. this device may not run). */
44
+ error?: string;
45
+ };
46
+ export type RunAction = RunFrame | {
47
+ type: 'start';
48
+ } | {
49
+ type: 'stop';
50
+ } | {
51
+ type: 'error';
52
+ message: string;
53
+ };
54
+ export declare const RUN_IDLE: RunState;
55
+ export declare function runStep(s: RunState, a: RunAction): RunState;
56
+ /** The kit's sentences a failed run needs (`@byokit/openclaw/device`'s `words` fits). */
57
+ export type RunWords = (key: 'member.signedOut' | 'member.resting' | 'member.plan' | 'member.network' | 'member.output', vars: {
58
+ name: string;
59
+ time: string;
60
+ }) => string;
61
+ export type RunWordsOptions = {
62
+ words: RunWords;
63
+ /** Who answers, as the person knows it: "ChatGPT". */
64
+ name: string;
65
+ /** How to say the time a resting plan is back; defaults to the device's short local time. */
66
+ time?: (ms: number) => string;
67
+ };
68
+ /** What to draw: the reply, the tool at work (if any), and the sentence for how it ended ('' when there is none). */
69
+ export declare function runView(s: RunState, { words, name, time }: RunWordsOptions): {
70
+ phase: RunPhase;
71
+ text: string;
72
+ tools: {
73
+ name: string;
74
+ done: boolean;
75
+ }[];
76
+ /** The tool running right now, the latest one started. */
77
+ tool: string | undefined;
78
+ words: string;
79
+ };
80
+ /** What `runStore` needs from a device client: `openclawDevice(link)` fits. */
81
+ export type RunSource = {
82
+ run(message: string, o?: {
83
+ sessionKey?: string;
84
+ }): AsyncIterable<RunFrame>;
85
+ };
86
+ /**
87
+ * One run at a time from a device: `send` starts one (stopping any still streaming), `stop` stops listening to it.
88
+ * Stopping here does not abort the run on the computer; pair it with the device's `abort(sessionKey)` for that.
89
+ */
90
+ export declare function runStore(source: RunSource): Store<RunState> & {
91
+ send(message: string, o?: {
92
+ sessionKey?: string;
93
+ }): void;
94
+ stop(): void;
95
+ };
package/dist/run.js ADDED
@@ -0,0 +1,85 @@
1
+ // One agent run as a person sees it: the reply streaming in, the tools it uses, and how it ended in plain words.
2
+ // Framework-free; typed structurally against `@byokit/openclaw/device`'s `run()`, so nothing here imports a kit.
3
+ // idle nothing sent yet running the reply is streaming in
4
+ // done the reply is complete stopped stopped before the end (here, or aborted on the computer)
5
+ // failed it ended with a problem: `words` says which, in the kit's own sentence
6
+ import { store } from "./follow.js";
7
+ export const RUN_IDLE = { phase: 'idle', text: '', tools: [] };
8
+ export function runStep(s, a) {
9
+ if (a.type === 'start')
10
+ return { phase: 'running', text: '', tools: [] };
11
+ if (s.phase !== 'running')
12
+ return s; // a finished run hears nothing more
13
+ switch (a.type) {
14
+ case 'started':
15
+ case 'thinking': return s;
16
+ case 'text': return { ...s, text: a.text };
17
+ case 'tool': {
18
+ if (a.phase === 'start')
19
+ return { ...s, tools: [...s.tools, { name: a.name, done: false }] };
20
+ const at = s.tools.findIndex((t) => t.name === a.name && !t.done);
21
+ return at < 0 ? s : { ...s, tools: s.tools.map((t, i) => (i === at ? { ...t, done: true } : t)) };
22
+ }
23
+ case 'end': {
24
+ const tools = s.tools.map((t) => (t.done ? t : { ...t, done: true }));
25
+ if (a.end.ok)
26
+ return { phase: 'done', text: a.end.text || s.text, tools, end: a.end };
27
+ return { ...s, phase: 'aborted' in a.end ? 'stopped' : 'failed', tools, end: a.end };
28
+ }
29
+ case 'error': return { ...s, phase: 'failed', error: a.message };
30
+ case 'stop': return { ...s, phase: 'stopped' };
31
+ }
32
+ }
33
+ const KEY = { 'signed-out': 'member.signedOut', resting: 'member.resting', plan: 'member.plan', network: 'member.network', output: 'member.output' };
34
+ const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
35
+ /** What to draw: the reply, the tool at work (if any), and the sentence for how it ended ('' when there is none). */
36
+ export function runView(s, { words, name, time = clock }) {
37
+ const end = s.end && !s.end.ok && 'kind' in s.end ? s.end : undefined;
38
+ const said = end && end.kind !== 'other'
39
+ ? words(KEY[end.kind], { name, time: end.until === undefined ? 'later' : time(end.until) })
40
+ : s.error ?? '';
41
+ return {
42
+ phase: s.phase, text: s.text, tools: s.tools,
43
+ /** The tool running right now, the latest one started. */
44
+ tool: s.tools.filter((t) => !t.done).pop()?.name,
45
+ words: said,
46
+ };
47
+ }
48
+ /**
49
+ * One run at a time from a device: `send` starts one (stopping any still streaming), `stop` stops listening to it.
50
+ * Stopping here does not abort the run on the computer; pair it with the device's `abort(sessionKey)` for that.
51
+ */
52
+ export function runStore(source) {
53
+ const s = store(RUN_IDLE, () => () => { });
54
+ let current;
55
+ const send = (message, o) => {
56
+ current?.();
57
+ let stopped = false;
58
+ let it;
59
+ const step = (a) => { if (!stopped)
60
+ s.set(runStep(s.get(), a)); };
61
+ current = () => {
62
+ if (stopped)
63
+ return;
64
+ step({ type: 'stop' });
65
+ stopped = true;
66
+ void it?.return?.();
67
+ };
68
+ step({ type: 'start' });
69
+ void (async () => {
70
+ try {
71
+ it = source.run(message, o)[Symbol.asyncIterator]();
72
+ for (let r = await it.next(); !r.done && !stopped; r = await it.next())
73
+ step(r.value);
74
+ step({ type: 'stop' }); // the stream ended without an end frame
75
+ }
76
+ catch (e) {
77
+ const message = e instanceof Error ? e.message : String(e);
78
+ // A stream the link cut ends with the link's bare reason ('unreachable'), not words: the run stopped here,
79
+ // and the link's own status says why.
80
+ step(/^[a-z][a-z-]*$/.test(message) ? { type: 'stop' } : { type: 'error', message });
81
+ }
82
+ })();
83
+ };
84
+ return { get: s.get, subscribe: s.subscribe, send, stop: () => current?.() };
85
+ }
@@ -0,0 +1,25 @@
1
+ export type StepState = 'todo' | 'checking' | 'ok' | 'failed';
2
+ /** One step as its caller reports it: `label` names the check ("Agent runner"), `detail` says how it stands
3
+ * ("ready", "not installed on this computer"), and `fix` is the one next action when it fails. Ids are the
4
+ * caller's own; nothing here knows any app's steps. */
5
+ export type StepInput = {
6
+ id: string;
7
+ state: StepState;
8
+ label: string;
9
+ detail?: string;
10
+ fix?: string;
11
+ };
12
+ /** One row to draw: `words` is the plain sentence, `fix` the next action ("" until the step fails). */
13
+ export type StepRow = {
14
+ id: string;
15
+ state: StepState;
16
+ words: string;
17
+ fix: string;
18
+ };
19
+ /** Turn the caller's steps into the rows to draw, in order. A failed step without its own `fix` says "Try again." */
20
+ export declare function stepsView(steps: StepInput[]): StepRow[];
21
+ /** The same rows as text lines for a terminal: `✓` done, `…` checking, `○` waiting, `✗` failed with its fix.
22
+ * A phone draws the rows itself; this keeps the terminal showing the same words. */
23
+ export declare function stepsText(rows: StepRow[], opts?: {
24
+ title?: string;
25
+ }): string;
package/dist/steps.js ADDED
@@ -0,0 +1,16 @@
1
+ /** Turn the caller's steps into the rows to draw, in order. A failed step without its own `fix` says "Try again." */
2
+ export function stepsView(steps) {
3
+ return steps.map((s) => ({
4
+ id: s.id,
5
+ state: s.state,
6
+ words: s.detail ? `${s.label}: ${s.detail}` : s.label,
7
+ fix: s.state === 'failed' ? (s.fix || 'Try again.') : '',
8
+ }));
9
+ }
10
+ const mark = { ok: '✓', checking: '…', todo: '○', failed: '✗' };
11
+ /** The same rows as text lines for a terminal: `✓` done, `…` checking, `○` waiting, `✗` failed with its fix.
12
+ * A phone draws the rows itself; this keeps the terminal showing the same words. */
13
+ export function stepsText(rows, opts) {
14
+ const lines = rows.map((r) => ` ${mark[r.state]} ${r.words}${r.state === 'failed' && r.fix ? ` ${r.fix}` : ''}`);
15
+ return opts?.title ? `${opts.title}\n${lines.join('\n')}` : lines.join('\n');
16
+ }
@@ -0,0 +1,48 @@
1
+ import { type Account, type AccountRow, type AccountsSource, type ModelInfo, type Pick, type PickerView, type Room, type RunSelection } from './accounts.ts';
2
+ /** What the hooks remove through: an `AccountsSource` plus the back end's own removal, when the app allows it. */
3
+ export type AccountHooksSource = AccountsSource & {
4
+ remove?: (id: string) => Promise<unknown>;
5
+ };
6
+ /** What the picker reads: the rows' source plus one model list per account and Auto's own pick. */
7
+ export type ModelPickerSource = AccountHooksSource & {
8
+ models: (account: Account) => Promise<readonly ModelInfo[]> | readonly ModelInfo[];
9
+ auto: () => Promise<Pick> | Pick;
10
+ };
11
+ /** A live `@byokit/accounts` member: `list()`/`models()`/`pick()`/`remove()` fit with one line per member. */
12
+ export type MemberAccounts<M> = {
13
+ list: (member: M) => Promise<readonly Account[]>;
14
+ models: (member: M, id: string) => Promise<readonly ModelInfo[]>;
15
+ pick: (member: M, sel: RunSelection, room?: (account: Account, demand: readonly string[]) => Room | Promise<Room>) => Promise<Pick>;
16
+ remove: (member: M, id: string) => Promise<unknown>;
17
+ };
18
+ /** A live `cliAccounts()` kit: `list()` fits with one line; `remove` when the app lets the person remove one. */
19
+ export type CliAccounts = {
20
+ list: () => Promise<readonly Account[]>;
21
+ remove?: (id: string) => Promise<unknown>;
22
+ };
23
+ /** One line per member: the rows' source (with the picker's models and Auto pick) over a live `Accounts`. */
24
+ export declare function accountsSource<M>(accounts: MemberAccounts<M>, member: M, room?: (account: Account) => Room | Promise<Room>): ModelPickerSource;
25
+ /** One line: the rows' source over a live `cliAccounts()` kit, with each provider's plain name on its rows. */
26
+ export declare function cliSource(cli: CliAccounts, room?: (account: Account) => Room | Promise<Room>): AccountHooksSource;
27
+ export type UseAccounts = {
28
+ rows: AccountRow[];
29
+ ready: boolean;
30
+ error: unknown;
31
+ refresh: () => Promise<void>;
32
+ remove: (id: string) => Promise<void>;
33
+ };
34
+ /** The rows live: `refresh()` re-reads the source; the caller's removal of an id sticks across refreshes. */
35
+ export declare function useAccounts(source: AccountHooksSource): UseAccounts;
36
+ export type UseModelPicker = {
37
+ rows: AccountRow[];
38
+ picker: PickerView | null;
39
+ selection: RunSelection;
40
+ ready: boolean;
41
+ error: unknown;
42
+ select: (sel: RunSelection) => void;
43
+ refresh: () => Promise<void>;
44
+ remove: (id: string) => Promise<void>;
45
+ };
46
+ /** The run picker live: `select()` marks the selection at once (kept across refreshes); `refresh()` re-reads
47
+ * accounts, models and Auto's pick together. `picker` is null until the first refresh resolves. */
48
+ export declare function useModelPicker(source: ModelPickerSource, initial?: RunSelection): UseModelPicker;
@@ -0,0 +1,156 @@
1
+ // Live account rows and the run picker as React hooks over `./accounts.ts`. An app fills an `AccountsSource`
2
+ // (or the fuller `ModelPickerSource`) from its own back end — one line per member — and draws the rows its own
3
+ // way. A failing room leaves only its row unknown; a mid-refresh `remove()` still drops the row. No openclaw
4
+ // adapter yet (pending O14).
5
+ import { useCallback, useEffect, useRef, useState } from 'react';
6
+ import { accountRows, modelPicker, rowsOf } from "./accounts.js";
7
+ const UNKNOWN_ROOM = { left: 'unknown' };
8
+ const CLI_LABEL = { claude: 'Claude', codex: 'Codex', pi: 'Pi' };
9
+ /** One line per member: the rows' source (with the picker's models and Auto pick) over a live `Accounts`. */
10
+ export function accountsSource(accounts, member, room = () => UNKNOWN_ROOM) {
11
+ return {
12
+ accounts: () => accounts.list(member),
13
+ room,
14
+ models: (a) => accounts.models(member, a.id),
15
+ auto: () => accounts.pick(member, { account: 'auto' }, room),
16
+ remove: (id) => accounts.remove(member, id),
17
+ };
18
+ }
19
+ /** One line: the rows' source over a live `cliAccounts()` kit, with each provider's plain name on its rows. */
20
+ export function cliSource(cli, room = () => UNKNOWN_ROOM) {
21
+ const remove = cli.remove;
22
+ return {
23
+ accounts: async () => (await cli.list()).map((c) => ({ ...c, label: CLI_LABEL[c.provider] ?? c.provider })),
24
+ room,
25
+ ...(remove ? { remove: (id) => remove(id) } : {}),
26
+ };
27
+ }
28
+ /** The rows live: `refresh()` re-reads the source; the caller's removal of an id sticks across refreshes. */
29
+ export function useAccounts(source) {
30
+ const [rows, setRows] = useState([]);
31
+ const [ready, setReady] = useState(false);
32
+ const [error, setError] = useState(undefined);
33
+ const removed = useRef(new Set());
34
+ const gen = useRef(0);
35
+ const refresh = useCallback(async () => {
36
+ const g = ++gen.current;
37
+ let next;
38
+ try {
39
+ next = await rowsOf(source, Date.now());
40
+ }
41
+ catch (e) {
42
+ if (gen.current === g)
43
+ setError(e);
44
+ return;
45
+ }
46
+ if (gen.current !== g)
47
+ return;
48
+ setRows(next.filter((r) => !removed.current.has(r.id)));
49
+ setError(undefined);
50
+ setReady(true);
51
+ }, [source]);
52
+ useEffect(() => { void refresh(); }, [refresh]);
53
+ const remove = useCallback(async (id) => {
54
+ removed.current.add(id);
55
+ gen.current++;
56
+ setRows((prev) => prev.filter((r) => r.id !== id));
57
+ try {
58
+ await source.remove?.(id);
59
+ }
60
+ catch (e) {
61
+ removed.current.delete(id);
62
+ throw e;
63
+ }
64
+ await refresh();
65
+ }, [source, refresh]);
66
+ return { rows, ready, error, refresh, remove };
67
+ }
68
+ /** The run picker live: `select()` marks the selection at once (kept across refreshes); `refresh()` re-reads
69
+ * accounts, models and Auto's pick together. `picker` is null until the first refresh resolves. */
70
+ export function useModelPicker(source, initial = { account: 'auto' }) {
71
+ const [rows, setRows] = useState([]);
72
+ const [picker, setPicker] = useState(null);
73
+ const [selection, setSelection] = useState(initial);
74
+ const [ready, setReady] = useState(false);
75
+ const [error, setError] = useState(undefined);
76
+ const sel = useRef(initial);
77
+ const cache = useRef(null);
78
+ const removed = useRef(new Set());
79
+ const gen = useRef(0);
80
+ const refresh = useCallback(async () => {
81
+ const g = ++gen.current;
82
+ const now = Date.now();
83
+ let list;
84
+ try {
85
+ list = [...(await source.accounts())];
86
+ }
87
+ catch (e) {
88
+ if (gen.current === g)
89
+ setError(e);
90
+ return;
91
+ }
92
+ const live = list.filter((a) => !removed.current.has(a.id));
93
+ const rooms = new Map();
94
+ for (const a of live) {
95
+ try {
96
+ rooms.set(a.id, (await source.room(a)) ?? UNKNOWN_ROOM);
97
+ }
98
+ catch {
99
+ rooms.set(a.id, UNKNOWN_ROOM);
100
+ }
101
+ }
102
+ const models = {};
103
+ for (const a of live) {
104
+ try {
105
+ models[a.id] = await source.models(a);
106
+ }
107
+ catch {
108
+ models[a.id] = [];
109
+ }
110
+ }
111
+ let auto;
112
+ try {
113
+ auto = await source.auto();
114
+ }
115
+ catch (e) {
116
+ if (gen.current === g)
117
+ setError(e);
118
+ return;
119
+ }
120
+ if (gen.current !== g)
121
+ return;
122
+ cache.current = { accounts: live, models, auto };
123
+ setRows(accountRows(live, (a) => rooms.get(a.id) ?? UNKNOWN_ROOM, now).filter((r) => !removed.current.has(r.id)));
124
+ setPicker(modelPicker(live, models, sel.current, auto, now));
125
+ setError(undefined);
126
+ setReady(true);
127
+ }, [source]);
128
+ useEffect(() => { void refresh(); }, [refresh]);
129
+ const select = useCallback((next) => {
130
+ sel.current = next;
131
+ setSelection(next);
132
+ const c = cache.current;
133
+ if (c)
134
+ setPicker(modelPicker(c.accounts, c.models, next, c.auto, Date.now()));
135
+ }, []);
136
+ const remove = useCallback(async (id) => {
137
+ removed.current.add(id);
138
+ gen.current++;
139
+ setRows((prev) => prev.filter((r) => r.id !== id));
140
+ const c = cache.current;
141
+ if (c) {
142
+ const accounts = c.accounts.filter((a) => a.id !== id);
143
+ cache.current = { ...c, accounts };
144
+ setPicker(modelPicker(accounts, c.models, sel.current, c.auto, Date.now()));
145
+ }
146
+ try {
147
+ await source.remove?.(id);
148
+ }
149
+ catch (e) {
150
+ removed.current.delete(id);
151
+ throw e;
152
+ }
153
+ await refresh();
154
+ }, [source, refresh]);
155
+ return { rows, picker, selection, ready, error, select, refresh, remove };
156
+ }
@@ -0,0 +1,18 @@
1
+ import { type ConnectAction, type ConnectRoute, type ConnectStep, type ConnectWords, type UseConnectSignIn } from './connect.ts';
2
+ export type UseConnect = UseConnectSignIn & {
3
+ routes: readonly ConnectRoute[];
4
+ words?: ConnectWords;
5
+ };
6
+ export declare function useConnect({ routes, words, ...o }: UseConnect): {
7
+ step: ConnectStep;
8
+ act: (a: ConnectAction) => void;
9
+ pick: (key: string) => void;
10
+ back: () => void;
11
+ /** Pass to `useSignIn` in a sheet keyed by `chosen.row.key`; undefined unless a ready sign-in row is chosen. */
12
+ signIn: import("./useSignIn.ts").UseSignIn | undefined;
13
+ groups: import("./connect.ts").ConnectGroup[];
14
+ chosen?: {
15
+ row: import("./connect.ts").ConnectRow;
16
+ key?: ReturnType<typeof import("./key.ts").keyView>;
17
+ };
18
+ };
@@ -0,0 +1,17 @@
1
+ // The connect view as a React hook: holds the step, recomputes the view from the routes the app passes, and hands
2
+ // the chosen sign-in row's options to `useSignIn`. Keys never enter it: the app sends the input to its kit and
3
+ // reports only the result.
4
+ import { useState } from 'react';
5
+ import { connectStep, connectView, signInFor } from "./connect.js";
6
+ export function useConnect({ routes, words, ...o }) {
7
+ const [step, setStep] = useState({ at: 'list' });
8
+ const view = connectView(routes, step, words);
9
+ const act = (a) => setStep((s) => connectStep(s, a));
10
+ return {
11
+ ...view, step, act,
12
+ pick: (key) => act({ type: 'pick', key }),
13
+ back: () => act({ type: 'back' }),
14
+ /** Pass to `useSignIn` in a sheet keyed by `chosen.row.key`; undefined unless a ready sign-in row is chosen. */
15
+ signIn: signInFor(view, o),
16
+ };
17
+ }