Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './navbar.module.css';
3 : import symbols from '@ankoh/dashql-svg-symbols';
4 :
5 : import { AnchorAlignment, AnchorSide } from '../ui/foundations/anchored_position.js';
6 : import { HoverMode, NavBarButtonWithRef } from './navbar_button.js';
7 : import { InternalsViewerOverlay } from './internals/internals_overlay.js';
8 : import { AgentViewOverlay } from '../agent/agent_view.js';
9 : import { useAgentBridge } from '../agent/agent_bridge.js';
10 : import { PlatformType, isMacDesktopHost, usePlatformType } from '../platform/platform_type.js';
11 : import { DASHQL_VERSION } from '../globals.js';
12 : import { VersionCheckIndicator } from '../ui/version/version_viewer.js';
13 : import { VersionInfoOverlay } from '../ui/version/version_viewer.js';
14 : import { useLogger } from '../platform/logger/logger_provider.js';
15 : import { useRouteContext } from './router/router.js';
16 : import { useVersionCheck } from '../platform/version/version_check.js';
17 : import { useLocation } from 'react-router-dom';
18 : import { FlavorLogo, useAppFlavor } from '../config/flavor_provider.js';
19 : import { NavRail, useNavRailWide } from './nav_rail.js';
20 : import { useMacWindowLayout } from './mac_window_layout.js';
21 : import { useColorSchemeToggle } from '../config/color_scheme.js';
22 : import { useOpenGreeter } from '../session/session_creation.js';
23 :
24 1 : const LOG_CTX = "navbar";
25 :
26 : interface NavBarItemProps {
27 : label: string;
28 : icon: React.ReactNode;
29 : /// The props of an anchored overlay
30 : anchor: object;
31 : onClick: () => void;
32 : 'data-testid'?: string;
33 : }
34 :
35 1 : const NavBarItem = ({ label, icon, anchor, ...props }: NavBarItemProps) => (
36 1 : <NavBarButtonWithRef {...anchor} {...props} className={styles.tab_button} hover={HoverMode.Darken}>
37 1 : <>
38 1 : {icon}
39 1 : <span className={styles.tab_button_text}>{label}</span>
40 1 : </>
41 1 : </NavBarButtonWithRef>
42 : );
43 :
44 : /// Overlays open below the bar at the top
45 1 : const OVERLAY_POSITION = { side: AnchorSide.OutsideBottom, align: AnchorAlignment.End, anchorOffset: 16 };
46 :
47 1 : const InternalsButton = (props: { sessionId: string | null }) => {
48 1 : const [showInternalsViewerOverlay, setInternalsViewerOverlay] = React.useState<boolean>(false);
49 :
50 1 : return (
51 1 : <div className={styles.tab}>
52 1 : <InternalsViewerOverlay
53 1 : sessionId={props.sessionId}
54 1 : isOpen={showInternalsViewerOverlay}
55 1 : onClose={() => setInternalsViewerOverlay(false)}
56 1 : renderAnchor={(p: object) => (
57 1 : <NavBarItem
58 1 : anchor={p}
59 1 : label="Client"
60 1 : icon={(
61 1 : <svg width="14px" height="14px" aria-hidden="true">
62 1 : <use xlinkHref={`${symbols}#device_desktop_24`} />
63 1 : </svg>
64 : )}
65 1 : onClick={() => setInternalsViewerOverlay(true)}
66 1 : />
67 : )}
68 1 : {...OVERLAY_POSITION}
69 1 : />
70 1 : </div>
71 : );
72 1 : };
73 :
74 1 : const AgentButton = () => {
75 1 : const bridge = useAgentBridge();
76 1 : const [showAgentOverlay, setAgentOverlay] = React.useState<boolean>(false);
77 1 : if (bridge == null) return null;
78 :
79 1 : return (
80 1 : <div className={styles.tab}>
81 1 : <AgentViewOverlay
82 1 : isOpen={showAgentOverlay}
83 1 : onClose={() => setAgentOverlay(false)}
84 1 : renderAnchor={(p: object) => (
85 1 : <NavBarItem
86 1 : anchor={p}
87 1 : label="Agent"
88 1 : icon={(
89 1 : <svg width="14px" height="14px" aria-hidden="true">
90 1 : <use xlinkHref={`${symbols}#sparkles_fill_16`} />
91 1 : </svg>
92 : )}
93 1 : onClick={() => setAgentOverlay(true)}
94 1 : data-testid="agent-settings"
95 1 : />
96 : )}
97 1 : {...OVERLAY_POSITION}
98 1 : />
99 1 : </div>
100 : );
101 1 : };
102 :
103 1 : const VersionButton = (props: { iconSize?: number }) => {
104 1 : const [showVersionOverlay, setShowVersionOverlay] = React.useState<boolean>(false);
105 1 : const versionCheck = useVersionCheck();
106 :
107 1 : return (
108 1 : <div className={styles.tab}>
109 1 : <VersionInfoOverlay
110 1 : isOpen={showVersionOverlay}
111 1 : onClose={() => setShowVersionOverlay(false)}
112 1 : renderAnchor={(p: object) => (
113 1 : <NavBarItem
114 1 : anchor={p}
115 1 : label={`Version ${DASHQL_VERSION}`}
116 1 : icon={<VersionCheckIndicator status={versionCheck} size={props.iconSize} />}
117 1 : onClick={() => setShowVersionOverlay(true)}
118 1 : />
119 : )}
120 1 : {...OVERLAY_POSITION}
121 1 : />
122 1 : </div>
123 : );
124 1 : };
125 :
126 : /// Switches between the light and the dark color scheme, the icon shows the scheme that a click switches to
127 1 : const ColorSchemeButton = () => {
128 1 : const [scheme, toggle] = useColorSchemeToggle();
129 1 : const label = scheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
130 1 : return (
131 1 : <div className={styles.tab}>
132 1 : <NavBarButtonWithRef
133 1 : className={styles.tab_button}
134 1 : hover={HoverMode.Darken}
135 1 : onClick={toggle}
136 1 : aria-label={label}
137 1 : title={label}
138 1 : data-testid="color-scheme-toggle"
139 : >
140 1 : <svg width="14px" height="14px" aria-hidden="true">
141 1 : <use xlinkHref={`${symbols}#${scheme === 'dark' ? 'sun_16' : 'moon_16'}`} />
142 1 : </svg>
143 1 : </NavBarButtonWithRef>
144 1 : </div>
145 : );
146 1 : };
147 :
148 : /// The height of a wordmark in the navbar, a bit smaller than square logos to match the cap height of text
149 1 : const WORDMARK_HEIGHT = 16;
150 :
151 1 : const BrandIdentity = () => {
152 1 : const flavor = useAppFlavor();
153 1 : return (
154 1 : <div className={styles.brand_identity} data-electron-drag-region>
155 1 : {flavor.logo.wordmark
156 1 : ? <FlavorLogo height={WORDMARK_HEIGHT} ariaHidden />
157 1 : : <>
158 1 : <FlavorLogo height={24} ariaHidden />
159 1 : <span>{flavor.displayName}</span>
160 1 : </>}
161 1 : </div>
162 : );
163 1 : };
164 :
165 : /// The height of a wordmark in the stripe at the top, a bit smaller than in the compact navbar
166 1 : const TOP_BAR_WORDMARK_HEIGHT = 14;
167 :
168 : /// The brand in the stripe at the top, the logo and, next to the wide rail, the name without the tagline.
169 : /// A square logo is a bit larger without the traffic lights of macOS next to it.
170 : /// A click opens the greeter.
171 1 : const TopBarBrand = (props: { mac: boolean, showName: boolean }) => {
172 1 : const openGreeter = useOpenGreeter();
173 1 : const flavor = useAppFlavor();
174 1 : return (
175 1 : <button
176 1 : type="button"
177 1 : className={`${styles.brand_identity} ${styles.brand_button}`}
178 1 : aria-label={`${flavor.displayName}, new session`}
179 1 : data-testid="brand-home"
180 1 : data-electron-drag-region="false"
181 1 : onClick={openGreeter}
182 : >
183 1 : {flavor.logo.wordmark
184 1 : ? <FlavorLogo height={TOP_BAR_WORDMARK_HEIGHT} ariaHidden />
185 1 : : <>
186 1 : <FlavorLogo height={props.mac ? 22 : 24} ariaHidden />
187 1 : {props.showName && <span>{flavor.displayName}</span>}
188 1 : </>}
189 1 : </button>
190 : );
191 1 : };
192 :
193 1 : export const CompactNavBar = (props: { showInternals?: boolean }): React.ReactElement => {
194 1 : const route = useRouteContext();
195 1 : const platform = usePlatformType();
196 1 : const navbarClass = platform === PlatformType.DESKTOP ? styles.navbar_mac : styles.navbar_default;
197 :
198 1 : return (
199 1 : <header className={`${navbarClass} ${styles.navbar_overlay}`} data-electron-drag-region>
200 1 : <BrandIdentity />
201 1 : <div className={styles.navbar_actions}>
202 1 : {(props.showInternals ?? true) && <InternalsButton sessionId={route.sessionId} />}
203 1 : <VersionButton />
204 1 : <ColorSchemeButton />
205 1 : </div>
206 1 : </header>
207 : );
208 1 : };
209 :
210 : /// Stand-ins for the traffic lights of the macOS window when the browser shows the layout of the desktop app on macOS
211 1 : const TrafficLightPlaceholders = () => (
212 0 : <div className={styles.traffic_lights} aria-hidden="true">
213 0 : <span className={styles.traffic_light_close} />
214 0 : <span className={styles.traffic_light_minimize} />
215 0 : <span className={styles.traffic_light_zoom} />
216 0 : </div>
217 : );
218 :
219 : /// The navbar is a rail of pages left of the page card below a dark stripe that holds the brand on the left and the overlays on the right.
220 : /// On the desktop app on macOS, the brand follows the traffic lights.
221 : /// The Mac Window Layout setting shows them in the browser.
222 1 : export function NavBarContainer(props: { children: React.ReactElement }) {
223 1 : const logger = useLogger();
224 1 : const location = useLocation();
225 1 : const route = useRouteContext();
226 1 : const macHost = isMacDesktopHost();
227 1 : const isMac = useMacWindowLayout();
228 1 : const [railWide] = useNavRailWide();
229 :
230 1 : React.useEffect(() => {
231 1 : logger.debug("Navigated to path", { "path": location.pathname }, LOG_CTX);
232 1 : }, [location.pathname]);
233 :
234 1 : return (
235 1 : <div className={railWide ? `${styles.container} ${styles.container_wide}` : styles.container}>
236 1 : <header key={2} className={isMac ? `${styles.top_bar} ${styles.top_bar_mac}` : styles.top_bar} data-electron-drag-region>
237 1 : <TopBarBrand mac={isMac} showName={railWide} />
238 1 : <div className={styles.top_bar_actions}>
239 1 : <AgentButton />
240 1 : <InternalsButton sessionId={route.sessionId} />
241 1 : <VersionButton iconSize={14} />
242 1 : <ColorSchemeButton />
243 1 : </div>
244 1 : </header>
245 1 : <NavRail key={0} />
246 1 : <div key={1} className={styles.page_container}>
247 1 : {props.children}
248 1 : </div>
249 1 : {isMac && !macHost && <TrafficLightPlaceholders />}
250 1 : </div>
251 : );
252 1 : }
|