@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.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -2
  3. package/dist/add/addon.d.ts +101 -0
  4. package/dist/add/addon.d.ts.map +1 -0
  5. package/dist/add/addon.js +6 -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 +61 -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 +106 -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 +165 -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 +66 -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 +34 -0
  56. package/dist/edit/syntax.d.ts.map +1 -0
  57. package/dist/edit/syntax.js +114 -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 +20 -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 +821 -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 +24 -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 +28 -0
  123. package/templates/default/src/routes/index.tsx +136 -0
  124. package/templates/default/tsconfig.json +8 -0
  125. 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
+ }