// Chat mode in the browser: the composer's "Chat" chip, and chat entries in the transcript. While chat mode is on, a // second chip shows how the agent is mentioned (`@pi`); the owner taps it to rename the agent for this conversation // (what people type after `@`, and what voice mode listens for). import { BACKGROUND_CONTEXT, contribute, defineFacet } from "@p2/app/v1"; import { Sheet, Slots, useComposerState, useObject, useOptimisticCall, useReadable, useRoute, useStatus, } from "@p2/app/v1/web"; import { useEffect, useState } from "react"; import { TranscriptEntries } from "../chat/contract.ts"; import { ChatMode, DEFAULT_AGENT_NAME, mentionToken } from "./contract.ts"; import { CHAT_ENTRY_DISPLAYS } from "./display.ts"; import "./style.css"; const hint = (name: string) => `Chat mode: messages go to the people in this session, not to ${name}. Mention ${mentionToken(name)} (or say "${name}") to ask ${name}; it then reads the chat since its last answer.`; /** One word, as typed after `@` and spoken: letters, digits, `_` and `-`, starting with a letter (AgentNameSchema). */ const VALID_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/; /** What the chip shows: the mode and the agent's name; a guess changes either. */ type Shown = { readonly enabled: boolean; readonly name: string }; /** * The owner's sheet for the agent's name in this conversation. Saving closes it at once: the chip shows the new name * while the rename is in flight, and a refused rename puts the old one back with the error in the toast. */ function NameSheet({ name, onSave, onClose }: { name: string; onSave: (name: string) => void; onClose: () => void }) { const [draft, setDraft] = useState(name); const valid = VALID_NAME.test(draft.trim()); const save = () => { const next = draft.trim(); if (!VALID_NAME.test(next)) return; onSave(next); onClose(); }; return (
{ event.preventDefault(); save(); }} >

People mention it as {mentionToken(valid ? draft.trim() : name)}, and in voice mode its name anywhere in what they say counts. One word: letters, digits, - and _.

{name !== DEFAULT_AGENT_NAME && ( )}
); } /** Beside Steer in the composer: the owner's switch; for guests, a marker while chat mode is on. */ function ChatModeChip() { const route = useRoute(); const { session } = route; const mode = useObject(ChatMode, session ? { session, conversation: route.conversation ?? 1 } : undefined); const live = useStatus(mode) === "live"; const waiting = useReadable(mode?.waiting) ?? 0; const { setCanReply, setReplyTo, setMentionName, fail } = useComposerState(); // The switch and the name flip at the tap; the state that arrives with the answer replaces the guess. const [{ enabled, name }, guess] = useOptimisticCall>( { enabled: useReadable(mode?.enabled) ?? false, name: useReadable(mode?.name) ?? DEFAULT_AGENT_NAME }, (shown, change) => ({ ...shown, ...change }), fail, ); const [renaming, setRenaming] = useState(false); const label = enabled && waiting ? `Chat ยท ${waiting}` : "Chat"; // Replies belong to chat mode: the transcript offers them while it is on, and the composer offers the mention. const replies = live && enabled; useEffect(() => { setCanReply(replies); if (!replies) setReplyTo(undefined); return () => setCanReply(false); }, [replies, setCanReply, setReplyTo]); useEffect(() => { setMentionName(name); return () => setMentionName(DEFAULT_AGENT_NAME); }, [name, setMentionName]); if (!mode || !live) return null; const title = hint(name); if (mode.variant !== "owner") return enabled ? ( <> {label} {mentionToken(name)} ) : null; return ( <> {enabled && ( )} {renaming && ( guess({ name: next }, () => mode.rename({ name: next }, BACKGROUND_CONTEXT))} onClose={() => setRenaming(false)} /> )} ); } export default defineFacet((host) => { contribute(host, "p2.chat-mode-web", (self) => { self.contribute(Slots, { "composer:chat-mode": ChatModeChip }); self.contribute(TranscriptEntries, CHAT_ENTRY_DISPLAYS); }); });