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