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

169 lines
6.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Messages sent from the composer, until the server has them: sending never waits for the connection. A message shows
* at once as it will look (`Outgoing.item`), and the outbox sends its messages in order whenever a sender is connected
* (the conversation's view is open and its chat object live), each with its own request ID: a retried send is admitted
* once, so a send whose answer was lost to a broken connection is simply sent again. A message the server refuses goes
* back to the composer (`Sender.restore`); one it accepted stays shown until the transcript shows it (`shown`).
*
* One outbox per conversation, kept for the page's lifetime: switching conversations keeps unsent messages, and they
* go out once their conversation is open again.
*/
import type { Item, PickedFile, ReplyTarget } from "@p2/app/v1/web";
export type Outgoing = {
/** The request ID: a retry of this message is admitted once. */
readonly id: string;
readonly text: string;
readonly files: readonly PickedFile[];
readonly whenBusy: "steer" | "followUp";
readonly replyTo?: ReplyTarget;
/** The message as the transcript will show it (a person's message), shown until it does. */
readonly item: Extract<Item, { type: "user" }>;
/** Accepted: its submission (a queued input's ID), and the conversation's newest entry before it. */
readonly sent?: { readonly submissionId: number; readonly after: number };
};
/** How an open conversation sends: the server's call, and what to do with a refused message. */
export type Sender = {
/** Sends `message` (uploads, expansion, the call); resolves with what the server accepted. */
send(message: Outgoing): Promise<NonNullable<Outgoing["sent"]>>;
/** Whether `error` refuses the message for good (not allowed, invalid); anything else is retried. */
refused(error: unknown): boolean;
/** Puts a refused message back into the composer, saying why. */
restore(message: Outgoing, error: unknown): void;
};
/** How long an accepted message stays shown when the transcript does not show it (it may never: a dropped steer). */
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.
*/
export const GIVE_UP = 6;
export class Outbox {
readonly #retryDelay: (attempt: number) => number;
readonly #shownAtMost: number;
#messages: readonly Outgoing[] = [];
readonly #listeners = new Set<() => void>();
#sender: Sender | undefined;
#running = false;
/** The message being sent now: the server may already have it. */
#inFlight: string | undefined;
#attempt = 0;
#retry: ReturnType<typeof setTimeout> | undefined;
/** 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 ?? retryDelay;
this.#shownAtMost = timing.shownAtMost ?? SHOWN_AT_MOST;
}
subscribe = (listener: () => void): (() => void) => {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
};
snapshot = (): readonly Outgoing[] => this.#messages;
/** Queues `message` and sends it as soon as a sender is connected. */
add(message: Outgoing): void {
this.#set([...this.#messages, message]);
void this.#run();
}
/** Takes back `id` if it is not sent (nor being sent): the queued chip's "move back to editor". */
take(id: string): Outgoing | undefined {
const message = this.#messages.find((candidate) => candidate.id === id);
if (!message || message.sent || message.id === this.#inFlight) return undefined;
this.remove(id);
return message;
}
/** Drops `id`, sent or not. */
remove(id: string): void {
this.#set(this.#messages.filter((message) => message.id !== id));
}
/**
* Takes back the messages not sent yet (Stop puts them into the composer, as the server's queued inputs). Not the
* one being sent: the server may have it already, and Stop's own answer returns it if so.
*/
takeUnsent(): Outgoing[] {
const taken = (message: Outgoing) => !message.sent && message.id !== this.#inFlight;
const unsent = this.#messages.filter(taken);
if (unsent.length) this.#set(this.#messages.filter((message) => !taken(message)));
return unsent;
}
/** How to send now, or undefined while sending cannot work (offline, the chat object not live). */
connect(sender: Sender | undefined): void {
this.#sender = sender;
if (sender) {
this.#attempt = 0;
void this.#run();
}
}
/** Drops accepted messages that `shown` says the transcript shows now. */
settle(shown: (message: Outgoing) => boolean): void {
const kept = this.#messages.filter((message) => !message.sent || !shown(message));
if (kept.length !== this.#messages.length) this.#set(kept);
}
#set(messages: readonly Outgoing[]): void {
this.#messages = messages;
for (const listener of [...this.#listeners]) listener();
}
/** Sends the unsent messages in order, one at a time; a failure that is not a refusal waits and tries again. */
async #run(): Promise<void> {
if (this.#running) return;
this.#running = true;
clearTimeout(this.#retry);
try {
for (;;) {
const sender = this.#sender;
const next = this.#messages.find((message) => !message.sent);
if (!sender || !next) return;
this.#inFlight = next.id;
try {
const sent = await sender.send(next);
this.#attempt = 0;
// Removed meanwhile (its chip's ×): the server has it now and shows it.
if (this.#messages.some((message) => message.id === next.id))
this.#set(this.#messages.map((message) => (message.id === next.id ? { ...message, sent } : message)));
setTimeout(() => this.remove(next.id), this.#shownAtMost);
} catch (error) {
if (sender.refused(error) || this.#attempt + 1 >= GIVE_UP) {
this.#attempt = 0;
this.remove(next.id);
sender.restore(next, error);
continue;
}
this.#retry = setTimeout(() => void this.#run(), this.#retryDelay(this.#attempt++));
return;
} finally {
this.#inFlight = undefined;
}
}
} finally {
this.#running = false;
}
}
}
const outboxes = new Map<string, Outbox>();
/** The outbox of conversation `key` (`session#conversation`). */
export function outboxOf(key: string): Outbox {
let outbox = outboxes.get(key);
if (!outbox) {
outbox = new Outbox();
outboxes.set(key, outbox);
}
return outbox;
}