mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
136a7b2d7d
commit
ccff9876e7
11 changed files with 885 additions and 345 deletions
|
|
@ -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
|
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
|
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
|
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`;
|
- **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
|
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
|
near the viewport (one viewport of overscan each way, everything up to the first row near the list's start) between
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,13 @@ export type Sender = {
|
||||||
const SHOWN_AT_MOST = 10_000;
|
const SHOWN_AT_MOST = 10_000;
|
||||||
const RETRY_MIN = 500;
|
const RETRY_MIN = 500;
|
||||||
const RETRY_MAX = 10_000;
|
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
|
* 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.
|
* 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 {
|
export class Outbox {
|
||||||
readonly #retryDelay: (attempt: number) => number;
|
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. */
|
/** 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 } = {}) {
|
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;
|
this.#shownAtMost = timing.shownAtMost ?? SHOWN_AT_MOST;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
108
apps/p2/plugins/chat/session-start.ts
Normal file
108
apps/p2/plugins/chat/session-start.ts
Normal 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();
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
buildTranscript,
|
buildTranscript,
|
||||||
ChevronIcon,
|
ChevronIcon,
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
|
ContextUsage,
|
||||||
CostPanel,
|
CostPanel,
|
||||||
closeLayers,
|
closeLayers,
|
||||||
conversationRoute,
|
conversationRoute,
|
||||||
|
|
@ -63,6 +64,7 @@ import {
|
||||||
seedSizes,
|
seedSizes,
|
||||||
setDrawer,
|
setDrawer,
|
||||||
setSidebarCollapsed,
|
setSidebarCollapsed,
|
||||||
|
type Transcript,
|
||||||
takeReveal,
|
takeReveal,
|
||||||
transcriptOf,
|
transcriptOf,
|
||||||
transcriptRows,
|
transcriptRows,
|
||||||
|
|
@ -83,7 +85,19 @@ import {
|
||||||
useStatus,
|
useStatus,
|
||||||
WorkingDirectory,
|
WorkingDirectory,
|
||||||
} from "@p2/app/v1/web";
|
} 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 { Drafts } from "../drafts/contract.ts";
|
||||||
import { DirectoryPicker, directoryPickerLayer } from "../envs/contract.web.tsx";
|
import { DirectoryPicker, directoryPickerLayer } from "../envs/contract.web.tsx";
|
||||||
import { Models } from "../models/contract.ts";
|
import { Models } from "../models/contract.ts";
|
||||||
|
|
@ -94,6 +108,7 @@ import { type Child, conversationLocation, SubagentControl, Subagents } from "..
|
||||||
import { SessionTree } from "../tree/contract.ts";
|
import { SessionTree } from "../tree/contract.ts";
|
||||||
import { Chat, ChatCosts, TranscriptEntries } from "./contract.ts";
|
import { Chat, ChatCosts, TranscriptEntries } from "./contract.ts";
|
||||||
import { type Outgoing, outboxOf } from "./outbox.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));
|
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. */
|
/** Picked files: shown above the textarea, removable; the attach button and pasted images add more. */
|
||||||
type Attaching = {
|
type Attaching = {
|
||||||
readonly files: readonly PickedFile[];
|
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 }) {
|
function TopBar({ session, child, owner = false }: { session?: Session; child?: Child; owner?: boolean }) {
|
||||||
const connected = useConnected();
|
const connected = useConnected();
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
|
@ -657,7 +798,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
|
||||||
// Kept while reconnecting, as the composer is (`canWrite`).
|
// Kept while reconnecting, as the composer is (`canWrite`).
|
||||||
const replying = canWrite && canReply ? replyTo : undefined;
|
const replying = canWrite && canReply ? replyTo : undefined;
|
||||||
// `@`/Tab file completion for participants; in chat mode (`canReply`) the agent's mention (`@pi`) is offered first.
|
// `@`/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(
|
const fileCompletion = useFileCompletion(
|
||||||
composerRef,
|
composerRef,
|
||||||
setDraft,
|
setDraft,
|
||||||
|
|
@ -711,30 +852,57 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
|
||||||
);
|
);
|
||||||
const stats = useReadable(chat?.stats) ?? undefined;
|
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
|
// 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
|
// message at the end, or a queued input while a run is busy. An accepted one is shown by the server once the view
|
||||||
// input is listed or the view has an entry newer than any before it was sent.
|
// 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 outbox = outboxOf(`${sessionId}#${conversationId}`);
|
||||||
const outgoing = useSyncExternalStore(outbox.subscribe, outbox.snapshot);
|
const outgoing = useSyncExternalStore(outbox.subscribe, outbox.snapshot);
|
||||||
const newestEntry = view?.entries.at(-1)?.id ?? 0;
|
const newestEntry = view?.entries.at(-1)?.id ?? 0;
|
||||||
const newest = useRef(newestEntry);
|
const newest = useRef(newestEntry);
|
||||||
newest.current = 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 queuedIds = useMemo(() => new Set(transcript?.queued.map((item) => item.id)), [transcript]);
|
||||||
const shownByServer = useCallback(
|
const shownByServer = useCallback(
|
||||||
(message: Outgoing) =>
|
(message: Outgoing) =>
|
||||||
message.sent !== undefined && (queuedIds.has(message.sent.submissionId) || newestEntry > message.sent.after),
|
message.sent !== undefined &&
|
||||||
[queuedIds, newestEntry],
|
((!started.get(message.id)?.idle && queuedIds.has(message.sent.submissionId)) ||
|
||||||
|
newestEntry > message.sent.after),
|
||||||
|
[started, queuedIds, newestEntry],
|
||||||
);
|
);
|
||||||
useEffect(() => outbox.settle(shownByServer), [outbox, shownByServer]);
|
useEffect(() => {
|
||||||
const pending = useMemo(() => outgoing.filter((message) => !shownByServer(message)), [outgoing, shownByServer]);
|
outbox.settle(shownByServer);
|
||||||
const busy = transcript?.busy ?? false;
|
for (const id of started.keys()) if (!outgoing.some((message) => message.id === id)) started.delete(id);
|
||||||
const shown = useMemo(
|
}, [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
|
outgoing.filter((message) => {
|
||||||
? { ...transcript, items: [...transcript.items, ...pending.map((message) => message.item)] }
|
const start = started.get(message.id);
|
||||||
: transcript,
|
return !shownByServer(message) && (!start || newestEntry <= start.after);
|
||||||
[transcript, pending, busy],
|
}),
|
||||||
|
[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 rows = useMemo(() => (shown ? transcriptRows(shown.items) : undefined), [shown]);
|
||||||
const follow = useChatScroll({
|
const follow = useChatScroll({
|
||||||
cache: `${sessionId}#${conversationId}`,
|
cache: `${sessionId}#${conversationId}`,
|
||||||
|
|
@ -770,14 +938,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
|
||||||
files: [...files],
|
files: [...files],
|
||||||
whenBusy: steer ? "steer" : "followUp",
|
whenBusy: steer ? "steer" : "followUp",
|
||||||
...(replying ? { replyTo: replying } : {}),
|
...(replying ? { replyTo: replying } : {}),
|
||||||
item: {
|
item: outgoingItem(id, text, files, author ?? (owner ? OWNER_AUTHOR : undefined)),
|
||||||
type: "user",
|
|
||||||
key: `outgoing-${id}`,
|
|
||||||
text,
|
|
||||||
images: [],
|
|
||||||
files: files.map((file) => file.file.name),
|
|
||||||
...(author ? { author } : owner ? { author: { kind: "owner", id: "owner", name: "Owner" } } : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
|
|
@ -785,8 +946,8 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
|
||||||
setSteer(true);
|
setSteer(true);
|
||||||
};
|
};
|
||||||
// The outbox sends while this conversation's chat object is live; the latest values, for its sender.
|
// 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 });
|
const latest = useRef({ chat: writer, host, sessionId, conversationId, busy });
|
||||||
latest.current = { chat: writer, host, sessionId, conversationId };
|
latest.current = { chat: writer, host, sessionId, conversationId, busy };
|
||||||
const live = connected && status === "live" && writer !== undefined;
|
const live = connected && status === "live" && writer !== undefined;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!live) {
|
if (!live) {
|
||||||
|
|
@ -797,7 +958,10 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
|
||||||
send: async (message) => {
|
send: async (message) => {
|
||||||
const { chat: current, host: client, sessionId: session, conversationId: conversation } = latest.current;
|
const { chat: current, host: client, sessionId: session, conversationId: conversation } = latest.current;
|
||||||
if (!current) throw new Error("not connected");
|
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[] = [];
|
const attachments: string[] = [];
|
||||||
for (const file of message.files) {
|
for (const file of message.files) {
|
||||||
if (file.session !== session) {
|
if (file.session !== session) {
|
||||||
|
|
@ -832,7 +996,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return () => outbox.connect(undefined);
|
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.
|
// Leaving the page loses what the outbox has not delivered: the browser asks first.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (outgoing.every((message) => message.sent)) return;
|
if (outgoing.every((message) => message.sent)) return;
|
||||||
|
|
@ -927,6 +1091,152 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
|
||||||
controls={modelControl}
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopBar session={session} child={child} owner={owner} />
|
<TopBar session={session} child={child} owner={owner} />
|
||||||
|
|
@ -977,7 +1287,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ErrorToast error={error} onDismiss={() => setError(undefined)} />
|
<ErrorToast error={error} onDismiss={() => setError(undefined)} />
|
||||||
<div className="chat">
|
{place(
|
||||||
<div className="scroller transcript" ref={follow.scroller}>
|
<div className="scroller transcript" ref={follow.scroller}>
|
||||||
{failure && connected ? (
|
{failure && connected ? (
|
||||||
<div className="hero worker-start-error">
|
<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>
|
<p className="muted">Ask the owner to retry this session.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : transcript ? (
|
) : shown ? (
|
||||||
shown?.items.length && rows ? (
|
shown.items.length && rows ? (
|
||||||
<SessionId.Provider value={sessionId}>
|
<SessionId.Provider value={sessionId}>
|
||||||
<WorkingDirectory.Provider value={viewCwd}>
|
<WorkingDirectory.Provider value={viewCwd}>
|
||||||
<OpenConversation.Provider value={openConversation}>
|
<OpenConversation.Provider value={openConversation}>
|
||||||
|
|
@ -1017,7 +1327,7 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
|
||||||
<RevealEntry.Provider value={reveal}>
|
<RevealEntry.Provider value={reveal}>
|
||||||
<FillGap.Provider value={fillGap}>
|
<FillGap.Provider value={fillGap}>
|
||||||
<Messages
|
<Messages
|
||||||
transcript={shown ?? transcript}
|
transcript={shown}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
list={follow.list}
|
list={follow.list}
|
||||||
range={follow.range}
|
range={follow.range}
|
||||||
|
|
@ -1055,180 +1365,14 @@ function SessionChat({ sessionId, conversationId }: { sessionId: string; convers
|
||||||
<div className="spinner large" />
|
<div className="spinner large" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 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() {
|
function NewSession() {
|
||||||
const follow = useChatScroll();
|
|
||||||
const connected = useConnected();
|
const connected = useConnected();
|
||||||
const host = useHost();
|
|
||||||
const owner = useOwner();
|
const owner = useOwner();
|
||||||
const catalog = useObject(Sessions, {});
|
const catalog = useObject(Sessions, {});
|
||||||
const listed = useReadable(liveIn(catalog, "owner", "guest")?.sessions);
|
const listed = useReadable(liveIn(catalog, "owner", "guest")?.sessions);
|
||||||
|
|
@ -1244,37 +1388,64 @@ function NewSession() {
|
||||||
const choice: ModelChoice | undefined =
|
const choice: ModelChoice | undefined =
|
||||||
selectedChoice ??
|
selectedChoice ??
|
||||||
(fallback ? { model: fallback, thinkingLevel: resources?.settings.thinkingLevel ?? "high" } : undefined);
|
(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 composer = useComposerState();
|
||||||
const {
|
const { draft, setDraft, files, setFiles, error, setError, steer, setSteer, setStopped } = composer;
|
||||||
draft,
|
|
||||||
setDraft,
|
|
||||||
files,
|
|
||||||
setFiles,
|
|
||||||
error,
|
|
||||||
setError,
|
|
||||||
sending,
|
|
||||||
setSending,
|
|
||||||
submission,
|
|
||||||
steer,
|
|
||||||
setSteer,
|
|
||||||
setStopped,
|
|
||||||
} = composer;
|
|
||||||
const cwdSheet = useSheet("cwd");
|
const cwdSheet = useSheet("cwd");
|
||||||
const modelSheet = useSheet("model");
|
const modelSheet = useSheet("model");
|
||||||
const attaching = attachingOf(composer);
|
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();
|
const text = draft.trim();
|
||||||
if ((!text && !files.length) || !sessions || sending || !connected || !owner) return;
|
if ((!text && !files.length) || !owner) return;
|
||||||
if (!choice) {
|
if (!choice) {
|
||||||
openLayer({ kind: "sheet", name: "model" });
|
openLayer({ kind: "sheet", name: "model" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStopped(undefined);
|
setStopped(undefined);
|
||||||
const key = `new:1:${text}\u0000${files.map((file) => file.key).join()}`;
|
const id = crypto.randomUUID();
|
||||||
if (submission.current?.key !== key) submission.current = { key, id: crypto.randomUUID() };
|
sessionStart.add(
|
||||||
setSending(true);
|
{
|
||||||
try {
|
id,
|
||||||
const session = await sessions?.create(
|
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,
|
env: location.env,
|
||||||
cwd: location.cwd,
|
cwd: location.cwd,
|
||||||
|
|
@ -1282,39 +1453,53 @@ function NewSession() {
|
||||||
thinkingLevel: choice.thinkingLevel,
|
thinkingLevel: choice.thinkingLevel,
|
||||||
},
|
},
|
||||||
BACKGROUND_CONTEXT,
|
BACKGROUND_CONTEXT,
|
||||||
);
|
)
|
||||||
const chat = host.hold(Chat, { session: session.id, conversation: 1 }, BACKGROUND_CONTEXT);
|
).id,
|
||||||
try {
|
// The server's own errors (a missing directory, an unknown model) refuse; a lost connection is tried again.
|
||||||
navigate({ session: session.id }, { replace: true });
|
refused: (error) => !isChordError(error) || refused(error),
|
||||||
await whenLive(chat);
|
created: (session) => {
|
||||||
const attachments: string[] = [];
|
if (shownHere.current) navigate({ session }, { replace: true });
|
||||||
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,
|
restore: (messages, error) => {
|
||||||
);
|
setDraft((current) => joinDraft(...messages.map((message) => message.text), current));
|
||||||
setDraft("");
|
setFiles((current) => [...messages.flatMap((message) => message.files), ...current]);
|
||||||
setFiles([]);
|
|
||||||
setSteer(true);
|
|
||||||
submission.current = undefined;
|
|
||||||
} finally {
|
|
||||||
chat.release();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setError(errorText(error));
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopBar />
|
<TopBar />
|
||||||
|
|
@ -1346,8 +1531,11 @@ function NewSession() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ErrorToast error={error} onDismiss={() => setError(undefined)} />
|
<ErrorToast error={error} onDismiss={() => setError(undefined)} />
|
||||||
<div className="chat">
|
{place(
|
||||||
<div className="scroller" ref={follow.scroller}>
|
<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">
|
||||||
<div className="hero-title">
|
<div className="hero-title">
|
||||||
<PiLogo />
|
<PiLogo />
|
||||||
|
|
@ -1372,50 +1560,23 @@ function NewSession() {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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. */
|
/** A guest's share ended: Pi Mobile clears the view and says so in its toast; nothing reconnects. */
|
||||||
function ShareEnded() {
|
function ShareEnded() {
|
||||||
|
const place = useChatScreen(undefined);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<button type="button" className="toast">
|
<button type="button" className="toast">
|
||||||
This share has ended.
|
This share has ended.
|
||||||
</button>
|
</button>
|
||||||
<div className="chat">
|
{place(<div className="scroller" />)}
|
||||||
<div className="scroller" />
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1430,19 +1591,34 @@ function Main() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (grant && !grantMode(grant, route.session)) void nav.replace(conversationRoute(route, grant.session), route);
|
if (grant && !grantMode(grant, route.session)) void nav.replace(conversationRoute(route, grant.session), route);
|
||||||
}, [grant, route, nav]);
|
}, [grant, route, nav]);
|
||||||
if (ended) return <ShareEnded />;
|
// The frame outlives the screens in it: its dock, with the one composer, stays while they change.
|
||||||
return route.session ? (
|
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-content">
|
||||||
<Slot name="chat-attribution">
|
<Slot name="chat-attribution">
|
||||||
<SessionChat
|
<SessionChat key={conversation} sessionId={route.session} conversationId={route.conversation ?? 1} />
|
||||||
key={`${route.session}:${route.conversation ?? 1}`}
|
|
||||||
sessionId={route.session}
|
|
||||||
conversationId={route.conversation ?? 1}
|
|
||||||
/>
|
|
||||||
</Slot>
|
</Slot>
|
||||||
</Slot>
|
</Slot>
|
||||||
) : (
|
) : (
|
||||||
<NewSession />
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
/** 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>>>();
|
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)
|
* `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.
|
* when this host's value of it was followed before.
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export {
|
||||||
import { Access } from "./access.ts";
|
import { Access } from "./access.ts";
|
||||||
import type { KeyedRegistry } from "./chord.ts";
|
import type { KeyedRegistry } from "./chord.ts";
|
||||||
import { canonicalJson } from "./json.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";
|
import { Slots } from "./web-core.ts";
|
||||||
|
|
||||||
export { Slots, webCoreFacet } 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. */
|
/** 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> {
|
export function useRegistry<V>(registry: KeyedRegistry<V>): ReadonlyMap<string, V> {
|
||||||
const host = useHost();
|
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]);
|
useEffect(() => followRegistry(host, registry, (next) => setTable(next)), [host, registry]);
|
||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,10 @@ type State = {
|
||||||
setFiles: Dispatch<SetStateAction<PickedFile[]>>;
|
setFiles: Dispatch<SetStateAction<PickedFile[]>>;
|
||||||
steer: boolean;
|
steer: boolean;
|
||||||
setSteer: Dispatch<SetStateAction<boolean>>;
|
setSteer: Dispatch<SetStateAction<boolean>>;
|
||||||
sending: boolean;
|
|
||||||
setSending: Dispatch<SetStateAction<boolean>>;
|
|
||||||
error: string | undefined;
|
error: string | undefined;
|
||||||
setError: Dispatch<SetStateAction<string | undefined>>;
|
setError: Dispatch<SetStateAction<string | undefined>>;
|
||||||
stopped: { sessionId: string; conversationId: number } | undefined;
|
stopped: { sessionId: string; conversationId: number } | undefined;
|
||||||
setStopped: Dispatch<SetStateAction<{ sessionId: string; conversationId: number } | undefined>>;
|
setStopped: Dispatch<SetStateAction<{ sessionId: string; conversationId: number } | undefined>>;
|
||||||
submission: { current: { key: string; id: string } | undefined };
|
|
||||||
viewer: string;
|
viewer: string;
|
||||||
addFiles: (files: File[]) => void;
|
addFiles: (files: File[]) => void;
|
||||||
replyTo: ReplyTarget | undefined;
|
replyTo: ReplyTarget | undefined;
|
||||||
|
|
@ -44,10 +41,8 @@ export function ComposerState({ children }: { children: ReactNode }) {
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [files, setFiles] = useState<PickedFile[]>([]);
|
const [files, setFiles] = useState<PickedFile[]>([]);
|
||||||
const [steer, setSteer] = useState(true);
|
const [steer, setSteer] = useState(true);
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [stopped, setStopped] = useState<State["stopped"]>();
|
const [stopped, setStopped] = useState<State["stopped"]>();
|
||||||
const submission = useRef<{ key: string; id: string } | undefined>(undefined);
|
|
||||||
const viewer = useRef(crypto.randomUUID());
|
const viewer = useRef(crypto.randomUUID());
|
||||||
const [replyTo, setReplyTo] = useState<ReplyTarget>();
|
const [replyTo, setReplyTo] = useState<ReplyTarget>();
|
||||||
const [canReply, setCanReply] = useState(false);
|
const [canReply, setCanReply] = useState(false);
|
||||||
|
|
@ -98,13 +93,10 @@ export function ComposerState({ children }: { children: ReactNode }) {
|
||||||
setFiles,
|
setFiles,
|
||||||
steer,
|
steer,
|
||||||
setSteer,
|
setSteer,
|
||||||
sending,
|
|
||||||
setSending,
|
|
||||||
error,
|
error,
|
||||||
setError,
|
setError,
|
||||||
stopped,
|
stopped,
|
||||||
setStopped,
|
setStopped,
|
||||||
submission,
|
|
||||||
viewer: viewer.current,
|
viewer: viewer.current,
|
||||||
addFiles,
|
addFiles,
|
||||||
replyTo,
|
replyTo,
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,10 @@ textarea.input {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
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 {
|
.drop-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: var(--s2);
|
inset: var(--s2);
|
||||||
|
|
|
||||||
146
apps/p2/test/p2/session-start.test.ts
Normal file
146
apps/p2/test/p2/session-start.test.ts
Normal 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"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
// Main-view controls exercise real browser events against fake providers and disposable data.
|
// Main-view controls exercise real browser events against fake providers and disposable data.
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
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 { join } from "node:path";
|
||||||
import { after, before, describe, it } from "node:test";
|
import { after, before, describe, it } from "node:test";
|
||||||
import { BACKGROUND_CONTEXT } from "@p2/app/v1";
|
import { BACKGROUND_CONTEXT } from "@p2/app/v1";
|
||||||
import { Chat } from "../../plugins/chat/contract.ts";
|
import { Chat } from "../../plugins/chat/contract.ts";
|
||||||
import { Sessions } from "../../plugins/sessions/contract.ts";
|
import { Sessions } from "../../plugins/sessions/contract.ts";
|
||||||
import { Browser, browserTests } from "./browser.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 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}));})()`;
|
`(()=>{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(
|
await browser.wait(
|
||||||
`[...document.querySelectorAll('.bubble-files span')].map(e=>e.textContent).join()==='image.png,pasted-image.png' && !document.querySelector('.attachment')`,
|
`[...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))
|
readdirSync(join(fixture.dataDir, "uploads", pasted))
|
||||||
.map((file) => file.slice(37))
|
.map((file) => file.slice(37))
|
||||||
.sort(),
|
.sort();
|
||||||
["image.png", "pasted-image.png"],
|
await poll(() => existsSync(join(fixture.dataDir, "uploads", pasted)) && uploaded().length === 2);
|
||||||
);
|
assert.deepEqual(uploaded(), ["image.png", "pasted-image.png"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -235,4 +235,85 @@ describe("offline in the browser", { skip: !browserTests }, () => {
|
||||||
chat.release();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue