From 8c2b2629e5e0e543913bf2b76c513316abf3a616 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 04:43:30 +0000 Subject: [PATCH] feat(task T03): implement via codex --- dashboard/src/v2/components/TopNav.tsx | 172 ++++++++++- .../hooks/use-project-effective-settings.ts | 9 + dashboard/src/v2/lib/settings-api.ts | 18 ++ .../v2/lib/settings/techstack-view-models.ts | 75 +++++ docs-web/user/dashboard/overview.md | 4 +- docs/dashboard/dashboard-guide.md | 1 + .../v2/top-nav-browser-menu.test.tsx | 16 + tests/dashboard/v2/top-nav-techstack.test.tsx | 288 ++++++++++++++++++ 8 files changed, 579 insertions(+), 4 deletions(-) create mode 100644 dashboard/src/v2/lib/settings/techstack-view-models.ts create mode 100644 tests/dashboard/v2/top-nav-techstack.test.tsx diff --git a/dashboard/src/v2/components/TopNav.tsx b/dashboard/src/v2/components/TopNav.tsx index 1a4ccae8b4..0c747d5bf0 100644 --- a/dashboard/src/v2/components/TopNav.tsx +++ b/dashboard/src/v2/components/TopNav.tsx @@ -1,9 +1,10 @@ import type { FunctionComponent, RefObject } from "preact"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import gsap from "gsap"; -import { Bell, Moon, Sun, ChevronDown, FolderOpen, ArrowRight } from "lucide-preact"; +import { Bell, Moon, Sun, ChevronDown, FolderOpen, ArrowRight, Layers } from "lucide-preact"; import { Link, useRouterState } from "@tanstack/react-router"; import { StatusDot } from "./ui/StatusDot.js"; +import type { TechstackCatalogSettings } from "../../types.js"; import { BrandSection } from "./top-nav/BrandSection.js"; import { GlobalSearch } from "./top-nav/GlobalSearch.js"; @@ -11,10 +12,13 @@ import { TelemetryStats } from "./top-nav/TelemetryStats.js"; import { AddProjectModal, type AddProjectModalSubmission } from "./ui/AddProjectModal.js"; import { buildGitHubModeProjectSettingsOverride } from "../../lib/settings-updaters.js"; +import { DEFAULT_DASHBOARD_SETTINGS } from "../../lib/settings.js"; import { useProjectData } from "../context/project-data.js"; import { useSprints } from "../../hooks/useSprints.js"; import { formatSprintDisplay } from "../lib/format-sprint.js"; -import { useProjectEffectiveSettings } from "../hooks/use-project-effective-settings.js"; +import { clearProjectEffectiveSettingsCache, useProjectEffectiveSettings } from "../hooks/use-project-effective-settings.js"; +import { fetchSystemSettings, saveProjectTechstackSettings } from "../lib/settings-api.js"; +import { buildTechstackSelectorViewModel } from "../lib/settings/techstack-view-models.js"; import { DockerStatusMenu } from "./DockerStatusMenu.js"; import { BrowserSessionsMenu } from "./browser/BrowserSessionsMenu.js"; import { NotificationPanel } from "./NotificationPanel.js"; @@ -263,6 +267,11 @@ export const TopNav: FunctionComponent = ({ onMenuToggle, isMobile, const [sprintDropdownOpen, setSprintDropdownOpen] = useState(false); const sprintDropdownRef = useRef(null); const [sprintDropdownWidth, setSprintDropdownWidth] = useState(0); + const [techstackDropdownOpen, setTechstackDropdownOpen] = useState(false); + const [techstackSwitchBusy, setTechstackSwitchBusy] = useState(false); + const [systemTechstackCatalog, setSystemTechstackCatalog] = useState(null); + const [systemSettingsLoading, setSystemSettingsLoading] = useState(true); + const techstackDropdownRef = useRef(null); const { projects, @@ -272,7 +281,7 @@ export const TopNav: FunctionComponent = ({ onMenuToggle, isMobile, loading, } = useProjectData(); const projectId = selectedProject?.id || null; - const { data: effectiveSettings } = useProjectEffectiveSettings(projectId); + const { data: effectiveSettings, loading: effectiveSettingsLoading, refresh: refreshEffectiveSettings } = useProjectEffectiveSettings(projectId); const sprintKeyPrefix = effectiveSettings?.settings?.git?.sprintKeyPrefix || "SPR"; const { data: sprints, selectedSprintId, selectedSprint, selectSprint, loading: sprintsLoading } = useSprints(selectedProject?.id || null); @@ -283,9 +292,26 @@ export const TopNav: FunctionComponent = ({ onMenuToggle, isMobile, const projectKb = useDropdownKeyboard(dropdownOpen, setDropdownOpen, dropdownRef, setProjectFilter); const sprintKb = useDropdownKeyboard(sprintDropdownOpen, setSprintDropdownOpen, sprintDropdownRef, setSprintFilter); + const techstackKb = useDropdownKeyboard(techstackDropdownOpen, setTechstackDropdownOpen, techstackDropdownRef); const filteredProjects = useMemo(() => projects.filter(p => p.name.toLowerCase().includes(projectFilter.toLowerCase())), [projects, projectFilter]); const filteredSprints = useMemo(() => sprints.filter(s => s.name.toLowerCase().includes(sprintFilter.toLowerCase())), [sprints, sprintFilter]); + const techstackCatalog = effectiveSettings?.settings.techstackCatalog + ?? systemTechstackCatalog + ?? DEFAULT_DASHBOARD_SETTINGS.techstackCatalog; + const techstackViewModel = useMemo( + () => buildTechstackSelectorViewModel(effectiveSettings?.settings.techstack, techstackCatalog), + [effectiveSettings?.settings.techstack, techstackCatalog], + ); + const techstackSelectorLoading = !!selectedProject && (effectiveSettingsLoading || systemSettingsLoading); + const techstackSelectorDisabled = !selectedProject || techstackSelectorLoading || techstackSwitchBusy; + const techstackHelper = !selectedProject + ? "Select a project first" + : techstackSelectorLoading + ? "Loading settings" + : techstackViewModel.isUnassigned + ? `Unassigned; using ${techstackViewModel.defaultLabel}` + : "Assigned"; useEffect(() => { if (previousPathRef.current !== currentPath) { @@ -323,6 +349,31 @@ export const TopNav: FunctionComponent = ({ onMenuToggle, isMobile, } }, [filteredSprints.length, sprintDropdownOpen, sprintFilter, sprintsLoading]); + useEffect(() => { + let cancelled = false; + setSystemSettingsLoading(true); + fetchSystemSettings() + .then((settings) => { + if (!cancelled) { + setSystemTechstackCatalog(settings.techstackCatalog); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + const message = error instanceof Error ? error.message : "Unknown error"; + setNavAnnouncement(`Could not load techstack catalog. ${message}`); + } + }) + .finally(() => { + if (!cancelled) { + setSystemSettingsLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, []); + useLayoutEffect(() => { if (sprintDropdownOpen && sprintDropdownRef.current) { setSprintDropdownWidth(sprintDropdownRef.current.offsetWidth); @@ -343,11 +394,41 @@ export const TopNav: FunctionComponent = ({ onMenuToggle, isMobile, if (sprintDropdownRef.current && !sprintDropdownRef.current.contains(e.target as Node)) { setSprintDropdownOpen(false); } + if (techstackDropdownRef.current && !techstackDropdownRef.current.contains(e.target as Node)) { + setTechstackDropdownOpen(false); + } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); + const handleTechstackSelection = async (nextTechstackId: string | null, label: string) => { + if (!projectId || techstackSwitchBusy || nextTechstackId === techstackViewModel.selectedTechstackId) { + setTechstackDropdownOpen(false); + return; + } + + setTechstackSwitchBusy(true); + setNavAnnouncement(`Saving techstack ${label}...`); + try { + await saveProjectTechstackSettings(projectId, { + selectedTechstackId: nextTechstackId, + applicationKind: effectiveSettings?.settings.techstack.applicationKind ?? null, + }); + clearProjectEffectiveSettingsCache(projectId); + await refreshEffectiveSettings(); + setNavAnnouncement(nextTechstackId === null + ? `Techstack cleared. ${techstackViewModel.defaultLabel} remains the display fallback.` + : `Techstack switched to ${label.replace(" (default)", "")}`); + setTechstackDropdownOpen(false); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + setNavAnnouncement(`Could not save techstack. ${message}`); + } finally { + setTechstackSwitchBusy(false); + } + }; + const handleCreateProject = async (project: AddProjectModalSubmission) => { if (project.type === 'new_project') { const isLocalProject = project.initMode === 'new-local'; @@ -496,6 +577,91 @@ export const TopNav: FunctionComponent = ({ onMenuToggle, isMobile, )} + {/* Techstack Selector */} +
+ + + {techstackDropdownOpen && !techstackSelectorDisabled && ( +
+
+ Techstack +
+
+ {techstackViewModel.options.map((option) => { + const selected = option.techstackId === techstackViewModel.selectedTechstackId; + return ( + + ); + })} +
+
+ )} +
+ {/* Sprint Selector */} {selectedProject && (
diff --git a/dashboard/src/v2/hooks/use-project-effective-settings.ts b/dashboard/src/v2/hooks/use-project-effective-settings.ts index fe13bfdbfb..8272577f92 100644 --- a/dashboard/src/v2/hooks/use-project-effective-settings.ts +++ b/dashboard/src/v2/hooks/use-project-effective-settings.ts @@ -8,6 +8,15 @@ const effectiveSettingsCache = new Map(); const effectiveSettingsInflightRequests = new Map>(); export const clearEffectiveSettingsCacheForTests = (): void => { + clearProjectEffectiveSettingsCache(); +}; + +export const clearProjectEffectiveSettingsCache = (projectId?: string): void => { + if (projectId) { + effectiveSettingsCache.delete(projectId); + effectiveSettingsInflightRequests.delete(projectId); + return; + } effectiveSettingsCache.clear(); effectiveSettingsInflightRequests.clear(); }; diff --git a/dashboard/src/v2/lib/settings-api.ts b/dashboard/src/v2/lib/settings-api.ts index bc40fdabb2..1abf04c872 100644 --- a/dashboard/src/v2/lib/settings-api.ts +++ b/dashboard/src/v2/lib/settings-api.ts @@ -2,6 +2,7 @@ import type { EffectiveSettingsResponse, ProjectSettings, SystemSettings, + TechstackSelectionSettings, } from "../../types.js"; import { fetchJson } from "../../lib/api/fetch-json.js"; @@ -108,6 +109,23 @@ export const saveProjectSettings = async (projectId: string, settings: ProjectSe } }; +export const saveProjectTechstackSettings = async ( + projectId: string, + techstack: TechstackSelectionSettings, +): Promise => { + await fetchJson(`/api/projects/${encodeURIComponent(projectId)}/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ techstack }), + }); + clearEffectiveSettingsRequests(projectId); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("codeux:settings-updated", { + detail: { scope: "project", projectId }, + })); + } +}; + export const resetProjectSettings = async (projectId: string): Promise => { await fetchJson(`/api/projects/${encodeURIComponent(projectId)}/settings`, { method: "DELETE", diff --git a/dashboard/src/v2/lib/settings/techstack-view-models.ts b/dashboard/src/v2/lib/settings/techstack-view-models.ts new file mode 100644 index 0000000000..430a91f3a7 --- /dev/null +++ b/dashboard/src/v2/lib/settings/techstack-view-models.ts @@ -0,0 +1,75 @@ +import type { TechstackCatalogSettings, TechstackSelectionSettings } from "../../../types.js"; +import { DEFAULT_DASHBOARD_SETTINGS } from "../../../lib/settings.js"; + +export type TechstackSelectorOptionKind = "unassigned" | "catalog"; + +export interface TechstackSelectorOption { + id: string; + label: string; + kind: TechstackSelectorOptionKind; + techstackId: string | null; +} + +export interface TechstackSelectorViewModel { + activeLabel: string; + activeTechstackId: string; + selectedTechstackId: string | null; + isUnassigned: boolean; + defaultLabel: string; + options: TechstackSelectorOption[]; +} + +const UNASSIGNED_OPTION_ID = "__unassigned__"; + +const normalizeCatalog = (catalog: TechstackCatalogSettings | null | undefined): TechstackCatalogSettings => { + const source = catalog ?? DEFAULT_DASHBOARD_SETTINGS.techstackCatalog; + const entries = source.entries.length > 0 ? source.entries : DEFAULT_DASHBOARD_SETTINGS.techstackCatalog.entries; + const hasDefault = entries.some((entry) => entry.id === source.defaultTechstackId); + const defaultTechstackId = hasDefault + ? source.defaultTechstackId + : DEFAULT_DASHBOARD_SETTINGS.techstackCatalog.defaultTechstackId; + + return { + defaultTechstackId, + entries, + }; +}; + +export const buildTechstackSelectorViewModel = ( + selection: TechstackSelectionSettings | null | undefined, + catalog: TechstackCatalogSettings | null | undefined, +): TechstackSelectorViewModel => { + const normalizedCatalog = normalizeCatalog(catalog); + const defaultEntry = normalizedCatalog.entries.find((entry) => entry.id === normalizedCatalog.defaultTechstackId) + ?? DEFAULT_DASHBOARD_SETTINGS.techstackCatalog.entries[0]!; + const selectedTechstackId = selection?.selectedTechstackId ?? null; + const activeEntry = selectedTechstackId + ? normalizedCatalog.entries.find((entry) => entry.id === selectedTechstackId) ?? defaultEntry + : defaultEntry; + const orderedEntries = [ + defaultEntry, + ...normalizedCatalog.entries.filter((entry) => entry.id !== defaultEntry.id), + ]; + + return { + activeLabel: activeEntry.label, + activeTechstackId: activeEntry.id, + selectedTechstackId, + isUnassigned: selectedTechstackId === null, + defaultLabel: defaultEntry.label, + options: [ + { + id: UNASSIGNED_OPTION_ID, + label: "Unassigned", + kind: "unassigned", + techstackId: null, + }, + ...orderedEntries.map((entry) => ({ + id: entry.id, + label: entry.id === defaultEntry.id ? `${entry.label} (default)` : entry.label, + kind: "catalog" as const, + techstackId: entry.id, + })), + ], + }; +}; diff --git a/docs-web/user/dashboard/overview.md b/docs-web/user/dashboard/overview.md index a1a087c094..bf8a9494f5 100644 --- a/docs-web/user/dashboard/overview.md +++ b/docs-web/user/dashboard/overview.md @@ -10,10 +10,12 @@ The dashboard uses a **dock-based navigation** by default: - **Dock** *(desktop)* — A floating dock at the screen edge with one icon per page plus a settings button. - **Sidebar** *(mobile or user preference)* — A collapsible left sidebar. -- **Top bar** — Project selector, theme toggle, mobile menu. +- **Top bar** — Project selector, techstack selector, theme toggle, mobile menu. A choice of theme (Light / Dark / System) is in the top bar; navigation mode override is in **Settings → Appearance**. +When a project is active, the top bar also shows its techstack. Projects imported before classification can remain **Unassigned**; in that state the selector displays the catalog default (the built-in Code UX Internal stack unless changed in system settings) as the working fallback without assigning it to the project. Choosing a stack from the dropdown saves only the project techstack selection. + The background is an animated Three.js scene ("Deep Ocean") that lazy-loads after the main UI is interactive, so it never blocks first paint. ## Pages diff --git a/docs/dashboard/dashboard-guide.md b/docs/dashboard/dashboard-guide.md index f10da0fd31..4ff4dbae16 100644 --- a/docs/dashboard/dashboard-guide.md +++ b/docs/dashboard/dashboard-guide.md @@ -277,6 +277,7 @@ Legacy runtime: - V2 pages render their intro/heading via the shared `PageHeader` atomic component (`components/layout/PageHeader.tsx`): an optional icon + uppercase eyebrow, a unified `text-2xl md:text-3xl` title, an optional subtitle, and optional actions. Header titles and subtitles use balanced wrapping, and action clusters stack/wrap below the heading until the `lg` breakpoint so mobile and tablet layouts do not squeeze controls beside long titles. Keep all non-H1 headings visually lighter than the route title with explicit Tailwind classes, generally `text-xl`/`text-2xl` with `font-semibold` for section headings and `text-base`/`text-lg` with `font-semibold` for card titles. - Light mode resolves the shared `signal-*` utilities to a stable blue accent for active, selected, focus, and primary controls; dark mode keeps the existing jade signal. Use the semantic signal utilities or CSS variables instead of hardcoded green values in new dashboard UI. - Top-nav project selector persists the active project in sqlite and uses a bounded scrollable listbox so long project lists stay inside the header overlay. +- Top-nav techstack selector sits beside the project and sprint selectors. It reads the active project's effective `techstack` selection plus the system-owned techstack catalog, displays the catalog default (`Code UX Internal` by default) when `selectedTechstackId` is `null`, and keeps imported/unclassified projects explicitly `Unassigned` until an operator chooses a catalog entry. Saving from the navbar writes only the project `techstack.selectedTechstackId` and `techstack.applicationKind` fields, clears cached effective settings for that project, and never mutates the system catalog. - Top-nav sprint selector persists the active sprint for the selected project and uses the same bounded scrollable listbox pattern. The header dropdown lists only real sprints; if persisted scope is null, `All Sprints` remains a fallback trigger label rather than a selectable header row. - Top-nav search sits in the left header cluster beside the brand and lazy-loads project tasks only after the search overlay opens; the active task counter uses the same compact height as the project, sprint, and worker selectors - Global Search preserves previous results during its token-timed debounce to avoid layout shift, only polls for container previews when opened, and keeps arrow-key/Enter/Escape navigation wired through `aria-activedescendant` while focus remains on the combobox. The trigger, overlay entrance/exit, row reveal, active-row movement, and control feedback all resolve through the shared `enterExit`, `listReveal`, `selectionMovement`, and `controlFeedback` motion contracts; reduced-motion users get instant state changes with static cues such as focus rings, selected borders, disabled badges, count chips, `aria-busy`, and live copy. Stale result refreshes keep current rows visible with `aria-busy`, a single polite refresh announcement, and a persistent updating badge, while a newly committed query with no matches shows a true empty state instead of a loading placeholder. Keyboard movement scrolls only the overlay results container so the page behind the search does not jump. Unavailable rows remain inspectable with a visible reason referenced by `aria-describedby`, are marked `aria-disabled`, are skipped by pointer and keyboard activation when another row can open, and stay non-navigating on Enter when every result is unavailable. Sprint results use the selected project's configured sprint key prefix, so searches for project keys such as `CODUX-32` match the same sprint key shown in the row. Selecting a sprint opens the Sprints page with `?sprintKey=` so the ledger filter is seeded from the explicit route payload rather than from visible row text. diff --git a/tests/dashboard/v2/top-nav-browser-menu.test.tsx b/tests/dashboard/v2/top-nav-browser-menu.test.tsx index df20a6a0ef..710c33518a 100644 --- a/tests/dashboard/v2/top-nav-browser-menu.test.tsx +++ b/tests/dashboard/v2/top-nav-browser-menu.test.tsx @@ -118,6 +118,22 @@ vi.mock("../../../dashboard/src/v2/lib/preview-origin.js", () => ({ }), })); +vi.mock("../../../dashboard/src/v2/lib/settings-api.js", () => ({ + fetchSystemSettings: vi.fn(() => Promise.resolve({ + techstackCatalog: { + defaultTechstackId: "code-ux-internal", + entries: [ + { + id: "code-ux-internal", + label: "Code UX Internal", + items: [{ id: "preact", label: "Preact" }], + }, + ], + }, + })), + saveProjectTechstackSettings: vi.fn(() => Promise.resolve()), +})); + vi.mock("@tanstack/react-router", () => ({ Link: ({ children, to, ...props }: any) => ( diff --git a/tests/dashboard/v2/top-nav-techstack.test.tsx b/tests/dashboard/v2/top-nav-techstack.test.tsx new file mode 100644 index 0000000000..f7fc8cfde6 --- /dev/null +++ b/tests/dashboard/v2/top-nav-techstack.test.tsx @@ -0,0 +1,288 @@ +/** @jsx h */ +// @vitest-environment happy-dom +import { h } from "preact"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/preact"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { TopNav } from "../../../dashboard/src/v2/components/TopNav.js"; +import { useProjectData } from "../../../dashboard/src/v2/context/project-data.js"; +import { useSprints } from "../../../dashboard/src/hooks/useSprints.js"; +import { useProjectEffectiveSettings, clearProjectEffectiveSettingsCache } from "../../../dashboard/src/v2/hooks/use-project-effective-settings.js"; +import { fetchSystemSettings, saveProjectTechstackSettings } from "../../../dashboard/src/v2/lib/settings-api.js"; + +expect.extend(matchers); + +vi.mock("../../../dashboard/src/v2/context/project-data.js", () => ({ + useProjectData: vi.fn(), +})); + +vi.mock("../../../dashboard/src/hooks/useSprints.js", () => ({ + useSprints: vi.fn(), +})); + +vi.mock("../../../dashboard/src/v2/hooks/use-project-effective-settings.js", () => ({ + useProjectEffectiveSettings: vi.fn(), + clearProjectEffectiveSettingsCache: vi.fn(), +})); + +vi.mock("../../../dashboard/src/v2/lib/settings-api.js", () => ({ + fetchSystemSettings: vi.fn(), + saveProjectTechstackSettings: vi.fn(), +})); + +vi.mock("../../../dashboard/src/v2/hooks/use-notifications.js", () => ({ + useNotifications: vi.fn(() => ({ + notifications: [], + unreadCount: 0, + markAllRead: vi.fn(), + markRead: vi.fn(), + dismiss: vi.fn(), + refresh: vi.fn(), + })), +})); + +vi.mock("../../../dashboard/src/v2/hooks/useThemeSetting.js", () => ({ + useThemeSetting: vi.fn(() => ({ setTheme: vi.fn() })), +})); + +vi.mock("../../../dashboard/src/v2/hooks/use-is-dark.js", () => ({ + useIsDark: vi.fn(() => true), +})); + +vi.mock("../../../dashboard/src/v2/hooks/use-reduced-motion.js", () => ({ + useReducedMotion: vi.fn(() => true), + useResolvedMotionDuration: (duration: number) => duration, +})); + +vi.mock("../../../dashboard/src/v2/components/DockerStatusMenu.js", () => ({ + DockerStatusMenu: () =>