Skip to content

Commit e10436e

Browse files
committed
fix(web): surface the blocked-origin guard instead of an empty project list
When the daemon binds 127.0.0.1 (Linux Docker host networking) and the browser loads the app from `localhost`, the powered-preview origin guard rejects /api/projects with a 403. Startup called the fail-soft `listProjects()`, reconciled [], and the Projects tab reported "No projects yet." — a permanent misconfiguration rendered as an empty workspace, which sends the user looking for lost work rather than at the address bar. Startup now asks for the strict variant and reproduces the fail-soft behaviour itself, so transport and bring-up failures keep leaving the UI rendered. The single exception is keyed to the structured origin-guard error body rather than the 403 status, because `Server initializing` is also a 403 and does heal on its own. Part of #6263 — the Linux Docker quickstart wording (127.0.0.1 vs localhost) is the remaining half and stays open.
1 parent f2a7568 commit e10436e

27 files changed

Lines changed: 213 additions & 7 deletions

apps/web/src/App.tsx

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ import {
172172
importFolderProject,
173173
invalidatePluginCatalogCache,
174174
invalidateWorkspaceProjectLists,
175+
isBlockedOriginError,
175176
listWorkspaceProjectSummaries,
176177
listProjects,
177178
listTemplates,
@@ -1192,6 +1193,11 @@ function AppInner() {
11921193
}, []);
11931194
const [dsLoading, setDsLoading] = useState(true);
11941195
const [projectsLoading, setProjectsLoading] = useState(true);
1196+
// Set only when the daemon's powered-preview origin guard rejected the
1197+
// project list. Holds the loopback address the user should switch to, so
1198+
// the projects tab can say why it is empty instead of claiming there are
1199+
// no projects.
1200+
const [blockedOriginUrl, setBlockedOriginUrl] = useState<string | null>(null);
11951201
const [promptTemplatesLoading, setPromptTemplatesLoading] = useState(true);
11961202
// Goes true once the daemon-persisted config (agentId/designSystemId/etc.)
11971203
// has merged into local state. Auto-selection effects below wait on this
@@ -2009,13 +2015,28 @@ function AppInner() {
20092015
}
20102016

20112017
const request = beginProjectListRequest(workspaceProjectViewRef.current);
2018+
// Startup asks for the strict variant only to inspect the failure, then
2019+
// reproduces the fail-soft behaviour itself: a daemon that is still
2020+
// coming up or briefly unreachable must keep leaving the UI rendered.
2021+
// The one exception is the powered-preview origin guard, which will
2022+
// keep rejecting until the user changes the address — reconciling []
2023+
// there would render a persistent misconfiguration as an empty
2024+
// workspace.
20122025
void listCurrentWorkspaceProjects({
20132026
workspaceView: workspaceProjectViewRef.current,
2014-
}).then((list) => {
2015-
if (cancelled) return;
2016-
reconcileFetchedProjects(list, request);
2017-
setProjectsLoading(false);
2018-
});
2027+
throwOnError: true,
2028+
})
2029+
.then((list) => {
2030+
if (cancelled) return;
2031+
reconcileFetchedProjects(list, request);
2032+
setProjectsLoading(false);
2033+
})
2034+
.catch((err: unknown) => {
2035+
if (cancelled) return;
2036+
if (isBlockedOriginError(err)) setBlockedOriginUrl(loopbackWorkaroundUrl());
2037+
else reconcileFetchedProjects([], request);
2038+
setProjectsLoading(false);
2039+
});
20192040

20202041
void listTemplates().then((list) => {
20212042
if (cancelled) return;
@@ -4980,6 +5001,7 @@ function AppInner() {
49805001
workspaceDesignSystems.identity !== currentWorkspaceCatalogIdentity || dsLoading
49815002
}
49825003
projectsLoading={projectsLoading}
5004+
blockedOriginUrl={blockedOriginUrl}
49835005
promptTemplatesLoading={promptTemplatesLoading}
49845006
onCreateProject={handleCreateProject}
49855007
onCreatePluginShareProject={handleCreatePluginShareProject}
@@ -5169,6 +5191,13 @@ function AppInner() {
51695191
);
51705192
}
51715193

5194+
// The address that reaches the daemon directly. Keeps the current port so
5195+
// the guidance stays correct for non-default `--port` runs.
5196+
function loopbackWorkaroundUrl(): string {
5197+
const { protocol, port } = window.location;
5198+
return `${protocol}//127.0.0.1${port ? `:${port}` : ''}`;
5199+
}
5200+
51725201
function generateInstallationIdSafe(): string {
51735202
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
51745203
return crypto.randomUUID();

apps/web/src/components/DesignsTab.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ interface Props {
8989
onRename?: (id: string, name: string) => void;
9090
onNewProject?: () => void;
9191
onRefresh?: () => Promise<void> | void;
92+
/** Loopback address to recommend when the daemon's origin guard blocked the
93+
* project list. Non-null means the list is empty because of the address,
94+
* not because there are no projects. */
95+
blockedOriginUrl?: string | null;
9296
isActive?: boolean;
9397
}
9498

@@ -103,6 +107,7 @@ export function DesignsTab({
103107
onRename,
104108
onNewProject,
105109
onRefresh,
110+
blockedOriginUrl = null,
106111
isActive = true,
107112
}: Props) {
108113
const renameTitleId = useId();
@@ -681,7 +686,17 @@ export function DesignsTab({
681686
</div>
682687
{filtered.length === 0 ? (
683688
<div className="tab-empty">
684-
{projects.length === 0 ? (
689+
{projects.length === 0 && blockedOriginUrl ? (
690+
// The list is empty because the daemon refused the request,
691+
// not because the workspace is. Saying "No projects yet"
692+
// here sends the user looking for lost work instead of at
693+
// the address bar, so name the cause and the fix.
694+
<div className="designs-empty-state" role="alert">
695+
<h2 className="designs-empty-title">
696+
{t("designs.blockedOrigin", { url: blockedOriginUrl })}
697+
</h2>
698+
</div>
699+
) : projects.length === 0 ? (
685700
<div className="designs-empty-state">
686701
<h2 className="designs-empty-title">
687702
{t("designs.emptyNoProjects")}

apps/web/src/components/EntryShell.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,10 @@ interface Props {
423423
skillsLoading?: boolean;
424424
designSystemsLoading?: boolean;
425425
projectsLoading?: boolean;
426+
/** Loopback address to recommend when the daemon's origin guard blocked the
427+
* project list. Non-null means the list is empty because of the address,
428+
* not because there are no projects. */
429+
blockedOriginUrl?: string | null;
426430
// Execution / model-switching context. Threaded down from `App` so the
427431
// top-bar `InlineModelSwitcher` can render the active mode/agent/model
428432
// and persist changes through the same callbacks the project view uses.
@@ -560,6 +564,7 @@ export function EntryShell({
560564
skillsLoading = false,
561565
designSystemsLoading = false,
562566
projectsLoading = false,
567+
blockedOriginUrl = null,
563568
config,
564569
providerModelsCache: sharedProviderModelsCache,
565570
onProviderModelsCacheChange,
@@ -1642,6 +1647,7 @@ export function EntryShell({
16421647
onDuplicate={onDuplicateProject}
16431648
onRename={onRenameProject}
16441649
onRefresh={onProjectsRefresh}
1650+
blockedOriginUrl={blockedOriginUrl}
16451651
isActive={view === 'projects'}
16461652
onNewProject={() => {
16471653
openNewProject();

apps/web/src/components/EntryView.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ interface Props {
111111
skillsLoading?: boolean;
112112
designSystemsLoading?: boolean;
113113
projectsLoading?: boolean;
114+
/** Loopback address to recommend when the daemon's origin guard blocked the
115+
* project list. Non-null means the list is empty because of the address,
116+
* not because there are no projects. */
117+
blockedOriginUrl?: string | null;
114118
promptTemplatesLoading?: boolean;
115119
onCreateProject: (input: EntryCreateProjectInput) => Promise<boolean> | boolean | void;
116120
onCreatePluginShareProject: (
@@ -273,6 +277,7 @@ export function EntryView({
273277
skillsLoading = false,
274278
designSystemsLoading = false,
275279
projectsLoading = false,
280+
blockedOriginUrl = null,
276281
promptTemplatesLoading: _promptTemplatesLoading = false,
277282
onCreateProject,
278283
onCreatePluginShareProject,
@@ -377,6 +382,7 @@ export function EntryView({
377382
skillsLoading={skillsLoading}
378383
designSystemsLoading={designSystemsLoading}
379384
projectsLoading={projectsLoading}
385+
blockedOriginUrl={blockedOriginUrl}
380386
config={config}
381387
providerModelsCache={providerModelsCache}
382388
onProviderModelsCacheChange={onProviderModelsCacheChange}

apps/web/src/i18n/locales/ar.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1894,6 +1894,7 @@ export const ar: Dict = {
18941894
'designs.filterAria': 'تصفية المشاريع',
18951895
'designs.searchPlaceholder': 'بحث...',
18961896
'designs.emptyNoProjects': 'لا توجد مشاريع بعد.',
1897+
'designs.blockedOrigin': 'تعذّر تحميل المشاريع: حظر الـ daemon هذا العنوان. افتح {url} بدلاً منه.',
18971898
'designs.emptyNoMatch': 'لا توجد مشاريع تطابق بحثك.',
18981899
'designs.deleteTitle': 'حذف المشروع',
18991900
'designs.deleteConfirm': 'هل تريد حذف "{name}"؟',

apps/web/src/i18n/locales/de.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1894,6 +1894,7 @@ export const de: Dict = {
18941894
'designs.filterAria': 'Projekte filtern',
18951895
'designs.searchPlaceholder': 'Suchen…',
18961896
'designs.emptyNoProjects': 'Noch keine Projekte.',
1897+
'designs.blockedOrigin': 'Projekte konnten nicht geladen werden: Der Daemon hat diese Adresse blockiert. Öffne stattdessen {url}.',
18971898
'designs.emptyNoMatch': 'Keine Projekte passen zu Ihrer Suche.',
18981899
'designs.deleteTitle': 'Projekt löschen',
18991900
'designs.deleteConfirm': '„{name}“ löschen?',

apps/web/src/i18n/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1894,6 +1894,7 @@ export const en: Dict = {
18941894
'designs.filterAria': 'Filter projects',
18951895
'designs.searchPlaceholder': 'Search…',
18961896
'designs.emptyNoProjects': 'No projects yet.',
1897+
'designs.blockedOrigin': 'Projects could not load: the daemon blocked this address. Open {url} instead.',
18971898
'designs.emptyNoMatch': 'No projects match your search.',
18981899
'designs.deleteTitle': 'Delete project',
18991900
'designs.deleteConfirm': 'Delete "{name}"?',

apps/web/src/i18n/locales/es-ES.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1894,6 +1894,7 @@ export const esES: Dict = {
18941894
'designs.filterAria': 'Filtrar proyectos',
18951895
'designs.searchPlaceholder': 'Buscar…',
18961896
'designs.emptyNoProjects': 'Aún no hay proyectos.',
1897+
'designs.blockedOrigin': 'No se pudieron cargar los proyectos: el daemon bloqueó esta dirección. Abre {url} en su lugar.',
18971898
'designs.emptyNoMatch': 'Ningún proyecto coincide con tu búsqueda.',
18981899
'designs.deleteTitle': 'Eliminar proyecto',
18991900
'designs.deleteConfirm': '¿Eliminar «{name}»?',

apps/web/src/i18n/locales/fa.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1894,6 +1894,7 @@ export const fa: Dict = {
18941894
'designs.filterAria': 'فیلتر پروژه‌ها',
18951895
'designs.searchPlaceholder': 'جستجو…',
18961896
'designs.emptyNoProjects': 'هنوز هیچ پروژه‌ای وجود ندارد.',
1897+
'designs.blockedOrigin': 'پروژه‌ها بارگذاری نشدند: دیمن این نشانی را مسدود کرد. به‌جای آن {url} را باز کنید.',
18971898
'designs.emptyNoMatch': 'هیچ پروژه‌ای با جستجوی شما مطابقت ندارد.',
18981899
'designs.deleteTitle': 'حذف پروژه',
18991900
'designs.deleteConfirm': 'آیا «{name}» حذف شود؟',

apps/web/src/i18n/locales/fr.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1894,6 +1894,7 @@ export const fr: Dict = {
18941894
'designs.filterAria': 'Filtrer les projets',
18951895
'designs.searchPlaceholder': 'Rechercher…',
18961896
'designs.emptyNoProjects': 'Aucun projet pour l\'instant.',
1897+
'designs.blockedOrigin': 'Impossible de charger les projets : le daemon a bloqué cette adresse. Ouvrez plutôt {url}.',
18971898
'designs.emptyNoMatch': 'Aucun projet ne correspond à votre recherche.',
18981899
'designs.deleteTitle': 'Supprimer le projet',
18991900
'designs.deleteConfirm': 'Supprimer « {name} » ?',

0 commit comments

Comments
 (0)