@@ -166,21 +166,21 @@ export function MarketplacePackagePage() {
166166 // with no marketplace proxy at all), so it stays its own check rather than
167167 // being folded into the other two predicates.
168168 if ( ! getRuntimeConfig ( ) . features . installLocal ) return ;
169- // Its only consumer is `localInstalls.find(...)` in the content branch
170- // below, which is unreachable whenever the page has already returned
171- // `MarketplaceDisabled` or (post-objectui#5583) `MarketplaceAccessDenied`.
172- // Firing anyway would be the same discarded-request class `2573ff434`
173- // closed for this page, on the flag that change was not about
174- // (objectui#5620).
175- if ( ! marketplaceEnabled ) return ;
169+ // Its only consumer is the `localInstall` match below, read by the content
170+ // branch AND by the marketplace-off local view (objectui#11627). Neither
171+ // is drawn for a viewer refused the admin surface, so firing for one would
172+ // be the same discarded-request class `2573ff434` closed for this page
173+ // (objectui#5620). `marketplaceEnabled` is deliberately NOT a gate any
174+ // more: on a runtime with no marketplace but a mounted install-local
175+ // surface, this answer is what decides whether the local menu is drawn.
176176 if ( ! isAdmin ) return ;
177177 let cancelled = false ;
178178 ( async ( ) => {
179179 const items = await listLocalInstalls ( ) ;
180180 if ( ! cancelled ) setLocalInstalls ( items ) ;
181181 } ) ( ) ;
182182 return ( ) => { cancelled = true ; } ;
183- } , [ packageId , localResult , marketplaceEnabled , isAdmin ] ) ;
183+ } , [ packageId , localResult , isAdmin ] ) ;
184184
185185 // Seed cloud-install state so the primary CTA renders as "Installed" on
186186 // first paint instead of inviting another install.
@@ -555,6 +555,103 @@ export function MarketplacePackagePage() {
555555 }
556556 } ;
557557
558+ // `features.installLocal` is the server's own answer to "is the install-local
559+ // surface mounted here" -- the same read the effect above gates on, never a
560+ // guess from the package's fields.
561+ const supportsLocal = getRuntimeConfig ( ) . features . installLocal ;
562+ // Which local install, if any, this page is about. With a marketplace the
563+ // catalog row names it, by manifest id. Without one there is no catalog row,
564+ // and the route's id is the one Installed Apps built this link from
565+ // (`entry.packageId`), so it is matched on that same field. Declared ahead of
566+ // every early return: the local sample-data handlers above close over it.
567+ const localInstall = ( marketplaceEnabled
568+ ? localInstalls . find ( ( i ) => ! ! data && i . manifestId === data . package . manifest_id )
569+ : localInstalls . find ( ( i ) => i . packageId === packageId ) ) ?? null ;
570+
571+ // The local-install menu and the two status banners, drawn by both the
572+ // catalog view and the marketplace-off local view below (objectui#11627).
573+ const localInstalledBadge = localInstall && (
574+ < Badge variant = "default" className = "bg-green-600 hover:bg-green-600 gap-1" >
575+ < CheckCircle2 className = "h-3 w-3" aria-hidden = "true" />
576+ { t ( 'marketplace.detail.installedV' , { version : localInstall . version } ) }
577+ </ Badge >
578+ ) ;
579+ const localMenu = localInstall && (
580+ < DropdownMenu >
581+ < DropdownMenuTrigger asChild >
582+ < Button variant = "outline" size = "lg" className = "px-2.5" aria-label = { t ( 'marketplace.detail.moreOptions' ) } >
583+ < MoreHorizontal className = "h-4 w-4" aria-hidden = "true" />
584+ </ Button >
585+ </ DropdownMenuTrigger >
586+ < DropdownMenuContent align = "end" className = "w-56" >
587+ < DropdownMenuItem onSelect = { doReseedLocalSampleData } disabled = { sampleDataBusy !== null } >
588+ { sampleDataBusy === 'reseed'
589+ ? < Loader2 className = "h-4 w-4 mr-2 animate-spin" aria-hidden = "true" />
590+ : < Database className = "h-4 w-4 mr-2" aria-hidden = "true" /> }
591+ { localInstall . withSampleData
592+ ? t ( 'marketplace.detail.reseedAgain' )
593+ : t ( 'marketplace.detail.addSampleData' ) }
594+ </ DropdownMenuItem >
595+ < DropdownMenuItem
596+ onSelect = { doPurgeLocalSampleData }
597+ disabled = { sampleDataBusy !== null || ! localInstall . withSampleData }
598+ className = "text-destructive focus:text-destructive"
599+ >
600+ { sampleDataBusy === 'purge'
601+ ? < Loader2 className = "h-4 w-4 mr-2 animate-spin" aria-hidden = "true" />
602+ : < Trash2 className = "h-4 w-4 mr-2" aria-hidden = "true" /> }
603+ { t ( 'marketplace.detail.purgeSampleData' ) }
604+ </ DropdownMenuItem >
605+ < DropdownMenuItem onSelect = { doUninstallLocal } disabled = { installingLocal } className = "text-destructive focus:text-destructive" >
606+ < Trash2 className = "h-4 w-4 mr-2" aria-hidden = "true" />
607+ { t ( 'marketplace.detail.uninstallFromRuntime' ) }
608+ </ DropdownMenuItem >
609+ </ DropdownMenuContent >
610+ </ DropdownMenu >
611+ ) ;
612+ const sampleDataNote = sampleDataMsg && (
613+ < div
614+ role = "status"
615+ className = { `flex items-start gap-2 rounded-md border p-3 text-sm ${ sampleDataMsg . ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive' } ` }
616+ >
617+ { sampleDataMsg . ok ? < CheckCircle2 className = "h-4 w-4 mt-0.5 shrink-0" aria-hidden = "true" /> : < AlertCircle className = "h-4 w-4 mt-0.5 shrink-0" aria-hidden = "true" /> }
618+ < div className = "flex-1" > { sampleDataMsg . text } </ div >
619+ < button
620+ type = "button"
621+ className = "text-xs underline opacity-60 hover:opacity-100"
622+ onClick = { ( ) => setSampleDataMsg ( null ) }
623+ >
624+ { t ( 'marketplace.action.dismiss' ) }
625+ </ button >
626+ </ div >
627+ ) ;
628+ // `suggestFor` is the manifest id whose suggested audience bindings a
629+ // successful local INSTALL surfaces (ADR-0090 D5), or `null` where no install
630+ // door is drawn and the only result this banner can carry is an uninstall's.
631+ const localResultNote = ( suggestFor : string | null ) => localResult && (
632+ < div
633+ role = "status"
634+ className = { `flex items-start gap-2 rounded-md border p-3 text-sm whitespace-pre-wrap ${ localResult . ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive' } ` }
635+ >
636+ { localResult . ok ? < CheckCircle2 className = "h-4 w-4 mt-0.5 shrink-0" aria-hidden = "true" /> : < AlertCircle className = "h-4 w-4 mt-0.5 shrink-0" aria-hidden = "true" /> }
637+ < div className = "flex-1" >
638+ { localResult . message }
639+ { /* ADR-0090 D5 — local installs land in this runtime's kernel, so
640+ its suggested audience bindings are confirmable right here. */ }
641+ { localResult . ok && suggestFor ? (
642+ < SuggestedBindingsPanel packageId = { suggestFor } strings = { suggestionStrings } className = "mt-2" />
643+ ) : null }
644+ </ div >
645+ < button
646+ type = "button"
647+ className = "text-xs underline opacity-60 hover:opacity-100"
648+ onClick = { ( ) => setLocalResult ( null ) }
649+ >
650+ { t ( 'marketplace.action.dismiss' ) }
651+ </ button >
652+ </ div >
653+ ) ;
654+
558655 // A CONFIGURATION CONCLUSION, not a load failure -- the same informational
559656 // state the catalog page renders, so the two pages stop disagreeing about the
560657 // same runtime (`2573ff434`). Reached by a pasted or bookmarked package URL,
@@ -565,7 +662,45 @@ export function MarketplacePackagePage() {
565662 // `features.marketplace` is public runtime config that any client already
566663 // reads. Telling an unprivileged operator they lack permission for a surface
567664 // that exists for nobody is the same misdirection this fix removes.
568- if ( ! marketplaceEnabled ) return < MarketplaceDisabled /> ;
665+ //
666+ // objectui#11627 — that answer covers the REMOTE half of the page only. A
667+ // runtime with no marketplace can still mount the install-local surface (an
668+ // offline boot, `OS_CLOUD_URL=off`), and Installed Apps links each local
669+ // install's Details here; the notice used to be all that page showed, so
670+ // local reseed / purge had no way in. Now an admin whose package IS a local
671+ // install also gets its header and local menu, ABOVE the same notice: the
672+ // catalog fetch, the install-to-cloud CTA, the readme and the version list
673+ // stay refused exactly as before, and every other viewer sees the notice
674+ // alone. The local half is ADDED once `listLocalInstalls` answers, so the
675+ // notice is never painted and then retracted.
676+ if ( ! marketplaceEnabled ) {
677+ if ( ! ( supportsLocal && isAdmin && ( localInstall || localResult ) ) ) return < MarketplaceDisabled /> ;
678+ return (
679+ < div className = "mx-auto w-full max-w-6xl flex flex-col gap-6 p-4 sm:p-6" >
680+ { localInstall && (
681+ < div className = "flex items-start gap-5 flex-wrap sm:flex-nowrap rounded-2xl border bg-gradient-to-br from-primary/5 via-background to-background p-6 sm:p-8" >
682+ < PackageIcon
683+ manifestId = { localInstall . manifestId }
684+ className = "h-20 w-20 rounded-2xl shadow-sm ring-1 ring-border shrink-0"
685+ initialClassName = "text-3xl font-bold"
686+ />
687+ < div className = "flex-1 min-w-0" >
688+ < h1 className = "text-2xl sm:text-3xl font-bold tracking-tight truncate" > { localInstall . manifestId } </ h1 >
689+ < div className = "text-sm text-muted-foreground mt-2 flex flex-wrap items-center gap-1.5" >
690+ { localInstalledBadge }
691+ </ div >
692+ </ div >
693+ < div className = "flex items-center gap-2 shrink-0 self-start" >
694+ { localMenu }
695+ </ div >
696+ </ div >
697+ ) }
698+ { sampleDataNote }
699+ { localResultNote ( null ) }
700+ < MarketplaceDisabled />
701+ </ div >
702+ ) ;
703+ }
569704
570705 // Ahead of BOTH the loading and the load-failure branches below
571706 // (objectui#5583): authorization is not a function of whether the fetch
@@ -630,7 +765,6 @@ export function MarketplacePackagePage() {
630765 const pkg = data . package ;
631766 const loc = localizePackage ( pkg as any , language ) ;
632767 const latestVersion = pkg . latest_version ?. version ?? data . versions [ 0 ] ?. version ?? null ;
633- const localInstall = localInstalls . find ( ( i ) => i . manifestId === pkg . manifest_id ) ?? null ;
634768 // PD4 (ADR-0025 §3.11): code-bearing packages must disclose + be acknowledged.
635769 const containsCode = ! ! pkg . latest_version ?. contains_code ;
636770 // ADR-0010 version lifecycle: installed cloud env is on an OLDER version than
@@ -643,7 +777,6 @@ export function MarketplacePackagePage() {
643777 && ! ! latestVersion
644778 && isNewerVersion ( latestVersion , cloudInstalledVersion ) ;
645779
646- const supportsLocal = getRuntimeConfig ( ) . features . installLocal ;
647780 const primaryDisabled = ! latestVersion || installingLocal || installing || ( ! supportsLocal && ! ! cloudInstalledVersion && ! cloudUpdateAvailable ) ;
648781 const primaryAction = supportsLocal
649782 ? {
@@ -713,12 +846,7 @@ export function MarketplacePackagePage() {
713846 ) }
714847 { categoryLabel && < Badge variant = "outline" > { categoryLabel } </ Badge > }
715848 { pkg . license && < Badge variant = "outline" className = "font-normal" > { pkg . license } </ Badge > }
716- { localInstall && (
717- < Badge variant = "default" className = "bg-green-600 hover:bg-green-600 gap-1" >
718- < CheckCircle2 className = "h-3 w-3" aria-hidden = "true" />
719- { t ( 'marketplace.detail.installedV' , { version : localInstall . version } ) }
720- </ Badge >
721- ) }
849+ { localInstalledBadge }
722850 { ! localInstall && cloudInstalledVersion && (
723851 < Badge variant = "default" className = "bg-green-600 hover:bg-green-600 gap-1" >
724852 < CheckCircle2 className = "h-3 w-3" aria-hidden = "true" />
@@ -741,39 +869,7 @@ export function MarketplacePackagePage() {
741869 < Download className = "h-4 w-4 mr-1.5" aria-hidden = "true" />
742870 { primaryAction . label }
743871 </ Button >
744- { localInstall && (
745- < DropdownMenu >
746- < DropdownMenuTrigger asChild >
747- < Button variant = "outline" size = "lg" className = "px-2.5" aria-label = { t ( 'marketplace.detail.moreOptions' ) } >
748- < MoreHorizontal className = "h-4 w-4" aria-hidden = "true" />
749- </ Button >
750- </ DropdownMenuTrigger >
751- < DropdownMenuContent align = "end" className = "w-56" >
752- < DropdownMenuItem onSelect = { doReseedLocalSampleData } disabled = { sampleDataBusy !== null } >
753- { sampleDataBusy === 'reseed'
754- ? < Loader2 className = "h-4 w-4 mr-2 animate-spin" aria-hidden = "true" />
755- : < Database className = "h-4 w-4 mr-2" aria-hidden = "true" /> }
756- { localInstall . withSampleData
757- ? t ( 'marketplace.detail.reseedAgain' )
758- : t ( 'marketplace.detail.addSampleData' ) }
759- </ DropdownMenuItem >
760- < DropdownMenuItem
761- onSelect = { doPurgeLocalSampleData }
762- disabled = { sampleDataBusy !== null || ! localInstall . withSampleData }
763- className = "text-destructive focus:text-destructive"
764- >
765- { sampleDataBusy === 'purge'
766- ? < Loader2 className = "h-4 w-4 mr-2 animate-spin" aria-hidden = "true" />
767- : < Trash2 className = "h-4 w-4 mr-2" aria-hidden = "true" /> }
768- { t ( 'marketplace.detail.purgeSampleData' ) }
769- </ DropdownMenuItem >
770- < DropdownMenuItem onSelect = { doUninstallLocal } disabled = { installingLocal } className = "text-destructive focus:text-destructive" >
771- < Trash2 className = "h-4 w-4 mr-2" aria-hidden = "true" />
772- { t ( 'marketplace.detail.uninstallFromRuntime' ) }
773- </ DropdownMenuItem >
774- </ DropdownMenuContent >
775- </ DropdownMenu >
776- ) }
872+ { localMenu }
777873 { /* Reseed / purge still POST cross-origin to the control plane, which
778874 the browser blocks on a tenant subdomain. Only offer them when the
779875 runtime IS the cloud (same-origin). On tenants the install state is
@@ -811,46 +907,9 @@ export function MarketplacePackagePage() {
811907 </ div >
812908 </ div >
813909
814- { sampleDataMsg && (
815- < div
816- role = "status"
817- className = { `flex items-start gap-2 rounded-md border p-3 text-sm ${ sampleDataMsg . ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive' } ` }
818- >
819- { sampleDataMsg . ok ? < CheckCircle2 className = "h-4 w-4 mt-0.5 shrink-0" aria-hidden = "true" /> : < AlertCircle className = "h-4 w-4 mt-0.5 shrink-0" aria-hidden = "true" /> }
820- < div className = "flex-1" > { sampleDataMsg . text } </ div >
821- < button
822- type = "button"
823- className = "text-xs underline opacity-60 hover:opacity-100"
824- onClick = { ( ) => setSampleDataMsg ( null ) }
825- >
826- { t ( 'marketplace.action.dismiss' ) }
827- </ button >
828- </ div >
829- ) }
910+ { sampleDataNote }
830911
831- { localResult && (
832- < div
833- role = "status"
834- className = { `flex items-start gap-2 rounded-md border p-3 text-sm whitespace-pre-wrap ${ localResult . ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive' } ` }
835- >
836- { localResult . ok ? < CheckCircle2 className = "h-4 w-4 mt-0.5 shrink-0" aria-hidden = "true" /> : < AlertCircle className = "h-4 w-4 mt-0.5 shrink-0" aria-hidden = "true" /> }
837- < div className = "flex-1" >
838- { localResult . message }
839- { /* ADR-0090 D5 — local installs land in this runtime's kernel, so
840- its suggested audience bindings are confirmable right here. */ }
841- { localResult . ok && (
842- < SuggestedBindingsPanel packageId = { pkg . manifest_id } strings = { suggestionStrings } className = "mt-2" />
843- ) }
844- </ div >
845- < button
846- type = "button"
847- className = "text-xs underline opacity-60 hover:opacity-100"
848- onClick = { ( ) => setLocalResult ( null ) }
849- >
850- { t ( 'marketplace.action.dismiss' ) }
851- </ button >
852- </ div >
853- ) }
912+ { localResultNote ( pkg . manifest_id ) }
854913
855914 < div className = "grid gap-4 lg:grid-cols-3" >
856915 < div className = "lg:col-span-2 space-y-4" >
0 commit comments