From ccff9876e7ca72fa6bcdd4820ef814831624601d Mon Sep 17 00:00:00 2001 From: Maximilian Blazek <68336485+dgtlntv@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:48:07 +0000 Subject: [PATCH] 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. --- apps/p2/docs/p2-notes.md | 20 +- apps/p2/plugins/chat/outbox.ts | 6 +- apps/p2/plugins/chat/session-start.ts | 108 +++ apps/p2/plugins/chat/web.tsx | 828 ++++++++++++++--------- apps/p2/src/runtime/refs.ts | 9 + apps/p2/src/runtime/web.tsx | 6 +- apps/p2/src/web/composer-state.tsx | 8 - apps/p2/src/web/style.css | 4 + apps/p2/test/p2/session-start.test.ts | 146 ++++ apps/p2/test/p2/ui-chat-controls.test.ts | 14 +- apps/p2/test/p2/ui-outbox.test.ts | 81 +++ 11 files changed, 885 insertions(+), 345 deletions(-) create mode 100644 apps/p2/plugins/chat/session-start.ts create mode 100644 apps/p2/test/p2/session-start.test.ts diff --git a/apps/p2/docs/p2-notes.md b/apps/p2/docs/p2-notes.md index 5be843647..f81b6a6d6 100644 --- a/apps/p2/docs/p2-notes.md +++ b/apps/p2/docs/p2-notes.md @@ -760,7 +760,25 @@ translated phone drawer and intercept chat taps. a send failing six times while connected put the text, attachments and reply target back into the composer with the error; Stop takes back what was not sent yet. The outbox lives for the page: leaving with unsent messages asks first. Who the page is (`useOwner`, a guest's grant) is kept while reconnecting, so the composer never turns into - the "Connecting…" bar after a drop; only a page that never connected shows it. + the "Connecting…" bar after a drop; only a page that never connected shows it. A message sent before the + conversation has loaded shows alone until it does. A message whose send started while no run was busy stays a + message until its entry shows: the run it starts lists it as queued for a moment, and the server's state can arrive + before the send's answer, so it would otherwise flash as a queued row above the composer and move it. +- **A new session's first message** (`plugins/chat/session-start.ts`) does not wait either: Send on the new-session + screen clears the composer and shows the message there at once, online or not, above the session's (empty) context + bar, so the composer stays in place when the session opens; it is the same composer (see **Chat frame**), so it + keeps its focus and a phone's keyboard. The session is created (with the location and model chosen at that Send) whenever the owner's catalog is live, its messages move to the main + conversation's outbox, which delivers them, and the page opens the session. Messages sent meanwhile join the same + session. A creation the server refuses (its own errors: a missing directory, an unknown model) puts the messages + back into the composer with the error; a connection failure is retried like an outbox send. A creation whose answer + was lost with the connection can leave an empty session behind (`create` takes no request ID). +- **Chat frame** (`Main` in `plugins/chat/web.tsx`): the chat's `.chat` area and its dock outlive the screens shown in + it (the new-session screen, a conversation, the next one). There is one composer, in the dock: a screen publishes + what the dock shows (`useChatScreen`: a bar above, the composer's settings, queued rows, the footer, or a bar in the + composer's place) after each of its renders, and renders its transcript into the frame's `.chat-view` (a + `display: contents` element) through a portal. Changing screens never replaces the composer's textarea, so it keeps + its focus and a phone's keyboard. Slots wrapping a screen start with the plugin registry's known table + (`knownRegistry`), so a screen mounts once, not again when its wrappers arrive a render later. - **Virtual transcript** (`src/web/use-chat-scroll.ts`, `virtual-list.ts`, `row-estimate.ts`, `scroll-gesture.ts`; after TanStack Virtual and react-virtuoso, without depending on either): the web transcript renders only the rows near the viewport (one viewport of overscan each way, everything up to the first row near the list's start) between diff --git a/apps/p2/plugins/chat/outbox.ts b/apps/p2/plugins/chat/outbox.ts index a65572833..19c61b110 100644 --- a/apps/p2/plugins/chat/outbox.ts +++ b/apps/p2/plugins/chat/outbox.ts @@ -37,11 +37,13 @@ export type Sender = { const SHOWN_AT_MOST = 10_000; const RETRY_MIN = 500; const RETRY_MAX = 10_000; +/** The wait before retry `attempt` (from 0): doubling from half a second, at most ten seconds. */ +export const retryDelay = (attempt: number): number => Math.min(RETRY_MAX, RETRY_MIN * 2 ** attempt); /** * Failed tries while connected after which a message goes back to the composer (about half a minute): an error that * keeps coming back is not the connection. A reconnect starts the count again. */ -const GIVE_UP = 6; +export const GIVE_UP = 6; export class Outbox { readonly #retryDelay: (attempt: number) => number; @@ -57,7 +59,7 @@ export class Outbox { /** Timings: the wait before retry `attempt` (from 0), and how long an accepted message stays shown at most. */ constructor(timing: { retryDelay?: (attempt: number) => number; shownAtMost?: number } = {}) { - this.#retryDelay = timing.retryDelay ?? ((attempt) => Math.min(RETRY_MAX, RETRY_MIN * 2 ** attempt)); + this.#retryDelay = timing.retryDelay ?? retryDelay; this.#shownAtMost = timing.shownAtMost ?? SHOWN_AT_MOST; } diff --git a/apps/p2/plugins/chat/session-start.ts b/apps/p2/plugins/chat/session-start.ts new file mode 100644 index 000000000..d911f59b5 --- /dev/null +++ b/apps/p2/plugins/chat/session-start.ts @@ -0,0 +1,108 @@ +/** + * A new session's first messages, before the session exists: Send on the new-session screen shows them at once, as + * sending in a conversation does (outbox.ts), and the session is created whenever the page is connected. Once it + * exists, the messages move to its main conversation's outbox, which delivers them (uploads, template expansion, + * retries), and the page opens the session. + * + * One start per page, kept for the page's lifetime: messages sent while the session is being created join it. A + * creation the server refuses (a directory that does not exist, an unknown model) puts the messages back into the + * composer; a failure of the connection is tried again, at most `GIVE_UP` times while connected. A creation whose answer + * was lost with the connection is tried again too and may leave an empty session behind. + */ +import type { ModelChoice } from "@p2/app/v1/web"; +import { GIVE_UP, type Outgoing, outboxOf, retryDelay } from "./outbox.ts"; + +/** Where and with which model the session starts (as chosen at the first Send), and the messages it starts with. */ +export type Start = { + readonly location: { readonly env: string | null; readonly cwd: string }; + readonly choice: ModelChoice; + readonly messages: readonly Outgoing[]; +}; + +/** How the connected new-session screen creates the session, and what it does with the outcome. */ +export type Creator = { + /** Creates the session; resolves with its ID. */ + create(start: Start): Promise; + /** Whether `error` refuses the creation for good; anything else is retried. */ + refused(error: unknown): boolean; + /** The session exists and its main conversation's outbox has the messages. */ + created(session: string): void; + /** Puts the messages of a refused creation back into the composer, saying why. */ + restore(messages: readonly Outgoing[], error: unknown): void; +}; + +export class SessionStart { + readonly #retryDelay: (attempt: number) => number; + #start: Start | undefined; + readonly #listeners = new Set<() => void>(); + #creator: Creator | undefined; + #running = false; + #attempt = 0; + #retry: ReturnType | undefined; + + /** Timing: the wait before retry `attempt` (from 0), as the outbox's by default. */ + constructor(timing: { retryDelay?: (attempt: number) => number } = {}) { + this.#retryDelay = timing.retryDelay ?? retryDelay; + } + + subscribe = (listener: () => void): (() => void) => { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + }; + snapshot = (): Start | undefined => this.#start; + + /** Adds `message`; the first one's `place` decides where and with which model the session starts. */ + add(message: Outgoing, place: Omit): void { + this.#set( + this.#start + ? { ...this.#start, messages: [...this.#start.messages, message] } + : { ...place, messages: [message] }, + ); + void this.#run(); + } + + /** How to create the session now, or undefined while it cannot work (offline, the catalog not live). */ + connect(creator: Creator | undefined): void { + this.#creator = creator; + if (creator) { + this.#attempt = 0; + void this.#run(); + } + } + + #set(start: Start | undefined): void { + this.#start = start; + for (const listener of [...this.#listeners]) listener(); + } + + /** Creates the session and hands the messages to its outbox; a failure that is not a refusal waits and tries again. */ + async #run(): Promise { + const creator = this.#creator; + const start = this.#start; + if (this.#running || !creator || !start) return; + this.#running = true; + clearTimeout(this.#retry); + try { + const session = await creator.create(start); + this.#attempt = 0; + // Read after the creation: messages sent meanwhile go with the first. + const messages = this.#start?.messages ?? []; + this.#set(undefined); + const outbox = outboxOf(`${session}#1`); + for (const message of messages) outbox.add(message); + creator.created(session); + } catch (error) { + if (creator.refused(error) || this.#attempt + 1 >= GIVE_UP) { + this.#attempt = 0; + const messages = this.#start?.messages ?? []; + this.#set(undefined); + creator.restore(messages, error); + } else this.#retry = setTimeout(() => void this.#run(), this.#retryDelay(this.#attempt++)); + } finally { + this.#running = false; + } + } +} + +/** The page's one new-session start. */ +export const sessionStart = new SessionStart(); diff --git a/apps/p2/plugins/chat/web.tsx b/apps/p2/plugins/chat/web.tsx index 3173a5964..248110ddc 100644 --- a/apps/p2/plugins/chat/web.tsx +++ b/apps/p2/plugins/chat/web.tsx @@ -18,6 +18,7 @@ import { buildTranscript, ChevronIcon, CloseIcon, + ContextUsage, CostPanel, closeLayers, conversationRoute, @@ -63,6 +64,7 @@ import { seedSizes, setDrawer, setSidebarCollapsed, + type Transcript, takeReveal, transcriptOf, transcriptRows, @@ -83,7 +85,19 @@ import { useStatus, WorkingDirectory, } from "@p2/app/v1/web"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +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"; @@ -94,6 +108,7 @@ import { type Child, conversationLocation, SubagentControl, Subagents } from ".. 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)); @@ -128,6 +143,38 @@ function refused(error: unknown): boolean { ); } +/** 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[]; @@ -349,6 +396,100 @@ function Composer({ ); } +/** The composer's settings from the screen in the frame; the frame owns its textarea (`Frame.composer`). */ +type ComposerProps = Omit[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; +}; +const ChatFrame = createContext(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 ( +
+ {content?.above} + {composer && ( +
+ {composer.toBottom} + {"bar" in composer ? ( + composer.bar + ) : ( + <> + {composer.queued} + + {composer.footer} + + )} +
+ )} +
+ ); +} + function TopBar({ session, child, owner = false }: { session?: Session; child?: Child; owner?: boolean }) { const connected = useConnected(); const nav = useNavigation(); @@ -657,7 +798,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers // 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 composerRef = useRef(null); + const { composer: composerRef } = useFrame(); const fileCompletion = useFileCompletion( composerRef, setDraft, @@ -711,30 +852,57 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers ); 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 its queued - // input is listed or the view has an entry newer than any before it was sent. + // 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()).current; const queuedIds = useMemo(() => new Set(transcript?.queued.map((item) => item.id)), [transcript]); const shownByServer = useCallback( (message: Outgoing) => - message.sent !== undefined && (queuedIds.has(message.sent.submissionId) || newestEntry > message.sent.after), - [queuedIds, newestEntry], + message.sent !== undefined && + ((!started.get(message.id)?.idle && queuedIds.has(message.sent.submissionId)) || + newestEntry > message.sent.after), + [started, queuedIds, newestEntry], ); - useEffect(() => outbox.settle(shownByServer), [outbox, shownByServer]); - const pending = useMemo(() => outgoing.filter((message) => !shownByServer(message)), [outgoing, shownByServer]); - const busy = transcript?.busy ?? false; - const shown = useMemo( + 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( () => - transcript && pending.length > 0 && !busy - ? { ...transcript, items: [...transcript.items, ...pending.map((message) => message.item)] } - : transcript, - [transcript, pending, busy], + outgoing.filter((message) => { + const start = started.get(message.id); + return !shownByServer(message) && (!start || newestEntry <= start.after); + }), + [outgoing, shownByServer, started, newestEntry], ); - const pendingQueued = busy ? pending : []; + 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}`, @@ -770,14 +938,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers files: [...files], whenBusy: steer ? "steer" : "followUp", ...(replying ? { replyTo: replying } : {}), - item: { - type: "user", - key: `outgoing-${id}`, - text, - images: [], - files: files.map((file) => file.file.name), - ...(author ? { author } : owner ? { author: { kind: "owner", id: "owner", name: "Owner" } } : {}), - }, + item: outgoingItem(id, text, files, author ?? (owner ? OWNER_AUTHOR : undefined)), }); setDraft(""); setFiles([]); @@ -785,8 +946,8 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers 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 }); - latest.current = { chat: writer, host, sessionId, conversationId }; + 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) { @@ -797,7 +958,10 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers send: async (message) => { const { chat: current, host: client, sessionId: session, conversationId: conversation } = latest.current; if (!current) throw new Error("not connected"); - const after = newest.current; + // 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) { @@ -832,7 +996,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers }, }); return () => outbox.connect(undefined); - }, [live, outbox, restore, setFiles, setReplyTo, setError]); + }, [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; @@ -927,6 +1091,152 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers controls={modelControl} /> ); + const toBottom = !follow.atBottom && ( + + ); + // 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 && ( +
+ {/* A one-shot view has no composer, which otherwise carries this button. */} + {oneShot && toBottom} +
+ + {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" : ""} + + {canWrite && !oneShot && ( + + )} + {canWrite && oneShot && busy && ( + + )} +
+
+ ); + const readOnlyBar = (!canWrite || failure) && ( +
+ {failure ? "Session unavailable" : grant ? `Joined as ${grant.name} · read-only` : "Connecting…"} +
+ ); + const queuedRows = queued.length + pendingQueued.length > 0 && ( +
+ {pendingQueued.map((message) => ( +
+ + +
+ ))} + {queued.map((item) => ( +
+ + +
+ ))} +
+ ); + 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: , + }, + footer, + }, + }), + }, + follow.dock, + ); return ( <> @@ -977,7 +1287,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers )} setError(undefined)} /> -
+ {place(
{failure && connected ? (
@@ -1004,8 +1314,8 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers

Ask the owner to retry this session.

)}
- ) : transcript ? ( - shown?.items.length && rows ? ( + ) : shown ? ( + shown.items.length && rows ? ( @@ -1017,7 +1327,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
)} -
-
- {child && ( -
- {/* A one-shot view has no composer, which otherwise carries this button. */} - {oneShot && !follow.atBottom && ( - - )} -
- - {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" : ""} - - {canWrite && !oneShot && ( - - )} - {canWrite && oneShot && busy && ( - - )} -
-
- )} - {oneShot && canWrite ? null : ( -
- {/* Only with the composer, as in Pi Mobile: the read-only bar has no scroll button. */} - {canWrite && !failure && !follow.atBottom && ( - - )} - {!canWrite || failure ? ( -
- - {failure - ? "Session unavailable" - : grant - ? `Joined as ${grant.name} · read-only` - : "Connecting…"} - -
- ) : ( - <> - {transcript && transcript.queued.length + pendingQueued.length > 0 && ( -
- {pendingQueued.map((message) => ( -
- - -
- ))} - {transcript.queued.map((item) => ( -
- - -
- ))} -
- )} - setReplyTo(undefined) }} - > - - - {footer} - - )} -
- )} -
- - + , + )} ); } function NewSession() { - const follow = useChatScroll(); const connected = useConnected(); - const host = useHost(); const owner = useOwner(); const catalog = useObject(Sessions, {}); const listed = useReadable(liveIn(catalog, "owner", "guest")?.sessions); @@ -1244,77 +1388,118 @@ function NewSession() { 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, - sending, - setSending, - submission, - steer, - setSteer, - setStopped, - } = composer; + const { draft, setDraft, files, setFiles, error, setError, steer, setSteer, setStopped } = composer; const cwdSheet = useSheet("cwd"); const modelSheet = useSheet("model"); const attaching = attachingOf(composer); - const send = async () => { + // 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) || !sessions || sending || !connected || !owner) return; + if ((!text && !files.length) || !owner) return; if (!choice) { openLayer({ kind: "sheet", name: "model" }); return; } setStopped(undefined); - const key = `new:1:${text}\u0000${files.map((file) => file.key).join()}`; - if (submission.current?.key !== key) submission.current = { key, id: crypto.randomUUID() }; - setSending(true); - try { - const session = await sessions?.create( - { - env: location.env, - cwd: location.cwd, - model: choice.model, - thinkingLevel: choice.thinkingLevel, - }, - BACKGROUND_CONTEXT, - ); - const chat = host.hold(Chat, { session: session.id, conversation: 1 }, BACKGROUND_CONTEXT); - try { - navigate({ session: session.id }, { replace: true }); - await whenLive(chat); - const attachments: string[] = []; - for (const file of files) { - // Upload IDs are session-scoped: a new creation must not reuse an earlier failed creation's IDs. - file.session = session.id; - file.uploaded = await upload(session.id, file.file); - attachments.push(file.uploaded); - } - await liveIn(chat, "participant")!.send( - { - text, - requestId: submission.current.id, - ...(attachments.length ? { attachments } : {}), - }, - BACKGROUND_CONTEXT, - ); - setDraft(""); - setFiles([]); - setSteer(true); - submission.current = undefined; - } finally { - chat.release(); - } - } catch (error) { - setError(errorText(error)); - } finally { - setSending(false); - } + 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: ( +
+
+ +
+ {/* The session's context bar, empty: the dock keeps its height when Send opens the session. */} + +
+ ), + } + : { + bar: ( +
+ Connecting… +
+ ), + }, + }, + follow.dock, + ); return ( <> @@ -1346,76 +1531,52 @@ function NewSession() { /> )} setError(undefined)} /> -
-
-
-
- -

What should Pi do?

-
- - {owner && resources && !resources.credentials.length && ( + {place( +
+ {outgoing && rows ? ( + + ) : ( +
+
+ +

What should Pi do?

+
- )} -
-
-
-
- {owner ? ( - <> - void send()} - attaching={attaching} - /> -
-
- -
-
- - ) : ( -
- Connecting… -
- )} -
-
-
+ {owner && resources && !resources.credentials.length && ( + + )} +
+ )} +
, + )} ); } /** 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 ( <> -
-
-
+ {place(
)} ); } @@ -1430,19 +1591,34 @@ function Main() { useEffect(() => { if (grant && !grantMode(grant, route.session)) void nav.replace(conversationRoute(route, grant.session), route); }, [grant, route, nav]); - if (ended) return ; - return route.session ? ( - - - - - - ) : ( - + // The frame outlives the screens in it: its dock, with the one composer, stays while they change. + const [view, setView] = useState(null); + const [dock, setDock] = useState(null); + const [contents] = useState(() => new DockContents()); + const composer = useRef(null); + const frame = useMemo(() => ({ view, dock, contents, composer }), [view, dock, contents]); + const conversation = route.session && `${route.session}:${route.conversation ?? 1}`; + return ( + + {ended ? ( + + ) : route.session ? ( + + + + + + ) : ( + + )} + {/* The transcript of the screen above goes into the view (`useChatScreen`), the dock floats over its bottom. */} +
+
+ +
+ {/* Below the chat, per conversation (the bottom terminal panel). */} + {conversation && !ended && } + ); } diff --git a/apps/p2/src/runtime/refs.ts b/apps/p2/src/runtime/refs.ts index 2b0c2f1f4..67fda9920 100644 --- a/apps/p2/src/runtime/refs.ts +++ b/apps/p2/src/runtime/refs.ts @@ -45,6 +45,15 @@ type RegistryHost = { registry(registry: Registry, context: Context) /** Registry values a host gave `followRegistry`, by host and registry: a later follower starts at once. */ const registryValues = new WeakMap, RegistryValue>>(); +/** + * Registry `registry`'s composed value on `host` if it was followed before (`followRegistry`): what a UI's first render + * shows, before its follower starts. + */ +export function knownRegistry(host: RegistryHost, registry: Registry): V | undefined { + const known = registryValues.get(host)?.get(registry as Registry) as RegistryValue | undefined; + return known?.value; +} + /** * `follow` for registry `registry`'s composed value on `host`, once the host published it; at once (synchronously) * when this host's value of it was followed before. diff --git a/apps/p2/src/runtime/web.tsx b/apps/p2/src/runtime/web.tsx index 3268312db..ec79aad95 100644 --- a/apps/p2/src/runtime/web.tsx +++ b/apps/p2/src/runtime/web.tsx @@ -40,7 +40,7 @@ export { import { Access } from "./access.ts"; import type { KeyedRegistry } from "./chord.ts"; import { canonicalJson } from "./json.ts"; -import { follow, followRegistry, type HeldIn, type HeldRef, statusOf, UI_LINGER } from "./refs.ts"; +import { follow, followRegistry, type HeldIn, type HeldRef, knownRegistry, statusOf, UI_LINGER } from "./refs.ts"; import { Slots } from "./web-core.ts"; export { Slots, webCoreFacet } from "./web-core.ts"; @@ -58,7 +58,9 @@ const EMPTY: ReadonlyMap = new Map(); /** A keyed registry's composed table (empty until the host published it); re-renders when it changes. */ export function useRegistry(registry: KeyedRegistry): ReadonlyMap { const host = useHost(); - const [table, setTable] = useState>(EMPTY); + // The table known already, at once: a slot mounted with an empty table renders its children unwrapped first, and + // wrapping them a render later would mount them again (losing their state and focus). + const [table, setTable] = useState>(() => knownRegistry(host, registry) ?? EMPTY); useEffect(() => followRegistry(host, registry, (next) => setTable(next)), [host, registry]); return table; } diff --git a/apps/p2/src/web/composer-state.tsx b/apps/p2/src/web/composer-state.tsx index e347a6d0a..44e03141e 100644 --- a/apps/p2/src/web/composer-state.tsx +++ b/apps/p2/src/web/composer-state.tsx @@ -20,13 +20,10 @@ type State = { setFiles: Dispatch>; steer: boolean; setSteer: Dispatch>; - sending: boolean; - setSending: Dispatch>; error: string | undefined; setError: Dispatch>; stopped: { sessionId: string; conversationId: number } | undefined; setStopped: Dispatch>; - submission: { current: { key: string; id: string } | undefined }; viewer: string; addFiles: (files: File[]) => void; replyTo: ReplyTarget | undefined; @@ -44,10 +41,8 @@ export function ComposerState({ children }: { children: ReactNode }) { const [draft, setDraft] = useState(""); const [files, setFiles] = useState([]); const [steer, setSteer] = useState(true); - const [sending, setSending] = useState(false); const [error, setError] = useState(); const [stopped, setStopped] = useState(); - const submission = useRef<{ key: string; id: string } | undefined>(undefined); const viewer = useRef(crypto.randomUUID()); const [replyTo, setReplyTo] = useState(); const [canReply, setCanReply] = useState(false); @@ -98,13 +93,10 @@ export function ComposerState({ children }: { children: ReactNode }) { setFiles, steer, setSteer, - sending, - setSending, error, setError, stopped, setStopped, - submission, viewer: viewer.current, addFiles, replyTo, diff --git a/apps/p2/src/web/style.css b/apps/p2/src/web/style.css index dc348a9b7..0080b4cb0 100644 --- a/apps/p2/src/web/style.css +++ b/apps/p2/src/web/style.css @@ -452,6 +452,10 @@ textarea.input { flex-direction: column; min-height: 0; } +/* The place of a screen's transcript in the chat (plugins/chat): the scroller is .chat's flex item. */ +.chat-view { + display: contents; +} .drop-overlay { position: absolute; inset: var(--s2); diff --git a/apps/p2/test/p2/session-start.test.ts b/apps/p2/test/p2/session-start.test.ts new file mode 100644 index 000000000..73f552d70 --- /dev/null +++ b/apps/p2/test/p2/session-start.test.ts @@ -0,0 +1,146 @@ +// A new session's first messages (plugins/chat/session-start.ts): shown before the session exists, the session created +// once a creator is connected, the messages handed to its main conversation's outbox, and put back into the composer +// when the server refuses the creation. +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { type Outgoing, outboxOf } from "../../plugins/chat/outbox.ts"; +import { type Creator, SessionStart } from "../../plugins/chat/session-start.ts"; + +const message = (id: string): Outgoing => ({ + id, + text: `text ${id}`, + files: [], + whenBusy: "followUp", + item: { type: "user", key: `outgoing-${id}`, text: `text ${id}`, images: [], files: [] }, +}); +const place = { + location: { env: null, cwd: "/tmp" }, + choice: { model: { provider: "faux", modelId: "faux-1" }, thinkingLevel: "off" }, +} as const; +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +const ticks = async (count: number) => { + for (let index = 0; index < count; index++) await tick(); +}; + +/** A creator recording what it did; `fail` decides per call what goes wrong, `gate` holds a creation until released. */ +function creator(fail: (call: number) => "refuse" | "drop" | undefined = () => undefined) { + const created: string[] = []; + const restored: { ids: string[]; error: string }[] = []; + let calls = 0; + let gate: Promise | undefined; + const value: Creator = { + create: async () => { + const call = ++calls; + await gate; + const outcome = fail(call); + if (outcome === "refuse") throw new Error("refused"); + if (outcome === "drop") throw new Error("connection lost"); + return `session-${crypto.randomUUID()}`; + }, + refused: (error) => error instanceof Error && error.message === "refused", + created: (session) => created.push(session), + restore: (messages, error) => + restored.push({ ids: messages.map((entry) => entry.id), error: (error as Error).message }), + }; + return { + value, + created, + restored, + calls: () => calls, + hold: () => { + const { promise, resolve } = Promise.withResolvers(); + gate = promise; + return () => { + gate = undefined; + resolve(); + }; + }, + }; +} + +describe("session start", () => { + it("shows messages before a creator connects, then creates the session once and hands them to its outbox", async () => { + const start = new SessionStart({ retryDelay: () => 1 }); + start.add(message("a"), place); + await tick(); + assert.deepEqual( + start.snapshot()?.messages.map((entry) => entry.id), + ["a"], + ); + const { value, created, calls } = creator(); + start.connect(value); + await ticks(5); + assert.equal(calls(), 1); + assert.equal(created.length, 1); + assert.equal(start.snapshot(), undefined); + assert.deepEqual( + outboxOf(`${created[0]}#1`) + .snapshot() + .map((entry) => entry.id), + ["a"], + ); + }); + + it("keeps the first message's place; messages sent while creating join the same session", async () => { + const start = new SessionStart({ retryDelay: () => 1 }); + const { value, created, calls, hold } = creator(); + const release = hold(); + start.connect(value); + start.add(message("a"), place); + start.add(message("b"), { ...place, location: { env: null, cwd: "/elsewhere" } }); + await ticks(3); + assert.equal(start.snapshot()?.location.cwd, "/tmp"); + release(); + await ticks(5); + assert.equal(calls(), 1); + assert.deepEqual( + outboxOf(`${created[0]}#1`) + .snapshot() + .map((entry) => entry.id), + ["a", "b"], + ); + }); + + it("puts the messages back when the server refuses the creation", async () => { + const start = new SessionStart({ retryDelay: () => 1 }); + const { value, created, restored } = creator(() => "refuse"); + start.add(message("a"), place); + start.add(message("b"), place); + start.connect(value); + await ticks(5); + assert.deepEqual(created, []); + assert.deepEqual(restored, [{ ids: ["a", "b"], error: "refused" }]); + assert.equal(start.snapshot(), undefined); + }); + + it("tries a lost connection again, and gives up after six failures while connected", async () => { + const start = new SessionStart({ retryDelay: () => 1 }); + const flaky = creator((call) => (call === 1 ? "drop" : undefined)); + start.add(message("a"), place); + start.connect(flaky.value); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(flaky.calls(), 2); + assert.equal(flaky.created.length, 1); + + const broken = creator(() => "drop"); + start.connect(broken.value); + start.add(message("b"), place); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(broken.calls(), 6); + assert.deepEqual(broken.restored, [{ ids: ["b"], error: "connection lost" }]); + }); + + it("waits while disconnected", async () => { + const start = new SessionStart({ retryDelay: () => 1 }); + const { value, calls } = creator(); + start.connect(value); + start.connect(undefined); + start.add(message("a"), place); + await ticks(5); + assert.equal(calls(), 0); + assert.deepEqual( + start.snapshot()?.messages.map((entry) => entry.id), + ["a"], + ); + }); +}); diff --git a/apps/p2/test/p2/ui-chat-controls.test.ts b/apps/p2/test/p2/ui-chat-controls.test.ts index 34e269464..55c15997d 100644 --- a/apps/p2/test/p2/ui-chat-controls.test.ts +++ b/apps/p2/test/p2/ui-chat-controls.test.ts @@ -1,14 +1,14 @@ // Main-view controls exercise real browser events against fake providers and disposable data. import assert from "node:assert/strict"; -import { mkdirSync, readdirSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { after, before, describe, it } from "node:test"; import { BACKGROUND_CONTEXT } from "@p2/app/v1"; import { Chat } from "../../plugins/chat/contract.ts"; import { Sessions } from "../../plugins/sessions/contract.ts"; import { Browser, browserTests } from "./browser.ts"; -import { type Fixture, holdIn, startFixture, until } from "./helpers.ts"; +import { type Fixture, holdIn, poll, startFixture, until } from "./helpers.ts"; const input = (selector: string, value: string) => `(()=>{const e=document.querySelector(${JSON.stringify(selector)});Object.getOwnPropertyDescriptor(e.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype,'value').set.call(e,${JSON.stringify(value)});e.dispatchEvent(new Event('input',{bubbles:true}));})()`; @@ -155,11 +155,13 @@ describe("chat control parity", { skip: !browserTests }, () => { await browser.wait( `[...document.querySelectorAll('.bubble-files span')].map(e=>e.textContent).join()==='image.png,pasted-image.png' && !document.querySelector('.attachment')`, ); - assert.deepEqual( + // The message shows before it is sent (plugins/chat/outbox.ts), even before the conversation has loaded; the + // uploads follow once its chat object is live. + const uploaded = () => readdirSync(join(fixture.dataDir, "uploads", pasted)) .map((file) => file.slice(37)) - .sort(), - ["image.png", "pasted-image.png"], - ); + .sort(); + await poll(() => existsSync(join(fixture.dataDir, "uploads", pasted)) && uploaded().length === 2); + assert.deepEqual(uploaded(), ["image.png", "pasted-image.png"]); }); }); diff --git a/apps/p2/test/p2/ui-outbox.test.ts b/apps/p2/test/p2/ui-outbox.test.ts index 8c9f6dce3..5a81b20f3 100644 --- a/apps/p2/test/p2/ui-outbox.test.ts +++ b/apps/p2/test/p2/ui-outbox.test.ts @@ -235,4 +235,85 @@ describe("offline in the browser", { skip: !browserTests }, () => { chat.release(); } }); + + it("keeps the one composer, focused, when a new session's first message opens the session", async () => { + await browser.metrics(390, 844); + await browser.open(`http://127.0.0.1:${fixture.server.port}/`); + await browser.wait( + `document.querySelector('.composer textarea') && document.querySelector('.cwd-pick') && !document.querySelector('.offline') && document.querySelector('.model-control')?.textContent !== 'Choose model'`, + ); + await browser.evaluate( + `window.__composer = document.querySelector('.composer textarea'); window.__composer.focus()`, + ); + const text = "first message, still typing"; + await browser.evaluate(draft(text)); + // Sent as Enter sends it, the focus staying in the textarea. + await browser.evaluate(`document.querySelector('.composer').requestSubmit()`); + await browser.wait(`location.pathname.startsWith('/s/') && ${bubbles(text)} === 1`); + // One composer for both screens: the same textarea, focused throughout. + assert.ok( + await browser.evaluate( + `document.querySelector('.composer textarea') === window.__composer && document.activeElement === window.__composer`, + ), + "the session opens with the same composer, still focused", + ); + }); + + it("shows a new session's first message at once and creates the session once back online", async () => { + const catalog = await holdIn(owner, Sessions, {}, "owner"); + const before = new Set(catalog.sessions.value.map((session) => session.id)); + await browser.metrics(390, 844); + await browser.open(`http://127.0.0.1:${fixture.server.port}/`); + await browser.wait( + `document.querySelector('.composer textarea') && document.querySelector('.cwd-pick') && !document.querySelector('.offline') && document.querySelector('.model-control')?.textContent !== 'Choose model'`, + ); + + // The composer's place: the new-session screen has the session screen's context bar (empty), so it stays. + const composerTop = `Math.round(document.querySelector('.composer').getBoundingClientRect().top)`; + const top = await browser.evaluate(composerTop); + await browser.evaluate("window.__goOffline()"); + await browser.wait(`Boolean(document.querySelector('.offline'))`); + const text = "first message while offline"; + await browser.evaluate(draft(text)); + await browser.click("button[aria-label=Send]"); + // It leaves the composer and shows at once, on the new-session screen: the session does not exist yet. + await browser.wait( + `document.querySelector('.composer textarea').value === '' && ${bubbles(text)} === 1 && location.pathname === '/'`, + ); + assert.equal(catalog.sessions.value.filter((session) => !before.has(session.id)).length, 0); + // From here until the answer: the message never leaves the screen (creation, navigation, the worker's start). + await browser.evaluate( + `(() => { window.__shown = []; window.__tops = []; window.__sampler = setInterval(() => { window.__shown.push(${bubbles(text)}); window.__tops.push(${composerTop}); }, 10); })()`, + ); + + await browser.evaluate("window.__offline = false"); + await browser.wait(`location.pathname.startsWith('/s/')`); + const id = await browser.evaluate("location.pathname.slice(3)"); + const chat = await holdIn(owner, Chat, { session: id, conversation: 1 }, "participant"); + const stop = follow(chat.view, () => {}); + try { + await until(chat.view, (view) => JSON.stringify(view?.entries ?? []).includes(`Echo: ${text}`), 60_000); + await browser.wait(`document.querySelector('.messages')?.textContent.includes('Echo: ${text}')`); + assert.equal(await browser.evaluate(bubbles(text)), 1, "shown once"); + const shown = await browser.evaluate("clearInterval(window.__sampler), window.__shown"); + assert.ok( + shown.every((count) => count === 1), + `shown throughout: ${JSON.stringify([...new Set(shown)])}`, + ); + assert.deepEqual( + [...new Set(await browser.evaluate("window.__tops"))], + [top], + "the composer stays in place", + ); + assert.equal(inputs(chat.view.value, text), 1, "delivered once"); + assert.deepEqual( + catalog.sessions.value.filter((session) => !before.has(session.id)).map((session) => session.id), + [id], + "one session created", + ); + } finally { + stop(); + chat.release(); + } + }); });