1import {
2 EnvironmentId,
3 GUEST_MESSAGE_MAX_LENGTH,
4 GUEST_HISTORY_ROUTE,
5 GUEST_NAME_MAX_LENGTH,
6 GUEST_ROUTE_PREFIX,
7 GUEST_SHARE_PARAM,
8 GuestMessageId,
9 type GuestRedeemRequest,
10 type GuestShareView,
11 type GuestThreadModel,
12 OrchestrationV2ThreadHistoryPage,
13 type OrchestrationV2ThreadProjection,
14 type ThreadGuestMessage,
15 type ThreadGuestMessageStatus,
16} from "@t3tools/contracts";
17import type { LegendListRef } from "@legendapp/list/react";
18import * as Option from "effect/Option";
19import * as Schema from "effect/Schema";
20import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
21import {
22 deriveRunlessWorkStartedAt,
23 deriveThreadActivityRun,
24 deriveThreadStructureRuntime,
25} from "@t3tools/client-runtime/state/thread-execution";
26import { createQuestionHistoryProjector } from "@t3tools/client-runtime/state/thread-requests";
27import { ArrowUpIcon, PaperclipIcon, XIcon } from "lucide-react";
28import {
29 useEffect,
30 useReducer,
31 useRef,
32 useState,
33 type ClipboardEvent,
34 type FormEvent,
35 type KeyboardEvent,
36} from "react";
37
38import { useIncrementalTimelineEntries } from "~/hooks/useIncrementalTimelineEntries";
39import { useNowMinute } from "~/hooks/useNowMinute";
40import { useStableCallback } from "~/hooks/useStableCallback";
41import { useTheme } from "~/hooks/useTheme";
42import { cn } from "~/lib/utils";
43import { deriveActiveWorkStartedAt, derivePhase } from "~/session-logic";
44import { ComposerControl, ComposerControlIcon } from "../chat/ComposerControl";
45import type { ExpandedImagePreview } from "../chat/ExpandedImagePreview";
46import { GuestMessageAttachments } from "../chat/GuestMessageAttachments";
47import { HostImageUrlContext } from "../chat/hostImageUrl";
48import { PierreEntryIcon } from "../chat/PierreEntryIcon";
49import { GuestMessageName, guestBubbleClassName } from "../chat/guestMessageStyle";
50import { MessagesTimeline, type MessagesTimelineHistoryControls } from "../chat/MessagesTimeline";
51import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
52import { getDisplayModelName, getTriggerDisplayModelName } from "../chat/providerIconUtils";
53import { ScrollToEndButton } from "../chat/TimelineOverlays";
54import type { TimelineRow } from "../chat/timelineRowContext";
55import { useTimelineFollow } from "../chat/useTimelineFollow";
56import { GuestHtmlRender } from "./GuestHtmlRender";
57import { Button } from "../ui/button";
58import { Input } from "../ui/input";
59import {
60 applyGuestStreamEvent,
61 formatGuestExpiry,
62 guestAttachmentUrl,
63 guestMediaUrl,
64 createGuestAttachmentUrls,
65 guestFilesProblem,
66 initialGuestStreamState,
67 inviteShareId,
68 type GuestStreamState,
69 parseGuestStreamEvent,
70 unplacedGuestMessages,
71} from "./guestStream.logic";
72import { guestTurnItemSource } from "./guestTurnItemSource";
73
74interface GuestEntry {
75 /** Unspent invite from the URL hash, if this load came from a link. */
76 readonly credential: string | null;
77 readonly shareId: string | null;
78}
79
80let capturedEntry: GuestEntry | null = null;
81
82// A reload keeps the tab's session, so it keeps which messages still wait for a row too.
83const AWAITING_ROWS_KEY = "t3:guest-awaiting-rows";
84const AWAITING_ROWS_LIMIT = 50;
85
86function readAwaitingRowIds(): ReadonlySet<GuestMessageId> {
87 try {
88 const stored: unknown = JSON.parse(sessionStorage.getItem(AWAITING_ROWS_KEY) ?? "[]");
89 return new Set(
90 Array.isArray(stored)
91 ? stored.filter((id) => typeof id === "string").map((id) => GuestMessageId.make(id))
92 : [],
93 );
94 } catch {
95 return new Set();
96 }
97}
98
99function writeAwaitingRowIds(ids: ReadonlySet<GuestMessageId>) {
100 try {
101 sessionStorage.setItem(AWAITING_ROWS_KEY, JSON.stringify([...ids].slice(-AWAITING_ROWS_LIMIT)));
102 } catch {}
103}
104
105/**
106 * The invite credential travels in the URL hash so it never reaches a server log. It is read
107 * once and removed from history, so it lives only in memory. Opening the page never redeems it:
108 * only submitting a name does, so a reload before that leaves the link usable.
109 */
110function takeGuestEntry(): GuestEntry {
111 if (capturedEntry === null) {
112 // A malformed fragment is an invalid link, not a crash.
113 let credential = "";
114 try {
115 credential = decodeURIComponent(window.location.hash.slice(1)).trim();
116 } catch {}
117 const shareId =
118 credential !== ""
119 ? inviteShareId(credential)
120 : new URLSearchParams(window.location.search).get(GUEST_SHARE_PARAM);
121 // The share stays in the URL, so a reload or bookmark finds this browser's session for it.
122 window.history.replaceState(
123 window.history.state,
124 "",
125 shareId === null
126 ? window.location.pathname
127 : `${window.location.pathname}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}`,
128 );
129 capturedEntry = { credential: credential === "" ? null : credential, shareId };
130 }
131 return capturedEntry;
132}
133
134const decodeErrorBody = Schema.decodeUnknownOption(Schema.Struct({ message: Schema.String }));
135const decodeHistoryPage = Schema.decodeUnknownOption(
136 Schema.toCodecJson(OrchestrationV2ThreadHistoryPage),
137);
138
139/** Resolves to the page, or to a message the guest can read. */
140async function fetchGuestHistoryPage(
141 shareId: string,
142 cursor: string,
143): Promise<OrchestrationV2ThreadHistoryPage | string> {
144 const failed = "Could not load earlier turns. Try again.";
145 try {
146 const response = await fetch(
147 `${GUEST_HISTORY_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&cursor=${encodeURIComponent(cursor)}`,
148 );
149 const json: unknown = await response.json().catch(() => null);
150 if (!response.ok) {
151 return Option.match(decodeErrorBody(json), {
152 onNone: () => failed,
153 onSome: ({ message }) => message,
154 });
155 }
156 return Option.getOrElse(decodeHistoryPage(json), () => failed);
157 } catch {
158 return "Could not reach the server.";
159 }
160}
161
162/** Resolves to null on success, or to a message the guest can read. */
163async function postGuest(
164 path: "redeem" | `messages?${string}`,
165 body: typeof GuestRedeemRequest.Encoded | FormData,
166): Promise<string | null> {
167 try {
168 const response = await fetch(
169 `${GUEST_ROUTE_PREFIX}/${path}`,
170 body instanceof FormData
171 ? { method: "POST", body }
172 : {
173 method: "POST",
174 headers: { "content-type": "application/json" },
175 body: JSON.stringify(body),
176 },
177 );
178 if (response.ok) return null;
179 const json: unknown = await response.json().catch(() => null);
180 return Option.match(decodeErrorBody(json), {
181 onNone: () => "Something went wrong. Try again.",
182 onSome: ({ message }) => message,
183 });
184 } catch {
185 return "Could not reach the server.";
186 }
187}
188
189const STATUS_LABEL: Record<ThreadGuestMessageStatus, string> = {
190 pending: "Waiting for approval",
191 sent: "Sent",
192 dismissed: "Not sent",
193};
194
195/** Same column as the owner's timeline, so both views wrap text at the same width. */
196const LANE = "chat-content-lane px-4";
197
198type GuestAccess = "checking" | "session" | "name" | "none";
199
200export function GuestPage() {
201 const [{ credential, shareId }] = useState(takeGuestEntry);
202 // A browser that already joined this share goes straight back in, even from the spent link.
203 // Sessions are per share, so one for another link never stands in for this one.
204 const [access, setAccess] = useState<GuestAccess>(shareId === null ? "none" : "checking");
205 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fetches whether this browser already has a guest session and ignores the answer after cleanup
206 useEffect(() => {
207 if (shareId === null) return;
208 let live = true;
209 void fetch(`${GUEST_ROUTE_PREFIX}/session?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}`)
210 .then(
211 (response): GuestAccess =>
212 response.ok ? "session" : credential !== null ? "name" : "none",
213 // Offline: an invite still needs the name form; without one, the stream reports what
214 // it finds once the network is back.
215 (): GuestAccess => (credential !== null ? "name" : "session"),
216 )
217 .then((next) => {
218 if (live) setAccess(next);
219 });
220 return () => {
221 live = false;
222 };
223 }, [credential, shareId]);
224 // Pasting another guest link into this tab changes only the hash, which does not reload the
225 // page; reload so the new invite is read instead of the one already captured.
226 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- hashchange listener reloads the page when another guest link is pasted into this tab
227 useEffect(() => {
228 const onHashChange = () => {
229 if (window.location.hash !== "") window.location.reload();
230 };
231 window.addEventListener("hashchange", onHashChange);
232 return () => window.removeEventListener("hashchange", onHashChange);
233 }, []);
234
235 return (
236 <div className="flex h-dvh flex-col bg-background text-foreground">
237 {access === "checking" ? (
238 <Notice title="Connecting..." />
239 ) : access === "name" && credential !== null ? (
240 <NameForm
241 credential={credential}
242 onJoined={() => {
243 // The invite is spent; a remount must open the session, not the name form again.
244 capturedEntry = { credential: null, shareId };
245 setAccess("session");
246 }}
247 />
248 ) : access === "session" && shareId !== null ? (
249 <GuestSession shareId={shareId} />
250 ) : (
251 <Notice
252 title="This link has ended or is not valid"
253 detail="Ask the owner for a new link. Each link works in one browser."
254 />
255 )}
256 </div>
257 );
258}
259
260function GuestSession({ shareId }: { readonly shareId: string }) {
261 const [stream, dispatch] = useReducer(applyGuestStreamEvent, null, () => ({
262 ...initialGuestStreamState,
263 awaitingRowIds: readAwaitingRowIds(),
264 }));
265 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- persists the awaiting row ids to sessionStorage so a reload keeps them
266 useEffect(() => writeAwaitingRowIds(stream.awaitingRowIds), [stream.awaitingRowIds]);
267 // EventSource closes for good only on a non-200 response, which the guest routes send for a
268 // missing, invalid, or ended session. Network drops reconnect on their own.
269 const [rejected, setRejected] = useState(false);
270
271 const [historyLoad, setHistoryLoad] = useState<{
272 readonly cursor: string;
273 readonly error: string | null;
274 } | null>(null);
275 const historyCursor = stream.historyCursor;
276 // A load for an older cursor is stale: a page or a reconnect snapshot moved the cursor on.
277 const currentLoad = historyLoad?.cursor === historyCursor ? historyLoad : null;
278 const loadingHistory = currentLoad !== null && currentLoad.error === null;
279 const historyError = currentLoad?.error ?? null;
280 const loadEarlier = async () => {
281 if (historyCursor === null || loadingHistory) return;
282 setHistoryLoad({ cursor: historyCursor, error: null });
283 const result = await fetchGuestHistoryPage(shareId, historyCursor);
284 if (typeof result === "string") setHistoryLoad({ cursor: historyCursor, error: result });
285 else dispatch({ type: "history-page", cursor: historyCursor, page: result });
286 };
287 const historyControls: MessagesTimelineHistoryControls | undefined =
288 historyCursor === null
289 ? undefined
290 : {
291 hasMoreHistory: true,
292 loading: loadingHistory,
293 error: historyError,
294 onLoadEarlier: () => void loadEarlier(),
295 };
296
297 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- sets document.title to the shared thread's title
298 useEffect(() => {
299 if (stream.view) document.title = stream.view.title;
300 }, [stream.view]);
301
302 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens the guest EventSource stream and closes it on cleanup
303 useEffect(() => {
304 const source = new EventSource(
305 `${GUEST_ROUTE_PREFIX}/stream?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}`,
306 );
307 source.addEventListener("message", (message) => {
308 const event = parseGuestStreamEvent(message.data);
309 if (event === null) return;
310 dispatch(event);
311 if (event.type === "ended") source.close();
312 });
313 source.addEventListener("error", () => {
314 if (source.readyState === EventSource.CLOSED) setRejected(true);
315 });
316 return () => source.close();
317 }, [shareId]);
318
319 if (stream.ended || (rejected && stream.view !== null)) {
320 return <Notice title="This link has ended" detail="The owner stopped sharing this thread." />;
321 }
322 if (stream.view !== null) {
323 if (stream.projection === null) return <Notice title="Connecting..." />;
324 return (
325 <GuestThread
326 view={stream.view}
327 projection={stream.projection}
328 awaitingRowIds={stream.awaitingRowIds}
329 historyControls={historyControls}
330 />
331 );
332 }
333 if (!rejected) return <Notice title="Connecting..." />;
334 return (
335 <Notice
336 title="This link has ended or is not valid"
337 detail="Ask the owner for a new link. Each link works in one browser."
338 />
339 );
340}
341
342function Notice({ title, detail }: { readonly title: string; readonly detail?: string }) {
343 return (
344 <div className="m-auto flex max-w-sm flex-col gap-1 p-6 text-center">
345 <p className="text-sm font-medium">{title}</p>
346 {detail ? <p className="text-xs text-muted-foreground">{detail}</p> : null}
347 </div>
348 );
349}
350
351function NameForm({
352 credential,
353 onJoined,
354}: {
355 readonly credential: string;
356 readonly onJoined: () => void;
357}) {
358 const [name, setName] = useState("");
359 const [submitting, setSubmitting] = useState(false);
360 const [error, setError] = useState<string | null>(null);
361 const submit = async (event: FormEvent) => {
362 event.preventDefault();
363 const trimmed = name.trim();
364 if (trimmed === "" || submitting) return;
365 setSubmitting(true);
366 const failure = await postGuest("redeem", { credential, name: trimmed });
367 setSubmitting(false);
368 if (failure === null) onJoined();
369 else setError(failure);
370 };
371 return (
372 <form className="m-auto flex w-full max-w-xs flex-col gap-3 p-6" onSubmit={submit}>
373 <h1 className="text-base font-medium">Join a shared thread</h1>
374 <label className="flex flex-col gap-1.5 text-sm" htmlFor="guest-name">
375 Your name
376 <Input
377 id="guest-name"
378 autoFocus
379 autoComplete="name"
380 maxLength={GUEST_NAME_MAX_LENGTH}
381 value={name}
382 onChange={(event) => setName(event.target.value)}
383 />
384 </label>
385 {error !== null ? (
386 <p role="alert" className="text-sm text-destructive">
387 {error}
388 </p>
389 ) : null}
390 <Button type="submit" disabled={submitting || name.trim() === ""}>
391 Join
392 </Button>
393 <p className="text-xs text-muted-foreground">
394 The owner sees your name and approves each message before the agent gets it.
395 </p>
396 </form>
397 );
398}
399
400// The timeline needs an environment id to key its rows. Nothing behind it loads on this page;
401// see isGuestPage.
402const GUEST_ENVIRONMENT_ID = EnvironmentId.make("guest");
403const NO_TURN_DIFFS: ReadonlyArray<never> = [];
404const NO_OPTIMISTIC_MESSAGES: ReadonlyArray<never> = [];
405const ignore = () => {};
406
407/**
408 * The owner's timeline, read-only: every action that would reach the owner's machine (fork,
409 * revert, diffs, files, other threads, shell) is left out, so its control does not render.
410 */
411function GuestThread({
412 view: { shareId, title, expiresAt, messages, model },
413 projection,
414 awaitingRowIds,
415 historyControls,
416}: {
417 readonly view: GuestShareView;
418 readonly projection: OrchestrationV2ThreadProjection;
419 readonly awaitingRowIds: GuestStreamState["awaitingRowIds"];
420 readonly historyControls: MessagesTimelineHistoryControls | undefined;
421}) {
422 // No ":" so the timeline finds no environment thread behind the key.
423 const threadKey = `guest-${shareId}`;
424 const listRef = useRef<LegendListRef | null>(null);
425 const { resolvedTheme } = useTheme();
426 // The rest is what ChatView derives from the same projection.
427 const [projectQuestionHistory] = useState(() => createQuestionHistoryProjector());
428 const items = projectQuestionHistory(projection);
429 // Files load through the guest route.
430 const [attachmentUrls] = useState(() => createGuestAttachmentUrls(shareId));
431 // Expanded rows read detail and command output through the guest routes.
432 const [turnItemSource] = useState(() => guestTurnItemSource(shareId));
433 const attachmentUrlById = attachmentUrls(items);
434 const entries = useIncrementalTimelineEntries(threadKey, {
435 visibleTurnItems: items,
436 optimisticMessages: NO_OPTIMISTIC_MESSAGES,
437 attachmentUrlById,
438 attempts: projection.attempts,
439 nodes: projection.nodes,
440 plans: projection.plans,
441 });
442 const openFile = useStableCallback((file: { readonly id: string }) =>
443 openInNewTab(guestAttachmentUrl(shareId, file.id)),
444 );
445 const hostImageUrl = useStableCallback((path: string) => guestMediaUrl(shareId, path));
446 const activityRun = deriveThreadActivityRun(projection);
447 const runtime = deriveThreadStructureRuntime(projection);
448 const runlessWorkStartedAt = deriveRunlessWorkStartedAt(projection);
449 const isWorking = derivePhase(runtime) === "running" || runlessWorkStartedAt !== null;
450 const workStartedAt =
451 deriveActiveWorkStartedAt(activityRun, runtime, null) ?? runlessWorkStartedAt;
452 const runningRunId = runtime?.activeRunId ?? null;
453 const follow = useTimelineFollow({
454 legendListRef: listRef,
455 composerTimelineInset: 0,
456 routeThreadKey: threadKey,
457 activeThreadKey: threadKey,
458 // Not a server thread: new turns follow the end instead of anchoring near the top.
459 isServerThread: false,
460 serverProjection: null,
461 threadStatus: "live",
462 activeActivityRun: activityRun,
463 activeRunningTurnId: runningRunId,
464 timelineEntries: entries,
465 timelineAnchorMessageId: null,
466 setTimelineAnchor: ignore,
467 });
468 const renderHtmlRender = useStableCallback(
469 (row: Extract<TimelineRow, { kind: "html-render" }>) => (
470 <GuestHtmlRender itemId={row.id} reference={row.htmlRender} shareId={shareId} />
471 ),
472 );
473 const unplaced = unplacedGuestMessages(messages, items, awaitingRowIds);
474 return (
475 <>
476 <header className="flex shrink-0 items-center gap-3 border-b border-border px-4 py-2.5">
477 <h1 className="min-w-0 flex-1 truncate text-sm font-medium">{title}</h1>
478 <ExpiryLabel expiresAt={expiresAt} />
479 </header>
480 <div className="relative min-h-0 flex-1">
481 <HostImageUrlContext value={hostImageUrl}>
482 <MessagesTimeline
483 listRef={listRef}
484 timelineEntries={entries}
485 isWorking={isWorking}
486 activeTurnStartedAt={workStartedAt}
487 latestRun={activityRun}
488 runningRunId={runningRunId}
489 turnDiffSummaries={NO_TURN_DIFFS}
490 runs={projection.runs}
491 routeThreadKey={threadKey}
492 onOpenThread={ignore}
493 supportsConversationRollback={false}
494 onRevertToTurnCount={ignore}
495 isRevertingCheckpoint={false}
496 onImageExpand={openExpandedImage}
497 onFileOpen={openFile}
498 onFileDownload={openFile}
499 renderHtmlRender={renderHtmlRender}
500 turnItemSource={turnItemSource}
501 historyControls={historyControls}
502 activeThreadEnvironmentId={GUEST_ENVIRONMENT_ID}
503 markdownCwd={undefined}
504 resolvedTheme={resolvedTheme}
505 timestampFormat="locale"
506 workspaceRoot={undefined}
507 anchorMessageId={null}
508 onAnchorReady={ignore}
509 onAnchorSizeChanged={ignore}
510 contentInsetEndAdjustment={0}
511 emptyPlaceholderInset={0}
512 liveFollowEnabled={follow.timelineLiveFollowEnabled}
513 onIsAtEndChange={follow.onIsAtEndChangeStable}
514 onViewportAttach={follow.attachTimelineViewport}
515 onFooterGrow={follow.onTimelineFooterGrowStable}
516 footer={
517 unplaced.length === 0 ? null : (
518 <ol className="chat-content-lane flex flex-col gap-4 pb-4">
519 {unplaced.map((message) => (
520 <li key={message.id}>
521 <UnplacedGuestMessage shareId={shareId} message={message} />
522 </li>
523 ))}
524 </ol>
525 )
526 }
527 />
528 </HostImageUrlContext>
529 {follow.showScrollToBottom ? (
530 <ScrollToEndButton
531 summary={null}
532 bottom={4}
533 onScrollToEnd={() => follow.scrollToEnd(true)}
534 />
535 ) : null}
536 </div>
537 <div className={cn(LANE, "shrink-0 pb-4")}>
538 <GuestComposer shareId={shareId} model={model} onSent={() => follow.scrollToEnd(true)} />
539 </div>
540 </>
541 );
542}
543
544function ExpiryLabel({ expiresAt }: { readonly expiresAt: string }) {
545 const nowMinute = useNowMinute();
546 return (
547 <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
548 {formatGuestExpiry(expiresAt, Date.parse(`${nowMinute}Z`))}
549 </span>
550 );
551}
552
553const BUBBLE =
554 "flex min-w-0 max-w-[80%] flex-col gap-2 rounded-2xl p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]";
555
556const openInNewTab = (url: string) => window.open(url, "_blank", "noopener,noreferrer");
557/** Guests have no image viewer; the browser shows the picture in its own tab. */
558const openExpandedImage = (preview: ExpandedImagePreview) => {
559 const src = preview.images[preview.index]?.src;
560 if (src) openInNewTab(src);
561};
562
563/** A guest message the owner has not sent yet, or chose not to send. */
564function UnplacedGuestMessage({
565 shareId,
566 message,
567}: {
568 readonly shareId: GuestShareView["shareId"];
569 readonly message: ThreadGuestMessage;
570}) {
571 // A dismissed message's files are deleted, so only their names remain.
572 const served = message.status !== "dismissed";
573 const url = (attachment: { readonly id: string }) => guestAttachmentUrl(shareId, attachment.id);
574 return (
575 <div className="flex flex-col items-end gap-1">
576 <GuestMessageName name="You" />
577 <div
578 className={cn(
579 BUBBLE,
580 guestBubbleClassName(message.status !== "sent"),
581 message.status === "dismissed" ? "text-muted-foreground" : "text-message-foreground",
582 )}
583 >
584 <GuestMessageAttachments
585 attachments={message.attachments ?? []}
586 imageUrl={(attachment) => (served ? url(attachment) : undefined)}
587 onOpenImage={(attachment) => openInNewTab(url(attachment))}
588 {...(served ? { fileHref: url } : {})}
589 />
590 {message.text ? <span>{message.text}</span> : null}
591 </div>
592 <p className="me-1 text-2xs text-muted-foreground">{STATUS_LABEL[message.status]}</p>
593 </div>
594 );
595}
596
597/** The thread's model, labeled as the owner's composer labels it, with no way to change it. */
598function GuestModelLabel({ model }: { readonly model: GuestThreadModel }) {
599 const label = getTriggerDisplayModelName({ slug: model.name, ...model }, model.driver);
600 const fullLabel = getDisplayModelName({ slug: model.name, ...model });
601 return (
602 <ComposerControl
603 render={<span />}
604 aria-label={`Model: ${fullLabel}`}
605 className="pointer-events-none min-w-0 shrink"
606 >
607 {model.driver === null ? null : (
608 <ProviderInstanceIcon
609 driverKind={model.driver}
610 displayName={label}
611 className="size-4"
612 iconClassName="size-4"
613 />
614 )}
615 <span className="min-w-0 truncate" data-chat-provider-model-picker-label="true">
616 {label}
617 </span>
618 </ComposerControl>
619 );
620}
621
622interface StagedFile {
623 readonly key: string;
624 readonly file: File;
625}
626
627/** A staged image's thumbnail; its object URL lives as long as the element. */
628function StagedImage({ file }: { readonly file: File }) {
629 return (
630 <img
631 alt={file.name}
632 className="size-full object-cover"
633 ref={(image) => {
634 if (!image) return;
635 const url = URL.createObjectURL(file);
636 image.src = url;
637 return () => URL.revokeObjectURL(url);
638 }}
639 />
640 );
641}
642
643function GuestComposer({
644 shareId,
645 model,
646 onSent,
647}: {
648 readonly shareId: GuestShareView["shareId"];
649 readonly model: GuestThreadModel;
650 /** The guest's own message comes into view even if they had scrolled up, as the owner's does. */
651 readonly onSent: () => void;
652}) {
653 const [text, setText] = useState("");
654 const [files, setFiles] = useState<ReadonlyArray<StagedFile>>([]);
655 const [sending, setSending] = useState(false);
656 const [error, setError] = useState<string | null>(null);
657 const fileInputRef = useRef<HTMLInputElement>(null);
658 const nextFileKey = useRef(0);
659 const { resolvedTheme } = useTheme();
660 const addFiles = (added: ReadonlyArray<File>) => {
661 if (added.length === 0) return;
662 const next = [
663 ...files,
664 ...added.map((file) => ({ key: `staged-${nextFileKey.current++}`, file })),
665 ];
666 const problem = guestFilesProblem(next.map((staged) => staged.file));
667 setError(problem);
668 if (problem === null) setFiles(next);
669 };
670 const send = async () => {
671 const trimmed = text.trim();
672 if ((trimmed === "" && files.length === 0) || sending) return;
673 setSending(true);
674 const sentFiles = files;
675 const form = new FormData();
676 form.append("shareId", shareId);
677 form.append("text", trimmed);
678 for (const staged of sentFiles) form.append("file", staged.file, staged.file.name);
679 const failure = await postGuest(
680 `messages?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}`,
681 form,
682 );
683 setSending(false);
684 setError(failure);
685 // Keep anything typed or attached while the request was in flight.
686 if (failure === null) {
687 onSent();
688 setText((current) => (current.trim() === trimmed ? "" : current));
689 setFiles((current) => current.filter((staged) => !sentFiles.includes(staged)));
690 }
691 };
692 const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
693 // Same as the app composer: Enter sends, Shift+Enter adds a line, IME composition is left alone.
694 if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
695 event.preventDefault();
696 void send();
697 }
698 };
699 // As in the app composer: pasted images attach, and other files attach when no text came along.
700 const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
701 const pasted = Array.from(event.clipboardData.files);
702 const hasImage = pasted.some((file) => file.type.startsWith("image/"));
703 if (pasted.length === 0 || (!hasImage && event.clipboardData.getData("text/plain") !== "")) {
704 return;
705 }
706 event.preventDefault();
707 addFiles(pasted);
708 };
709 return (
710 <form
711 className="flex flex-col gap-2 rounded-3xl border border-border bg-card px-4 pt-3 pb-2.5 shadow-composer dark:shadow-composer-dark"
712 onSubmit={(event) => {
713 event.preventDefault();
714 void send();
715 }}
716 onDragOver={(event) => {
717 if (event.dataTransfer.types.includes("Files")) event.preventDefault();
718 }}
719 onDrop={(event) => {
720 if (event.dataTransfer.files.length === 0) return;
721 event.preventDefault();
722 addFiles(Array.from(event.dataTransfer.files));
723 }}
724 >
725 {files.length > 0 ? (
726 <ul className="flex flex-wrap gap-2" aria-label="Attached files">
727 {files.map((staged) => (
728 <li
729 key={staged.key}
730 className="flex h-14 min-w-0 items-center gap-2 overflow-hidden rounded-lg border border-border/80 bg-background/70 pe-1"
731 >
732 {staged.file.type.startsWith("image/") ? (
733 <span className="size-14 shrink-0">
734 <StagedImage file={staged.file} />
735 </span>
736 ) : (
737 <span className="flex min-w-0 items-center gap-2 ps-2 text-sm">
738 <PierreEntryIcon pathValue={staged.file.name} kind="file" theme={resolvedTheme} />
739 <span className="max-w-40 truncate">{staged.file.name}</span>
740 <span className="shrink-0 text-xs text-muted-foreground">
741 {formatAttachmentSize(staged.file.size)}
742 </span>
743 </span>
744 )}
745 <Button
746 size="icon-xs"
747 variant="ghost-muted"
748 aria-label={`Remove ${staged.file.name}`}
749 onClick={() => {
750 setError(null);
751 setFiles((current) => current.filter((candidate) => candidate !== staged));
752 }}
753 >
754 <XIcon />
755 </Button>
756 </li>
757 ))}
758 </ul>
759 ) : null}
760 <textarea
761 aria-label="Message"
762 placeholder="Message"
763 rows={2}
764 maxLength={GUEST_MESSAGE_MAX_LENGTH}
765 value={text}
766 onChange={(event) => setText(event.target.value)}
767 onKeyDown={onKeyDown}
768 onPaste={onPaste}
769 className="max-h-60 min-h-12 w-full resize-none bg-transparent text-sm outline-none field-sizing-content placeholder:text-placeholder"
770 />
771 <div className="flex items-center gap-3">
772 <input
773 ref={fileInputRef}
774 type="file"
775 multiple
776 hidden
777 aria-label="Files to attach"
778 onChange={(event) => {
779 addFiles(Array.from(event.target.files ?? []));
780 // The same file can be picked again after it is removed.
781 event.target.value = "";
782 }}
783 />
784 <ComposerControl aria-label="Attach files" onClick={() => fileInputRef.current?.click()}>
785 <ComposerControlIcon icon={PaperclipIcon} />
786 </ComposerControl>
787 <GuestModelLabel model={model} />
788 {error !== null ? (
789 <p role="alert" className="min-w-0 flex-1 text-xs text-destructive">
790 {error}
791 </p>
792 ) : (
793 <p className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
794 The owner approves each message
795 </p>
796 )}
797 <button
798 type="submit"
799 aria-label="Send"
800 disabled={sending || (text.trim() === "" && files.length === 0)}
801 className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-message-action text-message-action-foreground shadow-xs hover:bg-message-action-hover focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64"
802 >
803 <ArrowUpIcon aria-hidden className="size-4" />
804 </button>
805 </div>
806 </form>
807 );
808}