Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function AppContent() {
// happened to be out does not open with it already there, waiting for a mouse that never went
// near it to leave. Reset during render rather than in an effect: it is a correction to state
// that is already wrong for this render, not a synchronisation with anything outside React.
// (The pattern and its three rules are named once in `CODING_STANDARDS.md` § 3.)
const [peekMode, setPeekMode] = useState(isFocused);
if (peekMode !== isFocused) {
setPeekMode(isFocused);
Expand Down
94 changes: 94 additions & 0 deletions src/features/buddy/BuddyDraftProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useCallback, useMemo, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import { matchEggPhrase } from "../easter-eggs/lib/eggPhrases";
import { playEggEffect } from "../easter-eggs/eggEffectBus";
import { BuddyDraftActionsContext, BuddyDraftContext } from "./buddyDraftContext";
import type { BuddyDraft, BuddyDraftActions } from "./buddyDraftContext";
import { useBuddySession } from "./buddySessionContext";

/**
* Owns the composer's words — one box for both buddy surfaces.
*
* **Why it is a provider of its own, *under* the session's.** The draft used to be a piece of
* `useBuddyConversation`, so typing in it re-rendered every reader of the session: on `/buddy`
* that is the whole page, and with it every reply in the thread, each one re-parsed by
* `ReactMarkdown` (issue #236 — the composer grew sluggish as a conversation grew). Down here
* the state is a step further from everything else: a keystroke re-renders this provider and the
* composer that reads it, and nothing else. The page, the dock and the widget are all `children`,
* whose elements never change identity, so React leaves them where they are.
*
* The words still outlive the dock — close it mid-sentence and they are there next time — because
* this provider lives as long as the app's one session, not as long as any one surface.
*
* Two contexts, not one: the value changes per keystroke by design, and only the composer should
* follow it. A surface that merely *fills* the box reads `BuddyDraftActions` and never re-renders
* for a keystroke — see that type for why the distinction is load-bearing.
*/
export function BuddyDraftProvider({ children }: { children: ReactNode }) {
const { sendMessage, draftResetToken } = useBuddySession();
const [draft, setDraft] = useState("");

/**
* A fresh visit or a project switch replaced the conversation on screen, so the words in the
* box went with it — they were a question about the thread that is gone.
*
* Told, not pulled: the session sits *above* this provider and cannot reach into its state, so
* it bumps a token and the clearing happens on the way through. React's documented "adjust
* state when a prop changes" pattern — the same shape `BuddyPage` uses for its rail, and named
* once with its rules in `CODING_STANDARDS.md` § 3 — rather than an effect, because an effect
* here would paint one frame of a draft belonging to a conversation that no longer exists, and
* cost a second render to fix it.
*/
const [seenResetToken, setSeenResetToken] = useState(draftResetToken);
if (seenResetToken !== draftResetToken) {
setSeenResetToken(draftResetToken);
setDraft("");
}

/**
* The one way a message leaves the box. The contract is unchanged from when this lived in the
* session: an egg phrase plays its effect and is swallowed, anything else is cleared and sent,
* and the return value says whether a turn actually started (the composer keeps the caret when
* one did not).
*/
const handleSubmit = useCallback(
(event: FormEvent) => {
event.preventDefault();

// Easter-egg phrases are intercepted before anything is sent: the effect plays app-wide
// (EggEffectsLayer) and the message is swallowed silently — no reply, no request. Same
// contract as the AI chat.
const eggEffect = matchEggPhrase(draft);
if (eggEffect) {
setDraft("");
playEggEffect(eggEffect);
return false;
}

const text = draft;
if (!text.trim()) return false;

setDraft("");
void sendMessage(text);
return true;
},
[draft, sendMessage],
);

const value = useMemo<BuddyDraft>(
// `setDraft` is a state setter — stable for this provider's lifetime — so it is deliberately
// not a dependency. The value is new exactly when the words, or the submit behaviour, are.
() => ({ draft, setDraft, handleSubmit }),
[draft, handleSubmit],
);

// Built once and never again: the setter it carries never changes, which is the whole point of
// handing surfaces this half instead of the value.
const actions = useMemo<BuddyDraftActions>(() => ({ setDraft }), []);

return (
<BuddyDraftActionsContext.Provider value={actions}>
<BuddyDraftContext.Provider value={value}>{children}</BuddyDraftContext.Provider>
</BuddyDraftActionsContext.Provider>
);
}
10 changes: 9 additions & 1 deletion src/features/buddy/BuddyProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { useToast } from "../../context/useToast";
import { BuddyDraftProvider } from "./BuddyDraftProvider";
import { BuddySessionContext } from "./buddySessionContext";
import { useBuddyConversation } from "./hooks/useBuddyConversation";
import { useProjectContext } from "../projects/useProjectContext";
Expand Down Expand Up @@ -52,5 +53,12 @@ export function BuddyProvider({ children }: { children: ReactNode }) {
),
);

return <BuddySessionContext.Provider value={session}>{children}</BuddySessionContext.Provider>;
// The composer's words get a provider of their own, under the session's — one keystroke then
// costs one render of one box instead of a render of every reader of the conversation. See
// `BuddyDraftProvider` for what the split is worth and why it sits below rather than inside.
return (
<BuddySessionContext.Provider value={session}>
<BuddyDraftProvider>{children}</BuddyDraftProvider>
</BuddySessionContext.Provider>
);
}
71 changes: 71 additions & 0 deletions src/features/buddy/buddyDraftContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createContext, useContext } from "react";
import type { Dispatch, FormEvent, SetStateAction } from "react";

/**
* Everything the composer is: the words in it, the way they are filled in, and the one way they
* leave it.
*
* Split out of the buddy session because the two age at completely different rates. The session
* changes when the conversation does — a message, a turn, a switch — while this changes on every
* keystroke. While they shared one context value, every keypress handed every consumer of the
* conversation a new value, so the whole thread re-parsed itself per character typed. See
* [BuddyDraftProvider](./BuddyDraftProvider.tsx) for the shape that fixes it.
*/
export type BuddyDraft = {
/** The words currently in the box — one composer, shared by the dock and the page. */
draft: string;
setDraft: Dispatch<SetStateAction<string>>;
/**
* Submits the composer. Returns whether a turn was actually started — `false` when the
* submission was swallowed (an easter-egg phrase, an empty draft), which is what keeps the
* caret in the box instead of handing it off on a send that never happened.
*/
handleSubmit: (event: FormEvent) => boolean;
};

/**
* The write-only half of the composer: what a surface needs in order to *fill* the box, without
* the box's value.
*
* Its value never changes, so a reader can hold the setter for the session's lifetime and never
* re-render for a keystroke — which is exactly what the surfaces that seed a draft (the
* suggestion chips, the dock's hand-off to `/buddy`) have to do. Reading [BuddyDraft] for the
* setter instead would put them back on the per-keystroke path this split exists to leave.
*/
export type BuddyDraftActions = { setDraft: Dispatch<SetStateAction<string>> };

export const BuddyDraftContext = createContext<BuddyDraft | null>(null);

export const BuddyDraftActionsContext = createContext<BuddyDraftActions | null>(null);

/**
* The composer this session has — the value *and* the way to fill it.
*
* Deliberately no fallback, like `useBuddySession`: a hook that quietly made its own draft would
* put the dock and the page back on two composers that merely share a name.
*/
export function useBuddyDraft(): BuddyDraft {
const draft = useContext(BuddyDraftContext);

if (!draft) {
throw new Error("useBuddyDraft must be used inside a BuddyDraftProvider");
}

return draft;
}

/**
* The setter without the value — see [BuddyDraftActions] for why the two are apart.
*
* This is the one to reach for whenever a surface only *writes* to the composer: nothing it
* reads here ever changes, so the surface keeps its sub over a keystroke-free value.
*/
export function useBuddyDraftActions(): BuddyDraftActions {
const actions = useContext(BuddyDraftActionsContext);

if (!actions) {
throw new Error("useBuddyDraftActions must be used inside a BuddyDraftProvider");
}

return actions;
}
6 changes: 3 additions & 3 deletions src/features/buddy/components/BuddyActionProposals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type BuddyActionProposalsProps = {
messageId: string;
actions: ProposedAction[];
onConfirm: (messageId: string, action: ProposedAction) => void;
onDismiss: (messageId: string, actionId: string) => void;
onDismiss: (messageId: string, action: ProposedAction) => void;
};

/**
Expand Down Expand Up @@ -207,7 +207,7 @@ export function BuddyActionProposals({
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onDismiss(messageId, action.id)}
onClick={() => onDismiss(messageId, action)}
disabled={isConfirming}
className="flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-sm text-app-text-muted transition-colors hover:bg-app-surface-hover hover:text-app-text disabled:opacity-60"
>
Expand Down Expand Up @@ -239,7 +239,7 @@ export function BuddyActionProposals({
</button>
<button
type="button"
onClick={() => onDismiss(messageId, action.id)}
onClick={() => onDismiss(messageId, action)}
disabled={isConfirming}
className="flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-sm text-app-text-muted transition-colors hover:bg-app-surface-hover hover:text-app-text disabled:opacity-60"
>
Expand Down
18 changes: 7 additions & 11 deletions src/features/buddy/components/BuddyComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,9 @@ import type { KeyboardEvent } from "react";
import { Send } from "lucide-react";
import { Button } from "../../../components/ui/Button";
import { useAutoResize } from "../../../components/ui/useAutoResize";
import { useBuddyDraft } from "../buddyDraftContext";

type BuddyComposerProps = {
draft: string;
setDraft: (value: string) => void;
/**
* Submits the box. Returns whether a turn was actually started: `false` when the caller
* swallowed the submission (an easter-egg phrase, an empty draft), which is what keeps the
* caret here instead of handing it off on a send that never happened — see `submit`.
*/
handleSubmit: (event: React.FormEvent) => boolean;
/** Composer placeholder — "Type your answer…" while the buddy is intaking. */
placeholder?: string;
/** Drops the keyboard hint under the box, for the dock where the room is better spent. */
Expand Down Expand Up @@ -56,17 +49,20 @@ type BuddyComposerProps = {
* It draws no band of its own — no border, no background, no page padding. Each surface frames
* it: the page's card gives it a bottom band, the dock hands it to `SidePanel`'s footer. Owning
* the frame here is what previously put two `border-t`s across the dock.
*
* It reads the words from the shared composer (`useBuddyDraft`) rather than taking them as
* props — the box is the one component a keystroke is *allowed* to re-render, and reading the
* draft directly is what keeps that true: a surface passing `draft` down would be re-rendering
* for every character it forwarded. See `BuddyDraftProvider`.
*/
export function BuddyComposer({
draft,
setDraft,
handleSubmit,
placeholder = "Ask your buddy anything...",
compact = false,
focusOnMount = false,
busy = false,
gameActive = false,
}: BuddyComposerProps) {
const { draft, setDraft, handleSubmit } = useBuddyDraft();
const fieldRef = useRef<HTMLTextAreaElement>(null);

// Set when *this* composer gave up the caret on a send, so the refocus
Expand Down
51 changes: 29 additions & 22 deletions src/features/buddy/components/BuddyConversation.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { memo, useCallback } from "react";
import type { ReactNode } from "react";
import type { BuddyMessageView, ProposedAction } from "../types";
import { BuddyComposer } from "./BuddyComposer";
Expand All @@ -14,21 +15,12 @@ type BuddyConversationProps = {
isThinking: boolean;
/** The tool the buddy is running right now, if any — becomes "Checking your progress…". */
activeTool: string | null;
draft: string;
setDraft: (value: string) => void;
/**
* Submits the composer. Returns whether a turn started — an egg phrase comes back `false`,
* because nothing was sent for it.
*/
handleSubmit: (event: React.FormEvent) => boolean;
/** Confirms a buddy-proposed action (the only path that mutates). */
confirmAction: (messageId: string, action: ProposedAction) => void;
/** Declines a proposed action; nothing changes. */
dismissAction: (messageId: string, actionId: string) => void;
dismissAction: (messageId: string, action: ProposedAction) => void;
/** Composer placeholder — "Type your answer…" while the buddy is intaking. */
placeholder?: string;
/** Rendered above the first message: what came back from the hire's PM. */
before?: ReactNode;
/** Rendered under the buddy's most recent reply — the greeting's suggested next step. */
lastMessageFooter?: ReactNode;
/** Rendered under each of the hire's own questions, handed that question's text. */
Expand Down Expand Up @@ -91,17 +83,13 @@ type BuddyConversationProps = {
* It scrolls down, never sideways — `overflow-x-hidden` plus the `min-w-0` chain running down
* to `BuddyMarkdown`, where wide blocks get their own scrollers.
*/
export function BuddyConversation({
function BuddyConversationImpl({
messages,
isThinking,
activeTool,
draft,
setDraft,
handleSubmit,
confirmAction,
dismissAction,
placeholder,
before,
lastMessageFooter,
renderQuestionAction,
aboveComposer,
Expand All @@ -117,6 +105,19 @@ export function BuddyConversation({
}: BuddyConversationProps) {
const { containerRef, onScroll } = useStickToBottom(messages);

/**
* The row under every reply, held in one identity for the life of this component.
*
* `BuddyThread` is memoised — that is what keeps a keystroke out of the thread — and a
* callback created inline would hand it a new prop on every render, defeating exactly that.
*/
const renderReplyAction = useCallback(
(reply: string, message: BuddyMessageView) => (
<BuddyReplyActions reply={reply} message={message} />
),
[],
);

return (
<>
<div
Expand Down Expand Up @@ -161,17 +162,14 @@ export function BuddyConversation({
)}

<BuddyThread
renderReplyAction={(reply, message) => (
<BuddyReplyActions reply={reply} message={message} />
)}
renderReplyAction={renderReplyAction}
messages={messages}
isThinking={isThinking}
isStreaming={isStreaming}
activeTool={activeTool}
confirmAction={confirmAction}
dismissAction={dismissAction}
showNames
before={before}
lastMessageFooter={lastMessageFooter}
renderQuestionAction={renderQuestionAction}
openError={openError}
Expand All @@ -191,9 +189,6 @@ export function BuddyConversation({
{aboveComposer && <div className="mb-3 min-w-0">{aboveComposer}</div>}

<BuddyComposer
draft={draft}
setDraft={setDraft}
handleSubmit={handleSubmit}
placeholder={placeholder}
focusOnMount={focusComposerOnMount}
busy={isThinking || isStreaming}
Expand All @@ -204,3 +199,15 @@ export function BuddyConversation({
</>
);
}

/**
* Memoised, and its props are the contract: everything in that list is a plain value, an element
* or callback the page holds in one identity (see the `useMemo`/`useCallback`s above its render),
* or a motion value. A fresh inline element added to it later — a `footer={`…`}` built in the
* page's render — is silently the one prop that always changed, and the memo stops paying.
*
* The thread *inside* this carries the per-message boundary; this one is about the page's own
* re-renders (the rail opening, a toast landing, a visit divider moving) not walking the whole
* conversation.
*/
export const BuddyConversation = memo(BuddyConversationImpl);
Loading
Loading