feat: home and empty ai sessions share one session composer (#11635)

* feat: open empty ai sessions with the home build-with-ai composer

* feat: vertically center the empty session composer

* fix: drop the stray indent on the session composer's rotating placeholder

* fix: give the starter composer a stable accessible name

* feat: pick workspace, fork and permission mode from the home composer

* fix: hand off the workspace home displays, not only an explicit pick

* feat: drop the starter prompt chips from the empty session

* feat: home and empty ai sessions share one session composer

* fix: keep session draft attachments across home and session page

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: drop superseded home ai-hidden responses

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: load home session starter lazily to keep monaco out of home

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: redesign home composer and items list

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: align the composer chat icon, share the folder row, add a home preview page

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: drop home tech debt, fix phone layout and keyboard access, remove the home preview page

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* perf: read filter chip positions before hiding any in clampRows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to dd0c7087f9b2453260f3d73b0d589042b4393a1d

This commit updates the EE repository reference after PR #900 was merged in windmill-ee-private.

Previous ee-repo-ref: e922599943c472a6d0f736f629ae04081f866686

New ee-repo-ref: dd0c7087f9b2453260f3d73b0d589042b4393a1d

Automated by sync-ee-ref workflow.

* fix: align the home separator, drop the dark composer band, unclip pin menu and session suggestions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: give the home composer the input background in dark mode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: put home items in a card, keep the row menu visible, colour kinds, add-to-context button

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: settle the session composer before the transition captures it, ease the greeting in after it lands

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: let the empty-workspace placeholder take the card's background

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep a remounted home composer open when its draft holds text

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: show favorites and recent edits as cards above the home tree (#11894)

* feat: show favorites and recent edits as cards above the home tree

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep label-filter paging on the home tree and skip draft agents in recent

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: star raw apps as apps on home cards, open filtered tree folders, keep cards under filters

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* style: set the favorites & recent label in normal weight and sentence case

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: add a playground comparing layouts for home's favorites & recent

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: scroll home's favorites & recent cards, add section settings and counts

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: drop stale favorites loads and keep home cards restorable without the AI beta

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: clear stale favorites on a failed load and gate card menu on role

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Guilhem Lemouel <guilhemlemouel@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Diego Imbert
2026-10-21 20:02:45 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 Guilhem Lemouel windmill-internal-app[bot] Ruben Fiszel
parent 7516a3ef0b
commit 56b2e106da
82 changed files with 3648 additions and 1912 deletions
+43
View File
@@ -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;
}
@@ -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}`}
@@ -801,14 +801,18 @@
<div class="py-1 p-2 overflow-y-auto" onmousedown={(e) => e.stopPropagation()}>
{#if !currentTag || !schema[currentTag]}
{#if presets.length}
<div class="text-xs px-2 my-2 font-bold">Presets</div>
<div class="text-2xs uppercase tracking-wide font-semibold text-secondary px-2 my-2">
Presets
</div>
<div class="mb-3 px-2 flex gap-2 flex-wrap max-h-24 overflow-y-auto">
{#each presets as preset}
{@render presetTag(preset)}
{/each}
</div>
{/if}
<div class="text-xs px-2 my-2 font-bold">Filters</div>
<div class="text-2xs uppercase tracking-wide font-semibold text-secondary px-2 my-2">
Filters
</div>
{#each menuItems as item, index}
{#if item.type === 'filter' && item.filterSchema}
{@render menuItem({
@@ -947,18 +951,18 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class={twMerge(
'py-1.5 px-2 rounded-md hover:bg-surface-hover cursor-pointer text-sm flex items-center gap-3',
'py-1.5 px-2 rounded-md hover:bg-surface-hover cursor-pointer text-xs text-primary flex items-center gap-2',
highlighted && 'bg-surface-hover'
)}
onclick={onClick}
>
{#if Icon}
<Icon size={16} class="inline" />
<Icon size={14} class="inline shrink-0" />
{/if}
<div class="inline flex-1 relative min-w-0">
<div class="text-sm ellipsize">{label}</div>
<div class="ellipsize">{label}</div>
{#if description}
<div class="text-xs text-hint">{description}</div>
<div class="text-2xs text-hint">{description}</div>
{/if}
</div>
{#if onNegativeClick}
@@ -1,33 +1,15 @@
<script lang="ts">
import Badge from './common/badge/Badge.svelte'
import LabelList from './common/badge/LabelList.svelte'
import type { BadgeColor } from './common/badge/model'
import { Folder } from 'lucide-svelte'
interface Props {
labels: string[] | undefined
max?: number
color?: BadgeColor
}
let { labels, max = 3 }: Props = $props()
let { labels, max, color }: Props = $props()
</script>
{#if labels?.length}
<div class="flex items-center gap-0.5">
{#each labels.slice(0, max) as label (label)}
<Badge color="gray" small class="px-1" title="Label inherited from folder: {label}">
<Folder size={10} class="mr-0.5 shrink-0" />
{label}
</Badge>
{/each}
{#if labels.length > max}
<Badge
color="gray"
small
class="px-1"
title={labels
.slice(max)
.map((l) => 'Label inherited from folder: ' + l)
.join('\n')}>+{labels.length - max}</Badge
>
{/if}
</div>
{/if}
<LabelList {labels} icon={Folder} titlePrefix="Label inherited from folder" {max} {color} />
+5 -2
View File
@@ -10,9 +10,10 @@
summary?: string
workspaceId?: string
size?: number
class?: string
}
let { path, kind, workspaceId, summary, size = 16 }: Props = $props()
let { path, kind, workspaceId, summary, size = 16, class: className = 'p-1' }: Props = $props()
let buttonHover = $state(false)
let starred = $derived(favoriteManager.isStarred(path, kind))
@@ -28,7 +29,9 @@
onclick={preventDefault(onClick)}
onmouseenter={() => (buttonHover = true)}
onmouseleave={() => (buttonHover = false)}
class="p-1"
class={className}
data-starred={starred || undefined}
title={starred ? 'Remove from favorites' : 'Add to favorites'}
>
{#if starred}
{#if buttonHover}
@@ -65,7 +65,8 @@
['dark-indigo']: 'bg-indigo-500 text-indigo-100 dark:bg-indigo-600 dark:text-indigo-200',
['dark-orange']: 'bg-orange-500 text-orange-100 dark:bg-orange-600 dark:text-orange-200',
['dark-violet']: 'bg-violet-500 text-violet-100 dark:bg-violet-600 dark:text-violet-200',
transparent: 'bg-transparent border'
transparent: 'bg-transparent border',
subtle: 'bg-transparent'
}
const selectedColors: Record<BadgeColor, string> = {
@@ -86,7 +87,8 @@
['dark-orange']: 'bg-orange-700 text-orange-100 dark:bg-orange-800 dark:text-orange-200',
['dark-violet']: 'bg-violet-700 text-violet-100 dark:bg-violet-800 dark:text-violet-200',
// Keeps the unselected chip's border, so selecting one does not change its height.
transparent: 'bg-surface-accent-selected text-accent border border-surface-accent-selected'
transparent: 'bg-surface-accent-selected text-accent border border-surface-accent-selected',
subtle: 'bg-surface-accent-selected text-accent'
}
const hovers: Partial<Record<BadgeColor, string>> = {
@@ -106,7 +108,8 @@
['dark-indigo']: 'hover:bg-indigo-600 dark:hover:bg-indigo-700',
['dark-orange']: 'hover:bg-orange-600 dark:hover:bg-orange-700',
['dark-violet']: 'hover:bg-violet-600 dark:hover:bg-violet-700',
transparent: 'hover:bg-surface-hover dark:hover:border-gray-500'
transparent: 'hover:bg-surface-hover dark:hover:border-gray-500',
subtle: 'hover:bg-surface-hover'
}
let badgeClass = $derived(
@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import Badge from './Badge.svelte'
import type { BadgeColor, BadgeIconProps } from './model'
/**
* How a label, an owner or a folder reads wherever an item is tagged with it: the row's own
* labels and Home's quick filters both draw it, so the two cannot drift apart.
*/
interface Props {
icon?: BadgeIconProps['icon']
color?: BadgeColor
title?: string
/** Makes it a filter: clickable, and marked with × once selected. */
onclick?: (event: MouseEvent) => void
selected?: boolean
children?: Snippet
}
let { icon: Icon, color = 'subtle', title, onclick, selected = false, children }: Props = $props()
</script>
<Badge
{color}
{title}
{onclick}
clickable={!!onclick}
{selected}
class="inline-flex items-center gap-1 px-1.5 align-middle"
>
{#if Icon}
<Icon size={12} class="shrink-0" />
{/if}
{@render children?.()}
{#if selected}&cross;{/if}
</Badge>
@@ -0,0 +1,33 @@
<script lang="ts">
import LabelBadge from './LabelBadge.svelte'
import type { BadgeColor, BadgeIconProps } from './model'
interface Props {
labels: string[] | undefined | null
icon: BadgeIconProps['icon']
/** Names where each label comes from in its tooltip, e.g. "Label". */
titlePrefix: string
/** Shown as badges; the rest are counted, and named in the count's tooltip. */
max?: number
color?: BadgeColor
}
let { labels, icon, titlePrefix, max = 3, color = 'gray' }: Props = $props()
</script>
{#if labels?.length}
<div class="flex items-center gap-0.5">
{#each labels.slice(0, max) as label (label)}
<LabelBadge {color} {icon} title="{titlePrefix}: {label}">{label}</LabelBadge>
{/each}
{#if labels.length > max}
<LabelBadge
{color}
title={labels
.slice(max)
.map((l) => `${titlePrefix}: ${l}`)
.join('\n')}>+{labels.length - max}</LabelBadge
>
{/if}
</div>
{/if}
@@ -1,6 +1,11 @@
type BaseColor = 'blue' | 'gray' | 'red' | 'green' | 'yellow' | 'indigo' | 'orange' | 'violet'
export const ColorModifier = 'dark-'
export type BadgeColor = BaseColor | 'transparent' | `${typeof ColorModifier}${BaseColor}`
/** `transparent` is outlined; `subtle` has neither fill nor border. */
export type BadgeColor =
| BaseColor
| 'transparent'
| 'subtle'
| `${typeof ColorModifier}${BaseColor}`
export interface BadgeIconProps {
position?: 'left' | 'right'
@@ -22,7 +22,7 @@
disabled?: boolean
onClick?: () => void
divider?: boolean
type?: 'action' | 'delete'
type?: 'action' | 'delete' | 'ai'
shortcut?: string
}
@@ -129,7 +129,8 @@
? CONTEXT_MENU_ITEM_DISABLED_CLASS
: menuItem.type === 'delete'
? CONTEXT_MENU_ITEM_DELETE_CLASS
: CONTEXT_MENU_ITEM_HOVER_MELT_CLASS
: CONTEXT_MENU_ITEM_HOVER_MELT_CLASS,
menuItem.type === 'ai' && !menuItem.disabled && 'text-ai'
)}
use:melt={$item}
onclick={() => handleItemClick(menuItem)}
@@ -16,7 +16,7 @@
import MSSqlServerIcon from '$lib/components/icons/MSSqlServerIcon.svelte'
import BunIcon from '$lib/components/icons/BunIcon.svelte'
import DenoIcon from '$lib/components/icons/DenoIcon.svelte'
import type { Script } from '$lib/gen'
import { languageLabel } from './languageLabels'
import PHPIcon from '$lib/components/icons/PHPIcon.svelte'
import RustIcon from '$lib/components/icons/RustIcon.svelte'
import AnsibleIcon from '$lib/components/icons/AnsibleIcon.svelte'
@@ -52,40 +52,6 @@
let { lang, width = 30, height = 30, scale = 1, size = undefined, ...rest }: Props = $props()
const languageLabel: Record<
Script['language'] | 'bunnative' | 'claudesandbox' | 'pisandbox',
String
> = {
python3: 'Python',
deno: 'TypeScript',
go: 'Go',
bash: 'Bash',
powershell: 'PowerShell',
nativets: 'HTTP',
bunnative: 'HTTP',
graphql: 'GraphQL',
postgresql: 'Postgresql',
bigquery: 'BigQuery',
oracledb: 'Oracle Database',
duckdb: 'DuckDB',
snowflake: 'Snowflake',
mysql: 'MySQL',
mssql: 'MS SQL Server',
bun: 'TypeScript',
php: 'PHP',
rust: 'Rust',
ansible: 'Ansible',
csharp: 'C#',
nu: 'Nu',
java: 'Java',
ruby: 'Ruby',
rlang: 'R',
dbt: 'dbt',
claudesandbox: 'Claude Sandbox',
pisandbox: 'Pi Sandbox'
// for related places search: ADD_NEW_LANG
}
const langToComponent: Record<
| SupportedLanguage
| 'pgsql'
@@ -0,0 +1,11 @@
<script lang="ts">
import { languageLabel, languageShortLabel, type LabelledLanguage } from './languageLabels'
let { lang }: { lang: LabelledLanguage } = $props()
</script>
<!-- Quiet on purpose: it sits beside a title, and the title is what the eye is reading. -->
<span
class="shrink-0 text-3xs font-medium text-hint tracking-wide"
title={languageLabel[lang] ?? lang}>{languageShortLabel[lang] ?? lang}</span
>
@@ -0,0 +1,68 @@
import type { Script } from '$lib/gen'
export type LabelledLanguage = Script['language'] | 'bunnative' | 'claudesandbox' | 'pisandbox'
/** A language's name, as a tooltip or anywhere the full word fits. */
export const languageLabel: Record<LabelledLanguage, string> = {
python3: 'Python',
deno: 'TypeScript',
go: 'Go',
bash: 'Bash',
powershell: 'PowerShell',
nativets: 'HTTP',
bunnative: 'HTTP',
graphql: 'GraphQL',
postgresql: 'Postgresql',
bigquery: 'BigQuery',
oracledb: 'Oracle Database',
duckdb: 'DuckDB',
snowflake: 'Snowflake',
mysql: 'MySQL',
mssql: 'MS SQL Server',
bun: 'TypeScript',
php: 'PHP',
rust: 'Rust',
ansible: 'Ansible',
csharp: 'C#',
nu: 'Nu',
java: 'Java',
ruby: 'Ruby',
rlang: 'R',
dbt: 'dbt',
claudesandbox: 'Claude Sandbox',
pisandbox: 'Pi Sandbox'
// for related places search: ADD_NEW_LANG
}
/** A few letters for a list row, where a logo per row is more than the eye needs. Every SQL
* dialect reads "SQL": which one is the tooltip's job (`languageLabel`). */
export const languageShortLabel: Record<LabelledLanguage, string> = {
python3: 'Py',
deno: 'TS',
bun: 'TS',
go: 'Go',
bash: 'Sh',
powershell: 'PS',
nativets: 'Rest',
bunnative: 'Rest',
graphql: 'GQL',
postgresql: 'SQL',
bigquery: 'SQL',
oracledb: 'SQL',
duckdb: 'SQL',
snowflake: 'SQL',
mysql: 'SQL',
mssql: 'SQL',
php: 'PHP',
rust: 'Rs',
ansible: 'Ans',
csharp: 'C#',
nu: 'Nu',
java: 'Java',
ruby: 'Rb',
rlang: 'R',
dbt: 'dbt',
claudesandbox: 'AI',
pisandbox: 'AI'
// for related places search: ADD_NEW_LANG
}
@@ -4,7 +4,7 @@
import DraftBadge from '$lib/components/DraftBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import RowLabels from './RowLabels.svelte'
import type { ListableResource } from '$lib/gen'
import { roleCapabilities, userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
@@ -84,6 +84,7 @@
>
{#snippet badges()}
<SharedBadge canWrite={agent.canWrite} extraPerms={agent.extra_perms} />
<RowLabels inherited={agent.inherited_labels} />
<DraftBadge
is_draft={agent.is_draft}
draft_only={agent.draft_only}
@@ -93,24 +94,18 @@
path={agent.path}
onMigrated={() => dispatch('change')}
/>
<InheritedLabels labels={agent.inherited_labels} />
{/snippet}
{#snippet actions()}
{#if agent.canWrite}
<span class="hidden md:inline-flex">
<Button
variant="subtle"
wrapperClasses="w-20"
unifiedSize="md"
startIcon={{ icon: Pen }}
href={editHref}
>
<Button variant="subtle" unifiedSize="sm" startIcon={{ icon: Pen }} href={editHref}>
Edit
</Button>
</span>
{/if}
<Dropdown
size="sm"
items={async () => [
...selectMenuItems(rowSelection),
...agentMenuItems({
@@ -12,7 +12,8 @@
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import { selectMenuItems, type RowSelection } from './rowSelection'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import RowLabels from './RowLabels.svelte'
import AskAiRowButton from './AskAiRowButton.svelte'
import Badge from '../badge/Badge.svelte'
import {
ExternalLink,
@@ -129,6 +130,7 @@
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
<RowLabels labels={app.labels} inherited={app.inherited_labels} />
<DraftBadge
is_draft={app.is_draft}
draft_only={app.draft_only}
@@ -139,36 +141,17 @@
path={app.path}
onMigrated={() => dispatch('change')}
/>
{#if app.labels?.length}
<div class="flex items-center gap-0.5">
{#each app.labels.slice(0, 3) as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{#if app.labels.length > 3}
<Badge
color="blue"
small
class="px-1"
title={app.labels
.slice(3)
.map((l) => 'Label: ' + l)
.join('\n')}>+{app.labels.length - 3}</Badge
>
{/if}
</div>
{/if}
<InheritedLabels labels={app.inherited_labels} />
<div class="w-8 center-center"></div>
{/snippet}
{#snippet actions()}
<span class="hidden md:inline-flex gap-x-1">
<span class="hidden md:inline-flex">
<AskAiRowButton kind="app" path={app.path} summary={app.summary} rawApp={!!app.raw_app} />
{#if showEditButton && app.canWrite}
<div>
<Button
aiId={`edit-app-button-${app.summary?.length > 0 ? app.summary : app.path}`}
aiDescription={`Edits the app ${app.summary?.length > 0 ? app.summary : app.path}`}
variant="subtle"
wrapperClasses="w-20"
unifiedSize="sm"
startIcon={{ icon: Pen }}
href="/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}"
>
@@ -181,6 +164,7 @@
{/if}
</span>
<Dropdown
size="sm"
aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`}
aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`}
items={async () => {
@@ -0,0 +1,30 @@
<script lang="ts">
import Button from '../button/Button.svelte'
import {
homeComposerContext,
itemContextElement
} from '$lib/components/sessions/homeComposerContext.svelte'
import type { BulkKind } from '$lib/components/home/homeSelection.svelte'
let {
kind,
path,
summary,
rawApp
}: { kind: BulkKind; path: string; summary?: string; rawApp?: boolean } = $props()
let element = $derived(itemContextElement({ kind, path, summary: summary || undefined, rawApp }))
</script>
{#if element && homeComposerContext.available}
<Button
variant="subtle"
unifiedSize="sm"
btnClasses="!text-ai font-medium"
title="Add to AI session context"
aria-label="Add to AI session context"
onClick={() => element && homeComposerContext.attach(element)}
>
+@
</Button>
{/if}
@@ -35,7 +35,7 @@
<Button
variant="subtle"
wrapperClasses="max-w-56"
unifiedSize="md"
unifiedSize="sm"
startIcon={{ icon: Pen }}
{href}
onClick={(e) => onEditInForkClick(e, itemType, path, { hasHref: true })}
@@ -36,7 +36,8 @@
} from 'lucide-svelte'
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import RowLabels from './RowLabels.svelte'
import AskAiRowButton from './AskAiRowButton.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork'
import EditInForkButton from './EditInForkButton.svelte'
@@ -159,6 +160,7 @@
<Badge color="red" baseClass="border">archived</Badge>
{/if}
<SharedBadge canWrite={flow.canWrite} extraPerms={flow.extra_perms} />
<RowLabels labels={flow.labels} inherited={flow.inherited_labels} />
<DraftBadge
is_draft={flow.is_draft}
draft_only={flow.draft_only}
@@ -169,35 +171,15 @@
path={flow.path}
onMigrated={() => dispatch('change')}
/>
{#if flow.labels?.length}
<div class="flex items-center gap-0.5">
{#each flow.labels.slice(0, 3) as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{#if flow.labels.length > 3}
<Badge
color="blue"
small
class="px-1"
title={flow.labels
.slice(3)
.map((l) => 'Label: ' + l)
.join('\n')}>+{flow.labels.length - 3}</Badge
>
{/if}
</div>
{/if}
<InheritedLabels labels={flow.inherited_labels} />
<div class="w-8 center-center"></div>
{/snippet}
{#snippet actions()}
<span class="hidden md:inline-flex gap-x-1">
<span class="hidden md:inline-flex">
<AskAiRowButton kind="flow" path={flow.path} summary={flow.summary} />
{#if !hideForOperator && showEditButton && flow.canWrite && !flow.archived}
<div>
<Button
variant="subtle"
wrapperClasses="w-20"
unifiedSize="md"
unifiedSize="sm"
startIcon={{ icon: Pen }}
href="/flows/edit/{flow.path}"
aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
@@ -214,6 +196,7 @@
</span>
<Dropdown
size="sm"
aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`}
items={async () => {
@@ -80,6 +80,7 @@
{#snippet actions()}
{#if $roleCapabilities.code}
<Dropdown
size="sm"
items={[
{
displayName: 'Delete',
@@ -47,6 +47,7 @@
{/snippet}
{#snippet actions()}
<Dropdown
size="sm"
items={async () => {
let { path } = app
@@ -9,6 +9,9 @@
import Tooltip from '../../meltComponents/Tooltip.svelte'
import Checkbox from '../checkbox/Checkbox.svelte'
import type { RowSelection } from './rowSelection'
import { HOVER_ACTIONS_CLASS, inUnruledList } from './unruledList'
const unruled = inUnruledList()
interface Props {
marked: string | undefined
@@ -180,8 +183,11 @@
data-row-selection-key={rowSelection?.key}
data-row-keyboard-selected={keyboardSelected ? 'true' : undefined}
class={twMerge(
'group/row w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b px-4 py-3 border-b last:border-b-0',
depth > 0 ? '!rounded-none' : '',
'group/row w-full inline-flex items-center gap-4 px-4 py-3',
unruled
? 'rounded-md'
: 'first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b border-b last:border-b-0',
depth > 0 && !unruled ? '!rounded-none' : '',
disabled ? 'opacity-25' : 'hover:bg-surface-hover',
clickToSelect || inSelectionMode ? 'cursor-pointer select-none' : '',
selected || rowSelection?.selected
@@ -255,20 +261,15 @@
{/if}
{#if badges}
<div class="hidden lg:flex flex-row gap-4 items-center">
<div class="hidden lg:flex shrink-0 flex-row gap-4 items-center">
{@render badges?.()}
</div>
{/if}
{#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow' || kind == 'agent')}
<div class="center-center h-full text-xs font-semibold text-secondary w-9 shrink-0">
<Star {kind} {path} {workspaceId} {summary} />
</div>
{:else}
<div class="w-9 shrink-0"></div>
{/if}
<div data-row-actions class="flex gap-1 items-center justify-end pr-2">
<div
data-row-actions
class="flex shrink-0 items-center justify-end {unruled ? HOVER_ACTIONS_CLASS : ''}"
>
{@render actions?.()}
</div>
</div>
@@ -280,7 +281,7 @@
</div>
{/if}
<div class="grow min-w-0">
<div class="text-emphasis flex items-center gap-2 text-left text-xs font-semibold">
<div class="text-emphasis flex items-center gap-1.5 text-left text-xs font-semibold">
{#if customSummary}
<div class="min-w-0">{@render customSummary()}</div>
{:else}
@@ -295,6 +296,19 @@
{#if titleBadge}
<span class="shrink-0">{@render titleBadge()}</span>
{/if}
{#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow' || kind == 'agent')}
<!-- Beside the title it marks: a star already set stays, an unset one shows on the
row's hover or keyboard focus only, so a list of titles doesn't read as a column of
outlines. Opacity, not visibility: a hidden button drops out of the Tab order. -->
<Star
{kind}
{path}
{workspaceId}
{summary}
size={13}
class="inline-flex shrink-0 p-px text-yellow-500 opacity-0 data-[starred]:opacity-100 group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100"
/>
{/if}
</div>
<div class="text-hint text-3xs truncate text-left font-normal" title={path}>
{#if pathDisplay}
@@ -0,0 +1,17 @@
<script lang="ts">
import { Folder, Tag } from 'lucide-svelte'
import LabelList from '../badge/LabelList.svelte'
let { labels, inherited }: { labels?: string[] | null; inherited: string[] | undefined } =
$props()
</script>
<!-- A row has little room beside its title: one of each kind shows, the rest are counted. -->
<LabelList {labels} icon={Tag} titlePrefix="Label" max={1} color="subtle" />
<LabelList
labels={inherited}
icon={Folder}
titlePrefix="Label inherited from folder"
max={1}
color="subtle"
/>
@@ -29,7 +29,7 @@
import { isDeployable } from '$lib/utils_deployable'
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import { LanguageIcon } from '../languageIcons'
import LanguageTag from '../languageIcons/LanguageTag.svelte'
import {
Archive,
Calendar,
@@ -51,7 +51,8 @@
import WacExportDrawer from '$lib/components/scripts/WacExportDrawer.svelte'
import { Drawer, DrawerContent } from '..'
import NoMainFuncBadge from '$lib/components/NoMainFuncBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import RowLabels from './RowLabels.svelte'
import AskAiRowButton from './AskAiRowButton.svelte'
import Popover from '$lib/components/Popover.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
@@ -208,6 +209,8 @@
>
{/if}
<SharedBadge canWrite={script.canWrite} extraPerms={script.extra_perms} />
<RowLabels labels={script.labels} inherited={script.inherited_labels} />
<!-- Last, so the draft badges line up down the list whatever labels each row has. -->
<DraftBadge
is_draft={script.is_draft}
draft_only={script.draft_only}
@@ -218,32 +221,15 @@
path={script.path}
onMigrated={() => dispatch('change')}
/>
{#if script.labels?.length}
<div class="flex items-center gap-0.5">
{#each script.labels.slice(0, 3) as label}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{#if script.labels.length > 3}
<Badge
color="blue"
small
class="px-1"
title={script.labels
.slice(3)
.map((l) => 'Label: ' + l)
.join('\n')}>+{script.labels.length - 3}</Badge
>
{/if}
</div>
{/if}
<InheritedLabels labels={script.inherited_labels} />
<div class="w-8 center-center">
<LanguageIcon lang={script.language} width={16} height={16} />
</div>
{/snippet}
{#snippet titleBadge()}
<LanguageTag lang={script.language} />
{/snippet}
{#snippet actions()}
<span class="hidden md:inline-flex gap-x-1">
<span class="hidden md:inline-flex">
<AskAiRowButton kind="script" path={script.path} summary={script.summary} />
{#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}
</span>
<Dropdown
size="sm"
aiId={`script-row-dropdown-${script.summary?.length > 0 ? script.summary : script.path}`}
aiDescription={`Open dropdown for script ${script.summary?.length > 0 ? script.summary : script.path} options`}
items={async () => {
@@ -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<boolean | undefined>(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'
@@ -98,7 +98,12 @@
title={config.readOnly ? config.readOnlyReason : config.title}
>
<span class="flex items-center gap-1 min-w-0">
<span class="truncate">{config.label}</span>
{#if config.shortLabel}
<span class="truncate hidden xl:inline">{config.label}</span>
<span class="truncate xl:hidden">{config.shortLabel}</span>
{:else}
<span class="truncate">{config.label}</span>
{/if}
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
@@ -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}
></AIChatDisplay>
File diff suppressed because it is too large Load Diff
@@ -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 @@
}
</script>
{#snippet sendStopButton()}
{#snippet toolbarRow()}
{#if oneLine}
<div class="flex flex-row items-center gap-1 pr-3 shrink-0">
{@render collapsedControls?.()}
{@render sendStopButton('sm')}
</div>
{:else if toolbarInBox}
<!-- The send button sets the row's height and the smaller controls centre on it, which
already puts them a few pixels further from the box's bottom edge than the padding. -->
<div class="flex flex-row items-center gap-1.5 px-3 pb-1.5">
<div class="flex-1 min-w-0">{@render toolbar?.()}</div>
{@render sendStopButton('sm')}
</div>
{/if}
{/snippet}
{#snippet sendStopButton(size: 'sm' | 'md' = 'md')}
<!-- The turn stays `loading` while parked on a question, but a drafted answer
is what the button should ship then — otherwise the only pointer action on
a typed answer would be Stop. Anything else keeps Stop. -->
@@ -1208,9 +1295,11 @@
(onSendRequest !== undefined ||
chatHost.mode !== AIMode.GLOBAL ||
selectedContext.length === 0))}
<!-- Accent only once there is something to send, so the button lights up as the draft
becomes sendable; idle and Stop stay quiet. -->
<Button
variant="subtle"
unifiedSize="md"
variant={!isLoading && !sendDisabled ? 'accent' : 'subtle'}
unifiedSize={size}
iconOnly
title={isLoading
? 'Stop'
@@ -1378,6 +1467,9 @@
{availableContext}
{selectedContext}
placeholder={modePlaceholder}
hero={hero && !disabled}
{oneLine}
{chatIcon}
onAddContext={(contextElement) => void addContextToSelection(contextElement)}
onRemoveContext={(element) => {
selectedContext = selectedContext?.filter((c) => !isSameContextElement(c, element))
@@ -1390,13 +1482,21 @@
}}
{disabled}
{onKeyDown}
className={twMerge(
oneLine ? '!pl-4 !pr-2 !py-3' : toolbarInBox ? FIELD_PADDING_WITH_TOOLBAR : '',
// A set line height, not the font's own: the chat icon is placed against it.
chatIcon && '!pl-2 !leading-5'
)}
>
{#snippet leading()}
{@render badgeRow()}
{@render imageChipsRow()}
{/snippet}
{#snippet trailing()}
{@render toolbarRow()}
{/snippet}
</ContextTextarea>
{#if !bottomRightSnippet}
{#if !bottomRightSnippet && !toolbarInBox}
<div class="absolute bottom-1 right-1">
{@render sendStopButton()}
</div>
@@ -1505,6 +1605,9 @@
</div>
</div>
{/if}
{#if toolbar && !toolbarInBox}
<div class="mt-1">{@render toolbar()}</div>
{/if}
{#if bottomRightSnippet}
<div class="absolute bottom-1 right-1">
{@render bottomRightSnippet()}
@@ -2319,10 +2319,14 @@ export class AIChatManager implements ChatViewHost {
)
}
setAiChatInput(aiChatInput: AIChatInput | null) {
setAiChatInput(aiChatInput: AIChatInput) {
this.aiChatInput = aiChatInput
}
releaseAiChatInput(aiChatInput: AIChatInput) {
if (this.aiChatInput === aiChatInput) this.aiChatInput = null
}
/** Queue the message typed while a turn is streaming. There is only ever
* one queued message; pressing Enter again appends the new text as another
* line so it all goes out as a single message, and its images accumulate
@@ -240,6 +240,9 @@
const config = $derived<ChatModelSettingsConfig>({
label: providerModel.model,
// "claude-sonnet-4-6" → "sonnet": the family is what tells Claude models apart at a
// glance. Other providers' names have no such word, so they stay whole.
shortLabel: /^claude-([a-z]+)-/.exec(providerModel.model)?.[1],
title: 'Model & reasoning settings',
badge: freeTier && !freeTier.exhausted ? { text: 'Free', warn: freeRunningLow } : undefined,
// Off in a session: the assistant settings modal's Instructions section owns the
@@ -0,0 +1,51 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import type { AIAutonomyMode } from './AIChatManager.svelte'
import {
autonomyModeOption,
availableAutonomyModeOptions,
resolveAutonomyMode,
type AutonomyAvailability
} from './autonomyModes'
let {
mode,
availability,
onChange
}: {
mode: AIAutonomyMode
availability: AutonomyAvailability
onChange: (mode: AIAutonomyMode) => void
} = $props()
const effective = $derived(resolveAutonomyMode(mode, availability))
const option = $derived(autonomyModeOption(effective))
</script>
<DropdownV2
items={() =>
availableAutonomyModeOptions(availability).map((o) => ({
displayName: o.label,
selected: effective === o.mode,
action: () => onChange(o.mode)
}))}
placement="bottom-start"
fixedHeight={false}
customWidth={240}
>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
title={option.tooltip(availability)}
btnClasses={option.triggerClass ?? ''}
startIcon={{ icon: option.icon, classes: option.iconColor }}
endIcon={{ icon: ChevronDown }}
>
{option.shortLabel ?? option.label}
</Button>
{/snippet}
</DropdownV2>
@@ -4,7 +4,13 @@
import { Button } from '$lib/components/common'
import autosize from '$lib/autosize'
import { CHAT_INPUT_PADDING } from './aiChatManagerContext'
import { composerBoxClass, COMPOSER_FIELD_RESET, type ComposerSelection } from './composerBox'
import type { Snippet } from 'svelte'
import {
composerBoxClass,
COMPOSER_FIELD_RESET,
HERO_BOX_CLASS,
type ComposerSelection
} from './composerBox'
/**
* The chat composer's box, drawn while the chat code loads. It must import nothing of
@@ -17,7 +23,8 @@
disabled = false,
autofocus = false,
onSelectionChange,
onCompositionChange
onCompositionChange,
toolbar
}: {
value: string
placeholder?: string
@@ -27,6 +34,8 @@
onSelectionChange?: (selection: ComposerSelection | undefined) => void
/** Whether an IME composition is in progress: replacing the field then would lose it. */
onCompositionChange?: (composing: boolean) => void
/** Draws the box that opens a session (AIChatInput's `hero`), its controls row in the box. */
toolbar?: Snippet
} = $props()
function trackSelection(e: Event & { currentTarget: HTMLTextAreaElement }) {
@@ -37,8 +46,19 @@
}
</script>
{#snippet sendButton(size: 'sm' | 'md')}
<Button
variant="subtle"
unifiedSize={size}
iconOnly
title="Send"
startIcon={{ icon: ArrowUp }}
disabled
/>
{/snippet}
<div class="relative mt-1">
<div class={composerBoxClass(disabled)}>
<div class={twMerge(composerBoxClass(disabled), toolbar && HERO_BOX_CLASS)}>
<div class="relative w-full">
<!-- Text metrics match ContextTextarea's `.textarea-input`, so the swap moves nothing. -->
<textarea
@@ -46,14 +66,15 @@
if (autofocus) node.focus()
}}
bind:value
use:autosize={{ maxHeight: '40vh' }}
use:autosize={toolbar ? { maxHeight: 160, minHeight: 56 } : { maxHeight: '40vh' }}
rows={1}
{placeholder}
{disabled}
class={twMerge(
'w-full resize-none overflow-clip whitespace-pre-wrap break-words text-xs leading-[1.72] min-h-[1.9rem]',
COMPOSER_FIELD_RESET,
CHAT_INPUT_PADDING
CHAT_INPUT_PADDING,
toolbar && '!pl-4 !pr-4 !pt-3 !pb-1'
)}
onfocus={trackSelection}
oninput={trackSelection}
@@ -78,16 +99,17 @@
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) e.preventDefault()
}}
></textarea>
<div class="absolute bottom-1 right-1">
<Button
variant="subtle"
unifiedSize="md"
iconOnly
title="Send"
startIcon={{ icon: ArrowUp }}
disabled
/>
</div>
{#if !toolbar}
<div class="absolute bottom-1 right-1">
{@render sendButton('md')}
</div>
{/if}
</div>
{#if toolbar}
<div class="flex flex-row items-center gap-1.5 px-3 pb-1.5">
<div class="flex-1 min-w-0">{@render toolbar()}</div>
{@render sendButton('sm')}
</div>
{/if}
</div>
</div>
@@ -1,5 +1,12 @@
<script lang="ts">
import { composerBoxClass, COMPOSER_FIELD_RESET, type ComposerSelection } from './composerBox'
import {
composerBoxClass,
COMPOSER_FIELD_RESET,
HERO_BOX_CLASS,
type ComposerSelection
} from './composerBox'
import { smoothHeight } from './starter/smoothHeight'
import { MessagesSquare } from 'lucide-svelte'
import autosize from '$lib/autosize'
import { tick, type Snippet } from 'svelte'
import type { ContextElement } from './context'
@@ -50,6 +57,14 @@
onKeyDown?: (e: KeyboardEvent) => void
/** Rendered inside the input box, above the textarea (e.g. context chips). */
leading?: Snippet
/** Rendered inside the input box, below the textarea (the composer's controls). */
trailing?: Snippet
/** The taller box that opens a session, matching Home's composer. */
hero?: boolean
/** One row: the field and `trailing` side by side, for Home's composer at rest. */
oneLine?: boolean
/** The assistant's chat mark before the field. */
chatIcon?: boolean
}
let {
@@ -66,7 +81,11 @@
onTextFiles,
className = '',
onKeyDown = undefined,
leading
leading,
trailing,
hero = false,
oneLine = false,
chatIcon = false
}: Props = $props()
const aiChatManager = getAiChatManager()
@@ -792,11 +811,35 @@
}
</script>
<div class={composerBoxClass(disabled)}>
<!-- Context chips live inside the input box, above the textarea. The snippet
<!-- The box that opens a session grows and shrinks between its one-line and full shapes. -->
<div
class={twMerge(composerBoxClass(disabled), hero && HERO_BOX_CLASS)}
use:smoothHeight={{ enabled: hero, key: oneLine }}
>
<div class={oneLine ? 'flex flex-row items-center' : ''}>
<!-- Context chips live inside the input box, above the textarea. The snippet
self-guards (renders nothing when empty) so no blank row appears. -->
{@render leading?.()}
<div class="relative w-full">
{@render leading?.()}
{#if chatIcon}
<!-- The same mark as the "open in chat" buttons, centred on the field's first text line in
both shapes: the slot is that line (1px border + 12px padding down, 20px tall), so it
relies on the field's `!py-3`/`!pt-3` and the `leading-5` set alongside `chatIcon`.
Centring on the field box instead follows its autosized height, which varies. -->
<div class="flex items-start {oneLine ? 'flex-1 min-w-0' : ''}">
<span class="flex items-center h-5 mt-[13px] ml-4 shrink-0">
<MessagesSquare size={16} class="text-ai" />
</span>
{@render field()}
</div>
{:else}
{@render field()}
{/if}
{@render trailing?.()}
</div>
</div>
{#snippet field()}
<div class="relative w-full {oneLine ? 'flex-1 min-w-0' : ''}">
<div
class={twMerge(
'textarea-input absolute inset-0 overflow-hidden pointer-events-none',
@@ -814,7 +857,7 @@
bind:this={textarea}
onkeydown={handleKeyDown}
bind:value
use:autosize={{ maxHeight: '40vh' }}
use:autosize={hero && !oneLine ? { maxHeight: 160, minHeight: 56 } : { maxHeight: '40vh' }}
rows={1}
oninput={handleInput}
onpaste={handlePaste}
@@ -840,7 +883,9 @@
}}
{placeholder}
class={twMerge(
'textarea-input resize-none caret-black dark:caret-white overflow-clip',
// `no-default-style`: the global input rule paints `surface-input` with `!important`
// and outranks `!bg-transparent`, so the field showed as a band on a hero box.
'textarea-input no-default-style block resize-none caret-black dark:caret-white overflow-clip',
COMPOSER_FIELD_RESET,
CHAT_INPUT_PADDING,
className
@@ -849,7 +894,7 @@
{disabled}
></textarea>
</div>
</div>
{/snippet}
{#if showContextTooltip || showCommandTooltip}
<Portal target="body">
@@ -0,0 +1,90 @@
import { ChevronsRight, ClipboardList, Hand } from 'lucide-svelte'
import { AIAutonomyMode } from './AIChatManager.svelte'
import { PLAN_MODE_TEXT_COLOR, PLAN_MODE_TRIGGER_CLASS } from './planMode'
export type AutonomyAvailability = {
autoAcceptEditsAvailable: boolean
autoAcceptToolConfirmationsAvailable: boolean
planModeAvailable: boolean
}
export 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
}
// 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.
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
}
]
export function autonomyModeOption(mode: AIAutonomyMode): AutonomyModeOption {
return autonomyModeOptions.find((o) => o.mode === mode) ?? askPermissionOption
}
export function availableAutonomyModeOptions(a: AutonomyAvailability): AutonomyModeOption[] {
return autonomyModeOptions.filter((option) => option.isAvailable(a))
}
/** Falls back to ask-permission when `mode` isn't applicable in the current AI mode
* (e.g. auto-accept edits while in a mode without edits). */
export function resolveAutonomyMode(mode: AIAutonomyMode, a: AutonomyAvailability) {
return availableAutonomyModeOptions(a).some((option) => option.mode === mode)
? mode
: AIAutonomyMode.DEFAULT
}
@@ -72,7 +72,11 @@ export interface ChatViewHost {
/** Resolves to whether the draft was consumed as a turn. */
sendRequest: (options?: ChatSendRequestOptions) => Promise<boolean | undefined>
cancel: (reason?: string) => void
setAiChatInput: (aiChatInput: AIChatInput | null) => void
setAiChatInput: (aiChatInput: AIChatInput) => void
/** Unregisters `aiChatInput` if it is still the registered one. A host's chat can remount
* elsewhere (Home hands its session to /sessions), and the new composer may register before
* the old one's teardown runs. */
releaseAiChatInput: (aiChatInput: AIChatInput) => void
readonly queuedMessage: string
queuedContext: ContextElement[] | undefined
readonly queuedImages: AttachedImage[]
@@ -22,10 +22,17 @@ export function composerBoxClass(disabled: boolean = false): string {
}`
}
/** Added to a usable box that opens a session (Home, an empty session). Focus shows as the
* base box's border colour alone: the brand has no focus ring. */
export const HERO_BOX_CLASS = 'rounded-[10px]'
/** Applied to the field inside the box; without it the field draws a second border. */
export const COMPOSER_FIELD_RESET =
'!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0 disabled:cursor-not-allowed disabled:placeholder:text-disabled'
/** The placeholder of a composer that opens a session. */
export const STARTER_PLACEHOLDER = 'Ask about this workspace, or describe what to build…'
/** A caret or selected range in the composer's text. */
export type ComposerSelection = { start: number; end: number }
@@ -29,6 +29,9 @@ export interface DraftSnapshot {
blobs: AttachedBlob[]
}
/** Everything a draft carries besides its text. */
export type DraftAttachments = Omit<DraftSnapshot, 'text'>
export class MessageDraft {
text = $state('')
pastes = $state<PasteAttachment[]>([])
@@ -42,6 +42,7 @@ export function readOnlyChatViewHost(options: {
sendRequest: async () => false,
cancel: noop,
setAiChatInput: noop,
releaseAiChatInput: noop,
queuedMessage: '',
queuedContext: undefined,
queuedImages: [],
@@ -0,0 +1,37 @@
<script lang="ts">
import { Pencil, Play } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import type { ComposerSuggestion } from './starterPrompts.svelte'
let {
suggestions,
onPick,
wrap = false
}: {
suggestions: ComposerSuggestion[]
onPick: (prompt: string) => void
/** Wrap onto more lines instead of one row that scrolls sideways. */
wrap?: boolean
} = $props()
</script>
<!-- Unwrapped, one row whatever the width: it scrolls sideways and fades out at its end, so the
page under the composer does not move with the window width. -->
<div
class="flex flex-row items-center gap-1.5 {wrap
? 'flex-wrap'
: 'overflow-x-auto no-scrollbar [mask-image:linear-gradient(90deg,#000_calc(100%-24px),transparent)]'}"
>
{#each suggestions.slice(0, 4) as suggestion (suggestion.label)}
<Button
variant="default"
unifiedSize="sm"
btnClasses="!rounded-full !text-xs !text-secondary !font-normal shrink-0"
title={suggestion.mode === 'use' ? 'Use: look, ask or run' : 'Build: create or change'}
startIcon={{ icon: suggestion.mode === 'use' ? Play : Pencil, classes: 'text-hint' }}
onClick={() => onPick(suggestion.prompt)}
>
{suggestion.label}
</Button>
{/each}
</div>
@@ -0,0 +1,30 @@
<script lang="ts">
import { Badge } from '$lib/components/common'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { takeFirstVisit, timeOfDayGreeting } from './greeting'
// `name` is the full name when the account has one; the greeting uses the first word.
const userName = $derived($userStore?.name?.trim().split(/\s+/)[0] || $userStore?.username)
const workspaceName = $derived(
$userWorkspaces.find((w) => w.id === $workspaceStore)?.name ?? $workspaceStore
)
// Read once the membership for this workspace has loaded: `userStore` still describes the
// previous workspace for a moment after a switch.
const firstVisit = $derived(
!!$workspaceStore &&
$userStore?.workspace_id === $workspaceStore &&
takeFirstVisit($workspaceStore, $userStore.created_at)
)
const greeting = timeOfDayGreeting()
</script>
<div class="flex items-center justify-center gap-2 mb-4">
<p class="text-center font-regular text-3xl">
{#if firstVisit}
Welcome to {workspaceName}{userName ? `, ${userName}` : ''}
{:else}
{greeting}{userName ? `, ${userName}` : ''}
{/if}
</p>
<Badge color="blue" small>Beta</Badge>
</div>
@@ -0,0 +1,11 @@
// The layers the composer's controls open render in portals under <body>, 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
}
@@ -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<string, string>()
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)
})
})
@@ -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<string>()
/** 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'
}
@@ -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()
}
}
}
@@ -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<StarterPrompt[]>()
#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<string, number>()
const itemCountRequests = new Map<string, Promise<number>>()
/** 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<number> {
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<number> {
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 }
)
@@ -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 }
@@ -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
}
}
}
@@ -570,7 +570,7 @@ export class FlowChatViewHost implements ChatViewHost, DraftSender<ComposerAttac
}
// Typed off the interface: a Svelte component's own type resolves differently
// across import specifiers, and the two would then not be assignable.
#aiChatInput: Parameters<ChatViewHost['setAiChatInput']>[0] = null
#aiChatInput: Parameters<ChatViewHost['setAiChatInput']>[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<ComposerAttac
this.#aiChatInput = aiChatInput
this.#takeReturned()
})
releaseAiChatInput: ChatViewHost['releaseAiChatInput'] = (aiChatInput) => {
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:
@@ -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
</span>
<div class="h-4 border-l"></div>
{#if homeComposerContext.available && askAiElements.length > 0}
<Button
variant="subtle"
unifiedSize="sm"
btnClasses="!text-ai"
startIcon={{ icon: WandSparkles }}
title={askAiElements.length < items.length
? `Add the ${askAiElements.length} scripts, flows and code apps of the selection to the chat above`
: 'Add the selection to the chat above'}
on:click={askAi}
>
Ask AI{askAiElements.length < items.length ? ` (${askAiElements.length})` : ''}
</Button>
{/if}
<Button
variant="subtle"
unifiedSize="sm"
+102 -305
View File
@@ -1,73 +1,36 @@
<script lang="ts" module>
/** 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))
}
</script>
<script lang="ts">
import TextInput from '$lib/components/text_input/TextInput.svelte'
import {
ArrowUp,
ExternalLink,
Globe2,
KeyRound,
PlugZap,
Settings,
WandSparkles
} from 'lucide-svelte'
import { ExternalLink, Globe2, PinOff, PlugZap } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { Badge } from '../common'
import CloseButton from '../common/CloseButton.svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { startSessionWithPrompt } from '../sessions/sessionSwitch.svelte'
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import { aiUserDisabled, hubBaseUrlStore, roleCapabilities, workspaceStore } from '$lib/stores'
import { hubBaseUrlStore, roleCapabilities, workspaceStore } from '$lib/stores'
import { sessionState } from '../sessions/sessionState.svelte'
import { currentWorkspaceRootId } from '../sessions/sessionScope.svelte'
import { HOME_SHOW_HUB } from '$lib/consts'
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
import { isRuleActive, protectionRulesState } from '$lib/workspaceProtectionRules.svelte'
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
import { BROWSER } from 'esm-env'
import AIChatModelSettings from '../copilot/chat/AIChatModelSettings.svelte'
import HomeConnectDrawer from './HomeConnectDrawer.svelte'
import { USER_SETTINGS_HASH } from '../sidebar/settings'
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { onboardingProfile } from '$lib/onboardingProfile'
import { prefetchItemCount } from '../copilot/chat/starter/starterPrompts.svelte'
import { useWorkspaceAiHidden } from '../copilot/workspaceAiHidden.svelte'
import { homeQuickAccessSettings } from './homeQuickAccessSettings.svelte'
const COLLAPSED_SETTING = 'home-ai-composer-collapsed'
@@ -78,79 +41,40 @@
const sideTripsInBar = $derived(pageHeader.barWidth >= BAR_FITS_SIDE_TRIPS)
const hubOffered = $derived($roleCapabilities.code && HOME_SHOW_HUB)
let value = $state('')
// The stock examples for the reader's role, unless the invite that brought this person here wrote prompts for
// them — those replace the set outright rather than joining it, since a prompt written
// for someone's own stack next to "Ban Discord users" reads as the generic one.
let invitePrompts: { label: string; prompt: string }[] | undefined = $state(undefined)
void onboardingProfile().then((p) => {
if (!p?.starter_prompts?.length) return
invitePrompts = p.starter_prompts
promptIndex = 0
})
let examples = $derived(
invitePrompts ?? ($roleCapabilities.code ? homeAIExamples : homeAIOperatorExamples)
)
let placeholderVisible = $state(true)
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
// How much of the home page this reader wants the composer to take, so it lives per browser
// rather than per workspace or account.
let collapsed = $state(BROWSER && getLocalSetting(COLLAPSED_SETTING) === 'true')
function setCollapsed(next: boolean) {
collapsed = next
// Whether this reader wants the composer on the home page at all, so it lives per browser
// rather than per workspace or account. It has nothing to do with the composer's one-line
// state despite the key's name; renaming the key would reset every reader's choice.
let removed = $state(BROWSER && getLocalSetting(COLLAPSED_SETTING) === 'true')
function setRemoved(next: boolean) {
removed = next
storeLocalSetting(COLLAPSED_SETTING, next ? 'true' : undefined)
}
// Removing puts the composer away rather than throwing it out, and the way back is one item in
// the band's menu — so the ellipsis flashes as it appears, in a corner nobody is watching while
// the hero collapses in the middle of the page.
// the composer leaves the page.
//
// A keyframe animation, not a transition: this mounts in the same flush that collapses the
// hero, so the frame that would paint it at rest is the frame it is already told to leave, and
// A keyframe animation, not a transition: this mounts in the same flush that removes the
// composer, so the frame that would paint it at rest is the frame it is already told to leave, and
// one `requestAnimationFrame` is not reliably past that paint. The flash then goes straight to
// invisible without ever being drawn. Mounted only while pulsing, so each removal replays it.
const PULSE_MS = 1000
const reducedMotion = useReducedMotion()
let pulsing = $state(false)
let pulseEndTimer: ReturnType<typeof setTimeout> | undefined
// `no_ai` is the overlay's button, shown only while AI is unusable here.
function removeComposer(source: 'close' | 'no_ai') {
logFeatureUsage('home', 'ai_hero_remove', { key: source })
setCollapsed(true)
function removeComposer() {
// The `close` key keeps these events in the series they have always been counted in.
logFeatureUsage('home', 'ai_hero_remove', { key: 'close' })
setRemoved(true)
if (reducedMotion.val) return
clearTimeout(pulseEndTimer)
pulsing = true
pulseEndTimer = setTimeout(() => (pulsing = false), PULSE_MS)
}
// In global-AI mode the layout's chat panel is disabled and never loads the copilot
// config, so the home chat loads it for the current workspace itself.
$effect(() => {
if ($workspaceStore) {
loadCopilot($workspaceStore)
}
})
// Whether the copilot config has actually loaded for the current workspace.
let configLoaded = $derived($copilotWorkspace === $workspaceStore)
// No usable model (no provider configured, or AI disabled): the input is blurred and an overlay
// explains why and links to the fix. Gate on `configLoaded` so the initial (unloaded) state
// doesn't flash the overlay while a provider is in fact configured.
let disabled = $derived(configLoaded && !$copilotInfo.enabled)
// Submission is stricter than the overlay: block it until the config is loaded AND
// enabled. Submitting during the unknown-config window hands the prompt to a session
// that only sends once `copilotInfo.enabled` flips true — on an unconfigured/disabled
// workspace that never happens and the prompt is silently lost.
let canSend = $derived(configLoaded && $copilotInfo.enabled)
// The input alone: what the overlay covers and the one part a missing provider makes unusable.
let blurClass = $derived(disabled ? 'blur-sm pointer-events-none select-none' : '')
// Disabled because the user spent their free Windmill AI grant, not because AI was never
// set up — the two look identical otherwise, and the "configure AI" copy would be a lie.
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
// A workspace locked against direct deployment is run, not authored in, so its home page drops
// the composer and the button to reopen it. This is about the workspace, not the caller:
// `createSession` would steer a prompt into the paired dev workspace and an admin bypasses the
@@ -158,206 +82,60 @@
// unlocked, so the far commoner unlocked workspace never pops the composer in mid-load.
let runOnlyWorkspace = $derived(isRuleActive('DisableDirectDeployment'))
// The composer hands off to /sessions, which refuses roles that cannot code and users opted out of the
// sessions beta — so hide it from them (the prompt would be silently dropped) while the
// The draft is created when the block mounts, and a new draft steers to the dev workspace
// while the rules are unknown (a locked prod could not be edited), so the block waits for
// them: mounted earlier, an unlocked prod's draft would act on its dev. Once shown for a
// family it stays: switching to a fork resets the rules, and remounting would flash it.
let rulesReady = $derived(
protectionRulesState.rulesets !== undefined &&
protectionRulesState.workspace === $workspaceStore
)
let shownForRoot = $state<string>()
let HomeSessionStarter = $state(loadedStarter)
if (!loadedStarter) void loadStarter().then((c) => (HomeSessionStarter = c))
// The starter waits on the workspace's item count before it mounts; asked alongside its code
// rather than once the code has loaded, it is no extra wait.
if ($workspaceStore) prefetchItemCount($workspaceStore)
// Whether Home's own workspace hides the assistant, read for that workspace: once the block
// shows, the shared copilot config is the draft's, so a draft acting on a workspace that hides
// AI would otherwise take the whole block, and the picker that could undo it, off Home.
const homeAiHidden = useWorkspaceAiHidden(() => $workspaceStore ?? undefined)
// The composer hands off to /sessions, which refuses roles that cannot code and users opted out
// of the sessions beta — so hide it from them (the prompt would be silently dropped) while the
// AI-independent CLI/MCP row below stays.
let showComposer = $derived(
prefersSessionHandoff(!$roleCapabilities.code) &&
!runOnlyWorkspace &&
!$copilotInfo.workspaceDisabled
prefersSessionHandoff(!$roleCapabilities.code) && !runOnlyWorkspace && !homeAiHidden.hidden
)
// The hero's margins and centered column are for the full block. The lone button row left
// by a collapsed, opted-out, run-only or hidden-assistant view is a hint line and should
// cost the page almost nothing: no top margin, and the content column's full width so it
// hugs the right edge instead of floating centered in empty space.
let hero = $derived(showComposer && !collapsed)
let outerSpacing = $derived(hero ? 'mt-20 mb-16' : 'mt-0 mb-1')
let starting = $state(false)
async function start() {
if (!canSend || starting || !value.trim()) return
starting = true
try {
await startSessionWithPrompt(value, { autoSend: true })
} finally {
starting = false
}
}
// Enter starts the session; Shift+Enter keeps inserting a newline.
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
start()
}
}
let prompts = $derived(examples.map((e) => e.prompt))
const CYCLE_MS = 7_000
// Must match the `duration-*` class on the placeholder overlay: the swap happens once the
// fade-out has finished, and Tailwind only emits classes it finds written out in full.
const FADE_MS = 600
// Rotate the example prompt every CYCLE_MS: fade the placeholder out, swap it, fade it back in.
// Only while the composer is shown — otherwise it would loop forever driving an
// unrendered input — and not under reduced motion, where the first prompt simply stays put.
// The index lives outside the effect so re-showing the composer resumes the rotation from the
// prompt currently displayed rather than restarting it.
const reducedMotion = useReducedMotion()
let promptIndex = $state(0)
let placeholder = $derived(prompts[promptIndex % prompts.length])
$effect(() => {
if (!showComposer || collapsed || reducedMotion.val) return
let timer: ReturnType<typeof setTimeout>
function next() {
placeholderVisible = false
timer = setTimeout(() => {
promptIndex = (promptIndex + 1) % prompts.length
placeholderVisible = true
timer = setTimeout(next, CYCLE_MS)
}, FADE_MS)
}
timer = setTimeout(next, CYCLE_MS)
return () => {
clearTimeout(timer)
// Torn down mid-fade, the input would otherwise remount with an invisible placeholder.
placeholderVisible = true
}
})
let hero = $derived(showComposer && !removed)
</script>
<div class="w-full flex justify-center {outerSpacing}">
<div class="{hero ? 'max-w-[40rem]' : ''} grow relative group">
{#if showComposer && !collapsed}
{#if !disabled}
<!-- The one dismiss control while the composer is usable; the overlay below carries its
own once it takes over, so the two never show at the same time. -->
<div class="absolute right-0 top-0 z-20">
<CloseButton
small
noBg
title="Remove session chat"
onClick={() => removeComposer('close')}
/>
</div>
<div class="w-full {hero ? 'mt-2' : 'mb-1'}">
<div class="relative group/composer">
{#if hero}
<!-- In the page's side padding, clear of the composer's own controls at its right end,
and centred on the composer's first row: the one-line box's middle, and where that
row stays once the box opens downward. Shown on hover; the container starts at the
composer's edge so the pointer reaches the unpin button without leaving the hovered
zone. Below `sm` there is no side padding to hold it, so unpinning is in the band's
menu instead. -->
<div
class="hidden sm:block absolute top-4 left-full pl-1 z-20 transition-opacity opacity-0 group-hover/composer:opacity-100 focus-within:opacity-100"
>
<CloseButton small noBg Icon={PinOff} title="Unpin from Home" onClick={removeComposer} />
</div>
<!-- The block is a session's chat, so it waits for the sessions it might reuse. Keyed on
the workspace family, not the workspace: another family needs its own draft, but a
fork shares its parent's, and remounting for it only flashes the block. -->
{#if HomeSessionStarter && sessionState.hydrated && (rulesReady || shownForRoot === $currentWorkspaceRootId)}
{#key $currentWorkspaceRootId}
<HomeSessionStarter onShown={() => (shownForRoot = $currentWorkspaceRootId)} />
{/key}
{/if}
<div class="flex items-center justify-center gap-2 mb-4">
<p class="text-center font-regular text-3xl">
{$roleCapabilities.code ? 'Build with AI' : 'Run with AI'}
</p>
<Badge color="blue" small>Beta</Badge>
</div>
<!-- Anchors the send button / model settings to the input, not to the whole block — the row
below would otherwise push them down. The inner wrapper stays `relative` in both
states: `blur-sm` is a filter, which makes an element the containing block for its
absolutely positioned children, so those two would shift when the blur turns on. -->
<div class="relative">
<div class="relative {blurClass}" inert={disabled}>
<TextInput
bind:value
class="resize-none px-4 py-3 pb-9 shadow-sm border-accent"
underlyingInputEl="textarea"
inputProps={{
rows: 4,
'aria-label': $roleCapabilities.code
? 'Describe what you want to build'
: 'Describe what you want to do',
onkeydown: onKeydown
}}
/>
{#if !value}
<!-- Drawn over the textarea instead of set as its `placeholder`: WebKit and Gecko do
not run transitions on `::placeholder`, so the fade would be a hard cut there.
The 1px margin is the textarea's border, so the text sits where typing starts. -->
<span
aria-hidden="true"
class="pointer-events-none absolute inset-x-4 top-3 m-px text-xs text-hint transition-opacity duration-[600ms] ease-in-out {placeholderVisible
? 'opacity-100'
: 'opacity-0'}"
>
{placeholder}
</span>
{/if}
<Button
endIcon={starting ? {} : { icon: ArrowUp }}
wrapperClasses="absolute right-2 bottom-3.5"
variant={value.trim() ? 'accent' : 'subtle'}
iconOnly
loading={starting}
disabled={!value.trim() || starting || !canSend}
onclick={start}
></Button>
<div class="absolute left-3 bottom-4 flex items-center gap-1.5 px-0.5">
<AIChatModelSettings />
</div>
</div>
{#if disabled}
<!-- Covers the input alone: the title, the example prompts and the CLI/MCP row are all
still legible and usable without a provider. Static, not hover-gated, so keyboard
and touch users see it too. -->
<div
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-md bg-surface/70"
>
<p class="text-sm text-secondary">
{#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}
</p>
<div class="flex items-center gap-2">
{#if $aiUserDisabled}
<!-- The fix lives in account settings (a hash-opened drawer, not a route), so link
the hash the sidebar's Account menu uses rather than the workspace AI settings. -->
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Settings }}
href={USER_SETTINGS_HASH}
>
Open account settings
</Button>
{:else}
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: freeTierExhausted ? KeyRound : Settings }}
href="/workspace_settings?tab=ai"
>
{freeTierExhausted ? 'Add your own API key' : 'Configure AI'}
</Button>
{/if}
<Button unifiedSize="sm" variant="default" onClick={() => removeComposer('no_ai')}
>Remove</Button
>
</div>
</div>
{/if}
</div>
{/if}
<div class="flex items-center justify-between gap-2 pt-2">
{#if showComposer && !collapsed}
<div class="flex flex-row flex-wrap items-center gap-1.5">
{#each examples as example (example.label)}
<Button
variant="default"
unifiedSize="xs"
btnClasses="!rounded-full !text-2xs !text-hint"
onClick={() => (value = example.prompt)}
>
{example.label}
</Button>
{/each}
</div>
{/if}
</div>
</div>
</div>
@@ -406,9 +184,9 @@
<!-- Nothing left to hold once the side trips stand in the bar and the composer is on the
page: an ellipsis that opens an empty menu is a button that does nothing. -->
<!-- No menu while the hero is on the page and its side trips are in the bar: the hero's own
cross removes it, so an ellipsis holding a second way to do that is a button whose only
unpin button removes it, so an ellipsis holding a second way to do that is a button whose only
item is a duplicate. -->
{#if !sideTripsInBar || (showComposer && collapsed)}
{#if !sideTripsInBar || (showComposer && removed) || homeQuickAccessSettings.hidden}
<!-- The flash rides the ellipsis itself, not the row: with the side trips beside it the
row is three controls wide, and marking all three names none of them. -->
<span class="relative inline-flex">
@@ -421,35 +199,54 @@
class="pointer-events-none absolute -inset-1 rounded-lg bg-blue-500/30 opacity-0 animate-fade-out"
></span>
{/if}
<!-- The same flash for hiding the favorites & recent section. It rests at opacity 0, so
it can stay mounted; the key replays it on each hide. -->
{#if homeQuickAccessSettings.hideCount > 0 && !reducedMotion.val}
{#key homeQuickAccessSettings.hideCount}
<span
aria-hidden="true"
class="pointer-events-none absolute -inset-1 rounded-lg bg-blue-500/30 opacity-0 animate-fade-out"
></span>
{/key}
{/if}
<DropdownV2
placement="bottom-end"
size="sm"
customWidth={240}
items={[
...(showComposer && collapsed
// Every phone gets the menu, and there the composer has no room for its own unpin
// button. The menu can also hold the section's pin, so the label names the session.
...(hero ? [{ displayName: 'Unpin session from Home', action: removeComposer }] : []),
...(showComposer && removed
? [
{
displayName: 'Pin session hero to homepage',
icon: WandSparkles,
action: () => {
logFeatureUsage('home', 'ai_hero_pin')
setCollapsed(false)
setRemoved(false)
}
}
]
: []),
...(homeQuickAccessSettings.hidden
? [
{
displayName: 'Pin favorites & recent to homepage',
action: () => homeQuickAccessSettings.setHidden(false)
}
]
: []),
...(sideTripsInBar
? []
: [
{
displayName: 'CLI / MCP',
icon: PlugZap,
action: () => homeConnectDrawer?.openDrawer?.()
},
...(hubOffered
? [
{
displayName: 'Hub',
icon: Globe2,
href: $hubBaseUrlStore,
hrefTarget: '_blank' as const
}
@@ -0,0 +1,479 @@
<script lang="ts">
import {
AtSign,
Check,
ChevronLeft,
ChevronRight,
Copy,
Eye,
EyeOff,
GitFork,
LayoutGrid,
List,
Pen,
Star as StarIcon,
StarOff,
X
} from 'lucide-svelte'
import { Button } from '$lib/components/common'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { useEditRights } from '$lib/operatorWriteRights'
import { roleCapabilities } from '$lib/stores'
import { AppService } from '$lib/gen'
import { copyToClipboard, type Item } from '$lib/utils'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import Star from '$lib/components/Star.svelte'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import {
favoriteManager,
getFavoriteHref,
type FavoriteKind
} from '$lib/components/sidebar/FavoriteMenu.svelte'
import { MAX_AGE_DAYS, recentItems, type RecentKind } from './recentItems.svelte'
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import {
homeComposerContext,
itemContextElement
} from '$lib/components/sessions/homeComposerContext.svelte'
import HomeSectionLabel from './HomeSectionLabel.svelte'
import HomeSectionCount from './HomeSectionCount.svelte'
import { QUICK_ACCESS_ROWS, homeQuickAccessSettings } from './homeQuickAccessSettings.svelte'
interface Props {
workspace: string
/** Cleared where direct-deploy protection hides the rows' edit actions. */
showEditButtons?: boolean
/** For the items card's heading, which shows only under this section: alone on the
* page, a heading has nothing to tell apart. */
itemCount?: number
}
let { workspace, showEditButtons = true, itemCount }: Props = $props()
type Entry = {
kind: RecentKind
/** The kind its favorite is stored under: every app, raw ones included, stars as `app`
* (AppRow, and the app listing's starred join), so a raw app's must not be `raw_app`. */
starKind: FavoriteKind
path: string
title: string
at?: number
}
function asStarKind(kind: RecentKind): FavoriteKind {
return kind === 'raw_app' ? 'app' : kind
}
const RECENT_COUNT = 6
// The way back from Hide from Home is in the header menu HomeAIChat adds, which Home mounts
// only with the AI sessions beta on. Without it the section can't be hidden, and one hidden
// earlier shows again.
const restoreMenuOnPage = isGlobalAiEnabled()
let hidden = $derived(restoreMenuOnPage && homeQuickAccessSettings.hidden)
// Turning off the last list shown would leave an empty section; Hide from Home does that.
const sectionMenu = (): Item[] => [
{
displayName: 'Show favorites',
description: 'Items you starred',
toggle: homeQuickAccessSettings.showFavorites,
disabled: homeQuickAccessSettings.showFavorites && !homeQuickAccessSettings.showRecent,
action: () => homeQuickAccessSettings.setShowFavorites(!homeQuickAccessSettings.showFavorites)
},
{
displayName: 'Show recent',
description: `Scripts, flows and apps you opened in their editor in the last ${MAX_AGE_DAYS} days, in this browser`,
toggle: homeQuickAccessSettings.showRecent,
disabled: homeQuickAccessSettings.showRecent && !homeQuickAccessSettings.showFavorites,
action: () => homeQuickAccessSettings.setShowRecent(!homeQuickAccessSettings.showRecent)
},
{
displayName: 'Grid size',
separatorTop: true,
icon: LayoutGrid,
submenuItems: QUICK_ACCESS_ROWS.map((rows) => ({
displayName: rows === 1 ? '1 row' : `${rows} rows`,
icon: homeQuickAccessSettings.rows === rows ? Check : undefined,
action: () => homeQuickAccessSettings.setRows(rows)
}))
},
{
displayName: 'Hide from Home',
icon: EyeOff,
hide: !restoreMenuOnPage,
action: () => homeQuickAccessSettings.setHidden(true)
}
]
// Only the role is checked: the cards don't carry the item's own permissions, and an
// editor opened without write access says so itself.
const editRights = useEditRights()
// A favorite app arrives as plain `app` (the layout lists apps without their subtype), and
// `/apps/edit` mounts the low-code editor whatever it opens, so the editor routes ask which
// one it is, once, when its menu opens.
async function editorKind(item: Entry): Promise<RecentKind> {
if (item.kind !== 'app') return item.kind
try {
const app: any = await AppService.getAppLiteByPath({ workspace, path: item.path })
return app?.raw_app ? 'raw_app' : 'app'
} catch {
return 'app'
}
}
// The row menus' actions that need nothing but the kind and path. Moving, deleting,
// deploying and sharing need the full item and the row's own drawers, so they stay there.
async function menuItems(item: Entry, favorite: boolean): Promise<Item[]> {
const kind = await editorKind(item)
const base = kind === 'raw_app' ? 'apps_raw' : `${kind}s`
const contextElement = itemContextElement({
kind: kind === 'raw_app' ? 'app' : kind,
rawApp: kind === 'raw_app',
path: item.path,
summary: item.title === item.path ? undefined : item.title
})
return [
{
displayName: 'Add to AI session context',
icon: AtSign,
type: 'ai',
action: () => contextElement && homeComposerContext.attach(contextElement),
hide: !contextElement || !homeComposerContext.available
},
{
displayName: 'Edit',
icon: Pen,
href: `/${base}/edit/${item.path}`,
hide: !showEditButtons || !editRights.roleCanAuthor(kind)
},
{
displayName: 'Duplicate/Fork',
icon: GitFork,
href: `/${base}/add?template=${item.path}`,
hide: !showEditButtons || !$roleCapabilities.code || kind === 'agent'
},
{
displayName: 'View runs',
icon: List,
// An agent run is a flow preview, which the runs page hides by default.
href: `/runs/${item.path}${item.kind === 'agent' ? '?job_kinds=previews' : ''}`,
hide: item.kind === 'app' || item.kind === 'raw_app'
},
{
displayName: 'Audit logs',
icon: Eye,
href: `/audit_logs?resource=${item.path}`,
hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs
},
{ displayName: 'Copy path', icon: Copy, action: () => copyToClipboard(item.path) },
{
displayName: favorite ? 'Remove from favorites' : 'Add to favorites',
icon: favorite ? StarOff : StarIcon,
separatorTop: true,
action: () =>
favorite
? favoriteManager.unstar(item.path, item.starKind, workspace)
: favoriteManager.star(item.path, item.starKind, workspace, item.title)
},
{
displayName: 'Remove from recent',
icon: X,
action: () => recentItems.forget(workspace, item.kind, item.path),
hide: favorite
}
]
}
// Assets are favorites too, but they open in the database manager rather than at a page,
// so they stay in the sidebar's menu.
let favorites: Entry[] = $derived(
favoriteManager.current.flatMap((f) =>
f.kind === 'asset' ? [] : [{ kind: f.kind, starKind: f.kind, path: f.path, title: f.label }]
)
)
// Favorites first, then what was edited lately; a starred item is listed once, as a favorite.
// With the favorites turned off, a starred item edited lately shows among the recent ones.
let recentAll: Entry[] = $derived.by(() => {
const { showFavorites, showRecent } = homeQuickAccessSettings
const starred = new Set(
showFavorites ? favorites.map((f) => `${asStarKind(f.kind)}/${f.path}`) : []
)
return (showRecent ? recentItems.list(workspace) : [])
.filter((r) => !starred.has(`${asStarKind(r.kind)}/${r.path}`))
.map((r) => ({
kind: r.kind,
starKind: asStarKind(r.kind),
path: r.path,
title: r.summary || r.path,
at: r.at
}))
})
// Grows by a page from the "Load next" card; back to one page on the next visit.
let recentLimit = $state(RECENT_COUNT)
let recentMore = $derived(Math.max(0, recentAll.length - recentLimit))
let entries: { item: Entry; favorite: boolean }[] = $derived([
...(homeQuickAccessSettings.showFavorites ? favorites : []).map((item) => ({
item,
favorite: true
})),
...recentAll.slice(0, recentLimit).map((item) => ({ item, favorite: false }))
])
// The list turned off has items, the one left on has none. The section stays, or its menu
// (the only way to turn the list back on) would leave with it.
let emptiedBySettings = $derived(
entries.length === 0 &&
((!homeQuickAccessSettings.showFavorites && favorites.length > 0) ||
(!homeQuickAccessSettings.showRecent && recentItems.list(workspace).length > 0))
)
// Read when the pointer reaches the time, so the tooltip is current however long the
// page has been open.
let now = $state(Date.now())
function editedTooltip(at: number): string {
const mins = Math.max(0, Math.floor((now - at) / 60_000))
const unit = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'} ago`
const ago =
mins < 1
? 'just now'
: mins < 60
? unit(mins, 'minute')
: mins < 1440
? unit(Math.floor(mins / 60), 'hour')
: unit(Math.floor(mins / 1440), 'day')
return `Edited by you ${ago}`
}
// Everything the lists hold, the recent ones not loaded yet included.
let favoriteCount = $derived(homeQuickAccessSettings.showFavorites ? favorites.length : 0)
let recentCount = $derived(recentAll.length)
// The cards fill one screen at a time, row by row, and the next screen scrolls in from the
// right. Placed by hand: the grid's own flow fills either whole rows across every screen or
// whole columns. The column counts follow the `auto-cols` breakpoints below.
let innerWidth = $state(1280)
let cols = $derived(innerWidth >= 1024 ? 3 : innerWidth >= 640 ? 2 : 1)
let cellCount = $derived(entries.length + (recentMore > 0 ? 1 : 0))
// Fewer rows when there aren't enough cards to fill a screen.
let rows = $derived(Math.min(homeQuickAccessSettings.rows, Math.ceil(cellCount / cols)))
function cellPosition(index: number): { row: number; column: number } {
const perScreen = cols * rows
const screen = Math.floor(index / perScreen)
const within = index % perScreen
return { row: Math.floor(within / cols) + 1, column: screen * cols + (within % cols) + 1 }
}
let scroller: HTMLDivElement | undefined = $state(undefined)
let canBack = $state(false)
let canForward = $state(false)
function measure() {
if (!scroller) return
canBack = scroller.scrollLeft > 1
canForward = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1
}
// No scroll snap: when the favorites load in front of the recent cards, a snapped scroller
// follows the card it had snapped to and opens scrolled to the end. One page (the width
// inside the scroller's 1px padding) plus the grid's 8px gap lands on a column edge by itself.
// Animated by hand: `behavior: 'smooth'` runs at the browser's own pace, which is slow for a
// page this wide. A click during the animation moves on from where the last one was going.
const PAGE_MS = 200
const reducedMotion = useReducedMotion()
let scrollTarget: number | undefined
let scrollFrame: number | undefined
function page(direction: 1 | -1) {
const el = scroller
if (!el) return
const max = el.scrollWidth - el.clientWidth
const from = el.scrollLeft
const to = Math.max(
0,
Math.min(max, (scrollTarget ?? from) + direction * (el.clientWidth - 2 + 8))
)
if (scrollFrame != undefined) cancelAnimationFrame(scrollFrame)
if (reducedMotion.val) {
el.scrollLeft = to
return
}
scrollTarget = to
const start = performance.now()
const step = (now: number) => {
const t = Math.min(1, (now - start) / PAGE_MS)
el.scrollLeft = from + (to - from) * (1 - Math.pow(1 - t, 3))
if (t < 1) {
scrollFrame = requestAnimationFrame(step)
} else {
scrollFrame = undefined
scrollTarget = undefined
}
}
scrollFrame = requestAnimationFrame(step)
}
// A resize changes what fits, and so does a change of cards or rows, which can leave the
// scroller's own box the same size.
function watchOverflow(node: HTMLElement) {
void cellCount
void rows
measure()
const observer = new ResizeObserver(measure)
observer.observe(node)
return () => observer.disconnect()
}
</script>
<svelte:window bind:innerWidth />
{#snippet card(item: Entry, favorite: boolean)}
<div
class="group flex items-center gap-1 rounded-md border border-light bg-surface-tertiary hover:bg-surface-hover transition-colors min-w-0"
>
<a
href={getFavoriteHref(item.path, item.kind)}
class="flex items-center gap-2 min-w-0 pl-2.5 py-2"
title={item.title === item.path ? item.path : `${item.title}\n${item.path}`}
>
<span class="shrink-0"><RowIcon kind={item.kind} size={14} /></span>
<span class="text-2xs font-normal text-primary truncate">{item.title}</span>
</a>
<!-- The star sits after the title, as on the tree's rows: a set one stays, an unset one
shows on hover or focus. -->
<Star
kind={item.starKind}
path={item.path}
workspaceId={workspace}
summary={item.title}
size={13}
class="inline-flex shrink-0 p-px text-yellow-500 opacity-0 data-[starred]:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
/>
<!-- A button can't sit inside the link, so the rest of the card is a second, untabbable
link to the same page. -->
<a
href={getFavoriteHref(item.path, item.kind)}
class="flex-1 self-stretch"
tabindex="-1"
aria-hidden="true"
></a>
{#if !favorite && item.at}
<span
class="shrink-0 text-2xs text-secondary"
title={editedTooltip(item.at)}
onmouseenter={() => (now = Date.now())}
role="note"
>
<TimeAgo date={new Date(item.at).toISOString()} compact />
</span>
{/if}
<div class="shrink-0 pr-1 text-secondary">
<DropdownV2 items={() => menuItems(item, favorite)} size="xs" fixedHeight={false} />
</div>
</div>
{/snippet}
{#snippet arrow(direction: 1 | -1)}
<!-- In the page's side padding, beside the cards rather than over them, so the cards keep
the items card's width. A touch screen swipes instead. -->
<div
class="absolute inset-y-0 hidden sm:block [@media(hover:none)]:hidden {direction === -1
? 'right-full mr-1'
: 'left-full ml-1'}"
>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
wrapperClasses="h-full"
btnClasses="!h-full !w-6 !px-0"
startIcon={{ icon: direction === -1 ? ChevronLeft : ChevronRight }}
title={direction === -1 ? 'Scroll left' : 'Scroll right'}
aria-label={direction === -1 ? 'Scroll left' : 'Scroll right'}
onClick={() => page(direction)}
/>
</div>
{/snippet}
<!-- Held back until the favorites are in: the recent cards are ready first, and the favorites
landing in front of them would push every card over. -->
{#if favoriteManager.loadedFor === workspace && (entries.length > 0 || emptiedBySettings) && !hidden}
<!-- 30px under the cards: the gap from the composer down to this section's heading text. -->
<section class="flex flex-col gap-2 px-4 sm:px-0 pb-[30px]">
<div class="flex items-center gap-1 -my-1">
<HomeSectionLabel>
{#if favoriteCount > 0}
Favorites <HomeSectionCount count={favoriteCount} />
{/if}
{#if favoriteCount > 0 && recentCount > 0}
<span class="text-hint">·</span>
{/if}
{#if recentCount > 0}
Recent <HomeSectionCount count={recentCount} />
{/if}
</HomeSectionLabel>
<div class="text-secondary">
<DropdownV2 items={sectionMenu} size="xs" fixedHeight={false} />
</div>
</div>
{#if entries.length === 0}
<p class="text-2xs text-hint">
{homeQuickAccessSettings.showFavorites
? 'No favorites yet. Star a script, flow or app to keep it here.'
: 'Nothing opened lately in this browser.'}
</p>
{/if}
<div class="relative">
<!-- The scrollbar is hidden, so a trackpad, shift-wheel or a swipe scrolls it and the
arrows stand in for the bar. The 1px padding, taken back by the margin, keeps the
cards' borders off the clip edge: a third of the width is rarely a whole pixel, and
the edge then cuts the outer border. -->
<div
bind:this={scroller}
{@attach watchOverflow}
onscroll={measure}
class="grid gap-2 overflow-x-auto no-scrollbar px-px -mx-px auto-cols-[85%] sm:auto-cols-[calc((100%_-_0.5rem)/2)] lg:auto-cols-[calc((100%_-_1rem)/3)]"
style:grid-template-rows="repeat({rows}, auto)"
>
{#each entries as { item, favorite }, i (item.kind + '/' + item.path)}
{@const at = cellPosition(i)}
<div class="min-w-0" style:grid-row={at.row} style:grid-column={at.column}>
{@render card(item, favorite)}
</div>
{/each}
{#if recentMore > 0}
{@const next = Math.min(recentMore, RECENT_COUNT)}
{@const at = cellPosition(entries.length)}
<div class="min-w-0" style:grid-row={at.row} style:grid-column={at.column}>
<Button
variant="subtle"
unifiedSize="sm"
wrapperClasses="h-full min-w-0"
btnClasses="!h-full rounded-md border border-dashed border-border-light !text-2xs text-secondary font-normal"
onClick={() => (recentLimit += RECENT_COUNT)}
>
Load next {next}
{next === 1 ? 'item' : 'items'}
</Button>
</div>
{/if}
</div>
{#if canBack}
{@render arrow(-1)}
{/if}
{#if canForward}
{@render arrow(1)}
{/if}
</div>
</section>
<HomeSectionLabel class="px-4 sm:px-0 pb-1">
Workspace items
{#if itemCount != undefined}
<HomeSectionCount count={itemCount} />
{/if}
</HomeSectionLabel>
{/if}
@@ -0,0 +1,5 @@
<script lang="ts">
let { count }: { count: number } = $props()
</script>
<span class="text-hint tabular-nums">({count})</span>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
children: Snippet
class?: string
}
let { children, class: className = '' }: Props = $props()
</script>
<!-- Names a section of Home without competing with what it labels. -->
<h2 class="flex items-center gap-1 text-xs font-normal text-secondary {className}">
{@render children()}
</h2>
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
<script lang="ts">
import { Folder, User } from 'lucide-svelte'
import { Badge } from '../common'
import LabelBadge from '../common/badge/LabelBadge.svelte'
import type { BadgeColor, BadgeIconProps } from '../common/badge/model'
import { appIconComponent } from '../icons/appIcon.svelte'
import { integrationDisplayName } from '../resourceTypeDisplay'
@@ -14,9 +14,6 @@
queryName?: string
syncQuery?: boolean
bottomMargin?: boolean
// Keep only the first N filters visible, the rest behind a "…" toggle. Unset
// shows every one.
maxDisplayed?: number
// Chip look. `icon` is drawn before every chip; unset, the icon follows the value
// (user/folder prefix, or the app icon under `resourceType`).
color?: BadgeColor
@@ -34,7 +31,6 @@
queryName = 'filter',
syncQuery = false,
bottomMargin = true,
maxDisplayed,
color = 'transparent',
icon,
inline = false
@@ -76,71 +72,39 @@
: [selectedFilter, ...filters]
: filters
)
let expanded = $state(false)
let truncated = $derived.by(() => {
if (maxDisplayed == undefined || filtersAndSelected.length <= maxDisplayed)
return filtersAndSelected
const shown = filtersAndSelected.slice(0, maxDisplayed)
// Clicking the selected chip is how the filter is cleared, so it is never truncated
// away, however far down the order it sits.
if (selectedFilter && !shown.includes(selectedFilter)) shown.push(selectedFilter)
return shown
})
// Derived from what is actually withheld, so the selected chip kept above never counts
// as hidden — otherwise the toggle offers to reveal a chip that is already on screen.
let hiddenCount = $derived(filtersAndSelected.length - truncated.length)
let displayedFilters = $derived(expanded ? filtersAndSelected : truncated)
</script>
{#if Array.isArray(filtersAndSelected) && filtersAndSelected.length > 0}
<div
class={inline ? 'contents' : `gap-2 w-full flex flex-wrap ${bottomMargin ? 'my-4' : 'mt-4'}`}
>
{#each displayedFilters as filter (filter)}
<div>
<Badge
class="inline-flex items-center gap-1 align-middle"
{#each filtersAndSelected as filter (filter)}
<div data-chip data-selected={filter === selectedFilter ? '' : undefined}>
<LabelBadge
{color}
icon={icon ??
(resourceType
? undefined
: filter.startsWith('u/')
? User
: filter.startsWith('f/')
? Folder
: undefined)}
selected={filter === selectedFilter}
onclick={() => {
selectedFilter = selectedFilter == filter ? undefined : filter
writeQuery(selectedFilter)
}}
{color}
clickable
selected={filter === selectedFilter}
>
<span style="height: 12px" class="-mt-0.5">
{#if icon}
{@const Icon = icon}
<Icon class="mr-0.5" size={12} />
{:else if resourceType}
{@const SvelteComponent = appIconComponent(filter)}
{#if SvelteComponent}
<SvelteComponent height="14px" width="14px" />
{/if}
{:else if filter.startsWith('u/')}
<User class="mr-0.5" size={14} />
{:else if filter.startsWith('f/')}
<Folder class="mr-0.5" size={14} />
{#if !icon && resourceType}
{@const AppIcon = appIconComponent(filter)}
{#if AppIcon}
<AppIcon height="12px" width="12px" />
{/if}
</span>
{/if}
{resourceType ? integrationDisplayName(filter) : filter}
{#if filter === selectedFilter}&cross;{/if}
</Badge>
</LabelBadge>
</div>
{/each}
{#if hiddenCount > 0}
<div>
<Badge
class="inline-flex items-center gap-1 align-middle"
{color}
clickable
title={expanded ? 'Show fewer' : `Show ${hiddenCount} more`}
onclick={() => (expanded = !expanded)}
>
{expanded ? 'Show less' : '…'}
</Badge>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,58 @@
<script lang="ts">
import { ChevronDown, ChevronUp } from 'lucide-svelte'
import type { Snippet } from 'svelte'
import { inUnruledList, rowRule } from '../common/table/unruledList'
const unruled = inUnruledList()
/** A row of the tree that groups others under it (a folder, a user, "Other users"): clicking
* anywhere on it opens or closes what it groups. */
interface Props {
icon: typeof import('lucide-svelte').Folder
name: string
opened: boolean
onToggle: () => void
/** Extra left padding in px, for a nested folder. */
indent?: number
/** The toggle's accessible name; the row's name when unset. */
toggleLabel?: string
/** The line under the name, usually its item count. */
detail: Snippet
}
let { icon: Icon, name, opened, onToggle, indent = 0, toggleLabel, detail }: Props = $props()
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={onToggle}
class="px-4 py-2 {rowRule(
unruled
)} w-full flex flex-row items-center justify-between cursor-pointer hover:bg-surface-hover"
>
<div
class="flex flex-row items-center gap-4"
style={indent > 0 ? `padding-left: ${indent}px;` : ''}
>
<Icon size={16} class="text-secondary shrink-0" />
<div>
<span class="whitespace-nowrap text-xs text-emphasis font-semibold">{name}</span>
<div class="text-2xs font-normal text-secondary whitespace-nowrap">
{@render detail()}
</div>
</div>
</div>
<!-- Keyboard access only: its click bubbles to the row, which does the toggling. -->
<button
class="w-full flex flex-row-reverse"
aria-expanded={opened}
aria-label={toggleLabel ?? name}
>
{#if opened}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
</div>
@@ -3,13 +3,14 @@
import { createEventDispatcher, onDestroy, untrack, type Snippet } from 'svelte'
import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte'
import { ChevronDown, ChevronUp, Folder, FolderTree, User } from 'lucide-svelte'
import { Folder, FolderTree, User } from 'lucide-svelte'
import TreeOwnerRow from './TreeOwnerRow.svelte'
import PipelineRow from '../common/table/PipelineRow.svelte'
import Item from './Item.svelte'
import { countLeaves, type FolderItem, type ItemType, type UserItem } from './treeViewUtils'
import { twMerge } from 'tailwind-merge'
import { pluralize } from '$lib/utils'
import { Button } from '$lib/components/common'
import { inUnruledList, rowRule } from '../common/table/unruledList'
interface Props {
item: ItemType | FolderItem | UserItem
@@ -72,6 +73,8 @@
leaf
}: Props = $props()
const unruled = inUnruledList()
let visualDepth = $derived(depth + indent)
const dispatch = createEventDispatcher<{ reload: void }>()
@@ -262,56 +265,30 @@
{#if isFolder(item) || isUser(item)}
<div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={toggleOwner}
class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer"
<TreeOwnerRow
icon={isUser(item) ? User : depth === 0 ? Folder : FolderTree}
name={isUser(item) ? `u/${item.username}` : `${depth === 0 ? 'f/' : ''}${item.folderName}`}
{opened}
onToggle={toggleOwner}
indent={visualDepth * 16}
>
<div
class={twMerge('flex flex-row items-center gap-4 text-sm font-semibold')}
style={visualDepth > 0 ? `padding-left: ${visualDepth * 16}px;` : ''}
>
<div class="flex justify-center items-center">
{#if isUser(item)}
<User size={16} class="text-secondary" />
{:else if depth === 0}
<Folder size={16} class="text-secondary" />
{:else}
<FolderTree size={16} class="text-secondary" />
{/if}
</div>
<div>
<span class="whitespace-nowrap text-xs text-emphasis font-semibold">
{#if isUser(item)}u/{item.username}{:else}{#if depth === 0}f/{/if}{item.folderName}{/if}
</span>
<div class="text-2xs font-normal text-secondary whitespace-nowrap">
{#if ownerCount != undefined}
({pluralize(ownerCount, ' item')})
{:else if isLazyOwner && !ownerState?.loaded}
<!-- Lazy owner not expanded yet and no count for it: its true item count
is unknown until loaded, so showing "(0 items)" would be misleading. -->
&nbsp;
{:else if nodeHasMore}
<!-- Partial: this node still has pages to load (its own once it has been
loaded directly, otherwise its ancestor's), so what is grouped under
it is only what has arrived so far. -->
({loadedHere}+ items)
{:else}
({pluralize(loadedHere, ' item')})
{/if}
</div>
</div>
</div>
<button class="w-full flex flex-row-reverse">
{#if opened}
<ChevronUp size={16} />
{#snippet detail()}
{#if ownerCount != undefined}
({pluralize(ownerCount, ' item')})
{:else if isLazyOwner && !ownerState?.loaded}
<!-- Lazy owner not expanded yet and no count for it: its true item count
is unknown until loaded, so showing "(0 items)" would be misleading. -->
&nbsp;
{:else if nodeHasMore}
<!-- Partial: this node still has pages to load (its own once it has been
loaded directly, otherwise its ancestor's), so what is grouped under
it is only what has arrived so far. -->
({loadedHere}+ items)
{:else}
<ChevronDown size={16} />
({pluralize(loadedHere, ' item')})
{/if}
</button>
</div>
{/snippet}
</TreeOwnerRow>
<!-- ResizeObserver, not a slide: a freshly-opened owner fetches its rows, so its height
changes twice (open-empty, then rows land) and a slide would only animate the first. -->
<ResizeTransitionWrapper vertical innerClass="w-full">
@@ -350,7 +327,9 @@
{/each}
{#if effectiveMax < item.items.length}
<div
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
class="px-4 py-2 {rowRule(
unruled
)} flex flex-row items-center justify-between gap-4 bg-surface-secondary"
style="padding-left: {(visualDepth + 1) * 16}px;"
>
<!-- Rows, not items: this slices the node's own entries, where a subfolder
@@ -387,7 +366,9 @@
is the point: without them this reads as an optional extra rather than
as rows still missing. -->
<div
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
class="px-4 py-2 {rowRule(
unruled
)} flex flex-row items-center justify-between gap-4 bg-surface-secondary"
style="padding-left: {(visualDepth + 1) * 16}px;"
>
<span class="text-xs text-secondary">
@@ -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 @@
<!-- Every row draws its own border-b, so the last one would double the frame's bottom border.
Pulling the content 1px down puts that border under the frame, where overflow-hidden
clips it, whichever row (collapsed owner, leaf, expanded subtree) ends the list. -->
<div class="border rounded-md bg-surface-tertiary overflow-hidden">
<div class="{framed ? 'border rounded-md bg-surface-tertiary' : ''} overflow-hidden">
<div class="-mb-px">
{#each rows.slice(0, nbDisplayed) as row (row.kind === 'otherUsers' ? 'other_users' : 'folderName' in row.node ? `f__${row.node.folderName}` : 'username' in row.node ? `u__${row.node.username}` : `i__${row.node.type}__${row.node.path}`)}
{#if row.kind === 'otherUsers'}
<!-- Same shape as an owner row, so it reads as part of the tree. It only reveals
the user rows under it: each still loads its own items when opened. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={toggleOtherUsers}
class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer"
<TreeOwnerRow
icon={Users}
name="Other users"
opened={otherUsersOpen}
onToggle={toggleOtherUsers}
toggleLabel={otherUsersOpen ? 'Hide other users' : 'Show other users'}
>
<div class="flex flex-row items-center gap-4">
<Users size={16} class="text-secondary" />
<div>
<span class="whitespace-nowrap text-xs text-emphasis font-semibold"
>Other users</span
>
<div class="text-2xs font-normal text-secondary whitespace-nowrap">
({pluralize(otherUsers.length, 'user')}{otherUsersItemCount != undefined
? ` · ${pluralize(otherUsersItemCount, 'item')}`
: ''})
</div>
</div>
</div>
<Button
iconOnly
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: otherUsersOpen ? ChevronUp : ChevronDown }}
title={otherUsersOpen ? 'Hide other users' : 'Show other users'}
aria-label="Other users"
aria-expanded={otherUsersOpen}
onClick={toggleOtherUsers}
/>
</div>
{#snippet detail()}
({pluralize(otherUsers.length, 'user')}{otherUsersItemCount != undefined
? ` · ${pluralize(otherUsersItemCount, 'item')}`
: ''})
{/snippet}
</TreeOwnerRow>
{#if otherUsersOpen}
<!-- Ranked after every root owner, so opening this row can't push folders out
of what "expand all" auto-loads. -->
@@ -324,7 +314,9 @@
{/each}
{#if nbOtherUsersDisplayed < otherUsers.length}
<div
class="pl-8 pr-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
class="pl-8 pr-4 py-2 {rowRule(
unruled
)} flex flex-row items-center justify-between gap-4 bg-surface-secondary"
>
<span class="text-xs text-secondary">
Showing {nbOtherUsersDisplayed} of {otherUsers.length} users
@@ -57,7 +57,7 @@
</script>
<div
class="rounded-md border-[1.5px] border-dashed border-border-normal/60 bg-surface"
class="rounded-md border-[1.5px] border-dashed border-border-normal/60"
role="status"
aria-label={archivedOnly ? 'Everything here is archived' : 'Your workspace is empty'}
>
@@ -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<HTMLElement, ClampRowsOptions> = (node, initial) => {
let options = initial
let frame = 0
let width = -1
function run() {
frame = 0
const toggle = node.querySelector<HTMLElement>('[data-clamp-toggle]')
const items = Array.from(node.querySelectorAll<HTMLElement>('[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()
}
}
}
@@ -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<QuickAccessRows>(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()
@@ -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<RecentItem, 'at'> }[] = []
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<RecentItem, 'at'>): 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()
@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount, setContext } from 'svelte'
import AIChat from '$lib/components/copilot/chat/AIChat.svelte'
import type { DraftAttachments } from '$lib/components/copilot/chat/messageDraft.svelte'
import type { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { StarterPrompts } from '$lib/components/copilot/chat/starter/starterPrompts.svelte'
import { goto } from '$lib/navigation'
import { getOrCreateRuntime } from './sessionRuntime.svelte'
import { workspaceStore } from '$lib/stores'
import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot'
import { copilotWorkspace } from '$lib/aiStore'
import {
getEffectiveWorkspaceId,
getSessionDraftAttachments,
getSessionDraftPrompt,
homeStarterSession,
setSessionDraftAttachments,
selectSession,
sessionPageHref,
sessionState,
setSessionDraftPrompt
} from './sessionState.svelte'
import SessionWorkspaceBar from './SessionWorkspaceBar.svelte'
import { composerSentFromHome, takeComposerFocus } from './homeSessionsTransition.svelte'
import { homeComposerContext } from './homeComposerContext.svelte'
// Home's block is the empty session's own chat, run by that session's manager: the
// composer, its controls and what a send does are the session's, not a copy of them.
// Picked once; the parent remounts this on a workspace switch, which changes the family.
let { onShown }: { onShown?: () => void } = $props()
const initialSession = homeStarterSession()
const sessionId = initialSession.id
const runtime = getOrCreateRuntime(initialSession)
setContext<AIChatManager>('aiChatManager', runtime.manager)
const session = $derived(sessionState.sessions.find((s) => s.id === sessionId))
const actingWorkspaceId = $derived(
(session ? getEffectiveWorkspaceId(session) : undefined) ?? $workspaceStore ?? undefined
)
const starterPrompts = new StarterPrompts(() => actingWorkspaceId)
// The AI config (models, whether AI is hidden) of the workspace the draft acts on, as the
// session page loads it, so a fork picked here shows its own models and the session page
// can judge it at once on arrival instead of covering it while it asks. The layout also
// loads Home's own workspace into the same shared config, and the last request wins, so
// this asks again whenever a later request named another workspace.
$effect(() => {
if (actingWorkspaceId && $copilotWorkspaceRequested !== actingWorkspaceId)
loadCopilot(actingWorkspaceId)
})
// The turn is already running on the session's manager; the page it belongs to takes over.
function onSent() {
composerSentFromHome()
selectSession(sessionId)
void goto(sessionPageHref(sessionId))
}
function onDraftChange(text: string) {
setSessionDraftPrompt(sessionId, text)
// A prompt typed here is what the AI Sessions switch should open; an empty box leaves
// the switch resuming whatever session the user was last in.
if (text.trim()) selectSession(sessionId)
}
function onAttachmentsChange(attachments: DraftAttachments) {
setSessionDraftAttachments(sessionId, attachments)
if (Object.values(attachments).some((lane) => lane.length > 0)) selectSession(sessionId)
}
let aiChat: AIChat | undefined = $state(undefined)
onMount(() => {
onShown?.()
if (takeComposerFocus()) setTimeout(() => aiChat?.focusInput(), 0)
// The rows' "Ask AI" lands here. Before the chat has mounted (its config still
// loading) there is nowhere to put the item, and the attach does nothing.
return homeComposerContext.register(async (element) => {
await aiChat?.attachContext(element)
})
})
</script>
{#snippet actingOn()}
{#if session}
<SessionWorkspaceBar {session} />
{/if}
{/snippet}
<!-- Until a config has loaded the chat reads "AI not configured" and would flash its disabled
state. Any loaded config will do, so a fork switch does not hide it while the fork's loads.
The item count decides whether the composer rests collapsed, so it waits for that too
rather than open and fold up a moment later. -->
{#if session && $copilotWorkspace !== undefined && starterPrompts.fewItems !== undefined}
<AIChat
bind:this={aiChat}
hideHeader
hideModeSelector
wideLayout
initialInstructions={getSessionDraftPrompt(sessionId)}
{onDraftChange}
initialAttachments={getSessionDraftAttachments(sessionId)}
{onAttachmentsChange}
starter={{
where: 'home',
suggestions: starterPrompts.suggestions,
fewItems: starterPrompts.fewItems
}}
viewTransitionNames
{onSent}
{actingOn}
/>
{/if}
@@ -28,8 +28,9 @@
}
</script>
<div class="flex flex-row items-center gap-1 py-0.5 px-1 text-2xs text-secondary">
<span class="shrink-0">Acting on</span>
<!-- A control of the composer's toolbar; on a narrow window it gives up its label first. -->
<div class="flex flex-row items-center gap-1 min-w-0 text-2xs text-secondary">
<span class="shrink-0 hidden lg:inline">Acting on</span>
<WorkspaceFamilyPicker
selectedId={effectiveId}
{pendingFork}
@@ -13,8 +13,11 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import type AIChat from '$lib/components/copilot/chat/AIChat.svelte'
import ComposerPlaceholder from '$lib/components/copilot/chat/ComposerPlaceholder.svelte'
import SessionGreeting from '$lib/components/copilot/chat/starter/SessionGreeting.svelte'
import ComposerSuggestions from '$lib/components/copilot/chat/starter/ComposerSuggestions.svelte'
import {
globalComposerSuggestion,
STARTER_PLACEHOLDER,
type ComposerSelection
} from '$lib/components/copilot/chat/composerBox'
import { useSessionPermissions } from './sessionPermissions.svelte'
@@ -37,10 +40,12 @@
EllipsisVertical,
MessageSquareDashed,
Pencil,
Plus,
Trash2,
X
} from 'lucide-svelte'
import SessionWorkspaceBar from './SessionWorkspaceBar.svelte'
import { arrivingFromHomeSend } from './homeSessionsTransition.svelte'
import SessionChangesBar from './SessionChangesBar.svelte'
import SessionShareButton from './SessionShareButton.svelte'
import { clearSessionRecovered, isSessionRecovered } from './sessionRecoveryNotice.svelte'
@@ -53,6 +58,8 @@
moveSessionToWorkspace,
getSessionDraftPrompt,
setSessionDraftPrompt,
getSessionDraftAttachments,
setSessionDraftAttachments,
peekSessionAutoSend,
takeSessionAutoSend,
reconcileAfterWorkspaceChange,
@@ -67,6 +74,7 @@
import { getOrCreateRuntime, removeSession } from './sessionRuntime.svelte'
import { goto } from '$lib/navigation'
import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture'
import { StarterPrompts } from '$lib/components/copilot/chat/starter/starterPrompts.svelte'
let {
sessionId,
@@ -92,6 +100,15 @@
// Reactive session reference (mutations to summary/target propagate via the $state proxy)
const session = $derived(sessionState.sessions.find((s) => 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}
<div class="p-8 text-secondary text-sm">Session not found</div>
{:else}
<!-- A sent session whose transcript is still loading (or came back empty) shows nothing
rather than the docked copilot's keyboard-shortcut hint, which means nothing here. -->
{#snippet sessionEmptyHint()}{/snippet}
{#snippet inputPreface()}
{#if showRecoveryNotice}
<!-- mb-6, not the banner column's mb-1: at that gap it reads as one more
@@ -399,9 +425,6 @@
/>
</div>
{/if}
{#if !hasFirstUserMessage}
<SessionWorkspaceBar {session} />
{/if}
<!-- gap-1 (4px) spaces the archived banner and the changes bar when both
are visible. Each renders a single in-flow root (or nothing); the diff
drawer is position:fixed, so it doesn't count as a flex item — no stray
@@ -438,9 +461,17 @@
</div>
{/snippet}
<!-- Override the chat's default keyboard-shortcut hint with nothing —
sessions have their own empty-state affordances above. -->
{#snippet sessionEmptyHint()}{/snippet}
{#snippet actingOn()}
<SessionWorkspaceBar {session} />
{/snippet}
<!-- The left of AIChatDisplay's controls row: the rest arrives with the chat. -->
{#snippet placeholderToolbar()}
<div class="flex flex-row items-center gap-x-1.5 min-w-0 flex-wrap">
<Button unifiedSize="2xs" variant="subtle" iconOnly startIcon={{ icon: Plus }} disabled />
{@render actingOn()}
</div>
{/snippet}
{#snippet sessionHeader()}
<EditableInput
@@ -528,7 +559,7 @@
<div class="flex-1 min-h-0 flex flex-col" use:splitterPointerCapture>
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
<Pane minSize={25} class="flex flex-col min-h-0 pb-2">
<div class="flex-1 min-h-0 w-full flex flex-col {hasFirstUserMessage ? '' : 'pt-8'}">
<div class="flex-1 min-h-0 w-full flex flex-col">
{#if showChat && aiChatComponent.current}
<aiChatComponent.current
bind:this={aiChat}
@@ -537,6 +568,8 @@
wideLayout
initialInstructions={draftText}
onDraftChange={(text) => setSessionDraftPrompt(sessionId, text)}
initialAttachments={armedAtInit ? undefined : getSessionDraftAttachments(sessionId)}
onAttachmentsChange={(a) => setSessionDraftAttachments(sessionId, a)}
placeholder={composerPlaceholder}
forceDisabled={isUnavailable || !!session.archived}
forceDisabledMessage={isUnavailable
@@ -544,41 +577,62 @@
: session.archived
? 'This session is archived. Unarchive it from the banner above to keep working.'
: ''}
starter={opensSession
? { where: 'session', suggestions: starterPrompts.suggestions }
: undefined}
viewTransitionNames={headerInPage && (opensSession || arrivingFromHomeSend())}
emptyHint={sessionEmptyHint}
{inputPreface}
actingOn={hasFirstUserMessage ? undefined : actingOn}
/>
{:else}
<!-- AIChatDisplay's layout, so the composer stays put when the chat replaces it:
its transcript area, its column, and its footer row's height. -->
its transcript area, its column, and its footer row's height; for a session
that has never sent, the greeting above the centred composer. -->
<div
class="flex flex-col h-full"
class="flex flex-col h-full {opensSession ? 'justify-center' : ''}"
{@attach () => () =>
runtime.manager.setComposerHasDraft(PLACEHOLDER_DRAFT_KEY, false)}
>
{#if runtime.manager.displayMessages.length > 0}
{#if opensSession}
<div class="flex-1 min-h-0 flex flex-col justify-end">
<div class="w-full max-w-3xl mx-auto px-7">
<SessionGreeting />
</div>
</div>
{:else if runtime.manager.displayMessages.length > 0}
<div class="flex-1 min-h-0"></div>
{/if}
<div class="w-full max-w-3xl mx-auto px-7 pb-2">
{@render inputPreface()}
<ComposerPlaceholder
bind:value={
() => draftText,
(text) => {
draftText = text
setSessionDraftPrompt(sessionId, text)
runtime.manager.setComposerHasDraft(PLACEHOLDER_DRAFT_KEY, text.trim() !== '')
}
}
placeholder={runtime.manager.displayMessages.length > 0
? 'Ask followup'
: composerPlaceholder}
bind:value={() => draftText, setPlaceholderText}
toolbar={opensSession ? placeholderToolbar : undefined}
placeholder={opensSession
? STARTER_PLACEHOLDER
: runtime.manager.displayMessages.length > 0
? 'Ask followup'
: composerPlaceholder}
disabled={isUnavailable || !!session.archived}
autofocus={sessionState.currentSessionId === sessionId && !covered}
onSelectionChange={(selection) => (handoff = selection ?? null)}
onCompositionChange={(composing) => (placeholderComposing = composing)}
/>
<div class="mt-1 h-5"></div>
{#if !opensSession}
<div class="mt-1 h-5"></div>
{:else if !isUnavailable && !session.archived && starterPrompts.suggestions.length > 0}
<div class="pt-2">
<ComposerSuggestions
suggestions={starterPrompts.suggestions}
wrap
onPick={setPlaceholderText}
/>
</div>
{/if}
</div>
{#if opensSession}
<div class="flex-1 min-h-0"></div>
{/if}
</div>
{/if}
</div>
@@ -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<void>
/** 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<Attach | undefined>(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<void> {
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
}
}
}
@@ -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<void> | 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)
})
}
@@ -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)
})
})
@@ -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<string, SessionRuntime>()
const runtimeRoots = new Map<string, () => 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_<uuid>`; resolve the name the way the chat's write tools do.
function draftStoragePath(
@@ -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<string, DraftAttachments>()
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
@@ -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()
}
})
})
@@ -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(
+3
View File
@@ -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
+2 -1
View File
@@ -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
@@ -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'),
@@ -1,6 +1,7 @@
<script lang="ts">
import { AppService, FlowService, type OpenFlow, type Script } from '$lib/gen'
import { roleCapabilities, userStore, workspaceStore } from '$lib/stores'
import { HOME_COLUMN_WIDTH } from '$lib/consts'
import { Alert, Button, Drawer, DrawerContent } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
@@ -97,6 +98,12 @@
// the mount animation below never covers. Replay it by hand instead of keying the page on the
// workspace: a remount would drop the list's own state and refetch what it is already loading.
let pageEl: HTMLDivElement | undefined = $state(undefined)
// Back from AI Sessions the view transition (homeSessionsTransition.svelte.ts) carries the arrival, so
// the page skips its own fade-in. Decided once at mount: gating the class on the attribute
// instead would start the fade from transparent the moment the transition removes it.
const fadeIn =
typeof document === 'undefined' || !document.documentElement.dataset.homeSessionsTransition
const reducedMotion = useReducedMotion()
let animatedWorkspace = $workspaceStore
$effect(() => {
@@ -285,7 +292,7 @@
<div
bind:this={pageEl}
class="wm-page-in flex flex-col w-full h-full overflow-y-auto items-center"
class="{fadeIn ? 'wm-page-in' : ''} flex flex-col w-full h-full overflow-y-auto items-center"
style="scrollbar-gutter: stable both-edges;"
>
<!-- Where edits to this workspace are made is a fact about the workspace, so it sits in the
@@ -294,7 +301,7 @@
<ForkWorkspaceBanner />
<WorkspaceDraftsBanner />
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full mb-6">
<div class="{HOME_COLUMN_WIDTH} px-4 sm:px-8 md:px-8 h-fit w-full mb-6 shrink-0">
<!-- HomeAIChat carries both the AI composer and the AI-independent CLI/MCP connect row,
so it shows whenever the sessions beta is on; the composer itself is gated on the
workspace inside the component, which owns its own vertical spacing
@@ -5,6 +5,9 @@
import { copilotInfo } from '$lib/aiStore'
import AgentEditorModal from '$lib/components/flows/content/AgentEditorModal.svelte'
import { openAgentEditor } from '$lib/components/flows/agentEditorStore.svelte'
import { recentItems } from '$lib/components/home/recentItems.svelte'
import { workspaceStore } from '$lib/stores'
import { ResourceService } from '$lib/gen'
let path = $derived(page.params.path ?? '')
/** Set by `/agents/add` on the path it minted: a missing agent there is a new one, not an error. */
@@ -12,8 +15,26 @@
$effect(() => {
const target = { path, isNew }
untrack(() => openAgentEditor(target))
untrack(() => {
openAgentEditor(target)
if (!isNew) recordRecent(target.path)
})
})
// The agent loads inside the editor, so it is read again here. Only a deployed agent is
// recorded: a draft-only one 404s, and has no page for Home's card to open.
async function recordRecent(agentPath: string) {
const workspace = $workspaceStore
if (!workspace) return
try {
const agent = await ResourceService.getResource({ workspace, path: agentPath })
recentItems.record(workspace, {
kind: 'agent',
path: agentPath,
summary: agent.description
})
} catch {}
}
</script>
<div class="h-full w-full">
@@ -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).
@@ -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) {
@@ -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) {
@@ -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.
@@ -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 @@
</Pane>
</Splitpanes>
</div>
{#if aiHiddenVerdict === undefined}
{#if firstVerdictPending}
<div class="absolute inset-0 z-20 flex items-center justify-center bg-surface">
<Loader2 class="animate-spin" />
</div>
{:else if aiHiddenVerdict}
<!-- The workspace hid the assistant, and the sidebar switch with it, so only a
direct URL or a session acting on such a workspace lands here. -->
<!-- Centred like the loader above: the page header overlays this pane's top edge. -->
<div
class="absolute inset-0 z-20 bg-surface p-8 flex flex-col items-start gap-3 text-secondary text-sm"
class="absolute inset-0 z-20 bg-surface p-8 flex flex-col items-center justify-center gap-3 text-secondary text-sm text-center"
>
<p class="text-primary font-medium">AI Sessions are hidden in this workspace</p>
<p>A workspace admin hid AI sessions in the workspace settings.</p>