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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
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,
|
||||
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 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" />
|
||||
</div>,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NewSession() {
|
||||
const follow = useChatScroll();
|
||||
const connected = useConnected();
|
||||
const host = useHost();
|
||||
const owner = useOwner();
|
||||
const catalog = useObject(Sessions, {});
|
||||
const listed = useReadable(liveIn(catalog, "owner", "guest")?.sessions);
|
||||
|
|
@ -1244,77 +1388,118 @@ function NewSession() {
|
|||
const choice: ModelChoice | undefined =
|
||||
selectedChoice ??
|
||||
(fallback ? { model: fallback, thinkingLevel: resources?.settings.thinkingLevel ?? "high" } : undefined);
|
||||
const contextWindow = resources?.models.find(
|
||||
(entry) => entry.provider === choice?.model.provider && entry.modelId === choice?.model.modelId,
|
||||
)?.contextWindow;
|
||||
const composer = useComposerState();
|
||||
const {
|
||||
draft,
|
||||
setDraft,
|
||||
files,
|
||||
setFiles,
|
||||
error,
|
||||
setError,
|
||||
sending,
|
||||
setSending,
|
||||
submission,
|
||||
steer,
|
||||
setSteer,
|
||||
setStopped,
|
||||
} = composer;
|
||||
const { draft, setDraft, files, setFiles, error, setError, steer, setSteer, setStopped } = composer;
|
||||
const cwdSheet = useSheet("cwd");
|
||||
const modelSheet = useSheet("model");
|
||||
const attaching = attachingOf(composer);
|
||||
const send = async () => {
|
||||
// The first messages, shown at once while the session is created (session-start.ts).
|
||||
const start = useSyncExternalStore(sessionStart.subscribe, sessionStart.snapshot);
|
||||
const outgoing = useMemo(() => (start ? outgoingTranscript(start.messages) : undefined), [start]);
|
||||
const rows = useMemo(() => (outgoing ? transcriptRows(outgoing.items) : undefined), [outgoing]);
|
||||
const follow = useChatScroll({ ...(rows ? { rows } : {}), estimates: rowEstimates });
|
||||
/**
|
||||
* Sends the composer's message without waiting for the session or the connection: it leaves the composer and shows
|
||||
* at once; the session is created when the page is connected, and its outbox delivers the message (session-start.ts).
|
||||
*/
|
||||
const send = () => {
|
||||
const text = draft.trim();
|
||||
if ((!text && !files.length) || !sessions || sending || !connected || !owner) return;
|
||||
if ((!text && !files.length) || !owner) return;
|
||||
if (!choice) {
|
||||
openLayer({ kind: "sheet", name: "model" });
|
||||
return;
|
||||
}
|
||||
setStopped(undefined);
|
||||
const key = `new:1:${text}\u0000${files.map((file) => file.key).join()}`;
|
||||
if (submission.current?.key !== key) submission.current = { key, id: crypto.randomUUID() };
|
||||
setSending(true);
|
||||
try {
|
||||
const session = await sessions?.create(
|
||||
{
|
||||
env: location.env,
|
||||
cwd: location.cwd,
|
||||
model: choice.model,
|
||||
thinkingLevel: choice.thinkingLevel,
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const chat = host.hold(Chat, { session: session.id, conversation: 1 }, BACKGROUND_CONTEXT);
|
||||
try {
|
||||
navigate({ session: session.id }, { replace: true });
|
||||
await whenLive(chat);
|
||||
const attachments: string[] = [];
|
||||
for (const file of files) {
|
||||
// Upload IDs are session-scoped: a new creation must not reuse an earlier failed creation's IDs.
|
||||
file.session = session.id;
|
||||
file.uploaded = await upload(session.id, file.file);
|
||||
attachments.push(file.uploaded);
|
||||
}
|
||||
await liveIn(chat, "participant")!.send(
|
||||
{
|
||||
text,
|
||||
requestId: submission.current.id,
|
||||
...(attachments.length ? { attachments } : {}),
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
setDraft("");
|
||||
setFiles([]);
|
||||
setSteer(true);
|
||||
submission.current = undefined;
|
||||
} finally {
|
||||
chat.release();
|
||||
}
|
||||
} catch (error) {
|
||||
setError(errorText(error));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
sessionStart.add(
|
||||
{
|
||||
id,
|
||||
text,
|
||||
files: [...files],
|
||||
whenBusy: steer ? "steer" : "followUp",
|
||||
item: outgoingItem(id, text, files, OWNER_AUTHOR),
|
||||
},
|
||||
{ location, choice },
|
||||
);
|
||||
setDraft("");
|
||||
setFiles([]);
|
||||
setSteer(true);
|
||||
};
|
||||
// The session is created while the owner's catalog is live; the session opens once it exists, if this screen still
|
||||
// shows (the outbox delivers the messages whenever the session is opened).
|
||||
const shownHere = useRef(true);
|
||||
useEffect(() => {
|
||||
shownHere.current = true;
|
||||
return () => {
|
||||
shownHere.current = false;
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!connected || !sessions) {
|
||||
sessionStart.connect(undefined);
|
||||
return;
|
||||
}
|
||||
sessionStart.connect({
|
||||
create: async ({ location, choice }) =>
|
||||
(
|
||||
await sessions.create(
|
||||
{
|
||||
env: location.env,
|
||||
cwd: location.cwd,
|
||||
model: choice.model,
|
||||
thinkingLevel: choice.thinkingLevel,
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
)
|
||||
).id,
|
||||
// The server's own errors (a missing directory, an unknown model) refuse; a lost connection is tried again.
|
||||
refused: (error) => !isChordError(error) || refused(error),
|
||||
created: (session) => {
|
||||
if (shownHere.current) navigate({ session }, { replace: true });
|
||||
},
|
||||
restore: (messages, error) => {
|
||||
setDraft((current) => joinDraft(...messages.map((message) => message.text), current));
|
||||
setFiles((current) => [...messages.flatMap((message) => message.files), ...current]);
|
||||
setError(errorText(error));
|
||||
},
|
||||
});
|
||||
return () => sessionStart.connect(undefined);
|
||||
}, [connected, sessions, setDraft, setFiles, setError]);
|
||||
// Leaving the page loses a session not created yet: the browser asks first.
|
||||
useEffect(() => {
|
||||
if (!start) return;
|
||||
const warn = (event: BeforeUnloadEvent) => event.preventDefault();
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [start]);
|
||||
const place = useChatScreen(
|
||||
{
|
||||
composer: owner
|
||||
? {
|
||||
props: { draft, setDraft, busy: false, steer, setSteer, disabled: false, onSend: send, attaching },
|
||||
footer: (
|
||||
<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,76 +1531,52 @@ function NewSession() {
|
|||
/>
|
||||
)}
|
||||
<ErrorToast error={error} onDismiss={() => setError(undefined)} />
|
||||
<div className="chat">
|
||||
<div className="scroller" ref={follow.scroller}>
|
||||
<div className="hero">
|
||||
<div className="hero-title">
|
||||
<PiLogo />
|
||||
<h2>What should Pi do?</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="cwd-pick"
|
||||
onClick={() => openLayer(directoryPickerLayer(location.env, location.cwd))}
|
||||
>
|
||||
<FolderIcon />
|
||||
<span>{locationLabel(location.env, location.cwd)}</span>
|
||||
<ChevronIcon size={14} />
|
||||
</button>
|
||||
{owner && resources && !resources.credentials.length && (
|
||||
{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 />
|
||||
<h2>What should Pi do?</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => openLayer({ kind: "sheet", name: "providers" })}
|
||||
className="cwd-pick"
|
||||
onClick={() => openLayer(directoryPickerLayer(location.env, location.cwd))}
|
||||
>
|
||||
Sign in to a provider
|
||||
<FolderIcon />
|
||||
<span>{locationLabel(location.env, location.cwd)}</span>
|
||||
<ChevronIcon size={14} />
|
||||
</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>
|
||||
{owner && resources && !resources.credentials.length && (
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => openLayer({ kind: "sheet", name: "providers" })}
|
||||
>
|
||||
Sign in to a provider
|
||||
</button>
|
||||
)}
|
||||
</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 ? (
|
||||
<Slot name="chat-content">
|
||||
<Slot name="chat-attribution">
|
||||
<SessionChat
|
||||
key={`${route.session}:${route.conversation ?? 1}`}
|
||||
sessionId={route.session}
|
||||
conversationId={route.conversation ?? 1}
|
||||
/>
|
||||
</Slot>
|
||||
</Slot>
|
||||
) : (
|
||||
<NewSession />
|
||||
// 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={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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
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.
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue