app switcher: replace product tabs with logo dropdown

- New AppSwitcher component in navbar — hover/click logo pill to switch between Grove, Canopy, Ring, Collab
- useAppSwitcherItems hook builds context-aware subdomain links
- Removed product tabs (Canopy/Ring/Collab) from grove repo tab bar
- Removed canopy status polling from main nav
- GroveLogo animates on its own row hover via .hover-row:hover
- Mobile: icon-only switcher with symmetric padding, pinned nav
- Ring nav updated with productName and link breadcrumbs
Anton Kaminsky3/1/202610621c544022parent 32ba63d
9 files changed+307-309
web/app/canopy/canopy-nav.tsx
@@ -8,6 +8,7 @@
88import { Badge } from "@/app/components/ui/badge";
99import { NavBar } from "@/app/components/ui/navbar";
1010import { useCanopyEvents, type CanopyEvent } from "@/lib/use-canopy-events";
11import { useAppSwitcherItems } from "@/lib/use-app-switcher";
1112
1213const statusLabels: Record<string, string> = {
1314 pending: "Pending",
@@ -189,11 +190,14 @@
189190 const isStatusPending = runId ? runStatusLoading : !repoStatusLoaded;
190191 useStatusFavicon(effectiveStatus, !!isStatusPending);
191192
193 const appSwitcherItems = useAppSwitcherItems("canopy", { owner, repo });
194
192195 return (
193196 <NavBar
194197 logo={<CanopyLogo size={28} />}
195198 productName="Canopy"
196199 showProductName={!owner}
200 appSwitcherItems={appSwitcherItems}
197201 breadcrumbs={
198202 <>
199203 {owner && (
200204
web/app/collab/collab-nav.tsx
@@ -5,15 +5,18 @@
55import { CollabLogo } from "@/app/components/collab-logo";
66import { NavBar } from "@/app/components/ui/navbar";
77import { CollabNavActionsSlot } from "./collab-nav-actions";
8import { useAppSwitcherItems } from "@/lib/use-app-switcher";
89
910export function CollabNav() {
1011 const params = useParams<{ owner?: string; repo?: string }>();
1112 const owner = params?.owner;
1213 const repo = params?.repo;
14 const appSwitcherItems = useAppSwitcherItems("collab", { owner, repo });
1315
1416 return (
1517 <NavBar
1618 logo={<CollabLogo size={28} />}
19 appSwitcherItems={appSwitcherItems}
1720 productName="Collab"
1821 showProductName={!owner}
1922 breadcrumbs={
2023
web/app/components/grove-logo.tsx
@@ -37,30 +37,33 @@
3737 cursor: pointer;
3838 transition: transform 0.5s cubic-bezier(0.25, 1, 0.5, 1);
3939 }
40 .grove-logo-${styleId}:hover {
40 .grove-logo-${styleId}:hover,
41 .hover-row:hover .grove-logo-${styleId} {
4142 transform: translateY(-2px);
4243 }
4344 .grove-logo-${styleId} .gl-bg {
4445 transition: fill 0.6s ease;
4546 }
46 .grove-logo-${styleId}:hover .gl-bg {
47 .grove-logo-${styleId}:hover .gl-bg,
48 .hover-row:hover .grove-logo-${styleId} .gl-bg {
4749 filter: brightness(0.9);
4850 }
4951 .grove-logo-${styleId} .gl-tree {
5052 transform-origin: 30px 52px;
5153 transform: rotate(var(--sway, 0deg));
52 transition: transform 0.15s ease-out;
53 }
54 .grove-logo-${styleId}:not(:hover) .gl-tree {
5554 transition: transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
5655 }
56 .grove-logo-${styleId}:hover .gl-tree,
57 .hover-row:hover .grove-logo-${styleId} .gl-tree {
58 transition: transform 0.15s ease-out;
59 }
5760 .grove-logo-${styleId} .gl-branch {
5861 transition: stroke-width 0.5s cubic-bezier(0.25, 1, 0.5, 1);
5962 }
60 .grove-logo-${styleId}:hover .gl-b0 { stroke-width: 4.5; transition-delay: 0s; }
61 .grove-logo-${styleId}:hover .gl-b1 { stroke-width: 3.5; transition-delay: 0.05s; }
62 .grove-logo-${styleId}:hover .gl-b2 { stroke-width: 3; transition-delay: 0.1s; }
63 .grove-logo-${styleId}:hover .gl-b3 { stroke-width: 2.5; transition-delay: 0.15s; }
63 .grove-logo-${styleId}:hover .gl-b0, .hover-row:hover .grove-logo-${styleId} .gl-b0 { stroke-width: 4.5; transition-delay: 0s; }
64 .grove-logo-${styleId}:hover .gl-b1, .hover-row:hover .grove-logo-${styleId} .gl-b1 { stroke-width: 3.5; transition-delay: 0.05s; }
65 .grove-logo-${styleId}:hover .gl-b2, .hover-row:hover .grove-logo-${styleId} .gl-b2 { stroke-width: 3; transition-delay: 0.1s; }
66 .grove-logo-${styleId}:hover .gl-b3, .hover-row:hover .grove-logo-${styleId} .gl-b3 { stroke-width: 2.5; transition-delay: 0.15s; }
6467 .grove-logo-${styleId} .gl-node {
6568 transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
6669 }
@@ -69,12 +72,12 @@
6972 .grove-logo-${styleId} .gl-n2 { transform-origin: 48px 23px; }
7073 .grove-logo-${styleId} .gl-n3 { transform-origin: 14px 33px; }
7174 .grove-logo-${styleId} .gl-n4 { transform-origin: 19px 11px; }
72 .grove-logo-${styleId}:hover .gl-node { transform: scale(1.25); }
73 .grove-logo-${styleId}:hover .gl-n0 { transition-delay: 0s; }
74 .grove-logo-${styleId}:hover .gl-n3 { transition-delay: 0.05s; }
75 .grove-logo-${styleId}:hover .gl-n2 { transition-delay: 0.1s; }
76 .grove-logo-${styleId}:hover .gl-n4 { transition-delay: 0.15s; }
77 .grove-logo-${styleId}:hover .gl-n1 { transition-delay: 0.2s; }
75 .grove-logo-${styleId}:hover .gl-node, .hover-row:hover .grove-logo-${styleId} .gl-node { transform: scale(1.25); }
76 .grove-logo-${styleId}:hover .gl-n0, .hover-row:hover .grove-logo-${styleId} .gl-n0 { transition-delay: 0s; }
77 .grove-logo-${styleId}:hover .gl-n3, .hover-row:hover .grove-logo-${styleId} .gl-n3 { transition-delay: 0.05s; }
78 .grove-logo-${styleId}:hover .gl-n2, .hover-row:hover .grove-logo-${styleId} .gl-n2 { transition-delay: 0.1s; }
79 .grove-logo-${styleId}:hover .gl-n4, .hover-row:hover .grove-logo-${styleId} .gl-n4 { transition-delay: 0.15s; }
80 .grove-logo-${styleId}:hover .gl-n1, .hover-row:hover .grove-logo-${styleId} .gl-n1 { transition-delay: 0.2s; }
7881 `}</style>
7982
8083 <circle cx="32" cy="32" r="32" fill="var(--accent)" className="gl-bg" />
8184
web/app/components/ui/navbar.tsx
@@ -1,25 +1,8 @@
11"use client";
22
3import { useState, useRef, useCallback } from "react";
3import { useState, useRef, useCallback, useEffect } from "react";
44import Link from "next/link";
55import { useAuth } from "@/lib/auth";
6import { Dropdown, DropdownItem } from "@/app/components/ui/dropdown";
7
8function HamburgerIcon() {
9 return (
10 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
11 <path d="M3 5h12M3 9h12M3 13h12" />
12 </svg>
13 );
14}
15
16function CloseIcon() {
17 return (
18 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
19 <path d="M4 4l10 10M14 4L4 14" />
20 </svg>
21 );
22}
236
247function UserPill({
258 user,
@@ -32,7 +15,6 @@
3215}) {
3316 const [expanded, setExpanded] = useState(false);
3417 const collapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
35 const menuRef = useRef<HTMLDivElement>(null);
3618
3719 const handleEnter = useCallback(() => {
3820 if (collapseTimer.current) clearTimeout(collapseTimer.current);
@@ -82,6 +64,7 @@
8264 {/* Pill trigger row */}
8365 <div
8466 className="flex items-center gap-2"
67 onClick={() => setExpanded((v) => !v)}
8568 style={{
8669 padding: "5px 12px 5px 8px",
8770 color: "var(--accent)",
@@ -114,7 +97,6 @@
11497
11598 {/* Menu items — expand below the pill row */}
11699 <div
117 ref={menuRef}
118100 style={{
119101 display: "grid",
120102 gridTemplateRows: expanded ? "1fr" : "0fr",
@@ -154,6 +136,136 @@
154136 );
155137}
156138
139export interface AppSwitcherItem {
140 name: string;
141 logo: React.ReactNode;
142 href: string;
143}
144
145function AppSwitcher({
146 logo,
147 productName,
148 showProductName,
149 items,
150}: {
151 logo: React.ReactNode;
152 productName?: string;
153 showProductName?: boolean;
154 items: AppSwitcherItem[];
155}) {
156 const [expanded, setExpanded] = useState(false);
157 const collapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
158
159 const handleEnter = useCallback(() => {
160 if (collapseTimer.current) clearTimeout(collapseTimer.current);
161 setExpanded(true);
162 }, []);
163
164 const handleLeave = useCallback(() => {
165 collapseTimer.current = setTimeout(() => setExpanded(false), 200);
166 }, []);
167
168 const rowClass = "flex items-center gap-2.5 p-[5px] sm:py-[5px] sm:pl-[5px] sm:pr-[12px]";
169
170 return (
171 <div
172 onMouseEnter={handleEnter}
173 onMouseLeave={handleLeave}
174 style={{ position: "relative", zIndex: 50 }}
175 className="shrink-0"
176 >
177 {/* Invisible spacer — reserves trigger size in navbar flow */}
178 <div
179 className={rowClass}
180 style={{
181 border: "1px solid transparent",
182 visibility: "hidden",
183 whiteSpace: "nowrap",
184 }}
185 >
186 <span style={{ width: 28, height: 28, flexShrink: 0 }} />
187 {productName && (
188 <span className="hidden sm:inline text-sm font-medium">{productName}</span>
189 )}
190 </div>
191
192 {/* Expanding border container — positioned over the spacer */}
193 <div
194 style={{
195 position: "absolute",
196 top: 0,
197 left: 0,
198 minWidth: "100%",
199 width: expanded ? "max-content" : undefined,
200 border: "1px solid var(--border)",
201 borderRadius: "16px",
202 backgroundColor: "var(--bg-page)",
203 overflow: "hidden",
204 transition: "width 0.2s ease",
205 }}
206 >
207 {/* Current app trigger row */}
208 <div
209 className={rowClass}
210 onClick={() => setExpanded((v) => !v)}
211 style={{ cursor: "pointer", whiteSpace: "nowrap" }}
212 >
213 <span className="shrink-0" style={{ width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center" }}>
214 {logo}
215 </span>
216 {productName && (
217 <span
218 className="hidden sm:inline text-sm font-medium"
219 style={{ color: "var(--text-primary)" }}
220 >
221 {productName}
222 </span>
223 )}
224 </div>
225
226 {/* Other apps — expand below */}
227 <div
228 style={{
229 display: "grid",
230 gridTemplateRows: expanded ? "1fr" : "0fr",
231 transition: "grid-template-rows 0.2s ease",
232 }}
233 >
234 <div style={{ overflow: "hidden" }}>
235 <div
236 style={{
237 borderTop: "1px solid var(--border)",
238 opacity: expanded ? 1 : 0,
239 transition: "opacity 0.15s ease",
240 }}
241 >
242 {items.map((item) => (
243 <a
244 key={item.name}
245 href={item.href}
246 className={`${rowClass} hover-row`}
247 style={{
248 color: "var(--text-primary)",
249 textDecoration: "none",
250 fontSize: "0.8125rem",
251 whiteSpace: "nowrap",
252 }}
253 onClick={() => setExpanded(false)}
254 >
255 <span className="shrink-0" style={{ width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center" }}>
256 {item.logo}
257 </span>
258 <span className="hidden sm:inline">{item.name}</span>
259 </a>
260 ))}
261 </div>
262 </div>
263 </div>
264 </div>
265 </div>
266 );
267}
268
157269export interface NavBarProps {
158270 /** Logo element (e.g. <GroveLogo size={28} />) */
159271 logo: React.ReactNode;
@@ -169,6 +281,8 @@
169281 belowBar?: React.ReactNode;
170282 /** Extra items for the user dropdown, rendered before Sign out */
171283 menuItems?: React.ReactNode;
284 /** App switcher items shown when clicking the logo */
285 appSwitcherItems?: AppSwitcherItem[];
172286 /** Extra items for the mobile side-panel user section (rendered as links, not DropdownItems) */
173287 mobileMenuItems?: React.ReactNode;
174288 /** Mobile menu content (if provided, shows hamburger on mobile and hides user pill on mobile) */
@@ -183,6 +297,7 @@
183297 actions,
184298 belowBar,
185299 menuItems,
300 appSwitcherItems,
186301 mobileMenuItems,
187302 mobileMenu,
188303}: NavBarProps) {
@@ -190,7 +305,7 @@
190305 const [menuOpen, setMenuOpen] = useState(false);
191306
192307 return (
193 <nav style={{ borderBottom: "1px solid var(--border)", backgroundColor: "var(--bg-page)" }}>
308 <nav style={{ borderBottom: "1px solid var(--border)", backgroundColor: "var(--bg-page)", position: "relative", zIndex: 50 }}>
194309 <div className="px-3 sm:px-6 h-14 flex items-center justify-between">
195310 <div className="flex items-center gap-1.5 sm:gap-3 text-sm min-w-0">
196311 {mobileMenu && (
@@ -203,17 +318,26 @@
203318 {menuOpen ? <CloseIcon /> : <HamburgerIcon />}
204319 </button>
205320 )}
206 <Link href="/" className="shrink-0 flex items-center gap-2">
207 {logo}
208 {productName && (showProductName ?? true) && (
209 <span
210 className="text-lg font-medium"
211 style={{ color: "var(--text-primary)", marginLeft: "0.25rem" }}
212 >
213 {productName}
214 </span>
215 )}
216 </Link>
321 {appSwitcherItems && appSwitcherItems.length > 0 ? (
322 <AppSwitcher
323 logo={logo}
324 productName={productName}
325 showProductName={showProductName}
326 items={appSwitcherItems}
327 />
328 ) : (
329 <Link href="/" className="shrink-0 flex items-center gap-2">
330 {logo}
331 {productName && (showProductName ?? true) && (
332 <span
333 className="text-lg font-medium"
334 style={{ color: "var(--text-primary)", marginLeft: "0.25rem" }}
335 >
336 {productName}
337 </span>
338 )}
339 </Link>
340 )}
217341 {breadcrumbs}
218342 </div>
219343 <div className={`${mobileMenu ? "hidden sm:flex" : "flex"} items-center gap-2 text-sm shrink-0`}>
@@ -303,3 +427,19 @@
303427 </nav>
304428 );
305429}
430
431function HamburgerIcon() {
432 return (
433 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
434 <path d="M3 5h12M3 9h12M3 13h12" />
435 </svg>
436 );
437}
438
439function CloseIcon() {
440 return (
441 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
442 <path d="M4 4l10 10M14 4L4 14" />
443 </svg>
444 );
445}
306446
web/app/globals.css
@@ -111,6 +111,20 @@
111111 overflow-x: hidden;
112112}
113113
114.grove-body {
115 height: 100dvh;
116 overflow: hidden;
117 display: flex;
118 flex-direction: column;
119}
120.grove-scroll {
121 flex: 1;
122 min-height: 0;
123 overflow: auto;
124 /* iOS momentum scrolling */
125 -webkit-overflow-scrolling: touch;
126}
127
114128code, pre, .font-mono, kbd, samp {
115129 font-family: 'JetBrains Mono', 'Menlo', monospace;
116130}
@@ -174,7 +188,12 @@
174188.hover-row {
175189 transition: background-color 0.1s;
176190}
177.hover-row:hover {
191@media (hover: hover) {
192 .hover-row:hover {
193 background-color: var(--bg-hover);
194 }
195}
196.hover-row:active {
178197 background-color: var(--bg-hover);
179198}
180199
181200
web/app/layout.tsx
@@ -21,13 +21,13 @@
2121}) {
2222 return (
2323 <html lang="en" suppressHydrationWarning>
24 <body style={{ height: "100vh", overflow: "hidden", display: "flex", flexDirection: "column", margin: 0 }}>
24 <body className="grove-body" style={{ margin: 0 }}>
2525 <DevErrorReporter />
2626 <ThemeProvider>
2727 <AuthProvider>
2828 <ToastProvider>
2929 <Nav />
30 <div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
30 <div className="grove-scroll">
3131 <div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>
3232 <main style={{ flex: 1 }}>
3333 {children}
3434
web/app/nav.tsx
@@ -1,37 +1,14 @@
11"use client";
22
3import { useState, useEffect, useCallback, useRef, type CSSProperties } from "react";
3import { useState, useEffect } from "react";
44import Link from "next/link";
55import { useParams, usePathname } from "next/navigation";
6import { canopy, repos as reposApi, orgs as orgsApi } from "@/lib/api";
6import { orgs as orgsApi } from "@/lib/api";
77import { useAuth } from "@/lib/auth";
88import { GroveLogo } from "@/app/components/grove-logo";
9import { CanopyLogo } from "@/app/components/canopy-logo";
10import { RingLogo } from "@/app/components/ring-logo";
11import { CollabLogo } from "@/app/components/collab-logo";
129import { DropdownItem } from "@/app/components/ui/dropdown";
1310import { NavBar } from "@/app/components/ui/navbar";
14import { useCanopyEvents } from "@/lib/use-canopy-events";
15
16function ExternalLinkIcon() {
17 return (
18 <svg
19 aria-hidden="true"
20 width="11"
21 height="11"
22 viewBox="0 0 12 12"
23 fill="none"
24 style={{ color: "var(--text-faint)" }}
25 >
26 <path
27 d="M4 2H10V8M10 2L2 10"
28 stroke="currentColor"
29 strokeWidth="1.2"
30 strokeLinecap="square"
31 />
32 </svg>
33 );
34}
11import { useAppSwitcherItems } from "@/lib/use-app-switcher";
3512
3613function useIsProductHost() {
3714 const [isProductHost, setIsProductHost] = useState<boolean | null>(null);
@@ -44,105 +21,15 @@
4421 return isProductHost;
4522}
4623
47function useCanopyOrigin() {
48 const [origin, setOrigin] = useState("");
49 useEffect(() => {
50 const host = window.location.host;
51 const parts = host.split(".");
52 if (parts[0] === "canopy") {
53 setOrigin(window.location.origin);
54 } else {
55 setOrigin(`${window.location.protocol}//canopy.${host}`);
56 }
57 }, []);
58 return origin;
59}
60
61function useRingOrigin() {
62 const [origin, setOrigin] = useState("");
63 useEffect(() => {
64 const host = window.location.host;
65 const parts = host.split(".");
66 if (parts[0] === "ring") {
67 setOrigin(window.location.origin);
68 } else {
69 setOrigin(`${window.location.protocol}//ring.${host}`);
70 }
71 }, []);
72 return origin;
73}
74
75function useCollabOrigin() {
76 const [origin, setOrigin] = useState("");
77 useEffect(() => {
78 const host = window.location.host;
79 const parts = host.split(".");
80 if (parts[0] === "collab") {
81 setOrigin(window.location.origin);
82 } else {
83 setOrigin(`${window.location.protocol}//collab.${host}`);
84 }
85 }, []);
86 return origin;
87}
88
8924export function Nav() {
9025 const params = useParams<{ owner?: string; repo?: string }>();
9126 const owner = params?.owner;
9227 const repo = params?.repo;
9328 const pathname = usePathname();
9429 const isProductHost = useIsProductHost();
95 const canopyOrigin = useCanopyOrigin();
96 const ringOrigin = useRingOrigin();
97 const collabOrigin = useCollabOrigin();
9830 const { user } = useAuth();
9931 const [isRepoOwner, setIsRepoOwner] = useState(false);
100 const [canopyStatus, setCanopyStatus] = useState<string | null | undefined>(undefined);
101
102 const canopyFetchActive = useRef(false);
103 const canopyFetchQueued = useRef(false);
104
105 const fetchCanopyStatus = useCallback((o: string, r: string) => {
106 if (canopyFetchActive.current) {
107 canopyFetchQueued.current = true;
108 return;
109 }
110 canopyFetchActive.current = true;
111 canopy
112 .listRuns(o, r, { limit: 10 })
113 .then((data) => {
114 const recent = data.runs.find((run) => run.status !== "cancelled");
115 setCanopyStatus(recent?.status ?? null);
116 })
117 .catch(() => {
118 setCanopyStatus(null);
119 })
120 .finally(() => {
121 canopyFetchActive.current = false;
122 if (canopyFetchQueued.current) {
123 canopyFetchQueued.current = false;
124 fetchCanopyStatus(o, r);
125 }
126 });
127 }, []);
128
129 useEffect(() => {
130 if (!owner || !repo || isProductHost !== false) {
131 setCanopyStatus(undefined);
132 return;
133 }
134 fetchCanopyStatus(owner, repo);
135 }, [owner, repo, isProductHost, fetchCanopyStatus]);
136
137 useCanopyEvents({
138 scope: "global",
139 enabled: !!owner && !!repo && isProductHost === false,
140 onEvent: useCallback((event) => {
141 if (!owner || !repo) return;
142 if (event.type === "log:append") return;
143 fetchCanopyStatus(owner, repo);
144 }, [owner, repo, fetchCanopyStatus]),
145 });
32 const appSwitcherItems = useAppSwitcherItems("grove", { owner, repo });
14633
14734 // Determine if the current user owns this repo (directly or via org membership)
14835 useEffect(() => {
@@ -171,27 +58,6 @@
17158 { key: "code", label: "Code", shortLabel: "Code", href: `/${owner}/${repo}` },
17259 { key: "diffs", label: "Diffs", shortLabel: "Diffs", href: `/${owner}/${repo}/diffs` },
17360 { key: "commits", label: "Commits", shortLabel: "Commits", href: `/${owner}/${repo}/commits` },
174 {
175 key: "pipelines",
176 label: "Canopy",
177 shortLabel: "Canopy",
178 href: canopyOrigin ? `${canopyOrigin}/${owner}/${repo}/builds` : `/${owner}/${repo}/builds`,
179 external: !!canopyOrigin,
180 },
181 {
182 key: "ring",
183 label: "Ring",
184 shortLabel: "Ring",
185 href: ringOrigin ? `${ringOrigin}/${owner}/${repo}` : `/ring/${owner}/${repo}`,
186 external: !!ringOrigin,
187 },
188 {
189 key: "collab",
190 label: "Collab",
191 shortLabel: "Collab",
192 href: collabOrigin ? `${collabOrigin}/${owner}/${repo}` : `/collab/${owner}/${repo}`,
193 external: !!collabOrigin,
194 },
19561 ...(isRepoOwner ? [{ key: "settings", label: "Settings", shortLabel: "Settings", href: `/${owner}/${repo}/settings` }] : []),
19662 ] : null;
19763
@@ -200,25 +66,9 @@
20066 const activeTab =
20167 repoPath.startsWith("/commit") ? "commits"
20268 : repoPath.startsWith("/diffs") ? "diffs"
203 : repoPath.startsWith("/builds") ? "pipelines"
20469 : repoPath.startsWith("/settings") ? "settings"
20570 : "code";
20671
207 const canopyStatusLabel =
208 canopyStatus === "running"
209 ? "Running"
210 : canopyStatus === "passed"
211 ? "Passed"
212 : canopyStatus === "failed"
213 ? "Failed"
214 : canopyStatus === "pending"
215 ? "Pending"
216 : canopyStatus === "cancelled"
217 ? "Cancelled"
218 : canopyStatus === null
219 ? "No builds"
220 : "Loading status";
221
22272 // Parse file/tree path breadcrumbs from URL like /blob/main/src/lib/utils.ts
22373 let pathBreadcrumbs: { name: string; href: string; isLast: boolean }[] = [];
22474 const blobMatch = repoPath.match(/^\/(blob|tree)\/([^/]+)\/(.+)$/);
@@ -269,7 +119,7 @@
269119 </>
270120 )}
271121 {pathBreadcrumbs.map((crumb) => (
272 <span key={crumb.href} className={`flex items-center gap-1.5 sm:gap-3 ${crumb.isLast ? "" : "hidden sm:flex"}`}>
122 <span key={crumb.href} className={`hidden sm:flex items-center gap-1.5 sm:gap-3`}>
273123 <span style={{ color: "var(--text-faint)" }}>/</span>
274124 {crumb.isLast ? (
275125 <span style={{ color: "var(--text-primary)" }}>{crumb.name}</span>
@@ -286,122 +136,28 @@
286136 ))}
287137 </>
288138 }
139 appSwitcherItems={appSwitcherItems}
289140 menuItems={
290141 <DropdownItem onClick={() => { window.location.href = "/dashboard"; }}>
291142 Dashboard
292143 </DropdownItem>
293144 }
294 mobileMenuItems={
295 <a
296 href="/dashboard"
297 className="px-4 py-2.5 hover-row"
298 style={{ color: "var(--text-muted)", textDecoration: "none" }}
299 >
300 Dashboard
301 </a>
302 }
303145 belowBar={
304146 repoTabs ? (
305 <div className="hidden sm:flex px-3 sm:px-6 gap-0 text-sm">
306 {repoTabs.map((tab) => {
307 const isActive = tab.key === activeTab;
308 const tabStyle = {
309 color: isActive ? "var(--text-primary)" : "var(--text-muted)",
310 borderBottom: isActive
311 ? "2px solid var(--accent)"
312 : "2px solid transparent",
313 };
314 if (tab.external) {
315 return (
316 <a
317 key={tab.key}
318 href={tab.href}
319 target="_blank"
320 rel="noopener"
321 title="Opens in a new tab"
322 className="px-3 py-2 -mb-px transition-colors"
323 style={tabStyle}
324 >
325 {tab.key === "pipelines" ? (
326 <span className="inline-flex items-center gap-1.5">
327 <span title={`Canopy: ${canopyStatusLabel}`}>
328 <CanopyLogo size={14} />
329 </span>
330 {tab.label}
331 <ExternalLinkIcon />
332 </span>
333 ) : tab.key === "ring" ? (
334 <span className="inline-flex items-center gap-1.5">
335 <RingLogo size={14} />
336 {tab.label}
337 <ExternalLinkIcon />
338 </span>
339 ) : tab.key === "collab" ? (
340 <span className="inline-flex items-center gap-1.5">
341 <CollabLogo size={14} />
342 {tab.label}
343 <ExternalLinkIcon />
344 </span>
345 ) : (
346 tab.label
347 )}
348 </a>
349 );
350 }
351 return (
352 <Link
353 key={tab.key}
354 href={tab.href}
355 className="px-3 py-2 -mb-px transition-colors"
356 style={tabStyle}
357 >
358 {tab.label}
359 </Link>
360 );
361 })}
362 </div>
363 ) : undefined
364 }
365 mobileMenu={
366 repoTabs ? (
367 <div className="flex flex-col py-2" style={{ borderBottom: "1px solid var(--divide)" }}>
147 <div className="flex px-3 sm:px-6 gap-0 text-sm overflow-x-auto overflow-y-hidden">
368148 {repoTabs.map((tab) => {
369149 const isActive = tab.key === activeTab;
370 const itemStyle: CSSProperties = {
371 color: isActive ? "var(--text-primary)" : "var(--text-muted)",
372 fontWeight: isActive ? 600 : 400,
373 textDecoration: "none",
374 font: "inherit",
375 fontSize: "0.875rem",
376 };
377 if (tab.external) {
378 return (
379 <a
380 key={tab.key}
381 href={tab.href}
382 target="_blank"
383 rel="noopener"
384 className="flex items-center gap-2 px-4 py-2.5 hover-row"
385 style={itemStyle}
386 >
387 {tab.key === "pipelines" && (
388 <span title={`Canopy: ${canopyStatusLabel}`}>
389 <CanopyLogo size={14} />
390 </span>
391 )}
392 {tab.key === "ring" && <RingLogo size={14} />}
393 {tab.key === "collab" && <CollabLogo size={14} />}
394 {tab.label}
395 <ExternalLinkIcon />
396 </a>
397 );
398 }
399150 return (
400151 <Link
401152 key={tab.key}
402153 href={tab.href}
403 className="flex items-center gap-2 px-4 py-2.5 hover-row"
404 style={itemStyle}
154 className="px-3 py-2 -mb-px transition-colors shrink-0"
155 style={{
156 color: isActive ? "var(--text-primary)" : "var(--text-muted)",
157 borderBottom: isActive
158 ? "2px solid var(--accent)"
159 : "2px solid transparent",
160 }}
405161 >
406162 {tab.label}
407163 </Link>
408164
web/app/ring/ring-nav.tsx
@@ -4,21 +4,32 @@
44import { useParams } from "next/navigation";
55import { RingLogo } from "@/app/components/ring-logo";
66import { NavBar } from "@/app/components/ui/navbar";
7import { useAppSwitcherItems } from "@/lib/use-app-switcher";
78
89export function RingNav() {
910 const params = useParams<{ owner?: string; repo?: string }>();
1011 const owner = params?.owner;
1112 const repo = params?.repo;
13 const appSwitcherItems = useAppSwitcherItems("ring", { owner, repo });
1214
1315 return (
1416 <NavBar
1517 logo={<RingLogo size={28} />}
18 productName="Ring"
19 showProductName={!owner}
20 appSwitcherItems={appSwitcherItems}
1621 breadcrumbs={
1722 <>
1823 {owner && (
1924 <>
2025 <span style={{ color: "var(--text-faint)" }}>/</span>
21 <span className="truncate" style={{ color: "var(--text-muted)" }}>{owner}</span>
26 <Link
27 href={`/${owner}`}
28 className="hover:underline truncate"
29 style={{ color: "var(--text-muted)" }}
30 >
31 {owner}
32 </Link>
2233 </>
2334 )}
2435 {owner && repo && (
2536
web/lib/use-app-switcher.tsx
@@ -0,0 +1,62 @@
1"use client";
2
3import { useState, useEffect, useMemo } from "react";
4import { GroveLogo } from "@/app/components/grove-logo";
5import { CanopyLogo } from "@/app/components/canopy-logo";
6import { RingLogo } from "@/app/components/ring-logo";
7import { CollabLogo } from "@/app/components/collab-logo";
8import type { AppSwitcherItem } from "@/app/components/ui/navbar";
9
10function useSubdomainOrigin(subdomain: string) {
11 const [origin, setOrigin] = useState("");
12 useEffect(() => {
13 const host = window.location.host;
14 // Strip existing subdomain prefix if present (e.g. canopy.grove.host → grove.host)
15 const parts = host.split(".");
16 const base =
17 parts[0] === "canopy" || parts[0] === "ring" || parts[0] === "collab"
18 ? parts.slice(1).join(".")
19 : host;
20 setOrigin(`${window.location.protocol}//${subdomain}.${base}`);
21 }, [subdomain]);
22 return origin;
23}
24
25function useBaseOrigin() {
26 const [origin, setOrigin] = useState("");
27 useEffect(() => {
28 const host = window.location.host;
29 const parts = host.split(".");
30 const base =
31 parts[0] === "canopy" || parts[0] === "ring" || parts[0] === "collab"
32 ? parts.slice(1).join(".")
33 : host;
34 setOrigin(`${window.location.protocol}//${base}`);
35 }, []);
36 return origin;
37}
38
39/**
40 * Returns app switcher items for all apps except the current one.
41 * Pass the current app name (e.g. "grove", "canopy") to exclude it.
42 */
43export function useAppSwitcherItems(
44 currentApp: "grove" | "canopy" | "ring" | "collab",
45 context?: { owner?: string; repo?: string },
46): AppSwitcherItem[] {
47 const groveOrigin = useBaseOrigin();
48 const canopyOrigin = useSubdomainOrigin("canopy");
49 const ringOrigin = useSubdomainOrigin("ring");
50 const collabOrigin = useSubdomainOrigin("collab");
51
52 return useMemo(() => {
53 const repoPath = context?.owner && context?.repo ? `/${context.owner}/${context.repo}` : "";
54 const all: (AppSwitcherItem & { key: string })[] = [
55 { key: "grove", name: "Grove", logo: <GroveLogo size={28} />, href: `${groveOrigin}${repoPath}` },
56 { key: "canopy", name: "Canopy", logo: <CanopyLogo size={28} />, href: `${canopyOrigin}${repoPath}${repoPath ? "/builds" : ""}` },
57 { key: "ring", name: "Ring", logo: <RingLogo size={28} />, href: `${ringOrigin}${repoPath}` },
58 { key: "collab", name: "Collab", logo: <CollabLogo size={28} />, href: `${collabOrigin}${repoPath}` },
59 ];
60 return all.filter((item) => item.key !== currentApp);
61 }, [currentApp, context?.owner, context?.repo, groveOrigin, canopyOrigin, ringOrigin, collabOrigin]);
62}
063