mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 16:02:14 +00:00
2f50e8bab0
* feat(ai-chat): align footer bar, use DropdownV2 for mode/autonomy selectors Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dropdown): add `selected` item prop rendering a trailing check Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(ai-chat): add small spacing between chat input and footer bar Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ai-chat): always offer the 3 autonomy options in the auto-accept picker Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai-chat): default autonomy mode to auto-accept on Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(ai-chat): use Button component for footer dropdown triggers Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(ai-chat): use a hand icon for the auto-accept-off autonomy state Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(ai-chat): use subtle Button variant for mode and model selectors Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(ai-chat): tighten spacing between input and footer bar Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ai-chat): reword autonomy levels as ask/auto-accept/bypass permissions Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(button): add 2xs unified size with tighter padding Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ai-chat): compact footer bar — 2xs buttons, AtSign context icon, short Yolo label, discreet model Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(ai-chat): widen the permission selector dropdown Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dropdown): group shortcut + selected check to avoid ml-auto collision Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(ai-chat): cover getPersistedAutonomyMode default; clarify default comment Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
382 lines
11 KiB
Svelte
382 lines
11 KiB
Svelte
<script module lang="ts">
|
|
import type { ButtonType } from '$lib/components/common/button/model'
|
|
import { PathAutocompleteService } from '$lib/gen'
|
|
|
|
type InputSize = ButtonType.UnifiedSize
|
|
|
|
export type AutocompleteSegment = { name: string; is_folder: boolean }
|
|
|
|
/** Client-side TTL for the workspace path list (mirrors backend cache). */
|
|
const PATH_LIST_TTL_MS = 60_000
|
|
|
|
type Entry = {
|
|
at: number
|
|
paths: string[] | null
|
|
pending: Promise<string[]> | null
|
|
}
|
|
|
|
/** Module-level shared cache so multiple PathNameAutocomplete instances on
|
|
* the same page reuse a single fetch per workspace. */
|
|
const pathListCache = new Map<string, Entry>()
|
|
|
|
/** Workspaces whose next fetch must bypass the server-side cache. Set by
|
|
* invalidateWorkspacePaths (e.g. after a deploy) and cleared once a forced
|
|
* fetch succeeds, so a just-created path shows up immediately instead of
|
|
* after the backend's 60s TTL. */
|
|
const forceNextFetch = new Set<string>()
|
|
|
|
export async function fetchWorkspacePaths(workspace: string): Promise<string[]> {
|
|
const force = forceNextFetch.has(workspace)
|
|
const now = Date.now()
|
|
const existing = pathListCache.get(workspace)
|
|
// When forcing, ignore any cached/in-flight entry — it may predate the
|
|
// deploy (or have been written by a fetch that hit the stale backend
|
|
// cache) and would otherwise mask the new path.
|
|
if (existing && !force) {
|
|
if (existing.paths && now - existing.at < PATH_LIST_TTL_MS) return existing.paths
|
|
if (existing.pending) return existing.pending
|
|
}
|
|
const pending = (async () => {
|
|
try {
|
|
const res = await PathAutocompleteService.listPathAutocompletePaths({ workspace, force })
|
|
const paths = res.paths ?? []
|
|
pathListCache.set(workspace, { at: Date.now(), paths, pending: null })
|
|
// Only clear the force flag once a forced fetch has actually
|
|
// landed fresh data, so a failed retry still forces.
|
|
forceNextFetch.delete(workspace)
|
|
return paths
|
|
} catch (_e) {
|
|
pathListCache.delete(workspace)
|
|
return []
|
|
}
|
|
})()
|
|
pathListCache.set(workspace, { at: now, paths: null, pending })
|
|
return pending
|
|
}
|
|
|
|
export function invalidateWorkspacePaths(workspace: string) {
|
|
pathListCache.delete(workspace)
|
|
forceNextFetch.add(workspace)
|
|
}
|
|
|
|
/** Derive the set of path segments that exist directly under a given folder
|
|
* prefix, from the flat list of all workspace paths. */
|
|
export function segmentsUnderPrefix(
|
|
paths: string[],
|
|
folderPrefix: string
|
|
): AutocompleteSegment[] {
|
|
const seen = new Map<string, boolean>()
|
|
const prefixLen = folderPrefix.length
|
|
for (const p of paths) {
|
|
if (!p.startsWith(folderPrefix)) continue
|
|
const remainder = p.slice(prefixLen)
|
|
if (remainder.length === 0) continue
|
|
const slash = remainder.indexOf('/')
|
|
if (slash >= 0) {
|
|
seen.set(remainder.slice(0, slash), true)
|
|
} else if (!seen.has(remainder)) {
|
|
seen.set(remainder, false)
|
|
}
|
|
}
|
|
return Array.from(seen, ([name, is_folder]) => ({ name, is_folder })).sort((a, b) =>
|
|
a.name.localeCompare(b.name)
|
|
)
|
|
}
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
|
import Badge from '$lib/components/common/badge/Badge.svelte'
|
|
import { workspaceStore } from '$lib/stores'
|
|
import { untrack } from 'svelte'
|
|
|
|
type Props = {
|
|
prefix: string
|
|
value?: string
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
autofocus?: boolean
|
|
id?: string
|
|
size?: InputSize
|
|
error?: string | boolean
|
|
textInputClass?: string
|
|
onkeyup?: (e: KeyboardEvent) => void
|
|
}
|
|
|
|
let {
|
|
prefix,
|
|
value = $bindable(),
|
|
placeholder = '',
|
|
disabled = false,
|
|
autofocus = false,
|
|
id,
|
|
size = 'md',
|
|
error,
|
|
textInputClass,
|
|
onkeyup
|
|
}: Props = $props()
|
|
|
|
let inputEl: TextInput | undefined = $state(undefined)
|
|
export function focus() {
|
|
inputEl?.focus()
|
|
}
|
|
|
|
let allPaths = $state<string[]>([])
|
|
let cycleMode = $state<{
|
|
cyclePrefix: string
|
|
options: AutocompleteSegment[]
|
|
index: number
|
|
} | null>(null)
|
|
let dismissedFor: string | undefined = $state(undefined)
|
|
let hasFocus = $state(false)
|
|
// Flag set when keydown consumes Enter for ghost-text acceptance.
|
|
// Checked in keyup to suppress Path.svelte's 'enter' dispatch.
|
|
let enterConsumed = false
|
|
|
|
let currentSegment = $derived.by(() => {
|
|
const v = value ?? ''
|
|
const idx = v.lastIndexOf('/')
|
|
return idx >= 0 ? v.slice(idx + 1) : v
|
|
})
|
|
|
|
let folderPrefix = $derived.by(() => {
|
|
const v = value ?? ''
|
|
const idx = v.lastIndexOf('/')
|
|
return prefix + (idx >= 0 ? v.slice(0, idx + 1) : '')
|
|
})
|
|
|
|
let fullPrefix = $derived(prefix + (value ?? ''))
|
|
|
|
let derivedFolderMatches = $derived.by(() => {
|
|
if (allPaths.length === 0) return []
|
|
return segmentsUnderPrefix(allPaths, folderPrefix).filter(
|
|
(s) => s.is_folder && s.name.startsWith(currentSegment)
|
|
)
|
|
})
|
|
|
|
let displayedOptions = $derived(cycleMode ? cycleMode.options : derivedFolderMatches)
|
|
let displayedActiveIndex = $derived(cycleMode ? cycleMode.index : -1)
|
|
|
|
// Ghost text: longest common prefix across all matching folder names,
|
|
// beyond what the user has already typed. Shows the unambiguous part
|
|
// the user can accept with Enter (multi-match) or Tab (single match).
|
|
let ghostText = $derived.by(() => {
|
|
if (cycleMode) return ''
|
|
if (derivedFolderMatches.length === 0) return ''
|
|
const names = derivedFolderMatches.map((s) => s.name)
|
|
// Compute LCP of all matching folder names
|
|
let lcp = names[0]
|
|
for (let i = 1; i < names.length; i++) {
|
|
let j = 0
|
|
while (j < lcp.length && j < names[i].length && lcp[j] === names[i][j]) j++
|
|
lcp = lcp.slice(0, j)
|
|
}
|
|
if (lcp.length <= currentSegment.length) return ''
|
|
const completion = lcp.slice(currentSegment.length)
|
|
// Append '/' only when the LCP fully resolves to one folder name
|
|
const trailingSlash = derivedFolderMatches.length === 1 ? '/' : ''
|
|
return completion + trailingSlash
|
|
})
|
|
|
|
let showList = $derived(
|
|
!disabled && dismissedFor !== fullPrefix && hasFocus && displayedOptions.length > 0
|
|
)
|
|
|
|
function applyCycleOrComplete(direction: 1 | -1): boolean {
|
|
if (cycleMode) {
|
|
const n = cycleMode.options.length
|
|
const nextIdx = (cycleMode.index + direction + n) % n
|
|
cycleMode = { ...cycleMode, index: nextIdx }
|
|
value = cycleMode.cyclePrefix + cycleMode.options[nextIdx].name + '/'
|
|
return true
|
|
}
|
|
if (derivedFolderMatches.length === 0) return false
|
|
const opts = [...derivedFolderMatches]
|
|
const vNow = value ?? ''
|
|
const slash = vNow.lastIndexOf('/')
|
|
const cyclePrefix = slash >= 0 ? vNow.slice(0, slash + 1) : ''
|
|
if (opts.length === 1) {
|
|
value = cyclePrefix + opts[0].name + '/'
|
|
return true
|
|
}
|
|
const idx = direction === 1 ? 0 : opts.length - 1
|
|
cycleMode = { cyclePrefix, options: opts, index: idx }
|
|
value = cyclePrefix + opts[idx].name + '/'
|
|
return true
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
const dir: 1 | -1 = e.shiftKey ? -1 : 1
|
|
if (applyCycleOrComplete(dir)) {
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
return
|
|
}
|
|
// Enter picks the currently highlighted folder (from Tab cycling)
|
|
// or the first match, and navigates into it.
|
|
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
|
|
let pick: AutocompleteSegment | undefined
|
|
let committed = ''
|
|
if (cycleMode) {
|
|
pick = cycleMode.options[cycleMode.index]
|
|
committed = cycleMode.cyclePrefix
|
|
} else if (derivedFolderMatches.length > 0) {
|
|
pick = derivedFolderMatches[0]
|
|
const vNow = value ?? ''
|
|
const slash = vNow.lastIndexOf('/')
|
|
committed = slash >= 0 ? vNow.slice(0, slash + 1) : ''
|
|
}
|
|
if (pick) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
enterConsumed = true
|
|
value = committed + pick.name + '/'
|
|
cycleMode = null
|
|
return
|
|
}
|
|
}
|
|
if (e.key === 'Escape' && (cycleMode || showList || ghostText)) {
|
|
e.stopPropagation()
|
|
cycleMode = null
|
|
dismissedFor = fullPrefix
|
|
return
|
|
}
|
|
}
|
|
|
|
function selectOption(opt: AutocompleteSegment) {
|
|
const vNow = value ?? ''
|
|
const slash = vNow.lastIndexOf('/')
|
|
const valueCommitted = slash >= 0 ? vNow.slice(0, slash + 1) : ''
|
|
value = valueCommitted + opt.name + '/'
|
|
cycleMode = null
|
|
|
|
inputEl?.focus()
|
|
}
|
|
|
|
async function loadPaths(workspace: string) {
|
|
const paths = await fetchWorkspacePaths(workspace)
|
|
// Guard against workspace changing during the in-flight fetch.
|
|
if ($workspaceStore === workspace) allPaths = paths
|
|
}
|
|
|
|
$effect(() => {
|
|
const ws = $workspaceStore
|
|
if (ws) void loadPaths(ws)
|
|
})
|
|
|
|
$effect(() => {
|
|
void value
|
|
void prefix
|
|
// Re-arm after the dismissed prefix is left.
|
|
if (untrack(() => dismissedFor) !== undefined) {
|
|
const curr = untrack(() => fullPrefix)
|
|
if (untrack(() => dismissedFor) !== curr) dismissedFor = undefined
|
|
}
|
|
// Exit cycle mode if the value no longer matches the cycled option.
|
|
const cm = untrack(() => cycleMode)
|
|
if (cm) {
|
|
const expected = cm.cyclePrefix + cm.options[cm.index].name + '/'
|
|
if ((value ?? '') !== expected) cycleMode = null
|
|
}
|
|
})
|
|
|
|
const overlayPaddingX: Record<InputSize, string> = {
|
|
'2xs': 'px-1',
|
|
xs: 'px-1',
|
|
sm: 'px-2',
|
|
md: 'px-2',
|
|
lg: 'px-2'
|
|
}
|
|
const overlayHeight: Record<InputSize, string> = {
|
|
'2xs': 'h-5',
|
|
xs: 'h-5',
|
|
sm: 'h-7',
|
|
md: 'h-8',
|
|
lg: 'h-10'
|
|
}
|
|
|
|
function onInputFocus() {
|
|
hasFocus = true
|
|
// Opportunistic refresh if the cache is stale.
|
|
if ($workspaceStore) void loadPaths($workspaceStore)
|
|
}
|
|
function onInputBlur() {
|
|
setTimeout(() => {
|
|
hasFocus = false
|
|
}, 150)
|
|
}
|
|
</script>
|
|
|
|
<div class="relative w-full">
|
|
<div class="relative w-full">
|
|
<TextInput
|
|
bind:this={inputEl}
|
|
bind:value
|
|
{size}
|
|
{error}
|
|
class={textInputClass}
|
|
inputProps={{
|
|
disabled,
|
|
type: 'text',
|
|
id,
|
|
autofocus,
|
|
autocomplete: 'off',
|
|
spellcheck: false,
|
|
placeholder: showList || ghostText ? '' : placeholder,
|
|
onkeydown: handleKeydown,
|
|
onkeyup: (e: KeyboardEvent) => {
|
|
if (e.key === 'Enter' && enterConsumed) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
enterConsumed = false
|
|
return
|
|
}
|
|
onkeyup?.(e)
|
|
},
|
|
onfocus: onInputFocus,
|
|
onblur: onInputBlur
|
|
}}
|
|
/>
|
|
{#if ghostText && hasFocus && !cycleMode}
|
|
<div
|
|
class="pointer-events-none absolute inset-0 flex items-center overflow-hidden whitespace-pre font-normal text-xs text-primary {overlayPaddingX[
|
|
size
|
|
]} {overlayHeight[size]}"
|
|
aria-hidden="true"
|
|
>
|
|
<span class="invisible">{value ?? ''}</span>
|
|
<span class="text-tertiary/70">{ghostText}</span>
|
|
<span
|
|
class="ml-1.5 px-1 py-0 rounded border border-border-light text-[10px] text-tertiary bg-surface-secondary"
|
|
>
|
|
Enter
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if showList}
|
|
<div
|
|
class="absolute left-0 top-full z-10 mt-1 flex max-w-full flex-wrap items-center gap-1 rounded border border-border-light bg-surface px-1.5 py-1 shadow-sm"
|
|
>
|
|
<span class="text-[10px] text-tertiary mr-0.5">
|
|
{cycleMode ? 'Tab to cycle' : 'Tab'}
|
|
</span>
|
|
{#each displayedOptions as opt, i (opt.name)}
|
|
<Badge
|
|
clickable
|
|
selected={i === displayedActiveIndex}
|
|
class="font-mono transition-colors"
|
|
type="button"
|
|
tabindex={-1}
|
|
onmousedown={(e: MouseEvent) => e.preventDefault()}
|
|
onclick={() => selectOption(opt)}
|
|
>
|
|
{opt.name}/
|
|
</Badge>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|