Skip to content
Open
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
20 changes: 20 additions & 0 deletions src/features/buddy/actionDrafts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* The hire's own wording for a proposal that carries an editable message, held per action.
*
* Deliberately *not* inside the card that renders the field. The dock unmounts every time it is
* closed, and the hand-off to `/buddy` mounts a second card for the same action — so a draft kept
* as component state is thrown away by gestures that were never about the text. Held by the
* session instead, the way the composer's own draft already is, the wording outlives whichever
* surface happens to be on screen.
*/
export type ActionDrafts = Record<string, string>;

/**
* How one draft is keyed: an action inside the message it was proposed in.
*
* Both halves are needed. A hire offer's id is local to its message (the backend assigns none), so
* keying by id alone would let one message's wording surface in another's card.
*/
export function actionDraftKey(messageId: string, actionId: string): string {
return `${messageId}:${actionId}`;
}
506 changes: 310 additions & 196 deletions src/features/buddy/components/BuddyActionProposals.tsx

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/features/buddy/components/BuddyConversation.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { memo, useCallback } from "react";
import type { ReactNode } from "react";
import type { BuddyMessageView, ProposedAction } from "../types";
import type { ActionDrafts } from "../actionDrafts";
import { BuddyComposer } from "./BuddyComposer";
import { BuddyThread } from "./BuddyThread";
import { BuddyReplyActions } from "./BuddyReplyActions";
Expand All @@ -19,6 +20,10 @@ type BuddyConversationProps = {
confirmAction: (messageId: string, action: ProposedAction) => void;
/** Declines a proposed action; nothing changes. */
dismissAction: (messageId: string, action: ProposedAction) => void;
/** The session's wording for offers that carry an editable message — see `actionDrafts`. */
actionDrafts: ActionDrafts;
/** Records one, so it outlives whichever surface is on screen. */
setActionDraft: (key: string, text: string) => void;
/** Composer placeholder — "Type your answer…" while the buddy is intaking. */
placeholder?: string;
/** Rendered under the buddy's most recent reply — the greeting's suggested next step. */
Expand Down Expand Up @@ -89,6 +94,8 @@ function BuddyConversationImpl({
activeTool,
confirmAction,
dismissAction,
actionDrafts,
setActionDraft,
placeholder,
lastMessageFooter,
renderQuestionAction,
Expand Down Expand Up @@ -169,6 +176,8 @@ function BuddyConversationImpl({
activeTool={activeTool}
confirmAction={confirmAction}
dismissAction={dismissAction}
actionDrafts={actionDrafts}
setActionDraft={setActionDraft}
showNames
lastMessageFooter={lastMessageFooter}
renderQuestionAction={renderQuestionAction}
Expand Down
6 changes: 6 additions & 0 deletions src/features/buddy/components/BuddyDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ type BuddyDockProps = Pick<
| "activeTool"
| "confirmAction"
| "dismissAction"
| "actionDrafts"
| "setActionDraft"
| "suggestions"
| "startFreshVisit"
| "isGreeting"
Expand Down Expand Up @@ -134,6 +136,8 @@ function BuddyDockImpl({
activeTool,
confirmAction,
dismissAction,
actionDrafts,
setActionDraft,
suggestions,
dinoGameActive = false,
onDinoGameExit,
Expand Down Expand Up @@ -356,6 +360,8 @@ function BuddyDockImpl({
lastMessageFooter={lastMessageFooter}
confirmAction={confirmAction}
dismissAction={dismissAction}
actionDrafts={actionDrafts}
setActionDraft={setActionDraft}
// Hire-flow only: "Send this to your PM" escalates the hire's own question, and a
// team-mode conversation is not one — the offer must not even render there.
renderQuestionAction={renderQuestionAction}
Expand Down
23 changes: 23 additions & 0 deletions src/features/buddy/components/BuddyThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import type { ReactNode } from "react";
import { AlertCircle, RotateCcw } from "lucide-react";
import { Button } from "../../../components/ui/Button";
import type { BuddyMessageView, ProposedAction } from "../types";
import type { ActionDrafts } from "../actionDrafts";
import { toolLabel } from "../toolLabel";
import { BuddyActionProposals } from "./BuddyActionProposals";
import { BuddyMarkdown } from "./BuddyMarkdown";
import { BuddyMessage, BuddyTypingMessage } from "./BuddyMessage";

/** The drafts a row without any proposal gets: one shared object, so its memo is never broken. */
const EMPTY_ACTION_DRAFTS: ActionDrafts = {};

type BuddyThreadProps = {
messages: BuddyMessageView[];
isThinking: boolean;
Expand All @@ -23,6 +27,10 @@ type BuddyThreadProps = {
confirmAction: (messageId: string, action: ProposedAction) => void;
/** Declines a proposed action; nothing changes. */
dismissAction: (messageId: string, action: ProposedAction) => void;
/** The session's wording for offers that carry an editable message — see `actionDrafts`. */
actionDrafts: ActionDrafts;
/** Records one, so it outlives whichever surface is on screen. */
setActionDraft: (key: string, text: string) => void;
/** Names above the bubbles — on for the page, off in the dock. */
showNames?: boolean;
/** The dock's narrow layout — see `BuddyMessage`'s `compact`. */
Expand Down Expand Up @@ -101,6 +109,10 @@ type BuddyThreadRowProps = {
compact: boolean;
confirmAction: (messageId: string, action: ProposedAction) => void;
dismissAction: (messageId: string, action: ProposedAction) => void;
/** The session's wording for the row's own offers, if any — see `BuddyThreadProps`. */
actionDrafts: ActionDrafts;
/** Records one, so it outlives whichever surface is on screen. */
setActionDraft: (key: string, text: string) => void;
renderQuestionAction?: (question: string) => ReactNode;
renderReplyAction?: (reply: string, message: BuddyMessageView) => ReactNode;
/** The greeting's suggested next step — present on the row it hangs under, nowhere else. */
Expand Down Expand Up @@ -132,6 +144,8 @@ function BuddyThreadRowImpl({
compact,
confirmAction,
dismissAction,
actionDrafts,
setActionDraft,
renderQuestionAction,
renderReplyAction,
lastMessageFooter,
Expand Down Expand Up @@ -198,6 +212,8 @@ function BuddyThreadRowImpl({
<BuddyActionProposals
messageId={message.id}
actions={message.actions ?? []}
actionDrafts={actionDrafts}
setActionDraft={setActionDraft}
onConfirm={confirmAction}
onDismiss={dismissAction}
/>
Expand Down Expand Up @@ -244,6 +260,8 @@ function BuddyThreadImpl({
activeTool,
confirmAction,
dismissAction,
actionDrafts,
setActionDraft,
showNames = false,
compact = false,
lastMessageFooter,
Expand Down Expand Up @@ -311,6 +329,11 @@ function BuddyThreadImpl({
compact={compact}
confirmAction={confirmAction}
dismissAction={dismissAction}
// Handed only to the rows that actually carry a proposal: the drafts object changes
// identity on every keystroke in a flag's field, and each row is memoised (#236), so
// the shared empty object keeps that keystroke from re-rendering the whole transcript.
actionDrafts={(message.actions?.length ?? 0) > 0 ? actionDrafts : EMPTY_ACTION_DRAFTS}
setActionDraft={setActionDraft}
renderQuestionAction={renderQuestionAction}
renderReplyAction={renderReplyAction}
// Resolved here rather than inside the row: only the buddy's latest reply gets it, and
Expand Down
4 changes: 4 additions & 0 deletions src/features/buddy/components/BuddyWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export function BuddyWidget() {
toggleOpen,
confirmAction,
dismissAction,
actionDrafts,
setActionDraft,
suggestions,
dinoGameActive,
closeDinoGame,
Expand Down Expand Up @@ -349,6 +351,8 @@ export function BuddyWidget() {
activeTool={activeTool}
confirmAction={confirmAction}
dismissAction={dismissAction}
actionDrafts={actionDrafts}
setActionDraft={setActionDraft}
suggestions={suggestions}
dinoGameActive={dinoGameActive}
onDinoGameExit={closeDinoGame}
Expand Down
66 changes: 59 additions & 7 deletions src/features/buddy/hooks/useBuddyConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
type BuddyOpeningAction,
} from "../../../services/buddyService";
import { announceBuddyPathChanged } from "../aiBuddyBus";
import { actionDraftKey } from "../actionDrafts";
import type { ActionDrafts } from "../actionDrafts";
import { BUDDY_PATH_ACTIONS } from "../types";
import { useAuth } from "../../../context/useAuth";
import { useInvalidateBoard } from "../../board/hooks/useInvalidateBoard";
Expand Down Expand Up @@ -185,6 +187,16 @@ export function useBuddyConversation(
* and a parent cannot reach into a child's setter.
*/
const [draftResetToken, setDraftResetToken] = useState(0);
/**
* The hire's wording for the proposals that carry an editable message — a flag's question, the
* only payload a person reads and the hire therefore rewords (see `actionDrafts` for the key).
*
* Session state rather than card state, for the same reason the composer's words are: the dock
* unmounts when it closes, the full page mounts a second card for the same action, and either
* one would otherwise throw away words the hire was halfway through. Unlike the composer's,
* nothing below this provider needs to write it, so it lives here rather than in a context.
*/
const [actionDrafts, setActionDrafts] = useState<ActionDrafts>({});
/**
* The last greeting a surface has actually put in front of the hire — either watched while it
* streamed, or revealed by `useGreetingReveal`. Held here, not per surface, so a greeting the
Expand Down Expand Up @@ -507,7 +519,8 @@ export function useBuddyConversation(
*
* Nothing is deleted. The whole transcript stays in `buddy_messages`, and the buddy's durable
* memory note is untouched — it is what the greeting is written from, which is why starting
* fresh does not mean starting over. Only the hire's scrollback moves on.
* fresh does not mean starting over. Only the hire's scrollback moves on — together with any
* flag wording they had half-edited, which belonged to the offers in it.
*/
const startFreshVisit = useCallback(async () => {
// The button stays enabled while the greeting is written, so a second click would run a
Expand All @@ -533,6 +546,9 @@ export function useBuddyConversation(
// The box is emptied through the token: a question typed about the conversation being
// cleared is about a thread that no longer exists. See `draftResetToken`.
setDraftResetToken((token) => token + 1);
// Wording the hire had half-edited belongs to the offers that are going with the
// transcript — it is not a composer draft and must not outlive them.
setActionDrafts({});
setIsOpening(true);
try {
await greet();
Expand Down Expand Up @@ -755,6 +771,29 @@ export function useBuddyConversation(
// the honest guard is here, where the second click is refused rather than re-sent.
const inFlightRef = useRef<Set<string>>(new Set());

/**
* Records what the hire typed into a proposal's field, so it survives a closed dock, a handed-
* over conversation and the retry a refusal offers — see `actionDrafts` for why it is session
* state and not the card's own.
*/
const setActionDraft = useCallback((key: string, text: string) => {
setActionDrafts((current) => ({ ...current, [key]: text }));
}, []);

/**
* Forgets a draft whose proposal is done with — it was sent, or the hire declined it. Nothing
* is left to edit, and a card re-rendered later must not offer wording that has already left
* the product.
*/
const clearActionDraft = useCallback((key: string) => {
setActionDrafts((current) => {
if (!(key in current)) return current;
const next = { ...current };
delete next[key];
return next;
});
}, []);

/**
* Confirms a proposed action: the one call that mutates. Reflects the outcome inline — a
* legible line whether it changed something (`ok`) or legibly couldn't, or a retryable error
Expand Down Expand Up @@ -833,6 +872,9 @@ export function useBuddyConversation(
ok: result.ok,
outcome: result.message,
});
// It went out: the wording has left the product, so the session keeps none of it. A
// refusal keeps it — that card is about to be handed the hire's text back to correct.
if (result.ok) clearActionDraft(actionDraftKey(messageId, action.id));
// A path action just moved something on a page that may be open behind this dock. Told
// rather than polled, and only on success: a refused confirm changed nothing to refresh.
if (result.ok && "action" in action && BUDDY_PATH_ACTIONS.includes(action.action)) {
Expand Down Expand Up @@ -868,7 +910,7 @@ export function useBuddyConversation(
}
})();
},
[beginDecision, endDecision, patchAction, invalidateBoard],
[beginDecision, endDecision, patchAction, invalidateBoard, clearActionDraft],
);

/**
Expand All @@ -892,6 +934,9 @@ export function useBuddyConversation(
// but still worth putting away here.
if (!("proposalId" in action)) {
patchAction(messageId, actionId, { status: "dismissed" });
// A hire offer is the only kind that carries a draft, and this is the one place one is
// declined — the wording goes with the offer it belonged to.
clearActionDraft(actionDraftKey(messageId, actionId));
return;
}

Expand Down Expand Up @@ -937,16 +982,17 @@ export function useBuddyConversation(
}
})();
},
[beginDecision, endDecision, patchAction],
[beginDecision, endDecision, patchAction, clearActionDraft],
);

/**
* The thread on screen always belongs to exactly one conversation, and when the derived
* target moves — a switch, a restored preference arriving, an involuntary exit — the thread
* is cleared and the new conversation opens exactly as an untouched visit would: read first,
* then greeted. The backend keeps the conversations separate, so reusing the latch would show
* one inside the other. Mid-turn the move waits: the busy flags are in the dependency list,
* so the effect re-runs the moment the turn ends and applies then.
* then greeted. The drafts of the thread it clears go with it, like every other piece of
* state that belonged to those offers. The backend keeps the conversations separate, so
* reusing the latch would show one inside the other. Mid-turn the move waits: the busy flags
* are in the dependency list, so the effect re-runs the moment the turn ends and applies then.
*/
const openedForRef = useRef<string>("hire");
useEffect(() => {
Expand All @@ -972,6 +1018,9 @@ export function useBuddyConversation(
setOpenError(null);
// Same rule as a fresh visit: the words belonged to the conversation that just went away.
setDraftResetToken((token) => token + 1);
// Bound to the offers of the conversation being left, not to the tab: a switch starts
// with no wording of its own, like every other piece of session state here.
setActionDrafts({});
setActiveTool(null);
setIsThinking(false);
setIsStreaming(false);
Expand Down Expand Up @@ -1056,7 +1105,10 @@ export function useBuddyConversation(
// thing about them the session still owns — the news that the thread they belonged to is
// gone. See `draftResetToken`.
draftResetToken,

// The same idea for the fields a proposal carries: a flag's question is the hire's to word,
// and the session is what keeps that wording across a closed dock and a handed-over page.
actionDrafts,
setActionDraft,
sendMessage,
confirmAction,
dismissAction,
Expand Down
5 changes: 3 additions & 2 deletions src/features/buddy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ export type HireActionProposal = {
/** The button text ("Work toward this task"). */
label: string;
/**
* Carried through only for flag-to-PM: the question the buddy composed, and the one that is
* actually sent. Shown under the button — see `BuddyActionProposals`.
* Carried through only for flag-to-PM: the question the buddy composed. Shown in an editable
* field *above* the confirm — this one is a message, so the hire may reword it, and what reaches
* the PM is whatever that field held. See `BuddyActionProposals`.
*/
question?: string;
/**
Expand Down
4 changes: 4 additions & 0 deletions src/pages/BuddyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ function BuddyMentorHome() {
sendMessage,
confirmAction,
dismissAction,
actionDrafts,
setActionDraft,
openError,
dinoGameActive,
closeDinoGame,
Expand Down Expand Up @@ -434,6 +436,8 @@ function BuddyMentorHome() {
activeTool={activeTool}
confirmAction={confirmAction}
dismissAction={dismissAction}
actionDrafts={actionDrafts}
setActionDraft={setActionDraft}
dinoGameActive={dinoGameActive}
onDinoGameExit={closeDinoGame}
// Both held in one identity above, with the reasons written there — the thread's memo
Expand Down
Loading
Loading