@parlor/scaffold 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +11 -2
- package/dist/add/addon.d.ts +101 -0
- package/dist/add/addon.d.ts.map +1 -0
- package/dist/add/addon.js +6 -0
- package/dist/add/addon.js.map +1 -0
- package/dist/add/plan.d.ts +72 -0
- package/dist/add/plan.d.ts.map +1 -0
- package/dist/add/plan.js +237 -0
- package/dist/add/plan.js.map +1 -0
- package/dist/add/run.d.ts +92 -0
- package/dist/add/run.d.ts.map +1 -0
- package/dist/add/run.js +167 -0
- package/dist/add/run.js.map +1 -0
- package/dist/add/transaction.d.ts +67 -0
- package/dist/add/transaction.d.ts.map +1 -0
- package/dist/add/transaction.js +181 -0
- package/dist/add/transaction.js.map +1 -0
- package/dist/addons/agent.d.ts +61 -0
- package/dist/addons/agent.d.ts.map +1 -0
- package/dist/addons/agent.js +50 -0
- package/dist/addons/agent.js.map +1 -0
- package/dist/addons/auth.d.ts +106 -0
- package/dist/addons/auth.d.ts.map +1 -0
- package/dist/addons/auth.js +93 -0
- package/dist/addons/auth.js.map +1 -0
- package/dist/addons/index.d.ts +165 -0
- package/dist/addons/index.d.ts.map +1 -0
- package/dist/addons/index.js +8 -0
- package/dist/addons/index.js.map +1 -0
- package/dist/catalog.d.ts +3 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +23 -0
- package/dist/catalog.js.map +1 -0
- package/dist/create.d.ts +45 -0
- package/dist/create.d.ts.map +1 -0
- package/dist/create.js +162 -0
- package/dist/create.js.map +1 -0
- package/dist/docs.d.ts +12 -0
- package/dist/docs.d.ts.map +1 -0
- package/dist/docs.js +66 -0
- package/dist/docs.js.map +1 -0
- package/dist/edit/inspect.d.ts +15 -0
- package/dist/edit/inspect.d.ts.map +1 -0
- package/dist/edit/inspect.js +73 -0
- package/dist/edit/inspect.js.map +1 -0
- package/dist/edit/ops.d.ts +92 -0
- package/dist/edit/ops.d.ts.map +1 -0
- package/dist/edit/ops.js +516 -0
- package/dist/edit/ops.js.map +1 -0
- package/dist/edit/source.d.ts +24 -0
- package/dist/edit/source.d.ts.map +1 -0
- package/dist/edit/source.js +36 -0
- package/dist/edit/source.js.map +1 -0
- package/dist/edit/syntax.d.ts +34 -0
- package/dist/edit/syntax.d.ts.map +1 -0
- package/dist/edit/syntax.js +114 -0
- package/dist/edit/syntax.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +5 -0
- package/dist/errors.js.map +1 -0
- package/dist/files.d.ts +15 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +65 -0
- package/dist/files.js.map +1 -0
- package/dist/fixtures.d.ts +28 -0
- package/dist/fixtures.d.ts.map +1 -0
- package/dist/fixtures.js +66 -0
- package/dist/fixtures.js.map +1 -0
- package/dist/git.d.ts +22 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +56 -0
- package/dist/git.js.map +1 -0
- package/dist/identity.d.ts +9 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +23 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/package-manager.d.ts +49 -0
- package/dist/package-manager.d.ts.map +1 -0
- package/dist/package-manager.js +118 -0
- package/dist/package-manager.js.map +1 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +9 -0
- package/dist/version.js.map +1 -0
- package/package.json +46 -3
- package/templates/auth/auth.ts +20 -0
- package/templates/auth/schema/auth.ts +1 -0
- package/templates/auth/src/auth/client.ts +3 -0
- package/templates/auth/src/auth/sign-in-gate.tsx +17 -0
- package/templates/auth/src/auth/sign-out-button.tsx +17 -0
- package/templates/auth/src/routes/link.tsx +92 -0
- package/templates/auth/src/routes/sign-in.tsx +117 -0
- package/templates/default/_gitignore +7 -0
- package/templates/default/actions/index.ts +58 -0
- package/templates/default/actions/todos.spec.ts +38 -0
- package/templates/default/index.html +13 -0
- package/templates/default/migrations/0000_init.sql +9 -0
- package/templates/default/migrations/meta/0000_snapshot.json +87 -0
- package/templates/default/migrations/meta/_journal.json +13 -0
- package/templates/default/package.json +44 -0
- package/templates/default/parlor.config.ts +12 -0
- package/templates/default/schema/index.ts +1 -0
- package/templates/default/schema/todos.ts +13 -0
- package/templates/default/server.ts +13 -0
- package/templates/default/src/agent/agent-button.tsx +120 -0
- package/templates/default/src/agent/agent-sidebar.tsx +821 -0
- package/templates/default/src/agent/agent.css +5 -0
- package/templates/default/src/agent/confirmation-card.tsx +42 -0
- package/templates/default/src/agent/sidebar-view.ts +36 -0
- package/templates/default/src/agent/views.ts +9 -0
- package/templates/default/src/app.css +1 -0
- package/templates/default/src/header-actions.tsx +9 -0
- package/templates/default/src/main.tsx +24 -0
- package/templates/default/src/parlor.d.ts +9 -0
- package/templates/default/src/routeTree.gen.ts +59 -0
- package/templates/default/src/routes/__root.tsx +28 -0
- package/templates/default/src/routes/index.tsx +136 -0
- package/templates/default/tsconfig.json +8 -0
- package/templates/default/vitest.config.ts +5 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
import { isPromptTooLong, PROMPT_LIMIT_MESSAGE, type TurnState } from "@parlor/chat";
|
|
2
|
+
import {
|
|
3
|
+
type AgentConfirmation,
|
|
4
|
+
Chat,
|
|
5
|
+
type ChatItem,
|
|
6
|
+
useChat,
|
|
7
|
+
useDevices,
|
|
8
|
+
useLinkDevice,
|
|
9
|
+
useSession,
|
|
10
|
+
} from "@parlor/client/react";
|
|
11
|
+
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
|
12
|
+
import { encode } from "uqr";
|
|
13
|
+
|
|
14
|
+
import { ConfirmationCard, focusComposer } from "./confirmation-card.js";
|
|
15
|
+
import { sidebarView } from "./sidebar-view.js";
|
|
16
|
+
|
|
17
|
+
const DISABLED = "disabled:cursor-not-allowed disabled:opacity-40";
|
|
18
|
+
const BUTTON = `rounded-md border border-zinc-300 px-3 py-1 text-sm enabled:hover:bg-zinc-100 ${DISABLED}`;
|
|
19
|
+
const PRIMARY = `rounded-md bg-zinc-900 px-3 py-1 text-sm font-medium text-white ${DISABLED}`;
|
|
20
|
+
const META = "text-xs text-zinc-500";
|
|
21
|
+
|
|
22
|
+
export default function AgentSidebar() {
|
|
23
|
+
return (
|
|
24
|
+
<Chat.Root>
|
|
25
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
26
|
+
<PublishThread />
|
|
27
|
+
<div className="space-y-2 border-b border-zinc-200 p-4">
|
|
28
|
+
<LinkedNotices />
|
|
29
|
+
<Computers />
|
|
30
|
+
<DeviceTools />
|
|
31
|
+
<Threads />
|
|
32
|
+
</div>
|
|
33
|
+
<Chat.Confirmations className="space-y-2 px-4 pt-4 empty:hidden">
|
|
34
|
+
{(confirmation) =>
|
|
35
|
+
confirmation.threadId === undefined ? (
|
|
36
|
+
<ConfirmationCard confirmation={confirmation} />
|
|
37
|
+
) : null
|
|
38
|
+
}
|
|
39
|
+
</Chat.Confirmations>
|
|
40
|
+
<Chat.Thread
|
|
41
|
+
className="flex-1 space-y-3 overflow-y-auto p-4"
|
|
42
|
+
empty={<ThreadEmpty />}
|
|
43
|
+
older={<Chat.LoadOlder className={`mx-auto block ${BUTTON}`} />}
|
|
44
|
+
>
|
|
45
|
+
{(item) => <Item item={item} />}
|
|
46
|
+
</Chat.Thread>
|
|
47
|
+
<LinkCard />
|
|
48
|
+
<Composer />
|
|
49
|
+
</div>
|
|
50
|
+
</Chat.Root>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function PublishThread() {
|
|
55
|
+
const threadId = useChat().thread?.id;
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
sidebarView.set({ threadId });
|
|
58
|
+
return () => sidebarView.set({ threadId: undefined });
|
|
59
|
+
}, [threadId]);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function Threads() {
|
|
64
|
+
const {
|
|
65
|
+
threads,
|
|
66
|
+
thread,
|
|
67
|
+
openThread,
|
|
68
|
+
newThread,
|
|
69
|
+
moreThreads,
|
|
70
|
+
loadMoreThreads,
|
|
71
|
+
loadingOlder,
|
|
72
|
+
} = useChat();
|
|
73
|
+
if (threads.length === 0) return null;
|
|
74
|
+
return (
|
|
75
|
+
<div className="space-y-1">
|
|
76
|
+
<div className="flex gap-2">
|
|
77
|
+
<select
|
|
78
|
+
name="thread"
|
|
79
|
+
aria-label="Conversation"
|
|
80
|
+
value={thread?.id ?? ""}
|
|
81
|
+
onChange={(event) => openThread(event.target.value)}
|
|
82
|
+
className="min-w-0 flex-1 rounded-md border border-zinc-300 px-2 py-1 text-sm"
|
|
83
|
+
>
|
|
84
|
+
{thread ? null : (
|
|
85
|
+
<option value="" disabled>
|
|
86
|
+
New conversation
|
|
87
|
+
</option>
|
|
88
|
+
)}
|
|
89
|
+
{threads.map((choice) => (
|
|
90
|
+
<option key={choice.id} value={choice.id}>
|
|
91
|
+
{choice.title}
|
|
92
|
+
</option>
|
|
93
|
+
))}
|
|
94
|
+
</select>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
disabled={thread === undefined}
|
|
98
|
+
onClick={newThread}
|
|
99
|
+
className={BUTTON}
|
|
100
|
+
>
|
|
101
|
+
New chat
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
{moreThreads ? (
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
disabled={loadingOlder}
|
|
108
|
+
onClick={() => void loadMoreThreads()}
|
|
109
|
+
className={META}
|
|
110
|
+
>
|
|
111
|
+
Show older conversations
|
|
112
|
+
</button>
|
|
113
|
+
) : null}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function Note({
|
|
119
|
+
title,
|
|
120
|
+
children,
|
|
121
|
+
}: {
|
|
122
|
+
readonly title: string;
|
|
123
|
+
readonly children: ReactNode;
|
|
124
|
+
}) {
|
|
125
|
+
return (
|
|
126
|
+
<div className="space-y-1 rounded-lg bg-zinc-100 p-3 text-sm">
|
|
127
|
+
<p className="font-medium">{title}</p>
|
|
128
|
+
{children}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function Computers() {
|
|
134
|
+
const { agents, agent, choose, connect, hosts, status } = useChat();
|
|
135
|
+
if (status === "loading") return null;
|
|
136
|
+
if (status === "unavailable") {
|
|
137
|
+
return (
|
|
138
|
+
<Note title="The agent is not available here">
|
|
139
|
+
<p>
|
|
140
|
+
It runs with <code>parlor dev</code>. In production it needs sign-in: add it
|
|
141
|
+
with <code>parlor add auth</code>.
|
|
142
|
+
</p>
|
|
143
|
+
</Note>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (agents.length === 0) {
|
|
147
|
+
return (
|
|
148
|
+
<Note title={hosts.length === 0 ? "No computer connected" : "No agent ready"}>
|
|
149
|
+
{hosts.length > 0 ? (
|
|
150
|
+
<p>None of your connected computers has an agent set up.</p>
|
|
151
|
+
) : connect.kind === "here" ? (
|
|
152
|
+
<button type="button" onClick={() => void connect.start()} className={PRIMARY}>
|
|
153
|
+
Use agents on this computer
|
|
154
|
+
</button>
|
|
155
|
+
) : (
|
|
156
|
+
<>
|
|
157
|
+
<p>
|
|
158
|
+
To chat with the AI agents on your computer, set it up once: run this in a
|
|
159
|
+
terminal on it, then link this device.
|
|
160
|
+
</p>
|
|
161
|
+
<code data-testid="connect-command" className="block wrap-anywhere text-xs">
|
|
162
|
+
{connect.command}
|
|
163
|
+
</code>
|
|
164
|
+
</>
|
|
165
|
+
)}
|
|
166
|
+
</Note>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return (
|
|
170
|
+
<div className="space-y-1">
|
|
171
|
+
<select
|
|
172
|
+
name="agent"
|
|
173
|
+
aria-label="Agent"
|
|
174
|
+
value={agent?.key ?? ""}
|
|
175
|
+
onChange={(event) => choose(event.target.value)}
|
|
176
|
+
className="w-full rounded-md border border-zinc-300 px-2 py-1 text-sm"
|
|
177
|
+
>
|
|
178
|
+
{agent ? null : (
|
|
179
|
+
<option value="" disabled>
|
|
180
|
+
Choose an agent
|
|
181
|
+
</option>
|
|
182
|
+
)}
|
|
183
|
+
{agents.map((choice) => (
|
|
184
|
+
<option
|
|
185
|
+
key={choice.key}
|
|
186
|
+
value={choice.key}
|
|
187
|
+
disabled={!choice.online || !choice.ready}
|
|
188
|
+
>
|
|
189
|
+
{choice.name} on {choice.hostName}
|
|
190
|
+
{!choice.online ? " (offline)" : !choice.ready ? " (not ready)" : ""}
|
|
191
|
+
</option>
|
|
192
|
+
))}
|
|
193
|
+
</select>
|
|
194
|
+
{agent?.problem ? <p className="text-xs text-red-600">{agent.problem}</p> : null}
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function ThreadEmpty() {
|
|
200
|
+
const { status, agents } = useChat();
|
|
201
|
+
const className = `px-4 pb-3 ${META}`;
|
|
202
|
+
if (status === "loading") return <p className={className}>Loading...</p>;
|
|
203
|
+
return (
|
|
204
|
+
<p className={className}>
|
|
205
|
+
{agents.length === 0
|
|
206
|
+
? "Your conversation with an agent shows up here."
|
|
207
|
+
: "Ask your agent to do something in this app, like adding a todo."}
|
|
208
|
+
</p>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const NOTE = "mx-4 mb-4 space-y-2 rounded-lg border border-zinc-200 p-3 text-sm";
|
|
213
|
+
|
|
214
|
+
const formatCode = (code: string) => code.replace(/^(.{4})(.{4})(.{2})$/, "$1-$2-$3");
|
|
215
|
+
|
|
216
|
+
function LinkQr({ value }: { readonly value: string }) {
|
|
217
|
+
const { size, path } = useMemo(() => {
|
|
218
|
+
const qr = encode(value, { border: 2 });
|
|
219
|
+
const cells = qr.data.flatMap((row, y) =>
|
|
220
|
+
row.flatMap((dark, x) => (dark ? [`M${x} ${y}h1v1h-1z`] : [])),
|
|
221
|
+
);
|
|
222
|
+
return { size: qr.size, path: cells.join("") };
|
|
223
|
+
}, [value]);
|
|
224
|
+
return (
|
|
225
|
+
<svg
|
|
226
|
+
viewBox={`0 0 ${size} ${size}`}
|
|
227
|
+
role="img"
|
|
228
|
+
aria-label="QR code to link this device"
|
|
229
|
+
shapeRendering="crispEdges"
|
|
230
|
+
className="mx-auto size-40 rounded bg-white"
|
|
231
|
+
>
|
|
232
|
+
<path d={path} fill="currentColor" />
|
|
233
|
+
</svg>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function LinkCard() {
|
|
238
|
+
const { link, startLink } = useChat();
|
|
239
|
+
if (link.status !== "unlinked") return null;
|
|
240
|
+
if (link.code === undefined) {
|
|
241
|
+
return (
|
|
242
|
+
<section aria-label="Link this device" className={NOTE}>
|
|
243
|
+
<p className="font-medium">Link this device</p>
|
|
244
|
+
<p>
|
|
245
|
+
To use your agents on {link.hostName} from this device, link it once, the way
|
|
246
|
+
you link WhatsApp to a new device.
|
|
247
|
+
</p>
|
|
248
|
+
{link.expired ? <p className={META}>That code expired. Show a new one.</p> : null}
|
|
249
|
+
{link.error ? (
|
|
250
|
+
<p role="alert" className="text-xs text-red-600">
|
|
251
|
+
{link.error}
|
|
252
|
+
</p>
|
|
253
|
+
) : null}
|
|
254
|
+
<div className="flex flex-wrap justify-end gap-2 pt-1">
|
|
255
|
+
{link.error ? (
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
onClick={() => void startLink({ presence: false })}
|
|
259
|
+
disabled={link.busy}
|
|
260
|
+
className={BUTTON}
|
|
261
|
+
>
|
|
262
|
+
Link without it
|
|
263
|
+
</button>
|
|
264
|
+
) : null}
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
onClick={() => void startLink()}
|
|
268
|
+
disabled={link.busy}
|
|
269
|
+
className={PRIMARY}
|
|
270
|
+
>
|
|
271
|
+
Continue with Face ID or Touch ID
|
|
272
|
+
</button>
|
|
273
|
+
</div>
|
|
274
|
+
<p className={META}>
|
|
275
|
+
Your messages and data stay in this app. Linking only lets this device send
|
|
276
|
+
requests to the agents on your computer.
|
|
277
|
+
</p>
|
|
278
|
+
</section>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (link.onlyComputer) {
|
|
282
|
+
return (
|
|
283
|
+
<section aria-label="Link this device" className={NOTE}>
|
|
284
|
+
<p className="font-medium">Link this device</p>
|
|
285
|
+
<p>
|
|
286
|
+
No other device is linked yet, so link this one from {link.hostName}. Run this
|
|
287
|
+
in a terminal on it:
|
|
288
|
+
</p>
|
|
289
|
+
<code className="block rounded bg-zinc-100 p-2 text-xs wrap-anywhere">
|
|
290
|
+
{link.command}
|
|
291
|
+
</code>
|
|
292
|
+
<p className={META}>The code works for 15 minutes.</p>
|
|
293
|
+
</section>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return (
|
|
297
|
+
<section aria-label="Link this device" className={NOTE}>
|
|
298
|
+
<p className="font-medium">Link this device</p>
|
|
299
|
+
<p>
|
|
300
|
+
Scan this with a phone you already use with your agents, or on any device you
|
|
301
|
+
already use open the Agent sidebar, choose Link a device, and type the code.
|
|
302
|
+
</p>
|
|
303
|
+
{link.qr ? <LinkQr value={link.qr} /> : null}
|
|
304
|
+
<p
|
|
305
|
+
data-testid="link-code"
|
|
306
|
+
className="py-1 text-center font-mono text-2xl font-semibold tracking-widest"
|
|
307
|
+
>
|
|
308
|
+
{formatCode(link.code)}
|
|
309
|
+
</p>
|
|
310
|
+
<p className={META}>No linked device with you? On {link.hostName}, run:</p>
|
|
311
|
+
<code className="block rounded bg-zinc-100 p-2 text-xs wrap-anywhere">
|
|
312
|
+
{link.command}
|
|
313
|
+
</code>
|
|
314
|
+
<p className={META}>The code works for 15 minutes.</p>
|
|
315
|
+
</section>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function DeviceTools() {
|
|
320
|
+
const { agents, link } = useChat();
|
|
321
|
+
const linking = useLinkDevice();
|
|
322
|
+
const devices = useDevices();
|
|
323
|
+
const [panel, setPanel] = useState<"link" | "devices" | undefined>(undefined);
|
|
324
|
+
if (agents.length === 0 || link.status !== "linked") return null;
|
|
325
|
+
const close = () => setPanel(undefined);
|
|
326
|
+
if (panel === "link") return <LinkADevice onClose={close} />;
|
|
327
|
+
if (panel === "devices") return <Devices onClose={close} />;
|
|
328
|
+
if (!linking.available && !devices.available) return null;
|
|
329
|
+
return (
|
|
330
|
+
<div className="flex gap-2">
|
|
331
|
+
{linking.available ? (
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
onClick={() => setPanel("link")}
|
|
335
|
+
className={`${BUTTON} flex-1`}
|
|
336
|
+
>
|
|
337
|
+
Link a device
|
|
338
|
+
</button>
|
|
339
|
+
) : null}
|
|
340
|
+
{devices.available ? (
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
onClick={() => setPanel("devices")}
|
|
344
|
+
className={`${BUTTON} flex-1`}
|
|
345
|
+
>
|
|
346
|
+
Devices
|
|
347
|
+
</button>
|
|
348
|
+
) : null}
|
|
349
|
+
</div>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function LinkADevice({ onClose }: { readonly onClose: () => void }) {
|
|
354
|
+
const { state, check, confirm, reset } = useLinkDevice();
|
|
355
|
+
const { agents } = useChat();
|
|
356
|
+
const session = useSession();
|
|
357
|
+
const [code, setCode] = useState("");
|
|
358
|
+
const computer = agents[0]?.hostName ?? "your computer";
|
|
359
|
+
const account = session.status === "signed-in" ? session.user.email : "your account";
|
|
360
|
+
const close = () => {
|
|
361
|
+
reset();
|
|
362
|
+
setCode("");
|
|
363
|
+
onClose();
|
|
364
|
+
};
|
|
365
|
+
return (
|
|
366
|
+
<section
|
|
367
|
+
aria-label="Link a device"
|
|
368
|
+
className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm"
|
|
369
|
+
>
|
|
370
|
+
{state.step === "confirm" ? (
|
|
371
|
+
<>
|
|
372
|
+
<p className="font-medium">Link {state.device.name}?</p>
|
|
373
|
+
<p>
|
|
374
|
+
It will be able to send requests to your agents on {computer}, signed in as{" "}
|
|
375
|
+
{account}. Only link a device you have with you.
|
|
376
|
+
</p>
|
|
377
|
+
<div className="flex justify-end gap-2">
|
|
378
|
+
<button type="button" onClick={close} className={BUTTON}>
|
|
379
|
+
Cancel
|
|
380
|
+
</button>
|
|
381
|
+
<button type="button" onClick={() => void confirm()} className={PRIMARY}>
|
|
382
|
+
Link with Face ID or Touch ID
|
|
383
|
+
</button>
|
|
384
|
+
</div>
|
|
385
|
+
</>
|
|
386
|
+
) : state.step === "linking" ? (
|
|
387
|
+
<p className={META}>Confirm with Face ID or Touch ID…</p>
|
|
388
|
+
) : state.step === "linked" ? (
|
|
389
|
+
<>
|
|
390
|
+
<p className="font-medium">{state.name} is linked</p>
|
|
391
|
+
<p>It can use your agents now.</p>
|
|
392
|
+
<div className="flex justify-end">
|
|
393
|
+
<button type="button" onClick={close} className={PRIMARY}>
|
|
394
|
+
Done
|
|
395
|
+
</button>
|
|
396
|
+
</div>
|
|
397
|
+
</>
|
|
398
|
+
) : state.step === "on-computer" ? (
|
|
399
|
+
<>
|
|
400
|
+
<p className="font-medium">
|
|
401
|
+
Link {state.device.name} on {computer}
|
|
402
|
+
</p>
|
|
403
|
+
<p>This browser cannot confirm with Face ID or Touch ID. On {computer}, run:</p>
|
|
404
|
+
<code className="block rounded bg-zinc-100 p-2 text-xs wrap-anywhere">
|
|
405
|
+
{state.command}
|
|
406
|
+
</code>
|
|
407
|
+
<div className="flex justify-end">
|
|
408
|
+
<button type="button" onClick={close} className={BUTTON}>
|
|
409
|
+
Done
|
|
410
|
+
</button>
|
|
411
|
+
</div>
|
|
412
|
+
</>
|
|
413
|
+
) : (
|
|
414
|
+
<form
|
|
415
|
+
className="space-y-2"
|
|
416
|
+
onSubmit={(event) => {
|
|
417
|
+
event.preventDefault();
|
|
418
|
+
void check(code);
|
|
419
|
+
}}
|
|
420
|
+
>
|
|
421
|
+
<p className="font-medium">Link a device</p>
|
|
422
|
+
<label className="block space-y-1">
|
|
423
|
+
<span className={META}>The code the new device shows</span>
|
|
424
|
+
<input
|
|
425
|
+
name="link-code"
|
|
426
|
+
value={code}
|
|
427
|
+
onChange={(event) => setCode(event.target.value)}
|
|
428
|
+
autoComplete="off"
|
|
429
|
+
autoCapitalize="characters"
|
|
430
|
+
spellCheck={false}
|
|
431
|
+
placeholder="K7M4-PX2Q-8R"
|
|
432
|
+
className="w-full rounded-md border border-zinc-300 px-3 py-2 font-mono text-sm uppercase tracking-widest"
|
|
433
|
+
/>
|
|
434
|
+
</label>
|
|
435
|
+
{state.step === "error" ? (
|
|
436
|
+
<p role="alert" className="text-xs text-red-600">
|
|
437
|
+
{state.message}
|
|
438
|
+
</p>
|
|
439
|
+
) : null}
|
|
440
|
+
<div className="flex justify-end gap-2">
|
|
441
|
+
<button type="button" onClick={close} className={BUTTON}>
|
|
442
|
+
Cancel
|
|
443
|
+
</button>
|
|
444
|
+
<button
|
|
445
|
+
type="submit"
|
|
446
|
+
disabled={code.trim() === "" || state.step === "checking"}
|
|
447
|
+
className={PRIMARY}
|
|
448
|
+
>
|
|
449
|
+
{state.step === "checking" ? "Checking…" : "Link"}
|
|
450
|
+
</button>
|
|
451
|
+
</div>
|
|
452
|
+
</form>
|
|
453
|
+
)}
|
|
454
|
+
</section>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const shortDate = (at: string) =>
|
|
459
|
+
new Date(at).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
460
|
+
|
|
461
|
+
function Devices({ onClose }: { readonly onClose: () => void }) {
|
|
462
|
+
const { devices, removal, remove, reset } = useDevices();
|
|
463
|
+
const { hosts } = useChat();
|
|
464
|
+
const [confirming, setConfirming] = useState<string | undefined>(undefined);
|
|
465
|
+
const reporting = hosts.filter((host) => host.devices !== undefined);
|
|
466
|
+
const nameOf = (id: string | undefined) =>
|
|
467
|
+
devices?.find((device) => device.id === id)?.name;
|
|
468
|
+
const close = () => {
|
|
469
|
+
reset();
|
|
470
|
+
onClose();
|
|
471
|
+
};
|
|
472
|
+
return (
|
|
473
|
+
<section
|
|
474
|
+
aria-label="Devices"
|
|
475
|
+
className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm"
|
|
476
|
+
>
|
|
477
|
+
<div className="flex items-center justify-between">
|
|
478
|
+
<p className="font-medium">Devices</p>
|
|
479
|
+
<button type="button" onClick={close} className={META}>
|
|
480
|
+
Close
|
|
481
|
+
</button>
|
|
482
|
+
</div>
|
|
483
|
+
{devices === undefined ? (
|
|
484
|
+
<p className={META}>Loading…</p>
|
|
485
|
+
) : (
|
|
486
|
+
<ul className="divide-y divide-zinc-100">
|
|
487
|
+
{devices.map((device) => {
|
|
488
|
+
const linkedHere = reporting.some((host) =>
|
|
489
|
+
host.devices!.includes(device.id),
|
|
490
|
+
);
|
|
491
|
+
const from = nameOf(device.linkedBy);
|
|
492
|
+
const busy = removal.step === "removing" && removal.id === device.id;
|
|
493
|
+
return (
|
|
494
|
+
<li key={device.id} className="space-y-1 py-2">
|
|
495
|
+
<div className="flex items-start justify-between gap-2">
|
|
496
|
+
<div className="min-w-0">
|
|
497
|
+
<p className="wrap-anywhere">
|
|
498
|
+
{device.name}
|
|
499
|
+
{device.current ? (
|
|
500
|
+
<span className={`${META} ml-1`}>This device</span>
|
|
501
|
+
) : null}
|
|
502
|
+
</p>
|
|
503
|
+
<p className={META}>
|
|
504
|
+
{from
|
|
505
|
+
? `Linked from ${from}`
|
|
506
|
+
: `Added ${shortDate(device.createdAt)}`}
|
|
507
|
+
{reporting.length > 0 && !linkedHere ? " · Not linked" : ""}
|
|
508
|
+
</p>
|
|
509
|
+
</div>
|
|
510
|
+
{confirming === device.id ? null : (
|
|
511
|
+
<button
|
|
512
|
+
type="button"
|
|
513
|
+
onClick={() => {
|
|
514
|
+
reset();
|
|
515
|
+
setConfirming(device.id);
|
|
516
|
+
}}
|
|
517
|
+
className={BUTTON}
|
|
518
|
+
>
|
|
519
|
+
Remove
|
|
520
|
+
</button>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
{confirming === device.id ? (
|
|
524
|
+
removal.step === "on-computer" && removal.id === device.id ? (
|
|
525
|
+
<>
|
|
526
|
+
<p>
|
|
527
|
+
This browser cannot confirm with Face ID or Touch ID. On your
|
|
528
|
+
computer, run:
|
|
529
|
+
</p>
|
|
530
|
+
<code className="block rounded bg-zinc-100 p-2 text-xs wrap-anywhere">
|
|
531
|
+
{removal.command}
|
|
532
|
+
</code>
|
|
533
|
+
<div className="flex justify-end">
|
|
534
|
+
<button
|
|
535
|
+
type="button"
|
|
536
|
+
onClick={() => setConfirming(undefined)}
|
|
537
|
+
className={BUTTON}
|
|
538
|
+
>
|
|
539
|
+
Done
|
|
540
|
+
</button>
|
|
541
|
+
</div>
|
|
542
|
+
</>
|
|
543
|
+
) : (
|
|
544
|
+
<>
|
|
545
|
+
<p>
|
|
546
|
+
{device.current
|
|
547
|
+
? "Remove this device? It will need to be linked again to use your agents."
|
|
548
|
+
: `Remove ${device.name}? It will no longer be able to use your agents.`}
|
|
549
|
+
</p>
|
|
550
|
+
{removal.step === "error" && removal.id === device.id ? (
|
|
551
|
+
<p role="alert" className="text-xs text-red-600">
|
|
552
|
+
{removal.message}
|
|
553
|
+
</p>
|
|
554
|
+
) : null}
|
|
555
|
+
<div className="flex justify-end gap-2">
|
|
556
|
+
<button
|
|
557
|
+
type="button"
|
|
558
|
+
onClick={() => {
|
|
559
|
+
reset();
|
|
560
|
+
setConfirming(undefined);
|
|
561
|
+
}}
|
|
562
|
+
className={BUTTON}
|
|
563
|
+
>
|
|
564
|
+
Cancel
|
|
565
|
+
</button>
|
|
566
|
+
<button
|
|
567
|
+
type="button"
|
|
568
|
+
disabled={busy}
|
|
569
|
+
onClick={() => void remove(device.id)}
|
|
570
|
+
className={PRIMARY}
|
|
571
|
+
>
|
|
572
|
+
{busy
|
|
573
|
+
? "Confirm with Face ID or Touch ID…"
|
|
574
|
+
: "Remove with Face ID or Touch ID"}
|
|
575
|
+
</button>
|
|
576
|
+
</div>
|
|
577
|
+
</>
|
|
578
|
+
)
|
|
579
|
+
) : null}
|
|
580
|
+
</li>
|
|
581
|
+
);
|
|
582
|
+
})}
|
|
583
|
+
</ul>
|
|
584
|
+
)}
|
|
585
|
+
</section>
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function LinkedNotices() {
|
|
590
|
+
const { linked, removal, remove, dismiss } = useDevices();
|
|
591
|
+
if (linked.length === 0) return null;
|
|
592
|
+
return (
|
|
593
|
+
<div className="space-y-2">
|
|
594
|
+
{linked.map((device) => {
|
|
595
|
+
const failed =
|
|
596
|
+
(removal.step === "error" || removal.step === "on-computer") &&
|
|
597
|
+
removal.id === device.id
|
|
598
|
+
? removal
|
|
599
|
+
: undefined;
|
|
600
|
+
return (
|
|
601
|
+
<div
|
|
602
|
+
key={device.id}
|
|
603
|
+
role="status"
|
|
604
|
+
className="space-y-2 rounded-lg bg-zinc-900 p-3 text-sm text-white"
|
|
605
|
+
>
|
|
606
|
+
<div className="flex items-center justify-between gap-2">
|
|
607
|
+
<p className="min-w-0 wrap-anywhere">
|
|
608
|
+
{device.name} was linked to your agents.
|
|
609
|
+
</p>
|
|
610
|
+
<div className="flex shrink-0 gap-2">
|
|
611
|
+
<button
|
|
612
|
+
type="button"
|
|
613
|
+
disabled={removal.step === "removing"}
|
|
614
|
+
onClick={() =>
|
|
615
|
+
void remove(device.id).then((removed) => {
|
|
616
|
+
if (removed) dismiss(device.id);
|
|
617
|
+
})
|
|
618
|
+
}
|
|
619
|
+
className="rounded-md px-2 py-1 text-zinc-300 hover:text-white"
|
|
620
|
+
>
|
|
621
|
+
Remove
|
|
622
|
+
</button>
|
|
623
|
+
<button
|
|
624
|
+
type="button"
|
|
625
|
+
onClick={() => dismiss(device.id)}
|
|
626
|
+
className="rounded-md bg-white px-2 py-1 font-medium text-zinc-900"
|
|
627
|
+
>
|
|
628
|
+
OK
|
|
629
|
+
</button>
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
{failed?.step === "error" ? (
|
|
633
|
+
<p role="alert" className="text-xs text-red-300">
|
|
634
|
+
{failed.message}
|
|
635
|
+
</p>
|
|
636
|
+
) : failed?.step === "on-computer" ? (
|
|
637
|
+
<>
|
|
638
|
+
<p className="text-xs">
|
|
639
|
+
This browser cannot confirm with Face ID or Touch ID. On your computer,
|
|
640
|
+
run:
|
|
641
|
+
</p>
|
|
642
|
+
<code className="block rounded bg-zinc-800 p-2 text-xs wrap-anywhere">
|
|
643
|
+
{failed.command}
|
|
644
|
+
</code>
|
|
645
|
+
</>
|
|
646
|
+
) : null}
|
|
647
|
+
</div>
|
|
648
|
+
);
|
|
649
|
+
})}
|
|
650
|
+
</div>
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function Composer() {
|
|
655
|
+
const { error, draft } = useChat();
|
|
656
|
+
const problem = isPromptTooLong(draft) ? PROMPT_LIMIT_MESSAGE : error;
|
|
657
|
+
return (
|
|
658
|
+
<Chat.Composer className="space-y-2 border-t border-zinc-200 p-4">
|
|
659
|
+
<Chat.Input
|
|
660
|
+
name="message"
|
|
661
|
+
aria-label="Message"
|
|
662
|
+
placeholder="Message your agent"
|
|
663
|
+
rows={2}
|
|
664
|
+
className="w-full resize-none rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none"
|
|
665
|
+
/>
|
|
666
|
+
{problem ? (
|
|
667
|
+
<p role="alert" className="text-xs text-red-600">
|
|
668
|
+
{problem}
|
|
669
|
+
</p>
|
|
670
|
+
) : null}
|
|
671
|
+
<div className="flex items-center gap-2">
|
|
672
|
+
<Chat.Status className={`min-w-0 flex-1 truncate ${META}`} />
|
|
673
|
+
<Chat.Stop className={BUTTON}>Stop</Chat.Stop>
|
|
674
|
+
<Chat.Send className={PRIMARY}>Send</Chat.Send>
|
|
675
|
+
</div>
|
|
676
|
+
</Chat.Composer>
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const TURN_STATE: Partial<Record<TurnState, string>> = {
|
|
681
|
+
queued: "Queued",
|
|
682
|
+
cancelled: "Stopped",
|
|
683
|
+
failed: "Failed",
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const TOOL_STATE = {
|
|
687
|
+
pending: "Waiting",
|
|
688
|
+
running: "Running",
|
|
689
|
+
done: "Done",
|
|
690
|
+
failed: "Failed",
|
|
691
|
+
cancelled: "Stopped",
|
|
692
|
+
} as const;
|
|
693
|
+
|
|
694
|
+
const EVENTS = {
|
|
695
|
+
device_rejected: "This device is not linked",
|
|
696
|
+
agent_started: "Agent started",
|
|
697
|
+
agent_exited: "Agent exited",
|
|
698
|
+
agent_switched: "Switched agent",
|
|
699
|
+
host_lost: "Lost contact with the computer",
|
|
700
|
+
turn_failed: "The turn failed",
|
|
701
|
+
answer_rejected: "Your answer was not accepted",
|
|
702
|
+
} as const;
|
|
703
|
+
|
|
704
|
+
type PromptItem = Extract<ChatItem<AgentConfirmation>, { kind: "prompt" }>;
|
|
705
|
+
type AskItem = Extract<ChatItem<AgentConfirmation>, { kind: "ask" }>;
|
|
706
|
+
|
|
707
|
+
function PromptMeta({ item }: { readonly item: PromptItem }) {
|
|
708
|
+
const { resend, hosts } = useChat();
|
|
709
|
+
if (item.failure === "prompt_expired") {
|
|
710
|
+
const hostName = hosts.find((host) => host.id === item.agent.hostId)?.name;
|
|
711
|
+
return (
|
|
712
|
+
<div className="flex items-center gap-2">
|
|
713
|
+
<p
|
|
714
|
+
className={META}
|
|
715
|
+
>{`Expired before ${hostName ?? "your computer"} picked it up`}</p>
|
|
716
|
+
<button type="button" onClick={() => void resend(item.id)} className={BUTTON}>
|
|
717
|
+
Resend
|
|
718
|
+
</button>
|
|
719
|
+
</div>
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
const text = TURN_STATE[item.state];
|
|
723
|
+
return text ? <p className={META}>{text}</p> : null;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function AskCard({ item }: { readonly item: AskItem }) {
|
|
727
|
+
const choices = (
|
|
728
|
+
<div className="flex flex-wrap gap-2">
|
|
729
|
+
{item.choices.map((choice) =>
|
|
730
|
+
item.form && choice.effect.startsWith("allow") ? (
|
|
731
|
+
<button
|
|
732
|
+
key={choice.id}
|
|
733
|
+
type="submit"
|
|
734
|
+
onClick={focusComposer}
|
|
735
|
+
className={PRIMARY}
|
|
736
|
+
>
|
|
737
|
+
{choice.label}
|
|
738
|
+
</button>
|
|
739
|
+
) : (
|
|
740
|
+
<Chat.AskChoice
|
|
741
|
+
key={choice.id}
|
|
742
|
+
choice={choice}
|
|
743
|
+
onClick={focusComposer}
|
|
744
|
+
className={choice.effect.startsWith("allow") ? PRIMARY : BUTTON}
|
|
745
|
+
>
|
|
746
|
+
{choice.label}
|
|
747
|
+
</Chat.AskChoice>
|
|
748
|
+
),
|
|
749
|
+
)}
|
|
750
|
+
</div>
|
|
751
|
+
);
|
|
752
|
+
return (
|
|
753
|
+
<Chat.Ask
|
|
754
|
+
item={item}
|
|
755
|
+
className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm"
|
|
756
|
+
>
|
|
757
|
+
<p>{item.question}</p>
|
|
758
|
+
{item.answer.state === "answered" ? (
|
|
759
|
+
<p className={META}>Answered</p>
|
|
760
|
+
) : item.answer.state === "closed" ? (
|
|
761
|
+
<p className={META}>No longer needed</p>
|
|
762
|
+
) : item.form ? (
|
|
763
|
+
<Chat.AskForm className="space-y-2">{choices}</Chat.AskForm>
|
|
764
|
+
) : (
|
|
765
|
+
choices
|
|
766
|
+
)}
|
|
767
|
+
</Chat.Ask>
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function Item({ item }: { readonly item: ChatItem<AgentConfirmation> }) {
|
|
772
|
+
switch (item.kind) {
|
|
773
|
+
case "prompt":
|
|
774
|
+
return (
|
|
775
|
+
<div className="ml-8 space-y-1">
|
|
776
|
+
<p className="rounded-lg bg-zinc-900 px-3 py-2 text-sm text-white">
|
|
777
|
+
{item.text}
|
|
778
|
+
</p>
|
|
779
|
+
<PromptMeta item={item} />
|
|
780
|
+
</div>
|
|
781
|
+
);
|
|
782
|
+
case "reply":
|
|
783
|
+
return (
|
|
784
|
+
<div className="space-y-1">
|
|
785
|
+
<p className="whitespace-pre-wrap text-sm">{item.text}</p>
|
|
786
|
+
{item.interrupted ? <p className={META}>Interrupted</p> : null}
|
|
787
|
+
</div>
|
|
788
|
+
);
|
|
789
|
+
case "reasoning":
|
|
790
|
+
return <p className="text-xs italic text-zinc-500">{item.text}</p>;
|
|
791
|
+
case "tool":
|
|
792
|
+
return (
|
|
793
|
+
<p className="flex gap-2 text-xs" data-status={item.status}>
|
|
794
|
+
<span className="font-mono">{item.title}</span>
|
|
795
|
+
<span className="text-zinc-500">{TOOL_STATE[item.status]}</span>
|
|
796
|
+
</p>
|
|
797
|
+
);
|
|
798
|
+
case "plan":
|
|
799
|
+
return (
|
|
800
|
+
<ul className="list-disc space-y-1 pl-5 text-sm">
|
|
801
|
+
{item.entries.map((entry, index) => (
|
|
802
|
+
<li key={`${index}:${entry.content}`} data-status={entry.status}>
|
|
803
|
+
{entry.content}
|
|
804
|
+
</li>
|
|
805
|
+
))}
|
|
806
|
+
</ul>
|
|
807
|
+
);
|
|
808
|
+
case "ask":
|
|
809
|
+
return <AskCard item={item} />;
|
|
810
|
+
case "confirm":
|
|
811
|
+
return <ConfirmationCard confirmation={item.confirmation} />;
|
|
812
|
+
case "event":
|
|
813
|
+
if (item.event === "device_pending") return null;
|
|
814
|
+
return (
|
|
815
|
+
<p className={META}>
|
|
816
|
+
{EVENTS[item.event]}
|
|
817
|
+
{item.detail ? `: ${item.detail}` : ""}
|
|
818
|
+
</p>
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
}
|