feat(frontend): redesign home with AI chatbox, FilterSearchbar and New/Import actions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-06-18 23:30:09 +02:00
parent 5508f1da9c
commit e83380d52b
5 changed files with 750 additions and 381 deletions
@@ -0,0 +1,261 @@
<script lang="ts">
import { base } from '$lib/base'
import { goto } from '$lib/navigation'
import { Button } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import type { Item } from '$lib/utils'
import { importFlowStore } from '$lib/components/flows/flowStore.svelte'
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
import { importStore } from '$lib/components/apps/store'
import { HOME_SHOW_CREATE_FLOW, HOME_SHOW_CREATE_APP } from '$lib/consts'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import { Code2, LayoutDashboard, Loader2, Plus, Download, ChevronDown } from 'lucide-svelte'
import YAML from 'yaml'
// Import drawer state — shared across flow / WAC / app import flows.
let flowDrawer: Drawer | undefined = $state(undefined)
let wacDrawer: Drawer | undefined = $state(undefined)
let appDrawer: Drawer | undefined = $state(undefined)
let pendingFlowRaw: string = $state('')
let pendingWacRaw: string = $state('')
let pendingAppRaw: string = $state('')
let flowImportType: 'yaml' | 'json' = $state('yaml')
let wacImportType: 'yaml' | 'json' = $state('yaml')
let appImportType: 'yaml' | 'json' = $state('yaml')
let appKind: 'lowcode' | 'fullcode' = $state('lowcode')
async function importFlowRaw() {
$importFlowStore =
flowImportType === 'yaml' ? YAML.parse(pendingFlowRaw) : JSON.parse(pendingFlowRaw)
await goto(`${base}/flows/add`)
flowDrawer?.closeDrawer?.()
}
async function importWacRaw() {
$importScriptStore =
wacImportType === 'yaml' ? YAML.parse(pendingWacRaw) : JSON.parse(pendingWacRaw)
await goto(`${base}/scripts/add?import=true`)
wacDrawer?.closeDrawer?.()
}
async function importAppRaw() {
const parsed = appImportType === 'yaml' ? YAML.parse(pendingAppRaw) : JSON.parse(pendingAppRaw)
if (appKind === 'fullcode') {
// /apps_raw/add triggers a full page reload (cross-origin isolation),
// so the in-memory importStore would be lost — use sessionStorage.
sessionStorage.setItem('rawAppImport', JSON.stringify(parsed))
await goto(`${base}/apps_raw/add`)
} else {
$importStore = parsed
await goto(`${base}/apps/add`)
}
appDrawer?.closeDrawer?.()
}
let newItems: Item[] = $derived([
{
displayName: 'Script',
icon: Code2,
href: `${base}/scripts/add`
},
...(HOME_SHOW_CREATE_FLOW
? [{ displayName: 'Flow', icon: BarsStaggered, href: `${base}/flows/add` } as Item]
: []),
...(HOME_SHOW_CREATE_APP
? [
{
displayName: 'App',
icon: LayoutDashboard,
submenuItems: [
{ displayName: 'Low-code app', href: `${base}/apps/add` },
{ displayName: 'Full-code app', href: `${base}/apps_raw/add` }
]
} as Item
]
: []),
{
displayName: 'Workflow-as-Code',
icon: Code2,
submenuItems: [
{ displayName: 'TypeScript', href: `${base}/scripts/add?wac=typescript` },
{ displayName: 'Python', href: `${base}/scripts/add?wac=python` }
]
}
])
let importItems: Item[] = $derived([
{
displayName: 'Import script',
icon: Code2,
action: () => wacDrawer?.openDrawer?.()
},
...(HOME_SHOW_CREATE_FLOW
? [
{
displayName: 'Import flow',
icon: BarsStaggered,
action: () => flowDrawer?.openDrawer?.()
} as Item
]
: []),
...(HOME_SHOW_CREATE_APP
? [
{
displayName: 'Import low-code app',
icon: LayoutDashboard,
action: () => {
appKind = 'lowcode'
appImportType = 'yaml'
appDrawer?.openDrawer?.()
}
} as Item,
{
displayName: 'Import full-code app',
icon: LayoutDashboard,
action: () => {
appKind = 'fullcode'
appImportType = 'yaml'
appDrawer?.openDrawer?.()
}
} as Item
]
: [])
])
</script>
<div class="flex flex-row gap-2 items-center">
<DropdownV2
items={newItems}
placement="bottom-end"
aiId="home-create-new"
aiDescription="Create a new script, flow, app or workflow-as-code"
>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
variant="accent"
unifiedSize="md"
startIcon={{ icon: Plus }}
endIcon={{ icon: ChevronDown }}
>
New
</Button>
{/snippet}
</DropdownV2>
<DropdownV2
items={importItems}
placement="bottom-end"
aiId="home-import"
aiDescription="Import a script, flow or app from YAML/JSON"
>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
variant="default"
unifiedSize="md"
startIcon={{ icon: Download }}
endIcon={{ icon: ChevronDown }}
>
Import
</Button>
{/snippet}
</DropdownV2>
</div>
<!-- Import flow drawer -->
<Drawer bind:this={flowDrawer} size="800px">
<DrawerContent title="Import flow from YAML/JSON" on:close={() => flowDrawer?.closeDrawer?.()}>
<Tabs bind:selected={flowImportType}>
<Tab value="yaml" label="YAML" />
<Tab value="json" label="JSON" />
{#snippet content()}
<div class="relative pt-2 h-full">
{#key flowImportType}
{#await import('$lib/components/SimpleEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:code={pendingFlowRaw}
lang={flowImportType}
class="h-full"
fixedOverflowWidgets={false}
/>
{/await}
{/key}
</div>
{/snippet}
</Tabs>
{#snippet actions()}
<Button size="sm" on:click={importFlowRaw}>Import</Button>
{/snippet}
</DrawerContent>
</Drawer>
<!-- Import workflow-as-code drawer -->
<Drawer bind:this={wacDrawer} size="800px">
<DrawerContent
title="Import script / Workflow-as-Code"
on:close={() => wacDrawer?.closeDrawer?.()}
>
<Tabs bind:selected={wacImportType}>
<Tab value="yaml" label="YAML" />
<Tab value="json" label="JSON" />
{#snippet content()}
<div class="relative pt-2 h-full">
{#key wacImportType}
{#await import('$lib/components/SimpleEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:code={pendingWacRaw}
lang={wacImportType}
class="h-full"
fixedOverflowWidgets={false}
/>
{/await}
{/key}
</div>
{/snippet}
</Tabs>
{#snippet actions()}
<Button size="sm" on:click={importWacRaw}>Import</Button>
{/snippet}
</DrawerContent>
</Drawer>
<!-- Import app drawer -->
<Drawer bind:this={appDrawer} size="800px">
<DrawerContent
title={appKind === 'fullcode' ? 'Import full-code app' : 'Import low-code app'}
on:close={() => appDrawer?.closeDrawer?.()}
>
<Tabs bind:selected={appImportType}>
<Tab value="yaml" label="YAML" />
<Tab value="json" label="JSON" />
{#snippet content()}
<div class="relative pt-2 h-full">
{#key appImportType}
{#await import('$lib/components/SimpleEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:code={pendingAppRaw}
lang={appImportType}
class="h-full"
fixedOverflowWidgets={false}
/>
{/await}
{/key}
</div>
{/snippet}
</Tabs>
{#snippet actions()}
<Button size="sm" on:click={importAppRaw}>Import</Button>
{/snippet}
</DrawerContent>
</Drawer>
@@ -0,0 +1,200 @@
<script lang="ts">
import { base } from '$lib/base'
import { goto } from '$lib/navigation'
import { browser } from '$app/environment'
import { workspaceStore } from '$lib/stores'
import { Button } from '$lib/components/common'
import { Sparkles, ArrowUp, Code2, LayoutDashboard, Clock } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { createSession } from '$lib/components/sessions/sessionState.svelte'
import { getOrCreateRuntime } from '$lib/components/sessions/sessionRuntime.svelte'
import type { LatestItem } from './homeFilter'
let { latest = [] }: { latest?: LatestItem[] } = $props()
const aiEnabled = isGlobalAiEnabled()
let input = $state('')
let submitting = $state(false)
const EXAMPLE_PROMPTS = [
'Create a workflow that triggers on a Discord message, checks for offensive language with an AI agent and possibly blocks them',
'Build an app to browse and edit rows of my Postgres database',
'Sync new Stripe customers into a Google Sheet every hour',
'Summarize incoming support emails and post them to Slack',
'Generate a weekly report from my analytics and email it to the team'
]
const reducedMotion = browser && window.matchMedia('(prefers-reduced-motion: reduce)').matches
let placeholder = $state(reducedMotion ? EXAMPLE_PROMPTS[0] : '')
let promptIndex = $state(0)
// Typewriter placeholder: type a prompt, hold, delete, advance to the next.
// Re-runs whenever promptIndex changes (set when a prompt finishes deleting).
$effect(() => {
if (reducedMotion || !aiEnabled) return
const prompt = EXAMPLE_PROMPTS[promptIndex]
let char = 0
let deleting = false
let timer: ReturnType<typeof setTimeout>
const tick = () => {
if (!deleting) {
char++
placeholder = prompt.slice(0, char)
if (char >= prompt.length) {
deleting = true
timer = setTimeout(tick, 2800)
return
}
timer = setTimeout(tick, 32)
} else {
char--
placeholder = prompt.slice(0, char)
if (char <= 0) {
promptIndex = (promptIndex + 1) % EXAMPLE_PROMPTS.length
return
}
timer = setTimeout(tick, 16)
}
}
timer = setTimeout(tick, 500)
return () => clearTimeout(timer)
})
async function submit() {
const text = input.trim()
if (!text || submitting) return
submitting = true
try {
const session = createSession()
const runtime = getOrCreateRuntime(session)
// Fire the first message (beforeSend materialises + commits the session)
// and navigate immediately so the session page renders the in-flight chat.
void runtime.manager.sendRequest({ instructions: text })
await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`)
} finally {
submitting = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
function hrefFor(it: LatestItem): string {
const ws = $workspaceStore
if (it.type === 'script') {
return it.draft_only
? `${base}/scripts/edit/${it.path}`
: `${base}/scripts/get/${it.hash}?workspace=${ws}`
}
if (it.type === 'flow') {
return it.draft_only
? `${base}/flows/edit/${it.path}`
: `${base}/flows/get/${it.path}?workspace=${ws}`
}
const seg = it.raw_app ? '_raw' : ''
return it.draft_only
? `${base}/apps${seg}/edit/${it.path}`
: `${base}/apps${seg}/get/${it.path}`
}
function relativeTime(t?: number): string {
if (!t) return ''
const diff = Date.now() - t
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d ago`
const months = Math.floor(days / 30)
if (months < 12) return `${months}mo ago`
return `${Math.floor(months / 12)}y ago`
}
const typeColor: Record<LatestItem['type'], string> = {
script: 'var(--color-gray-500, #6b7280)',
flow: '#14b8a6',
app: '#fb923c',
raw_app: '#fb923c'
}
</script>
{#if aiEnabled}
<div class="w-full pt-6 pb-2">
<div
class="relative rounded-2xl border border-border bg-surface shadow-sm focus-within:border-blue-400 dark:focus-within:border-blue-500 transition-colors"
>
<div class="flex items-start gap-3 p-4">
<div class="mt-1 text-blue-500 dark:text-blue-400 shrink-0">
<Sparkles size={20} />
</div>
<textarea
bind:value={input}
onkeydown={onKeydown}
rows={2}
{placeholder}
class="flex-1 resize-none bg-transparent outline-none text-primary placeholder:text-tertiary text-sm leading-relaxed min-h-[3rem] max-h-40"
></textarea>
<div class="shrink-0 self-end">
<Button
variant="accent"
unifiedSize="md"
iconOnly
startIcon={{ icon: ArrowUp }}
disabled={!input.trim() || submitting}
on:click={submit}
aiId="home-ai-new-session"
aiDescription="Start a new AI session from the home chatbox"
/>
</div>
</div>
<div class="px-4 pb-2 -mt-1 text-2xs text-tertiary">
Describe what you want to build — Windmill AI will create it for you. Press Enter to start.
</div>
</div>
</div>
{/if}
{#if latest.length > 0}
<div class="w-full pt-4 pb-2">
<div class="flex items-center gap-2 mb-2 text-secondary">
<Clock size={14} />
<span class="text-xs font-semibold uppercase tracking-wide">Latest edited</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{#each latest as it (it.type + '/' + it.path)}
<a
href={hrefFor(it)}
class="flex items-center gap-3 rounded-md border border-border bg-surface hover:bg-surface-hover transition-colors p-3 min-w-0"
>
<div class="shrink-0" style="color: {typeColor[it.type]}">
{#if it.type === 'script'}
<Code2 size={18} />
{:else if it.type === 'flow'}
<BarsStaggered size={18} />
{:else}
<LayoutDashboard size={18} />
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="text-sm text-primary truncate font-medium">
{it.summary || it.path}
</div>
<div class="text-2xs text-tertiary truncate">{it.path}</div>
</div>
<div class="shrink-0 text-2xs text-tertiary whitespace-nowrap">
{relativeTime(it.time)}
</div>
</a>
{/each}
</div>
</div>
{/if}
+67 -277
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Badge, Button, Skeleton } from '$lib/components/common'
import { Button, Skeleton } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import {
AppService,
@@ -13,47 +13,48 @@
} from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import type uFuzzy from '@leeoniya/ufuzzy'
import {
ChevronsDownUp,
ChevronsUpDown,
Code2,
LayoutDashboard,
ListFilterPlus,
SearchCode,
Tag
} from 'lucide-svelte'
import { HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts'
import { ChevronsDownUp, ChevronsUpDown } from 'lucide-svelte'
import SearchItems from '../SearchItems.svelte'
import ListFilters from './ListFilters.svelte'
import NoItemFound from './NoItemFound.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import FlowIcon from './FlowIcon.svelte'
import { canWrite, getLocalSetting, storeLocalSetting } from '$lib/utils'
import { page } from '$app/state'
import { setQuery } from '$lib/navigation'
import Drawer from '../common/drawer/Drawer.svelte'
import HighlightCode from '../HighlightCode.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import Item from './Item.svelte'
import TreeViewRoot from './TreeViewRoot.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { getContext, tick, untrack } from 'svelte'
import { tick, untrack } from 'svelte'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import TextInput from '../text_input/TextInput.svelte'
import type { HomeFilterValue, LatestItem } from './homeFilter'
interface Props {
filter?: string
subtab?: 'flow' | 'script' | 'app'
filterValue: HomeFilterValue
itemKind?: 'all' | 'script' | 'flow' | 'app'
showEditButtons?: boolean
onMeta?: (meta: {
owners: string[]
labels: string[]
loading: boolean
latest: LatestItem[]
}) => void
}
let {
filter = $bindable(''),
subtab = $bindable('script'),
showEditButtons = true
}: Props = $props()
let { filterValue, itemKind = 'all', showEditButtons = true, onMeta }: Props = $props()
// Filter inputs are owned by the parent toolbar (FilterSearchbar) and read
// from `filterValue`. The list itself stays the source of truth for the
// loaded items, the fuzzy search, and keyboard navigation.
let filter = $derived(filterValue._default_ ?? '')
let ownerFilter = $derived(filterValue.path)
let labelFilters = $derived(
(filterValue.label ?? '')
.split(',')
.map((l) => l.trim())
.filter(Boolean)
)
let archived = $derived(!!filterValue.archived)
let includeWithoutMain = $derived(!filterValue.exclude_library)
let filterUserFolders = $derived(!!filterValue.user_folders_only)
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
canWrite: boolean
@@ -76,10 +77,6 @@
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
let itemKind = $state(
(page.url.searchParams.get('kind') as 'script' | 'flow' | 'app' | 'all') ?? 'all'
)
let loading = $state(true)
let nbDisplayed = $state(15)
@@ -154,9 +151,6 @@
return true // should not happen
}
let ownerFilter: string | undefined = $state(undefined)
let labelFilter: string | undefined = $state(undefined)
const cmp = new Intl.Collator('en').compare
const opts: uFuzzy.Options = {
@@ -210,11 +204,7 @@
}
}
let archived = $state(false)
const TREE_VIEW_SETTING_NAME = 'treeView'
const FILTER_USER_FOLDER_SETTING_NAME = 'filterUserFolders'
const INCLUDE_WITHOUT_MAIN_SETTING_NAME = 'includeWithoutMain'
let treeView = $state(getLocalSetting(TREE_VIEW_SETTING_NAME) == 'true')
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
$userStore?.is_super_admin && $userStore.username.includes('@')
@@ -223,16 +213,6 @@
? 'u/username and f/*'
: undefined
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
let includeWithoutMain = $state(
getLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME)
? getLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME) == 'true'
: true
)
const openSearchWithPrefilledText: (t?: string) => void = getContext(
'openSearchWithPrefilledText'
)
let viewCodeDrawer: Drawer | undefined = $state()
let viewCodeTitle: string | undefined = $state()
@@ -251,11 +231,6 @@
}
let collapseAll = $state(true)
let owners = $derived(
Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
)
$effect(() => {
if ($userStore && $workspaceStore) {
;[archived, includeWithoutMain]
@@ -304,30 +279,35 @@
function itemLabels(x: { labels?: string[]; inherited_labels?: string[] }): string[] {
return [...(x.labels ?? []), ...(x.inherited_labels ?? [])]
}
let owners = $derived(
Array.from(
new Set(combinedItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
)
let allLabels = $derived(
Array.from(new Set(combinedItems?.flatMap((x) => itemLabels(x)) ?? [])).sort()
)
// Latest-edited items for the home hero, by recency only (ignoring the
// starred-first ordering used for the main list).
let latestEdited = $derived(
[...(combinedItems ?? [])]
.sort((a, b) => (b.time ?? 0) - (a.time ?? 0))
.slice(0, 6) as LatestItem[]
)
// Surface the owners/labels/loading/latest the parent toolbar + hero need to
// build the FilterSearchbar schema, presets, and "Latest edited" cards.
$effect(() => {
if ($workspaceStore) {
ownerFilter = undefined
labelFilter = undefined
}
onMeta?.({ owners, labels: allLabels, loading, latest: latestEdited })
})
let preFilteredItems = $derived(
ownerFilter != undefined
? combinedItems?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
(x.type == itemKind || itemKind == 'all') &&
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) &&
(labelFilter == undefined || itemLabels(x).includes(labelFilter))
)
: combinedItems?.filter(
(x) =>
(x.type == itemKind || itemKind == 'all') &&
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) &&
(labelFilter == undefined || itemLabels(x).includes(labelFilter))
)
combinedItems?.filter(
(x) =>
(ownerFilter == undefined || x.path.startsWith(ownerFilter + '/')) &&
(x.type == itemKind || itemKind == 'all') &&
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) &&
(labelFilters.length == 0 || labelFilters.every((l) => itemLabels(x).includes(l)))
)
)
let items = $derived(filter !== '' ? filteredItems : preFilteredItems)
let displayedItems = $derived((items ?? []).slice(0, nbDisplayed))
@@ -348,30 +328,13 @@
firstWorkspaceRun = false
return
}
// On workspace switch, melt-ui restores focus to the workspace-picker trigger
// button asynchronously after the menu closes. Without overriding it, pressing
// an arrow key would re-open / re-highlight the workspace picker instead of
// moving the items-list selection. Run several times to win the focus race.
const focusSearch = () => {
const el = document.getElementById('home-search-input') as HTMLInputElement | null
el?.focus()
}
focusSearch()
const raf1 = requestAnimationFrame(() => {
focusSearch()
requestAnimationFrame(focusSearch)
})
const timeoutId = setTimeout(focusSearch, 100)
return () => {
cancelAnimationFrame(raf1)
clearTimeout(timeoutId)
}
})
$effect(() => {
filter
itemKind
ownerFilter
labelFilter
labelFilters
archived
// Skip while pendingAutoSelect is true (initial load / workspace switch);
// the auto-select effect below will set the index once items appear.
if (!pendingAutoSelect) {
@@ -419,7 +382,7 @@
const target = e.target as HTMLElement | null
// When focus is inside a row's action buttons, handle arrow keys ourselves:
// - Left/Right cycle between buttons (Left from the first returns to search).
// - Left/Right cycle between buttons.
// - Up/Down move to the same-position button on the previous/next row.
// All other keys pass through so Enter/Space activate the focused button normally.
// This must run BEFORE the skipSelector check, since the dropdown ellipsis
@@ -447,8 +410,6 @@
e.preventDefault()
if (currentIdx > 0) {
buttons[currentIdx - 1].focus()
} else {
;(document.getElementById('home-search-input') as HTMLInputElement | null)?.focus()
}
} else {
// ArrowUp / ArrowDown: move to same-position button on prev/next row.
@@ -501,20 +462,14 @@
const tag = target.tagName
const isEditable =
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
const isOurSearch = target.id === 'home-search-input'
if (isEditable && !isOurSearch) return
if (isEditable) return
if (target.closest(skipSelector)) return
}
const active = document.activeElement as HTMLElement | null
if (active?.closest(skipSelector)) return
// ArrowRight from search input / body → focus first action button of selected row.
// Guard: if cursor is in the middle of typed search text, let the cursor move.
// ArrowRight from body → focus first action button of selected row.
if (e.key === 'ArrowRight') {
if (target?.id === 'home-search-input') {
const inp = target as HTMLInputElement
if (inp.value.length > 0 && inp.selectionEnd !== inp.value.length) return
}
if (selectedIndex < 0 || selectedIndex >= displayedItems.length) return
const buttons = getSelectedRowActionButtons()
if (buttons.length > 0) {
@@ -523,14 +478,6 @@
}
return
}
// ArrowLeft from search input with cursor at start: no-op (let default handle).
if (e.key === 'ArrowLeft') {
if (target?.id === 'home-search-input') {
const inp = target as HTMLInputElement
if (inp.value.length > 0 && inp.selectionStart !== 0) return
}
return
}
if (e.key === 'ArrowDown') {
if (displayedItems.length === 0) return
@@ -579,12 +526,13 @@
$effect(() => {
storeLocalSetting(TREE_VIEW_SETTING_NAME, treeView ? 'true' : undefined)
})
$effect(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
$effect(() => {
storeLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME, includeWithoutMain ? 'true' : undefined)
})
function reloadAll() {
loadScripts(includeWithoutMain)
loadFlows()
loadApps()
loadRawApps()
}
</script>
<SearchItems
@@ -615,161 +563,13 @@
<CenteredPage wrapperClasses="w-full" handleOverflow={false}>
<div
class="flex flex-wrap gap-2 items-center justify-between w-full"
use:triggerableByAI={{
id: 'home-items-list',
description: 'Lists of scripts, flows, and apps'
}}
>
<div class="flex justify-start">
<ToggleButtonGroup
bind:selected={itemKind}
onSelected={(v) => {
if (itemKind != 'all') {
subtab = v
}
setQuery(page.url, 'kind', v)
}}
>
{#snippet children({ item })}
<ToggleButton value="all" label="All" size="md" {item} />
<ToggleButton value="script" icon={Code2} label="Scripts" size="md" {item} />
{#if HOME_SEARCH_SHOW_FLOW}
<ToggleButton
value="flow"
label="Flows"
icon={FlowIcon}
selectedColor="#14b8a6"
size="md"
{item}
/>
{/if}
<ToggleButton
value="app"
label="Apps"
icon={LayoutDashboard}
selectedColor="#fb923c"
size="md"
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
<div class="relative text-primary grow min-w-[100px]">
<!-- svelte-ignore a11y_autofocus -->
<TextInput
inputProps={{
autofocus: true,
placeholder: HOME_SEARCH_PLACEHOLDER,
id: 'home-search-input'
}}
size="md"
bind:value={filter}
class="!pr-10"
/>
<button aria-label="Search" type="submit" class="absolute right-0 top-0 mt-2 mr-4">
<svg
class="h-4 w-4 fill-current"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
id="Capa_1"
x="0px"
y="0px"
viewBox="0 0 56.966 56.966"
style="enable-background:new 0 0 56.966 56.966;"
xml:space="preserve"
width="512px"
height="512px"
>
<path
d="M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23 s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92 c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17 s-17-7.626-17-17S14.61,6,23.984,6z"
/>
</svg>
</button>
</div>
<Button
on:click={() => openSearchWithPrefilledText('#')}
variant="default"
unifiedSize="md"
endIcon={{
icon: SearchCode
}}
>
Content
</Button>
</div>
<div class="relative">
<ListFilters
syncQuery
bind:selectedFilter={ownerFilter}
filters={owners}
bottomMargin={false}
/>
{#if allLabels.length > 0}
<div class="gap-1.5 w-full flex flex-wrap mt-2">
{#each allLabels as label (label)}
<Badge
color="blue"
small
clickable
selected={label === labelFilter}
title="Label: {label}"
onclick={() => {
labelFilter = labelFilter === label ? undefined : label
}}
>
<Tag size={10} class="inline -mt-px" />{label}
{#if label === labelFilter}&cross;{/if}
</Badge>
{/each}
</div>
{/if}
{#if filteredItems?.length == 0}
<div class="mt-10"></div>
{/if}
{#if !loading}
<div class="flex w-full flex-row-reverse gap-2 mt-2 mb-1 items-center h-6">
<Popover floatingConfig={{ placement: 'bottom-end' }}>
{#snippet trigger()}
<Button
startIcon={{
icon: ListFilterPlus
}}
nonCaptureEvent
iconOnly
size="xs"
color="light"
variant="default"
spacingSize="xs2"
/>
{/snippet}
{#snippet content()}
<div class="p-4">
<span class="text-sm font-semibold text-emphasis">Filters</span>
<div class="flex flex-col gap-2 mt-2">
<Toggle size="xs" bind:checked={archived} options={{ right: 'Only archived' }} />
{#if $userStore && !$userStore.operator}
<Toggle
size="xs"
bind:checked={includeWithoutMain}
options={{ right: 'Include library scripts' }}
/>
{/if}
</div>
</div>
{/snippet}
</Popover>
{#if filterUserFoldersType === 'only f/*'}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if filterUserFoldersType === 'u/username and f/*'}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore?.username} and f/*` }}
/>
{/if}
<div class="flex w-full flex-row-reverse gap-2 mb-1 items-center h-6">
<Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} />
{#if treeView}
<Button
@@ -809,12 +609,7 @@
on:flowChanged={loadFlows}
on:appChanged={loadApps}
on:rawAppChanged={loadRawApps}
on:reload={() => {
loadScripts(includeWithoutMain)
loadFlows()
loadApps()
loadRawApps()
}}
on:reload={reloadAll}
{showCode}
/>
{:else}
@@ -826,12 +621,7 @@
on:flowChanged={loadFlows}
on:appChanged={loadApps}
on:rawAppChanged={loadRawApps}
on:reload={() => {
loadScripts(includeWithoutMain)
loadFlows()
loadApps()
loadRawApps()
}}
on:reload={reloadAll}
{showCode}
showEditButton={showEditButtons}
keyboardSelected={selectedIndex === i}
@@ -0,0 +1,77 @@
import { Folder, Tag, Archive, Library, Users } from 'lucide-svelte'
import type { FilterSchemaRec, FilterInstanceRec } from '../FilterSearchbar.svelte'
export function buildHomeFilterSchema({
owners,
labels,
showUserFoldersFilter,
userFoldersLabel
}: {
owners: string[]
labels: string[]
showUserFoldersFilter?: boolean
userFoldersLabel?: string
}) {
return {
_default_: {
type: 'string' as const,
hidden: true
},
path: {
type: 'oneof' as const,
options: owners.map((o) => ({ label: o, value: o })),
allowCustomValue: true,
allowNegative: false,
allowMultiple: false,
label: 'Owner / folder',
icon: Folder,
description: 'Filter by owner or folder prefix (e.g. u/alice or f/team)'
},
label: {
type: 'oneof' as const,
options: labels.map((l) => ({ label: l, value: l })),
allowNegative: false,
allowMultiple: true,
label: 'Label',
icon: Tag,
description: 'Filter by label (comma-separated to require multiple)'
},
archived: {
type: 'boolean' as const,
label: 'Only archived',
icon: Archive,
description: 'Show only archived items'
},
exclude_library: {
type: 'boolean' as const,
label: 'Exclude library scripts',
icon: Library,
description: 'Hide scripts without a main function'
},
...(showUserFoldersFilter
? {
user_folders_only: {
type: 'boolean' as const,
label: userFoldersLabel || 'User folders only',
icon: Users,
description: 'Show only items in user folders'
}
}
: {})
} satisfies FilterSchemaRec
}
export type HomeFilterSchema = ReturnType<typeof buildHomeFilterSchema>
export type HomeFilterValue = Partial<FilterInstanceRec<HomeFilterSchema>>
export type LatestItem = {
type: 'script' | 'flow' | 'app' | 'raw_app'
path: string
summary?: string
time?: number
starred?: boolean
hash?: string
draft_only?: boolean
raw_app?: boolean
workspace_id?: string
}
+145 -104
View File
@@ -1,13 +1,17 @@
<script lang="ts">
import { AppService, FlowService, type OpenFlow, type Script } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { Alert, Button, Drawer, DrawerContent, Tab, Tabs } from '$lib/components/common'
import { Alert, Button, Drawer, DrawerContent } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import FlowIcon from '$lib/components/home/FlowIcon.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import CreateActionsFlow from '$lib/components/flows/CreateActionsFlow.svelte'
import CreateActionsScript from '$lib/components/scripts/CreateActionsScript.svelte'
import HomeCreateActions from '$lib/components/home/HomeCreateActions.svelte'
import HomeHero from '$lib/components/home/HomeHero.svelte'
import FilterSearchbar, {
useUrlSyncedFilterInstance
} from '$lib/components/FilterSearchbar.svelte'
import { buildHomeFilterSchema, type LatestItem } from '$lib/components/home/homeFilter'
import { getScriptByPath } from '$lib/scripts'
import type { HubItem } from '$lib/components/flows/pickers/model'
import PickHubScript from '$lib/components/flows/pickers/PickHubScript.svelte'
@@ -20,32 +24,30 @@
GitFork,
Globe2,
Loader2,
Code,
Code2,
LayoutDashboard,
PlugZap
PlugZap,
SearchCode
} from 'lucide-svelte'
import { hubBaseUrlStore } from '$lib/stores'
import { base } from '$lib/base'
import ItemsList from '$lib/components/home/ItemsList.svelte'
import CreateActionsApp from '$lib/components/flows/CreateActionsApp.svelte'
import PickHubApp from '$lib/components/flows/pickers/PickHubApp.svelte'
import { writable } from 'svelte/store'
import type { EditorBreakpoint } from '$lib/components/apps/types'
import { HOME_SHOW_HUB, HOME_SHOW_CREATE_FLOW, HOME_SHOW_CREATE_APP } from '$lib/consts'
import { HOME_SHOW_HUB } from '$lib/consts'
import { setQuery } from '$lib/navigation'
import { page } from '$app/state'
import { goto, replaceState } from '$app/navigation'
import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte'
import WorkspaceDraftsBanner from '$lib/components/WorkspaceDraftsBanner.svelte'
import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte'
import { onMount, setContext } from 'svelte'
import { getContext, onMount, setContext, untrack } from 'svelte'
import { tutorialsToDo } from '$lib/stores'
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
import TutorialBanner from '$lib/components/home/TutorialBanner.svelte'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
import { z } from 'zod'
type Tab = 'hub' | 'workspace'
@@ -55,11 +57,53 @@
: 'workspace'
)
let subtab: 'flow' | 'script' | 'app' = $state('script')
function selectTab(t: Tab) {
tab = t
if (typeof window !== 'undefined') window.location.hash = t
}
const searchParams = useSearchParams(z.object({ search: z.string().nullable() }))
const getFilter = () => searchParams.search ?? ''
const setFilter = (v: string) => (searchParams.search = v === '' ? null : v)
// Unified item-kind toggle (All / Scripts / Flows / Apps). Drives workspace
// filtering and, for the Hub tab, which picker is shown ('all' → scripts).
let itemKind: 'all' | 'script' | 'flow' | 'app' = $state(
(page.url.searchParams.get('kind') as 'all' | 'script' | 'flow' | 'app') ?? 'all'
)
let hubKind = $derived(itemKind === 'all' ? 'script' : itemKind)
// Meta surfaced by ItemsList (owners/labels for the filter schema, latest for
// the hero). Empty until the workspace list has loaded.
let homeMeta = $state<{
owners: string[]
labels: string[]
loading: boolean
latest: LatestItem[]
}>({ owners: [], labels: [], loading: true, latest: [] })
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
$userStore?.is_super_admin && $userStore.username.includes('@')
? 'only f/*'
: $userStore?.is_admin || $userStore?.is_super_admin
? 'u/username and f/*'
: undefined
)
let homeFilterSchema = $derived(
buildHomeFilterSchema({
owners: homeMeta.owners,
labels: homeMeta.labels,
showUserFoldersFilter: filterUserFoldersType !== undefined,
userFoldersLabel:
filterUserFoldersType === 'only f/*' ? 'Only f/*' : `Only u/${$userStore?.username} and f/*`
})
)
let filters = useUrlSyncedFilterInstance(untrack(() => homeFilterSchema))
const getFilter = () => filters.val._default_ ?? ''
const setFilter = (v: string) => (filters.val._default_ = v === '' ? undefined : v)
let presets = $derived([
...homeMeta.owners.map((o) => ({ name: o, value: `path:\\ ${o}` })),
...homeMeta.labels.map((l) => ({ name: l, value: `label:\\ ${l}` }))
])
let flowViewer: Drawer | undefined = $state(undefined)
let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined = $state(undefined)
@@ -117,6 +161,10 @@
let showCreateButtons = $state(false)
const openSearchWithPrefilledText: (t?: string) => void = getContext(
'openSearchWithPrefilledText'
)
onMount(() => {
// Check if there's a tutorial parameter in the URL
const tutorialParam = page.url.searchParams.get('tutorial')
@@ -293,21 +341,17 @@
title="Home"
childrenWrapperDivClasses="flex-1 flex flex-row gap-4 flex-wrap justify-end items-center"
>
{#if $userStore?.operator}
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: PlugZap }}
btnClasses="whitespace-nowrap"
onClick={() => homeConnectDrawer?.openDrawer?.()}
>
CLI / MCP
</Button>
{/if}
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: PlugZap }}
btnClasses="whitespace-nowrap"
onClick={() => homeConnectDrawer?.openDrawer?.()}
>
CLI / MCP
</Button>
{#if !$userStore?.operator && showCreateButtons}
<CreateActionsScript aiId="create-script-button" aiDescription="Creates a new script" />
{#if HOME_SHOW_CREATE_FLOW}<CreateActionsFlow />{/if}
{#if HOME_SHOW_CREATE_APP}<CreateActionsApp />{/if}
<HomeCreateActions />
{/if}
</PageHeader>
@@ -315,97 +359,89 @@
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
{#if !$userStore?.operator}
<div class="flex w-full items-center gap-3 pb-2">
<div class="min-w-0 flex-1 overflow-auto scrollbar-hidden">
<Tabs values={['hub', 'workspace']} hashNavigation bind:selected={tab}>
<Tab value="workspace" label="Workspace" icon={Building} />
{#if HOME_SHOW_HUB}
<Tab value="hub" label="Hub" icon={Globe2} />
{/if}
</Tabs>
</div>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: PlugZap }}
btnClasses="whitespace-nowrap shrink-0"
onClick={() => homeConnectDrawer?.openDrawer?.()}
>
CLI / MCP
</Button>
</div>
{#if tab == 'workspace'}
<HomeHero latest={homeMeta.latest} />
{/if}
<!-- Unified toolbar: Workspace/Hub + kind toggles + filter searchbar -->
<div class="flex flex-wrap items-center gap-2 w-full pt-3 pb-2">
{#if !$userStore?.operator && HOME_SHOW_HUB}
<ToggleButtonGroup selected={tab} onSelected={(v) => selectTab(v as Tab)} noWFull>
{#snippet children({ item })}
<ToggleButton value="workspace" label="Workspace" icon={Building} {item} />
<ToggleButton value="hub" label="Hub" icon={Globe2} {item} />
{/snippet}
</ToggleButtonGroup>
{/if}
<ToggleButtonGroup
bind:selected={itemKind}
onSelected={(v) => setQuery(page.url, 'kind', v, window.location.hash)}
noWFull
>
{#snippet children({ item })}
<ToggleButton value="all" label="All" {item} />
<ToggleButton value="script" label="Scripts" icon={Code2} {item} />
<ToggleButton value="flow" label="Flows" icon={FlowIcon} selectedColor="#14b8a6" {item} />
<ToggleButton
value="app"
label="Apps"
icon={LayoutDashboard}
selectedColor="#fb923c"
{item}
/>
{/snippet}
</ToggleButtonGroup>
<div class="grow min-w-[12rem]">
<FilterSearchbar
schema={homeFilterSchema}
bind:value={filters.val}
{presets}
placeholder="Search scripts, flows & apps..."
/>
</div>
<Button
onClick={() => openSearchWithPrefilledText?.('#')}
variant="default"
unifiedSize="md"
endIcon={{ icon: SearchCode }}
btnClasses="whitespace-nowrap"
>
Content
</Button>
{#if tab == 'hub'}
<Button
startIcon={{ icon: ExternalLink }}
target="_blank"
href={$hubBaseUrlStore}
variant="default"
unifiedSize="md"
>
Hub
</Button>
{/if}
</div>
{#if tab == 'hub'}
<div class="flex flex-col gap-y-16">
<div class="flex flex-col pb-8">
{#snippet toggleKinds()}
<ToggleButtonGroup
bind:selected={subtab}
onSelected={(v) => {
setQuery(page.url, 'kind', v, window.location.hash)
}}
noWFull
>
{#snippet children({ item })}
<ToggleButton value="script" label="Scripts" icon={Code} {item} />
<ToggleButton
value="flow"
label="Flows"
icon={FlowIcon}
selectedColor="#14b8a6"
{item}
/>
<ToggleButton
value="app"
label="Apps"
icon={LayoutDashboard}
selectedColor="#fb923c"
{item}
/>
{/snippet}
</ToggleButtonGroup>
<Button
startIcon={{ icon: ExternalLink }}
target="_blank"
href={$hubBaseUrlStore}
variant="default"
>
Hub
</Button>
{/snippet}
{#if subtab == 'script'}
{#if hubKind == 'script'}
<PickHubScript
syncQuery
bind:filter={getFilter, setFilter}
on:pick={(e) => viewCode(e.detail)}
>
{#snippet children()}
{@render toggleKinds?.()}
{/snippet}
</PickHubScript>
{:else if subtab == 'flow'}
/>
{:else if hubKind == 'flow'}
<PickHubFlow
syncQuery
bind:filter={getFilter, setFilter}
on:pick={(e) => viewFlow(e.detail)}
>
{#snippet children()}
{@render toggleKinds?.()}
{/snippet}
</PickHubFlow>
{:else if subtab == 'app'}
/>
{:else if hubKind == 'app'}
<PickHubApp
syncQuery
bind:filter={getFilter, setFilter}
on:pick={(e) => viewApp(e.detail)}
>
{#snippet children()}
{@render toggleKinds?.()}
{/snippet}
</PickHubApp>
/>
{/if}
</div>
</div>
@@ -413,7 +449,12 @@
</div>
{#if tab == 'workspace'}
<ItemsList bind:filter={getFilter, setFilter} bind:subtab showEditButtons={showCreateButtons} />
<ItemsList
filterValue={filters.val}
{itemKind}
showEditButtons={showCreateButtons}
onMeta={(m) => (homeMeta = m)}
/>
{/if}
</div>