diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 4217c95831..f7f9b53972 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -426,3 +426,46 @@ input[type='time']::-webkit-calendar-picker-indicator { .svelte-flow__edges { z-index: -10; } + +/* The composer's glide, defined once: the Home ↔ AI Sessions view transition below and the + in-page move on a session's first message (the spacer under AIChatDisplay's composer) both + read it. */ +:root { + --composer-glide-duration: 380ms; + --composer-glide-easing: cubic-bezier(0.2, 0.8, 0.2, 1); +} + +/* Home ↔ AI Sessions (homeSessionsTransition.svelte.ts). The composer glides from where it + was to where it lands; everything else, Home's list and the session's greeting included, is + the page cross-fading, kept short. The pseudo-elements hang off :root itself, so no space + before `::view-transition-*`: with one, the selector looks for a descendant and never + matches. */ +:root[data-home-sessions-transition]::view-transition-group(composer) { + animation-duration: var(--composer-glide-duration); + animation-timing-function: var(--composer-glide-easing); +} +:root[data-home-sessions-transition]::view-transition-old(root), +:root[data-home-sessions-transition]::view-transition-new(root) { + animation-duration: 150ms; +} +/* The session's greeting sits above where the composer lands, so it waits for the composer: + shown while the composer is still gliding, it reads as text placed in mid-air. It starts as + the composer settles and rises into place on the same easing, so the two read as one motion. + Leaving, it goes with the page. */ +:root[data-home-sessions-transition='enter']::view-transition-new(greeting) { + animation: greeting-in 260ms var(--composer-glide-easing) + calc(var(--composer-glide-duration) * 0.6) both; +} +:root[data-home-sessions-transition]::view-transition-old(greeting) { + animation-duration: 150ms; +} +@keyframes greeting-in { + from { + opacity: 0; + transform: translateY(4px); + filter: blur(2px); + } +} +:root[data-home-sessions-transition='fade'] * { + view-transition-name: none !important; +} diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 70ed83398d..5a18a09157 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -45,7 +45,8 @@ item?.disabled && 'text-disabled cursor-not-allowed pointer-events-none', item?.type === 'delete' && !item?.disabled && - 'text-red-600 dark:text-red-400 data-[highlighted]:bg-red-500/10 dark:data-[highlighted]:bg-red-900/80 dark:data-[highlighted]:text-red-300 ' + 'text-red-600 dark:text-red-400 data-[highlighted]:bg-red-500/10 dark:data-[highlighted]:bg-red-900/80 dark:data-[highlighted]:text-red-300 ', + item?.type === 'ai' && !item?.disabled && 'text-ai' )} item={meltItem} aiId={`${aiId ? `${aiId}-${item.displayName}` : undefined}`} diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index 0a65701b06..4f7ee40fd1 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -801,14 +801,18 @@
e.stopPropagation()}> {#if !currentTag || !schema[currentTag]} {#if presets.length} -
Presets
+
+ Presets +
{#each presets as preset} {@render presetTag(preset)} {/each}
{/if} -
Filters
+
+ Filters +
{#each menuItems as item, index} {#if item.type === 'filter' && item.filterSchema} {@render menuItem({ @@ -947,18 +951,18 @@
{#if Icon} - + {/if}
-
{label}
+
{label}
{#if description} -
{description}
+
{description}
{/if}
{#if onNegativeClick} diff --git a/frontend/src/lib/components/InheritedLabels.svelte b/frontend/src/lib/components/InheritedLabels.svelte index bfc8190ea4..f15aaf5567 100644 --- a/frontend/src/lib/components/InheritedLabels.svelte +++ b/frontend/src/lib/components/InheritedLabels.svelte @@ -1,33 +1,15 @@ -{#if labels?.length} -
- {#each labels.slice(0, max) as label (label)} - - - {label} - - {/each} - {#if labels.length > max} - 'Label inherited from folder: ' + l) - .join('\n')}>+{labels.length - max} - {/if} -
-{/if} + diff --git a/frontend/src/lib/components/Star.svelte b/frontend/src/lib/components/Star.svelte index 4f0f169109..127bd66efa 100644 --- a/frontend/src/lib/components/Star.svelte +++ b/frontend/src/lib/components/Star.svelte @@ -10,9 +10,10 @@ summary?: string workspaceId?: string size?: number + class?: string } - let { path, kind, workspaceId, summary, size = 16 }: Props = $props() + let { path, kind, workspaceId, summary, size = 16, class: className = 'p-1' }: Props = $props() let buttonHover = $state(false) let starred = $derived(favoriteManager.isStarred(path, kind)) @@ -28,7 +29,9 @@ onclick={preventDefault(onClick)} onmouseenter={() => (buttonHover = true)} onmouseleave={() => (buttonHover = false)} - class="p-1" + class={className} + data-starred={starred || undefined} + title={starred ? 'Remove from favorites' : 'Add to favorites'} > {#if starred} {#if buttonHover} diff --git a/frontend/src/lib/components/common/badge/Badge.svelte b/frontend/src/lib/components/common/badge/Badge.svelte index 09a56c427a..77d59ce6d4 100644 --- a/frontend/src/lib/components/common/badge/Badge.svelte +++ b/frontend/src/lib/components/common/badge/Badge.svelte @@ -65,7 +65,8 @@ ['dark-indigo']: 'bg-indigo-500 text-indigo-100 dark:bg-indigo-600 dark:text-indigo-200', ['dark-orange']: 'bg-orange-500 text-orange-100 dark:bg-orange-600 dark:text-orange-200', ['dark-violet']: 'bg-violet-500 text-violet-100 dark:bg-violet-600 dark:text-violet-200', - transparent: 'bg-transparent border' + transparent: 'bg-transparent border', + subtle: 'bg-transparent' } const selectedColors: Record = { @@ -86,7 +87,8 @@ ['dark-orange']: 'bg-orange-700 text-orange-100 dark:bg-orange-800 dark:text-orange-200', ['dark-violet']: 'bg-violet-700 text-violet-100 dark:bg-violet-800 dark:text-violet-200', // Keeps the unselected chip's border, so selecting one does not change its height. - transparent: 'bg-surface-accent-selected text-accent border border-surface-accent-selected' + transparent: 'bg-surface-accent-selected text-accent border border-surface-accent-selected', + subtle: 'bg-surface-accent-selected text-accent' } const hovers: Partial> = { @@ -106,7 +108,8 @@ ['dark-indigo']: 'hover:bg-indigo-600 dark:hover:bg-indigo-700', ['dark-orange']: 'hover:bg-orange-600 dark:hover:bg-orange-700', ['dark-violet']: 'hover:bg-violet-600 dark:hover:bg-violet-700', - transparent: 'hover:bg-surface-hover dark:hover:border-gray-500' + transparent: 'hover:bg-surface-hover dark:hover:border-gray-500', + subtle: 'hover:bg-surface-hover' } let badgeClass = $derived( diff --git a/frontend/src/lib/components/common/badge/LabelBadge.svelte b/frontend/src/lib/components/common/badge/LabelBadge.svelte new file mode 100644 index 0000000000..698a585e82 --- /dev/null +++ b/frontend/src/lib/components/common/badge/LabelBadge.svelte @@ -0,0 +1,36 @@ + + + + {#if Icon} + + {/if} + {@render children?.()} + {#if selected}✗{/if} + diff --git a/frontend/src/lib/components/common/badge/LabelList.svelte b/frontend/src/lib/components/common/badge/LabelList.svelte new file mode 100644 index 0000000000..8fb15f8186 --- /dev/null +++ b/frontend/src/lib/components/common/badge/LabelList.svelte @@ -0,0 +1,33 @@ + + +{#if labels?.length} +
+ {#each labels.slice(0, max) as label (label)} + {label} + {/each} + {#if labels.length > max} + `${titlePrefix}: ${l}`) + .join('\n')}>+{labels.length - max} + {/if} +
+{/if} diff --git a/frontend/src/lib/components/common/badge/model.ts b/frontend/src/lib/components/common/badge/model.ts index 03adf9f195..4ecfdd8ea4 100644 --- a/frontend/src/lib/components/common/badge/model.ts +++ b/frontend/src/lib/components/common/badge/model.ts @@ -1,6 +1,11 @@ type BaseColor = 'blue' | 'gray' | 'red' | 'green' | 'yellow' | 'indigo' | 'orange' | 'violet' export const ColorModifier = 'dark-' -export type BadgeColor = BaseColor | 'transparent' | `${typeof ColorModifier}${BaseColor}` +/** `transparent` is outlined; `subtle` has neither fill nor border. */ +export type BadgeColor = + | BaseColor + | 'transparent' + | 'subtle' + | `${typeof ColorModifier}${BaseColor}` export interface BadgeIconProps { position?: 'left' | 'right' diff --git a/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte b/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte index 6958de0338..49421e78b3 100644 --- a/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte +++ b/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte @@ -22,7 +22,7 @@ disabled?: boolean onClick?: () => void divider?: boolean - type?: 'action' | 'delete' + type?: 'action' | 'delete' | 'ai' shortcut?: string } @@ -129,7 +129,8 @@ ? CONTEXT_MENU_ITEM_DISABLED_CLASS : menuItem.type === 'delete' ? CONTEXT_MENU_ITEM_DELETE_CLASS - : CONTEXT_MENU_ITEM_HOVER_MELT_CLASS + : CONTEXT_MENU_ITEM_HOVER_MELT_CLASS, + menuItem.type === 'ai' && !menuItem.disabled && 'text-ai' )} use:melt={$item} onclick={() => handleItemClick(menuItem)} diff --git a/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte b/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte index 7635b900bb..96eb682456 100644 --- a/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte +++ b/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte @@ -16,7 +16,7 @@ import MSSqlServerIcon from '$lib/components/icons/MSSqlServerIcon.svelte' import BunIcon from '$lib/components/icons/BunIcon.svelte' import DenoIcon from '$lib/components/icons/DenoIcon.svelte' - import type { Script } from '$lib/gen' + import { languageLabel } from './languageLabels' import PHPIcon from '$lib/components/icons/PHPIcon.svelte' import RustIcon from '$lib/components/icons/RustIcon.svelte' import AnsibleIcon from '$lib/components/icons/AnsibleIcon.svelte' @@ -52,40 +52,6 @@ let { lang, width = 30, height = 30, scale = 1, size = undefined, ...rest }: Props = $props() - const languageLabel: Record< - Script['language'] | 'bunnative' | 'claudesandbox' | 'pisandbox', - String - > = { - python3: 'Python', - deno: 'TypeScript', - go: 'Go', - bash: 'Bash', - powershell: 'PowerShell', - nativets: 'HTTP', - bunnative: 'HTTP', - graphql: 'GraphQL', - postgresql: 'Postgresql', - bigquery: 'BigQuery', - oracledb: 'Oracle Database', - duckdb: 'DuckDB', - snowflake: 'Snowflake', - mysql: 'MySQL', - mssql: 'MS SQL Server', - bun: 'TypeScript', - php: 'PHP', - rust: 'Rust', - ansible: 'Ansible', - csharp: 'C#', - nu: 'Nu', - java: 'Java', - ruby: 'Ruby', - rlang: 'R', - dbt: 'dbt', - claudesandbox: 'Claude Sandbox', - pisandbox: 'Pi Sandbox' - // for related places search: ADD_NEW_LANG - } - const langToComponent: Record< | SupportedLanguage | 'pgsql' diff --git a/frontend/src/lib/components/common/languageIcons/LanguageTag.svelte b/frontend/src/lib/components/common/languageIcons/LanguageTag.svelte new file mode 100644 index 0000000000..610af5b0e8 --- /dev/null +++ b/frontend/src/lib/components/common/languageIcons/LanguageTag.svelte @@ -0,0 +1,11 @@ + + + +{languageShortLabel[lang] ?? lang} diff --git a/frontend/src/lib/components/common/languageIcons/languageLabels.ts b/frontend/src/lib/components/common/languageIcons/languageLabels.ts new file mode 100644 index 0000000000..142a2790ee --- /dev/null +++ b/frontend/src/lib/components/common/languageIcons/languageLabels.ts @@ -0,0 +1,68 @@ +import type { Script } from '$lib/gen' + +export type LabelledLanguage = Script['language'] | 'bunnative' | 'claudesandbox' | 'pisandbox' + +/** A language's name, as a tooltip or anywhere the full word fits. */ +export const languageLabel: Record = { + python3: 'Python', + deno: 'TypeScript', + go: 'Go', + bash: 'Bash', + powershell: 'PowerShell', + nativets: 'HTTP', + bunnative: 'HTTP', + graphql: 'GraphQL', + postgresql: 'Postgresql', + bigquery: 'BigQuery', + oracledb: 'Oracle Database', + duckdb: 'DuckDB', + snowflake: 'Snowflake', + mysql: 'MySQL', + mssql: 'MS SQL Server', + bun: 'TypeScript', + php: 'PHP', + rust: 'Rust', + ansible: 'Ansible', + csharp: 'C#', + nu: 'Nu', + java: 'Java', + ruby: 'Ruby', + rlang: 'R', + dbt: 'dbt', + claudesandbox: 'Claude Sandbox', + pisandbox: 'Pi Sandbox' + // for related places search: ADD_NEW_LANG +} + +/** A few letters for a list row, where a logo per row is more than the eye needs. Every SQL + * dialect reads "SQL": which one is the tooltip's job (`languageLabel`). */ +export const languageShortLabel: Record = { + python3: 'Py', + deno: 'TS', + bun: 'TS', + go: 'Go', + bash: 'Sh', + powershell: 'PS', + nativets: 'Rest', + bunnative: 'Rest', + graphql: 'GQL', + postgresql: 'SQL', + bigquery: 'SQL', + oracledb: 'SQL', + duckdb: 'SQL', + snowflake: 'SQL', + mysql: 'SQL', + mssql: 'SQL', + php: 'PHP', + rust: 'Rs', + ansible: 'Ans', + csharp: 'C#', + nu: 'Nu', + java: 'Java', + ruby: 'Rb', + rlang: 'R', + dbt: 'dbt', + claudesandbox: 'AI', + pisandbox: 'AI' + // for related places search: ADD_NEW_LANG +} diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte index 9725d4b4f3..20421ac514 100644 --- a/frontend/src/lib/components/common/table/AgentRow.svelte +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -4,7 +4,7 @@ import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' - import InheritedLabels from '$lib/components/InheritedLabels.svelte' + import RowLabels from './RowLabels.svelte' import type { ListableResource } from '$lib/gen' import { roleCapabilities, userStore, workspaceStore } from '$lib/stores' import { createEventDispatcher } from 'svelte' @@ -84,6 +84,7 @@ > {#snippet badges()} + dispatch('change')} /> - {/snippet} {#snippet actions()} {#if agent.canWrite} {/if} [ ...selectMenuItems(rowSelection), ...agentMenuItems({ diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 44a27f3122..812c06284b 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -12,7 +12,8 @@ import Button from '../button/Button.svelte' import Row from './Row.svelte' import { selectMenuItems, type RowSelection } from './rowSelection' - import InheritedLabels from '$lib/components/InheritedLabels.svelte' + import RowLabels from './RowLabels.svelte' + import AskAiRowButton from './AskAiRowButton.svelte' import Badge from '../badge/Badge.svelte' import { ExternalLink, @@ -129,6 +130,7 @@ Raw {/if} + dispatch('change')} /> - {#if app.labels?.length} -
- {#each app.labels.slice(0, 3) as label} - {label} - {/each} - {#if app.labels.length > 3} - 'Label: ' + l) - .join('\n')}>+{app.labels.length - 3} - {/if} -
- {/if} - -
{/snippet} {#snippet actions()} -
- {/if} - {#if footerMessageShown} -
- -
- {:else} -
- {#if chatHost.mode === AIMode.GLOBAL} - - {/if} - {#if !hideModeSelector} - - {/if} - {#if chatHost.mode === AIMode.APP} - - {/if} - - {#if chatHost.supportsModelSettings} - +
+ {@render actingOn?.()} +
+ +
+
+ {:else} +
+ {#if chatHost.mode === AIMode.GLOBAL} + + {/if} + {#if !hideModeSelector} + + {/if} + {#if chatHost.mode === AIMode.APP} + + {/if} + + {#if chatHost.supportsModelSettings} + - - {/if} - {@render footerSettings?.()} - {#if chatHost.mode === AIMode.GLOBAL} - - {/if} + + {/if} + {@render footerSettings?.()} + {#if chatHost.mode === AIMode.GLOBAL} + + {/if} - {#if chatHost.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)} - {#if appContext.inspectorElement} -
- - - {appContext.inspectorElement.tagName.toLowerCase()}{appContext.inspectorElement - .id - ? `#${appContext.inspectorElement.id}` - : ''}{appContext.inspectorElement.className - ? `.${appContext.inspectorElement.className.split(' ')[0]}` - : ''} - - -
- {/if} - {#if appContext.codeSelection} -
- - - L{appContext.codeSelection.startLine}-{appContext.codeSelection.endLine} - - -
- {/if} + {#if chatHost.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)} + {#if appContext.inspectorElement} +
+ + + {appContext.inspectorElement.tagName.toLowerCase()}{appContext + .inspectorElement.id + ? `#${appContext.inspectorElement.id}` + : ''}{appContext.inspectorElement.className + ? `.${appContext.inspectorElement.className.split(' ')[0]}` + : ''} + + +
+ {/if} + {#if appContext.codeSelection} +
+ + + L{appContext.codeSelection.startLine}-{appContext.codeSelection.endLine} + + +
+ {/if} + {/if} +
{/if}
+ {/snippet} + +
+ +
+
+ {#if starterLayout && starter && !disabled && !composerCollapsed && starter.suggestions.length > 0} +
+ aiChatInput?.setText(prompt)} + /> +
{/if}
@@ -1387,4 +1426,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {/if} + +
diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index cd95da5a88..13aaccec94 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -19,6 +19,12 @@ globalComposerSuggestion, type ComposerSelection } from './composerBox' + + // The send button sits in the toolbar row, not over the field, so the text needs no + // room reserved on its right, and the row right below it stands in for most of its + // bottom padding. The top padding is the one-line row's, so the text holds still as Home's + // composer opens. + const FIELD_PADDING_WITH_TOOLBAR = '!pl-4 !pr-4 !pt-3 !pb-1' import { formatMention, hasMention, removeMentionFromText } from './mention' import { twMerge } from 'tailwind-merge' import { tick, untrack, type Snippet } from 'svelte' @@ -54,7 +60,7 @@ MAX_BLOB_BYTES, type AttachedBlob } from './blobUtils' - import { MessageDraft } from './messageDraft.svelte' + import { MessageDraft, type DraftAttachments } from './messageDraft.svelte' import { useSessionPermissions } from '$lib/components/sessions/sessionPermissions.svelte' import ExpandableImage, { isImageViewerOpen @@ -94,10 +100,30 @@ // `chatHost.instructions` only carries programmatic prompts). Used by // sessions to persist the typed-but-unsent prompt with the session draft. onDraftChange?: (text: string) => void + /** The rest of the draft — collapsed pastes, images, files — seeded and observed the + * same way. The text alone is not the draft: a collapsed paste is a token in it whose + * content lives here, so a composer remounted elsewhere needs both. */ + initialAttachments?: DraftAttachments + onAttachmentsChange?: (attachments: DraftAttachments) => void // tool_call_id of the askUserQuestion the turn is parked on, when it is. A // plain-text draft sent from here answers it instead of queueing behind a // turn that only the answer can resume. pendingQuestionToolCallId?: string + /** The composer that opens a session (Home, an empty session): `toolbar` is drawn in the + * box's bottom row with the send button at its end, and a usable box is taller. Otherwise + * the controls sit under the box and the send button floats over the field's corner, as a + * chat under way reads more clearly. */ + hero?: boolean + /** With `hero`: one row, the field then `collapsedControls` and the send button, in + * place of the toolbar. */ + collapsed?: boolean + collapsedControls?: Snippet + /** A chat mark before the field, where the box alone doesn't say it is the assistant's. */ + chatIcon?: boolean + /** After a new turn is handed to the host — not an edit, a queued message or an answer. */ + onSent?: () => void + /** The composer's controls. */ + toolbar?: Snippet } let { @@ -121,9 +147,20 @@ loading, onCancel, onDraftChange = undefined, - pendingQuestionToolCallId = undefined + initialAttachments = undefined, + onAttachmentsChange = undefined, + pendingQuestionToolCallId = undefined, + onSent = undefined, + toolbar = undefined, + hero = false, + collapsed = false, + collapsedControls = undefined, + chatIcon = false }: Props = $props() + const oneLine = $derived(hero && collapsed) + const toolbarInBox = $derived(!!toolbar && hero) + // Picked at mount-time so each new session lands on a different prompt. const permissions = useSessionPermissions(() => chatHost.operatingWorkspace) const suggestionPick = Math.random() @@ -180,9 +217,10 @@ const draft = new MessageDraft( untrack(() => ({ text: initialInstructions, - pastes: initialPastes ?? [], - images: initialImages ?? [], - files: initialFiles ?? [] + pastes: initialPastes ?? initialAttachments?.pastes ?? [], + images: initialImages ?? initialAttachments?.images ?? [], + files: initialFiles ?? initialAttachments?.files ?? [], + blobs: initialAttachments?.blobs ?? [] })) ) // Report edits, never the mount-time value. The first run carries whatever the @@ -201,6 +239,24 @@ onDraftChange?.(text) }) }) + // Same rule for the rest of the draft. + let attachmentsReported = false + $effect(() => { + // Copied, not passed: the paste lane is edited in place, and a copy reads every item. + const attachments = { + pastes: [...draft.pastes], + images: [...draft.images], + files: [...draft.files], + blobs: [...draft.blobs] + } + untrack(() => { + if (!attachmentsReported) { + attachmentsReported = true + return + } + onAttachmentsChange?.(attachments) + }) + }) // A parked askUserQuestion is a request for input, so a plain-text draft sent // from the composer answers it rather than being queued behind a turn that can @@ -586,10 +642,12 @@ * auto-remove items when the user deletes them. No-op when the same * standalone mention is already present, so picking the same item * again doesn't leave duplicate tokens. */ - export function insertMention(title: string) { + /** Resolves once the caret sits after the mention, so mentions inserted one after another + * land in that order. */ + export async function insertMention(title: string) { if (hasMention(draft.text, title)) return if (contextTextareaComponent) { - void contextTextareaComponent.insertMention(title) + await contextTextareaComponent.insertMention(title) return } const sep = draft.text.length === 0 || /\s$/.test(draft.text) ? '' : ' ' @@ -609,6 +667,18 @@ draft.text = removeMentionFromText(draft.text, title) } + /** Replace the draft's text, keeping its attachments: a starter prompt fills the box, it + * never sends. */ + export function setText(text: string) { + draft.text = text + focusInput() + } + + /** Whether nothing would ship with a send: no text and no attachment. */ + export function isDraftEmpty(): boolean { + return draft.isEmpty + } + export function focusInput(selection?: ComposerSelection) { if (isContextEnabledMode) { contextTextareaComponent?.focus(selection) @@ -953,6 +1023,7 @@ // textarea still rely on the draft reset alone (no `@`-mention state to // coordinate). contextTextareaComponent?.clearForSend() + onSent?.() } } @@ -1183,7 +1254,23 @@ } -{#snippet sendStopButton()} +{#snippet toolbarRow()} + {#if oneLine} +
+ {@render collapsedControls?.()} + {@render sendStopButton('sm')} +
+ {:else if toolbarInBox} + +
+
{@render toolbar?.()}
+ {@render sendStopButton('sm')} +
+ {/if} +{/snippet} + +{#snippet sendStopButton(size: 'sm' | 'md' = 'md')} @@ -1208,9 +1295,11 @@ (onSendRequest !== undefined || chatHost.mode !== AIMode.GLOBAL || selectedContext.length === 0))} + + {/snippet} + diff --git a/frontend/src/lib/components/copilot/chat/ComposerPlaceholder.svelte b/frontend/src/lib/components/copilot/chat/ComposerPlaceholder.svelte index 17c95086ca..2aba3d0be4 100644 --- a/frontend/src/lib/components/copilot/chat/ComposerPlaceholder.svelte +++ b/frontend/src/lib/components/copilot/chat/ComposerPlaceholder.svelte @@ -4,7 +4,13 @@ import { Button } from '$lib/components/common' import autosize from '$lib/autosize' import { CHAT_INPUT_PADDING } from './aiChatManagerContext' - import { composerBoxClass, COMPOSER_FIELD_RESET, type ComposerSelection } from './composerBox' + import type { Snippet } from 'svelte' + import { + composerBoxClass, + COMPOSER_FIELD_RESET, + HERO_BOX_CLASS, + type ComposerSelection + } from './composerBox' /** * The chat composer's box, drawn while the chat code loads. It must import nothing of @@ -17,7 +23,8 @@ disabled = false, autofocus = false, onSelectionChange, - onCompositionChange + onCompositionChange, + toolbar }: { value: string placeholder?: string @@ -27,6 +34,8 @@ onSelectionChange?: (selection: ComposerSelection | undefined) => void /** Whether an IME composition is in progress: replacing the field then would lose it. */ onCompositionChange?: (composing: boolean) => void + /** Draws the box that opens a session (AIChatInput's `hero`), its controls row in the box. */ + toolbar?: Snippet } = $props() function trackSelection(e: Event & { currentTarget: HTMLTextAreaElement }) { @@ -37,8 +46,19 @@ } +{#snippet sendButton(size: 'sm' | 'md')} + + {/each} + diff --git a/frontend/src/lib/components/copilot/chat/starter/SessionGreeting.svelte b/frontend/src/lib/components/copilot/chat/starter/SessionGreeting.svelte new file mode 100644 index 0000000000..dfa8ae3884 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/SessionGreeting.svelte @@ -0,0 +1,30 @@ + + +
+

+ {#if firstVisit} + Welcome to {workspaceName}{userName ? `, ${userName}` : ''} + {:else} + {greeting}{userName ? `, ${userName}` : ''} + {/if} +

+ Beta +
diff --git a/frontend/src/lib/components/copilot/chat/starter/composerCollapse.ts b/frontend/src/lib/components/copilot/chat/starter/composerCollapse.ts new file mode 100644 index 0000000000..d21f04f1ba --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/composerCollapse.ts @@ -0,0 +1,11 @@ +// The layers the composer's controls open render in portals under , outside the +// composer's own element: DropdownV2 menus, melt popovers, and the settings modals. Not +// `[data-menu]`: DropdownV2 puts it on its trigger too, and Home has triggers everywhere. +const OVERLAY = '[data-melt-dropdown-menu], [data-melt-popover-content], [role="dialog"]' + +/** Whether a pointer-down outside the composer still leaves it open: it landed in a menu or + * dialog, or one is open, and the press is closing it rather than leaving the composer. */ +export function isComposerOverlay(target: EventTarget | null): boolean { + if (target instanceof Element && target.closest(OVERLAY)) return true + return document.querySelector(OVERLAY) !== null +} diff --git a/frontend/src/lib/components/copilot/chat/starter/greeting.test.ts b/frontend/src/lib/components/copilot/chat/starter/greeting.test.ts new file mode 100644 index 0000000000..835896cfb8 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/greeting.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const scoped = vi.hoisted(() => ({ key: 'welcomed::ada@example.com' as string | undefined })) +vi.mock('$lib/userScopedStorage', () => ({ scopedKey: () => scoped.key })) + +import { takeFirstVisit } from './greeting' + +const DAY = 24 * 60 * 60 * 1000 +const daysAgo = (n: number) => new Date(Date.now() - n * DAY).toISOString() + +const store = new Map() +vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v) +}) + +beforeEach(() => { + store.clear() + scoped.key = 'welcomed::ada@example.com' +}) + +describe('takeFirstVisit', () => { + it('welcomes a new member once per workspace, for the whole page load', () => { + expect(takeFirstVisit('acme', daysAgo(2))).toBe(true) + // Back on Home or the empty session in the same load: still the welcome. + expect(takeFirstVisit('acme', daysAgo(2))).toBe(true) + expect(JSON.parse(store.get('welcomed::ada@example.com')!)).toEqual(['acme']) + }) + + it('greets by time of day for an older member, or a workspace already welcomed', () => { + expect(takeFirstVisit('old-team', daysAgo(45))).toBe(false) + store.set('welcomed::ada@example.com', JSON.stringify(['seen-before'])) + expect(takeFirstVisit('seen-before', daysAgo(1))).toBe(false) + }) + + it('records nothing before the user is known', () => { + scoped.key = undefined + expect(takeFirstVisit('fresh', daysAgo(1))).toBe(false) + expect(store.size).toBe(0) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/starter/greeting.ts b/frontend/src/lib/components/copilot/chat/starter/greeting.ts new file mode 100644 index 0000000000..8e63dc3d5a --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/greeting.ts @@ -0,0 +1,37 @@ +import { scopedKey } from '$lib/userScopedStorage' + +const WELCOMED_KEY = 'session-block-welcomed-workspaces' +/** A membership older than this is not a first visit, whatever this browser remembers: without + * it, everyone already in a workspace would be welcomed to it once on their next visit. */ +const NEW_MEMBER_MS = 30 * 24 * 60 * 60 * 1000 + +// Workspaces welcomed during this page load: the welcome holds for the whole visit (Home, the +// empty session, back), and only a later load greets by time of day. +const welcomedThisLoad = new Set() + +/** Whether this is the user's first visit to `workspace`: a recent member this browser has not + * welcomed there yet. Answering yes records the welcome. */ +export function takeFirstVisit(workspace: string, memberSince: string | undefined): boolean { + if (welcomedThisLoad.has(workspace)) return true + const key = scopedKey(WELCOMED_KEY) + if (!key) return false + let welcomed: string[] = [] + try { + welcomed = JSON.parse(localStorage.getItem(key) ?? '[]') + } catch {} + if (welcomed.includes(workspace)) return false + const joined = memberSince ? Date.parse(memberSince) : NaN + if (!(Date.now() - joined < NEW_MEMBER_MS)) return false + welcomedThisLoad.add(workspace) + try { + localStorage.setItem(key, JSON.stringify([...welcomed, workspace])) + } catch {} + return true +} + +export function timeOfDayGreeting(now = new Date()): string { + const hour = now.getHours() + if (hour >= 5 && hour < 12) return 'Good morning' + if (hour >= 12 && hour < 18) return 'Good afternoon' + return 'Good evening' +} diff --git a/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts b/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts new file mode 100644 index 0000000000..57336ef9b8 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts @@ -0,0 +1,63 @@ +type SmoothHeightParams = { + /** Off: the element takes its natural height and nothing is animated. */ + enabled: boolean + /** The state whose change should animate. Height changes at any other time follow the + * content at once: animating every line the user types would clip the caret's line. */ + key: unknown +} + +const DURATION_MS = 200 +const EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)' + +/** + * Sizes `node` to its first child and animates that height when `key` changes, so a block + * that changes shape grows and shrinks instead of jumping. The child is measured, not the + * node: the node's own height is the one being set. `node` clips while it animates, so a + * focus ring belongs on `node` itself (box-shadow is not clipped by its own overflow). + */ +export function smoothHeight(node: HTMLElement, params: SmoothHeightParams) { + let { enabled, key } = params + let animateUntil = 0 + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') + + const apply = () => { + const inner = node.firstElementChild as HTMLElement | null + if (!enabled || !inner) { + node.style.height = '' + node.style.overflow = '' + node.style.transition = '' + return + } + const borders = node.offsetHeight - node.clientHeight + const animate = performance.now() < animateUntil && !reducedMotion.matches + node.style.overflow = 'hidden' + node.style.transition = animate ? `height ${DURATION_MS}ms ${EASING}` : 'none' + node.style.height = `${inner.offsetHeight + borders}px` + } + + // Created on first use: most chats mount this disabled and never turn it on. + let ro: ResizeObserver | undefined + const observe = () => { + ro?.disconnect() + const inner = node.firstElementChild + if (!enabled || !inner) return + ro ??= new ResizeObserver(apply) + ro.observe(inner) + } + // No measuring at mount: the content's height is not settled yet (a newly mounted session + // composer reads ~60px too tall), and a view transition captures the box at that height. + // The observer's first callback comes after layout and sets the right one. + observe() + + return { + update(next: SmoothHeightParams) { + if (next.key !== key) animateUntil = performance.now() + DURATION_MS + ;({ enabled, key } = next) + observe() + apply() + }, + destroy() { + ro?.disconnect() + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts b/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts new file mode 100644 index 0000000000..9bb9341a0b --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts @@ -0,0 +1,152 @@ +import { onboardingProfile, type StarterPrompt } from '$lib/onboardingProfile' +import { roleCapabilities } from '$lib/stores' +import { fromStore } from 'svelte/store' +import { resource } from 'runed' +import { ScriptService } from '$lib/gen' + +/** A prompt offered under the composer: `label` is the pill, `prompt` what it puts in the + * box. `use` looks at, asks about or runs what exists; `build` creates or changes things. */ +export type ComposerSuggestion = StarterPrompt & { mode: 'use' | 'build' } + +const USE_EXAMPLES: ComposerSuggestion[] = [ + { + mode: 'use', + label: 'What failed today?', + prompt: 'Which runs failed in this workspace today, and why?' + }, + { + mode: 'use', + label: 'Which schedules run the most?', + prompt: + 'Which schedules ran the most today, and does any of them run more often than it should?' + } +] + +const BUILD_EXAMPLES: ComposerSuggestion[] = [ + { + mode: 'build', + label: 'Sync Salesforce', + prompt: 'Sync new Salesforce leads into a postgres table every hour' + }, + { + mode: 'build', + label: 'Weekly Slack report', + prompt: 'Generate a weekly sales report from postgres and post it to Slack every Monday' + }, + { + mode: 'build', + label: 'Ban Discord users', + prompt: + 'Build a workflow that triggers on a Discord message, checks for offensive language using an LLM, and possibly block them' + } +] + +/** A session for a role that cannot code can run what is deployed, read runs and call MCP + * servers, but cannot author scripts, so none of these asks it to build anything. */ +const OPERATOR_EXAMPLES: ComposerSuggestion[] = [ + USE_EXAMPLES[0], + { + mode: 'use', + label: 'Rerun failed jobs', + prompt: 'List the jobs that failed today, tell me why, and rerun the ones that timed out' + }, + { + mode: 'use', + label: 'Run a flow', + prompt: 'Find the flow that onboards a new employee and run it for Jane Doe, starting Monday' + }, + { + mode: 'use', + label: 'Summarize GitHub issues', + prompt: "Read this week's open GitHub issues and summarize the ones that mention billing" + } +] + +/** From this many items on, Home's composer rests collapsed: below it the workspace is new + * enough that the composer is the page's main way in. */ +const FEW_ITEMS = 10 + +/** The suggestions for the reader's role and the workspace the session acts on, and whether + * that workspace is still small. An invite that wrote prompts for this person replaces the + * examples outright: a prompt written for someone's own stack next to "Ban Discord users" + * reads as the generic one. */ +export class StarterPrompts { + #invite = $state() + #capabilities = fromStore(roleCapabilities) + #workspace: () => string | undefined + #itemCount: { readonly current: number | undefined } + + /** `workspace`: where the session acts. */ + constructor(workspace: () => string | undefined) { + this.#workspace = workspace + void onboardingProfile().then((p) => { + if (p?.starter_prompts?.length) this.#invite = p.starter_prompts + }) + this.#itemCount = resource(workspace, async (ws) => (ws ? workspaceItemCount(ws) : undefined)) + } + + /** At most `FEW_ITEMS`; undefined until the workspace has answered. A count already read + * this page load stands in while it is asked again, so a remount does not wait on it. */ + get #count(): number | undefined { + const ws = this.#workspace() + return this.#itemCount.current ?? (ws ? itemCounts.get(ws) : undefined) + } + + /** Up to four: two Use then two Build. An empty workspace has nothing to ask about yet. */ + readonly suggestions: ComposerSuggestion[] = $derived.by(() => { + if (this.#invite) return this.#invite.map((p) => ({ ...p, mode: 'build' as const })) + if (!this.#capabilities.current.code) return OPERATOR_EXAMPLES + if (this.#count === 0) return BUILD_EXAMPLES + return [...USE_EXAMPLES, ...BUILD_EXAMPLES.slice(0, 2)] + }) + + /** Whether the workspace holds fewer than `FEW_ITEMS`; undefined until known. */ + get fewItems(): boolean | undefined { + const count = this.#count + return count === undefined ? undefined : count < FEW_ITEMS + } +} + +const itemCounts = new Map() +const itemCountRequests = new Map>() + +/** Starts the count for a page about to mount the starter; the starter then takes this + * request rather than sending its own. */ +export function prefetchItemCount(workspace: string): void { + void workspaceItemCount(workspace) +} + +function workspaceItemCount(workspace: string): Promise { + let request = itemCountRequests.get(workspace) + if (!request) { + request = fetchItemCount(workspace).finally(() => itemCountRequests.delete(workspace)) + itemCountRequests.set(workspace, request) + } + return request +} + +async function fetchItemCount(workspace: string): Promise { + let count = FEW_ITEMS + try { + // What Home's list counts: library scripts and the user's own drafts included. + const res = await ScriptService.listRunnables({ + workspace, + includeWithoutMain: true, + includeDraftOnly: true, + perPage: FEW_ITEMS + }) + count = res.items?.length ?? 0 + } catch (e) { + // Unknown reads as a full workspace: a collapsed composer is the quieter mistake. + console.error(`Could not count the items of workspace ${workspace}:`, e) + } + itemCounts.set(workspace, count) + return count +} + +/** What turns an empty chat into the block that starts a session. */ +export type ChatStarter = { suggestions: ComposerSuggestion[] } & ( + | /** An empty session centres the block in the pane under a greeting. */ + { where: 'session' } /** Home shows it alone, collapsed to one line at rest unless `fewItems`. */ + | { where: 'home'; fewItems: boolean } +) diff --git a/frontend/src/lib/components/copilot/chatModelSettings.ts b/frontend/src/lib/components/copilot/chatModelSettings.ts index 3fce0270d1..e65a0b5141 100644 --- a/frontend/src/lib/components/copilot/chatModelSettings.ts +++ b/frontend/src/lib/components/copilot/chatModelSettings.ts @@ -41,6 +41,8 @@ export type ChoiceSection = { export type ChatModelSettingsConfig = { /** The trigger's main text: the chosen model, or an invitation to choose one. */ label: string + /** Shown instead of `label` on windows narrower than 1280px. */ + shortLabel?: string title?: string /** Trailing pill on the trigger, e.g. the free-tier grant this chat is spending. */ badge?: { text: string; warn?: boolean } diff --git a/frontend/src/lib/components/copilot/workspaceAiHidden.svelte.ts b/frontend/src/lib/components/copilot/workspaceAiHidden.svelte.ts new file mode 100644 index 0000000000..99d0cca9f0 --- /dev/null +++ b/frontend/src/lib/components/copilot/workspaceAiHidden.svelte.ts @@ -0,0 +1,44 @@ +import { resource } from 'runed' +import { fromStore } from 'svelte/store' +import { WorkspaceService } from '$lib/gen' +import { copilotInfo, copilotWorkspace } from '$lib/aiStore' + +/** + * Whether a workspace hid the AI assistant (`ai_config.copilot_disabled`), read for that workspace + * directly: `copilotInfo` holds whichever workspace loaded last, so it answers only when it was + * loaded for this very workspace. `hidden` is `undefined` until a verdict for the workspace asked + * about lands; runed keeps the previous `current` while a new source loads, so each verdict is + * tagged with its workspace. A failed read counts as not hidden, as an unloaded config does + * everywhere else. + */ +export function useWorkspaceAiHidden(workspace: () => string | undefined) { + const loadedInfo = fromStore(copilotInfo) + const loadedWorkspace = fromStore(copilotWorkspace) + const verdict = resource(workspace, async (ws, _prev, { signal }) => { + if (!ws) return { workspace: ws, hidden: false } + let hidden = false + try { + hidden = (await WorkspaceService.getCopilotInfo({ workspace: ws })).copilot_disabled === true + } catch (e) { + console.error(`Failed to read the AI config of workspace ${ws}:`, e) + } + // The generated client can't take an abort signal, so drop a superseded response here. + if (signal.aborted) throw new DOMException('superseded', 'AbortError') + return { workspace: ws, hidden } + }) + const hidden = $derived.by(() => { + const ws = workspace() + const current = verdict.current + if (current && current.workspace === ws) return current.hidden + return ws && loadedWorkspace.current === ws ? loadedInfo.current.workspaceDisabled : undefined + }) + return { + get hidden(): boolean | undefined { + return hidden + }, + /** Whether any verdict has landed yet, for any workspace. */ + get settledOnce(): boolean { + return verdict.current !== undefined + } + } +} diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index d8dee9dcaa..1aba4c2d02 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -570,7 +570,7 @@ export class FlowChatViewHost implements ChatViewHost, DraftSender[0] = null + #aiChatInput: Parameters[0] | null = null setAiChatInput: ChatViewHost['setAiChatInput'] = (aiChatInput) => // Called from the composer's mount effect, and taking what a turn handed back reads the // draft it writes: tracked, that would rerun the effect and hand it back again. @@ -578,6 +578,9 @@ export class FlowChatViewHost implements ChatViewHost, DraftSender { + if (this.#aiChatInput === aiChatInput) this.#aiChatInput = null + } /** Whether a composer showing this conversation holds something not sent — text, an * attachment, a file still being read. Its panel stays mounted for as long as one does: diff --git a/frontend/src/lib/components/home/BulkActionsBar.svelte b/frontend/src/lib/components/home/BulkActionsBar.svelte index ef595cb63c..1874008fc2 100644 --- a/frontend/src/lib/components/home/BulkActionsBar.svelte +++ b/frontend/src/lib/components/home/BulkActionsBar.svelte @@ -12,7 +12,19 @@ import { sendUserToast } from '$lib/toast' import { useReducedMotion } from '$lib/svelte5Utils.svelte' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' - import { Archive, ArchiveRestore, FolderInput, MoreHorizontal, Trash, X } from 'lucide-svelte' + import { + Archive, + ArchiveRestore, + FolderInput, + MoreHorizontal, + Trash, + WandSparkles, + X + } from 'lucide-svelte' + import { + homeComposerContext, + itemContextElement + } from '$lib/components/sessions/homeComposerContext.svelte' import { fly } from 'svelte/transition' import { blockedReason, @@ -50,6 +62,17 @@ }) let items = $derived(selection.items.length > 0 ? selection.items : lastNonEmpty) + // What of the selection the composer can take as context; the rest has no context element. + let askAiElements = $derived( + items.flatMap((i) => itemContextElement({ ...i, summary: i.summary || undefined }) ?? []) + ) + // The selection was the way to gather them; once they are in the composer it is done. + async function askAi() { + const elements = askAiElements + selection.exit() + for (const element of elements) await homeComposerContext.attach(element) + } + let reducedMotion = useReducedMotion() const plural = (n: number) => (n === 1 ? '' : 's') @@ -185,6 +208,20 @@ {items.length} selected
+ {#if homeComposerContext.available && askAiElements.length > 0} + + {/if} -
- -
- - {#if disabled} - -
-

- {#if $aiUserDisabled} - Windmill AI is disabled in your account settings - {:else if freeTierExhausted} - You have used all of your free Windmill AI tokens - {:else} - No AI provider is configured - {/if} -

-
- {#if $aiUserDisabled} - - - {:else} - - {/if} - -
-
- {/if} - {/if} - -
- {#if showComposer && !collapsed} -
- {#each examples as example (example.label)} - - {/each} -
- {/if} -
@@ -406,9 +184,9 @@ - {#if !sideTripsInBar || (showComposer && collapsed)} + {#if !sideTripsInBar || (showComposer && removed) || homeQuickAccessSettings.hidden} @@ -421,35 +199,54 @@ class="pointer-events-none absolute -inset-1 rounded-lg bg-blue-500/30 opacity-0 animate-fade-out" > {/if} + + {#if homeQuickAccessSettings.hideCount > 0 && !reducedMotion.val} + {#key homeQuickAccessSettings.hideCount} + + {/key} + {/if} { logFeatureUsage('home', 'ai_hero_pin') - setCollapsed(false) + setRemoved(false) } } ] : []), + ...(homeQuickAccessSettings.hidden + ? [ + { + displayName: 'Pin favorites & recent to homepage', + action: () => homeQuickAccessSettings.setHidden(false) + } + ] + : []), ...(sideTripsInBar ? [] : [ { displayName: 'CLI / MCP', - icon: PlugZap, action: () => homeConnectDrawer?.openDrawer?.() }, ...(hubOffered ? [ { displayName: 'Hub', - icon: Globe2, href: $hubBaseUrlStore, hrefTarget: '_blank' as const } diff --git a/frontend/src/lib/components/home/HomeQuickAccess.svelte b/frontend/src/lib/components/home/HomeQuickAccess.svelte new file mode 100644 index 0000000000..12f655350d --- /dev/null +++ b/frontend/src/lib/components/home/HomeQuickAccess.svelte @@ -0,0 +1,479 @@ + + + + +{#snippet card(item: Entry, favorite: boolean)} +
+ + + {item.title} + + + + + + {#if !favorite && item.at} + (now = Date.now())} + role="note" + > + + + {/if} +
+ menuItems(item, favorite)} size="xs" fixedHeight={false} /> +
+
+{/snippet} + +{#snippet arrow(direction: 1 | -1)} + + +{/snippet} + + +{#if favoriteManager.loadedFor === workspace && (entries.length > 0 || emptiedBySettings) && !hidden} + +
+
+ + {#if favoriteCount > 0} + Favorites + {/if} + {#if favoriteCount > 0 && recentCount > 0} + · + {/if} + {#if recentCount > 0} + Recent + {/if} + +
+ +
+
+ {#if entries.length === 0} +

+ {homeQuickAccessSettings.showFavorites + ? 'No favorites yet. Star a script, flow or app to keep it here.' + : 'Nothing opened lately in this browser.'} +

+ {/if} +
+ +
+ {#each entries as { item, favorite }, i (item.kind + '/' + item.path)} + {@const at = cellPosition(i)} +
+ {@render card(item, favorite)} +
+ {/each} + {#if recentMore > 0} + {@const next = Math.min(recentMore, RECENT_COUNT)} + {@const at = cellPosition(entries.length)} +
+ +
+ {/if} +
+ {#if canBack} + {@render arrow(-1)} + {/if} + {#if canForward} + {@render arrow(1)} + {/if} +
+
+ + Workspace items + {#if itemCount != undefined} + + {/if} + +{/if} diff --git a/frontend/src/lib/components/home/HomeSectionCount.svelte b/frontend/src/lib/components/home/HomeSectionCount.svelte new file mode 100644 index 0000000000..043be77dae --- /dev/null +++ b/frontend/src/lib/components/home/HomeSectionCount.svelte @@ -0,0 +1,5 @@ + + +({count}) diff --git a/frontend/src/lib/components/home/HomeSectionLabel.svelte b/frontend/src/lib/components/home/HomeSectionLabel.svelte new file mode 100644 index 0000000000..54deb6067a --- /dev/null +++ b/frontend/src/lib/components/home/HomeSectionLabel.svelte @@ -0,0 +1,15 @@ + + + +

+ {@render children()} +

diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index fab76474e8..6b9ee2fc50 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -2,7 +2,6 @@ import CenteredPage from '$lib/components/CenteredPage.svelte' import { PIPELINE_DRAFT_KIND, pipelineFolderFromBundlePath } from '$lib/pipelinePaths' import { Button, Skeleton } from '$lib/components/common' - import Toggle from '$lib/components/Toggle.svelte' import { AssetService, FolderService, @@ -21,20 +20,21 @@ import { useEditRights } from '$lib/operatorWriteRights' import type uFuzzy from '@leeoniya/ufuzzy' import { - ArrowDownUp, Bot, + ChevronDown, ChevronsDownUp, ChevronsUpDown, Code2, LayoutDashboard, + MoreHorizontal, Tag } from 'lucide-svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import CreateActionsMenu from './CreateActionsMenu.svelte' import ContentSearchInner from '$lib/components/ContentSearchInner.svelte' - import type { Item as MenuItem } from '$lib/utils' + import type { IconType } from '$lib/utils' - import { HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts' + import { HOME_COLUMN_WIDTH, HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts' import SearchItems from '../SearchItems.svelte' import FilterSearchbar, { @@ -46,22 +46,22 @@ import HubProjectPickerModal from './HubProjectPickerModal.svelte' import type { HubProjectPick } from '$lib/hubProject' import ListFilters from './ListFilters.svelte' - import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' - import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte' - import FlowIcon from './FlowIcon.svelte' - import { getLocalSetting, isOwner, storeLocalSetting } from '$lib/utils' + import LabelBadge from '../common/badge/LabelBadge.svelte' + import { clampRows } from './clampRows' + import { setUnruledList } from '../common/table/unruledList' + import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' + import { isOwner } from '$lib/utils' import { sendUserToast } from '$lib/toast' 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 { effectivePath, type ItemType } from './treeViewUtils' import { tick, untrack } from 'svelte' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' - import PipelineRow from '../common/table/PipelineRow.svelte' import BulkActionsBar from './BulkActionsBar.svelte' - import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte' + import { HomeSelection, setHomeSelection } from './homeSelection.svelte' + import HomeQuickAccess from './HomeQuickAccess.svelte' const editRights = useEditRights() @@ -72,6 +72,8 @@ let { subtab = $bindable('script'), showEditButtons = true }: Props = $props() + setUnruledList() + // Which user-folder scoping toggle (if any) this role gets. Declared before the // FilterSearchbar schema and the derived filters that both read it. let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived( @@ -183,7 +185,7 @@ // (which carries only path + content), so the row-list filters can't // apply to it. When it's active we restrict the searchbar to just the content filter, // clear any other filters so they don't linger as ignored chips, and hide the row-list - // controls (kind toggle, tree view) that no longer drive anything. + // controls (kind filter, tree view) that no longer drive anything. let contentActive = $derived(!!filterValues.val.content) let searchbarSchema = $derived( contentActive ? { content: searchFilterSchema.content } : searchFilterSchema @@ -216,7 +218,7 @@ starred?: boolean hash?: string // Fetch ordinal: the position this row arrived in from the server (which already - // applied the chosen order + starred-first). Sorting by it reproduces the server's + // applied its order + starred-first). Sorting by it reproduces the server's // global order EXACTLY — no client re-derivation that could disagree on collation // or sub-millisecond ties and make a later page jump above shown rows. ord?: number @@ -320,29 +322,12 @@ let loading = $state(true) - let nbDisplayed = $state(15) - // Keyset cursor for the next browse page; null once the stream is exhausted. let serverCursor: string | undefined = undefined let hasMoreServer = $state(false) // Guards against out-of-order responses: only the latest request applies. let loadGen = 0 - // Sort selector value -> merged-endpoint order params. - function sortToParams(o: SortOrder): { orderBy: 'updated' | 'name'; orderDesc: boolean } { - switch (o) { - case 'updated_asc': - return { orderBy: 'updated', orderDesc: false } - case 'name_asc': - return { orderBy: 'name', orderDesc: false } - case 'name_desc': - return { orderBy: 'name', orderDesc: true } - case 'updated_desc': - default: - return { orderBy: 'updated', orderDesc: true } - } - } - function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp | TableAgent { const base = { ...it, @@ -363,15 +348,15 @@ } // The merged, server-ordered, keyset-paginated source. `reset` reloads from - // the first page (order/filter change or workspace switch); otherwise it + // the first page (filter change or workspace switch); otherwise it // appends the next page. Every kind arrives interleaved and are split into // the existing per-kind arrays so the downstream pipeline is unchanged. async function loadRunnables(reset: boolean): Promise { const ws = $workspaceStore if (!ws || !$userStore) return - // Only the very first load shows the skeleton; reorder/filter reloads keep - // the toolbar and current items visible (they're replaced on arrival) so the - // sort control itself doesn't flicker out when you use it. + // Only the very first load shows the skeleton; filter reloads keep the toolbar + // and current items visible (they're replaced on arrival) so the filter control + // itself doesn't flicker out when you use it. // An append (load-more) needs a live cursor. If there isn't one — the stream // is exhausted, or a reset just cleared it and its page-1 response hasn't // landed — bail so a load-more can't append a fresh page-1 onto the old @@ -381,23 +366,22 @@ if (reset) { serverCursor = undefined hasMoreServer = false - // Note: this resets only the global/flat stream. The lazy tree's own store + // Note: this resets only the global stream. The lazy tree's own store // (treeOwnerItems/ownerLoad/openOwners) is managed separately — a mode change - // clears it (see the treeKey effect), while an in-place sort/filter change + // clears it (see the treeKey effect), while an in-place filter change // replaces each open owner's rows atomically as they re-load (see - // loadOwnerItems), so expanded folders don't blank out mid-reorder. + // loadOwnerItems), so expanded folders don't blank out mid-reload. } - const { orderBy, orderDesc } = sortToParams(sortOrder) const gen = ++loadGen // Snapshot the cursor now: a later request must not consume a cursor minted - // by an order/filter that has since changed. + // by a filter that has since changed. const cursor = reset ? undefined : serverCursor let res: { items: RunnableItem[]; next_cursor?: string } try { res = await ScriptService.listRunnables({ workspace: ws, - orderBy, - orderDesc, + orderBy: 'updated', + orderDesc: true, showArchived: archived ? true : undefined, includeWithoutMain: includeWithoutMain ? true : undefined, kinds: runnableKinds, @@ -480,7 +464,7 @@ } if (have.has(itemKey(it))) continue // Appended after the browse window; a higher ord keeps them after it (search - // itself ranks by relevance, so this ordinal only matters if sort resumes). + // itself ranks by relevance, so this ordinal only matters once it is cleared). const mapped = { ...mapRunnable(it), ord: fetchOrd++ } if (it.type === 'script') s.push(mapped as TableScript) else if (it.type === 'flow') f.push(mapped as TableFlow) @@ -504,9 +488,9 @@ // // Rows for every prefix live in ONE store (`treeOwnerItems`), never in the global // browse arrays: those advance by a single `serverCursor`, so out-of-window rows there - // would make the flat stream non-contiguous and duplicate once pagination reached them. + // would make the global stream non-contiguous and duplicate once pagination reached them. // - // `treeGen` ties every request to the active scope (order/archived/library/kind/ + // `treeGen` ties every request to the active scope (archived/library/kind/ // workspace); a reset bumps it so an in-flight response from a stale scope is dropped. type OwnerLoadState = { cursor?: string @@ -515,7 +499,7 @@ loaded: boolean gen: number } - // Rows per request when loading a prefix in the tree. Larger than the flat list's + // Rows per request when loading a prefix in the tree. Larger than the global stream's // page because a tree row is a single line and a folder is opened to see what it // holds — most folders come in whole on the first click. const OWNER_PAGE_SIZE = 300 @@ -546,7 +530,7 @@ // `owner` is the full prefix a node covers: `f/`, `u/`, or any // folder under one. `force` re-fetches its first page even if already loaded (a - // re-sort / re-filter reload uses it to refresh the loaded rows in place); `all` + // re-filter reload uses it to refresh the loaded rows in place); `all` // keeps paging until the prefix's stream is exhausted instead of stopping at a page. async function loadOwnerItems( owner: string, @@ -562,7 +546,7 @@ openOwners.add(owner) const st = ownerLoad[owner] // Only a load for the CURRENT generation blocks a new one. A load left in flight - // by a superseded generation (treeGen bumped on a sort/filter reload) has already + // by a superseded generation (treeGen bumped on a filter reload) has already // been invalidated, so it must not wedge the owner as permanently loading. if (st?.loading && st.gen === treeGen) return if (!force && (more ? !st?.hasMore : st?.loaded)) return @@ -574,9 +558,8 @@ loaded: st?.loaded ?? false, gen } - const { orderBy, orderDesc } = sortToParams(sortOrder) const prefix = `${owner}/` - // Only a forced refresh replaces this prefix's rows, so a re-sort swaps them without + // Only a forced refresh replaces this prefix's rows, so a reload swaps them without // blanking the tree. Everything else merges: a nested prefix inherits rows from an // ancestor's pages, and one page of its own can cover fewer of them than are shown — // replacing would delete rows on the click meant to add them. @@ -618,8 +601,8 @@ try { res = await ScriptService.listRunnables({ workspace: ws, - orderBy, - orderDesc, + orderBy: 'updated', + orderDesc: true, showArchived: archived ? true : undefined, includeWithoutMain: includeWithoutMain ? true : undefined, kinds: runnableKinds, @@ -646,7 +629,7 @@ sendUserToast(`Failed to load ${owner}: ${e?.body ?? e?.message ?? e}`, true) return } - // The scope moved on while this was in flight (order/archive/library/kind/ + // The scope moved on while this was in flight (archive/library/kind/ // workspace changed and reset the tree); drop the response so stale rows from // another scope can't appear under the current one. if (gen !== treeGen) return @@ -678,13 +661,13 @@ } // Reload the merged list once and re-fetch the owners that are currently expanded so // they don't go blank. Used both for row mutations (create/edit/archive/move/share) - // and for in-place scope changes (sort/archive/library/kind): those keep the tree in + // and for in-place scope changes (archive/library/kind): those keep the tree in // lazy mode (treeKey unchanged, so folders stay open like a file explorer), and the - // re-fetch reloads each open owner's items in the new order/filter. When a mode + // re-fetch reloads each open owner's items under the new filter. When a mode // change (owner/search) has switched the tree out of lazy mode, there's nothing to // re-fetch — the tree remounts and groups the global stream instead. async function reloadItems(): Promise { - // Invalidate any in-flight owner loads from the previous sort/filter so their + // Invalidate any in-flight owner loads from the previous filter so their // late responses can't overwrite the fresh ones. treeGen++ const toReload = treeLazyMode ? [...openOwners] : [] @@ -720,7 +703,7 @@ } // For row mutations (create/delete/move/archive), which also change how many - // runnables an owner holds. A scope change (sort/archive/kind/…) doesn't go + // runnables an owner holds. A scope change (archive/kind/…) doesn't go // through here: the counts resource keys on those itself. async function reloadItemsAndCounts(): Promise { // The answer can change with the rows: archiving the last item leaves the listing empty @@ -769,45 +752,14 @@ const cmp = new Intl.Collator('en').compare - // The selected order maps to the endpoint's order_by/order_desc (see - // sortToParams) so it is applied server-side and stays correct across the - // whole workspace, not just loaded pages. Starred items are pinned on top of - // the first page. - type SortOrder = 'updated_desc' | 'updated_asc' | 'name_asc' | 'name_desc' - const SORT_SETTING_NAME = 'homeSort' - // `short` labels the trigger button next to the sort icon (the button is icon-only - // only while searching, when sorting is disabled — see below). - const sortOptions: { value: SortOrder; label: string; short: string }[] = [ - { value: 'updated_desc', label: 'Recently updated', short: 'Recent' }, - { value: 'updated_asc', label: 'Oldest updated', short: 'Oldest' }, - { value: 'name_asc', label: 'Name (A-Z)', short: 'A-Z' }, - { value: 'name_desc', label: 'Name (Z-A)', short: 'Z-A' } - ] - let sortOrder = $state( - sortOptions.find((o) => o.value === getLocalSetting(SORT_SETTING_NAME))?.value ?? 'updated_desc' - ) - $effect(() => { - storeLocalSetting(SORT_SETTING_NAME, sortOrder === 'updated_desc' ? undefined : sortOrder) - }) - let sortItems: MenuItem[] = $derived( - sortOptions.map((o) => ({ - displayName: o.label, - selected: o.value === sortOrder, - action: () => (sortOrder = o.value) - })) - ) - // Preserve the endpoint's exact order rather than re-deriving it on the client: - // each row carries its server fetch ordinal (`ord`), which already reflects the - // chosen order, the (path, kind) tiebreaks, full-precision timestamps, the database - // collation, and starred-first pinning. Sorting by it can never disagree with the - // server, so a later page never jumps above shown rows on "load more". Depends on - // `sortOrder` only so its identity changes when the order does (re-grouping the tree, - // whose leaves also sort by ord); the actual reordering comes from the reload that - // restamps ord. - let compareItems = $derived.by(() => { - sortOrder - return (a: { ord?: number }, b: { ord?: number }): number => (a.ord ?? 0) - (b.ord ?? 0) - }) + // Preserve the endpoint's exact order (most recently updated first, starred pinned on top + // of the first page) rather than re-deriving it on the client: each row carries its server + // fetch ordinal (`ord`), which already reflects the (path, kind) tiebreaks, full-precision + // timestamps and the database collation. Sorting by it can never disagree with the server, + // so a later page never jumps above shown rows on "load more". + function compareItems(a: { ord?: number }, b: { ord?: number }): number { + return (a.ord ?? 0) - (b.ord ?? 0) + } const opts: uFuzzy.Options = { sort: (info, haystack, needle) => { @@ -860,8 +812,8 @@ } } - const TREE_VIEW_SETTING_NAME = 'treeView' - let treeView = $state(getLocalSetting(TREE_VIEW_SETTING_NAME) == 'true') + let toolbarHeight = $state(0) + let chipsExpanded = $state(false) // Pipeline entries are rendered independently of the item list, so apply the // same gates the items get — otherwise a pipeline would still show under the @@ -982,15 +934,12 @@ // search (see the reload effect below). Only the label filter and fuzzy ranking // stay client-side over the loaded pages, so they don't reload here. let searching = $derived(filter !== '') - // Lazy owner tree is active only in the tree view when browsing all (no owner - // selected, no search, no label filter): every folder AND user shows as a top-level - // node and paginates on expand, its items coming from the separate `treeOwnerItems` - // store. In the flat view, or a selected owner / active search / label filter, the - // lazy store is unused (the flat list and reloadItems must not touch it), so this is - // false and those views group the global loaded window instead. - let treeLazyMode = $derived( - treeView && !searching && ownerFilter == undefined && labelFilter == undefined - ) + // Lazy owner tree is active only when browsing all (no owner selected, no search, no + // label filter): every folder AND user shows as a top-level node and paginates on + // expand, its items coming from the separate `treeOwnerItems` store. With a selected + // owner / active search / label filter, the lazy store is unused (reloadItems must not + // touch it), so this is false and the tree groups the global loaded window instead. + let treeLazyMode = $derived(!searching && ownerFilter == undefined && labelFilter == undefined) // How many runnables each owner (`f/` / `u/`) holds for this user, // in one request. It labels every tree node up front — a lazy owner's own count is // unknown until it's expanded — and lets both the tree and the owner chips drop the @@ -1018,6 +967,53 @@ } ) let ownerCounts = $derived(ownerCountsRes.current) + // Every path sits under a folder or a user, so the owners' counts add up to the workspace. + let workspaceItemCount = $derived( + ownerCounts == undefined ? undefined : Object.values(ownerCounts).reduce((sum, n) => sum + n, 0) + ) + + type KindOption = { + value: 'all' | 'script' | 'flow' | 'app' | 'agent' + label: string + icon?: IconType + /** The kind's colour in the rows (`RowIcon`). */ + iconClass?: string + } + const kindOptions: KindOption[] = [ + { value: 'all', label: 'All kinds' }, + { value: 'script', label: 'Scripts', icon: Code2, iconClass: 'text-blue-500' }, + ...(HOME_SEARCH_SHOW_FLOW + ? [ + { + value: 'flow', + label: 'Flows', + icon: BarsStaggered, + iconClass: 'text-teal-500' + } satisfies KindOption + ] + : []), + { value: 'app', label: 'Apps', icon: LayoutDashboard, iconClass: 'text-orange-500' }, + { value: 'agent', label: 'Agents', icon: Bot, iconClass: 'text-violet-500' } + ] + function setItemKind(v: KindOption['value']) { + // itemKind is derived from the shared filter object (which URL-syncs itself); `all` + // clears the kind filter (delete, not null, so it doesn't linger as a `kind: null` chip). + if (v === 'all') { + delete filterValues.val.kind + } else { + filterValues.val.kind = v + subtab = v + } + } + let kindItems = $derived( + kindOptions.map((o) => ({ + displayName: o.label, + icon: o.icon, + iconProps: o.iconClass ? { class: `shrink-0 ${o.iconClass}` } : undefined, + selected: itemKind === o.value, + action: () => setItemKind(o.value) + })) + ) // The counts decide which owners the tree renders, so drawing it before they land // would show every workspace folder and then prune it away. Hold the skeleton // until the first response instead — it is fetched in parallel with the listing, @@ -1027,16 +1023,8 @@ 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. - let justImported = $state(false) - let justImportedTimer: ReturnType | undefined function onImported() { reloadItemsAndCounts() - justImported = true - clearTimeout(justImportedTimer) - justImportedTimer = setTimeout(() => (justImported = false), 2500) } // The hub import, owned here rather than by either entry point: the empty state's link and @@ -1133,6 +1121,8 @@ * tell leaves it live as the fallback. */ let toolbarInert = $derived(placeholderTakesOver && !archivedUnknown) + // Expand/collapse: nothing to group on an empty workspace. + let listControls = $derived(!loading && !workspaceEmpty) // Owners the counts found the user has something in, split by kind. They cover // what the folder/username lists miss: an item shared individually out of a @@ -1152,7 +1142,7 @@ const s = new Set() // Always inject your own personal space (u/): list_usernames can be empty // (you may not be a listed workspace member) yet u/ still holds runnables, - // and it must not vanish under a name sort whose first page is all folders. + // and it must not vanish when the first page holds none of its rows. if ($userStore?.username) s.add($userStore.username) // Other users only when no user-folder restriction is active. if (!filterUserFolders) { @@ -1163,18 +1153,17 @@ }) // The bottom "load more" only pages the *global* stream, which in lazy mode holds // folder rows the tree ignores (folders come from the store and are all injected - // already) — so surfacing it there is confusing. Restrict it to scoped mode, where - // it pages within the selected owner. In lazy mode the footer instead reveals more - // root nodes purely client-side (nbDisplayed) when there are more than are shown, - // and each folder paginates within itself; users past the window are reached via - // their owner chip. - let treeGlobalHasMore = $derived(ownerFilter != undefined && !searching ? hasMoreServer : false) + // already) — so surfacing it there is confusing. Offer it wherever the tree groups that + // stream instead: a selected owner pages within it, and a label filter with no owner + // matches against it, so later pages hold matches the first one lacks. In lazy mode each + // folder paginates within itself; users past the window are reached via their owner chip. + let treeGlobalHasMore = $derived(!treeLazyMode && !searching ? hasMoreServer : false) $effect(() => { if ($userStore && $workspaceStore) { - ;[archived, includeWithoutMain, sortOrder, searching, ownerFilter, itemKind] - // reloadItems (not a bare loadRunnables) so an in-place change — sort, archive, - // library, kind — reloads the global stream AND re-fetches the currently-open - // owners in the new order/filter, keeping folders expanded (file-explorer style) + ;[archived, includeWithoutMain, searching, ownerFilter, itemKind] + // reloadItems (not a bare loadRunnables) so an in-place change — archive, library, + // kind — reloads the global stream AND re-fetches the currently-open owners + // under the new filter, keeping folders expanded (file-explorer style) // rather than collapsing them. Mode changes (owner/search) flip treeLazyMode, so // reloadItems skips the re-fetch and the {#key} remount handles the reset. untrack(() => { @@ -1281,8 +1270,6 @@ return mergeRunnables(res.items ?? []) searchCursor = res.next_cursor - // Reveal the freshly fetched matches instead of leaving them behind the display cap. - nbDisplayed += 30 } let combinedItems = $derived( @@ -1382,16 +1369,18 @@ ) // Remount identity: only a *mode* change (workspace, selected owner, entering/leaving // search, label filter) restructures the tree, so only those key the {#key} remount. - // A sort/archive/library/kind change keeps the same lazy tree and is applied in place + // An archive/library/kind change keeps the same lazy tree and is applied in place // by reloadItems re-fetching the open owners — so expanded folders don't collapse when - // you re-sort (searching is a boolean so per-keystroke query changes don't remount). - let treeKey = $derived( - `${treeView}|${$workspaceStore}|${ownerFilter}|${searching}|${labelFilter}` - ) + // you change it (searching is a boolean so per-keystroke query changes don't remount). + let treeKey = $derived(`${$workspaceStore}|${ownerFilter}|${searching}|${labelFilter}`) // A mode change restructures the tree (it remounts on treeKey), so the lazy owner // store is stale — clear it here (the global reset no longer does). An in-place - // sort/filter change leaves treeKey unchanged, so the store survives and its owners + // filter change leaves treeKey unchanged, so the store survives and its owners // are refreshed in place instead of blanking. + // A selected owner or label narrows the tree to what matches, so its folders open on + // the way in and close again on the way out; in between, expand/collapse stays the + // user's. Closed again because "expand all" in the lazy tree loads every owner it opens. + let wasScoped = false $effect(() => { treeKey untrack(() => { @@ -1399,35 +1388,25 @@ ownerLoad = {} treeOwnerItems = [] openOwners = new Set() + const scoped = ownerFilter != undefined || labelFilter != undefined + if (scoped !== wasScoped) collapseAll = !scoped + wasScoped = scoped }) }) - let displayedItems = $derived((items ?? []).slice(0, nbDisplayed)) $effect(() => { items && resetScroll() }) - let selectedIndex: number = $state(-1) - // More to show: either loaded items not yet sliced in, or the server has further - // browse pages (not while searching — the browse cursor is paused then; further - // search matches load on demand via searchCursor / "Load more results"). - let hasMore = $derived( - items != undefined && (items.length > nbDisplayed || (hasMoreServer && !searching)) - ) - let loadMoreIndex = $derived(displayedItems.length) - let loadMoreEl: HTMLButtonElement | undefined = $state() - let pendingAutoSelect = $state(true) let firstWorkspaceRun = true $effect(() => { $workspaceStore - pendingAutoSelect = true if (firstWorkspaceRun) { 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. + // button asynchronously after the menu closes. Run several times to win the focus + // race and hand the keyboard back to the search. const focusSearch = () => { const el = document.getElementById('home-search-input') as HTMLInputElement | null el?.focus() @@ -1443,303 +1422,36 @@ clearTimeout(timeoutId) } }) - $effect(() => { - filter - itemKind - ownerFilter - labelFilter - // Skip while pendingAutoSelect is true (initial load / workspace switch); - // the auto-select effect below will set the index once items appear. - if (!pendingAutoSelect) { - selectedIndex = -1 - } - }) - $effect(() => { - if (pendingAutoSelect && displayedItems.length > 0) { - selectedIndex = 0 - pendingAutoSelect = false - } - }) - $effect(() => { - const max = hasMore ? displayedItems.length : displayedItems.length - 1 - if (selectedIndex > max) { - selectedIndex = max - } - }) - $effect(() => { - if (hasMore && selectedIndex === loadMoreIndex) { - loadMoreEl?.scrollIntoView({ block: 'nearest' }) - } - }) - // Capture-phase listener so we run before melt-ui's button keydown handlers - // (e.g. ArrowDown on the dropdown trigger would otherwise open the menu). + // Capture-phase listener so we run before melt-ui's button keydown handlers. $effect(() => { window.addEventListener('keydown', handleGlobalKeydown, true) return () => window.removeEventListener('keydown', handleGlobalKeydown, true) }) - async function loadMore() { - // Fetch the next server page once all loaded items are already sliced in. - if (items && nbDisplayed >= items.length && hasMoreServer && !searching) { - await loadRunnables(false) - } - nbDisplayed += 30 - } - - // Fetch the next server page directly (tree view and the empty-state control - // use this — their "all shown" threshold is on a different scale than the flat - // list's, so they must not route through loadMore's item-count check). + // Fetch the next server page (the tree's scoped footer and the empty-state control). async function fetchMoreServer() { if (hasMoreServer && !searching) { await loadRunnables(false) - nbDisplayed += 30 } } - async function loadMoreAndPreselectFirstNew() { - const previousNbDisplayed = nbDisplayed - await loadMore() - selectedIndex = previousNbDisplayed - } - - // The searchbar is a contenteditable, not an , so it can't be matched by SKIP_SELECTOR - // and has no `.value`/`.selectionEnd`. It owns the arrows only while its suggestion dropdown is - // open (free-text mode passes them through to the list); track that so nav stands down then. - let searchbarDropdownOpen = $state(false) - - // Caret position inside the searchbar's contenteditable, via the Selection API — the equivalent - // of an 's selectionStart/End the list navigation used before the searchbar swap. - function searchCaret(el: HTMLElement): { atStart: boolean; atEnd: boolean; empty: boolean } { - const text = el.textContent ?? '' - const sel = window.getSelection() - if (!sel || sel.rangeCount === 0) - return { atStart: true, atEnd: true, empty: text.length === 0 } - const range = sel.getRangeAt(0) - const pre = range.cloneRange() - pre.selectNodeContents(el) - pre.setEnd(range.endContainer, range.endOffset) - const caret = pre.toString().length - return { atStart: caret === 0, atEnd: caret >= text.length, empty: text.length === 0 } - } - - // Elements that own the keyboard themselves (menus, dialogs, comboboxes): the - // list's own shortcuts stand down while one of them has focus. + // Elements that own the keyboard themselves (menus, dialogs, comboboxes). const SKIP_SELECTOR = '[role="menu"], [role="menuitem"], [role="dialog"], [role="listbox"], [role="combobox"], [aria-expanded="true"], [data-menu], [data-chat-keyboard-scope]' - // The marker sits on the row itself, not on its title link — selection mode - // drops the link, and the action buttons must stay keyboard-reachable there too. - function getSelectedRowActionButtons(): HTMLElement[] { - const actions = document.querySelector( - '[data-row-keyboard-selected="true"] [data-row-actions]' - ) - return actions ? Array.from(actions.querySelectorAll('button, a[href]')) : [] - } - + // Escape leaves selection mode, unless a menu that owns the key closes itself first. function handleGlobalKeydown(e: KeyboardEvent) { // An open dialog owns the keyboard. Testing the focused element alone misses it - // (a modal opened from a button leaves focus on that button), and this capture - // listener runs before the dialog's, so Enter would tick a row into the batch - // being confirmed. Dialogs are in the DOM only while open. + // (a modal opened from a button leaves focus on that button). Dialogs are in the + // DOM only while open. if (document.querySelector('[role="dialog"]')) return - + if (e.key !== 'Escape' || !homeSelection.active) return const target = e.target as HTMLElement | null - - // Escape leaves selection mode from either view; everything below is flat-list - // navigation. A menu that owns the key closes itself first. - if (e.key === 'Escape' && homeSelection.active) { - const active = document.activeElement as HTMLElement | null - if (!target?.closest(SKIP_SELECTOR) && !active?.closest(SKIP_SELECTOR)) { - e.preventDefault() - homeSelection.exit() - return - } - } - if (treeView) return - - // 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). - // - 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 - // trigger carries [data-menu] (which would otherwise filter the event out). - // Up/Down also need stopImmediatePropagation so melt-ui's dropdown trigger - // doesn't open the menu (its default ArrowDown behavior). - const actionsContainer = target?.closest('[data-row-actions]') - if (actionsContainer) { - if ( - e.key !== 'ArrowRight' && - e.key !== 'ArrowLeft' && - e.key !== 'ArrowUp' && - e.key !== 'ArrowDown' - ) - return - const buttons = Array.from(actionsContainer.querySelectorAll('button, a[href]')) - const currentIdx = buttons.indexOf(target as HTMLElement) - if (currentIdx < 0) return - if (e.key === 'ArrowRight') { - if (currentIdx < buttons.length - 1) { - e.preventDefault() - buttons[currentIdx + 1].focus() - } - } else if (e.key === 'ArrowLeft') { - 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. - e.preventDefault() - e.stopImmediatePropagation() - if (selectedIndex < 0 || selectedIndex >= displayedItems.length) return - const newIndex = - e.key === 'ArrowDown' - ? Math.min(selectedIndex + 1, displayedItems.length - 1) - : Math.max(selectedIndex - 1, 0) - if (newIndex === selectedIndex) return - selectedIndex = newIndex - tick().then(() => { - const newButtons = getSelectedRowActionButtons() - if (newButtons.length === 0) return - const targetIdx = Math.min(currentIdx, newButtons.length - 1) - newButtons[targetIdx]?.focus() - }) - } - return - } - - // Inside an open dropdown menu: ArrowUp on first item / ArrowDown on last item - // closes the menu (so users can leave with arrows instead of needing Escape). - // Other arrow keys fall through to melt-ui's default cycle. - const menuItem = target?.closest('[role="menuitem"]') - if (menuItem) { - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - const menu = menuItem.closest('[role="menu"]') - // menus marked data-arrow-loop keep melt's cyclic wrap instead of exiting - if (menu && !menu.hasAttribute('data-arrow-loop')) { - const items = Array.from(menu.querySelectorAll('[role="menuitem"]')) - const idx = items.indexOf(menuItem) - const isFirst = idx === 0 - const isLast = idx === items.length - 1 - if ((e.key === 'ArrowUp' && isFirst) || (e.key === 'ArrowDown' && isLast)) { - e.preventDefault() - e.stopImmediatePropagation() - menuItem.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) - ) - } - } - } - return - } - - const skipSelector = SKIP_SELECTOR - if (target) { - 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 - // While the searchbar's suggestion dropdown is open it owns the arrows/Enter itself. - if (isOurSearch && searchbarDropdownOpen) 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. - if (e.key === 'ArrowRight') { - if (target?.id === 'home-search-input') { - const c = searchCaret(target) - if (!c.empty && !c.atEnd) return - } - if (selectedIndex < 0 || selectedIndex >= displayedItems.length) return - const buttons = getSelectedRowActionButtons() - if (buttons.length > 0) { - e.preventDefault() - buttons[0].focus() - } - 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 c = searchCaret(target) - if (!c.empty && !c.atStart) return - } - return - } - - if (e.key === 'ArrowDown') { - if (displayedItems.length === 0) return - e.preventDefault() - if (selectedIndex === -1) { - selectedIndex = 0 - } else if (selectedIndex === loadMoreIndex && hasMore) { - selectedIndex = 0 - } else if (selectedIndex === displayedItems.length - 1) { - selectedIndex = hasMore ? loadMoreIndex : 0 - } else { - selectedIndex = selectedIndex + 1 - } - } else if (e.key === 'ArrowUp') { - if (displayedItems.length === 0) return - e.preventDefault() - if (selectedIndex === -1) { - selectedIndex = displayedItems.length - 1 - } else if (selectedIndex === loadMoreIndex && hasMore) { - selectedIndex = displayedItems.length - 1 - } else if (selectedIndex === 0) { - selectedIndex = hasMore ? loadMoreIndex : displayedItems.length - 1 - } else { - selectedIndex = selectedIndex - 1 - } - } else if (e.key === 'Enter') { - // Enter belongs to whatever control has focus — the action bar's buttons, - // a row's own actions, a link. Claiming it there would both suppress that - // control and act on the highlighted row instead. - if (target?.closest('button, a[href], [role="button"]')) return - // In selection mode the rows carry no link, so Enter ticks the highlighted - // row instead of opening it. Never for a legacy raw-app row, which carries - // no selection control — that falls through to opening it below. - if ( - homeSelection.active && - selectedIndex >= 0 && - selectedIndex < displayedItems.length && - displayedItems[selectedIndex].type !== 'raw_app' - ) { - e.preventDefault() - homeSelection.toggle( - toBulkItem(displayedItems[selectedIndex], $userStore, $workspaceStore), - e.shiftKey - ) - } else if (selectedIndex === loadMoreIndex && hasMore) { - e.preventDefault() - loadMoreAndPreselectFirstNew() - } else if (selectedIndex >= 0 && selectedIndex < displayedItems.length) { - // Direct child only: that is the title link. Selection mode drops it, and - // a descendant match would find the row's Edit link and open the editor. - const anchor = document.querySelector( - '[data-row-keyboard-selected="true"] > a[href]' - ) - if (anchor) { - e.preventDefault() - anchor.click() - } - } - } else if (e.key === 'Escape') { - if (selectedIndex !== -1) { - e.preventDefault() - selectedIndex = -1 - } - } + if (target?.closest(SKIP_SELECTOR) || active?.closest(SKIP_SELECTOR)) return + e.preventDefault() + homeSelection.exit() } - $effect(() => { - storeLocalSetting(TREE_VIEW_SETTING_NAME, treeView ? 'true' : undefined) - }) // Multi-selection + bulk actions. Published through context so the tree's // nested levels don't have to carry it; `Item` is the only reader. const homeSelection = new HomeSelection() @@ -1795,273 +1507,224 @@ - -
- {#if !contentActive} - -
- { - // itemKind is derived from the shared filter object (which URL-syncs itself); - // `all` clears the kind filter (delete, not null, so it doesn't linger as a - // `kind: null` chip). - if (v === 'all') { - delete filterValues.val.kind - } else { - filterValues.val.kind = v - subtab = v - } - }} + + + {#if !searching && !contentActive && !workspaceEmpty && $workspaceStore} + + {/if} + +
+
+ +
+
- {#snippet children({ item })} - - - {#if HOME_SEARCH_SHOW_FLOW} - - {/if} - - - {/snippet} - -
- {/if} - - {#if !loading && !contentActive && !workspaceEmpty} - -
- - {#if treeView} + +
+ {#if !contentActive} + +
+ + {#snippet buttonReplacement()} + {@const active = kindOptions.find((o) => o.value === itemKind)} + + {/snippet} + +
+ {/if} + {#if !contentActive && listControls} + /> {/if} - - {#snippet buttonReplacement()} - {@const active = sortOptions.find((o) => o.value === sortOrder)} - {@const short = filter !== '' ? '' : (active?.short ?? '')} - - {/snippet} - -
- {/if} - -
-
- (searchbarDropdownOpen = v)} - /> -
- - {#if canCreateHere} - - (hubPickerOpen = true)} - /> - {/if} + (hubPickerOpen = true)} + /> + {/if} +
-
- {#if !contentActive && hasChips} - + {#if !contentActive && hasChips} + -
- ownerFilter, setOwnerFilter} - filters={owners} - queryName="owner" - maxDisplayed={10} - /> - labelFilter, setLabelFilter} - filters={allLabels} - queryName="label" - maxDisplayed={10} - color="blue" - icon={Tag} - /> -
- {/if} - {#if filteredItems?.length == 0 && !workspaceEmpty} -
- {/if} -
- {#if filterValues.val.content} - +
+ {/if} + {#if filteredItems?.length == 0 && !workspaceEmpty} +
+ {/if} + +
+ {#if filterValues.val.content} + - -
- {#key $workspaceStore} - - {/key} -
- {:else if filteredItems == undefined || treeCountsPending} -
- - {#each new Array(6) as _} - - {/each} - {:else if filteredItems.length === 0 && (filter !== '' || visiblePipelineFolders.size === 0)} - +
+ {#key $workspaceStore} + + {/key} +
+ {:else if filteredItems == undefined || treeCountsPending} +
+
+ + {#each new Array(6) as _} + + {/each} +
+ {:else if filteredItems.length === 0 && (filter !== '' || visiblePipelineFolders.size === 0)} +
+ - {#if workspaceEmpty} - - {#if emptyStateAnswered} - {#if archivedProbe?.hasArchived || canCreateHere} - - (hubPick = project)} - onShowArchived={() => (filterValues.val = { ...filterValues.val, archived: true })} - /> + (hubPick = project)} + onShowArchived={() => + (filterValues.val = { ...filterValues.val, archived: true })} + /> + {:else} + + {/if} + {/if} {:else} {/if} - {/if} - {:else} - - {/if} - {#if hasMoreServer && !searching} - -
- +
+ +
+ {/if}
- {/if} - {:else if treeView} - - {#key treeKey} - - {/key} - {:else} -
- {#if filter === ''} - {#each [...visiblePipelineFolders].sort() as folder (folder)} - - {/each} - {/if} - {#each displayedItems as item, i (item.type + '/' + item.path + (item.hash ? '/' + item.hash : ''))} - - {/each} -
- {#if items && hasMore} - {Math.min(nbDisplayed, items.length)} items{hasMoreServer && !searching - ? '' - : ` out of ${items.length}`} - + {/key} {/if} - {/if} - {#if searching && searchCursor} - -
- {#if searchLoadingMore} - Loading more results… - {:else} - - {/if} -
- {/if} - {#if homeSelection.active} - -
- {/if} +
+ {#if searchLoadingMore} + Loading more results… + {:else} + + {/if} +
+ {/if} + {#if homeSelection.active} + +
+ {/if} +
@@ -2136,59 +1785,3 @@ (hubPick = undefined)} {onImported} /> {/await} {/if} - - diff --git a/frontend/src/lib/components/home/ListFilters.svelte b/frontend/src/lib/components/home/ListFilters.svelte index 5482c59c42..9714b18aa4 100644 --- a/frontend/src/lib/components/home/ListFilters.svelte +++ b/frontend/src/lib/components/home/ListFilters.svelte @@ -1,6 +1,6 @@ {#if Array.isArray(filtersAndSelected) && filtersAndSelected.length > 0}
- {#each displayedFilters as filter (filter)} -
- + { selectedFilter = selectedFilter == filter ? undefined : filter writeQuery(selectedFilter) }} - {color} - clickable - selected={filter === selectedFilter} > - - {#if icon} - {@const Icon = icon} - - {:else if resourceType} - {@const SvelteComponent = appIconComponent(filter)} - {#if SvelteComponent} - - {/if} - {:else if filter.startsWith('u/')} - - {:else if filter.startsWith('f/')} - + {#if !icon && resourceType} + {@const AppIcon = appIconComponent(filter)} + {#if AppIcon} + {/if} - + {/if} {resourceType ? integrationDisplayName(filter) : filter} - {#if filter === selectedFilter}✗{/if} - +
{/each} - {#if hiddenCount > 0} -
- (expanded = !expanded)} - > - {expanded ? 'Show less' : '…'} - -
- {/if}
{/if} diff --git a/frontend/src/lib/components/home/TreeOwnerRow.svelte b/frontend/src/lib/components/home/TreeOwnerRow.svelte new file mode 100644 index 0000000000..6a5f95dee3 --- /dev/null +++ b/frontend/src/lib/components/home/TreeOwnerRow.svelte @@ -0,0 +1,58 @@ + + + + +
+
0 ? `padding-left: ${indent}px;` : ''} + > + +
+ {name} +
+ {@render detail()} +
+
+
+ + +
diff --git a/frontend/src/lib/components/home/TreeView.svelte b/frontend/src/lib/components/home/TreeView.svelte index 806787cb76..37d97b1f61 100644 --- a/frontend/src/lib/components/home/TreeView.svelte +++ b/frontend/src/lib/components/home/TreeView.svelte @@ -3,13 +3,14 @@ import { createEventDispatcher, onDestroy, untrack, type Snippet } from 'svelte' import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte' - import { ChevronDown, ChevronUp, Folder, FolderTree, User } from 'lucide-svelte' + import { Folder, FolderTree, User } from 'lucide-svelte' + import TreeOwnerRow from './TreeOwnerRow.svelte' import PipelineRow from '../common/table/PipelineRow.svelte' import Item from './Item.svelte' import { countLeaves, type FolderItem, type ItemType, type UserItem } from './treeViewUtils' - import { twMerge } from 'tailwind-merge' import { pluralize } from '$lib/utils' import { Button } from '$lib/components/common' + import { inUnruledList, rowRule } from '../common/table/unruledList' interface Props { item: ItemType | FolderItem | UserItem @@ -72,6 +73,8 @@ leaf }: Props = $props() + const unruled = inUnruledList() + let visualDepth = $derived(depth + indent) const dispatch = createEventDispatcher<{ reload: void }>() @@ -262,56 +265,30 @@ {#if isFolder(item) || isUser(item)}
- - -
-
0 ? `padding-left: ${visualDepth * 16}px;` : ''} - > -
- {#if isUser(item)} - - {:else if depth === 0} - - {:else} - - {/if} -
- -
- - {#if isUser(item)}u/{item.username}{:else}{#if depth === 0}f/{/if}{item.folderName}{/if} - -
- {#if ownerCount != undefined} - ({pluralize(ownerCount, ' item')}) - {:else if isLazyOwner && !ownerState?.loaded} - -   - {:else if nodeHasMore} - - ({loadedHere}+ items) - {:else} - ({pluralize(loadedHere, ' item')}) - {/if} -
-
-
- -
+ {/snippet} + @@ -350,7 +327,9 @@ {/each} {#if effectiveMax < item.items.length}
diff --git a/frontend/src/lib/components/home/TreeViewRoot.svelte b/frontend/src/lib/components/home/TreeViewRoot.svelte index 753feb6a8b..793667ce5b 100644 --- a/frontend/src/lib/components/home/TreeViewRoot.svelte +++ b/frontend/src/lib/components/home/TreeViewRoot.svelte @@ -3,7 +3,9 @@ import TreeView from './TreeView.svelte' import { countLeaves, groupItems, type ItemType, type UserItem } from './treeViewUtils' import { Button } from '$lib/components/common' - import { ChevronDown, ChevronUp, Users } from 'lucide-svelte' + import { Users } from 'lucide-svelte' + import TreeOwnerRow from './TreeOwnerRow.svelte' + import { inUnruledList, rowRule } from '../common/table/unruledList' import { getLocalSetting, pluralize, storeLocalSetting } from '$lib/utils' interface Props { @@ -40,6 +42,8 @@ onExpandOwner?: (owner: string, more?: boolean, opts?: { all?: boolean }) => void onCollapseOwner?: (owner: string) => void showEditButton?: boolean + /** Off where the tree is part of its host's list rather than a box of its own (Home). */ + framed?: boolean // Tuck every user space but `selfUsername`'s into one collapsible "Other users" row // under it. Only for browsing: a search or filter must not hide its matches behind // a closed row. @@ -66,10 +70,13 @@ onExpandOwner, onCollapseOwner, showEditButton = true, + framed = true, groupOtherUsers = false, leaf }: Props = $props() + const unruled = inUnruledList() + // How many root nodes render at once. A root node is a collapsed owner row that // fetches nothing until expanded, so a large slice costs a row each and no request // — and an owner sliced off the end is indistinguishable from one that doesn't @@ -280,42 +287,25 @@ -
+
{#each rows.slice(0, nbDisplayed) as row (row.kind === 'otherUsers' ? 'other_users' : 'folderName' in row.node ? `f__${row.node.folderName}` : 'username' in row.node ? `u__${row.node.username}` : `i__${row.node.type}__${row.node.path}`)} {#if row.kind === 'otherUsers'} - - -
-
- -
- Other users -
- ({pluralize(otherUsers.length, 'user')}{otherUsersItemCount != undefined - ? ` · ${pluralize(otherUsersItemCount, 'item')}` - : ''}) -
-
-
-
+ {#snippet detail()} + ({pluralize(otherUsers.length, 'user')}{otherUsersItemCount != undefined + ? ` · ${pluralize(otherUsersItemCount, 'item')}` + : ''}) + {/snippet} + {#if otherUsersOpen} @@ -324,7 +314,9 @@ {/each} {#if nbOtherUsersDisplayed < otherUsers.length}
Showing {nbOtherUsersDisplayed} of {otherUsers.length} users diff --git a/frontend/src/lib/components/home/WorkspaceEmptyState.svelte b/frontend/src/lib/components/home/WorkspaceEmptyState.svelte index 33b8ee0e16..813cb3a265 100644 --- a/frontend/src/lib/components/home/WorkspaceEmptyState.svelte +++ b/frontend/src/lib/components/home/WorkspaceEmptyState.svelte @@ -57,7 +57,7 @@
diff --git a/frontend/src/lib/components/home/clampRows.ts b/frontend/src/lib/components/home/clampRows.ts new file mode 100644 index 0000000000..b62b45dcf5 --- /dev/null +++ b/frontend/src/lib/components/home/clampRows.ts @@ -0,0 +1,65 @@ +import type { Action } from 'svelte/action' + +export type ClampRowsOptions = { rows: number; expanded: boolean } + +/** + * Keeps a wrapping row of `[data-chip]` items to `rows` lines, measured rather than counted, so + * how many fit follows the width. The `[data-clamp-toggle]` child shows only on overflow, and + * while collapsed it takes the end of the last line: items are hidden from there back until it + * fits. A `[data-selected]` item is never hidden, since clicking it is how its filter clears. + */ +export const clampRows: Action = (node, initial) => { + let options = initial + let frame = 0 + let width = -1 + + function run() { + frame = 0 + const toggle = node.querySelector('[data-clamp-toggle]') + const items = Array.from(node.querySelectorAll('[data-chip]')) + for (const item of items) item.hidden = false + if (toggle) toggle.hidden = true + + // Every position is read before any item is hidden: reading one after a write forces a + // layout per item, quadratic in the number of chips. + const tops = items.map((item) => item.offsetTop) + const lines = [...new Set(tops)].sort((a, b) => a - b) + if (lines.length <= options.rows || !toggle) return + toggle.hidden = false + if (options.expanded) return + + const lastLine = lines[options.rows - 1] + items.forEach((item, i) => { + if (tops[i] > lastLine && !item.hasAttribute('data-selected')) item.hidden = true + }) + const hideable = items.filter((item) => !item.hidden && !item.hasAttribute('data-selected')) + while (toggle.offsetTop > lastLine && hideable.length) hideable.pop()!.hidden = true + } + + function schedule() { + if (!frame) frame = requestAnimationFrame(run) + } + + // Width only: hiding items changes the height, which must not measure again. + const resize = new ResizeObserver(([entry]) => { + if (entry.contentRect.width === width) return + width = entry.contentRect.width + schedule() + }) + resize.observe(node) + // The items themselves changing; the `hidden` flips above are attributes, not seen here. + const mutations = new MutationObserver(schedule) + mutations.observe(node, { childList: true, subtree: true, characterData: true }) + + return { + update(next) { + options = next + schedule() + }, + destroy() { + cancelAnimationFrame(frame) + resize.disconnect() + mutations.disconnect() + } + } +} diff --git a/frontend/src/lib/components/home/homeQuickAccessSettings.svelte.ts b/frontend/src/lib/components/home/homeQuickAccessSettings.svelte.ts new file mode 100644 index 0000000000..af88da656a --- /dev/null +++ b/frontend/src/lib/components/home/homeQuickAccessSettings.svelte.ts @@ -0,0 +1,56 @@ +import { BROWSER } from 'esm-env' +import { getLocalSetting, storeLocalSetting } from '$lib/utils' + +// Per browser, like the composer's own pin: how a reader likes their Home laid out. +const HIDDEN_SETTING = 'home-quick-access-hidden' +const ROWS_SETTING = 'home-quick-access-rows' +const NO_FAVORITES_SETTING = 'home-quick-access-no-favorites' +const NO_RECENT_SETTING = 'home-quick-access-no-recent' + +export const QUICK_ACCESS_ROWS = [1, 2, 3, 4] as const +export type QuickAccessRows = (typeof QUICK_ACCESS_ROWS)[number] +const DEFAULT_ROWS: QuickAccessRows = 2 + +// Blocked storage (some private windows) throws on write; the setting then lasts the visit. +function save(name: string, value: string | undefined) { + try { + storeLocalSetting(name, value) + } catch {} +} + +function readRows(): QuickAccessRows { + const raw = getLocalSetting(ROWS_SETTING) + return QUICK_ACCESS_ROWS.find((r) => String(r) === raw) ?? DEFAULT_ROWS +} + +class HomeQuickAccessSettings { + hidden = $state(BROWSER && getLocalSetting(HIDDEN_SETTING) === 'true') + rows = $state(BROWSER ? readRows() : DEFAULT_ROWS) + showFavorites = $state(!(BROWSER && getLocalSetting(NO_FAVORITES_SETTING) === 'true')) + showRecent = $state(!(BROWSER && getLocalSetting(NO_RECENT_SETTING) === 'true')) + /** Bumped on each hide, so the header menu that holds the way back can flash. */ + hideCount = $state(0) + + setHidden(next: boolean) { + this.hidden = next + if (next) this.hideCount++ + save(HIDDEN_SETTING, next ? 'true' : undefined) + } + + setShowFavorites(next: boolean) { + this.showFavorites = next + save(NO_FAVORITES_SETTING, next ? undefined : 'true') + } + + setShowRecent(next: boolean) { + this.showRecent = next + save(NO_RECENT_SETTING, next ? undefined : 'true') + } + + setRows(next: QuickAccessRows) { + this.rows = next + save(ROWS_SETTING, next === DEFAULT_ROWS ? undefined : String(next)) + } +} + +export const homeQuickAccessSettings = new HomeQuickAccessSettings() diff --git a/frontend/src/lib/components/home/recentItems.svelte.ts b/frontend/src/lib/components/home/recentItems.svelte.ts new file mode 100644 index 0000000000..edc80e8aba --- /dev/null +++ b/frontend/src/lib/components/home/recentItems.svelte.ts @@ -0,0 +1,99 @@ +import { onUserChange, scopedKey } from '$lib/userScopedStorage' + +export type RecentKind = 'script' | 'flow' | 'app' | 'raw_app' | 'agent' + +export type RecentItem = { + kind: RecentKind + path: string + summary?: string + /** Epoch ms of the last time the item was opened in its editor. */ + at: number +} + +const MAX_ITEMS = 30 +export const MAX_AGE_DAYS = 7 +const MAX_AGE_MS = MAX_AGE_DAYS * 24 * 60 * 60 * 1000 + +function fresh(items: RecentItem[]): RecentItem[] { + const cutoff = Date.now() - MAX_AGE_MS + return items.filter((x) => typeof x.at === 'number' && x.at >= cutoff) +} + +// Per user (scopedKey) and per workspace: what one person opened in one workspace says nothing +// about another, and a shared browser must not show the previous user's items. +function storageKey(workspace: string): string | undefined { + return scopedKey(`homeRecentItems:${workspace}`) +} + +function read(workspace: string): RecentItem[] { + const key = storageKey(workspace) + if (!key) return [] + try { + const parsed = JSON.parse(localStorage.getItem(key) ?? '[]') + return Array.isArray(parsed) + ? parsed.filter((x) => x && typeof x.path === 'string' && typeof x.kind === 'string') + : [] + } catch { + return [] + } +} + +function write(workspace: string, items: RecentItem[]): void { + const key = storageKey(workspace) + if (!key) return + try { + localStorage.setItem(key, JSON.stringify(items)) + } catch {} +} + +class RecentItems { + // Bumped on every write and on the user resolving, so readers re-read storage. + #version = $state(0) + // A page opened by a cold load can finish loading before the user's email resolves, and the + // storage key needs it; those visits wait here instead of being dropped. + #pending: { workspace: string; item: Omit }[] = [] + + constructor() { + onUserChange((email) => { + if (email) { + const pending = this.#pending + this.#pending = [] + for (const p of pending) this.record(p.workspace, p.item) + } + this.#version++ + }) + } + + list(workspace: string): RecentItem[] { + this.#version + return fresh(read(workspace)) + } + + /** Records an item as just opened. A missing summary keeps the one already stored. */ + record(workspace: string, item: Omit): void { + if (!workspace || !item.path) return + if (!storageKey(workspace)) { + this.#pending.push({ workspace, item }) + return + } + const previous = fresh(read(workspace)) + const existing = previous.find((x) => x.kind === item.kind && x.path === item.path) + const next: RecentItem = { + ...item, + summary: item.summary || existing?.summary, + at: Date.now() + } + write(workspace, [next, ...previous.filter((x) => x !== existing)].slice(0, MAX_ITEMS)) + this.#version++ + } + + forget(workspace: string, kind: RecentKind, path: string): void { + write( + workspace, + read(workspace).filter((x) => !(x.kind === kind && x.path === path)) + ) + this.#version++ + } +} + +export const recentItems = new RecentItems() diff --git a/frontend/src/lib/components/sessions/HomeSessionStarter.svelte b/frontend/src/lib/components/sessions/HomeSessionStarter.svelte new file mode 100644 index 0000000000..b43af354b3 --- /dev/null +++ b/frontend/src/lib/components/sessions/HomeSessionStarter.svelte @@ -0,0 +1,113 @@ + + +{#snippet actingOn()} + {#if session} + + {/if} +{/snippet} + + +{#if session && $copilotWorkspace !== undefined && starterPrompts.fewItems !== undefined} + +{/if} diff --git a/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte b/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte index 1a992b818b..9aef699490 100644 --- a/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte +++ b/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte @@ -28,8 +28,9 @@ } -
- Acting on + +
+ s.id === sessionId)) + // Only a session that has never sent opens as the starter block. Read off the record, not + // the transcript: a cold session's history is still loading from storage when it mounts, + // and an empty transcript would show the greeting and then glide the composer away. + // The first send commits the workspace, so this flips only after the transcript has. + const opensSession = $derived(!session?.workspace_id) + const starterPrompts = new StarterPrompts(() => + opensSession ? (actingWorkspaceId ?? $workspaceStore ?? undefined) : undefined + ) + // Seed the composer with the unsent prompt a reload preserved on the session // record (script-init: AIChatInput reads it once at mount). const restoredDraftPrompt = getSessionDraftPrompt(sessionId) @@ -116,6 +133,11 @@ // The placeholder's text counts as unsent input, like the real composer's draft, so the page // does not evict this session's chat (and the text with it) while it waits for the chat code. const PLACEHOLDER_DRAFT_KEY = 'composer-placeholder' + function setPlaceholderText(text: string) { + draftText = text + setSessionDraftPrompt(sessionId, text) + runtime?.manager.setComposerHasDraft(PLACEHOLDER_DRAFT_KEY, text.trim() !== '') + } // Drawn once, and shared by the placeholder and the real composer so the text survives the swap. const suggestionPick = Math.random() @@ -372,6 +394,10 @@ {#if !session || !runtime}
Session not found
{:else} + + {#snippet sessionEmptyHint()}{/snippet} + {#snippet inputPreface()} {#if showRecoveryNotice} - {#snippet sessionEmptyHint()}{/snippet} + {#snippet actingOn()} + + {/snippet} + + + {#snippet placeholderToolbar()} +
+
+ {/snippet} {#snippet sessionHeader()} -
+
{#if showChat && aiChatComponent.current} setSessionDraftPrompt(sessionId, text)} + initialAttachments={armedAtInit ? undefined : getSessionDraftAttachments(sessionId)} + onAttachmentsChange={(a) => setSessionDraftAttachments(sessionId, a)} placeholder={composerPlaceholder} forceDisabled={isUnavailable || !!session.archived} forceDisabledMessage={isUnavailable @@ -544,41 +577,62 @@ : session.archived ? 'This session is archived. Unarchive it from the banner above to keep working.' : ''} + starter={opensSession + ? { where: 'session', suggestions: starterPrompts.suggestions } + : undefined} + viewTransitionNames={headerInPage && (opensSession || arrivingFromHomeSend())} emptyHint={sessionEmptyHint} {inputPreface} + actingOn={hasFirstUserMessage ? undefined : actingOn} /> {:else} + its transcript area, its column, and its footer row's height; for a session + that has never sent, the greeting above the centred composer. -->
() => runtime.manager.setComposerHasDraft(PLACEHOLDER_DRAFT_KEY, false)} > - {#if runtime.manager.displayMessages.length > 0} + {#if opensSession} +
+
+ +
+
+ {:else if runtime.manager.displayMessages.length > 0}
{/if}
{@render inputPreface()} draftText, - (text) => { - draftText = text - setSessionDraftPrompt(sessionId, text) - runtime.manager.setComposerHasDraft(PLACEHOLDER_DRAFT_KEY, text.trim() !== '') - } - } - placeholder={runtime.manager.displayMessages.length > 0 - ? 'Ask followup' - : composerPlaceholder} + bind:value={() => draftText, setPlaceholderText} + toolbar={opensSession ? placeholderToolbar : undefined} + placeholder={opensSession + ? STARTER_PLACEHOLDER + : runtime.manager.displayMessages.length > 0 + ? 'Ask followup' + : composerPlaceholder} disabled={isUnavailable || !!session.archived} autofocus={sessionState.currentSessionId === sessionId && !covered} onSelectionChange={(selection) => (handoff = selection ?? null)} onCompositionChange={(composing) => (placeholderComposing = composing)} /> -
+ {#if !opensSession} +
+ {:else if !isUnavailable && !session.archived && starterPrompts.suggestions.length > 0} +
+ +
+ {/if}
+ {#if opensSession} +
+ {/if}
{/if}
diff --git a/frontend/src/lib/components/sessions/homeComposerContext.svelte.ts b/frontend/src/lib/components/sessions/homeComposerContext.svelte.ts new file mode 100644 index 0000000000..3a923c0cbc --- /dev/null +++ b/frontend/src/lib/components/sessions/homeComposerContext.svelte.ts @@ -0,0 +1,50 @@ +import type { ContextElement } from '$lib/components/copilot/chat/context' +import type { BulkKind } from '$lib/components/home/homeSelection.svelte' + +/** Resolves once the element is in the draft, so several attach in the order given. */ +type Attach = (element: ContextElement) => Promise + +/** What the composer can take as context from a workspace item: a script, a flow or a code + * app, as "+" → Mention file builds it. A visual-builder app or an agent has no context + * element, so it gets none. */ +export function itemContextElement(item: { + kind: BulkKind + path: string + summary?: string + rawApp?: boolean +}): ContextElement | undefined { + const type = + item.kind === 'script' + ? 'workspace_script' + : item.kind === 'flow' + ? 'workspace_flow' + : item.kind === 'app' && item.rawApp + ? 'workspace_app' + : undefined + if (!type) return undefined + return { type, path: item.path, title: item.path, summary: item.summary, deletable: true } +} + +let attach = $state(undefined) + +/** + * The way from anywhere on Home into Home's composer: a row's "Ask AI" hands its item over + * here, and the composer takes it as context. Only a mounted composer registers, so a page + * whose composer is removed, hidden or not Home offers no button that would go nowhere. + */ +export const homeComposerContext = { + get available(): boolean { + return attach !== undefined + }, + async attach(element: ContextElement): Promise { + await attach?.(element) + }, + /** Returns the unregister, which only clears its own registration: a remount registers + * the new composer before the old one is destroyed. */ + register(fn: Attach): () => void { + attach = fn + return () => { + if (attach === fn) attach = undefined + } + } +} diff --git a/frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts b/frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts new file mode 100644 index 0000000000..74a72ec0ce --- /dev/null +++ b/frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts @@ -0,0 +1,96 @@ +import type { OnNavigate } from '@sveltejs/kit' +import { sessionState } from './sessionState.svelte' + +// Moving between Home and AI Sessions keeps the composer in sight: the browser +// snapshots the page before and after the navigation and glides the element that +// carries the `composer` view-transition-name on both sides (AIChatDisplay names it); +// the rest of the page cross-fades. The timing lives in app.css, keyed on the +// `data-home-sessions-transition` attribute set here for the length of the move. + +const HOME = '/' +const SESSIONS = '/sessions' + +/** Whether the composer had focus when the user left AI Sessions for Home. */ +let composerFocused = false + +/** The focus flag to keep after a navigation from `from` to `to`. Only the return to Home + * hands focus on — the sessions page focuses its composer on its own — and every other + * navigation clears the flag, so a later arrival on Home never acts on a stale one. */ +export function composerFocusAfter( + from: string | undefined, + to: string | undefined, + composerHadFocus: boolean +): boolean { + return from === SESSIONS && to === HOME && composerHadFocus +} + +/** Whether Home should give its composer the focus the sessions page's had. Read once: + * a later visit starts unfocused. */ +export function takeComposerFocus(): boolean { + const focused = composerFocused + composerFocused = false + return focused +} + +// A session that has sent its first message has a composer of its own, not Home's draft, so +// a move between it and Home is not one composer moving: its page names no composer. The one +// exception is the send from Home itself, which lands on that session already under way. +let sentFromHome = $state(false) + +/** Called by Home's composer as its send hands over to the session page. */ +export function composerSentFromHome(): void { + sentFromHome = true +} + +/** Whether the session page arriving now came from a send on Home, so its composer is the + * one Home just sent from. */ +export function arrivingFromHomeSend(): boolean { + return sentFromHome +} + +/** For SvelteKit's `onNavigate`: runs a move between Home and AI Sessions as a view + * transition. Any other navigation, or a browser without the API, is left alone. */ +export function homeSessionsTransition(navigation: OnNavigate): Promise | undefined { + const from = navigation.from?.url.pathname + const to = navigation.to?.url.pathname + composerFocused = composerFocusAfter( + from, + to, + document.activeElement?.closest('[data-session-composer]') != null + ) + const direction = + from === HOME && to === SESSIONS + ? 'enter' + : from === SESSIONS && to === HOME + ? 'leave' + : undefined + // Good for the navigation the send started, and only that one. + if (direction !== 'enter') sentFromHome = false + if (!direction || navigation.willUnload || !document.startViewTransition) return + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return + const root = document.documentElement + // To or from a session under way there is no composer to carry over: Home's would only + // fade out or in on its own, so the move drops every name and the page cross-fades as + // one. Leaving, the page says so; arriving, the session's record does (it has sent once + // it has a workspace), short of the send from Home that just started it. + const composerOnScreen = Array.from(document.querySelectorAll('[data-session-composer]')).some( + (el) => getComputedStyle(el).viewTransitionName === 'composer' + ) + const arrivingId = navigation.to?.url.searchParams.get('session') ?? sessionState.currentSessionId + const arrivingUnderWay = + !sentFromHome && !!sessionState.sessions.find((s) => s.id === arrivingId)?.workspace_id + const plain = direction === 'leave' ? !composerOnScreen : arrivingUnderWay + root.dataset.homeSessionsTransition = plain ? 'fade' : direction + return new Promise((resolve) => { + const transition = document.startViewTransition(async () => { + resolve() + await navigation.complete + }) + // Rejects when the navigation is cancelled or fails; the attribute goes either way. + const done = () => { + delete root.dataset.homeSessionsTransition + sentFromHome = false + } + void transition.finished.then(done, done) + }) +} diff --git a/frontend/src/lib/components/sessions/homeSessionsTransition.test.ts b/frontend/src/lib/components/sessions/homeSessionsTransition.test.ts new file mode 100644 index 0000000000..4be3f18fce --- /dev/null +++ b/frontend/src/lib/components/sessions/homeSessionsTransition.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { composerFocusAfter } from './homeSessionsTransition.svelte' + +describe('composerFocusAfter', () => { + it('hands focus on only for the return from AI Sessions to Home', () => { + expect(composerFocusAfter('/sessions', '/', true)).toBe(true) + expect(composerFocusAfter('/sessions', '/', false)).toBe(false) + // Going to AI Sessions: that page focuses its own composer, so nothing is kept for Home. + expect(composerFocusAfter('/', '/sessions', true)).toBe(false) + // Any other navigation clears it, so a later arrival on Home never reads a stale flag. + expect(composerFocusAfter('/sessions', '/runs', true)).toBe(false) + expect(composerFocusAfter('/runs', '/', true)).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index ff05c01a7a..a79d98d713 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -58,6 +58,7 @@ import { setSessionPreviewCollapsed, setSessionPreviewSize, setSessionTabs, + setDraftsDiscardedHandler, type Session } from './sessionState.svelte' import { @@ -306,6 +307,7 @@ export interface SessionRuntime { } const runtimes = new SvelteMap() +const runtimeRoots = new Map void>() function emptyFlow(): Flow { return { @@ -1330,7 +1332,18 @@ async function initRuntime(runtime: SessionRuntime, session: Session) { export function getOrCreateRuntime(session: Session): SessionRuntime { let runtime = runtimes.get(session.id) if (!runtime) { - runtime = createRuntime(session) + // The manager's `$derived` fields belong to whatever effect is running when they are + // built. A runtime outlives the component that first asked for it (Home hands its + // session to /sessions and unmounts), so give it a root of its own; built inside the + // component, its deriveds turn inert once that component is gone. + let created: SessionRuntime | undefined + runtimeRoots.set( + session.id, + $effect.root(() => { + created = createRuntime(session) + }) + ) + runtime = created! runtimes.set(session.id, runtime) // The gate opens even when the restore fails: a session that cannot read its // history is still usable, and a send that waits forever is not. @@ -1347,6 +1360,8 @@ export function disposeRuntime(sessionId: string) { runtime.manager.cancel('runtime disposed') runtime.manager.historyManager.close() runtimes.delete(sessionId) + runtimeRoots.get(sessionId)?.() + runtimeRoots.delete(sessionId) // The sidebar reads the stored chat again rather than trust a read that // predates everything this runtime saw. peeks.delete(sessionId) @@ -1475,6 +1490,8 @@ export function removeSession(sessionId: string): void { deleteSessionState(sessionId) } +setDraftsDiscardedHandler((ids) => ids.forEach(disposeRuntime)) + // The chat calls a new item by its chosen name while its draft is stored at // `draft_`; resolve the name the way the chat's write tools do. function draftStoragePath( diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index f032385d31..4a3abebb4f 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -28,6 +28,7 @@ import { emailOfScopedKey, scopedKeyFor } from '$lib/userScopedStorage' import { deleteItemsForSession } from '../copilot/chat/files/attachedFilesDB' import { deleteArtifactsForSession } from '../copilot/chat/artifacts/artifactsDB' import { deleteSessionChats } from '../copilot/chat/HistoryManager.svelte' +import type { DraftAttachments } from '../copilot/chat/messageDraft.svelte' import { markSessionDirty, markSessionRemoved, @@ -387,6 +388,32 @@ export function getSessionDraftPrompt(sessionId: string): string | undefined { return s.draftPrompt } +// The rest of an unsent draft (collapsed pastes, images, files), so a composer mounted again +// for the same session, on Home or the session page, gets the draft whole: a collapsed paste +// is a token in `draftPrompt` whose content lives here. In memory only, like the attachments +// themselves before a send, so a reload drops them as it always has. +const draftAttachments = new Map() + +export function setSessionDraftAttachments(sessionId: string, attachments: DraftAttachments): void { + const empty = Object.values(attachments).every((lane) => lane.length === 0) + if (empty) { + draftAttachments.delete(sessionId) + return + } + const s = sessionState.sessions.find((x) => x.id === sessionId) + if (!s || s.workspace_id) return + draftAttachments.set(sessionId, attachments) + // Attaching is a touch, as typing is: a draft holding only an image would otherwise still + // read as a blank, be reused by the next hand-off with the image in it, or be dropped. + if (s.transient) persistTouched(s) +} + +export function getSessionDraftAttachments(sessionId: string): DraftAttachments | undefined { + const s = sessionState.sessions.find((x) => x.id === sessionId) + if (!s || s.workspace_id) return undefined + return draftAttachments.get(sessionId) +} + // A hand-off arrives within a navigation; anything older than this is the debris // of one that never landed, and must not fire at whatever the user does next. const AUTO_SEND_TTL_MS = 5 * 60_000 @@ -1070,7 +1097,24 @@ export function createSession(): Session { requestComposerFocus() return reusable } + const session = newTransientSession() + sessionState.currentSessionId = session.id + return session +} + +// Told which never-touched drafts were dropped, so their runtimes go with them. Set by +// sessionRuntime, which imports this module and so cannot be imported back. Home builds a +// runtime for its draft on every mount, so a dropped draft would otherwise leave its +// manager and its history database handle open. +let onDraftsDiscarded: ((ids: string[]) => void) | undefined +export function setDraftsDiscardedHandler(handler: (ids: string[]) => void): void { + onDraftsDiscarded = handler +} + +function newTransientSession(): Session { + const discarded = sessionState.sessions.filter(isDiscardableDraft).map((s) => s.id) sessionState.sessions = sessionState.sessions.filter((s) => !isDiscardableDraft(s)) + if (discarded.length > 0) onDraftsDiscarded?.(discarded) // Start in the workspace you're in. The one exception: a root you can't // deploy to (locked, no bypass) steers to its dev, since a session there // couldn't edit anything. The picker lets you switch. @@ -1103,12 +1147,32 @@ export function createSession(): Session { transient: true } sessionState.sessions = [session, ...sessionState.sessions] - sessionState.currentSessionId = session.id // Transient until first touch: no DB record yet. Persisting is deferred to the // first user edit (the mutation helpers below route through persistTouched). return session } +/** The unsent session Home's composer writes into, so a prompt half-typed on Home is the + * one the empty session page shows, and the reverse. Never selects it: merely opening Home + * must not change which session the AI Sessions switch resumes. A session opened on an + * item (it carries preview tabs) is about that item, so Home starts its own instead. */ +export function homeStarterSession(): Session { + const unsent = (s: Session) => + !s.archived && !s.workspace_id && !s.previewTabs?.length && sessionInCurrentFamily(s) + const current = sessionState.sessions.find((s) => s.id === sessionState.currentSessionId) + if (current && unsent(current)) return current + // Stored but never sent: a draft the user touched, by typing or by picking where it acts. + // Either is theirs to come back to, and leaving it would strand it in the sidebar. + const touched = sessionState.sessions + .filter((s) => unsent(s) && !s.transient) + .sort((a, b) => sessionLastActivityAt(b) - sessionLastActivityAt(a))[0] + return ( + touched ?? + sessionState.sessions.find((s) => isDiscardableDraft(s) && sessionInCurrentFamily(s)) ?? + newTransientSession() + ) +} + // Promote an in-memory transient session to a persisted IndexedDB record. // No-op when the session isn't transient (already persisted by a prior touch). // Called on the first genuine user touch (via persistTouched) and, idempotently, @@ -1457,6 +1521,7 @@ export function deleteSession(id: string) { // a draft deleted inside the debounce window. clearTimeout(draftPromptFlushHandles.get(id)) draftPromptFlushHandles.delete(id) + draftAttachments.delete(id) sessionState.sessions = sessionState.sessions.filter((x) => x.id !== id) if (sessionState.currentSessionId === id) { sessionState.currentSessionId = sessionState.sessions[0]?.id diff --git a/frontend/src/lib/components/sessions/sessionState.test.ts b/frontend/src/lib/components/sessions/sessionState.test.ts index 710028413e..4c6666a349 100644 --- a/frontend/src/lib/components/sessions/sessionState.test.ts +++ b/frontend/src/lib/components/sessions/sessionState.test.ts @@ -5,11 +5,15 @@ import { createSession, decideSessionLifecycle, findEmptyLandingSession, + getSessionDraftAttachments, + homeStarterSession, isForkSession, isTearingDownOpenSession, renameSession, + setDraftsDiscardedHandler, sessionInCurrentFamily, setGeneratedSessionSummary, + setSessionDraftAttachments, setSessionDraftPrompt, sessionState, withOpenSessionTeardown, @@ -27,6 +31,10 @@ import { type UserWorkspace } from '$lib/stores' import { WorkspaceService } from '$lib/gen' +import type { DraftAttachments } from '$lib/components/copilot/chat/messageDraft.svelte' +import type { PasteAttachment } from '$lib/components/copilot/chat/pasteTokens' + +const NO_ATTACHMENTS: DraftAttachments = { pastes: [], images: [], files: [], blobs: [] } // Force createWorkspaceFork to fail so we can pin commitSessionWorkspace's // failure contract (the invariant the beforeSend abort fix relies on). @@ -451,6 +459,35 @@ describe('createSession — reuses an untouched draft, family-scoped', () => { } }) + it('does not reuse a draft that holds only an attachment', () => { + const restore = withTwoFamilies('rootA') + const prevCurrent = sessionState.currentSessionId + const attached = session({ + id: 'attachment-only', + name: 'session-904', + pending_workspace_id: 'forkA', + transient: true + }) + sessionState.sessions.push(attached) + let createdId: string | undefined + try { + setSessionDraftAttachments('attachment-only', { + ...NO_ATTACHMENTS, + pastes: [{} as PasteAttachment] + }) + createdId = createSession().id + expect(createdId).not.toBe('attachment-only') + expect(getSessionDraftAttachments('attachment-only')).toBeDefined() + } finally { + setSessionDraftAttachments('attachment-only', NO_ATTACHMENTS) + sessionState.sessions = sessionState.sessions.filter( + (s) => s.id !== 'attachment-only' && s.id !== createdId + ) + sessionState.currentSessionId = prevCurrent + restore() + } + }) + it('clears the recovery notice off the draft it reuses, so `+` is not answered with "not found"', () => { const restore = withTwoFamilies('rootA') const prevCurrent = sessionState.currentSessionId @@ -674,3 +711,64 @@ describe('withOpenSessionTeardown — the gate that holds recovery off during a expect(isTearingDownOpenSession()).toBe(false) }) }) + +describe('homeStarterSession', () => { + it("shows the family's stored draft, not the conversation in view, and selects nothing", () => { + const restore = withTwoFamilies('rootA') + const prevCurrent = sessionState.currentSessionId + const conversation = session({ id: 'conversation', workspace_id: 'rootA' }) + // Touched only by picking where it acts: no text, but stored, so it is the user's draft. + const picked = session({ id: 'picked-draft', pending_workspace_id: 'forkA', lastActivityAt: 2 }) + const otherFamily = session({ + id: 'other-family-draft', + pending_workspace_id: 'rootB', + draftPrompt: 'elsewhere', + lastActivityAt: 3 + }) + sessionState.sessions.push(conversation, picked, otherFamily) + sessionState.currentSessionId = 'conversation' + try { + expect(homeStarterSession().id).toBe('picked-draft') + // Opening Home must leave the AI Sessions switch resuming the conversation. + expect(sessionState.currentSessionId).toBe('conversation') + } finally { + sessionState.sessions = sessionState.sessions.filter( + (s) => !['conversation', 'picked-draft', 'other-family-draft'].includes(s.id) + ) + sessionState.currentSessionId = prevCurrent + restore() + } + }) + + it('reports the never-touched drafts it drops, so their runtimes can be disposed', () => { + const restore = withTwoFamilies('rootB') + const prevCurrent = sessionState.currentSessionId + const blank = session({ + id: 'blank-other-family', + pending_workspace_id: 'forkA', + transient: true + }) + const typed = session({ + id: 'typed-other-family', + pending_workspace_id: 'rootA', + draftPrompt: 'kept' + }) + sessionState.sessions.push(blank, typed) + const discarded: string[][] = [] + setDraftsDiscardedHandler((ids) => discarded.push(ids)) + let createdId: string | undefined + try { + // No draft of family B exists, so Home starts one, dropping family A's blank draft. + createdId = homeStarterSession().id + expect(discarded).toEqual([['blank-other-family']]) + expect(sessionState.sessions.some((s) => s.id === 'typed-other-family')).toBe(true) + } finally { + setDraftsDiscardedHandler(() => {}) + sessionState.sessions = sessionState.sessions.filter( + (s) => !['blank-other-family', 'typed-other-family', createdId].includes(s.id) + ) + sessionState.currentSessionId = prevCurrent + restore() + } + }) +}) diff --git a/frontend/src/lib/components/sidebar/FavoriteMenu.svelte b/frontend/src/lib/components/sidebar/FavoriteMenu.svelte index c3d14d4c4d..448d7644bc 100644 --- a/frontend/src/lib/components/sidebar/FavoriteMenu.svelte +++ b/frontend/src/lib/components/sidebar/FavoriteMenu.svelte @@ -30,6 +30,9 @@ href: string kind: FavoriteKind }[] = $state([]) + /** The workspace `current` was last loaded for, so a reader can tell "none yet" from + * "none". */ + loadedFor: string | undefined = $state(undefined) async unstar(path: string, favorite_kind: FavoriteKind, workspaceId?: string) { this.current = this.current.filter( diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts index a6bbc25d49..62108cf23b 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -10,6 +10,9 @@ export const DEFAULT_WEBHOOK_TYPE: 'async' | 'sync' = 'async' export const HOME_SHOW_HUB = true +/** The width of Home's one column: the composer and the items list below it. */ +export const HOME_COLUMN_WIDTH = 'max-w-[1120px]' + export const HOME_SHOW_CREATE_FLOW = true export const HOME_SEARCH_SHOW_FLOW = true diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 13ae454c92..7341baef49 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1657,7 +1657,8 @@ export type Item = { href?: string hrefTarget?: '_blank' | '_self' | '_parent' | '_top' disabled?: boolean - type?: 'action' | 'delete' + /** `ai` sets the row in the AI colour, for an action that hands something to the assistant. */ + type?: 'action' | 'delete' | 'ai' hide?: boolean | undefined extra?: Snippet id?: string diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index fd4b828be3..6b79582958 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -58,7 +58,8 @@ type UserWorkspace } from '$lib/stores' import CenteredModal from '$lib/components/CenteredModal.svelte' - import { afterNavigate, beforeNavigate } from '$app/navigation' + import { afterNavigate, beforeNavigate, onNavigate } from '$app/navigation' + import { homeSessionsTransition } from '$lib/components/sessions/homeSessionsTransition.svelte' import { goto } from '$lib/navigation' import { registerToolDisplayActionHandler } from '$lib/components/copilot/chat/createdResourceActions.svelte' import type UserSettings from '$lib/components/UserSettings.svelte' @@ -449,6 +450,8 @@ return kind ? { kind, path: route.itemPath } : undefined } + onNavigate(homeSessionsTransition) + beforeNavigate((navigation) => { if (preserveMenuOnNextNav) { preserveMenuOnNextNav = false @@ -661,27 +664,37 @@ } async function loadFavorites() { - const scripts = await ScriptService.listScripts({ - workspace: $workspaceStore ?? '', - starredOnly: true, - includeWithoutMain: true, - withoutDescription: true - }) - const flows = await FlowService.listFlows({ - workspace: $workspaceStore ?? '', - starredOnly: true, - withoutDescription: true - }) - const apps = await AppService.listApps({ - workspace: $workspaceStore ?? '', - starredOnly: true - }) - const agents = await ResourceService.listResource({ - workspace: $workspaceStore ?? '', - resourceType: 'ai_agent', - starredOnly: true - }) - const assets = await AssetService.listFavoriteAssets({ workspace: $workspaceStore ?? '' }) + const workspace = $workspaceStore ?? '' + try { + await loadFavoritesFor(workspace) + } catch (e) { + // `current` still holds another workspace's favorites, which `loadedFor` is about to + // vouch for as this one's. + if (favoriteManager.loadedFor !== workspace && ($workspaceStore ?? '') === workspace) + favoriteManager.current = [] + throw e + } finally { + // Set on failure too: Home holds its shortcuts back until this says the favorites + // are in, and a failed read must not hold them back for good. Not once the workspace + // has changed: a slow load for the last one would name it and hide them in this one. + if (($workspaceStore ?? '') === workspace) favoriteManager.loadedFor = workspace + } + } + + async function loadFavoritesFor(workspace: string) { + const [scripts, flows, apps, agents, assets] = await Promise.all([ + ScriptService.listScripts({ + workspace, + starredOnly: true, + includeWithoutMain: true, + withoutDescription: true + }), + FlowService.listFlows({ workspace, starredOnly: true, withoutDescription: true }), + AppService.listApps({ workspace, starredOnly: true }), + ResourceService.listResource({ workspace, resourceType: 'ai_agent', starredOnly: true }), + AssetService.listFavoriteAssets({ workspace }) + ]) + if (($workspaceStore ?? '') !== workspace) return favoriteManager.current = [ ...scripts.map((s) => ({ label: s.summary || getFavoriteLabel(s.path, 'script'), diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index 665c93b661..d23f92de96 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -1,6 +1,7 @@
diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index 57e387ad20..fdcf447f64 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -17,6 +17,7 @@ import { importStore } from '$lib/components/apps/store' import { onDestroy, untrack } from 'svelte' import { page } from '$app/state' + import { recentItems } from '$lib/components/home/recentItems.svelte' import { UserDraft } from '$lib/userDraft.svelte' import { stripNewDraftFlag, stripNewDraftFlagOnSave, shouldSeedNewDraft } from '$lib/newDraftFlag' import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte' @@ -206,6 +207,14 @@ getDraft }) if (tok !== loadAppToken) return + // A draft-only app has no page for Home's card to open. + if (!backendApp.no_deployed) { + recentItems.record($workspaceStore!, { + kind: 'app', + path: backendApp.path, + summary: backendApp.summary + }) + } // Deployed App value for AppEditor's autosave `discardIf`, captured BEFORE // the draft swap below replaces `backendApp.value`. `undefined` when // there's no deployed row (draft-only path). diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 92df0e72a4..0a89e2141d 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -15,6 +15,7 @@ import { openEditorInSession } from '$lib/components/sessions/sessionSwitch.svelte' import { stateSnapshot } from '$lib/svelte5Utils.svelte' import { page } from '$app/state' + import { recentItems } from '$lib/components/home/recentItems.svelte' import { type RawAppData, DEFAULT_DATA, @@ -312,6 +313,14 @@ rawApp: true })) as any if (tok !== loadAppToken) return + // A draft-only app has no page for Home's card to open. + if (!backendApp.no_deployed) { + recentItems.record($workspaceStore!, { + kind: 'raw_app', + path: backendApp.path, + summary: backendApp.summary + }) + } // `other_drafts_users` only computed when `getDraft`; don't clobber the // known list on a `getDraft:false` reload. See /scripts/edit's loader. if (getDraft) { diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index d724c4873d..9fd0e65a45 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -21,6 +21,7 @@ import { stripNewDraftFlag, stripNewDraftFlagOnSave, shouldSeedNewDraft } from '$lib/newDraftFlag' import type { stepState } from '$lib/components/stepHistoryLoader.svelte' import { page } from '$app/state' + import { recentItems } from '$lib/components/home/recentItems.svelte' import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' @@ -346,6 +347,14 @@ getDraft }) if (tok !== loadFlowToken) return + // A draft-only flow has no page for Home's card to open. + if (!backendFlow.no_deployed) { + recentItems.record($workspaceStore!, { + kind: 'flow', + path: backendFlow.path, + summary: backendFlow.summary + }) + } // `other_drafts_users` only computed when `getDraft`; don't clobber the // known list on a `getDraft:false` reload. See /scripts/edit's loader. if (getDraft) { diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index 6110af0fef..9a9f9fb14e 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -18,6 +18,7 @@ import { onDestroy, untrack } from 'svelte' import { stripNewDraftFlag, stripNewDraftFlagOnSave, shouldSeedNewDraft } from '$lib/newDraftFlag' import { page } from '$app/state' + import { recentItems } from '$lib/components/home/recentItems.svelte' import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { discardDraftAfterDeploy, runResetToDeployed } from '$lib/userDraftToast' @@ -311,6 +312,14 @@ getDraft }) if (tok !== loadScriptToken) return + // A draft-only script has no page for Home's card to open. + if (!backendScript.no_deployed) { + recentItems.record($workspaceStore!, { + kind: 'script', + path: backendScript.path, + summary: backendScript.summary + }) + } // The editor re-infers the schema as soon as it mounts, so a baseline // taken from the raw stored schema would flag every unedited open of a // script whose stored schema differs from what this parser emits. diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 7fc5630b79..b648bd4212 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -35,8 +35,7 @@ type Scope } from '$lib/components/sessions/PreviewRouterPicker.svelte' import { goto } from '$lib/navigation' - import { resource } from 'runed' - import { WorkspaceService } from '$lib/gen' + import { useWorkspaceAiHidden } from '$lib/components/copilot/workspaceAiHidden.svelte' import SessionWrapper from '$lib/components/sessions/SessionWrapper.svelte' import type PreviewTabHost from '$lib/components/sessions/PreviewTabHost.svelte' import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte' @@ -339,35 +338,16 @@ (activeSession ? getEffectiveWorkspaceId(activeSession) : undefined) ?? $workspaceStore ) - // Whether that workspace hid the AI assistant (`ai_config.copilot_disabled`). Read - // directly rather than from `copilotInfo`: that store holds whichever workspace loaded - // last, and the gate below unmounts the session wrapper that would refresh it, so a - // hidden verdict would stick across session and workspace switches. Tagged with its - // workspace and guarded against a superseded response like the protection-rules - // resource: runed keeps the previous `current` while a new source loads, so a switch - // would otherwise be judged on the previous workspace's verdict. A failed read leaves - // the page usable, as an unloaded config does everywhere else. - const workspaceAiHidden = resource( - () => previewWorkspace, - async (workspace, _prev, { signal }) => { - if (!workspace) return { workspace, hidden: false } - let hidden = false - try { - hidden = (await WorkspaceService.getCopilotInfo({ workspace })).copilot_disabled === true - } catch (e) { - console.error(`Failed to read the AI config of workspace ${workspace}:`, e) - } - // The generated client can't take an abort signal, so drop a superseded response here. - if (signal.aborted) throw new DOMException('superseded', 'AbortError') - return { workspace, hidden } - } + // Whether that workspace hid the AI assistant. The gate below unmounts the session wrapper + // that would refresh `copilotInfo`, so the verdict is read for the workspace itself. + const workspaceAiHidden = useWorkspaceAiHidden(() => previewWorkspace) + const aiHiddenVerdict = $derived(workspaceAiHidden.hidden) + // The full loader is for the page's first verdict only. Later, picking another workspace in + // "Acting on" leaves the session on screen, inert until its verdict lands: covering it for + // a request reads as the page reloading. + const firstVerdictPending = $derived( + aiHiddenVerdict === undefined && !workspaceAiHidden.settledOnce ) - // Only a verdict for the workspace currently judged counts; `undefined` means it has not - // landed yet. - const aiHiddenVerdict = $derived.by(() => { - const current = workspaceAiHidden.current - return current && current.workspace === previewWorkspace ? current.hidden : undefined - }) // Lazy-mount gate: a tab's content only renders once its key lands here (on // first activation) — so restoring a session with N saved tabs boots just @@ -1293,15 +1273,16 @@
- {#if aiHiddenVerdict === undefined} + {#if firstVerdictPending}
{:else if aiHiddenVerdict} +

AI Sessions are hidden in this workspace

A workspace admin hid AI sessions in the workspace settings.