@igstack/app-catalog-frontend-core 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/esm/__tests__/integration/appDeepLink.integration.test.d.ts +0 -0
  2. package/dist/esm/__tests__/integration/harness/given.d.ts +5 -1
  3. package/dist/esm/__tests__/modules/appCatalog/utils/mergeFrontendBuildId.test.d.ts +1 -0
  4. package/dist/esm/__tests__/ui/components/header/VersionDisplay.test.d.ts +0 -0
  5. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +6 -8
  6. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  7. package/dist/esm/modules/appCatalog/context/mergeFrontendBuildId.d.ts +15 -0
  8. package/dist/esm/modules/appCatalog/context/mergeFrontendBuildId.js +15 -0
  9. package/dist/esm/modules/appCatalog/context/mergeFrontendBuildId.js.map +1 -0
  10. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +1 -0
  11. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  12. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +1 -0
  13. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  14. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +1 -0
  15. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +3 -1
  17. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +3 -1
  18. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  19. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.d.ts +3 -1
  20. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +17 -12
  21. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  22. package/dist/esm/routeTree.gen.d.ts +18 -4
  23. package/dist/esm/routeTree.gen.js +11 -4
  24. package/dist/esm/routeTree.gen.js.map +1 -1
  25. package/dist/esm/routes/_layout/app.$slug.d.ts +3 -0
  26. package/dist/esm/routes/_layout/app._slug.js +21 -0
  27. package/dist/esm/routes/_layout/app._slug.js.map +1 -0
  28. package/dist/esm/routes/_layout/index.d.ts +0 -1
  29. package/dist/esm/routes/_layout/index.js +0 -1
  30. package/dist/esm/routes/_layout/index.js.map +1 -1
  31. package/dist/esm/ui/components/header/Header.d.ts +4 -0
  32. package/dist/esm/ui/components/header/Header.js +40 -17
  33. package/dist/esm/ui/components/header/Header.js.map +1 -1
  34. package/package.json +4 -3
  35. package/src/__tests__/integration/appDeepLink.integration.test.ts +55 -0
  36. package/src/__tests__/integration/harness/given.tsx +7 -3
  37. package/src/__tests__/modules/appCatalog/utils/mergeFrontendBuildId.test.ts +42 -0
  38. package/src/__tests__/ui/components/header/VersionDisplay.test.tsx +38 -0
  39. package/src/modules/appCatalog/context/AppCatalogContext.tsx +5 -11
  40. package/src/modules/appCatalog/context/mergeFrontendBuildId.ts +31 -0
  41. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +4 -0
  42. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +18 -15
  43. package/src/routeTree.gen.ts +21 -2
  44. package/src/routes/_layout/app.$slug.tsx +23 -0
  45. package/src/routes/_layout/index.tsx +0 -1
  46. package/src/ui/components/header/Header.tsx +39 -16
@@ -32,6 +32,7 @@ import { GalleryTools } from '../tools/GalleryTools'
32
32
  export interface GivenResult {
33
33
  ui: UiTools
34
34
  backend: MockBackendVerifier
35
+ router: ReturnType<typeof createAcRouter>
35
36
  }
36
37
 
37
38
  export interface UiTools {
@@ -57,7 +58,10 @@ export async function cleanupTestResources(): Promise<void> {
57
58
  }
58
59
  }
59
60
 
60
- export async function given(magazine: Magazine): Promise<GivenResult> {
61
+ export async function given(
62
+ magazine: Magazine,
63
+ opts: { initialRoute?: string } = {},
64
+ ): Promise<GivenResult> {
61
65
  // Clean up any previous resources
62
66
  await cleanupTestResources()
63
67
  resetConfigurerCounter()
@@ -126,7 +130,7 @@ export async function given(magazine: Magazine): Promise<GivenResult> {
126
130
 
127
131
  const router = createAcRouter({
128
132
  history: createMemoryHistory({
129
- initialEntries: ['/catalog/apps'],
133
+ initialEntries: [opts.initialRoute ?? '/catalog/apps'],
130
134
  }),
131
135
  context: {
132
136
  queryClient,
@@ -187,5 +191,5 @@ export async function given(magazine: Magazine): Promise<GivenResult> {
187
191
  globalError: () => getGlobalError(),
188
192
  }
189
193
 
190
- return { ui, backend }
194
+ return { ui, backend, router }
191
195
  }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mergeFrontendBuildId } from '~/modules/appCatalog/context/mergeFrontendBuildId'
3
+
4
+ describe('mergeFrontendBuildId', () => {
5
+ it('returns versions unchanged when no build id', () => {
6
+ const versions = { frontend: { displayName: '0.4.0', sha: 'abc1234' } }
7
+ expect(mergeFrontendBuildId(versions, undefined)).toEqual(versions)
8
+ })
9
+
10
+ it('shows local (dropping SHA) when build id is local', () => {
11
+ const versions = { frontend: { displayName: '0.4.0', sha: 'abc1234' } }
12
+ const out = mergeFrontendBuildId(versions, 'local')
13
+ expect(out.frontend).toEqual({ displayName: 'local' })
14
+ })
15
+
16
+ it('prepends pipeline id while preserving fe-core version + sha + url', () => {
17
+ const versions = {
18
+ frontend: {
19
+ displayName: '0.4.0-alpha-x',
20
+ sha: 'abc1234',
21
+ shaUrl: 'https://github.com/lislon/app-catalog/commit/abc1234',
22
+ },
23
+ }
24
+ const out = mergeFrontendBuildId(versions, '12345')
25
+ expect(out.frontend?.displayName).toBe('#12345 · 0.4.0-alpha-x')
26
+ expect(out.frontend?.sha).toBe('abc1234')
27
+ expect(out.frontend?.shaUrl).toBe(
28
+ 'https://github.com/lislon/app-catalog/commit/abc1234',
29
+ )
30
+ })
31
+
32
+ it('shows only pipeline id when backend gave no frontend slot', () => {
33
+ const out = mergeFrontendBuildId({}, '12345')
34
+ expect(out.frontend).toEqual({ displayName: '#12345' })
35
+ })
36
+
37
+ it('does not mutate the input', () => {
38
+ const versions = { frontend: { displayName: '0.4.0' } }
39
+ mergeFrontendBuildId(versions, '99')
40
+ expect(versions.frontend.displayName).toBe('0.4.0')
41
+ })
42
+ })
@@ -0,0 +1,38 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import '@testing-library/jest-dom/vitest'
4
+
5
+ import { VersionDisplay } from '~/ui/components/header/Header'
6
+
7
+ describe('VersionDisplay footer', () => {
8
+ it('renders the frontend-core version and its short SHA as a commit link', () => {
9
+ render(
10
+ <VersionDisplay
11
+ versions={{
12
+ backend: { displayName: '#12345 (deadbee)' },
13
+ coreVersion: { displayName: 'be 0.4.0 / fe 0.3.1' },
14
+ frontend: {
15
+ displayName: '#12345 · 0.3.1-alpha-x',
16
+ sha: 'abc1234',
17
+ shaUrl: 'https://github.com/lislon/app-catalog/commit/abc1234',
18
+ },
19
+ }}
20
+ />,
21
+ )
22
+ // FE line shows pipeline id + fe-core version
23
+ expect(screen.getByText(/0\.3\.1-alpha-x/)).toBeInTheDocument()
24
+ // SHA rendered and linked to the commit
25
+ const shaLink = screen.getByText('(abc1234)').closest('a')
26
+ expect(shaLink).toHaveAttribute(
27
+ 'href',
28
+ 'https://github.com/lislon/app-catalog/commit/abc1234',
29
+ )
30
+ // Backend line carries the natera SHA suffix
31
+ expect(screen.getByText(/#12345 \(deadbee\)/)).toBeInTheDocument()
32
+ })
33
+
34
+ it('shows plain "local" for local dev', () => {
35
+ render(<VersionDisplay versions={{ backend: { displayName: 'local' } }} />)
36
+ expect(screen.getByText('local')).toBeInTheDocument()
37
+ })
38
+ })
@@ -11,6 +11,7 @@ import type { ReactNode } from 'react'
11
11
  import { createContext, use, useEffect, useMemo } from 'react'
12
12
  import { ApiQueryMagazineAppCatalog } from '~/modules/appCatalog'
13
13
  import { useUiSettings } from '~/context/UiSettingsContext'
14
+ import { mergeFrontendBuildId } from '~/modules/appCatalog/context/mergeFrontendBuildId'
14
15
 
15
16
  export interface AppCatalogContextIface {
16
17
  resources: Resource[]
@@ -44,17 +45,10 @@ export function AppCatalogProvider({ children }: AppCatalogProviderProps) {
44
45
  approvalMethods: data?.approvalMethods ?? [],
45
46
  persons: data?.persons ?? [],
46
47
  groups: data?.groups ?? [],
47
- versions: {
48
- ...data?.versions,
49
- ...(uiSettings.frontendBuildId && {
50
- frontend: {
51
- displayName:
52
- uiSettings.frontendBuildId === 'local'
53
- ? 'local'
54
- : `#${uiSettings.frontendBuildId}`,
55
- },
56
- }),
57
- },
48
+ versions: mergeFrontendBuildId(
49
+ data?.versions ?? {},
50
+ uiSettings.frontendBuildId,
51
+ ),
58
52
  }),
59
53
  [
60
54
  data?.approvalMethods,
@@ -0,0 +1,31 @@
1
+ import type { AppVersionInfo } from '@igstack/app-catalog-backend-core'
2
+
3
+ /**
4
+ * Merge the (natera-agnostic) frontend build id into the backend-provided
5
+ * version info's `frontend` slot.
6
+ *
7
+ * The build id is baked into the deployed JS bundle at build time (e.g. a CI
8
+ * pipeline id via a Vite env var). We PREPEND it to the resolved frontend-core
9
+ * npm version instead of replacing it, so the footer surfaces both:
10
+ * `#<buildId> · <frontend-core version> (<sha>)`.
11
+ *
12
+ * - No build id → return versions unchanged.
13
+ * - build id === 'local' → show `local` (local dev, no meaningful SHA/version).
14
+ * - otherwise → prepend `#<buildId> · ` and preserve the version + sha + shaUrl.
15
+ */
16
+ export function mergeFrontendBuildId(
17
+ versions: AppVersionInfo,
18
+ frontendBuildId: string | undefined,
19
+ ): AppVersionInfo {
20
+ if (!frontendBuildId) return versions
21
+ if (frontendBuildId === 'local') {
22
+ return { ...versions, frontend: { displayName: 'local' } }
23
+ }
24
+ const be = versions.frontend
25
+ return {
26
+ ...versions,
27
+ frontend: be
28
+ ? { ...be, displayName: `#${frontendBuildId} · ${be.displayName}` }
29
+ : { displayName: `#${frontendBuildId}` },
30
+ }
31
+ }
@@ -55,6 +55,8 @@ export interface AppCatalogGridProps {
55
55
  totalAppsCount?: number
56
56
  /** Callback to clear all filters and search */
57
57
  onClearFilters?: () => void
58
+ /** Called when the user closes the detail panel (Esc or X) — e.g. to navigate back to the catalog URL */
59
+ onClosePanel?: () => void
58
60
  }
59
61
 
60
62
  function getIconUrl(iconName: string): string {
@@ -697,6 +699,7 @@ export function AppCatalogGrid({
697
699
  searchQuery,
698
700
  totalAppsCount,
699
701
  onClearFilters,
702
+ onClosePanel,
700
703
  }: AppCatalogGridProps) {
701
704
  const selectedApp = selectedAppSlug
702
705
  ? apps.find((a) => a.slug === selectedAppSlug)
@@ -862,6 +865,7 @@ export function AppCatalogGrid({
862
865
 
863
866
  const handleClosePanel = () => {
864
867
  setHasUserClosed(true)
868
+ onClosePanel?.()
865
869
  }
866
870
 
867
871
  return (
@@ -1,4 +1,5 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import { useNavigate } from '@tanstack/react-router'
2
3
  import { X } from 'lucide-react'
3
4
  import { useDeferredValue, useEffect, useMemo, useState } from 'react'
4
5
  import { Button } from '~/ui/button'
@@ -13,25 +14,22 @@ import {
13
14
  import { useAppCatalogContext } from '../../context/AppCatalogContext'
14
15
  import { useAppClickHistory } from '../../hooks/useAppClickHistory'
15
16
  import { useAppCounts } from '../../hooks/useAppCounts'
16
- import { useUrlSyncedState } from '../../hooks/useUrlSyncedState'
17
17
  import { searchResources } from '../../utils/searchApps'
18
18
  import { OnboardingCard } from '../components/OnboardingCard'
19
19
  import { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'
20
20
  import { FilterBar } from '../filters/FilterBar'
21
21
  import { AppCatalogGrid } from '../grid/AppCatalogGrid'
22
22
 
23
- export function AppCatalogPage() {
23
+ export function AppCatalogPage({
24
+ selectedSlug,
25
+ }: { selectedSlug?: string } = {}) {
24
26
  const { resources, isLoadingApps, tagsDefinitions } = useAppCatalogContext()
25
27
  const { state: filterState, actions } = useAppCatalogFilters()
26
28
  const { getTopApps } = useAppClickHistory()
29
+ const navigate = useNavigate()
27
30
 
28
- // URL-synced state
29
- const [selectedAppSlug, setSelectedAppSlug] = useUrlSyncedState<
30
- string | undefined
31
- >({
32
- key: 'app',
33
- defaultValue: undefined,
34
- })
31
+ // Selected app comes from the route path (/app/<slug>); undefined = nothing open
32
+ const selectedAppSlug = selectedSlug
35
33
 
36
34
  // Search value from context (URL-synced in AppCatalogFiltersContext)
37
35
  const searchValue = filterState.searchValue
@@ -108,19 +106,23 @@ export function AppCatalogPage() {
108
106
 
109
107
  // Auto-open details when only 1 result
110
108
  useEffect(() => {
111
- if (filteredApps.length === 1 && filteredApps[0]) {
112
- setSelectedAppSlug(filteredApps[0].slug)
109
+ if (filteredApps.length === 1 && filteredApps[0] && !selectedAppSlug) {
110
+ void navigate({
111
+ to: '/app/$slug',
112
+ params: { slug: filteredApps[0].slug },
113
+ replace: true,
114
+ })
113
115
  }
114
- }, [filteredApps, setSelectedAppSlug])
116
+ }, [filteredApps, selectedAppSlug, navigate])
115
117
 
116
118
  const handleAppClick = (app: Resource) => {
117
- setSelectedAppSlug(app.slug)
119
+ void navigate({ to: '/app/$slug', params: { slug: app.slug } })
118
120
  }
119
121
 
120
122
  const handleClearFilters = () => {
121
123
  setSearchValue('')
122
124
  actions.clearAllFilters()
123
- setSelectedAppSlug(undefined)
125
+ void navigate({ to: '/' })
124
126
  }
125
127
 
126
128
  // Calculate total apps count (respecting showDeprecated setting)
@@ -174,7 +176,7 @@ export function AppCatalogPage() {
174
176
  variant="outline"
175
177
  onClick={() => {
176
178
  setSearchValue('')
177
- setSelectedAppSlug(undefined)
179
+ void navigate({ to: '/' })
178
180
  }}
179
181
  className="gap-2"
180
182
  >
@@ -190,6 +192,7 @@ export function AppCatalogPage() {
190
192
  selectedAppSlug={selectedAppSlug}
191
193
  groupingDefinition={groupingDefinition}
192
194
  onAppClick={handleAppClick}
195
+ onClosePanel={() => void navigate({ to: '/' })}
193
196
  hasSearch={!!deferredSearchValue}
194
197
  searchQuery={deferredSearchValue}
195
198
  totalAppsCount={totalAppsCount}
@@ -13,6 +13,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
13
13
  import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
14
14
  import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
15
15
  import { Route as LayoutLoginRouteImport } from './routes/_layout/login'
16
+ import { Route as LayoutAppSlugRouteImport } from './routes/_layout/app.$slug'
16
17
  import { Route as LayoutCatalogAppsIndexRouteImport } from './routes/_layout/catalog.apps.index'
17
18
 
18
19
  const LayoutRoute = LayoutRouteImport.update({
@@ -34,6 +35,11 @@ const LayoutLoginRoute = LayoutLoginRouteImport.update({
34
35
  path: '/login',
35
36
  getParentRoute: () => LayoutRoute,
36
37
  } as any)
38
+ const LayoutAppSlugRoute = LayoutAppSlugRouteImport.update({
39
+ id: '/app/$slug',
40
+ path: '/app/$slug',
41
+ getParentRoute: () => LayoutRoute,
42
+ } as any)
37
43
  const LayoutCatalogAppsIndexRoute = LayoutCatalogAppsIndexRouteImport.update({
38
44
  id: '/catalog/apps/',
39
45
  path: '/catalog/apps/',
@@ -44,12 +50,14 @@ export interface FileRoutesByFullPath {
44
50
  '/': typeof LayoutIndexRoute
45
51
  '/login': typeof LayoutLoginRoute
46
52
  '/auth/callback': typeof AuthCallbackRoute
53
+ '/app/$slug': typeof LayoutAppSlugRoute
47
54
  '/catalog/apps/': typeof LayoutCatalogAppsIndexRoute
48
55
  }
49
56
  export interface FileRoutesByTo {
50
57
  '/login': typeof LayoutLoginRoute
51
58
  '/auth/callback': typeof AuthCallbackRoute
52
59
  '/': typeof LayoutIndexRoute
60
+ '/app/$slug': typeof LayoutAppSlugRoute
53
61
  '/catalog/apps': typeof LayoutCatalogAppsIndexRoute
54
62
  }
55
63
  export interface FileRoutesById {
@@ -58,19 +66,21 @@ export interface FileRoutesById {
58
66
  '/_layout/login': typeof LayoutLoginRoute
59
67
  '/auth/callback': typeof AuthCallbackRoute
60
68
  '/_layout/': typeof LayoutIndexRoute
69
+ '/_layout/app/$slug': typeof LayoutAppSlugRoute
61
70
  '/_layout/catalog/apps/': typeof LayoutCatalogAppsIndexRoute
62
71
  }
63
72
  export interface FileRouteTypes {
64
73
  fileRoutesByFullPath: FileRoutesByFullPath
65
- fullPaths: '/' | '/login' | '/auth/callback' | '/catalog/apps/'
74
+ fullPaths: '/' | '/login' | '/auth/callback' | '/app/$slug' | '/catalog/apps/'
66
75
  fileRoutesByTo: FileRoutesByTo
67
- to: '/login' | '/auth/callback' | '/' | '/catalog/apps'
76
+ to: '/login' | '/auth/callback' | '/' | '/app/$slug' | '/catalog/apps'
68
77
  id:
69
78
  | '__root__'
70
79
  | '/_layout'
71
80
  | '/_layout/login'
72
81
  | '/auth/callback'
73
82
  | '/_layout/'
83
+ | '/_layout/app/$slug'
74
84
  | '/_layout/catalog/apps/'
75
85
  fileRoutesById: FileRoutesById
76
86
  }
@@ -109,6 +119,13 @@ declare module '@tanstack/react-router' {
109
119
  preLoaderRoute: typeof LayoutLoginRouteImport
110
120
  parentRoute: typeof LayoutRoute
111
121
  }
122
+ '/_layout/app/$slug': {
123
+ id: '/_layout/app/$slug'
124
+ path: '/app/$slug'
125
+ fullPath: '/app/$slug'
126
+ preLoaderRoute: typeof LayoutAppSlugRouteImport
127
+ parentRoute: typeof LayoutRoute
128
+ }
112
129
  '/_layout/catalog/apps/': {
113
130
  id: '/_layout/catalog/apps/'
114
131
  path: '/catalog/apps'
@@ -122,12 +139,14 @@ declare module '@tanstack/react-router' {
122
139
  interface LayoutRouteChildren {
123
140
  LayoutLoginRoute: typeof LayoutLoginRoute
124
141
  LayoutIndexRoute: typeof LayoutIndexRoute
142
+ LayoutAppSlugRoute: typeof LayoutAppSlugRoute
125
143
  LayoutCatalogAppsIndexRoute: typeof LayoutCatalogAppsIndexRoute
126
144
  }
127
145
 
128
146
  const LayoutRouteChildren: LayoutRouteChildren = {
129
147
  LayoutLoginRoute: LayoutLoginRoute,
130
148
  LayoutIndexRoute: LayoutIndexRoute,
149
+ LayoutAppSlugRoute: LayoutAppSlugRoute,
131
150
  LayoutCatalogAppsIndexRoute: LayoutCatalogAppsIndexRoute,
132
151
  }
133
152
 
@@ -0,0 +1,23 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { appCatalogRouteLoader } from '~/modules/appCatalog/routeLoader'
3
+ import { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayout'
4
+ import { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'
5
+
6
+ export const Route = createFileRoute('/_layout/app/$slug')({
7
+ component: RouteComponent,
8
+ async loader() {
9
+ const appCatalogLoader = await appCatalogRouteLoader()
10
+ return { appCatalogLoader }
11
+ },
12
+ })
13
+
14
+ function RouteComponent() {
15
+ const { queryClient, trpcClient } = Route.useRouteContext()
16
+ const { slug } = Route.useParams()
17
+
18
+ return (
19
+ <AppCatalogLayout queryClient={queryClient} trpcClient={trpcClient}>
20
+ <AppCatalogPage selectedSlug={slug} />
21
+ </AppCatalogLayout>
22
+ )
23
+ }
@@ -5,7 +5,6 @@ import { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayou
5
5
  import { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'
6
6
 
7
7
  const searchSchema = z.object({
8
- app: z.string().optional(),
9
8
  filterTag: z.string().optional(),
10
9
  })
11
10
 
@@ -27,6 +27,27 @@ import {
27
27
  } from '~/ui/dropdown-menu'
28
28
  import { Skeleton } from '~/ui/skeleton'
29
29
 
30
+ function ShaSuffix({ version }: { version: VersionInfo }) {
31
+ if (!version.sha) return null
32
+ if (version.shaUrl) {
33
+ return (
34
+ <>
35
+ {' '}
36
+ <a
37
+ href={version.shaUrl}
38
+ target="_blank"
39
+ rel="noopener noreferrer"
40
+ className="text-muted-foreground hover:text-primary"
41
+ onClick={(e) => e.stopPropagation()}
42
+ >
43
+ ({version.sha})
44
+ </a>
45
+ </>
46
+ )
47
+ }
48
+ return <span className="text-muted-foreground"> ({version.sha})</span>
49
+ }
50
+
30
51
  function VersionItem({
31
52
  label,
32
53
  version,
@@ -34,28 +55,30 @@ function VersionItem({
34
55
  label: string
35
56
  version: VersionInfo
36
57
  }) {
37
- if (version.url) {
38
- return (
39
- <a
40
- href={version.url}
41
- target="_blank"
42
- rel="noopener noreferrer"
43
- className="text-xs text-primary hover:text-primary/80 transition-colors font-medium"
44
- onClick={(e) => e.stopPropagation()}
45
- title={label}
46
- >
47
- {label}: {version.displayName}
48
- </a>
49
- )
50
- }
58
+ // Outer element is a <span> (not <a>) so the optional SHA link can be a
59
+ // sibling anchor without producing invalid nested <a> markup.
51
60
  return (
52
61
  <span className="text-xs text-muted-foreground" title={label}>
53
- {label}: {version.displayName}
62
+ {label}:{' '}
63
+ {version.url ? (
64
+ <a
65
+ href={version.url}
66
+ target="_blank"
67
+ rel="noopener noreferrer"
68
+ className="text-primary hover:text-primary/80 transition-colors font-medium"
69
+ onClick={(e) => e.stopPropagation()}
70
+ >
71
+ {version.displayName}
72
+ </a>
73
+ ) : (
74
+ version.displayName
75
+ )}
76
+ <ShaSuffix version={version} />
54
77
  </span>
55
78
  )
56
79
  }
57
80
 
58
- function VersionDisplay({ versions }: { versions: AppVersionInfo }) {
81
+ export function VersionDisplay({ versions }: { versions: AppVersionInfo }) {
59
82
  const isLocal =
60
83
  versions.backend?.displayName === 'local' && !versions.coreVersion
61
84