Replace lower-right controls with a full status bar (#7443)

* Resurrect this branch with an initial commit

* Add telemetry to global default items

* Add credit progress bar to status bar

* Add selection info to status bar

* Add help menu to "local" side

* Rename statusBarItem utils

* Delete LowerRightControls, now unused

* fix lints

* Update snapshots

* Add test-id to network health indicator, which all E2E tests rely on

* Update src/components/StatusBar/StatusBar.tsx

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

* Fix a couple little issues with the e2e tests

* Fix gizmo e2e tests (it moved) and network health test (it should not be on home page)

* More tweaks to accomodate changes to network health indicator

We made use of two test ID's to make Playwright aware of the state, one
of which was on the icon. Now that we want to normalize usage of the
status bar along a more limited API, that became a not possible. This
just tweaks some test code that relied on that fact.

* Fix lints

* Update snapshots

* Re-run CI

* Update snapshots

* Update snapshots

* Test fixes, label logic tweaks

* Update snapshots

* Update snapshots

* Fix up last few tests hopefully. Relative path syntax failed on windows

* Relative paths are behaving badly on Windows, use the old code here

* Update snapshots

* Update snapshots

* Tweak y-value to work on all platforms, ubuntu didn't like 438

* Fix tooltip and popover alignment on NetworkMachineIndicator

* Remove dire warning comment

* Update src/components/StatusBar/defaultStatusBarItems.tsx

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

* @lee-at-zoo-corp feedback, pull hooks out of UI code

* Re-run CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2025-06-17 16:29:27 -04:00
committed by GitHub
parent b301fbba22
commit fe581ff1d2
46 changed files with 646 additions and 388 deletions

View File

@ -575,7 +575,7 @@ extrude002 = extrude(profile002, length = 150)
name: 'Projects', name: 'Projects',
}) })
const projectLink = page.getByRole('link', { name: 'bracket' }) const projectLink = page.getByRole('link', { name: 'bracket' })
const networkHealthIndicator = page.getByTestId('network-toggle') const networkHealthIndicator = page.getByTestId(/network-toggle/)
await test.step('Check the home page', async () => { await test.step('Check the home page', async () => {
await expect(projectsHeading).toBeVisible() await expect(projectsHeading).toBeVisible()

View File

@ -798,7 +798,7 @@ test('theme persists', async ({ page, context, homePage }) => {
await page.getByTestId('settings-close-button').click() await page.getByTestId('settings-close-button').click()
const networkToggle = page.getByTestId('network-toggle') const networkToggle = page.getByTestId(/network-toggle/)
// simulate network down // simulate network down
await u.emulateNetworkConditions({ await u.emulateNetworkConditions({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -24,16 +24,15 @@ test.describe('Test network related behaviors', () => {
await homePage.goToModelingScene() await homePage.goToModelingScene()
const networkToggle = page.getByTestId('network-toggle') const networkToggle = page.getByTestId(/network-toggle/)
// This is how we wait until the stream is online // This is how we wait until the stream is online
await expect( await expect(
page.getByRole('button', { name: 'Start Sketch' }) page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled({ timeout: 15000 }) ).not.toBeDisabled({ timeout: 15000 })
const networkWidget = page.locator('[data-testid="network-toggle"]') await expect(networkToggle).toBeVisible()
await expect(networkWidget).toBeVisible() await networkToggle.hover()
await networkWidget.hover()
const networkPopover = page.locator('[data-testid="network-popover"]') const networkPopover = page.locator('[data-testid="network-popover"]')
await expect(networkPopover).not.toBeVisible() await expect(networkPopover).not.toBeVisible()
@ -44,7 +43,7 @@ test.describe('Test network related behaviors', () => {
).toBeVisible() ).toBeVisible()
// Click the network widget // Click the network widget
await networkWidget.click() await networkToggle.click()
// Check the modal opened. // Check the modal opened.
await expect(networkPopover).toBeVisible() await expect(networkPopover).toBeVisible()
@ -65,8 +64,8 @@ test.describe('Test network related behaviors', () => {
// Expect the network to be down // Expect the network to be down
await expect(networkToggle).toContainText('Network health (Offline)') await expect(networkToggle).toContainText('Network health (Offline)')
// Click the network widget // Click the network toggle
await networkWidget.click() await networkToggle.click()
// Check the modal opened. // Check the modal opened.
await expect(networkPopover).toBeVisible() await expect(networkPopover).toBeVisible()
@ -99,7 +98,7 @@ test.describe('Test network related behaviors', () => {
'Engine disconnect & reconnect in sketch mode', 'Engine disconnect & reconnect in sketch mode',
{ tag: '@skipLocalEngine' }, { tag: '@skipLocalEngine' },
async ({ page, homePage, toolbar, scene, cmdBar }) => { async ({ page, homePage, toolbar, scene, cmdBar }) => {
const networkToggle = page.getByTestId('network-toggle') const networkToggle = page.getByTestId(/network-toggle/)
const networkToggleConnectedText = page.getByText( const networkToggleConnectedText = page.getByText(
'Network health (Strong)' 'Network health (Strong)'
) )
@ -286,7 +285,7 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34])
'Paused stream freezes view frame, unpause reconnect is seamless to user', 'Paused stream freezes view frame, unpause reconnect is seamless to user',
{ tag: ['@desktop', '@skipLocalEngine'] }, { tag: ['@desktop', '@skipLocalEngine'] },
async ({ page, homePage, scene, cmdBar, toolbar, tronApp }) => { async ({ page, homePage, scene, cmdBar, toolbar, tronApp }) => {
const networkToggle = page.getByTestId('network-toggle') const networkToggle = page.getByTestId(/network-toggle/)
const networkToggleConnectedText = page.getByText( const networkToggleConnectedText = page.getByText(
'Network health (Strong)' 'Network health (Strong)'
) )

View File

@ -37,7 +37,7 @@ export const headerMasks = (page: Page) => [
] ]
export const lowerRightMasks = (page: Page) => [ export const lowerRightMasks = (page: Page) => [
page.getByTestId('network-toggle'), page.getByTestId(/network-toggle/),
page.getByTestId('billing-remaining-bar'), page.getByTestId('billing-remaining-bar'),
] ]

View File

@ -8,37 +8,37 @@ test.describe('Testing Gizmo', () => {
const cases = [ const cases = [
{ {
testDescription: 'top view', testDescription: 'top view',
clickPosition: { x: 951, y: 385 }, clickPosition: { x: 951, y: 402 },
expectedCameraPosition: { x: 800, y: -152, z: 4886.02 }, expectedCameraPosition: { x: 800, y: -152, z: 4886.02 },
expectedCameraTarget: { x: 800, y: -152, z: 26 }, expectedCameraTarget: { x: 800, y: -152, z: 26 },
}, },
{ {
testDescription: 'bottom view', testDescription: 'bottom view',
clickPosition: { x: 951, y: 429 }, clickPosition: { x: 951, y: 449 },
expectedCameraPosition: { x: 800, y: -152, z: -4834.02 }, expectedCameraPosition: { x: 800, y: -152, z: -4834.02 },
expectedCameraTarget: { x: 800, y: -152, z: 26 }, expectedCameraTarget: { x: 800, y: -152, z: 26 },
}, },
{ {
testDescription: 'right view', testDescription: 'right view',
clickPosition: { x: 929, y: 417 }, clickPosition: { x: 929, y: 435 },
expectedCameraPosition: { x: 5660.02, y: -152, z: 26 }, expectedCameraPosition: { x: 5660.02, y: -152, z: 26 },
expectedCameraTarget: { x: 800, y: -152, z: 26 }, expectedCameraTarget: { x: 800, y: -152, z: 26 },
}, },
{ {
testDescription: 'left view', testDescription: 'left view',
clickPosition: { x: 974, y: 397 }, clickPosition: { x: 974, y: 417 },
expectedCameraPosition: { x: -4060.02, y: -152, z: 26 }, expectedCameraPosition: { x: -4060.02, y: -152, z: 26 },
expectedCameraTarget: { x: 800, y: -152, z: 26 }, expectedCameraTarget: { x: 800, y: -152, z: 26 },
}, },
{ {
testDescription: 'back view', testDescription: 'back view',
clickPosition: { x: 967, y: 421 }, clickPosition: { x: 967, y: 441 },
expectedCameraPosition: { x: 800, y: 4708.02, z: 26 }, expectedCameraPosition: { x: 800, y: 4708.02, z: 26 },
expectedCameraTarget: { x: 800, y: -152, z: 26 }, expectedCameraTarget: { x: 800, y: -152, z: 26 },
}, },
{ {
testDescription: 'front view', testDescription: 'front view',
clickPosition: { x: 935, y: 393 }, clickPosition: { x: 935, y: 413 },
expectedCameraPosition: { x: 800, y: -5012.02, z: 26 }, expectedCameraPosition: { x: 800, y: -5012.02, z: 26 },
expectedCameraTarget: { x: 800, y: -152, z: 26 }, expectedCameraTarget: { x: 800, y: -152, z: 26 },
}, },

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import ModalContainer from 'react-modal-promise' import ModalContainer from 'react-modal-promise'
@ -12,7 +12,6 @@ import {
import { AppHeader } from '@src/components/AppHeader' import { AppHeader } from '@src/components/AppHeader'
import { EngineStream } from '@src/components/EngineStream' import { EngineStream } from '@src/components/EngineStream'
import Gizmo from '@src/components/Gizmo' import Gizmo from '@src/components/Gizmo'
import { LowerRightControls } from '@src/components/LowerRightControls'
import { useLspContext } from '@src/components/LspProvider' import { useLspContext } from '@src/components/LspProvider'
import { ModelingSidebar } from '@src/components/ModelingSidebar/ModelingSidebar' import { ModelingSidebar } from '@src/components/ModelingSidebar/ModelingSidebar'
import { UnitsMenu } from '@src/components/UnitsMenu' import { UnitsMenu } from '@src/components/UnitsMenu'
@ -29,6 +28,7 @@ import {
codeManager, codeManager,
kclManager, kclManager,
settingsActor, settingsActor,
getSettings,
} from '@src/lib/singletons' } from '@src/lib/singletons'
import { maybeWriteToDisk } from '@src/lib/telemetry' import { maybeWriteToDisk } from '@src/lib/telemetry'
import type { IndexLoaderData } from '@src/lib/types' import type { IndexLoaderData } from '@src/lib/types'
@ -53,6 +53,17 @@ import {
} from '@src/lib/constants' } from '@src/lib/constants'
import { isPlaywright } from '@src/lib/isPlaywright' import { isPlaywright } from '@src/lib/isPlaywright'
import { VITE_KC_SITE_BASE_URL } from '@src/env' import { VITE_KC_SITE_BASE_URL } from '@src/env'
import { useNetworkHealthStatus } from '@src/components/NetworkHealthIndicator'
import { useNetworkMachineStatus } from '@src/components/NetworkMachineIndicator'
import {
defaultLocalStatusBarItems,
defaultGlobalStatusBarItems,
} from '@src/components/StatusBar/defaultStatusBarItems'
import { StatusBar } from '@src/components/StatusBar/StatusBar'
import { useModelingContext } from '@src/hooks/useModelingContext'
import { xStateValueToString } from '@src/lib/xStateValueToString'
import { getSelectionTypeDisplayText } from '@src/lib/selections'
import type { StatusBarItemType } from '@src/components/StatusBar/statusBarTypes'
// CYCLIC REF // CYCLIC REF
sceneInfra.camControls.engineStreamActor = engineStreamActor sceneInfra.camControls.engineStreamActor = engineStreamActor
@ -62,6 +73,7 @@ maybeWriteToDisk()
.catch(() => {}) .catch(() => {})
export function App() { export function App() {
const { state: modelingState } = useModelingContext()
useQueryParamEffects() useQueryParamEffects()
const { project, file } = useLoaderData() as IndexLoaderData const { project, file } = useLoaderData() as IndexLoaderData
const [nativeFileMenuCreated, setNativeFileMenuCreated] = useState(false) const [nativeFileMenuCreated, setNativeFileMenuCreated] = useState(false)
@ -70,9 +82,10 @@ export function App() {
const navigate = useNavigate() const navigate = useNavigate()
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
const { onProjectOpen } = useLspContext() const { onProjectOpen } = useLspContext()
const networkHealthStatus = useNetworkHealthStatus()
const networkMachineStatus = useNetworkMachineStatus()
// We need the ref for the outermost div so we can screenshot the app for // We need the ref for the outermost div so we can screenshot the app for
// the coredump. // the coredump.
const ref = useRef<HTMLDivElement>(null)
// Stream related refs and data // Stream related refs and data
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@ -220,7 +233,8 @@ export function App() {
}, []) }, [])
return ( return (
<div className="relative h-full flex flex-col" ref={ref}> <div className="h-screen flex flex-col overflow-hidden select-none">
<div className="relative flex flex-1 flex-col">
<AppHeader <AppHeader
className="transition-opacity transition-duration-75" className="transition-opacity transition-duration-75"
project={{ project, file }} project={{ project, file }}
@ -234,10 +248,47 @@ export function App() {
<ModelingSidebar /> <ModelingSidebar />
<EngineStream pool={pool} authToken={authToken} /> <EngineStream pool={pool} authToken={authToken} />
{/* <CamToggle /> */} {/* <CamToggle /> */}
<LowerRightControls navigate={navigate}> <section className="absolute bottom-2 right-2 flex flex-col items-end gap-3 pointer-events-none">
<UnitsMenu /> <UnitsMenu />
<Gizmo /> <Gizmo />
</LowerRightControls> </section>
</div>
<StatusBar
globalItems={[
networkHealthStatus,
networkMachineStatus,
...defaultGlobalStatusBarItems({ location, filePath }),
]}
localItems={[
...(getSettings().app.showDebugPanel.current
? ([
{
id: 'modeling-state',
element: 'text',
label:
modelingState.value instanceof Object
? (xStateValueToString(modelingState.value) ?? '')
: modelingState.value,
toolTip: {
children: 'The current state of the modeler',
},
},
] satisfies StatusBarItemType[])
: []),
{
id: 'selection',
element: 'text',
label:
getSelectionTypeDisplayText(
modelingState.context.selectionRanges
) ?? 'No selection',
toolTip: {
children: 'Currently selected geometry',
},
},
...defaultLocalStatusBarItems,
]}
/>
</div> </div>
) )
} }

View File

@ -89,20 +89,19 @@ export const BillingRemaining = (props: BillingRemainingProps) => {
const isFlex = props.mode === BillingRemainingMode.ProgressBarStretch const isFlex = props.mode === BillingRemainingMode.ProgressBarStretch
const cssWrapper = [ const cssWrapper = [
'bg-ml-green', 'bg-ml-green',
'dark:bg-transparent',
'select-none', 'select-none',
'cursor-pointer', 'cursor-pointer',
'py-1',
'rounded',
'!no-underline', '!no-underline',
'text-xs', 'text-xs',
'!text-chalkboard-100', '!text-chalkboard-100',
'dark:!text-chalkboard-0', 'dark:!text-ml-green',
] ]
return ( return (
<div <div
data-testid="billing-remaining" data-testid="billing-remaining"
className={[isFlex ? 'flex flex-col gap-1' : 'px-2'] className={[isFlex ? 'flex flex-col gap-1' : 'px-2 flex items-stretch']
.concat(cssWrapper) .concat(cssWrapper)
.join(' ')} .join(' ')}
> >

View File

@ -2,12 +2,12 @@ import { useSelector } from '@xstate/react'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import type { StateFrom } from 'xstate' import type { StateFrom } from 'xstate'
import type { Artifact } from '@src/lang/std/artifactGraph'
import type { CommandArgument } from '@src/lib/commandTypes' import type { CommandArgument } from '@src/lib/commandTypes'
import { import {
canSubmitSelectionArg, canSubmitSelectionArg,
getSelectionCountByType, getSelectionCountByType,
getSelectionTypeDisplayText, getSelectionTypeDisplayText,
getSemanticSelectionType,
type Selections, type Selections,
} from '@src/lib/selections' } from '@src/lib/selections'
import { engineCommandManager, kclManager } from '@src/lib/singletons' import { engineCommandManager, kclManager } from '@src/lib/singletons'
@ -16,29 +16,6 @@ import { toSync } from '@src/lib/utils'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons' import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
import type { modelingMachine } from '@src/machines/modelingMachine' import type { modelingMachine } from '@src/machines/modelingMachine'
const semanticEntityNames: {
[key: string]: Array<Artifact['type'] | 'defaultPlane'>
} = {
face: ['wall', 'cap'],
profile: ['solid2d'],
edge: ['segment', 'sweepEdge', 'edgeCutEdge'],
point: [],
plane: ['defaultPlane'],
}
function getSemanticSelectionType(selectionType: Array<Artifact['type']>) {
const semanticSelectionType = new Set()
selectionType.forEach((type) => {
Object.entries(semanticEntityNames).forEach(([entity, entityTypes]) => {
if (entityTypes.includes(type)) {
semanticSelectionType.add(entity)
}
})
})
return Array.from(semanticSelectionType)
}
const selectionSelector = (snapshot?: StateFrom<typeof modelingMachine>) => const selectionSelector = (snapshot?: StateFrom<typeof modelingMachine>) =>
snapshot?.context.selectionRanges snapshot?.context.selectionRanges

View File

@ -748,6 +748,16 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
loading: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.5001 6.25839C11.76 5.76392 10.89 5.5 10 5.5V4.5C11.0878 4.5 12.1512 4.82257 13.0556 5.42692C13.9601 6.03126 14.6651 6.89025 15.0813 7.89524C15.4976 8.90023 15.6065 10.0061 15.3943 11.073C15.1821 12.1399 14.6583 13.1199 13.8891 13.8891C13.1199 14.6583 12.1399 15.1821 11.073 15.3943C10.0061 15.6065 8.90023 15.4976 7.89524 15.0813C6.89025 14.6651 6.03126 13.9601 5.42692 13.0556C4.82257 12.1512 4.5 11.0878 4.5 10H5.5C5.5 10.89 5.76392 11.76 6.25839 12.5001C6.75285 13.2401 7.45566 13.8169 8.27792 14.1575C9.10019 14.4981 10.005 14.5872 10.8779 14.4135C11.7508 14.2399 12.5526 13.8113 13.182 13.182C13.8113 12.5526 14.2399 11.7508 14.4135 10.8779C14.5872 10.005 14.4981 9.10019 14.1575 8.27792C13.8169 7.45566 13.2401 6.75285 12.5001 6.25839Z"
fill="currentColor"
/>
</svg>
),
lockClosed: ( lockClosed: (
<svg <svg
viewBox="0 0 20 20" viewBox="0 0 20 20"

View File

@ -562,7 +562,7 @@ export const EngineStream = (props: {
// eslint-disable-next-line jsx-a11y/no-static-element-interactions // eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div <div
ref={videoWrapperRef} ref={videoWrapperRef}
className="absolute inset-0 z-0" className="fixed inset-0 z-0"
id="stream" id="stream"
data-testid="stream" data-testid="stream"
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}

View File

@ -1,6 +1,5 @@
import { Popover } from '@headlessui/react' import { Popover } from '@headlessui/react'
import { type NavigateFunction, useLocation } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { CustomIcon } from '@src/components/CustomIcon' import { CustomIcon } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip' import Tooltip from '@src/components/Tooltip'
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
@ -21,11 +20,8 @@ const HelpMenuDivider = () => (
<div className="h-[1px] bg-chalkboard-110 dark:bg-chalkboard-80" /> <div className="h-[1px] bg-chalkboard-110 dark:bg-chalkboard-80" />
) )
export function HelpMenu({ export function HelpMenu() {
navigate = () => {}, const navigate = useNavigate()
}: {
navigate?: NavigateFunction
}) {
const location = useLocation() const location = useLocation()
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
@ -49,15 +45,12 @@ export function HelpMenu({
useMenuListener(cb) useMenuListener(cb)
return ( return (
<Popover className="relative"> <Popover className="relative flex items-stretch">
<Popover.Button <Popover.Button
className="grid p-0 m-0 border-none rounded-full place-content-center" className="flex items-stretch px-2 py-1 m-0 border-none !bg-chalkboard-110 dark:!bg-chalkboard-80 text-chalkboard-10 rounded-none"
data-testid="help-button" data-testid="help-button"
> >
<CustomIcon <CustomIcon name="questionMark" className="w-5 h-5" />
name="questionMark"
className="rounded-full w-7 h-7 bg-chalkboard-110 dark:bg-chalkboard-80 text-chalkboard-10"
/>
<span className="sr-only">Help and resources</span> <span className="sr-only">Help and resources</span>
<Tooltip position="top-right" wrapperClassName="ui-open:hidden"> <Tooltip position="top-right" wrapperClassName="ui-open:hidden">
Help and resources Help and resources

View File

@ -1,127 +0,0 @@
import { Link, type NavigateFunction, useLocation } from 'react-router-dom'
import { Popover } from '@headlessui/react'
import {
BillingRemaining,
BillingRemainingMode,
} from '@src/components/BillingRemaining'
import { BillingDialog } from '@src/components/BillingDialog'
import { CustomIcon } from '@src/components/CustomIcon'
import { HelpMenu } from '@src/components/HelpMenu'
import { NetworkHealthIndicator } from '@src/components/NetworkHealthIndicator'
import { NetworkMachineIndicator } from '@src/components/NetworkMachineIndicator'
import Tooltip from '@src/components/Tooltip'
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
import { PATHS } from '@src/lib/paths'
import { APP_VERSION, getReleaseUrl } from '@src/routes/utils'
import { billingActor } from '@src/lib/singletons'
import { ActionButton } from '@src/components/ActionButton'
import { isDesktop } from '@src/lib/isDesktop'
import { VITE_KC_SITE_BASE_URL } from '@src/env'
import { APP_DOWNLOAD_PATH } from '@src/lib/constants'
export function LowerRightControls({
children,
navigate = () => {},
}: {
children?: React.ReactNode
navigate?: NavigateFunction
}) {
const location = useLocation()
const filePath = useAbsoluteFilePath()
const linkOverrideClassName =
'!text-chalkboard-70 hover:!text-chalkboard-80 dark:!text-chalkboard-40 dark:hover:!text-chalkboard-30'
return (
<section className="fixed bottom-2 right-2 flex flex-col items-end gap-3 pointer-events-none">
{children}
<menu className="flex items-center justify-end gap-3 pointer-events-auto">
<Popover className="relative">
<Popover.Button
className="p-0 !border-transparent"
data-testid="billing-remaining-bar"
>
<BillingRemaining
mode={BillingRemainingMode.ProgressBarFixed}
billingActor={billingActor}
/>
<Tooltip position="top" contentClassName="text-xs">
Text-to-CAD credits
</Tooltip>
</Popover.Button>
<Popover.Panel className="absolute right-0 left-auto bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch rounded-lg shadow-lg text-sm">
<BillingDialog billingActor={billingActor} />
</Popover.Panel>
</Popover>
{isDesktop() ? (
<ActionButton
Element="externalLink"
to={getReleaseUrl()}
className={
'!no-underline !border-none !bg-transparent font-mono text-xs' +
linkOverrideClassName
}
>
v{APP_VERSION}
</ActionButton>
) : (
<ActionButton
Element="externalLink"
to={`${VITE_KC_SITE_BASE_URL}/${APP_DOWNLOAD_PATH}`}
className={
'!no-underline !border-none !bg-transparent font-mono text-xs' +
linkOverrideClassName
}
iconStart={{
icon: 'download',
className: `w-5 h-5 !bg-transparent`,
}}
>
Download the app
</ActionButton>
)}
<Link
to={
location.pathname.includes(PATHS.FILE)
? filePath + PATHS.TELEMETRY + '?tab=project'
: PATHS.HOME + PATHS.TELEMETRY
}
data-testid="telemetry-link"
>
<CustomIcon
name="stopwatch"
className={`w-5 h-5 ${linkOverrideClassName}`}
/>
<span className="sr-only">Telemetry</span>
<Tooltip position="top" contentClassName="text-xs">
Telemetry
</Tooltip>
</Link>
<Link
to={
location.pathname.includes(PATHS.FILE)
? filePath + PATHS.SETTINGS + '?tab=project'
: PATHS.HOME + PATHS.SETTINGS
}
data-testid="settings-link"
>
<CustomIcon
name="settings"
className={`w-5 h-5 ${linkOverrideClassName}`}
/>
<span className="sr-only">Settings</span>
<Tooltip position="top" contentClassName="text-xs">
Settings
</Tooltip>
</Link>
<NetworkMachineIndicator className={linkOverrideClassName} />
{!location.pathname.startsWith(PATHS.HOME) && (
<NetworkHealthIndicator />
)}
<HelpMenu navigate={navigate} />
</menu>
</section>
)
}

View File

@ -1,13 +1,11 @@
import { Popover } from '@headlessui/react'
import type { ActionIconProps } from '@src/components/ActionIcon' import type { ActionIconProps } from '@src/components/ActionIcon'
import { ActionIcon } from '@src/components/ActionIcon' import { ActionIcon } from '@src/components/ActionIcon'
import Tooltip from '@src/components/Tooltip'
import { useNetworkContext } from '@src/hooks/useNetworkContext' import { useNetworkContext } from '@src/hooks/useNetworkContext'
import { NetworkHealthState } from '@src/hooks/useNetworkStatus' import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
import type { ConnectingTypeGroup } from '@src/lang/std/engineConnection' import type { ConnectingTypeGroup } from '@src/lang/std/engineConnection'
import { reportRejection } from '@src/lib/trap' import { reportRejection } from '@src/lib/trap'
import { toSync } from '@src/lib/utils' import { toSync } from '@src/lib/utils'
import type { StatusBarItemType } from '@src/components/StatusBar/statusBarTypes'
export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = { export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = {
[NetworkHealthState.Ok]: 'Strong', [NetworkHealthState.Ok]: 'Strong',
@ -66,57 +64,45 @@ const overallConnectionStateColor: Record<NetworkHealthState, IconColorConfig> =
}, },
} }
const overallConnectionStateIcon: Record< const overallConnectionStateIcon = {
NetworkHealthState,
ActionIconProps['icon']
> = {
[NetworkHealthState.Ok]: 'network', [NetworkHealthState.Ok]: 'network',
[NetworkHealthState.Weak]: 'network', [NetworkHealthState.Weak]: 'network',
[NetworkHealthState.Issue]: 'networkCrossedOut', [NetworkHealthState.Issue]: 'networkCrossedOut',
[NetworkHealthState.Disconnected]: 'networkCrossedOut', [NetworkHealthState.Disconnected]: 'networkCrossedOut',
} as const
export const useNetworkHealthStatus = (): StatusBarItemType => {
const { overallState } = useNetworkContext()
return {
id: 'network-health',
'data-testid': `network-toggle-${
overallState === NetworkHealthState.Ok ? 'ok' : 'other'
}`,
label: `Network health (${NETWORK_HEALTH_TEXT[overallState]})`,
hideLabel: true,
element: 'popover',
className: overallConnectionStateColor[overallState].icon,
icon: overallConnectionStateIcon[overallState],
popoverContent: <NetworkHealthPopoverContent />,
}
} }
export const NetworkHealthIndicator = () => { function NetworkHealthPopoverContent() {
const { const {
hasIssues,
overallState, overallState,
internetConnected, internetConnected,
steps, steps,
issues, issues,
error, error,
ping,
setHasCopied, setHasCopied,
hasCopied, hasCopied,
ping,
} = useNetworkContext() } = useNetworkContext()
return ( return (
<Popover className="relative"> <div
<Popover.Button className="absolute left-2 bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm"
className={
'p-0 border-none bg-transparent dark:bg-transparent relative ' +
(hasIssues
? 'focus-visible:outline-destroy-80'
: 'focus-visible:outline-succeed-80')
}
data-testid="network-toggle"
>
<ActionIcon
icon={overallConnectionStateIcon[overallState]}
data-testid={`network-toggle-${
overallState == NetworkHealthState.Ok ? 'ok' : 'other'
}`}
className="p-1"
iconClassName={overallConnectionStateColor[overallState].icon}
bgClassName={
'rounded-sm ' + overallConnectionStateColor[overallState].bg
}
/>
<Tooltip position="top-right" wrapperClassName="ui-open:hidden">
Network health ({NETWORK_HEALTH_TEXT[overallState]})
</Tooltip>
</Popover.Button>
<Popover.Panel
className="absolute right-0 left-auto bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm"
data-testid="network-popover" data-testid="network-popover"
> >
<div <div
@ -130,7 +116,7 @@ export const NetworkHealthIndicator = () => {
{NETWORK_HEALTH_TEXT[overallState]} {NETWORK_HEALTH_TEXT[overallState]}
</p> </p>
</div> </div>
<div className={`flex items-center justify-between p-2 rounded-t-sm`}> <div className="flex items-center justify-between p-2 rounded-t-sm">
<h2 <h2
className={`text-xs font-sans font-normal ${overallConnectionStateColor[overallState].icon}`} className={`text-xs font-sans font-normal ${overallConnectionStateColor[overallState].icon}`}
> >
@ -145,31 +131,25 @@ export const NetworkHealthIndicator = () => {
</div> </div>
<ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80"> <ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80">
{Object.keys(steps).map((name) => ( {Object.keys(steps).map((name) => (
<li <li key={name} className={'flex flex-col px-2 py-4 gap-1 last:mb-0 '}>
key={name}
className={'flex flex-col px-2 py-4 gap-1 last:mb-0 '}
>
<div className="flex items-center text-left gap-1"> <div className="flex items-center text-left gap-1">
<p className="flex-1">{name}</p> <p className="flex-1">{name}</p>
{internetConnected ? ( {internetConnected ? (
<ActionIcon <ActionIcon
size="lg" size="lg"
icon={ icon={
hasIssueToIcon[ hasIssueToIcon[String(issues[name as ConnectingTypeGroup])]
String(issues[name as ConnectingTypeGroup])
]
} }
iconClassName={ iconClassName={
hasIssueToIconColors[ hasIssueToIconColors[
String(issues[name as ConnectingTypeGroup]) String(issues[name as ConnectingTypeGroup])
].icon ].icon
} }
bgClassName={ bgClassName={`rounded-sm ${
'rounded-sm ' +
hasIssueToIconColors[ hasIssueToIconColors[
String(issues[name as ConnectingTypeGroup]) String(issues[name as ConnectingTypeGroup])
].bg ].bg
} }`}
/> />
) : ( ) : (
<ActionIcon <ActionIcon
@ -181,6 +161,7 @@ export const NetworkHealthIndicator = () => {
</div> </div>
{issues[name as ConnectingTypeGroup] && ( {issues[name as ConnectingTypeGroup] && (
<button <button
type="button"
onClick={toSync(async () => { onClick={toSync(async () => {
await navigator.clipboard.writeText( await navigator.clipboard.writeText(
JSON.stringify(error, null, 2) || '' JSON.stringify(error, null, 2) || ''
@ -202,7 +183,6 @@ export const NetworkHealthIndicator = () => {
</li> </li>
))} ))}
</ul> </ul>
</Popover.Panel> </div>
</Popover>
) )
} }

View File

@ -5,7 +5,7 @@ import { MachineManagerContext } from '@src/components/MachineManagerProvider'
import Tooltip from '@src/components/Tooltip' import Tooltip from '@src/components/Tooltip'
import { isDesktop } from '@src/lib/isDesktop' import { isDesktop } from '@src/lib/isDesktop'
import type { components } from '@src/lib/machine-api' import type { components } from '@src/lib/machine-api'
import { capitaliseFC } from '@src/lib/utils' import type { StatusBarItemType } from '@src/components/StatusBar/statusBarTypes'
export const NetworkMachineIndicator = ({ export const NetworkMachineIndicator = ({
className, className,
@ -22,24 +22,50 @@ export const NetworkMachineIndicator = ({
return isDesktop() ? ( return isDesktop() ? (
<Popover className="relative"> <Popover className="relative">
<Popover.Button <Popover.Button
className={ className={`flex items-center p-0 border-none bg-transparent dark:bg-transparent relative ${className || ''}`}
'flex items-center p-0 border-none bg-transparent dark:bg-transparent relative ' +
(className || '')
}
data-testid="network-machine-toggle" data-testid="network-machine-toggle"
> >
<NetworkMachinesIcon machineCount={machineCount} />
<Tooltip position="top-left" wrapperClassName="ui-open:hidden">
Network machines ({machineCount}) {reason && `: ${reason}`}
</Tooltip>
</Popover.Button>
<Popover.Panel
className="absolute left-0 bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm"
data-testid="network-popover"
>
<NetworkMachinesPopoverContent machines={machines} />
</Popover.Panel>
</Popover>
) : null
}
export const useNetworkMachineStatus = (): StatusBarItemType => {
return {
id: 'network-machines',
component: NetworkMachineIndicator,
}
}
function NetworkMachinesIcon({ machineCount }: { machineCount: number }) {
return (
<>
<CustomIcon name="printer3d" className="w-5 h-5" /> <CustomIcon name="printer3d" className="w-5 h-5" />
{machineCount > 0 && ( {machineCount > 0 && (
<p aria-hidden className="flex items-center justify-center text-xs"> <p aria-hidden className="flex items-center justify-center text-xs">
{machineCount} {machineCount}
</p> </p>
)} )}
<Tooltip position="top-right" wrapperClassName="ui-open:hidden"> </>
Network machines ({machineCount}) {reason && `: ${reason}`} )
</Tooltip> }
</Popover.Button>
<Popover.Panel function NetworkMachinesPopoverContent({
className="absolute right-0 left-auto bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm" machines,
}: { machines: components['schemas']['MachineInfoResponse'][] }) {
return (
<div
className="absolute left-2 bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm"
data-testid="network-popover" data-testid="network-popover"
> >
<div className="flex items-center justify-between p-2 rounded-t-sm bg-chalkboard-20 dark:bg-chalkboard-80"> <div className="flex items-center justify-between p-2 rounded-t-sm bg-chalkboard-20 dark:bg-chalkboard-80">
@ -48,10 +74,10 @@ export const NetworkMachineIndicator = ({
data-testid="network" data-testid="network"
className="font-bold text-xs uppercase px-2 py-1 rounded-sm" className="font-bold text-xs uppercase px-2 py-1 rounded-sm"
> >
{machineCount} {machines.length}
</p> </p>
</div> </div>
{machineCount > 0 && ( {machines.length > 0 && (
<ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80"> <ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80">
{machines.map( {machines.map(
(machine: components['schemas']['MachineInfoResponse']) => { (machine: components['schemas']['MachineInfoResponse']) => {
@ -69,7 +95,9 @@ export const NetworkMachineIndicator = ({
</p> </p>
)} )}
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs"> <p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
{`Status: ${capitaliseFC(machine.state.state)}`} {`Status: ${machine.state.state
.charAt(0)
.toUpperCase()}${machine.state.state.slice(1)}`}
{machine.state.state === 'failed' && machine.state.message {machine.state.state === 'failed' && machine.state.message
? ` (${machine.state.message})` ? ` (${machine.state.message})`
: ''} : ''}
@ -83,7 +111,6 @@ export const NetworkMachineIndicator = ({
)} )}
</ul> </ul>
)} )}
</Popover.Panel> </div>
</Popover> )
) : null
} }

View File

@ -0,0 +1,161 @@
import { ActionButton } from '@src/components/ActionButton'
import type { StatusBarItemType } from '@src/components/StatusBar/statusBarTypes'
import Tooltip, { type TooltipProps } from '@src/components/Tooltip'
import { ActionIcon } from '@src/components/ActionIcon'
import { Popover } from '@headlessui/react'
export function StatusBar({
globalItems,
localItems,
}: {
globalItems: StatusBarItemType[]
localItems: StatusBarItemType[]
}) {
return (
<footer
id="statusbar"
className="relative z-10 flex justify-between items-center bg-chalkboard-20 dark:bg-chalkboard-90 text-chalkboard-80 dark:text-chalkboard-30 border-t border-t-chalkboard-30 dark:border-t-chalkboard-80"
>
<menu id="statusbar-globals" className="flex items-stretch">
{globalItems.map((item) => (
<StatusBarItem key={item.id} {...item} position="left" />
))}
</menu>
<menu id="statusbar-locals" className="flex items-stretch">
{localItems.map((item) => (
<StatusBarItem key={item.id} {...item} position="right" />
))}
</menu>
</footer>
)
}
function StatusBarItem(
props: StatusBarItemType & { position: 'left' | 'middle' | 'right' }
) {
const defaultClassNames =
'flex items-center px-2 py-1 text-xs text-chalkboard-80 dark:text-chalkboard-30 rounded-none border-none hover:bg-chalkboard-30 dark:hover:bg-chalkboard-80 focus:bg-chalkboard-30 dark:focus:bg-chalkboard-80 hover:text-chalkboard-100 dark:hover:text-chalkboard-10 focus:text-chalkboard-100 dark:focus:text-chalkboard-10 focus:outline-none focus-visible:ring-2 focus:ring-primary focus:ring-opacity-50'
const tooltipPosition: TooltipProps['position'] =
props.position === 'middle' ? 'top' : `top-${props.position}`
// If the consumer used `component`, just render that
if ('component' in props) {
return props.component({})
}
switch (props.element) {
case 'button':
return (
<ActionButton
Element="button"
iconStart={
'icon' in props
? props.icon && {
icon: props.icon,
iconClassName: props.icon === 'loading' ? 'animate-spin' : '',
bgClassName: 'bg-transparent dark:bg-transparent',
}
: undefined
}
className={`${defaultClassNames} ${props.className}`}
data-testid={props['data-testid']}
>
{'label' in props && props.label && !props.hideLabel && (
<span>{props.label}</span>
)}
{(props.toolTip || (props.label && props.hideLabel)) && (
<Tooltip
{...(props.toolTip || { children: props.label })}
position={tooltipPosition}
/>
)}
</ActionButton>
)
case 'popover':
return (
<Popover className="relative">
<Popover.Button
as={ActionButton}
Element="button"
iconStart={
'icon' in props
? props.icon && {
icon: props.icon,
iconClassName:
props.icon === 'loading' ? 'animate-spin' : '',
bgClassName: 'bg-transparent dark:bg-transparent',
}
: undefined
}
className={`${defaultClassNames} ${props.className}`}
data-testid={props['data-testid']}
>
{'label' in props && props.label && !props.hideLabel && (
<span>{props.label}</span>
)}
{(props.toolTip || (props.label && props.hideLabel)) && (
<Tooltip
{...(props.toolTip || { children: props.label })}
wrapperClassName={`${
props.toolTip?.wrapperClassName || ''
} ui-open:hidden`}
position={tooltipPosition}
/>
)}
</Popover.Button>
<Popover.Panel>{props.popoverContent}</Popover.Panel>
</Popover>
)
case 'text':
return (
<div
role="tooltip"
className={`${defaultClassNames} ${props.className}`}
>
{'icon' in props && props.icon && (
<ActionIcon
icon={props.icon}
iconClassName={props.icon === 'loading' ? 'animate-spin' : ''}
bgClassName="bg-transparent dark:bg-transparent"
/>
)}
{'label' in props && props.label && !props.hideLabel && (
<span>{props.label}</span>
)}
{(props.toolTip || (props.label && props.hideLabel)) && (
<Tooltip
{...(props.toolTip || { children: props.label })}
position={tooltipPosition}
/>
)}
</div>
)
default:
return (
<ActionButton
Element={props.element}
to={props.href}
iconStart={
'icon' in props
? props.icon && {
icon: props.icon,
bgClassName: 'bg-transparent dark:bg-transparent',
}
: undefined
}
className={`${defaultClassNames} ${props.className}`}
data-testid={props['data-testid']}
>
{'label' in props && props.label && !props.hideLabel && (
<span>{props.label}</span>
)}
{(props.toolTip || (props.label && props.hideLabel)) && (
<Tooltip
{...(props.toolTip || { children: props.label })}
position={tooltipPosition}
/>
)}
</ActionButton>
)
}
}

View File

@ -0,0 +1,95 @@
import type { StatusBarItemType } from '@src/components/StatusBar/statusBarTypes'
import type { Location } from 'react-router-dom'
import { PATHS } from '@src/lib/paths'
import { APP_VERSION } from '@src/routes/utils'
import {
BillingRemaining,
BillingRemainingMode,
} from '@src/components/BillingRemaining'
import { billingActor } from '@src/lib/singletons'
import { BillingDialog } from '@src/components/BillingDialog'
import { Popover } from '@headlessui/react'
import Tooltip from '@src/components/Tooltip'
import { HelpMenu } from '@src/components/HelpMenu'
export const defaultGlobalStatusBarItems = ({
location,
filePath,
}: {
location: Location
filePath?: string
}): StatusBarItemType[] => [
{
id: 'version',
element: 'externalLink',
label: `v${APP_VERSION}`,
href: `https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`,
toolTip: {
children: 'View the release notes on GitHub',
},
},
{
id: 'telemetry',
element: 'link',
icon: 'stopwatch',
href: location.pathname.includes(PATHS.FILE)
? filePath + PATHS.TELEMETRY + '?tab=project'
: PATHS.HOME + PATHS.TELEMETRY,
'data-testid': 'telemetry-link',
label: 'Telemetry',
hideLabel: true,
toolTip: {
children: 'Telemetry',
},
},
{
id: 'settings',
element: 'link',
icon: 'settings',
href: location.pathname.includes(PATHS.FILE)
? filePath + PATHS.SETTINGS + '?tab=project'
: PATHS.HOME + PATHS.SETTINGS,
'data-testid': 'settings-link',
label: 'Settings',
},
{
id: 'credits',
'data-testid': 'billing-remaining-bar',
component: BillingStatusBarItem,
},
]
function BillingStatusBarItem() {
return (
<Popover className="relative flex items-stretch">
<Popover.Button
className="m-0 p-0 border-0 flex items-stretch"
data-testid="billing-remaining-bar"
>
<BillingRemaining
mode={BillingRemainingMode.ProgressBarFixed}
billingActor={billingActor}
/>
<Tooltip
position="top"
contentClassName="text-xs"
hoverOnly
wrapperClassName="ui-open:!hidden"
>
Text-to-CAD credits
</Tooltip>
</Popover.Button>
<Popover.Panel className="absolute left-0 bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch rounded-lg shadow-lg text-sm">
<BillingDialog billingActor={billingActor} />
</Popover.Panel>
</Popover>
)
}
export const defaultLocalStatusBarItems: StatusBarItemType[] = [
{
id: 'help',
'data-testid': 'help-button',
component: HelpMenu,
},
]

View File

@ -0,0 +1,34 @@
import type { CustomIconName } from '@src/components/CustomIcon'
import type { TooltipProps } from '@src/components/Tooltip'
export type StatusBarItemType = {
id: string
'data-testid'?: string
} & (
| ({
label: string
hideLabel?: boolean
className?: string
toolTip?: Omit<TooltipProps, 'position'>
icon?: CustomIconName
} & (
| {
element: 'button'
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
}
| {
element: 'popover'
popoverContent: React.ReactNode
}
| {
element: 'link' | 'externalLink'
href: string
}
| {
element: 'text'
}
))
| {
component: React.FC
}
)

View File

@ -8,7 +8,7 @@ type LeftOrRight = 'left' | 'right'
type Corner = `${TopOrBottom}-${LeftOrRight}` type Corner = `${TopOrBottom}-${LeftOrRight}`
type TooltipPosition = TopOrBottom | LeftOrRight | Corner type TooltipPosition = TopOrBottom | LeftOrRight | Corner
interface TooltipProps extends React.PropsWithChildren { export interface TooltipProps extends React.PropsWithChildren {
position?: TooltipPosition position?: TooltipPosition
wrapperClassName?: string wrapperClassName?: string
contentClassName?: string contentClassName?: string

View File

@ -779,3 +779,27 @@ export function updateSelections(
otherSelections: prevSelectionRanges.otherSelections, otherSelections: prevSelectionRanges.otherSelections,
} }
} }
const semanticEntityNames: {
[key: string]: Array<Artifact['type'] | 'defaultPlane'>
} = {
face: ['wall', 'cap'],
profile: ['solid2d'],
edge: ['segment', 'sweepEdge', 'edgeCutEdge'],
point: [],
plane: ['defaultPlane'],
}
/** Convert selections to a human-readable format */
export function getSemanticSelectionType(selectionType: Artifact['type'][]) {
const semanticSelectionType = new Set()
for (const type of selectionType) {
for (const [entity, entityTypes] of Object.entries(semanticEntityNames)) {
if (entityTypes.includes(type)) {
semanticSelectionType.add(entity)
}
}
}
return Array.from(semanticSelectionType)
}

View File

@ -0,0 +1,23 @@
import type { AnyStateMachine, StateFrom } from 'xstate'
/**
* Convert an XState state value to a pretty string,
* with nested states separated by slashes
*/
export function xStateValueToString(
stateValue: StateFrom<AnyStateMachine>['value']
) {
const sep = ' / '
let output = ''
let remainingValues = stateValue
let isFirstStep = true
while (remainingValues instanceof Object) {
const key: keyof typeof remainingValues = Object.keys(remainingValues)[0]
output += (isFirstStep ? '' : sep) + key
remainingValues = remainingValues[key]
isFirstStep = false
}
if (typeof remainingValues === 'string' && remainingValues.trim().length) {
return output + sep + remainingValues.trim()
}
}

View File

@ -12,7 +12,6 @@ import {
import { ActionButton } from '@src/components/ActionButton' import { ActionButton } from '@src/components/ActionButton'
import { AppHeader } from '@src/components/AppHeader' import { AppHeader } from '@src/components/AppHeader'
import Loading from '@src/components/Loading' import Loading from '@src/components/Loading'
import { LowerRightControls } from '@src/components/LowerRightControls'
import ProjectCard from '@src/components/ProjectCard/ProjectCard' import ProjectCard from '@src/components/ProjectCard/ProjectCard'
import { import {
ProjectSearchBar, ProjectSearchBar,
@ -61,6 +60,12 @@ import {
import { CustomIcon } from '@src/components/CustomIcon' import { CustomIcon } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip' import Tooltip from '@src/components/Tooltip'
import { ML_EXPERIMENTAL_MESSAGE } from '@src/lib/constants' import { ML_EXPERIMENTAL_MESSAGE } from '@src/lib/constants'
import { StatusBar } from '@src/components/StatusBar/StatusBar'
import { useNetworkMachineStatus } from '@src/components/NetworkMachineIndicator'
import {
defaultLocalStatusBarItems,
defaultGlobalStatusBarItems,
} from '@src/components/StatusBar/defaultStatusBarItems'
type ReadWriteProjectState = { type ReadWriteProjectState = {
value: boolean value: boolean
@ -75,6 +80,7 @@ const Home = () => {
const readWriteProjectDir = useCanReadWriteProjectDirectory() const readWriteProjectDir = useCanReadWriteProjectDirectory()
const [nativeFileMenuCreated, setNativeFileMenuCreated] = useState(false) const [nativeFileMenuCreated, setNativeFileMenuCreated] = useState(false)
const apiToken = useToken() const apiToken = useToken()
const networkMachineStatus = useNetworkMachineStatus()
// Only create the native file menus on desktop // Only create the native file menus on desktop
useEffect(() => { useEffect(() => {
@ -216,7 +222,7 @@ const Home = () => {
nativeFileMenuCreated={nativeFileMenuCreated} nativeFileMenuCreated={nativeFileMenuCreated}
showToolbar={false} showToolbar={false}
/> />
<div className="overflow-hidden self-stretch w-full flex-1 home-layout max-w-4xl lg:max-w-5xl xl:max-w-7xl mb-12 px-4 mx-auto mt-8 lg:mt-24 lg:px-0"> <div className="overflow-hidden self-stretch w-full flex-1 home-layout max-w-4xl lg:max-w-5xl xl:max-w-7xl px-4 mx-auto mt-8 lg:mt-24 lg:px-0">
<HomeHeader <HomeHeader
setQuery={setQuery} setQuery={setQuery}
sort={sort} sort={sort}
@ -225,7 +231,7 @@ const Home = () => {
readWriteProjectDir={readWriteProjectDir} readWriteProjectDir={readWriteProjectDir}
className="col-start-2 -col-end-1" className="col-start-2 -col-end-1"
/> />
<aside className="lg:row-start-1 -row-end-1 grid sm:grid-cols-2 lg:flex flex-col justify-between"> <aside className="lg:row-start-1 -row-end-1 grid sm:grid-cols-2 md:mb-12 lg:flex flex-col justify-between">
<ul className="flex flex-col"> <ul className="flex flex-col">
{needsToOnboard(location, onboardingStatus) && ( {needsToOnboard(location, onboardingStatus) && (
<li className="flex group"> <li className="flex group">
@ -394,8 +400,14 @@ const Home = () => {
sort={sort} sort={sort}
className="flex-1 col-start-2 -col-end-1 overflow-y-auto pr-2 pb-24" className="flex-1 col-start-2 -col-end-1 overflow-y-auto pr-2 pb-24"
/> />
<LowerRightControls navigate={navigate} />
</div> </div>
<StatusBar
globalItems={[
networkMachineStatus,
...defaultGlobalStatusBarItems({ location, filePath: undefined }),
]}
localItems={defaultLocalStatusBarItems}
/>
</div> </div>
) )
} }