// Main view: the transcript of the open session with its composer, or the new-session screen. import { Access, BACKGROUND_CONTEXT, defineFacet, expandComposerText, grantMode, type Held, isChordError, liveIn, serveKeyed, UI_LINGER, whenLive, } from "@p2/app/v1"; import { AttachIcon, BranchFrom, buildTranscript, ChevronIcon, CloseIcon, ContextUsage, CostPanel, closeLayers, conversationRoute, DownIcon, errorText, type FileCompletion, FileCompletionPopover, FillGap, FolderIcon, forgetSession, type Gap, type Kept, keep, keepSessions, keptSizes, knownMain, LockIcon, loadKept, locationLabel, MenuIcon, Messages, type ModelChoice, ModelPicker, modelName, navigate, OneShotAgents, OpenConversation, OpenTerminal, OpenWorkspaceFile, openLayer, type PickedFile, PiLogo, PlusIcon, ReplyTo, RevealEntry, revealEntry, revealRequest, rowEstimates, SendIcon, SessionId, SessionStats, Slot, SlotList, Slots, StopIcon, seedSizes, setDrawer, setSidebarCollapsed, type Transcript, takeReveal, transcriptOf, transcriptRows, useChatScroll, useComposerState, useConnected, useFileCompletion, useHost, useNavigation, useNewSessionState, useObject, useOptimisticCall, useOwner, useReadable, useRegistry, useRoute, useShareEnded, useSheet, useStatus, WorkingDirectory, } from "@p2/app/v1/web"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { createPortal } from "react-dom"; import { Drafts } from "../drafts/contract.ts"; import { Envs } from "../envs/contract.ts"; import { DirectoryPicker, directoryPickerLayer } from "../envs/contract.web.tsx"; import { Models } from "../models/contract.ts"; import type { ResourceSnapshot } from "../models/resources.ts"; import { type Session, Sessions } from "../sessions/contract.ts"; import { SessionMetrics, SessionStatus, WorkerControl } from "../status/contract.ts"; import { type Child, conversationLocation, SubagentControl, Subagents } from "../subagents/contract.ts"; import { SessionTree } from "../tree/contract.ts"; import { Chat, ChatCosts, TranscriptEntries } from "./contract.ts"; import { type Outgoing, outboxOf } from "./outbox.ts"; import { sessionStart } from "./session-start.ts"; /** An upload the server answered with an error status. */ class UploadError extends Error { readonly status: number; constructor(message: string, status: number) { super(message); this.status = status; } } /** Uploads a file to the session (the files plugin's route); returns its upload ID. */ async function upload(session: string, file: File): Promise { const query = new URLSearchParams({ session, name: file.name }); const response = await fetch(`/upload?${query}`, { method: "POST", body: file, credentials: "same-origin" }); const result = (await response.json().catch(() => ({}))) as { id?: string; error?: string }; if (!response.ok || !result.id) throw new UploadError(`Upload of ${file.name} failed: ${result.error ?? response.status}`, response.status); return result.id; } /** * Whether a failed send refuses the message for good: not allowed, invalid (a template that does not exist), an upload * the server rejects. Anything else (the connection, a restarting worker) is retried by the outbox. */ function refused(error: unknown): boolean { if (error instanceof UploadError) return error.status >= 400 && error.status < 500; return ( isChordError(error) && ["forbidden", "invalid", "not_in_variant", "contract_mismatch", "upgrade_required"].includes(error.code) ); } /** The owner on an outgoing message before the transcript has shown one of theirs (the default owner name). */ const OWNER_AUTHOR = { kind: "owner", id: "owner", name: "Owner" } as const; /** A message sent from here as the transcript will show it, until the server shows it (outbox.ts). */ function outgoingItem( id: string, text: string, files: readonly PickedFile[], author: Outgoing["item"]["author"], ): Outgoing["item"] { return { type: "user", key: `outgoing-${id}`, text, images: [], files: files.map((file) => file.file.name), ...(author ? { author } : {}), }; } /** A transcript of only messages sent from here: what shows before the server has the conversation (a new session). */ const outgoingTranscript = (messages: readonly Outgoing[]): Transcript => ({ items: messages.map((message) => message.item), earlier: false, artifacts: [], busy: false, working: false, compactions: [], queued: [], agent: {}, }); /** Picked files: shown above the textarea, removable; the attach button and pasted images add more. */ type Attaching = { readonly files: readonly PickedFile[]; readonly add: (files: File[]) => void; readonly remove: (file: PickedFile) => void; }; /** * The images a paste attaches, or none to leave the paste to the textarea. Plain text wins: Office apps put a rendered * picture of the copied cells beside the text, and Chrome on macOS pastes a Finder file as its icon beside its name. */ function pastedImages(data: DataTransfer): File[] { if (data.getData("text/plain") !== "") return []; return [...data.items] .filter((item) => item.kind === "file" && item.type.startsWith("image/")) .map((item) => item.getAsFile()) .filter((file) => file !== null) .map((file) => // The server infers an upload's image type from its extension; a nameless paste gets one from its type. /\.[a-z0-9]+$/i.test(file.name) ? file : new File([file], `pasted-image.${file.type.slice(6).replace("jpeg", "jpg")}`, { type: file.type }), ); } /** The composer's attachments from its state. */ const attachingOf = ({ files, setFiles, addFiles }: ReturnType): Attaching => ({ files, add: addFiles, remove: (file) => setFiles((current) => current.filter((entry) => entry !== file)), }); /** * The owner's model catalog and settings, with the default model as just chosen: `setDefault` shows the choice as the * default at once (the picker's mark) and rolls back if the server refuses it. */ function useModelResources(owner: boolean, fail: (error: unknown) => void) { const models = useObject(Models, owner ? {} : undefined); const [resources, guess] = useOptimisticCall( useReadable(models?.resources), (snapshot, choice: ModelChoice) => snapshot && { ...snapshot, settings: { ...snapshot.settings, defaultModel: choice.model, thinkingLevel: choice.thinkingLevel }, }, fail, ); const setDefault = (choice: ModelChoice) => { if (models) guess(choice, () => models.setDefault({ ...choice.model, thinkingLevel: choice.thinkingLevel }, BACKGROUND_CONTEXT), ); }; return [resources, setDefault] as const; } /** The model and thinking chip that opens the model sheet. */ function ModelControl({ resources, choice }: { resources: ResourceSnapshot | undefined; choice?: ModelChoice }) { const name = choice ? modelName(resources, choice.model) : "Choose model"; const level = choice && choice.thinkingLevel !== "off" ? choice.thinkingLevel : undefined; return ( ); } /** The composer's error; tapping it dismisses it. */ function ErrorToast({ error, onDismiss }: { error: string | undefined; onDismiss: () => void }) { return error ? ( ) : null; } /** Textarea with send, stop, steer and attachments; Enter sends with a fine pointer, Escape stops. */ function Composer({ busy, steer, setSteer, disabled, onSend, onStop, attaching, replying, completion, textareaRef, children, }: { busy: boolean; steer: boolean; setSteer: (steer: boolean) => void; disabled: boolean; onSend: () => void; onStop?: () => void; attaching?: Attaching; /** The message being answered, shown above the text with a way to drop the reply. */ replying?: { name: string; onCancel: () => void }; /** `@` and Tab file completion (p2 addition, src/web/file-completion.tsx); only where a session exists. */ completion?: FileCompletion; textareaRef?: React.RefObject; children?: React.ReactNode; }) { // The draft straight from the app's composer state, not through the screen's dock content: that arrives a layout // effect after a keystroke, so the controlled textarea rendered the previous text first, which put the caret at the // end on every keystroke and broke a phone keyboard's composition (its first tap on Send only closed the keyboard). const { draft, setDraft } = useComposerState(); const ownRef = useRef(null); const textarea = textareaRef ?? ownRef; // biome-ignore lint/correctness/useExhaustiveDependencies: resizing follows the rendered draft, as in Pi Mobile. useLayoutEffect(() => { const element = textarea.current; if (!element) return; element.style.height = "auto"; element.style.overflowY = "hidden"; element.style.height = `${Math.min(element.scrollHeight + 1, 200)}px`; element.style.overflowY = element.scrollHeight > element.clientHeight ? "auto" : "hidden"; }, [draft]); const files = attaching?.files ?? []; const content = draft.trim() !== "" || files.length > 0; return (
{ event.preventDefault(); onSend(); }} > {replying && (
Replying to {replying.name}
)} {files.length > 0 && (
{files.map((file) => ( {file.preview ? : } {file.file.name} ))}
)} {completion && }