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
10 changes: 10 additions & 0 deletions build-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import pkg from "./package.json";
import openapiSpec from "./openapi.json";

// Shared `define` block for vite.config.ts and vitest.config.ts — exposes the
// UI's own package version and the ContextForge API version it was generated
// against to the client bundle (see Header.tsx).
export const versionDefines = {
__APP_VERSION__: JSON.stringify(pkg.version),
__SUPPORTED_API_VERSION__: JSON.stringify(openapiSpec.info.version),
};
26 changes: 19 additions & 7 deletions src/components/layout/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Header } from "./Header";
import { useQuery } from "../../hooks/useQuery";
Expand Down Expand Up @@ -26,19 +27,17 @@ describe("Header", () => {
);
};

it("renders without version when useQuery returns no data", () => {
it("always shows the UI's own package version", () => {
vi.mocked(useQuery).mockReturnValue({ data: null } as unknown as ReturnType<typeof useQuery>);
renderHeader();

expect(screen.getByTestId("quick-nav")).toBeInTheDocument();
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();

// Check that there is no version string visible (like v1.2.3)
const versionMatch = screen.queryByText(/v\d+\.\d+\.\d+/);
expect(versionMatch).not.toBeInTheDocument();
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
});

it("renders with version when useQuery returns version data", () => {
it("shows supported and live API versions in the hover popover", async () => {
const user = userEvent.setup();
vi.mocked(useQuery).mockReturnValue({
data: {
app: {
Expand All @@ -48,6 +47,19 @@ describe("Header", () => {
} as unknown as ReturnType<typeof useQuery>);
renderHeader();

expect(screen.getByText("v1.0.0")).toBeInTheDocument();
await user.hover(screen.getByText(`v${__APP_VERSION__}`));

expect(await screen.findByText(`v${__SUPPORTED_API_VERSION__}`)).toBeInTheDocument();
expect(await screen.findByText("v1.0.0")).toBeInTheDocument();
});

it("shows the live API version as unavailable when useQuery returns no data", async () => {
const user = userEvent.setup();
vi.mocked(useQuery).mockReturnValue({ data: null } as unknown as ReturnType<typeof useQuery>);
renderHeader();

await user.hover(screen.getByText(`v${__APP_VERSION__}`));

expect(await screen.findByText("unavailable")).toBeInTheDocument();
});
});
35 changes: 29 additions & 6 deletions src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { BookOpen } from "lucide-react";
import { useQuery } from "../../hooks/useQuery";
import { GitHubIcon } from "../icons/GitHubIcon";
import { SidebarTrigger } from "../ui/sidebar";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "../ui/hover-card";
import { Typography } from "../ui/typography";
import { HeaderProfileMenu } from "./HeaderProfileMenu";
import { HeaderQuickNav } from "./HeaderQuickNav";

Expand All @@ -16,18 +18,39 @@ interface VersionResponse {

export function Header() {
const { data: versionData } = useQuery<VersionResponse>("/version?partial=false");
const appVersion = versionData?.app?.version;
const liveApiVersion = versionData?.app?.version;

return (
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4">
<SidebarTrigger />
<div className="flex items-center gap-2">
<HeaderQuickNav />
{appVersion ? (
<span className="hidden text-sm font-medium text-muted-foreground sm:inline">
v{appVersion}
</span>
) : null}
<HoverCard openDelay={100}>
<HoverCardTrigger asChild>
<span
tabIndex={0}
className="hidden cursor-default text-sm font-medium text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring sm:inline"
>
v{__APP_VERSION__}
</span>
</HoverCardTrigger>
<HoverCardContent className="w-auto" align="end">
<div className="space-y-1">
<Typography variant="body">
ContextForge supported API:{" "}
<Typography variant="label" as="span">
v{__SUPPORTED_API_VERSION__}
</Typography>
</Typography>
<Typography variant="body">
Live API version:{" "}
<Typography variant="label" as="span">
{liveApiVersion ? `v${liveApiVersion}` : "unavailable"}
</Typography>
</Typography>
</div>
</HoverCardContent>
</HoverCard>
<a
href={GITHUB_URL}
target="_blank"
Expand Down
36 changes: 36 additions & 0 deletions src/components/ui/hover-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from "react";
import { HoverCard as HoverCardPrimitive } from "radix-ui";

import { cn } from "@/lib/utils";

function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}

function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
}

function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}

export { HoverCard, HoverCardTrigger, HoverCardContent };
5 changes: 5 additions & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
/// <reference types="vite/client" />

// Injected by vite.config.ts `define` from package.json's version field.
declare const __APP_VERSION__: string;
// Injected by vite.config.ts `define` from openapi.json's info.version field.
declare const __SUPPORTED_API_VERSION__: string;
3 changes: 3 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
import { versionDefines } from "./build-constants";

// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],

define: versionDefines,

css: {
postcss: {
plugins: [],
Expand Down
3 changes: 3 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
import { versionDefines } from "./build-constants";

export default defineConfig({
plugins: [react()] as any,
// Mirrors vite.config.ts's version constants for code under test.
define: versionDefines,
css: {
postcss: {
plugins: [],
Expand Down
Loading