Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion packages/cli/src/routes/runs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from "node:path";
import type { Chapter, ChapterRun, KeyChange } from "@stage-cli/types/chapters";
import { asc, eq, inArray } from "drizzle-orm";
import type { StageDb } from "../db/client.js";
Expand Down Expand Up @@ -35,7 +36,7 @@ function mapChapter(ch: ChapterRow, kcs: KeyChangeRow[]): Chapter {
}

function mapRun(run: ChapterRunRow): ChapterRun {
return { id: run.id };
return { id: run.id, repoName: path.basename(run.repoRoot) };
}

export function runRoutes(db: StageDb): Route[] {
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/chapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export type Chapter = z.infer<typeof ChapterSchema>;

export const ChapterRunSchema = z.object({
id: z.string(),
repoName: z.string(),
});
export type ChapterRun = z.infer<typeof ChapterRunSchema>;

Expand Down
20 changes: 20 additions & 0 deletions packages/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stage CLI</title>
<script>
// Apply the persisted theme before paint to avoid a flash of the wrong
// theme. Mirrors the runtime logic in src/lib/theme.tsx.
(function () {
try {
var stored = localStorage.getItem("ui-theme");
var theme = stored && ["light", "dark", "system"].indexOf(stored) !== -1 ? stored : "system";
var resolved =
theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: theme;
document.documentElement.classList.add(resolved);
} catch (e) {
var fallback = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.classList.add(fallback);
}
})();
</script>
</head>
<body>
<div id="root"></div>
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@stage-cli/types": "workspace:*",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
Expand Down
11 changes: 8 additions & 3 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Topbar } from "@/components/layout/topbar";
import { useHashRunId } from "@/lib/use-hash-run-id";
import { PullRequestLayout } from "@/routes/pull-request-layout";

function NoRunSelected() {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-6 text-foreground">
<div className="flex flex-1 items-center justify-center p-6">
<div className="max-w-md text-center">
<h1 className="font-semibold text-lg">No run selected</h1>
<p className="mt-2 text-muted-foreground text-sm">
Expand All @@ -17,6 +18,10 @@ function NoRunSelected() {

export function App() {
const runId = useHashRunId();
if (!runId) return <NoRunSelected />;
return <PullRequestLayout runId={runId} />;
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
<Topbar runId={runId} />
{runId ? <PullRequestLayout runId={runId} /> : <NoRunSelected />}
</div>
);
}
46 changes: 46 additions & 0 deletions packages/web/src/components/layout/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Monitor, Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { USER_THEME, type UserTheme, useTheme } from "@/lib/theme";

interface Option {
value: UserTheme;
label: string;
icon: React.ElementType;
}

const OPTIONS: Option[] = [
{ value: USER_THEME.LIGHT, label: "Light", icon: Sun },
{ value: USER_THEME.DARK, label: "Dark", icon: Moon },
{ value: USER_THEME.SYSTEM, label: "System", icon: Monitor },
];

export function ThemeToggle() {
const { userTheme, setTheme } = useTheme();

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm">
<Sun className="h-4 w-4 dark:hidden" />
<Moon className="hidden h-4 w-4 dark:block" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{OPTIONS.map(({ value, label, icon: Icon }) => (
<DropdownMenuItem key={value} onClick={() => setTheme(value)} className="cursor-pointer">
<Icon className="mr-2 h-4 w-4" />
{label}
{userTheme === value && <span className="ml-auto text-xs">✓</span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
18 changes: 18 additions & 0 deletions packages/web/src/components/layout/topbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ThemeToggle } from "@/components/layout/theme-toggle";
import { useChapters } from "@/lib/use-chapters";

export function Topbar({ runId }: { runId: string | null }) {
const { data } = useChapters(runId);
const repoName = data?.run.repoName;

return (
<header className="sticky top-0 z-30 flex h-12 shrink-0 items-center justify-between border-border border-b bg-background px-6 lg:px-8">
<div className="flex min-w-0 items-center gap-2 text-sm">
{repoName && <span className="truncate font-medium text-foreground">{repoName}</span>}
</div>
<div className="flex shrink-0 items-center gap-2">
<ThemeToggle />
</div>
</header>
);
}
59 changes: 59 additions & 0 deletions packages/web/src/components/ui/dropdown-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import type * as React from "react";

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

function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}

function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}

function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}

function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}

export { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger };
106 changes: 106 additions & 0 deletions packages/web/src/lib/theme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
createContext,
type ReactNode,
use,
useCallback,
useEffect,
useMemo,
useState,
} from "react";

export const USER_THEME = {
LIGHT: "light",
DARK: "dark",
SYSTEM: "system",
} as const;
export type UserTheme = (typeof USER_THEME)[keyof typeof USER_THEME];

export const APP_THEME = {
LIGHT: "light",
DARK: "dark",
} as const;
export type AppTheme = (typeof APP_THEME)[keyof typeof APP_THEME];

export const THEME_STORAGE_KEY = "ui-theme";

const VALID_THEMES: ReadonlySet<string> = new Set<string>(Object.values(USER_THEME));

function isValidUserTheme(value: string): value is UserTheme {
return VALID_THEMES.has(value);
}

function getStoredUserTheme(): UserTheme {
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored && isValidUserTheme(stored)) return stored;
} catch {
// localStorage unavailable
}
return USER_THEME.SYSTEM;
}

function getSystemTheme(): AppTheme {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? APP_THEME.DARK
: APP_THEME.LIGHT;
}

function applyThemeToDOM(userTheme: UserTheme): void {
const root = document.documentElement;
root.classList.remove(APP_THEME.LIGHT, APP_THEME.DARK);
const resolved = userTheme === USER_THEME.SYSTEM ? getSystemTheme() : userTheme;
root.classList.add(resolved);
}

interface ThemeContextValue {
userTheme: UserTheme;
appTheme: AppTheme;
setTheme: (theme: UserTheme) => void;
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

export function ThemeProvider({ children }: { children: ReactNode }) {
const [userTheme, setUserTheme] = useState<UserTheme>(getStoredUserTheme);
const [systemTheme, setSystemTheme] = useState<AppTheme>(getSystemTheme);

useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => {
const next = mq.matches ? APP_THEME.DARK : APP_THEME.LIGHT;
setSystemTheme(next);
if (userTheme === USER_THEME.SYSTEM) {
applyThemeToDOM(USER_THEME.SYSTEM);
}
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [userTheme]);

const appTheme: AppTheme = userTheme === USER_THEME.SYSTEM ? systemTheme : userTheme;

const setTheme = useCallback((next: UserTheme) => {
setUserTheme(next);
try {
localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
// localStorage unavailable
}
applyThemeToDOM(next);
}, []);

const contextValue = useMemo(
() => ({ userTheme, appTheme, setTheme }),
[userTheme, appTheme, setTheme],
);

return <ThemeContext value={contextValue}>{children}</ThemeContext>;
}

export function useTheme(): ThemeContextValue {
const ctx = use(ThemeContext);
if (!ctx) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return ctx;
}
21 changes: 21 additions & 0 deletions packages/web/src/lib/use-chapters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { type ChaptersResponse, ChaptersResponseSchema } from "@stage-cli/types/chapters";
import { skipToken, useQuery } from "@tanstack/react-query";
import { jsonFetch } from "@/lib/use-view-state";

async function fetchChapters(runId: string): Promise<ChaptersResponse> {
// Parse at the boundary — schema drift surfaces here as a query error,
// not as a render crash deeper in the component tree.
const raw = await jsonFetch<unknown>(`/api/runs/${encodeURIComponent(runId)}/chapters`);
return ChaptersResponseSchema.parse(raw);
}

/**
* Shared chapters query. Multiple components calling this hook with the same
* runId dedupe to a single network fetch via TanStack Query's cache.
*/
export function useChapters(runId: string | null) {
return useQuery<ChaptersResponse>({
queryKey: ["chapters", runId],
queryFn: runId === null ? skipToken : () => fetchChapters(runId),
});
}
9 changes: 6 additions & 3 deletions packages/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { ThemeProvider } from "./lib/theme";
import "./styles/globals.css";

const rootElement = document.getElementById("root");
Expand All @@ -22,8 +23,10 @@ const queryClient = new QueryClient({

createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</ThemeProvider>
</StrictMode>,
);
Loading
Loading