pi-mono/apps/p2/plugins/chat/web.tsx
Maximilian Blazek ccff9876e7 feat(p2): a new session's first message sends at once; one composer for every screen
- New session: Send clears the composer and shows the message at once, online or not
  (plugins/chat/session-start.ts); the session is created when connected and its
  messages move to the main conversation's outbox. Refused creations go back to the
  composer; connection failures are retried.
- One composer: Main keeps the chat frame (.chat and its dock) while screens change;
  screens publish what the dock shows (useChatScreen) and portal their transcript into
  it. The composer keeps its focus and the phone's keyboard when the session opens.
- The composer stays in place: the new-session screen shows the session's empty
  context bar, and a message sent to an idle conversation stays a message until its
  entry shows instead of flashing as a queued row.
- Slots start with the registry's known table (knownRegistry), so a screen inside
  slot wrappers mounts once instead of three times.
- A message sent before a conversation has loaded shows alone until it loads.
2026-11-05 12:52:52 +00:00

1629 lines
57 KiB
TypeScript

// 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,
whenLive,
} from "@p2/app/v1";
import {
AttachIcon,
BranchFrom,
buildTranscript,
ChevronIcon,
CloseIcon,
ContextUsage,
CostPanel,
closeLayers,
conversationRoute,
DownIcon,
type FileCompletion,
FileCompletionPopover,
FillGap,
FolderIcon,
forgetSession,
type Gap,
type Kept,
keep,
keepSessions,
keptSizes,
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,
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 { 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";
const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/** 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<string> {
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<typeof useComposerState>): Attaching => ({
files,
add: addFiles,
remove: (file) => setFiles((current) => current.filter((entry) => entry !== file)),
});
/** 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 (
<button
type="button"
className="model-control footer-action"
aria-label="Model and thinking"
title={`${name}${level ? ` · ${level}` : ""}`}
onClick={() => openLayer({ kind: "sheet", name: "model" })}
disabled={!resources}
>
<span>
{name}
{level && ` · ${level}`}
</span>
</button>
);
}
/** The composer's error; tapping it dismisses it. */
function ErrorToast({ error, onDismiss }: { error: string | undefined; onDismiss: () => void }) {
return error ? (
<button type="button" className="toast" onClick={onDismiss}>
{error}
</button>
) : null;
}
/** Textarea with send, stop, steer and attachments; Enter sends with a fine pointer, Escape stops. */
function Composer({
draft,
setDraft,
busy,
steer,
setSteer,
disabled,
onSend,
onStop,
attaching,
replying,
completion,
textareaRef,
children,
}: {
draft: string;
setDraft: (text: string) => void;
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<HTMLTextAreaElement | null>;
children?: React.ReactNode;
}) {
const ownRef = useRef<HTMLTextAreaElement>(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 (
<form
className="composer"
onSubmit={(event) => {
event.preventDefault();
onSend();
}}
>
{replying && (
<div className="replying">
<span>
Replying to <strong>{replying.name}</strong>
</span>
<button type="button" aria-label="Cancel reply" onClick={replying.onCancel}>
<CloseIcon />
</button>
</div>
)}
{files.length > 0 && (
<div className="attachments">
{files.map((file) => (
<span key={file.key} className="attachment">
{file.preview ? <img src={file.preview} alt="" /> : <AttachIcon />}
<span className="attachment-name">{file.file.name}</span>
<button
type="button"
aria-label={`Remove ${file.file.name}`}
onClick={() => attaching?.remove(file)}
>
<CloseIcon />
</button>
</span>
))}
</div>
)}
{completion && <FileCompletionPopover completion={completion} />}
<textarea
ref={textarea}
rows={1}
value={draft}
placeholder={busy ? (steer ? "Steer the current run…" : "Queue a follow-up…") : "Message Pi…"}
aria-label="Message"
onChange={(event) => {
setDraft(event.target.value);
completion?.update();
}}
onClick={() => completion?.update()}
onPaste={(event) => {
if (!attaching) return;
const images = pastedImages(event.clipboardData);
if (!images.length) return;
event.preventDefault();
attaching.add(images);
}}
onFocus={() => completion?.onFocus()}
onBlur={() => completion?.onBlur()}
onCompositionStart={() => completion?.onCompositionStart()}
onCompositionEnd={() => completion?.onCompositionEnd()}
onKeyDown={(event) => {
// An open completion popover owns Enter, Tab, the arrows and Escape (section 8.4).
if (completion?.onKeyDown(event)) return;
if (event.key === "Escape" && busy && onStop) {
event.preventDefault();
onStop();
return;
}
if (event.key === "Enter" && !event.shiftKey && window.matchMedia("(pointer: fine)").matches) {
event.preventDefault();
onSend();
}
}}
/>
<div className="composer-bar">
{attaching && (
<label className="round attach" aria-label="Attach files" title="Attach files">
<PlusIcon />
<input
type="file"
multiple
hidden
onChange={(event) => {
attaching.add([...(event.target.files ?? [])]);
event.target.value = "";
}}
/>
</label>
)}
{busy && (
<button
type="button"
className={`chip toggle ${steer ? "on" : ""}`}
aria-pressed={steer}
onClick={() => setSteer(!steer)}
>
Steer
</button>
)}
{children}
<span className="spacer" />
{busy && onStop && (
<button type="button" className="round stop" aria-label="Stop" onClick={onStop}>
<StopIcon />
</button>
)}
{(!busy || content) && (
<button type="submit" className="round send" aria-label="Send" disabled={!content || disabled}>
<SendIcon />
</button>
)}
</div>
</form>
);
}
/** The composer's settings from the screen in the frame; the frame owns its textarea (`Frame.composer`). */
type ComposerProps = Omit<Parameters<typeof Composer>[0], "textareaRef">;
/**
* What the chat's dock shows for the screen in the frame: a bar above (a subagent's), and the composer's place with the
* scroll-down button, holding either a bar (read-only, unavailable, connecting) or the queued inputs, the composer
* and its footer. Absent: nothing below the bar above (a one-shot agent's view).
*/
type DockContent = {
readonly above?: ReactNode;
readonly composer?: { readonly toBottom?: ReactNode } & (
| { readonly bar: ReactNode }
| { readonly queued?: ReactNode; readonly props: ComposerProps; readonly footer: ReactNode }
);
};
/** The dock's content, set by the screen in the frame after each of its renders. */
class DockContents {
#content: DockContent | undefined;
readonly #listeners = new Set<() => void>();
subscribe = (listener: () => void): (() => void) => {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
};
snapshot = (): DockContent | undefined => this.#content;
set(content: DockContent | undefined): void {
this.#content = content;
for (const listener of [...this.#listeners]) listener();
}
}
/**
* The chat's frame (`Main`), kept while screens change (the new-session screen, a conversation, another one): the
* place of a screen's transcript in `.chat`, the dock floating over the transcript's bottom, what the dock shows, and
* the composer's textarea. There is one composer: opening the session a new session's first message started keeps it,
* with its focus (and a phone's keyboard) and what was typed meanwhile.
*/
type Frame = {
readonly view: HTMLDivElement | null;
readonly dock: HTMLDivElement | null;
readonly contents: DockContents;
readonly composer: React.RefObject<HTMLTextAreaElement | null>;
};
const ChatFrame = createContext<Frame | undefined>(undefined);
/**
* A screen in the chat's frame: `dock` is what the dock shows while it does, and `room` (its transcript's
* `useChatScroll().dock`) keeps room for the dock. Returns where the transcript goes.
*/
function useChatScreen(
dock: DockContent | undefined,
room?: (dock: HTMLDivElement) => (() => void) | undefined,
): (transcript: ReactNode) => ReactNode {
const { view, dock: docked, contents } = useFrame();
// After the transcript's scroller is attached: it is rendered into `view`, a child of this screen.
useLayoutEffect(() => (view && docked && room ? room(docked) : undefined), [view, docked, room]);
useLayoutEffect(() => contents.set(dock));
// The next screen sets its own in the same commit, after this cleanup: the dock never shows nothing in between.
useLayoutEffect(() => () => contents.set(undefined), [contents]);
return (transcript) => (view ? createPortal(transcript, view) : null);
}
/** The chat's frame, for a screen in it. */
function useFrame(): Frame {
const frame = useContext(ChatFrame);
if (!frame) throw new Error("Missing the chat frame");
return frame;
}
/** The dock: what the screen in the frame says, around the one composer. */
function Dock({ frame, dockRef }: { frame: Frame; dockRef: (element: HTMLDivElement | null) => void }) {
const content = useSyncExternalStore(frame.contents.subscribe, frame.contents.snapshot);
const composer = content?.composer;
return (
<div className="dock" ref={dockRef}>
{content?.above}
{composer && (
<div className="composer-wrap">
{composer.toBottom}
{"bar" in composer ? (
composer.bar
) : (
<>
{composer.queued}
<Composer {...composer.props} textareaRef={frame.composer} />
{composer.footer}
</>
)}
</div>
)}
</div>
);
}
function TopBar({ session, child, owner = false }: { session?: Session; child?: Child; owner?: boolean }) {
const connected = useConnected();
const nav = useNavigation();
const route = useRoute();
return (
<header className="topbar">
<button
type="button"
className="icon-button menu sidebar-open"
onClick={() =>
window.matchMedia("(min-width: 900px)").matches ? setSidebarCollapsed(false) : setDrawer(true)
}
aria-label="Sessions"
>
<MenuIcon />
</button>
<div className="title">
{child ? (
<>
<h1>
<span className="sub-tag">{child.kind === "agent" ? "agent" : "subagent"}</span> {child.name}
</h1>
<button
type="button"
className="crumb"
onClick={() => session && void nav.backOrPush(conversationRoute(route, session.id, session.main))}
>
← {session?.title}
</button>
</>
) : (
<>
<h1>{session?.title ?? "New session"}</h1>
{session &&
(owner ? (
<button
type="button"
className="title-cwd"
onClick={() => openLayer(directoryPickerLayer(session.env ?? null, session.cwd))}
>
{locationLabel(session.env, session.cwd)}
</button>
) : (
<p>{locationLabel(session.env, session.cwd)}</p>
))}
</>
)}
</div>
{!connected && <span className="offline">Offline</span>}
<Slot name="artifact-actions" />
<Slot name="terminal-actions" />
<Slot name="workspace-actions" />
<Slot name="session-options" />
<Slot name="session-actions" />
</header>
);
}
/** The viewed conversation's transcript (see `TranscriptStore`), and its store. */
function useTranscriptPages(key: string, chat: Held<typeof Chat> | undefined) {
const host = useHost();
const store = chat
? transcriptOf(host, key, { view: chat.view, entries: (query) => chat.entries(query, BACKGROUND_CONTEXT) })
: undefined;
const subscribe = useCallback((onChange: () => void) => store?.subscribe(onChange) ?? (() => {}), [store]);
const read = useCallback(() => store?.value, [store]);
return [useSyncExternalStore(subscribe, read, read), store] as const;
}
/**
* What this device kept of conversation `key` (conversation-cache.ts), once loaded; only for the owner. Its measured row
* heights seed the transcript's, so the kept transcript opens where it was laid out.
*/
function useKept(key: string, owner: boolean): Kept | undefined {
const [kept, setKept] = useState<{ key: string; value: Kept | undefined }>();
useEffect(() => {
if (!owner) return;
let current = true;
void loadKept(key).then((value) => {
if (!current) return;
if (value) seedSizes(key, value.sizes);
setKept({ key, value });
});
return () => {
current = false;
};
}, [key, owner]);
return kept?.key === key ? kept.value : undefined;
}
/** Joins restored texts and the current draft into one draft, skipping empty parts. */
const joinDraft = (...parts: readonly string[]): string => parts.filter((part) => part.trim()).join("\n\n");
/**
* Inputs of this author withdrawn by someone else (Stop, a queue edit) come back into the composer. Each is
* claimed once, so only one of the author's tabs gets it.
*/
function useRestoredDrafts(
sessionId: string,
conversationId: number,
participant: boolean,
restore: (texts: readonly string[]) => void,
): void {
const drafts = useObject(Drafts, participant ? { session: sessionId, conversation: conversationId } : undefined);
const pending = useReadable(drafts?.drafts);
useEffect(() => {
if (!drafts || !pending?.length) return;
drafts
.claim({ ids: pending.map((item) => item.id) }, BACKGROUND_CONTEXT)
.then(({ texts }) => restore(texts))
.catch(() => {});
}, [drafts, pending, restore]);
}
function SessionChat({ sessionId, conversationId }: { sessionId: string; conversationId: number }) {
const nav = useNavigation();
const openConversation = useCallback(
(conversation: number) => {
void nav.push(conversationRoute(nav.route, sessionId, conversation));
},
[nav, sessionId],
);
const openWorkspaceFile = useCallback(
(path: string, line?: number) => {
const current = nav.route;
void nav.push({
...current,
workspace: {
...(current.workspace ?? { tab: "files", directory: "" }),
file: { path, view: "file", line },
},
});
},
[nav],
);
// The terminal panel where it is open (the workspace's tab), else below the chat.
const openTerminal = useCallback(
(terminalId: string) => {
const current = nav.route;
void nav.push(
current.workspace?.tab === "terminal"
? { ...current, workspace: { ...current.workspace, terminal: terminalId, startTerminal: undefined } }
: { ...current, terminal: terminalId, startTerminal: undefined },
);
},
[nav],
);
const connected = useConnected();
const host = useHost();
// The catalog as the owner or a guest sees it (`worker` is a session worker's).
const sessions = liveIn(useObject(Sessions, {}), "owner", "guest");
const listed = useReadable(sessions?.sessions);
const session = listed?.find((entry) => entry.id === sessionId);
// A session archived or removed elsewhere while viewed: the new-session screen replaces it, and the toast says why
// (Pi Mobile's attach errors). Archiving from this view navigates first, so it never shows here.
const { setError: toast } = useComposerState();
useEffect(() => {
const current = nav.route;
if (listed && (!session || session.archivedAt !== null) && current.session === sessionId) {
// What this device kept of a session the server no longer has goes with it (an archived one stays).
if (!session) void forgetSession(sessionId);
toast(session ? "Session is archived" : "Session not found");
void nav.replace(conversationRoute(current, undefined), current);
}
}, [listed, session, nav, sessionId, toast]);
// Who this browser is, as the server last said: kept while reconnecting (`useOwner`), so the composer stays.
const access = useObject(Access, {});
const liveGrant = useReadable(liveIn(access, "guest")?.grant);
const owner = useOwner();
const lastGrant = useRef(liveGrant);
// Taken while the object is live, a removed grant (null) included.
if (useStatus(access) === "live") lastGrant.current = liveGrant;
const grant = owner ? undefined : lastGrant.current;
const models = useObject(Models, owner ? {} : undefined);
const resources = useReadable(models?.resources);
const subagents = liveIn(useObject(Subagents, {}), "reader");
const children = useReadable(subagents?.children);
const child = children?.[sessionId]?.find((entry) => entry.conversationId === conversationId);
// A code mode script's one-shot agent: read-only, opened from the script's tool card.
const oneShot = child?.kind === "agent";
// Where the viewed conversation works: a subagent may have its own location (e.g. a git worktree, or another machine).
const viewLocation = conversationLocation(session, child);
const viewCwd = viewLocation?.cwd;
const oneShots = useMemo(
() =>
new Map(
(children?.[sessionId] ?? []).flatMap((agent) =>
agent.kind === "agent" && agent.callId ? [[agent.callId, agent.conversationId] as const] : [],
),
),
[children, sessionId],
);
// Only subagents and the main conversation are shown: an earlier `/tree` branch (another device switched, or a link
// from before) and unknown conversations open the main one.
const main = session?.main;
useEffect(() => {
const current = nav.route;
if (
children &&
main !== undefined &&
conversationId !== main &&
!child &&
current.session === sessionId &&
current.conversation === conversationId
)
void nav.replace(conversationRoute(current, sessionId, main), current);
}, [children, child, conversationId, main, nav, sessionId]);
const workers = liveIn(useObject(SessionStatus, {}), "reader");
const workerError = useReadable(workers?.statuses)?.[sessionId]?.error;
const [activationError, setActivationError] = useState<string>();
const failure = workerError ?? activationError;
// A rejected reference is released rather than repeatedly asking a broken worker to start. Chat lingers
// (`CLIENT_LINGER`): switching to a subagent and back finds this conversation still held, its pages still loaded.
const chat = useObject(Chat, failure ? undefined : { session: sessionId, conversation: conversationId });
useEffect(() => {
let current = true;
void (chat ? whenLive(chat) : Promise.resolve()).catch((reason: unknown) => {
if (current) setActivationError(errorText(reason));
});
return () => {
current = false;
};
}, [chat]);
const control = useObject(WorkerControl, owner ? { session: sessionId } : undefined);
const [retrying, setRetrying] = useState(false);
const retry = async (): Promise<void> => {
if (!control || retrying) return;
setRetrying(true);
try {
await control.restart(BACKGROUND_CONTEXT);
setActivationError(undefined);
} catch (reason) {
setActivationError(errorText(reason));
} finally {
setRetrying(false);
}
};
const status = useStatus(chat);
const liveView = useReadable(chat?.view);
// The conversation as kept on this device (the owner's; conversation-cache.ts): shown until the live view arrives.
const cacheKey = `${sessionId}#${conversationId}`;
const kept = useKept(cacheKey, owner);
// Sessions the server no longer has (deleted elsewhere) are not kept on this device either.
useEffect(() => {
if (owner && listed) void keepSessions(new Set(listed.map((entry) => entry.id)));
}, [owner, listed]);
// The window as the server or this device has it (its newest entries); the transcript holds more (`pages`).
const view = liveView ?? kept?.view;
const participant = status === "live" && chat?.variant === "participant";
// The members only a participant has (send, stop, queue edits, configure).
const writer = liveIn(chat, "participant");
// Who may write decides what shows (as Pi Mobile's `canWrite`), not whether the chat object is live yet: during a
// load, a cold start or a reconnect the composer stays and only Send waits.
const canWrite = owner || grantMode(grant, sessionId) === "full";
const subagentControl = useObject(
SubagentControl,
canWrite && child && !oneShot ? { session: sessionId } : undefined,
);
const composer = useComposerState();
const {
draft,
setDraft,
steer,
setSteer,
error,
setError,
files,
setFiles,
viewer,
stopped,
setStopped,
replyTo,
setReplyTo,
canReply,
mentionName,
} = composer;
// `/tree` from a message (docs/tree.md): the owner, in the main conversation. A reference of its own, as the switch
// reaches the session row (whose `main` moves this view) before its result; a user message's text fills the composer.
const branchFrom = useCallback(
(entry: number) => {
const held = host.hold(SessionTree, { session: sessionId }, BACKGROUND_CONTEXT);
whenLive(held)
.then(() => {
const tree = liveIn(held, "owner");
if (!tree) throw new Error("Only the owner branches the session");
return tree.navigate({ entry }, BACKGROUND_CONTEXT);
})
.then(({ conversation, text }) => {
if (text !== null) setDraft(text);
void nav.replace(conversationRoute(nav.route, sessionId, conversation));
})
.catch((reason: unknown) => setError(errorText(reason)))
.finally(() => held.release());
},
[host, nav, sessionId, setDraft, setError],
);
const canBranch = owner && conversationId === session?.main;
// Replies exist while a plugin allows them here (chat-mode, in chat mode); the reply action fills the composer.
const startReply = useCallback(
(target: { entryId: number; name: string }) => {
setReplyTo(target);
document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus();
},
[setReplyTo],
);
// Kept while reconnecting, as the composer is (`canWrite`).
const replying = canWrite && canReply ? replyTo : undefined;
// `@`/Tab file completion for participants; in chat mode (`canReply`) the agent's mention (`@pi`) is offered first.
const { composer: composerRef } = useFrame();
const fileCompletion = useFileCompletion(
composerRef,
setDraft,
participant ? { session: sessionId, conversation: conversationId } : undefined,
{ mention: canReply ? mentionName : undefined },
);
const metrics = useObject(SessionMetrics, { session: sessionId, viewer });
const clients = useReadable(metrics?.clients) ?? 0;
const usage = useReadable(metrics?.usage) ?? undefined;
const cwdSheet = useSheet("cwd");
const modelSheet = useSheet("model");
const costSheet = useSheet("cost");
/**
* Restored text goes into the composer, which takes the focus, as in Pi Mobile; sending it again is a new message
* (each send has its own request ID). Stop and others' withdrawals put it before the draft, a queue edit after.
*/
const restore = useCallback(
(texts: readonly string[], after = false) => {
if (!texts.length) return;
setDraft((current) => (after ? joinDraft(current, ...texts) : joinDraft(...texts, current)));
document.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus();
},
[setDraft],
);
useRestoredDrafts(sessionId, conversationId, participant, restore);
const [pages, transcriptStore] = useTranscriptPages(cacheKey, chat);
// What this device kept starts the transcript; the live view is merged into it when it continues it.
useEffect(() => {
if (kept && transcriptStore) transcriptStore.seed(kept.view);
}, [kept, transcriptStore]);
// The live state is kept for the next start (a second after it settles, or when the page is hidden); older pages
// arriving are a change too.
// biome-ignore lint/correctness/useExhaustiveDependencies: `pages` changes when pages arrive; read at write time.
useEffect(() => {
const store = transcriptStore;
if (!owner || !liveView || !store?.value) return;
keep(cacheKey, () => ({ view: store.value!.view, sizes: [...keptSizes(cacheKey)] }));
}, [owner, liveView, pages, transcriptStore, cacheKey]);
const perform = useCallback(
(action: Promise<unknown> | undefined) => {
void action?.catch((reason: unknown) => setError(errorText(reason)));
},
[setError],
);
// Plugins show their own display-only entry kinds (chat-mode's chat messages) as people's messages.
const entryKinds = useRegistry(TranscriptEntries);
const display = useCallback((kind: string, data: unknown) => entryKinds.get(kind)?.(data), [entryKinds]);
const transcript = useMemo(
() => (pages ? buildTranscript(pages.view, display, pages.filling) : undefined),
[pages, display],
);
const stats = useReadable(chat?.stats) ?? undefined;
// Messages sent from here that the server does not show yet (outbox.ts): shown as they will look, a person's
// message at the end, or a queued input while a run is busy. An accepted one is shown by the server once the view
// has an entry newer than any before it was sent, or, if a run was busy when its send started, once its queued input
// is listed. One sent to an idle conversation is listed as queued only until the run it starts takes it, and the
// server's state can arrive before the send's answer: it stays a message until its entry shows, never turning into a
// queued row for a moment (the composer would move).
const outbox = outboxOf(`${sessionId}#${conversationId}`);
const outgoing = useSyncExternalStore(outbox.subscribe, outbox.snapshot);
const newestEntry = view?.entries.at(-1)?.id ?? 0;
const newest = useRef(newestEntry);
newest.current = newestEntry;
const busy = transcript?.busy ?? false;
// Where each message's first send started: the newest entry then, and whether no run was busy (written by the sender).
const started = useRef(new Map<string, { after: number; idle: boolean }>()).current;
const queuedIds = useMemo(() => new Set(transcript?.queued.map((item) => item.id)), [transcript]);
const shownByServer = useCallback(
(message: Outgoing) =>
message.sent !== undefined &&
((!started.get(message.id)?.idle && queuedIds.has(message.sent.submissionId)) ||
newestEntry > message.sent.after),
[started, queuedIds, newestEntry],
);
useEffect(() => {
outbox.settle(shownByServer);
for (const id of started.keys()) if (!outgoing.some((message) => message.id === id)) started.delete(id);
}, [outbox, shownByServer, started, outgoing]);
// Not shown by the server yet; a message in flight whose entry has arrived before the answer already is.
const pending = useMemo(
() =>
outgoing.filter((message) => {
const start = started.get(message.id);
return !shownByServer(message) && (!start || newestEntry <= start.after);
}),
[outgoing, shownByServer, started, newestEntry],
);
const pendingQueued = useMemo(
() => (busy ? pending.filter((message) => !started.get(message.id)?.idle) : []),
[pending, busy, started],
);
// The server's queued inputs, without those still shown as messages sent from here.
const queued = useMemo(() => {
const sentIds = new Set(pending.map((message) => message.sent?.submissionId));
return transcript?.queued.filter((item) => !sentIds.has(item.id)) ?? [];
}, [transcript, pending]);
// Before the server shows the conversation (a new session's first message), the messages sent from here show alone.
const shown = useMemo(() => {
const messages = pending.filter((message) => !pendingQueued.includes(message));
if (!messages.length) return transcript;
return transcript
? { ...transcript, items: [...transcript.items, ...messages.map((message) => message.item)] }
: outgoingTranscript(messages);
}, [transcript, pending, pendingQueued]);
const rows = useMemo(() => (shown ? transcriptRows(shown.items) : undefined), [shown]);
const follow = useChatScroll({
cache: `${sessionId}#${conversationId}`,
...(rows ? { rows } : {}),
estimates: rowEstimates,
...(transcript && pages ? { history: { more: transcript.earlier, loading: pages.loadingEarlier } } : {}),
onEarlier: () => void loadEarlier(),
});
const attaching = attachingOf(composer);
// The author the server will record, as the transcript last showed it: the outgoing message looks the same.
const author = useMemo(() => {
const own = transcript?.items.findLast(
(item) =>
item.type === "user" &&
item.author !== undefined &&
(owner ? item.author.kind === "owner" : item.author.kind === "guest" && item.author.name === grant?.name),
);
return own?.type === "user" ? own.author : undefined;
}, [transcript, owner, grant]);
/**
* Sends the composer's message without waiting for the connection: it leaves the composer and shows at once, and
* the outbox delivers it when it can (outbox.ts). Sending keeps the reader's scroll position; the scroll-down button
* follows the answer when wanted.
*/
const send = () => {
const text = draft.trim();
if ((!text && !files.length) || !canWrite) return;
setStopped(undefined);
const id = crypto.randomUUID();
outbox.add({
id,
text,
files: [...files],
whenBusy: steer ? "steer" : "followUp",
...(replying ? { replyTo: replying } : {}),
item: outgoingItem(id, text, files, author ?? (owner ? OWNER_AUTHOR : undefined)),
});
setDraft("");
setFiles([]);
setReplyTo(undefined);
setSteer(true);
};
// The outbox sends while this conversation's chat object is live; the latest values, for its sender.
const latest = useRef({ chat: writer, host, sessionId, conversationId, busy });
latest.current = { chat: writer, host, sessionId, conversationId, busy };
const live = connected && status === "live" && writer !== undefined;
useEffect(() => {
if (!live) {
outbox.connect(undefined);
return;
}
outbox.connect({
send: async (message) => {
const { chat: current, host: client, sessionId: session, conversationId: conversation } = latest.current;
if (!current) throw new Error("not connected");
// The first try's: a retry after a lost answer must not count the entry the first one wrote.
const start = started.get(message.id) ?? { after: newest.current, idle: !latest.current.busy };
started.set(message.id, start);
const { after } = start;
const attachments: string[] = [];
for (const file of message.files) {
if (file.session !== session) {
file.uploaded = undefined;
file.session = session;
}
file.uploaded ??= await upload(session, file.file);
attachments.push(file.uploaded);
}
// `/template args` and `/skill:name args` expand on the session worker first (10.8).
const text = message.text.startsWith("/")
? await expandComposerText(client, session, conversation, message.text)
: message.text;
const { submissionId } = await current.send(
{
text,
requestId: message.id,
whenBusy: message.whenBusy,
...(attachments.length ? { attachments } : {}),
...(message.replyTo ? { replyTo: message.replyTo.entryId } : {}),
},
BACKGROUND_CONTEXT,
);
return { submissionId, after };
},
refused,
restore: (message, error) => {
restore([message.text]);
setFiles((current) => [...message.files, ...current]);
if (message.replyTo) setReplyTo((current) => current ?? message.replyTo);
setError(errorText(error));
},
});
return () => outbox.connect(undefined);
}, [live, outbox, started, restore, setFiles, setReplyTo, setError]);
// Leaving the page loses what the outbox has not delivered: the browser asks first.
useEffect(() => {
if (outgoing.every((message) => message.sent)) return;
const warn = (event: BeforeUnloadEvent) => event.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [outgoing]);
const stop = () => {
// What the outbox has not sent comes back to the composer, as the server's queued inputs do.
const unsent = outbox.takeUnsent();
if (unsent.length) {
restore(unsent.map((message) => message.text));
setFiles((current) => [...unsent.flatMap((message) => message.files), ...current]);
}
if (!writer) return;
perform(
writer.stop(BACKGROUND_CONTEXT).then(({ restored }) => {
restore(restored);
setStopped({ sessionId, conversationId });
}),
);
};
/** Pages earlier entries into the transcript (Pi Mobile's `loadOlder`). */
const loadEarlier = async () => {
try {
await transcriptStore?.loadEarlier();
} catch (reason) {
setError(errorText(reason));
}
};
/** Loads the missing entries of a gap, a page at a time. */
const fillGap = useCallback(
(gap: Gap) => {
transcriptStore?.fillGap(gap).catch((reason: unknown) => setError(errorText(reason)));
},
[transcriptStore, setError],
);
/** Loads entries until `entryId` is shown (a quoted message, a search hit); false when it is not here. */
const reveal = useCallback(
async (entryId: number): Promise<boolean> => {
if (!transcriptStore) return false;
try {
return await transcriptStore.loadThrough(entryId);
} catch (reason) {
setError(errorText(reason));
return false;
}
},
[transcriptStore, setError],
);
// A search hit opened this conversation at one of its messages.
const revealRequests = useSyncExternalStore(revealRequest.subscribe, revealRequest.count);
const loaded = pages !== undefined;
useEffect(() => {
const entryId = revealRequests >= 0 ? takeReveal(sessionId, conversationId, loaded) : undefined;
if (entryId !== undefined) void revealEntry(entryId, reveal);
}, [sessionId, conversationId, loaded, reveal, revealRequests]);
const agentModel = transcript?.agent.model;
const contextWindow = resources?.models.find(
(entry) => entry.provider === agentModel?.provider && entry.modelId === agentModel?.modelId,
)?.contextWindow;
// Once earlier pages were loaded, the transcript's top edge marks the conversation's start.
const first = pages?.view.items.find((item) => item.kind === "entry");
const paged = (first?.kind === "entry" ? first.entry.id : Number.POSITIVE_INFINITY) < (view?.entries[0]?.id ?? 0);
const earlier = transcript?.earlier ? (pages?.loadingEarlier ? "loading" : "more") : paged ? "start" : undefined;
const choice: ModelChoice | undefined = session
? {
model: agentModel ?? session.model,
thinkingLevel: transcript?.agent.thinkingLevel ?? (session.thinkingLevel as ModelChoice["thinkingLevel"]),
}
: undefined;
const modelControl = owner ? (
<ModelControl resources={resources} {...(choice ? { choice } : {})} />
) : (
<span className="model-control static">{grant?.name} · guest</span>
);
const footer = (
<SessionStats
usage={usage}
cacheUsage={stats?.latestAssistantUsage}
clients={clients}
context={
stats && contextWindow && contextWindow > 0
? { used: stats.contextTokens, limit: contextWindow }
: // The owner gets the bar once the model's context window is known: its space stays meanwhile.
owner
? "pending"
: undefined
}
onCost={() => openLayer({ kind: "sheet", name: "cost" })}
onClients={owner ? () => openLayer({ kind: "sheet", name: "share" }) : undefined}
controls={modelControl}
/>
);
const toBottom = !follow.atBottom && (
<button type="button" className="to-bottom" onClick={follow.toBottom} aria-label="Scroll to bottom">
<DownIcon />
</button>
);
// The dock (`Dock`): a subagent's bar above, then the composer, or a bar in its place for those who may not write.
const subagentBar = child && (
<div className="composer-wrap subagent-wrap">
{/* A one-shot view has no composer, which otherwise carries this button. */}
{oneShot && toBottom}
<div className="subagent-bar">
<span>
{oneShot ? "One-shot agent of a script · read-only · " : ""}
Tools: {child.tools.join(", ") || "none"}
{!oneShot && viewLocation && ` · in ${locationLabel(viewLocation.env, viewLocation.cwd)}`}
{!oneShot && !child.location && " · follows the session"}
{child.archived ? " · archived" : ""}
</span>
{canWrite && !oneShot && (
<button
type="button"
className="text-action"
title={
child.archived
? "Show it in the sidebar again"
: "Stop it and move it to the sidebar's archived row"
}
onClick={() =>
perform(
subagentControl?.archive(
{
conversation: child.conversationId,
archived: !child.archived,
},
BACKGROUND_CONTEXT,
),
)
}
>
{child.archived ? "Restore" : "Archive"}
</button>
)}
{canWrite && oneShot && busy && (
<button type="button" className="round stop" aria-label="Stop" onClick={stop}>
<StopIcon />
</button>
)}
</div>
</div>
);
const readOnlyBar = (!canWrite || failure) && (
<div className="subagent-bar">
<span>{failure ? "Session unavailable" : grant ? `Joined as ${grant.name} · read-only` : "Connecting…"}</span>
</div>
);
const queuedRows = queued.length + pendingQueued.length > 0 && (
<div className="queued">
{pendingQueued.map((message) => (
<div key={`outgoing-${message.id}`}>
<button
type="button"
className="queue-edit"
title="Move back to editor"
onClick={() => {
if (!outbox.take(message.id)) return;
restore([message.text], true);
setFiles((current) => [...current, ...message.files]);
}}
>
<span>{message.whenBusy === "steer" ? "Steer" : "Queued"}</span> {message.text}
</button>
<button
type="button"
className="queue-remove"
aria-label="Remove from queue"
onClick={() => outbox.remove(message.id)}
>
<CloseIcon />
</button>
</div>
))}
{queued.map((item) => (
<div key={item.id}>
<button
type="button"
className="queue-edit"
title="Move back to editor"
onClick={() =>
perform(
writer
?.dequeue({ submissionId: item.id, restore: true }, BACKGROUND_CONTEXT)
.then(({ text }) => {
if (text) restore([text], true);
}),
)
}
>
<span>{item.mode === "steer" ? "Steer" : "Queued"}</span> {item.text}
</button>
<button
type="button"
className="queue-remove"
aria-label="Remove from queue"
onClick={() => perform(writer?.dequeue({ submissionId: item.id }, BACKGROUND_CONTEXT))}
>
<CloseIcon />
</button>
</div>
))}
</div>
);
const place = useChatScreen(
{
above: subagentBar,
// A one-shot agent's view has no composer: its bar above has the Stop.
...(oneShot && canWrite
? {}
: {
composer: readOnlyBar
? // As in Pi Mobile, the read-only bar has no scroll-down button.
{ bar: readOnlyBar }
: {
toBottom,
queued: queuedRows,
props: {
draft,
setDraft,
busy,
steer,
setSteer,
disabled: false,
onSend: send,
onStop: stop,
attaching,
completion: fileCompletion,
...(replying
? { replying: { name: replying.name, onCancel: () => setReplyTo(undefined) } }
: {}),
children: <SlotList prefix="composer" />,
},
footer,
},
}),
},
follow.dock,
);
return (
<>
<TopBar session={session} child={child} owner={owner} />
{cwdSheet && owner && session && (
<DirectoryPicker
value={session.cwd}
env={session.env ?? null}
moving={{ env: session.env ?? null }}
recent={(env) => [
...new Set((listed ?? []).filter((row) => (row.env ?? null) === env).map((row) => row.cwd)),
]}
onClose={closeLayers}
onChange={(env, cwd) => {
perform(liveIn(sessions, "owner")?.relocate({ id: sessionId, env, cwd }, BACKGROUND_CONTEXT));
closeLayers();
}}
/>
)}
{modelSheet && owner && (
<ModelPicker
resources={resources}
value={choice}
onChange={(choice) => perform(writer?.configure(choice, BACKGROUND_CONTEXT))}
onDefault={(choice) =>
perform(
models?.setDefault({ ...choice.model, thinkingLevel: choice.thinkingLevel }, BACKGROUND_CONTEXT),
)
}
onClose={closeLayers}
/>
)}
{costSheet && (
<CostPanel
load={async () => {
const costs = host.hold(
ChatCosts,
{ session: sessionId, conversation: conversationId },
BACKGROUND_CONTEXT,
);
try {
return await costs.costs(BACKGROUND_CONTEXT);
} finally {
costs.release();
}
}}
onClose={closeLayers}
/>
)}
<ErrorToast error={error} onDismiss={() => setError(undefined)} />
{place(
<div className="scroller transcript" ref={follow.scroller}>
{failure && connected ? (
<div className="hero worker-start-error">
<h2>Session unavailable</h2>
<p className="error" role="alert">
{failure}
</p>
{owner ? (
<div className="actions">
<button type="button" className="button" disabled={retrying} onClick={() => void retry()}>
Retry
</button>
<button
type="button"
className="button"
onClick={() =>
openLayer({ kind: "sheet", name: "logs", locator: `session:${sessionId}` })
}
>
Show logs
</button>
</div>
) : (
<p className="muted">Ask the owner to retry this session.</p>
)}
</div>
) : shown ? (
shown.items.length && rows ? (
<SessionId.Provider value={sessionId}>
<WorkingDirectory.Provider value={viewCwd}>
<OpenConversation.Provider value={openConversation}>
<OneShotAgents.Provider value={oneShots}>
<OpenWorkspaceFile.Provider value={canWrite ? openWorkspaceFile : undefined}>
<OpenTerminal.Provider value={owner ? openTerminal : undefined}>
<ReplyTo.Provider value={participant && canReply ? startReply : undefined}>
<BranchFrom.Provider value={canBranch ? branchFrom : undefined}>
<RevealEntry.Provider value={reveal}>
<FillGap.Provider value={fillGap}>
<Messages
transcript={shown}
rows={rows}
list={follow.list}
range={follow.range}
stopped={
stopped?.sessionId === sessionId &&
stopped?.conversationId === conversationId &&
!view?.entries.some((entry) =>
entry.model?.some(
(message) =>
message.role === "assistant" &&
message.stopReason === "aborted",
),
)
}
earlier={earlier}
onEarlier={() => void loadEarlier()}
/>
</FillGap.Provider>
</RevealEntry.Provider>
</BranchFrom.Provider>
</ReplyTo.Provider>
</OpenTerminal.Provider>
</OpenWorkspaceFile.Provider>
</OneShotAgents.Provider>
</OpenConversation.Provider>
</WorkingDirectory.Provider>
</SessionId.Provider>
) : (
<div className="hero small-hero">
<p className="muted">Send a message to start.</p>
</div>
)
) : (
<div className="hero">
<div className="spinner large" />
</div>
)}
</div>,
)}
</>
);
}
function NewSession() {
const connected = useConnected();
const owner = useOwner();
const catalog = useObject(Sessions, {});
const listed = useReadable(liveIn(catalog, "owner", "guest")?.sessions);
// Only the owner creates sessions.
const sessions = liveIn(catalog, "owner");
const models = useObject(Models, owner ? {} : undefined);
const resources = useReadable(models?.resources);
const recent = useMemo(() => (listed ?? []).toSorted((a, b) => b.updatedAt - a.updatedAt), [listed]);
const { location: selectedLocation, setLocation, choice: selectedChoice, setChoice } = useNewSessionState();
const location =
selectedLocation ?? (recent[0] ? { env: recent[0].env ?? null, cwd: recent[0].cwd } : { env: null, cwd: "~" });
const fallback = resources?.settings.defaultModel ?? recent[0]?.model;
const choice: ModelChoice | undefined =
selectedChoice ??
(fallback ? { model: fallback, thinkingLevel: resources?.settings.thinkingLevel ?? "high" } : undefined);
const contextWindow = resources?.models.find(
(entry) => entry.provider === choice?.model.provider && entry.modelId === choice?.model.modelId,
)?.contextWindow;
const composer = useComposerState();
const { draft, setDraft, files, setFiles, error, setError, steer, setSteer, setStopped } = composer;
const cwdSheet = useSheet("cwd");
const modelSheet = useSheet("model");
const attaching = attachingOf(composer);
// The first messages, shown at once while the session is created (session-start.ts).
const start = useSyncExternalStore(sessionStart.subscribe, sessionStart.snapshot);
const outgoing = useMemo(() => (start ? outgoingTranscript(start.messages) : undefined), [start]);
const rows = useMemo(() => (outgoing ? transcriptRows(outgoing.items) : undefined), [outgoing]);
const follow = useChatScroll({ ...(rows ? { rows } : {}), estimates: rowEstimates });
/**
* Sends the composer's message without waiting for the session or the connection: it leaves the composer and shows
* at once; the session is created when the page is connected, and its outbox delivers the message (session-start.ts).
*/
const send = () => {
const text = draft.trim();
if ((!text && !files.length) || !owner) return;
if (!choice) {
openLayer({ kind: "sheet", name: "model" });
return;
}
setStopped(undefined);
const id = crypto.randomUUID();
sessionStart.add(
{
id,
text,
files: [...files],
whenBusy: steer ? "steer" : "followUp",
item: outgoingItem(id, text, files, OWNER_AUTHOR),
},
{ location, choice },
);
setDraft("");
setFiles([]);
setSteer(true);
};
// The session is created while the owner's catalog is live; the session opens once it exists, if this screen still
// shows (the outbox delivers the messages whenever the session is opened).
const shownHere = useRef(true);
useEffect(() => {
shownHere.current = true;
return () => {
shownHere.current = false;
};
}, []);
useEffect(() => {
if (!connected || !sessions) {
sessionStart.connect(undefined);
return;
}
sessionStart.connect({
create: async ({ location, choice }) =>
(
await sessions.create(
{
env: location.env,
cwd: location.cwd,
model: choice.model,
thinkingLevel: choice.thinkingLevel,
},
BACKGROUND_CONTEXT,
)
).id,
// The server's own errors (a missing directory, an unknown model) refuse; a lost connection is tried again.
refused: (error) => !isChordError(error) || refused(error),
created: (session) => {
if (shownHere.current) navigate({ session }, { replace: true });
},
restore: (messages, error) => {
setDraft((current) => joinDraft(...messages.map((message) => message.text), current));
setFiles((current) => [...messages.flatMap((message) => message.files), ...current]);
setError(errorText(error));
},
});
return () => sessionStart.connect(undefined);
}, [connected, sessions, setDraft, setFiles, setError]);
// Leaving the page loses a session not created yet: the browser asks first.
useEffect(() => {
if (!start) return;
const warn = (event: BeforeUnloadEvent) => event.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [start]);
const place = useChatScreen(
{
composer: owner
? {
props: { draft, setDraft, busy: false, steer, setSteer, disabled: false, onSend: send, attaching },
footer: (
<div className="session-usage">
<div className="session-controls">
<ModelControl resources={resources} {...(choice ? { choice } : {})} />
</div>
{/* The session's context bar, empty: the dock keeps its height when Send opens the session. */}
<ContextUsage {...(contextWindow ? { used: 0, limit: contextWindow } : {})} />
</div>
),
}
: {
bar: (
<div className="subagent-bar">
<span>Connecting…</span>
</div>
),
},
},
follow.dock,
);
return (
<>
<TopBar />
{cwdSheet && (
<DirectoryPicker
value={location.cwd}
env={location.env}
recent={(env) => [
...new Set(recent.filter((session) => (session.env ?? null) === env).map((session) => session.cwd)),
]}
onChange={(env, cwd) => {
setLocation({ env, cwd });
closeLayers();
}}
onClose={closeLayers}
/>
)}
{modelSheet && owner && (
<ModelPicker
resources={resources}
value={choice}
onChange={setChoice}
onDefault={(choice) =>
void models
?.setDefault({ ...choice.model, thinkingLevel: choice.thinkingLevel }, BACKGROUND_CONTEXT)
.catch((error) => setError(errorText(error)))
}
onClose={closeLayers}
/>
)}
<ErrorToast error={error} onDismiss={() => setError(undefined)} />
{place(
<div className={outgoing ? "scroller transcript" : "scroller"} ref={follow.scroller}>
{outgoing && rows ? (
<Messages transcript={outgoing} rows={rows} list={follow.list} range={follow.range} />
) : (
<div className="hero">
<div className="hero-title">
<PiLogo />
<h2>What should Pi do?</h2>
</div>
<button
type="button"
className="cwd-pick"
onClick={() => openLayer(directoryPickerLayer(location.env, location.cwd))}
>
<FolderIcon />
<span>{locationLabel(location.env, location.cwd)}</span>
<ChevronIcon size={14} />
</button>
{owner && resources && !resources.credentials.length && (
<button
type="button"
className="button"
onClick={() => openLayer({ kind: "sheet", name: "providers" })}
>
Sign in to a provider
</button>
)}
</div>
)}
</div>,
)}
</>
);
}
/** A guest's share ended: Pi Mobile clears the view and says so in its toast; nothing reconnects. */
function ShareEnded() {
const place = useChatScreen(undefined);
return (
<>
<TopBar />
<button type="button" className="toast">
This share has ended.
</button>
{place(<div className="scroller" />)}
</>
);
}
function Main() {
const nav = useNavigation();
const route = useRoute();
const ended = useShareEnded();
const access = useObject(Access, {});
const grant = useReadable(liveIn(access, "guest")?.grant);
// A guest stays within the sessions shared with it; anywhere else it lands in the one shared or joined last.
useEffect(() => {
if (grant && !grantMode(grant, route.session)) void nav.replace(conversationRoute(route, grant.session), route);
}, [grant, route, nav]);
// The frame outlives the screens in it: its dock, with the one composer, stays while they change.
const [view, setView] = useState<HTMLDivElement | null>(null);
const [dock, setDock] = useState<HTMLDivElement | null>(null);
const [contents] = useState(() => new DockContents());
const composer = useRef<HTMLTextAreaElement>(null);
const frame = useMemo(() => ({ view, dock, contents, composer }), [view, dock, contents]);
const conversation = route.session && `${route.session}:${route.conversation ?? 1}`;
return (
<ChatFrame.Provider value={frame}>
{ended ? (
<ShareEnded />
) : route.session ? (
<Slot name="chat-content">
<Slot name="chat-attribution">
<SessionChat key={conversation} sessionId={route.session} conversationId={route.conversation ?? 1} />
</Slot>
</Slot>
) : (
<NewSession />
)}
{/* The transcript of the screen above goes into the view (`useChatScreen`), the dock floats over its bottom. */}
<div className="chat">
<div className="chat-view" ref={setView} />
<Dock frame={frame} dockRef={setDock} />
</div>
{/* Below the chat, per conversation (the bottom terminal panel). */}
{conversation && !ended && <Slot key={conversation} name="chat-bottom" />}
</ChatFrame.Provider>
);
}
export default defineFacet((host, self) => {
// The chat plugin defines the transcript's entry displays (chat-mode adds its own): served on the page too.
serveKeyed(host, TranscriptEntries);
self.contribute(Slots, { main: Main });
});