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
29 changes: 29 additions & 0 deletions src/features/board/hooks/useInvalidateBoard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "../../../services/queryKeys";

/**
* Marks the cached board stale after a write made outside the board itself — a checklist kept
* from a reply, a card authored from a selection, a confirmed buddy action.
*
* These writes land on a surface the hire is usually not looking at, so nothing else would ever
* re-read the board: an open board behind the dock keeps serving its previous read, and a visit
* inside the `staleTime` window is served the pre-write copy. Marking is what makes whichever
* comes next — the open board, or the next visit — show the card. Call it only once the write has
* really happened: a failed save must not cost the board its cache.
*
* @param projectId The project the card went to. Omit it only for writes whose project the
* backend resolved server-side (buddy actions) and never told the client — that marks every
* cached board rather than guessing one.
* @returns A stable callback; call it after a successful write to invalidate the board cache.
*/
export function useInvalidateBoard(projectId?: string) {
const queryClient = useQueryClient();

return useCallback(() => {
void queryClient.invalidateQueries({
queryKey:
projectId === undefined ? queryKeys.board.all() : queryKeys.board.byProject(projectId),
});
}, [queryClient, projectId]);
}
5 changes: 5 additions & 0 deletions src/features/board/save/SaveToBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button, type ButtonSize } from "../../../components/ui/Button";
import { useToast } from "../../../context/useToast";
import { boardService } from "../../../services/boardService";
import { useProjectContext } from "../../projects/useProjectContext";
import { useInvalidateBoard } from "../hooks/useInvalidateBoard";
import { rememberOrigin, type CardOrigin } from "../layout/cardOrigins";
import type { AuthoredCardRequest } from "../types";

Expand Down Expand Up @@ -76,6 +77,7 @@ export function SaveToBoard({
}: SaveToBoardProps) {
const { selectedProjectId } = useProjectContext();
const toast = useToast();
const invalidateBoard = useInvalidateBoard(selectedProjectId);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);

Expand All @@ -90,6 +92,9 @@ export function SaveToBoard({
try {
const created = await boardService.addCard(selectedProjectId, card);

// This button is often pressed far away from the board — see `useInvalidateBoard`.
invalidateBoard();

const where = origin?.();
// Never allowed to fail the save: the card is what was asked for, the trail back is extra.
if (where) rememberOrigin(selectedProjectId, created.id, where);
Expand Down
6 changes: 5 additions & 1 deletion src/features/board/selection/SelectionActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useFocusMode } from "../../../context/useFocusMode";
import { useProjectContext } from "../../projects/useProjectContext";
import { ChatContext } from "../../../context/ChatContext";
import { boardService } from "../../../services/boardService";
import { useInvalidateBoard } from "../hooks/useInvalidateBoard";
import { rememberOrigin } from "../layout/cardOrigins";
import { useCardMarks } from "../marks/useCardMarks";
import { DEFAULT_HIGHLIGHT } from "../marks/highlightColors";
Expand Down Expand Up @@ -48,6 +49,7 @@ export function SelectionActions() {
const quoteSelection = chatContext?.quoteSelection;
const [saving, setSaving] = useState(false);
const toast = useToast();
const invalidateBoard = useInvalidateBoard(selectedProjectId);
const navigate = useNavigate();

const add = useCallback(async () => {
Expand All @@ -65,6 +67,8 @@ export function SelectionActions() {
url: selection.origin,
label: selection.source ?? "where you were",
});
// Found anywhere in the app, so the board is almost never on screen — see `useInvalidateBoard`.
invalidateBoard();
toast.success(
request.kind === "LINK" ? "Link saved to your board" : "Note saved to your board",
{
Expand All @@ -78,7 +82,7 @@ export function SelectionActions() {
} finally {
setSaving(false);
}
}, [selection, selectedProjectId, toast, navigate, clear]);
}, [selection, selectedProjectId, toast, navigate, clear, invalidateBoard]);

/**
* Hands the selection to the buddy as a quote, unsent.
Expand Down
4 changes: 4 additions & 0 deletions src/features/buddy/components/SaveReplyToBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button } from "../../../components/ui/Button";
import { useToast } from "../../../context/useToast";
import { boardService } from "../../../services/boardService";
import { extractChecklist, toChecklistRequest } from "../../board/generation/checklistFromMarkdown";
import { useInvalidateBoard } from "../../board/hooks/useInvalidateBoard";
import { useProjectContext } from "../../projects/useProjectContext";

type SaveReplyToBoardProps = {
Expand All @@ -29,6 +30,7 @@ type SaveReplyToBoardProps = {
export function SaveReplyToBoard({ content }: SaveReplyToBoardProps) {
const { selectedProjectId } = useProjectContext();
const toast = useToast();
const invalidateBoard = useInvalidateBoard(selectedProjectId);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);

Expand All @@ -42,6 +44,8 @@ export function SaveReplyToBoard({ content }: SaveReplyToBoardProps) {
setSaving(true);
try {
await boardService.addCard(selectedProjectId, toChecklistRequest(checklist));
// The board this list joined may be cached right behind this dock — see `useInvalidateBoard`.
invalidateBoard();
setSaved(true);
toast.success("Kept on your board", {
description: `"${checklist.title}" — ${checklist.items.length} things to tick off.`,
Expand Down
64 changes: 62 additions & 2 deletions src/features/buddy/hooks/useBuddyConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ import {
type BuddyOpeningAction,
} from "../../../services/buddyService";
import { useAuth } from "../../../context/useAuth";
import { useInvalidateBoard } from "../../board/hooks/useInvalidateBoard";
import {
BUDDY_ACTION_AMEND_CHECKLIST,
BUDDY_ACTION_CLAIM_GOAL,
BUDDY_ACTION_PLACE_CHECKLIST,
BUDDY_ACTION_PLACE_NOTE,
BUDDY_ACTION_REWORD_CHECKLIST,
BUDDY_ACTION_TICK_CHECKLIST,
} from "../types";
import type { ActionPatch, BuddyMessageView, ProposedAction } from "../types";

/**
Expand Down Expand Up @@ -44,6 +53,29 @@ function isNotFound(e: unknown): boolean {
return e instanceof Error && "status" in e && (e as { status: number }).status === 404;
}

/**
* The hire-confirmed buddy actions that write to the board, by their wire names: the five
* `BuddyBoardWriteActions` handles (placing, amending, ticking and rewording a checklist, and
* the note), plus `claim_goal`, which pins the claimed task as the board's CURRENT_TASK card —
* its own outcome line says so ("It's on your board too"). Every other confirm changes something
* else — a task claim, an attestation request, a flag, a username — and needs no board sync.
*/
const BUDDY_BOARD_ACTIONS = new Set<string>([
BUDDY_ACTION_PLACE_CHECKLIST,
BUDDY_ACTION_AMEND_CHECKLIST,
BUDDY_ACTION_TICK_CHECKLIST,
BUDDY_ACTION_REWORD_CHECKLIST,
BUDDY_ACTION_PLACE_NOTE,
BUDDY_ACTION_CLAIM_GOAL,
]);

/**
* The mid-answer tool that puts a card on the board. `place_card` is deliberately not a confirmed
* action — it applies the moment the mentor runs it — so a turn that ran it is the only signal
* the client ever gets that the board moved. Acted on by `sendMessage` when the turn ends.
*/
const BUDDY_BOARD_TOOLS = new Set<string>(["place_card"]);

/**
* Where "is the buddy in team mode" lives between reloads — scoped to the signed-in user, the
* way the project selection is. A shared browser must not hand one manager's team conversation
Expand Down Expand Up @@ -87,6 +119,7 @@ export function useBuddyConversation(
const [messages, setMessages] = useState<BuddyMessageView[]>([]);
const [isThinking, setIsThinking] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const invalidateBoard = useInvalidateBoard();
// The tool the buddy is running right now, if any -- drives "Checking your progress…"
// in place of a generic spinner. Cleared as soon as the answer starts streaming.
const [activeTool, setActiveTool] = useState<string | null>(null);
Expand Down Expand Up @@ -557,12 +590,27 @@ export function useBuddyConversation(
// Once the hire says anything, the opener's one-click suggestion has served its purpose.
setOpenerAction(null);

// Whether this turn ran a tool that puts a card on the board. A property rather than a
// `let`, so the reads below see the writes made in the stream callbacks — see `greet`.
const touched = { board: false };

/**
* `place_card` is the one board write that is not confirmed: its `tool_use` event is the
* whole signal the client gets, and it cannot say whether the tool wrote a card or was
* refused. So the board is marked stale when the turn ends — the failing paths included.
* See `useInvalidateBoard` for why the mark is what makes this visible.
*/
const syncBoardIfTouched = () => {
if (touched.board) invalidateBoard();
};

try {
await streamMessage(
text,
{
onToolUse: (name) => {
setActiveTool(name);
if (BUDDY_BOARD_TOOLS.has(name)) touched.board = true;
},

onToken: (token) => {
Expand Down Expand Up @@ -655,15 +703,20 @@ export function useBuddyConversation(
// Read at call time: a turn speaks to whichever conversation is current when it starts.
teamProjectIdRef.current ?? undefined,
);

// The turn is over — the first moment a card placed mid-answer is certainly on the board.
syncBoardIfTouched();
} catch (e) {
console.error(e);
setIsStreaming(false);
setIsThinking(false);
setActiveTool(null);
failReply(assistantId);
// Same reason as above: a turn that placed a card and then broke still wrote it.
syncBoardIfTouched();
}
},
[failReply],
[failReply, invalidateBoard],
);

/** Patches one proposed action in place, keyed by its message and action id. */
Expand Down Expand Up @@ -752,6 +805,13 @@ export function useBuddyConversation(
ok: result.ok,
outcome: result.message,
});

// `board.all()`, not a project key: the backend re-resolves the project server-side
// (the caller's single onboarding project) and never tells the client which board —
// see `useInvalidateBoard`.
if (result.ok && "action" in action && BUDDY_BOARD_ACTIONS.has(action.action)) {
invalidateBoard();
}
} catch (e) {
console.error(e);
// A settled proposal does NOT come back 404 — the backend answers 200 with ok: false
Expand All @@ -775,7 +835,7 @@ export function useBuddyConversation(
}
})();
},
[beginDecision, endDecision, patchAction],
[beginDecision, endDecision, patchAction, invalidateBoard],
);

/**
Expand Down
17 changes: 17 additions & 0 deletions src/features/buddy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,23 @@ export const BUDDY_ACTION_TICK_CHECKLIST = "tick_checklist_items";
/** The backend's `reword_checklist_item` action: one line replaced, shown before and after. */
export const BUDDY_ACTION_REWORD_CHECKLIST = "reword_checklist_item";

/**
* The backend's `place_note` action: an explanation the mentor offered to keep as a note.
*
* Named like the checklist actions above because the same reader needs it: the confirm path has
* to recognise which actions write to the board (see `BUDDY_BOARD_ACTIONS` in `useBuddyConversation`).
*/
export const BUDDY_ACTION_PLACE_NOTE = "place_note";

/**
* The backend's `claim_goal` action: the hire starting to work toward a task.
*
* Confirming this writes twice — the goal claim itself, and the CURRENT_TASK card pinned onto
* the board ("It's on your board too") — which is why the board-syncing set includes it despite
* its not being one of the board tools.
*/
export const BUDDY_ACTION_CLAIM_GOAL = "claim_goal";

/**
* An action proposed in hire mode: the buddy offers to do something *for this hire*, and the
* confirm echoes the offer's own payload back verbatim. What gets written is what was shown on
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/features/board/SaveToBoard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BookmarkPlus } from "lucide-react";
import type { ReactElement } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { SaveToBoard } from "../../../../src/features/board/save/SaveToBoard";
import { boardService } from "../../../../src/services/boardService";
import { queryKeys } from "../../../../src/services/queryKeys";
import type { AuthoredCardRequest } from "../../../../src/features/board/types";

let selectedProjectId = "p1";
vi.mock("../../../../src/features/projects/useProjectContext", () => ({
useProjectContext: () => ({ selectedProjectId }),
}));

const toast = { success: vi.fn(), error: vi.fn() };
vi.mock("../../../../src/context/useToast", () => ({ useToast: () => toast }));

const request = (): AuthoredCardRequest => ({ kind: "NOTE", text: "A reply, frozen." });

/** Renders under a client whose cache already holds this project's board, read a moment ago. */
function renderSave(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(queryKeys.board.byProject("p1"), {
boardId: "b1",
projectId: "p1",
cards: [],
});
render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
return { client };
}

const boardIsStale = (client: QueryClient) =>
client.getQueryState(queryKeys.board.byProject("p1"))?.isInvalidated ?? false;

function props(overrides: Partial<Parameters<typeof SaveToBoard>[0]> = {}) {
return {
request,
label: "Keep on my board",
icon: <BookmarkPlus />,
...overrides,
};
}

describe("SaveToBoard", () => {
beforeEach(() => {
vi.clearAllMocks();
selectedProjectId = "p1";
// jsdom on this Node has no Storage backing (CI's Node does); clearing is hygiene when present.
window.localStorage?.clear();
});

/** The one button every "keep this" offer is built on — chat, buddy replies, task cards. */
it("puts the built card on the selected project's board", async () => {
const addCard = vi.spyOn(boardService, "addCard").mockResolvedValue({ id: "c1" } as never);
renderSave(<SaveToBoard {...props()} />);

await userEvent.click(screen.getByRole("button", { name: "Keep on my board" }));

await waitFor(() => expect(addCard).toHaveBeenCalledOnce());
expect(addCard.mock.calls[0][0]).toBe("p1");
expect(addCard.mock.calls[0][1]).toEqual({ kind: "NOTE", text: "A reply, frozen." });
});

it("marks the board stale once the card is really on it", async () => {
vi.spyOn(boardService, "addCard").mockResolvedValue({ id: "c1" } as never);
const { client } = renderSave(<SaveToBoard {...props()} />);

await userEvent.click(screen.getByRole("button", { name: "Keep on my board" }));

await waitFor(() => expect(boardIsStale(client)).toBe(true));
});

/** The board-surface callers still get their callback — for the affordances that are theirs. */
it("tells the caller the card landed", async () => {
vi.spyOn(boardService, "addCard").mockResolvedValue({ id: "c1" } as never);
const onSaved = vi.fn();
renderSave(<SaveToBoard {...props({ label: "Break this into a checklist", onSaved })} />);

await userEvent.click(screen.getByRole("button", { name: "Break this into a checklist" }));

await waitFor(() => expect(onSaved).toHaveBeenCalledOnce());
});

it("leaves the board and the caller alone when the write failed", async () => {
vi.spyOn(boardService, "addCard").mockRejectedValue(new Error("nope"));
const onSaved = vi.fn();
const { client } = renderSave(<SaveToBoard {...props({ onSaved })} />);

await userEvent.click(screen.getByRole("button", { name: "Keep on my board" }));

await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(onSaved).not.toHaveBeenCalled();
expect(boardIsStale(client)).toBe(false);
});

it("offers nothing when no project is selected", () => {
selectedProjectId = "";
renderSave(<SaveToBoard {...props()} />);

expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
Loading
Loading