mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-23 08:03:33 +00:00
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:
co-authored by
Claude Opus 5.5
Guilhem Lemouel
windmill-internal-app[bot]
Ruben Fiszel
parent
7516a3ef0b
commit
56b2e106da
@@ -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} />
|
||||
|
||||
@@ -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}✗{/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"
|
||||
|
||||
@@ -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}✗{/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. -->
|
||||
|
||||
{: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. -->
|
||||
|
||||
{: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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user