diff --git a/app/src/client/pages/ChatPage.tsx b/app/src/client/pages/ChatPage.tsx index f394495..90e0ebc 100644 --- a/app/src/client/pages/ChatPage.tsx +++ b/app/src/client/pages/ChatPage.tsx @@ -82,22 +82,25 @@ export function ChatPage({ }); /** - * Push settings to the agent. With `connect`, it also opens the MCP - * connections a turn needs — connections are held only while work runs - * (an idle Durable Object with live MCP clients never hibernates). + * Push settings to the agent (fast — MCP connections open server-side in + * beforeTurn, not here). */ - async function ensureSetup(connect = false) { - if (!connect && appliedRef.current === settingsHash) return; + async function ensureSetup() { + if (appliedRef.current === settingsHash) return; appliedRef.current = settingsHash; try { await agent.ready; - await agent.call("setup", [settings, { connect }]); + await agent.call("setup", [settings]); } catch (err) { console.warn("PI setup failed", err); appliedRef.current = null; } } + // Sends run strictly in click order: a slow settings push for one message + // must never let a later message overtake it on the wire. + const sendChain = useRef(Promise.resolve()); + // Apply changed settings to a conversation that's already underway. useEffect(() => { if (startedRef.current || messages.length > 0) void ensureSetup(); @@ -109,7 +112,7 @@ export function ChatPage({ if (el) el.scrollTop = el.scrollHeight; }, [messages, status]); - async function send(text: string) { + function send(text: string) { startedRef.current = true; if (isDraft) navigate(`/chat/${chatId}`, true); upsertChat(identity.netid, { @@ -117,9 +120,15 @@ export function ChatPage({ title: firstTitle(messages) ?? text.slice(0, 48), at: Date.now(), }); - // Open this turn's MCP connections (released again when the turn ends). - await ensureSetup(true); - void sendMessage({ text }); + sendChain.current = sendChain.current.then(async () => { + // The first message of a chat needs settings on the server; bound the + // wait so a slow push can never swallow a message silently. + await Promise.race([ + ensureSetup(), + new Promise((resolve) => setTimeout(resolve, 4000)), + ]); + void sendMessage({ text }); + }); } /** Copy history up to `endIndex` (exclusive) into a fresh chat. */ @@ -237,7 +246,7 @@ export function ChatPage({ } onRegenerate={ m.role === "assistant" && m.id === lastAssistantId - ? () => void ensureSetup(true).then(() => regenerate()) + ? () => void regenerate() : undefined } /> diff --git a/app/src/server/pi.ts b/app/src/server/pi.ts index 4eed388..4f473d2 100644 --- a/app/src/server/pi.ts +++ b/app/src/server/pi.ts @@ -155,9 +155,10 @@ export class Pi extends Think { const connectedIds = new Set(Object.keys(this.getMcpServers().servers)); for (const app of PI_APPS) { if (!enabled.has(app.key) || connectedIds.has(app.key)) continue; - // Engine connections are only opened right before a turn (see - // releaseIdleMcp for why); Google is handled here for the consent flow. - if (app.key !== "gcal" && !opts.connect) continue; + // Engine connections open right before a turn (see releaseIdleMcp for + // why); in a plain settings push only the desk's Google consent flow + // needs any connection work. + if (!opts.connect && !(app.key === "gcal" && this.isDesk())) continue; try { if (app.key === "gcal") { if ( @@ -253,6 +254,32 @@ export class Pi extends Think { } } + /** + * Open this turn's MCP connections on the server, so the client can fire + * a message instantly instead of awaiting a connect round-trip first + * (which both delayed the echo of sent messages and let quick successive + * sends overtake each other). Think assembles its automatic MCP toolset + * before this hook runs — while nothing is connected — so the freshly + * connected tools are returned here to be merged into the turn. + */ + override async beforeTurn( + ctx: Parameters[0] + ): Promise extends infer R ? Awaited : never> { + const inherited = await super.beforeTurn(ctx); + const settings = this.getConfig(); + if (!settings) return inherited ?? undefined; + try { + await this.setup(settings, { connect: true }); + } catch (err) { + console.warn("beforeTurn connect failed", err); + } + const tools = this.mcp.getAITools(); + return { + ...(inherited ?? {}), + tools: { ...(inherited?.tools ?? {}), ...tools }, + }; + } + override async onStart(props?: Record) { await super.onStart(props); await this.releaseIdleMcp();