feat(frontend): reach the hub importer from the create menu, and count the funnel

The picker only existed inside the empty state, which disappears as soon
as a workspace holds one item — nothing else in the product linked to
`/projects/import`. New → Import now offers a hub project, opening the
catalogue in a dialog: a popover anchored to an item inside an open
dropdown leaves two melt layers arguing over focus. The list and the
import dialog move up to ItemsList, so one dialog serves both doors.

`template_setup` records how the credentials step ended — `filled` only
when nothing was outstanding, `skipped` carrying how many rows were left
— and `template_abandon` records where a dismissed import was given up.
`template_picker_open` gains a key naming the entry point.

Also on the workspace picker: logging out is a text link on the line
that says who you are and an item in the settings menu, rather than the
page's accent action, and onboarding's Previous joins the row it belongs
to instead of hanging under the button that finishes the form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9
This commit is contained in:
Guilhem Lemouel
2026-09-04 13:26:09 +02:00
co-authored by Claude Opus 5
parent c324d2294c
commit 7812095c8d
13 changed files with 200 additions and 103 deletions
+1 -1
View File
@@ -1 +1 @@
e1851154b61494928ba698d58ba7a50a8f4e991f
1b569b727364e8f3db8513fb41e99f1fff1d1406
@@ -43,7 +43,8 @@
* own slug and `installProject` retargets them, so reading the raw paths here would
* look for stubs that are not where they landed. */
folder?: string
onSkip: () => void
/** Left with `outstanding` rows still unfilled, which the caller may want to count. */
onSkip: (outstanding: number) => void
/** Off where the surface already names the step, e.g. a dialog whose title is it. */
showHeading?: boolean
/** Fill the height given, actions pinned to the bottom. See ImportProjectStep. */
@@ -492,7 +493,7 @@
})
if (!confirmed) return
}
onSkip()
onSkip(outstanding)
}
/**
@@ -564,7 +565,7 @@
{#if row.status === 'done'}
<Check size={20} class="text-emerald-600" />
{:else if row.status === 'running'}
<Loader2 size={20} class="animate-spin text-blue-500" />
<Loader2 size={20} class="animate-spin text-accent" />
{:else if row.status === 'failed'}
<X size={20} class="text-red-500" />
{:else if row.status === 'unknown'}
@@ -1072,8 +1072,8 @@
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, whether AI chat skills are turned on or off and how often one is
loaded, the plan tier and quota shown when the execution meter is opened, and which
home-page entry point a new item is created from and the name of any public hub
project imported from it, last 30 days)</li
home-page entry point a new item is created from, the name of any public hub project
imported from it and how far that import got, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
@@ -1127,8 +1127,8 @@
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, whether AI chat skills are turned on or off and how often one is
loaded, the plan tier and quota shown when the execution meter is opened, and which
home-page entry point a new item is created from and the name of any public hub
project imported from it, last 30 days)</li
home-page entry point a new item is created from, the name of any public hub project
imported from it and how far that import got, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
@@ -15,6 +15,7 @@
Loader2,
Workflow,
Import,
Store,
PanelLeftClose
} from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
@@ -36,9 +37,15 @@
triggerElement?: HTMLElement
/** Which entry point this menu hangs off, for telemetry. */
source?: 'toolbar' | 'empty_state'
/**
* Opens the hub project picker. The menu only offers the entry; the picker and the
* import dialog belong to the host, which is the one place a single import modal can
* serve both this menu and the empty state's own link.
*/
onImportHubProject?: () => void
}
let { trigger, triggerElement, source = 'toolbar' }: Props = $props()
let { trigger, triggerElement, source = 'toolbar', onImportHubProject }: Props = $props()
type Variant = {
label: string
@@ -241,8 +248,15 @@
}
let activeKey = $state(allOptions[0]?.key)
// every option's import action, surfaced together under the bottom "Import" submenu
const importActions: Extra[] = allOptions.flatMap((o) => o.extras ?? [])
// every option's import action, surfaced together under the bottom "Import" submenu.
// The hub project leads and is separated below: the others each paste one artifact the
// user already holds, while this one brings a whole project in from somewhere else.
const importActions: Extra[] = $derived([
...(onImportHubProject
? [{ label: 'Import a hub project', onSelect: onImportHubProject }]
: []),
...allOptions.flatMap((o) => o.extras ?? [])
])
// melt dropdown menu: arrow-key nav, typeahead, focus management and outside/escape
// close all come for free; we only drive the doc panel off the highlighted item.
@@ -554,17 +568,24 @@
use:hugViewportRight
class="z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
>
{#each importActions as action (action.label)}
{#each importActions as action, i (action.label)}
<button
use:melt={$item}
class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
onclick={() => action.onSelect()}
>
<Import size={14} class="shrink-0 text-tertiary" />
{#if onImportHubProject && i === 0}
<Store size={14} class="shrink-0 text-tertiary" />
{:else}
<Import size={14} class="shrink-0 text-tertiary" />
{/if}
<span class="text-xs font-medium text-primary whitespace-nowrap">
{action.label}
</span>
</button>
{#if onImportHubProject && i === 0}
<div class="mx-1 my-0.5 border-t border-gray-200 dark:border-gray-700"></div>
{/if}
{/each}
</div>
{/if}
@@ -0,0 +1,48 @@
<script lang="ts">
import { untrack } from 'svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import HubTemplatePicker from './HubTemplatePicker.svelte'
import type { HubProjectPick } from '$lib/hubProject'
interface Props {
open: boolean
/** A project was chosen. The host closes this and opens the import dialog on it. */
onPick: (project: HubProjectPick) => void
onClose: () => void
}
let { open, onPick, onClose }: Props = $props()
// Bound, not one-way, for the same reason the import dialog binds it: `Modal` dispatches
// `confirmed`/`canceled` only, so the X, Escape and the backdrop are visible to the caller
// through this value and nowhere else.
let modalOpen = $state(false)
let wasOpen = false
$effect(() => {
const shouldBeOpen = open
if (shouldBeOpen !== untrack(() => modalOpen)) {
modalOpen = shouldBeOpen
if (shouldBeOpen) logFeatureUsage('home', 'template_picker_open', { key: 'new_menu' })
}
})
$effect(() => {
const isOpen = modalOpen
untrack(() => {
if (!isOpen && wasOpen) onClose()
wasOpen = isOpen
})
})
</script>
<!-- A dialog rather than the empty state's popover: this one opens from an item inside an
already-open dropdown, and a popover anchored there leaves two melt layers arguing over
focus and dismissal. The height is fixed so the list inside has a definite box to page in,
the same requirement the popover meets with `fitViewport`. -->
<!-- `kind="X"`: picking a card is the action, so a Cancel button under the list would be the
only thing in the dialog that looks like one. -->
<Modal bind:open={modalOpen} kind="X" title="Import a hub project" class="sm:!max-w-[560px]">
<div class="flex h-[min(70vh,520px)] flex-col">
<HubTemplatePicker fullWidth {onPick} />
</div>
</Modal>
@@ -11,9 +11,11 @@
interface Props {
onPick: (project: HubProjectPick) => void
/** Take the width given instead of the popover's own, for a host that sets one. */
fullWidth?: boolean
}
let { onPick }: Props = $props()
let { onPick, fullWidth = false }: Props = $props()
let list: InfiniteList | undefined = $state(undefined)
@@ -47,7 +49,7 @@
<!-- The popover gives this box a definite height; the list takes what the header leaves and
scrolls inside it, which is also what lets it page. -->
<div class="flex min-h-0 w-[380px] flex-col">
<div class="flex min-h-0 flex-col {fullWidth ? 'w-full flex-1' : 'w-[380px]'}">
<!-- The hub is named once, as the link to it: a footer row saying the same thing again is
a second line spent on somewhere the reader is not going. -->
<p class="px-3 pb-2 pt-3 text-[11.5px] leading-snug text-hint">
@@ -55,6 +55,13 @@
* there rather than importing twice.
*/
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.
if (!finishing) {
const stage = execution?.running ? 'running' : onSetupStep ? 'setup' : 'idle'
logFeatureUsage('home', 'template_abandon', { key: stage })
}
if (execution?.running) {
execution.abandon()
// Not on the way out through Finish: `done` survives a retry, so Finish is clickable
@@ -129,10 +136,17 @@
// by the same falling edge as the X.
let finishing = false
function finish() {
/**
* 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.
*/
function finish(setupOutcome: 'filled' | 'skipped' | 'none', 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 })
finishing = true
onImported?.()
onClose()
@@ -181,7 +195,7 @@
setupPending={setup.needed}
setupUndecided={setup.undecided}
onFolderChange={(f) => (folder = f)}
onFinish={() => (setup.needed ? (onSetupStep = true) : finish())}
onFinish={() => (setup.needed ? (onSetupStep = true) : finish('none'))}
onBack={onClose}
onExecution={(e) => (execution = e)}
resume={execution}
@@ -205,8 +219,8 @@
slug={slug ?? ''}
{folder}
showHeading={false}
onSkip={finish}
onFinish={finish}
onSkip={(outstanding) => finish('skipped', outstanding)}
onFinish={() => finish('filled')}
onBack={execution ? () => (onSetupStep = false) : undefined}
/>
</div>
@@ -40,6 +40,9 @@
} from '$lib/components/FilterSearchbar.svelte'
import NoItemFound from './NoItemFound.svelte'
import WorkspaceEmptyState from './WorkspaceEmptyState.svelte'
import HubProjectPickerModal from './HubProjectPickerModal.svelte'
import ImportProjectModal from './ImportProjectModal.svelte'
import type { HubProjectPick } from '$lib/hubProject'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import FlowIcon from './FlowIcon.svelte'
@@ -975,6 +978,12 @@
// 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<HubProjectPick | undefined>(undefined)
let hubPickerOpen = $state(false)
let justImported = $state(false)
let justImportedTimer: ReturnType<typeof setTimeout> | undefined
function onImported() {
@@ -1774,7 +1783,7 @@
whose direct-deploy protection cleared showEditButtons (NoDirectDeployAlert), since
the menu itself does no permission check. -->
{#if !$userStore?.operator && showEditButtons}
<CreateActionsMenu />
<CreateActionsMenu onImportHubProject={() => (hubPickerOpen = true)} />
{/if}
</div>
</div>
@@ -1810,7 +1819,7 @@
workspace whose direct-deploy protection cleared `showEditButtons`, gets the plain
message instead of two actions it may not take. -->
{#if workspaceEmpty && !$userStore?.operator && showEditButtons}
<WorkspaceEmptyState {onImported} />
<WorkspaceEmptyState onPick={(project) => (hubPick = project)} />
{:else}
<NoItemFound {activeFilters} />
{/if}
@@ -1930,6 +1939,16 @@
/>
{/if}
<HubProjectPickerModal
open={hubPickerOpen}
onClose={() => (hubPickerOpen = false)}
onPick={(project) => {
hubPickerOpen = false
hubPick = project
}}
/>
<ImportProjectModal pick={hubPick} onClose={() => (hubPick = undefined)} {onImported} />
<style>
/* Rows arriving after an import, one after another. The animation is declared on the
container's children rather than on each row: a wrapper element around a row would make
@@ -6,16 +6,13 @@
import { workspaceStore } from '$lib/stores'
import CreateActionsMenu from './CreateActionsMenu.svelte'
import HubTemplatePicker from './HubTemplatePicker.svelte'
import ImportProjectModal from './ImportProjectModal.svelte'
interface Props {
/** An import landed: the list has rows now, so the caller reloads it. */
onImported?: () => void
/** A project was chosen here. The list owns the import dialog, and opens it on this. */
onPick: (project: HubProjectPick) => void
}
let { onImported }: Props = $props()
let picked = $state<HubProjectPick | undefined>(undefined)
let { onPick }: Props = $props()
// Row opacities: the list fading out of existence. Static on purpose — motion is what
// makes a skeleton mean "loading", and this state means "empty".
@@ -86,14 +83,15 @@
contentStyle="height: min(72vh, 520px);"
class="border-b border-transparent text-accent hover:border-accent"
triggerAttrs={{ 'aria-label': 'Start from a template' }}
on:openChange={(e) => e.detail && logFeatureUsage('home', 'template_picker_open')}
on:openChange={(e) =>
e.detail && logFeatureUsage('home', 'template_picker_open', { key: 'empty_state' })}
>
{#snippet trigger()}Start from a template{/snippet}
{#snippet content({ close })}
<HubTemplatePicker
onPick={(project) => {
close()
picked = project
onPick(project)
}}
/>
{/snippet}
@@ -103,8 +101,7 @@
{#snippet trigger()}
<!-- A bare <button> for a link inside a sentence, signed off by design: <Button>
carries its own padding and background and cannot sit inline in running text.
The colour is `text-accent` — the `text-blue-500` older links use is the
mistake to avoid, not the pattern to copy.
Inline links take `text-accent`, never a raw Tailwind blue.
The full stop rides inside the snippet: across a component boundary Svelte
keeps the markup whitespace, which would leave a gap before it. -->
<button
@@ -116,5 +113,3 @@
</CreateActionsMenu>
</div>
</div>
<ImportProjectModal pick={picked} onClose={() => (picked = undefined)} {onImported} />
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import TextInput from '$lib/components/text_input/TextInput.svelte'
@@ -22,23 +23,16 @@
/** Where to go once the workspace exists. It is already the active one by then. */
onCreated: (workspaceId: string) => void
/**
* Told when the form starts handing over to the new workspace and if it comes back, so
* a surface with chrome of its own around this one — onboarding's Previous button — can
* stand down for the hand-over instead of offering a way back out of a workspace that
* now exists. A callback rather than a bound prop: this is something the form reports,
* not state it shares, and `$bindable(default)` on an optional prop is banned.
* Rendered at the head of the action row — a host's own way back, next to Advanced
* settings rather than stranded under the button that finishes the form.
*/
onCreatingChange?: (creating: boolean) => void
leading?: Snippet
}
let { onCreated, onCreatingChange }: Props = $props()
let { onCreated, leading }: Props = $props()
let name = $state('')
let creating = $state(false)
function setCreating(next: boolean) {
creating = next
onCreatingChange?.(next)
}
// The full form — id, colour, username, invites — for the person who wants it. Forced on
// when the instance does not derive usernames: one is required and a name field has
@@ -98,7 +92,7 @@
async function create() {
if (problem || creating) return
setCreating(true)
creating = true
const workspaceName = name.trim()
const started = Date.now()
try {
@@ -109,7 +103,7 @@
true
)
advanced = true
setCreating(false)
creating = false
return
}
await WorkspaceService.createWorkspace({
@@ -130,7 +124,7 @@
} catch (error) {
console.error('Could not create the workspace:', error)
sendUserToast('Could not create the workspace: ' + (error?.body || error?.message), true)
setCreating(false)
creating = false
}
}
</script>
@@ -142,6 +136,11 @@
</div>
{:else if advanced}
<CreateWorkspaceInner inModal onFinish={() => onCreated('')} />
<!-- The full form has no way back to this one, so the host's way out of the step stays
reachable here too — below it, since that form ends on its own action row. -->
{#if leading}
<div class="mt-6 flex items-center">{@render leading()}</div>
{/if}
{:else}
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
@@ -158,11 +157,17 @@
{/if}
<div class="mt-6 flex items-center justify-between gap-4">
<!-- A bare <button> as a quiet text link, signed off by design: a second <Button> here
would compete with Create workspace for the eye. -->
<button class="text-xs text-secondary hover:text-emphasis" onclick={() => (advanced = true)}>
Advanced settings
</button>
<div class="flex items-center gap-3">
{@render leading?.()}
<!-- A bare <button> as a quiet text link, signed off by design: a second <Button> here
would compete with Create workspace for the eye. -->
<button
class="text-xs text-secondary hover:text-emphasis"
onclick={() => (advanced = true)}
>
Advanced settings
</button>
</div>
<Button variant="accent" unifiedSize="md" disabled={!!problem} onClick={create}>
Create workspace
</Button>
@@ -281,7 +281,7 @@
{#if step === 1}
·
<a
class="text-blue-500 hover:underline"
class="text-accent hover:underline"
href="{base}/user/logout?rd={encodeURIComponent(logoutReturnTo)}"
>
Switch account
@@ -50,8 +50,6 @@
let alreadyPlaced = $state(false)
// The survey was skipped, so the last step has nothing to go back to.
let skippedSurvey = $state(false)
// Set by the create form while it hands over to the new workspace.
let creatingWorkspace = $state(false)
async function loadWorkspaceStep() {
try {
@@ -315,23 +313,20 @@
<!-- The same one-field form the workspace picker falls back to, so a user who leaves
onboarding early meets it again rather than something new. It owns the name, the
id, the advanced form and the hand-over into the workspace. -->
<SimpleCreateWorkspace
onCreated={leaveOnboarding}
onCreatingChange={(v) => (creatingWorkspace = v)}
/>
{#if !skippedSurvey && !creatingWorkspace}
<div class="flex flex-row justify-start items-center pt-6">
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: ArrowLeft }}
on:click={goToPreviousStep}
>
Previous
</Button>
</div>
{/if}
<SimpleCreateWorkspace onCreated={leaveOnboarding}>
{#snippet leading()}
{#if !skippedSurvey}
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: ArrowLeft }}
on:click={goToPreviousStep}
>
Previous
</Button>
{/if}
{/snippet}
</SimpleCreateWorkspace>
<div class="flex justify-center mt-4">
<div class="flex items-center gap-2">
@@ -24,7 +24,15 @@
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
import { switchWorkspace } from '$lib/storeUtils'
import { GitFork, Settings, User, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-svelte'
import {
GitFork,
Settings,
User,
Search,
ChevronsDownUp,
ChevronsUpDown,
LogOut
} from 'lucide-svelte'
import { isCloudHosted } from '$lib/cloud'
import { canCreateWorkspace } from '$lib/workspaceCreation'
import SimpleCreateWorkspace from '$lib/components/workspaceSettings/SimpleCreateWorkspace.svelte'
@@ -228,22 +236,18 @@
<CenteredModal
title={showCreate ? 'Create your workspace' : 'Select a workspace'}
subtitle={showCreate ? undefined : `Logged in as ${$usersWorkspaceStore?.email}`}
centerVertically={false}
>
{#snippet subtitleSnippet()}
<!-- With one thing to do on the page, the way out belongs on the line that says who you
are rather than in a footer of its own. -->
{#if showCreate}
<span class="text-xs text-tertiary">
Logged in as <span class="text-secondary">{$usersWorkspaceStore?.email}</span>
·
<!-- A bare <button> for a link inside the sentence, signed off by design: <Button>
cannot sit inline in running text. `text-accent`, not the `text-blue-500` of
older links. -->
<button class="text-accent hover:underline" onclick={() => logout()}>Log out</button>
</span>
{/if}
<!-- The way out belongs on the line that says who you are, not in a footer as the page's
accent action: leaving is not what anyone came here to do. -->
<span class="text-xs text-tertiary">
Logged in as <span class="text-secondary">{$usersWorkspaceStore?.email}</span>
·
<!-- A bare <button> for a link inside the sentence, signed off by design: <Button>
cannot sit inline in running text. Inline links take `text-accent`. -->
<button class="text-accent hover:underline" onclick={() => logout()}>Log out</button>
</span>
{/snippet}
{@const nonForkInvites = invites.filter((invite) => invite.parent_workspace_id == undefined)}
<div class="flex flex-col">
@@ -335,7 +339,7 @@
wrapperClasses="w-full"
>
<Button
unifiedSize="sm"
unifiedSize="md"
href="{base}/user/create_workspace{rd ? `?rd=${encodeURIComponent(rd)}` : ''}"
variant={onlyAdminsWorkspace || noWorkspaces ? 'accent' : 'default'}
wrapperClasses="w-full"
@@ -476,14 +480,15 @@
{/if}
{/if}
<!-- Settings and the way out are for someone who lives here. A user with no workspace yet
has one thing to do, and their way out is on the subtitle line. -->
<!-- Settings are for someone who lives here; a user with no workspace yet has one thing
to do. Logging out rides in this menu, and on the subtitle line above for the create
state, which has no menu. -->
{#if !showCreate}
<div class="flex justify-between items-center mt-10 flex-wrap gap-2">
<div class="flex items-center mt-10 flex-wrap gap-2">
{#if $superadmin}
<Button
variant="default"
unifiedSize="md"
unifiedSize="sm"
onClick={superadminSettings?.openDrawer}
startIcon={{ icon: Settings }}
dropdownItems={[
@@ -491,7 +496,8 @@
label: 'User settings',
onClick: () => userSettings?.openDrawer(),
icon: User
}
},
{ label: 'Log out', onClick: () => logout(), icon: LogOut }
]}
>
Instance settings
@@ -499,23 +505,14 @@
{:else}
<Button
variant="default"
unifiedSize="md"
unifiedSize="sm"
onClick={() => userSettings?.openDrawer()}
startIcon={{ icon: Settings }}
dropdownItems={[{ label: 'Log out', onClick: () => logout(), icon: LogOut }]}
>
User settings
</Button>
{/if}
<Button
variant="accent"
unifiedSize="md"
onClick={async () => {
logout()
}}
>
Log out
</Button>
</div>
{/if}
</div>