Merge remote-tracking branch 'origin/fix/delegated-fix2' into work/sharing-write-integration

This commit is contained in:
Marc-André Bombeck committed 2026-08-03 21:15:01 +02:00
commit b817a2d47e
25 files changed
+956 -204

No files matched your search

@@ -1,11 +1,7 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import {
CoachLaunchProvider,
resolveLaunchState,
useCoachLaunch,
} from "../coach-launch-context";
import type { AccountAccess } from "@/lib/sharing/account-access-view";
/**
* v1.4.27 R3d MB4 — Coach launch context smoke contract.
@@ -19,8 +15,48 @@ import {
* 3. The hook returns `null` when called outside the provider —
* consumers degrade gracefully (e.g. the launch button renders
* nothing rather than crashing).
* 4. v1.36.x — the provider publishes nothing inside somebody else's
* record, so (3) also covers every consumer under a switch. The shell
* mounts no drawer there, and a launch call that opened nothing was a
* button that silently did nothing on every page that offers one.
*
* Mutation check, run: dropping the `inSharedRecord` arm from the provider's
* value memo → "publishes nothing inside somebody else's record" goes red
* with the full context shape in the diff.
*/
const OWNER = {
accountId: "acct-owner",
username: "owner",
displayName: "Margarethe",
access: "write" as const,
canWrite: true,
};
const mockAccessRef: { value: AccountAccess } = {
value: { accounts: [OWNER], active: null, canSwitch: true },
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "delegate",
username: "delegate",
email: null,
role: "USER",
avatarUrl: null,
modules: {},
accountAccess: mockAccessRef.value,
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
const { CoachLaunchProvider, resolveLaunchState, useCoachLaunch } =
await import("../coach-launch-context");
function Probe({ output }: { output: string[] }) {
const launch = useCoachLaunch();
if (!launch) {
@@ -72,6 +108,41 @@ describe("CoachLaunchProvider", () => {
expect(html).toContain('data-slot="child-mount"');
expect(html).toContain("child");
});
it("publishes nothing inside somebody else's record, at both levels", () => {
for (const access of ["read", "write"] as const) {
mockAccessRef.value = {
accounts: [OWNER],
active: { ...OWNER, access, canWrite: access === "write" },
canSwitch: true,
};
const output: string[] = [];
renderToStaticMarkup(
<CoachLaunchProvider>
<Probe output={output} />
</CoachLaunchProvider>,
);
// The whole recorded value, not a boolean: a failure prints the shape
// that leaked rather than `false !== true`.
expect(output, `grant level: ${access}`).toEqual(["null"]);
}
mockAccessRef.value = { accounts: [OWNER], active: null, canSwitch: true };
});
it("still renders its children there — only the launch value is withheld", () => {
mockAccessRef.value = {
accounts: [OWNER],
active: OWNER,
canSwitch: true,
};
const html = renderToStaticMarkup(
<CoachLaunchProvider>
<div data-slot="child-mount">child</div>
</CoachLaunchProvider>,
);
mockAccessRef.value = { accounts: [OWNER], active: null, canSwitch: true };
expect(html).toContain('data-slot="child-mount"');
});
});
/**
+47 -13
View File
@@ -10,6 +10,7 @@ import {
type ReactNode,
} from "react";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types";
/**
@@ -191,7 +192,30 @@ export interface CoachLaunchProviderProps {
children: ReactNode;
}
/**
* Owns the drawer's launch state — and publishes it only where a drawer
* exists to receive it.
*
* v1.36.x — the shell stopped mounting `<LayoutCoachMount>` inside somebody
* else's record, and this provider kept answering: `askCoach()` set an open
* flag that nothing was reading, so every per-page Coach entry point became a
* button that did nothing at all. A control that errors tells a person where
* they stand; one that silently does nothing tells them the product is
* broken.
*
* The gate lives here, on the publisher, rather than in `useCoachLaunch()`.
* Six components read this context and five already treat `null` as "no Coach
* here", so withholding the value fixes all of them at once and a seventh
* inherits the rule. Putting it in the hook instead would drag the account
* query into every one of those components for an answer that is the same in
* all of them.
*
* Nothing is taken away by this: `/insights` and `/coach` are not
* shared-record destinations, so the Coach is outside what sharing covers to
* begin with.
*/
export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const { inSharedRecord } = useRecordCapabilities();
const [open, setOpen] = useState<boolean>(false);
const [closeIntent, setCloseIntent] = useState<CoachCloseIntent | null>(null);
const [prefill, setPrefill] = useState<string | null>(null);
@@ -277,20 +301,24 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
[],
);
const value = useMemo<CoachLaunchValue>(
() => ({
open,
closeIntent,
prefill,
autoSend,
scope,
documentId,
workoutId,
askCoach,
registerScope,
setOpen: handleSetOpen,
}),
const value = useMemo<CoachLaunchValue | null>(
() =>
inSharedRecord
? null
: {
open,
closeIntent,
prefill,
autoSend,
scope,
documentId,
workoutId,
askCoach,
registerScope,
setOpen: handleSetOpen,
},
[
inSharedRecord,
closeIntent,
open,
prefill,
@@ -316,6 +344,12 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
* `<CoachLaunchProvider>` so consumer components can degrade gracefully
* (e.g. the hero strip's "Ask the coach" action stays disabled until
* the provider mounts).
*
* v1.36.x — also `null` inside somebody else's record, and by the same
* mechanism: the provider publishes nothing there (see its own docblock), so
* every consumer's existing `if (!launch) return null` becomes right without
* being told. Kept as a plain context read on purpose — the answer is decided
* once, in the provider, rather than by a hook that six components call.
*/
export function useCoachLaunch(): CoachLaunchValue | null {
return useContext(CoachLaunchContext);