From f14e60cc388628f1a9d8b9747b99bc8e6b26fa94 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Fri, 4 Sep 2026 13:42:32 +0200 Subject: [PATCH] fix(frontend): make the import counters answer what they claim to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `template_abandon` folded a landed import closed with the X into `idle`, the bucket read as "opened this and bounced" — three outcomes in one number. It gets its own `done` stage. `template_setup` counted skipped rows through `value`, which is an increment: `skipped` accumulated rows while `filled` and `none` counted imports, two units in one counter with no way to recover one from the other. The row count becomes a bucket in the key, so every event is one import and the buckets compare. The import dialog also asked for the hub URL settings at init, and the home list now mounts it for everyone on every arrival — two GETs for a string only the project card renders. Deferred to the first pick. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9 --- .../components/home/ImportProjectModal.svelte | 51 +++++++++++++++---- .../src/lib/components/home/ItemsList.svelte | 18 +++---- .../user/(user)/workspaces/+page.svelte | 7 +-- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/frontend/src/lib/components/home/ImportProjectModal.svelte b/frontend/src/lib/components/home/ImportProjectModal.svelte index 7d3f0a1406..445383f70d 100644 --- a/frontend/src/lib/components/home/ImportProjectModal.svelte +++ b/frontend/src/lib/components/home/ImportProjectModal.svelte @@ -57,9 +57,17 @@ function dismiss() { // Where they left, which is the half of the funnel Finish cannot report: `running` // walked out on an import in progress, `setup` on the credentials it asked for, - // `idle` opened the dialog and picked nothing up. + // `done` closed a landed import instead of pressing Finish, `idle` opened the dialog + // and picked nothing up. `done` is its own bucket because it is a normal exit — folded + // into `idle` it would read as people bouncing off a dialog they never used. if (!finishing) { - const stage = execution?.running ? 'running' : onSetupStep ? 'setup' : 'idle' + const stage: AbandonStage = execution?.running + ? 'running' + : onSetupStep + ? 'setup' + : execution?.done + ? 'done' + : 'idle' logFeatureUsage('home', 'template_abandon', { key: stage }) } if (execution?.running) { @@ -117,10 +125,17 @@ : undefined) ) + // Asked for on the first pick, not at init: this dialog is mounted by the home list for + // every user on every arrival, and the host is a string only the project card renders. let hubHost = $state('hub.windmill.dev') - void hubBrowserUrl() - .then((u) => (hubHost = new URL(u).host)) - .catch(() => {}) + let hubHostAsked = false + $effect(() => { + if (!slug || hubHostAsked) return + hubHostAsked = true + void hubBrowserUrl() + .then((u) => (hubHost = new URL(u).host)) + .catch(() => {}) + }) // Each open is its own import: a dialog reopened for another project must not inherit // the previous run, or its step would offer to resume a bundle from a different slug. @@ -132,21 +147,37 @@ } }) + // The counters' key vocabularies, enumerated here so the whole set is reviewable at once. + type AbandonStage = 'running' | 'setup' | 'done' | 'idle' + type SetupOutcome = 'filled' | 'skipped' | 'none' + type SetupBucket = 'filled' | 'none' | 'skipped_1' | 'skipped_2_5' | 'skipped_6plus' + // Set for the closing that Finish itself asks for, since that closing reaches `dismiss()` // by the same falling edge as the X. let finishing = false /** * How the credentials step ended, counted alongside the import itself: `filled` only when - * nothing was left outstanding — the step disables Finish until then — `skipped` carrying - * how many rows were walked away from, and `none` where the project asked for nothing. - * Skipping with one credential left and skipping with eight are different problems. + * nothing was left outstanding — the step disables Finish until then — `none` where the + * project asked for nothing, and a `skipped_*` bucket carrying roughly how many rows were + * walked away from, since skipping with one credential left and skipping with eight are + * different problems. + * + * A bucket rather than `value`: `value` is an increment, so counting rows there would make + * `skipped` a sum of rows while its siblings count imports — two units in one counter, and + * no way to recover filled-versus-skipped. */ - function finish(setupOutcome: 'filled' | 'skipped' | 'none', outstanding = 1) { + function setupKey(outcome: SetupOutcome, outstanding: number): SetupBucket { + if (outcome !== 'skipped') return outcome + if (outstanding <= 1) return 'skipped_1' + return outstanding <= 5 ? 'skipped_2_5' : 'skipped_6plus' + } + + function finish(setupOutcome: SetupOutcome, outstanding = 1) { // On the way out rather than on the pick: what is worth counting is an import that // landed, not a dialog that was opened and abandoned. if (slug) logFeatureUsage('home', 'template_import', { key: slug }) - logFeatureUsage('home', 'template_setup', { key: setupOutcome, value: outstanding }) + logFeatureUsage('home', 'template_setup', { key: setupKey(setupOutcome, outstanding) }) finishing = true onImported?.() onClose() diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 8b47c19ef7..6efa27f733 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -975,15 +975,13 @@ // until the first response instead — it is fetched in parallel with the listing, // so it costs no extra wait in practice. Only the first load gates: `current` // survives a refetch, so an in-place scope change refreshes without flashing. + let treeCountsPending = $derived( + treeLazyMode && ownerCountsRes.current == undefined && ownerCountsRes.loading + ) + // An import just landed, so the rows about to replace the empty state are all new: they // fade in one after another rather than appearing as a finished list. Cleared on a timer // because nothing else marks the end — the reload resolves before the rows animate. - // The hub import, owned here rather than by either entry point: the empty state's link and - // the create menu's Import section open the same dialog, and mounting one per entry point - // would put two of them on the page at once while the workspace is still empty. - let hubPick = $state(undefined) - let hubPickerOpen = $state(false) - let justImported = $state(false) let justImportedTimer: ReturnType | undefined function onImported() { @@ -993,9 +991,11 @@ justImportedTimer = setTimeout(() => (justImported = false), 2500) } - let treeCountsPending = $derived( - treeLazyMode && ownerCountsRes.current == undefined && ownerCountsRes.loading - ) + // The hub import, owned here rather than by either entry point: the empty state's link and + // the create menu's Import section open the same dialog, and mounting one per entry point + // would put two of them on the page at once while the workspace is still empty. + let hubPick = $state(undefined) + let hubPickerOpen = $state(false) // The workspace itself holds nothing — no filter is narrowing the list away. It stays // false until the first load resolves: a skeleton already means "loading", and the diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte index 21e194c2ee..a6400cab2c 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte @@ -240,7 +240,8 @@ > {#snippet subtitleSnippet()} + accent action: leaving is not what anyone came here to do. Shown in both states; the + picker also carries it in the settings menu below, which the create state hides. --> Logged in as {$usersWorkspaceStore?.email} · @@ -481,8 +482,8 @@ {/if} + to do, so this row stands down for the create state. Logging out is in this menu as + well as on the subtitle line, which is the only one of the two the create state has. --> {#if !showCreate}
{#if $superadmin}