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 @@
+ {/snippet}
+
+ {#snippet titleBadge()}
+
{/snippet}
{#snippet actions()}
-
+
+
{#if $roleCapabilities.code}
{#if showEditButton}
{#if script.use_codebase}
@@ -258,8 +244,7 @@
aiId={`edit-script-button-${script.summary?.length > 0 ? script.summary : script.path}`}
aiDescription={`Edits the script ${script.summary?.length > 0 ? script.summary : script.path}`}
variant="subtle"
- wrapperClasses="w-20"
- unifiedSize="md"
+ unifiedSize="sm"
startIcon={{ icon: Pen }}
href="/scripts/edit/{script.path}"
>
@@ -274,6 +259,7 @@
{/if}
0 ? script.summary : script.path}`}
aiDescription={`Open dropdown for script ${script.summary?.length > 0 ? script.summary : script.path} options`}
items={async () => {
diff --git a/frontend/src/lib/components/common/table/unruledList.ts b/frontend/src/lib/components/common/table/unruledList.ts
new file mode 100644
index 0000000000..9e7ef59caa
--- /dev/null
+++ b/frontend/src/lib/components/common/table/unruledList.ts
@@ -0,0 +1,27 @@
+import { getContext, setContext } from 'svelte'
+
+const KEY = Symbol('unruledList')
+
+/** Marks the rows below as one unruled list (Home's items): no line between rows, a rounded
+ * hover, and row actions shown only on the row in use. Every other list keeps its ruled rows. */
+export function setUnruledList() {
+ setContext(KEY, true)
+}
+
+export function inUnruledList(): boolean {
+ return getContext(KEY) ?? false
+}
+
+/** The divider an ordinary list row draws, or none in an unruled list. */
+export function rowRule(unruled: boolean): string {
+ return unruled ? 'rounded-md' : 'border-b'
+}
+
+/** Row actions in an unruled list: shown for the row hovered, tabbed into, keyboard-selected,
+ * or with one of its menus open. Opacity rather than visibility, so Tab still reaches them;
+ * focus counts only when visible, since a menu hands focus back to its trigger on close and a
+ * mouse user would otherwise keep that row's actions up after leaving it. Needs `group/row`
+ * on the row. Set per child, not on the container: the menu trigger (`button[data-menu]`)
+ * stays visible so every row ends on the same control. */
+export const HOVER_ACTIONS_CLASS =
+ '*:opacity-0 *:transition-opacity *:duration-100 group-hover/row:*:opacity-100 group-has-[:focus-visible]/row:*:opacity-100 group-data-[row-keyboard-selected=true]/row:*:opacity-100 *:has-[[aria-expanded=true]]:opacity-100 [&>button[data-menu]]:opacity-100'
diff --git a/frontend/src/lib/components/copilot/ChatModelSettings.svelte b/frontend/src/lib/components/copilot/ChatModelSettings.svelte
index a1e677d46b..70745eee1c 100644
--- a/frontend/src/lib/components/copilot/ChatModelSettings.svelte
+++ b/frontend/src/lib/components/copilot/ChatModelSettings.svelte
@@ -98,7 +98,12 @@
title={config.readOnly ? config.readOnlyReason : config.title}
>
- {config.label}
+ {#if config.shortLabel}
+ {config.label}
+ {config.shortLabel}
+ {:else}
+ {config.label}
+ {/if}
{#if effortLabel}
· {effortLabel}
{/if}
diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte
index 129faa9f01..a9312bc5fb 100644
--- a/frontend/src/lib/components/copilot/chat/AIChat.svelte
+++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte
@@ -19,9 +19,15 @@
forceDisabledMessage = '',
wideLayout = false,
emptyHint,
+ starter = undefined,
+ viewTransitionNames = false,
+ onSent = undefined,
inputPreface,
+ actingOn,
initialInstructions = undefined,
onDraftChange = undefined,
+ initialAttachments = undefined,
+ onAttachmentsChange = undefined,
placeholder = undefined
}: {
hideHeader?: boolean
@@ -37,10 +43,17 @@
// in; the narrow global-chat panel leaves it off.
wideLayout?: boolean
emptyHint?: import('svelte').Snippet
+ starter?: import('./starter/starterPrompts.svelte').ChatStarter
+ viewTransitionNames?: boolean
+ onSent?: () => void
inputPreface?: import('svelte').Snippet
- // Seed / observe the composer's draft text (forwarded to AIChatDisplay).
+ /** Forwarded to AIChatDisplay: where the session acts, first among the controls. */
+ actingOn?: import('svelte').Snippet
+ // Seed / observe the composer's draft (forwarded to AIChatDisplay).
initialInstructions?: string
onDraftChange?: (text: string) => void
+ initialAttachments?: import('./messageDraft.svelte').DraftAttachments
+ onAttachmentsChange?: (attachments: import('./messageDraft.svelte').DraftAttachments) => void
/** Composer placeholder for the first message. Falls back to the per-AI-mode wording. */
placeholder?: string
} = $props()
@@ -114,6 +127,10 @@
aiChatDisplay?.focusInput(selection)
}
+ export async function attachContext(element: import('./context').ContextElement) {
+ await aiChatDisplay?.attachContext(element)
+ }
+
const historyManager = aiChatManager.historyManager
let aiChatDisplay: AIChatDisplay | undefined = $state(undefined)
@@ -202,8 +219,14 @@
{hideModeSelector}
{wideLayout}
{emptyHint}
+ {starter}
+ {viewTransitionNames}
+ {onSent}
{inputPreface}
+ {actingOn}
{initialInstructions}
{onDraftChange}
+ {initialAttachments}
+ {onAttachmentsChange}
{placeholder}
>
diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
index 845126b4ef..5f31bd4272 100644
--- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
+++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
@@ -10,13 +10,9 @@
ArrowDown,
AtSign,
BookOpen,
- ChevronDown,
- ChevronsRight,
CheckIcon,
- ClipboardList,
FileText,
Folder,
- Hand,
HistoryIcon,
KeyRound,
MousePointer2,
@@ -31,7 +27,8 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { pendingUserAction, pendingUserActionDetail, type DisplayMessage } from './shared'
- import { PLAN_MODE_TEXT_COLOR, PLAN_MODE_TRIGGER_CLASS } from './planMode'
+ import AutonomyModePicker from './AutonomyModePicker.svelte'
+ import { availableAutonomyModeOptions, resolveAutonomyMode } from './autonomyModes'
import { PLAN_MODE_MESSAGES } from './planModeMessages'
import type { ContextElement } from './context'
import ChatQuickActions from './ChatQuickActions.svelte'
@@ -53,7 +50,7 @@
import ToolGroupDisplay from './ToolGroupDisplay.svelte'
import { chatItemKey, groupToolRuns } from './toolGroups'
import AIChatInput from './AIChatInput.svelte'
- import type { ComposerSelection } from './composerBox'
+ import { STARTER_PLACEHOLDER, type ComposerSelection } from './composerBox'
import AttachedFilesBar from './files/AttachedFilesBar.svelte'
import QueuedMessageChip from './QueuedMessageChip.svelte'
import JobsSegment from './JobsSegment.svelte'
@@ -72,6 +69,12 @@
import { sendUserToast } from '$lib/toast'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { copilotInfo } from '$lib/aiStore'
+ import SessionGreeting from './starter/SessionGreeting.svelte'
+ import ComposerSuggestions from './starter/ComposerSuggestions.svelte'
+ import type { DraftAttachments } from './messageDraft.svelte'
+ import type { ChatStarter } from './starter/starterPrompts.svelte'
+ import { isComposerOverlay } from './starter/composerCollapse'
+ import { smoothHeight } from './starter/smoothHeight'
const MAX_YOLO_TOOLTIP_TOOLS = 8
const chatHost = getChatViewHost()
@@ -90,80 +93,6 @@
let freeTierUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
let showFreeTierUsage = $derived(!!freeTier && !freeTier.exhausted)
- // One row per autonomy posture, in picker order, so adding one touches only this
- // table. `isAvailable` hides the postures that would do nothing in the current AI
- // mode, which is why the picker can be shorter than this list.
- type AutonomyAvailability = {
- autoAcceptEditsAvailable: boolean
- autoAcceptToolConfirmationsAvailable: boolean
- planModeAvailable: boolean
- }
- type AutonomyModeOption = {
- mode: AIAutonomyMode
- label: string
- shortLabel?: string
- icon: typeof Hand
- iconColor: string
- /** Tints the whole trigger, not just its icon. Only plan mode needs it. */
- triggerClass?: string
- tooltip: (a: AutonomyAvailability) => string
- isAvailable: (a: AutonomyAvailability) => boolean
- }
- // The one posture available everywhere, so also the fallback for a mode the
- // current AI mode does not offer.
- const askPermissionOption: AutonomyModeOption = {
- mode: AIAutonomyMode.DEFAULT,
- label: 'Ask permission',
- icon: Hand,
- iconColor: 'text-secondary',
- tooltip: (a) =>
- a.autoAcceptEditsAvailable
- ? 'Requires confirmation for edits and tool calls.'
- : 'Requires confirmation for tool calls.',
- isAvailable: () => true
- }
- const autonomyModeOptions: AutonomyModeOption[] = [
- {
- mode: AIAutonomyMode.PLAN,
- label: 'Plan (read-only)',
- shortLabel: 'Plan',
- icon: ClipboardList,
- iconColor: PLAN_MODE_TEXT_COLOR,
- triggerClass: PLAN_MODE_TRIGGER_CLASS,
- tooltip: () =>
- 'Read-only: the assistant researches and drafts a plan for your approval before it can change anything.',
- isAvailable: (a) => a.planModeAvailable
- },
- askPermissionOption,
- {
- mode: AIAutonomyMode.ACCEPT_EDIT,
- label: 'Auto-accept edits',
- icon: ChevronsRight,
- iconColor: 'text-accent',
- tooltip: () =>
- 'Automatically accepts script and flow edits. Tool calls still ask for confirmation.',
- isAvailable: (a) => a.autoAcceptEditsAvailable
- },
- {
- mode: AIAutonomyMode.YOLO,
- label: 'Yolo (bypass permissions)',
- shortLabel: 'Yolo',
- icon: ChevronsRight,
- iconColor: 'text-red-500',
- tooltip: (a) =>
- a.autoAcceptEditsAvailable
- ? 'Automatically accepts script and flow edits plus tool confirmations.'
- : 'Automatically accepts tool confirmations.',
- isAvailable: (a) => a.autoAcceptToolConfirmationsAvailable
- }
- ]
- const autonomyModeOption = (mode: AIAutonomyMode) =>
- autonomyModeOptions.find((o) => o.mode === mode) ?? askPermissionOption
- const autonomyModeLabel = (mode: AIAutonomyMode) => {
- const option = autonomyModeOption(mode)
- return option.shortLabel ?? option.label
- }
-
let {
messages,
pastChats,
@@ -184,10 +113,16 @@
hideModeSelector = false,
wideLayout = false,
emptyHint,
+ starter = undefined,
+ viewTransitionNames = false,
+ onSent = undefined,
inputPreface,
+ actingOn,
footerSettings,
initialInstructions = undefined,
onDraftChange = undefined,
+ initialAttachments = undefined,
+ onAttachmentsChange = undefined,
placeholder = undefined,
scrollElement = $bindable(),
onTranscriptScroll = undefined
@@ -216,13 +151,28 @@
// off there.
wideLayout?: boolean
emptyHint?: Snippet
+ /** Open an empty chat as the block that starts a session: Home and an empty session
+ * both mount it, so the two are one entry point rather than look-alikes. Replaces
+ * `emptyHint`. */
+ starter?: ChatStarter
+ /** Names the composer for the view transition between Home and AI Sessions, so it
+ * glides between its two places, a send from Home included: that lands it under the
+ * session's first message. Only one chat on the page may carry the name: a duplicate
+ * makes the browser skip the transition. */
+ viewTransitionNames?: boolean
+ /** After the composer hands a new turn to the host. */
+ onSent?: () => void
inputPreface?: Snippet
+ /** Where the session acts: first among the controls after the attach button. */
+ actingOn?: Snippet
/** The settings control at the footer's right edge, where the copilot puts its
* model picker. A host that configures its turn elsewhere replaces it here. */
footerSettings?: Snippet
- // Seed / observe the main composer's draft text (see AIChatInput).
+ // Seed / observe the main composer's draft (see AIChatInput).
initialInstructions?: string
onDraftChange?: (text: string) => void
+ initialAttachments?: DraftAttachments
+ onAttachmentsChange?: (attachments: DraftAttachments) => void
/** Composer placeholder. Falls back to the per-AI-mode wording. */
placeholder?: string
/** The transcript's scroll container. A host that paginates older messages
@@ -362,14 +312,18 @@
aiChatInput?.focusInput(selection)
}
- $effect(() => {
- if (aiChatInput) {
- chatHost.setAiChatInput(aiChatInput)
- }
+ /** Mention a workspace item in the draft, as "+" → Mention file does, and hand the user
+ * the caret after it. Focusing is what opens Home's one-line composer. */
+ export async function attachContext(element: ContextElement) {
+ await mentionWorkspaceItem(element)
+ aiChatInput?.focusInput()
+ }
- return () => {
- chatHost.setAiChatInput(null)
- }
+ $effect(() => {
+ const input = aiChatInput
+ if (!input) return
+ chatHost.setAiChatInput(input)
+ return () => chatHost.releaseAiChatInput(input)
})
// Also shown for a run held by another tab, labeled with where it is: the
@@ -602,7 +556,7 @@
function mentionWorkspaceItem(element: ContextElement) {
void aiChatInput?.addContextToSelection(element)
- aiChatInput?.insertMention(element.title)
+ return aiChatInput?.insertMention(element.title)
}
function onFolderInputChange(e: Event) {
@@ -617,18 +571,12 @@
autoAcceptToolConfirmationsAvailable: chatHost.autoAcceptToolConfirmationsAvailable,
planModeAvailable: chatHost.planModeAvailable
})
- const availableAutonomyModeOptions = $derived(
- autonomyModeOptions.filter((option) => option.isAvailable(autonomyAvailability))
- )
- // Fall back to ask-permission when the persisted mode isn't applicable in the
- // current AI mode (e.g. auto-accept edits while in a mode without edits).
const effectiveAutonomyMode = $derived(
- availableAutonomyModeOptions.some((option) => option.mode === chatHost.autonomyMode)
- ? chatHost.autonomyMode
- : AIAutonomyMode.DEFAULT
+ resolveAutonomyMode(chatHost.autonomyMode, autonomyAvailability)
+ )
+ const showAutonomyModeSelector = $derived(
+ !disabled && availableAutonomyModeOptions(autonomyAvailability).length > 1
)
- const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1)
- const effectiveAutonomyModeOption = $derived(autonomyModeOption(effectiveAutonomyMode))
// The typing-dots indicator implies the AI is busy, which is misleading while
// the loop is parked on the user; surface a text pill instead so users know to
@@ -636,10 +584,55 @@
// A step name hangs its icon in the column's left padding (see AssistantMessage), so a
// transcript carrying one widens the padding, on both sides to keep the column centred.
const agentGutter = $derived(messages.some((m) => m.role === 'assistant' && m.stepName))
+ // The block's layout holds while the chat is empty, usable or not; only the taller box
+ // and the suggestions need a composer that can send.
+ const starterLayout = $derived(!!starter && messages.length === 0)
+ // Home is a page, not a pane: nothing there to centre in.
+ const centreComposer = $derived(starterLayout && starter?.where === 'session')
+
+ // Home's composer at rest is one line. Focusing the field opens it; a press outside it or
+ // Escape puts it back, but only with nothing in the draft: leaving it open then means
+ // clearing the text later cannot fold it up under the caret. Opened by the field rather
+ // than any focus inside: the one-line row's model button would otherwise unmount under the
+ // menu it is opening.
+ let composerEl: HTMLDivElement | undefined = $state()
+ let composerOpened = $state(false)
+ const homeComposer = $derived(starterLayout && starter?.where === 'home')
+ // Anything the user has started (text, an attachment) or is in the middle of (they opened it
+ // and have not left) keeps it open, and so does a workspace small enough that the composer is
+ // the page's main way in.
+ // Until the input is bound, the draft it starts with is the one it is seeded with: reading
+ // it as empty collapses a remounted composer that holds text for a frame, and the box then
+ // animates open as if the user had opened it.
+ const seededDraftEmpty = $derived(
+ !initialInstructions?.trim() &&
+ !initialAttachments?.pastes.length &&
+ !initialAttachments?.images.length &&
+ !initialAttachments?.files.length &&
+ !initialAttachments?.blobs.length
+ )
+ const composerCollapsed = $derived(
+ homeComposer &&
+ !disabled &&
+ !(starter?.where === 'home' && starter.fewItems) &&
+ !composerOpened &&
+ (aiChatInput ? aiChatInput.isDraftEmpty() : seededDraftEmpty)
+ )
+ function closeComposer() {
+ if (aiChatInput?.isDraftEmpty() ?? true) composerOpened = false
+ }
+ function onComposerPointerDown(e: PointerEvent) {
+ if (!homeComposer || !composerOpened || composerEl?.contains(e.target as Node)) return
+ if (!isComposerOverlay(e.target)) closeComposer()
+ }
+
+ // Home's composer is one block of the page and takes the page's column as it is.
const columnClass = $derived(
- wideLayout
- ? `w-full max-w-3xl mx-auto ${agentGutter ? 'px-8' : 'px-7'}`
- : `w-full max-w-2xl mx-auto ${agentGutter ? 'px-8' : 'px-3'}`
+ starter?.where === 'home'
+ ? 'w-full'
+ : wideLayout
+ ? `w-full max-w-3xl mx-auto ${agentGutter ? 'px-8' : 'px-7'}`
+ : `w-full max-w-2xl mx-auto ${agentGutter ? 'px-8' : 'px-3'}`
)
const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages))
@@ -695,6 +688,7 @@
const showFooterLeftControls = $derived(
!footerMessageShown &&
(canAttachFiles ||
+ !!actingOn ||
attachmentsOffReason !== undefined ||
showContextPicker ||
showAutonomyModeSelector ||
@@ -740,10 +734,12 @@
- A linked folder is a session-wide resource: the assistant lists, searches,
- and reads its files whenever it needs them.
+ {chatHost.autoAcceptEditsAvailable
+ ? 'This can result in edits being applied or tools being called without user confirmation.'
+ : 'This can result in tools being called without user confirmation.'}
+ {#if yoloBypassedTools.length > 0}
+
Bypassed in current mode:
+
+ {#each visibleYoloBypassedTools as tool (tool.name)}
+
{tool.label}
+ {/each}
+
+ {#if hiddenYoloBypassedToolCount > 0}
+
+ {hiddenYoloBypassedToolCount} more
+ {/if}
+ {:else}
+
No tools in the current mode require confirmation.
- {chatHost.autoAcceptEditsAvailable
- ? 'This can result in edits being applied or tools being called without user confirmation.'
- : 'This can result in tools being called without user confirmation.'}
-
- {#if yoloBypassedTools.length > 0}
-
Bypassed in current mode:
-
- {#each visibleYoloBypassedTools as tool (tool.name)}
-
{tool.label}
- {/each}
-
- {#if hiddenYoloBypassedToolCount > 0}
-
+ {hiddenYoloBypassedToolCount} more
- {/if}
- {:else}
-
No tools in the current mode require confirmation.
@@ -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}
+
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}
+
+ Ask AI{askAiElements.length < items.length ? ` (${askAiElements.length})` : ''}
+
+ {/if}
- /** Example prompts: the short `label` is shown as a clickable tag under the chat,
- * the `prompt` is what rotates through the placeholder / gets dropped into the input. */
- export const homeAIExamples: { label: string; prompt: string }[] = [
- {
- label: 'Sync Salesforce',
- prompt: 'Sync new Salesforce leads into a postgres table every hour'
- },
- {
- 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'
- },
- {
- label: 'Weekly Slack report',
- prompt: 'Generate a weekly sales report from postgres and post it to Slack every Monday'
- }
- ]
+ import type HomeSessionStarterComponent from '../sessions/HomeSessionStarter.svelte'
- /** A session for a role that cannot code can run what is deployed, read runs and call MCP
- * servers, but cannot author scripts. It composes flows or apps only where its role grants
- * that, so none of these asks it to build anything. */
- export const homeAIOperatorExamples: { label: string; prompt: string }[] = [
- {
- label: 'Run a flow',
- prompt: 'Find the flow that onboards a new employee and run it for Jane Doe, starting Monday'
- },
- {
- label: 'Rerun failed jobs',
- prompt: 'List the jobs that failed today, tell me why, and rerun the ones that timed out'
- },
- {
- label: 'Summarize GitHub issues',
- prompt: "Read this week's open GitHub issues and summarize the ones that mention billing"
- }
- ]
+ // The starter is a full AIChat, which reaches monaco, and the build refuses monaco in Home's
+ // static imports. Kept once loaded, so a return to Home renders it in its first frame: the
+ // AI Sessions → Home view transition snapshots that frame.
+ let loadedStarter: typeof HomeSessionStarterComponent | undefined
+ function loadStarter() {
+ return import('../sessions/HomeSessionStarter.svelte').then((m) => (loadedStarter = m.default))
+ }
-
- {#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}
-
-
- Open account settings
-
- {:else}
-
- {freeTierExhausted ? 'Add your own API key' : 'Configure AI'}
-
- {/if}
- removeComposer('no_ai')}
- >Remove
-
-
- {/if}
-
{/if}
-
-
- {#if showComposer && !collapsed}
-
- {#each examples as example (example.label)}
- (value = example.prompt)}
- >
- {example.label}
-
- {/each}
-
+ {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)}
+
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}
+
+
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 @@
-
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
+
+
+ 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}
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 @@