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.
This commit is contained in:
Maximilian Blazek 2026-11-05 12:48:07 +00:00
parent 136a7b2d7d
commit ccff9876e7
11 changed files with 885 additions and 345 deletions

View file

@ -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

View file

@ -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;
}

View file

@ -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<string>;
/** 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<typeof setTimeout> | 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<Start, "messages">): 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<void> {
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();

View file

@ -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<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();
@ -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<HTMLTextAreaElement>(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<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 && (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 && (
<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} />
@ -977,7 +1287,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
)}
<ErrorToast error={error} onDismiss={() => setError(undefined)} />
<div className="chat">
{place(
<div className="scroller transcript" ref={follow.scroller}>
{failure && connected ? (
<div className="hero worker-start-error">
@ -1004,8 +1314,8 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
<p className="muted">Ask the owner to retry this session.</p>
)}
</div>
) : transcript ? (
shown?.items.length && rows ? (
) : shown ? (
shown.items.length && rows ? (
<SessionId.Provider value={sessionId}>
<WorkingDirectory.Provider value={viewCwd}>
<OpenConversation.Provider value={openConversation}>
@ -1017,7 +1327,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
<RevealEntry.Provider value={reveal}>
<FillGap.Provider value={fillGap}>
<Messages
transcript={shown ?? transcript}
transcript={shown}
rows={rows}
list={follow.list}
range={follow.range}
@ -1055,180 +1365,14 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
<div className="spinner large" />
</div>
)}
</div>
<div className="dock" ref={follow.dock}>
{child && (
<div className="composer-wrap subagent-wrap">
{/* A one-shot view has no composer, which otherwise carries this button. */}
{oneShot && !follow.atBottom && (
<button
type="button"
className="to-bottom"
onClick={follow.toBottom}
aria-label="Scroll to bottom"
>
<DownIcon />
</button>
</div>,
)}
<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>
)}
{oneShot && canWrite ? null : (
<div className="composer-wrap">
{/* Only with the composer, as in Pi Mobile: the read-only bar has no scroll button. */}
{canWrite && !failure && !follow.atBottom && (
<button
type="button"
className="to-bottom"
onClick={follow.toBottom}
aria-label="Scroll to bottom"
>
<DownIcon />
</button>
)}
{!canWrite || failure ? (
<div className="subagent-bar">
<span>
{failure
? "Session unavailable"
: grant
? `Joined as ${grant.name} · read-only`
: "Connecting…"}
</span>
</div>
) : (
<>
{transcript && transcript.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>
))}
{transcript.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>
)}
<Composer
draft={draft}
setDraft={setDraft}
busy={busy}
steer={steer}
setSteer={setSteer}
disabled={false}
onSend={send}
onStop={stop}
attaching={attaching}
completion={fileCompletion}
textareaRef={composerRef}
replying={replying && { name: replying.name, onCancel: () => setReplyTo(undefined) }}
>
<SlotList prefix="composer" />
</Composer>
{footer}
</>
)}
</div>
)}
</div>
</div>
<Slot name="chat-bottom" />
</>
);
}
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,37 +1388,64 @@ 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(
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,
@ -1282,39 +1453,53 @@ function NewSession() {
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 } : {}),
)
).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 });
},
BACKGROUND_CONTEXT,
);
setDraft("");
setFiles([]);
setSteer(true);
submission.current = undefined;
} finally {
chat.release();
}
} catch (error) {
restore: (messages, error) => {
setDraft((current) => joinDraft(...messages.map((message) => message.text), current));
setFiles((current) => [...messages.flatMap((message) => message.files), ...current]);
setError(errorText(error));
} finally {
setSending(false);
},
});
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 />
@ -1346,8 +1531,11 @@ function NewSession() {
/>
)}
<ErrorToast error={error} onDismiss={() => setError(undefined)} />
<div className="chat">
<div className="scroller" ref={follow.scroller}>
{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 />
@ -1372,50 +1560,23 @@ function NewSession() {
</button>
)}
</div>
</div>
<div className="dock" ref={follow.dock}>
<div className="composer-wrap">
{owner ? (
<>
<Composer
draft={draft}
setDraft={setDraft}
busy={false}
steer={steer}
setSteer={setSteer}
disabled={!connected || sending}
onSend={() => void send()}
attaching={attaching}
/>
<div className="session-usage">
<div className="session-controls">
<ModelControl resources={resources} {...(choice ? { choice } : {})} />
</div>
</div>
</>
) : (
<div className="subagent-bar">
<span>Connecting…</span>
</div>
)}
</div>
</div>
</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>
<div className="chat">
<div className="scroller" />
</div>
{place(<div className="scroller" />)}
</>
);
}
@ -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 <ShareEnded />;
return route.session ? (
// 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={`${route.session}:${route.conversation ?? 1}`}
sessionId={route.session}
conversationId={route.conversation ?? 1}
/>
<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>
);
}

View file

@ -45,6 +45,15 @@ type RegistryHost = { registry<C, V>(registry: Registry<C, V>, context: Context)
/** Registry values a host gave `followRegistry`, by host and registry: a later follower starts at once. */
const registryValues = new WeakMap<RegistryHost, Map<Registry<unknown, unknown>, RegistryValue<unknown>>>();
/**
* 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<C, V>(host: RegistryHost, registry: Registry<C, V>): V | undefined {
const known = registryValues.get(host)?.get(registry as Registry<unknown, unknown>) as RegistryValue<V> | 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.

View file

@ -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<string, never> = new Map<string, never>();
/** A keyed registry's composed table (empty until the host published it); re-renders when it changes. */
export function useRegistry<V>(registry: KeyedRegistry<V>): ReadonlyMap<string, V> {
const host = useHost();
const [table, setTable] = useState<ReadonlyMap<string, V>>(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<ReadonlyMap<string, V>>(() => knownRegistry(host, registry) ?? EMPTY);
useEffect(() => followRegistry(host, registry, (next) => setTable(next)), [host, registry]);
return table;
}

View file

@ -20,13 +20,10 @@ type State = {
setFiles: Dispatch<SetStateAction<PickedFile[]>>;
steer: boolean;
setSteer: Dispatch<SetStateAction<boolean>>;
sending: boolean;
setSending: Dispatch<SetStateAction<boolean>>;
error: string | undefined;
setError: Dispatch<SetStateAction<string | undefined>>;
stopped: { sessionId: string; conversationId: number } | undefined;
setStopped: Dispatch<SetStateAction<{ sessionId: string; conversationId: number } | undefined>>;
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<PickedFile[]>([]);
const [steer, setSteer] = useState(true);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
const [stopped, setStopped] = useState<State["stopped"]>();
const submission = useRef<{ key: string; id: string } | undefined>(undefined);
const viewer = useRef(crypto.randomUUID());
const [replyTo, setReplyTo] = useState<ReplyTarget>();
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,

View file

@ -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);

View file

@ -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<void> | 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<void>();
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"],
);
});
});

View file

@ -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"]);
});
});

View file

@ -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<boolean>(
`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<number>(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<string>("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<number>(bubbles(text)), 1, "shown once");
const shown = await browser.evaluate<number[]>("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<number[]>("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();
}
});
});