mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
feat: one band, one workspace part, and the pickers that hang off it
The band is a single element whose placement is styled rather than branched: a page
registering a side-panel inset used to swap it between two `{#if}` arms, destroying the
header and rebuilding it a frame later — a blink of no header with the content sliding
up into the gap.
The workspace and its fork become one breadcrumb part: the name leads home (inert in a
session, where the workspace is the session's scope rather than a place to go) and one
chevron opens the picker, which already expands a family to reach its forks. The
picker's panel hangs from the part's left edge, opens with the active family expanded,
and closes before the switch runs — the switch renames the workspace, which moves the
trigger the panel hangs from, and floating-ui was chasing it across the viewport.
The item's path is a plain label rather than a row of pickers: same separators and
weight, and a click copies it with its own confirmation. The kind icon goes, the page
below says what kind it is.
Around them: a "prod" badge for a root workspace, the session's acting fork beside the
session's name rather than in the breadcrumb, the edit-in-dev affordance as a button in
the header, the drafts count as a sentence, and a fade when the workspace changes under
a page that stays mounted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9129cfe39f
commit
283cf48ccf
@@ -7,30 +7,15 @@ picker, and the item drill picker for the kind and each path level — and every
|
||||
same weight, size and icon size, so the line reads as one control rather than four.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Building, Folder, GitFork, User } from 'lucide-svelte'
|
||||
import { Building, ChevronDown, Folder, GitFork, User } from 'lucide-svelte'
|
||||
import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte'
|
||||
import { Menu, Menubar } from '$lib/components/meltComponents'
|
||||
import NavBreadcrumbTrigger from './NavBreadcrumbTrigger.svelte'
|
||||
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
|
||||
import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte'
|
||||
import WorkspaceFamilyPicker from '$lib/components/sessions/WorkspaceFamilyPicker.svelte'
|
||||
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
|
||||
import {
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
workspaceColor,
|
||||
globalForkModal,
|
||||
userStore,
|
||||
superadmin
|
||||
} from '$lib/stores'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { fixupUrlAfterWorkspaceSwitch } from './sidebar/workspaceSwitchUrl'
|
||||
import { workspaceAIClients } from '$lib/components/copilot/lib'
|
||||
import {
|
||||
findWorkspaceRoot,
|
||||
findWorkspaceDescendants,
|
||||
isForkOwner
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores'
|
||||
import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy'
|
||||
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
@@ -39,15 +24,8 @@ same weight, size and icon size, so the line reads as one control rather than fo
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
import { page } from '$app/state'
|
||||
import { navPageFor } from './sidebar/navPages'
|
||||
import { getContrastTextColor } from '$lib/utils'
|
||||
import { splitItemPath } from './breadcrumbPath'
|
||||
import {
|
||||
dirKey,
|
||||
KIND_LABEL_LOWER,
|
||||
kindKey,
|
||||
leafKeyFor,
|
||||
type WorkspaceItem
|
||||
} from '$lib/components/workspacePicker'
|
||||
import { copyToClipboard, getContrastTextColor } from '$lib/utils'
|
||||
import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker'
|
||||
import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
@@ -66,6 +44,12 @@ same weight, size and icon size, so the line reads as one control rather than fo
|
||||
} = $props()
|
||||
|
||||
const scopeId = $derived(actingWorkspaceId ?? $workspaceStore ?? undefined)
|
||||
// In session mode the workspace is the session's scope, not a place to navigate to: the name
|
||||
// says which workspace the chat acts on, and going home from it would leave the session.
|
||||
const sessionMode = $derived(page.url.pathname.startsWith(`${base}/sessions`))
|
||||
const homeHref = $derived(
|
||||
actingWorkspaceId ? `${base}/?workspace=${encodeURIComponent(actingWorkspaceId)}` : `${base}/`
|
||||
)
|
||||
|
||||
// With no item or section registered, the route still says where the user is: Runs, Variables,
|
||||
// and the rest name themselves in the breadcrumb rather than leaving it at the workspace.
|
||||
@@ -80,9 +64,25 @@ same weight, size and icon size, so the line reads as one control rather than fo
|
||||
const SEGMENT =
|
||||
'flex items-center gap-1 min-w-0 px-1 py-0.5 rounded text-xs font-normal text-primary hover:bg-surface-hover hover:text-emphasis transition-colors'
|
||||
const ICON = 14
|
||||
/** The workspace disc: the same glyph as the rest, with a ring of its own around it. */
|
||||
const DISC = 20
|
||||
|
||||
/** Width of the name beside the chevron, so the picker can hang from the part's own left edge. */
|
||||
let nameWidth = $state(0)
|
||||
let workspaceMenu: Menu | undefined = $state(undefined)
|
||||
|
||||
// Its own confirmation under the path rather than the global toast: the answer belongs where
|
||||
// the click was, and a toast for something this small is a lot of furniture.
|
||||
let pathCopied = $state(false)
|
||||
let copiedReset: ReturnType<typeof setTimeout> | undefined
|
||||
async function copyPath() {
|
||||
if (!item?.path || !(await copyToClipboard(item.path, false))) return
|
||||
pathCopied = true
|
||||
clearTimeout(copiedReset)
|
||||
copiedReset = setTimeout(() => (pathCopied = false), 1500)
|
||||
}
|
||||
|
||||
const family = $derived(findWorkspaceRoot(scopeId, $userWorkspaces ?? []))
|
||||
const workspaceName = $derived(family?.name ?? scopeId ?? '')
|
||||
const discColor = $derived(family?.color ?? $workspaceColor ?? 'rgb(var(--color-surface-sunken))')
|
||||
const glyphColor = $derived(getContrastTextColor(discColor))
|
||||
|
||||
@@ -93,73 +93,48 @@ same weight, size and icon size, so the line reads as one control rather than fo
|
||||
})
|
||||
const forkableWorkspaces = $derived(forkable.current)
|
||||
const currentWs = $derived(forkableWorkspaces.find((w) => w.id === scopeId))
|
||||
const forkCount = $derived.by(() => {
|
||||
const root = findWorkspaceRoot(scopeId, forkableWorkspaces)
|
||||
return root ? findWorkspaceDescendants(root.id, forkableWorkspaces).length : 0
|
||||
})
|
||||
// A fork names itself; a root says how many it has, which is the hint that this opens them.
|
||||
const forkLabel = $derived(
|
||||
currentWs && currentWs.id !== family?.id
|
||||
? currentWs.name
|
||||
: `${forkCount} fork${forkCount === 1 ? '' : 's'}`
|
||||
)
|
||||
// Forks are a developer's workspace. An operator sees the segment only when one of them is
|
||||
// theirs — they were added to a fork, or are standing in one — and never the row that makes one.
|
||||
const inFork = $derived(!!currentWs?.parent_workspace_id)
|
||||
// A page acting on another workspace (a session) names that fork and nothing else: "0 forks"
|
||||
// would be about the workspace the user is browsing, not the one the session runs in.
|
||||
const showFork = $derived(
|
||||
actingWorkspaceId ? inFork : !$userStore?.operator || forkCount > 0 || inFork
|
||||
)
|
||||
// A session names the workspace it acts on beside its own title, so here the family name alone
|
||||
// says where the user is browsing — the fork in this spot would read as a workspace switch.
|
||||
const showFork = $derived(inFork && !actingWorkspaceId)
|
||||
const familyName = $derived(family?.name ?? scopeId ?? '')
|
||||
// The workspace the user actually stands in, which is the fork's own name inside a fork.
|
||||
const scopeName = $derived(currentWs?.name ?? familyName)
|
||||
|
||||
// A fork wears its workspace colour and, for a dev workspace, its environment badge — the same
|
||||
// two marks the fork chip carries everywhere else.
|
||||
const forkAccent = $derived(inFork ? forkAccentStyle(currentWs?.color) : undefined)
|
||||
const devBadge = $derived(
|
||||
inFork && currentWs?.is_dev_workspace ? devBadgeText(currentWs.dev_workspace_label) : undefined
|
||||
)
|
||||
// A dev workspace wears its environment; the root wears "prod", so which environment the user
|
||||
// is standing in is answered the same way whichever one it is.
|
||||
const envBadge = $derived.by(() => {
|
||||
if (actingWorkspaceId) return undefined
|
||||
if (!inFork) return { text: 'prod', dev: false }
|
||||
if (currentWs?.is_dev_workspace)
|
||||
return { text: devBadgeText(currentWs.dev_workspace_label), dev: true }
|
||||
return undefined
|
||||
})
|
||||
|
||||
const canManageWorkspace = $derived(
|
||||
$userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email)
|
||||
)
|
||||
// No hover fill: the chevron beside it is what reacts to the pointer. The accent stays on the
|
||||
// fork's own name inside, so the family's name is not painted as if it were the fork.
|
||||
const scopeChipClass = twMerge(SEGMENT, 'hover:bg-transparent')
|
||||
|
||||
const segments = $derived(item?.path ? splitItemPath(item.path) : undefined)
|
||||
const currentItem = $derived<(WorkspaceItem & { savedPath?: string }) | undefined>(
|
||||
item
|
||||
? {
|
||||
path: item.path ?? '',
|
||||
summary: item.summary ?? '',
|
||||
kind: item.kind,
|
||||
raw_app: item.raw_app,
|
||||
savedPath: item.savedPath
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
|
||||
/** `f/demo` and `u/alice` name a folder and a user: the prefix becomes the segment's icon. */
|
||||
const scopeKind = $derived(segments?.dirs[0]?.fullPath.split('/')[0])
|
||||
|
||||
function switchWorkspaceDirect(id: string) {
|
||||
if ($workspaceStore === id) return
|
||||
workspaceAIClients.init(id)
|
||||
switchWorkspace(id)
|
||||
void fixupUrlAfterWorkspaceSwitch(id)
|
||||
}
|
||||
/** `f/demo` and `u/alice` name a folder and a user: the prefix becomes the path's icon. */
|
||||
const scopeKind = $derived(item?.path?.split('/')[0])
|
||||
/** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */
|
||||
const pathLevels = $derived((item?.path ?? '').split('/').slice(1).filter(Boolean))
|
||||
</script>
|
||||
|
||||
{#snippet workspaceDisc()}
|
||||
<svg width={ICON} height={ICON} viewBox="0 0 16 16" class="flex-shrink-0">
|
||||
<circle cx="8" cy="8" r="8" fill={discColor} />
|
||||
<foreignObject x="3" y="3" width="10" height="10">
|
||||
<Building size={10} style={glyphColor ? `color: ${glyphColor}` : undefined} />
|
||||
<!-- The glyph is the same size as every other icon on the line; only the disc around it is
|
||||
larger, by the ring it needs to read as a disc at all. -->
|
||||
<svg width={DISC} height={DISC} viewBox="0 0 {DISC} {DISC}" class="flex-shrink-0">
|
||||
<circle cx={DISC / 2} cy={DISC / 2} r={DISC / 2} fill={discColor} />
|
||||
<foreignObject x={(DISC - ICON) / 2} y={(DISC - ICON) / 2} width={ICON} height={ICON}>
|
||||
<Building size={ICON} style={glyphColor ? `color: ${glyphColor}` : undefined} />
|
||||
</foreignObject>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
{#snippet forkIcon()}
|
||||
<GitFork size={ICON} class="flex-shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
{#snippet itemKindIcon()}
|
||||
{#if item}<WorkspaceItemKindIcon kind={item.kind} size={ICON} />{/if}
|
||||
{/snippet}
|
||||
@@ -176,6 +151,18 @@ same weight, size and icon size, so the line reads as one control rather than fo
|
||||
<User size={ICON} class="flex-shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
{#snippet envBadgeMark()}
|
||||
{#if envBadge}
|
||||
<Badge
|
||||
color={envBadge.dev ? 'dark-blue' : 'gray'}
|
||||
small
|
||||
class={envBadge.dev
|
||||
? 'text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white'
|
||||
: 'text-3xs px-1 py-0'}>{envBadge.text}</Badge
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet slash()}
|
||||
<span class="shrink-0 text-hint/40 text-xs" aria-hidden="true">/</span>
|
||||
{/snippet}
|
||||
@@ -184,103 +171,111 @@ same weight, size and icon size, so the line reads as one control rather than fo
|
||||
aria-label="Breadcrumb"
|
||||
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
|
||||
>
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu {createMenu} usePointerDownOutside placement="bottom-start">
|
||||
{#snippet triggr({ trigger })}
|
||||
<NavBreadcrumbTrigger {trigger} class={SEGMENT} title={workspaceName}>
|
||||
<BreadcrumbItemContent label={workspaceName} icon={workspaceDisc} />
|
||||
</NavBreadcrumbTrigger>
|
||||
{/snippet}
|
||||
{#snippet children({ item: menuItem })}
|
||||
<WorkspacePickerBody item={menuItem} />
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
|
||||
{#if showFork}
|
||||
{@render slash()}
|
||||
|
||||
<WorkspaceFamilyPicker
|
||||
selectedId={scopeId}
|
||||
{forkableWorkspaces}
|
||||
onPick={switchWorkspaceDirect}
|
||||
allowCreateFork={!$userStore?.operator}
|
||||
onRequestCreateFork={() => (globalForkModal.val = { opened: true })}
|
||||
settingsHref={canManageWorkspace ? `${base}/workspace_settings` : undefined}
|
||||
settingsLabel={`${currentWs?.name ?? scopeId ?? 'Workspace'} settings`}
|
||||
class="min-w-0"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<!-- twMerge so the accent's text colour wins over the segment's own. -->
|
||||
<span
|
||||
class={twMerge(
|
||||
SEGMENT,
|
||||
forkAccent &&
|
||||
'bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)] font-semibold'
|
||||
)}
|
||||
style={forkAccent}
|
||||
<!-- One part for where the user is: the name of the workspace they are standing in — a fork
|
||||
included, in its own colour — leading home, and one chevron whose picker changes it. The
|
||||
picker lists the families and expands one to reach its forks, so a fork needs no part of
|
||||
its own here. -->
|
||||
<div class="flex items-center min-w-0">
|
||||
<!-- No hover fill on the name: the chevron beside it is the thing that lights up, and two
|
||||
boxes reacting to one pass of the pointer read as two controls fighting. -->
|
||||
<!-- bind:clientWidth: the menu hangs from the chevron, so it is shifted back by the width of
|
||||
the name to line its left edge up with the disc. -->
|
||||
<div class="flex items-center min-w-0" bind:clientWidth={nameWidth}>
|
||||
<svelte:element
|
||||
this={sessionMode ? 'span' : 'a'}
|
||||
href={sessionMode ? undefined : homeHref}
|
||||
class={scopeChipClass}
|
||||
title={showFork ? `${familyName} / ${scopeName}` : familyName}
|
||||
>
|
||||
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
|
||||
{#if showFork}
|
||||
<!-- Both names: the fork is where the work happens, the family is which product it is
|
||||
part of, and either alone leaves the other to be guessed. -->
|
||||
{@render slash()}
|
||||
<span
|
||||
class={twMerge(
|
||||
'flex items-center gap-1 min-w-0 px-1 rounded',
|
||||
forkAccent &&
|
||||
'bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)] font-semibold'
|
||||
)}
|
||||
style={forkAccent}
|
||||
>
|
||||
<GitFork size={ICON} class="flex-shrink-0" />
|
||||
<span class="truncate">{scopeName}</span>
|
||||
{@render envBadgeMark()}
|
||||
</span>
|
||||
{:else}
|
||||
{@render envBadgeMark()}
|
||||
{/if}
|
||||
</svelte:element>
|
||||
</div>
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu
|
||||
bind:this={workspaceMenu}
|
||||
{createMenu}
|
||||
usePointerDownOutside
|
||||
placement="bottom-start"
|
||||
contentStyle="margin-left: {-nameWidth}px"
|
||||
>
|
||||
<BreadcrumbItemContent label={forkLabel} icon={forkIcon} />
|
||||
{#if devBadge}
|
||||
<Badge
|
||||
color="dark-blue"
|
||||
small
|
||||
class="text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white"
|
||||
>{devBadge}</Badge
|
||||
{#snippet triggr({ trigger })}
|
||||
<NavBreadcrumbTrigger
|
||||
{trigger}
|
||||
class="flex items-center p-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
|
||||
title="Switch workspace"
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<ChevronDown size={ICON} class="flex-shrink-0" />
|
||||
</NavBreadcrumbTrigger>
|
||||
{/snippet}
|
||||
{#snippet children({ item: menuItem })}
|
||||
<WorkspacePickerBody item={menuItem} closeMenu={() => workspaceMenu?.close()} />
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
</WorkspaceFamilyPicker>
|
||||
{/if}
|
||||
</Menubar>
|
||||
</div>
|
||||
|
||||
{#if item || section || routePage}
|
||||
{@render slash()}
|
||||
{/if}
|
||||
|
||||
{#if item}
|
||||
{#if segments}
|
||||
{#each segments.dirs as dir, i (dir.fullPath)}
|
||||
{#if i > 0}{@render slash()}{/if}
|
||||
<BreadcrumbSegment
|
||||
label={i === 0 ? dir.name.split('/').slice(1).join('/') : dir.name}
|
||||
icon={i === 0 ? (scopeKind === 'u' ? userIcon : folderIcon) : undefined}
|
||||
extraClass="{SEGMENT} {i === 0 ? 'max-w-[40%]' : ''}"
|
||||
initialScope={i === 0
|
||||
? { kind: 'all' }
|
||||
: { kind: 'all', dir: segments.dirs[i - 1].fullPath }}
|
||||
initialHighlight={dirKey('all', dir.fullPath)}
|
||||
{currentItem}
|
||||
workspaceId={item.workspaceId}
|
||||
onPick={(picked) => item?.onNavigate?.(picked)}
|
||||
/>
|
||||
{/each}
|
||||
{@render slash()}
|
||||
<BreadcrumbSegment
|
||||
label={segments.leaf.name}
|
||||
extraClass={SEGMENT}
|
||||
initialScope={segments.dirs.length
|
||||
? { kind: 'all', dir: segments.dirs[segments.dirs.length - 1].fullPath }
|
||||
: { kind: 'all' }}
|
||||
initialHighlight={leafKeyFor(item.kind, segments.leaf.fullPath)}
|
||||
isCurrent
|
||||
{currentItem}
|
||||
workspaceId={item.workspaceId}
|
||||
onPick={(picked) => item?.onNavigate?.(picked)}
|
||||
/>
|
||||
{#if item.path}
|
||||
<!-- The path whole, as one label rather than a row of pickers: it is what a person reads
|
||||
to know which item this is, and what they reach for to paste somewhere else. -->
|
||||
<div class="relative flex items-center min-w-0">
|
||||
<!-- No hover fill: a fill offers to take you somewhere, and this only copies. The
|
||||
pointer and the popup after the click are affordance enough. -->
|
||||
<button
|
||||
class={twMerge(SEGMENT, 'gap-1.5 hover:bg-transparent')}
|
||||
title="Copy path"
|
||||
onclick={copyPath}
|
||||
aria-label="Copy path {item.path}"
|
||||
>
|
||||
{#if scopeKind === 'u'}
|
||||
{@render userIcon()}
|
||||
{:else}
|
||||
{@render folderIcon()}
|
||||
{/if}
|
||||
{#each pathLevels as level, i (i)}
|
||||
{#if i > 0}{@render slash()}{/if}
|
||||
<span class="truncate">{level}</span>
|
||||
{/each}
|
||||
</button>
|
||||
{#if pathCopied}
|
||||
<span
|
||||
class="absolute left-0 top-full mt-1 z-[6000] rounded-md border bg-surface px-2 py-1 text-2xs text-secondary shadow-md whitespace-nowrap"
|
||||
role="status"
|
||||
>
|
||||
Path copied successfully
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<BreadcrumbSegment
|
||||
label={KIND_LABEL_LOWER[item.kind]}
|
||||
icon={itemKindIcon}
|
||||
extraClass={SEGMENT}
|
||||
initialHighlight={kindKey(item.kind)}
|
||||
isCurrent
|
||||
{currentItem}
|
||||
workspaceId={item.workspaceId}
|
||||
onPick={(picked) => item?.onNavigate?.(picked)}
|
||||
/>
|
||||
<span class="{SEGMENT} hover:bg-transparent" aria-current="page">
|
||||
{@render itemKindIcon()}
|
||||
<span class="truncate">{KIND_LABEL_LOWER[item.kind]}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{:else if section?.kind}
|
||||
<BreadcrumbSegment
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
import { devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { Badge, Button } from './common'
|
||||
import { Button, ButtonType } from './common'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { GitFork, Lock, ShieldOff } from 'lucide-svelte'
|
||||
@@ -53,28 +54,35 @@
|
||||
? `Edits in ${devLabelKey(canonicalDev.dev_workspace_label)}`
|
||||
: 'Edits restricted'
|
||||
)
|
||||
|
||||
// The Popover renders its trigger as a <button>, so this wears the design system's subtle
|
||||
// button rather than containing one — a <Button> here would nest a button inside a button.
|
||||
const triggerClass = twMerge(
|
||||
'inline-flex items-center gap-1 rounded-md whitespace-nowrap transition-all',
|
||||
ButtonType.VariantStyles.subtle,
|
||||
ButtonType.UnifiedSizingClasses.sm,
|
||||
ButtonType.UnifiedHeightClasses.sm,
|
||||
ButtonType.UnifiedFontSizes.sm,
|
||||
'text-xs'
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if !$userStore?.operator && activeDeployRulesets.length > 0}
|
||||
<div class="my-2">
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
class="inline-flex items-center"
|
||||
class={triggerClass}
|
||||
triggerAttrs={{ 'aria-label': badgeLabel }}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<!-- `clickable` is unusable here: it renders the badge as a <button>, nested inside the
|
||||
one Popover wraps its trigger in. -->
|
||||
<Badge small color={bypassActive ? 'yellow' : 'blue'} class="cursor-pointer">
|
||||
{#if bypassActive}
|
||||
<ShieldOff class="h-3 w-3" />
|
||||
{:else if canonicalDev}
|
||||
<GitFork class="h-3 w-3" />
|
||||
{:else}
|
||||
<Lock class="h-3 w-3" />
|
||||
{/if}
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
{#if bypassActive}
|
||||
<ShieldOff size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" />
|
||||
{:else if canonicalDev}
|
||||
<GitFork size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" />
|
||||
{:else}
|
||||
<Lock size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" />
|
||||
{/if}
|
||||
{badgeLabel}
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-3 p-4 text-xs max-w-sm">
|
||||
|
||||
@@ -8,11 +8,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PanelLeft } from 'lucide-svelte'
|
||||
import { Tooltip } from '$lib/components/meltComponents'
|
||||
import { navDetached } from './sidebar/navDetached.svelte'
|
||||
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
|
||||
import NavBreadcrumb from './NavBreadcrumb.svelte'
|
||||
import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte'
|
||||
import { pageHeader } from './pageHeaderRegistry.svelte'
|
||||
import ContextBridge from './ContextBridge.svelte'
|
||||
|
||||
@@ -23,7 +21,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
const section = $derived(content?.section)
|
||||
</script>
|
||||
|
||||
<div data-page-header class="flex items-center gap-1 h-11 px-2 shrink-0 min-w-0 bg-surface">
|
||||
<div data-page-header class="flex items-center gap-1 h-11 pl-2 pr-4 shrink-0 min-w-0 bg-surface">
|
||||
{#if navDetached.val}
|
||||
<!-- The only way back to a hidden sidebar: hovering slides the card in for a look, a click
|
||||
puts it back for good. Docked, the sidebar speaks for itself and nothing leads the bar. -->
|
||||
@@ -33,16 +31,15 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
onmouseenter={() => navHandleSlot.open()}
|
||||
onmouseleave={() => navHandleSlot.scheduleClose()}
|
||||
>
|
||||
<Tooltip class="flex" placement="bottom" small>
|
||||
<button
|
||||
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
|
||||
aria-label="Show sidebar"
|
||||
onclick={() => (navDetached.val = false)}
|
||||
>
|
||||
<PanelLeft size={16} class="flex-shrink-0 text-hint" />
|
||||
</button>
|
||||
{#snippet text()}Show sidebar{/snippet}
|
||||
</Tooltip>
|
||||
<!-- No tooltip: hovering here already slides the sidebar in, which says what the button
|
||||
does better than a label popping up over it. -->
|
||||
<button
|
||||
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
|
||||
aria-label="Show sidebar"
|
||||
onclick={() => (navDetached.val = false)}
|
||||
>
|
||||
<PanelLeft size={16} class="flex-shrink-0 text-hint" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -62,9 +59,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
|
||||
<!-- A dot rather than a slash: the summary names the same item the path just located, it is
|
||||
not another level of it. -->
|
||||
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
|
||||
<!-- The item's kind belongs with the name a human reads, not with its path. -->
|
||||
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
|
||||
editor tells the reader what they can already see. -->
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<WorkspaceItemKindIcon kind={item.kind} />
|
||||
{#if item.summaryContent}
|
||||
{@render item.summaryContent()}
|
||||
{:else}
|
||||
|
||||
@@ -15,6 +15,7 @@ the header, so nesting is decided by the caller rather than guessed from context
|
||||
section,
|
||||
hint,
|
||||
actions,
|
||||
actionsOrder,
|
||||
contexts,
|
||||
barPlacement,
|
||||
actingWorkspaceId,
|
||||
@@ -27,6 +28,7 @@ the header, so nesting is decided by the caller rather than guessed from context
|
||||
section,
|
||||
hint,
|
||||
actions,
|
||||
actionsOrder,
|
||||
contexts,
|
||||
barPlacement,
|
||||
actingWorkspaceId,
|
||||
|
||||
@@ -872,7 +872,7 @@
|
||||
<PageHeaderContent hint={runsHint} actions={headerActions} />
|
||||
|
||||
<!-- Graph -->
|
||||
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 mt-4 border rounded-md">
|
||||
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 mt-2 border rounded-md">
|
||||
<div class="relative z-10 mb-2 flex gap-2">
|
||||
<Tabs bind:selected={graph}>
|
||||
<Tab value="RunChart" label="Duration" id="runs-chart-duration-tab" />
|
||||
|
||||
@@ -31,10 +31,12 @@
|
||||
{#if !isFork && draftCount > 0}
|
||||
<!-- In the band rather than a banner over the page: it is a standing fact about the workspace,
|
||||
not an interruption, and one button says it and acts on it. -->
|
||||
<PageHeaderContent actions={draftsAction} />
|
||||
<PageHeaderContent actions={draftsAction} actionsOrder={-100} />
|
||||
{/if}
|
||||
|
||||
{#snippet draftsAction()}
|
||||
<!-- A count on its own leaves the reader to work out what it counts; the sentence says it. -->
|
||||
<span class="shrink-0 text-2xs text-tertiary">This workspace has</span>
|
||||
<Badge color="blue" small>
|
||||
{draftCount} draft{draftCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
|
||||
@@ -125,27 +125,33 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<PublicAppFrame
|
||||
{fetchEmbedToken}
|
||||
{viewerUrl}
|
||||
onViewerReady={(_token, requestTokenRefresh) => {
|
||||
refresh = requestTokenRefresh
|
||||
loadApp()
|
||||
}}
|
||||
>
|
||||
{#snippet viewer()}
|
||||
<PublicApp
|
||||
{app}
|
||||
{workspace}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
jwtError={false}
|
||||
inWorkspace
|
||||
{hideRefreshBar}
|
||||
onLoginSuccess={() => loadApp()}
|
||||
></PublicApp>
|
||||
{/snippet}
|
||||
</PublicAppFrame>
|
||||
<!-- The band draws no edge of its own, so the separation from it is this page's first line. It
|
||||
goes on the app's wrapper rather than on the frame, which the header-less public page shares —
|
||||
and as an inset shadow rather than a border, which would take a pixel off an app sized to fill
|
||||
the page exactly and leave it scrolling. -->
|
||||
<div class="h-full shadow-[inset_0_1px_0_0_rgb(var(--color-border-light))]">
|
||||
<PublicAppFrame
|
||||
{fetchEmbedToken}
|
||||
{viewerUrl}
|
||||
onViewerReady={(_token, requestTokenRefresh) => {
|
||||
refresh = requestTokenRefresh
|
||||
loadApp()
|
||||
}}
|
||||
>
|
||||
{#snippet viewer()}
|
||||
<PublicApp
|
||||
{app}
|
||||
{workspace}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
jwtError={false}
|
||||
inWorkspace
|
||||
{hideRefreshBar}
|
||||
onLoginSuccess={() => loadApp()}
|
||||
></PublicApp>
|
||||
{/snippet}
|
||||
</PublicAppFrame>
|
||||
</div>
|
||||
|
||||
{#if canWriteApp && !hideEditBtn}
|
||||
<!-- The page header carries it, with the app's name: a floating button over the app's own
|
||||
|
||||
@@ -296,7 +296,9 @@
|
||||
|
||||
<!-- Everything the hero offered beside the composer lives in the band's menu: the connect helper,
|
||||
the hub, and the way back to the composer once it has been put away. -->
|
||||
<PageHeaderContent actions={homeMenu} />
|
||||
<!-- Last in the bar whatever else the page registers: a menu of side trips belongs after the
|
||||
buttons that act on what is on screen. -->
|
||||
<PageHeaderContent actions={homeMenu} actionsOrder={100} />
|
||||
|
||||
{#snippet homeMenu()}
|
||||
<!-- The band's own menu: the connect helper, the hub, and whether the composer is pinned to
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
interface Props {
|
||||
placement?: Placement
|
||||
/** Drops the fly-out, so the panel is out of the DOM the moment it closes. For a menu whose
|
||||
* act tears its own trigger down — picking a workspace — where floating-ui spends the
|
||||
* outro re-placing the panel against a reference on its way out, and it bounces. */
|
||||
closeInstantly?: boolean
|
||||
/** Inline style on the panel. A margin here shifts it after floating-ui has placed it,
|
||||
* which is the only way to anchor it to a box wider than the trigger: melt's menubar
|
||||
* menu ignores `positioning.offset`. */
|
||||
contentStyle?: string
|
||||
justifyEnd?: boolean
|
||||
lightMode?: boolean
|
||||
maxHeight?: number
|
||||
@@ -45,6 +53,8 @@
|
||||
|
||||
let {
|
||||
placement = 'right-start',
|
||||
contentStyle = '',
|
||||
closeInstantly = false,
|
||||
justifyEnd = false,
|
||||
lightMode = false,
|
||||
maxHeight = 900,
|
||||
@@ -220,7 +230,8 @@
|
||||
data-menu
|
||||
onmouseenter={cancelPendingClose}
|
||||
onmouseleave={scheduleClose}
|
||||
transition:placementFly={{ duration: 100, placement }}
|
||||
transition:placementFly={{ duration: closeInstantly ? 0 : 100, placement }}
|
||||
style={contentStyle}
|
||||
class={twMerge(
|
||||
'z-[6000] border w-56 origin-top-right rounded-md shadow-md focus:outline-none',
|
||||
// Default: scroll on the melt element. submenuSafe moves it to the inner
|
||||
|
||||
@@ -36,6 +36,9 @@ export type PageHeaderContent = {
|
||||
section?: PageHeaderSection
|
||||
/** Rendered at the right end of the bar — the page's own buttons. */
|
||||
actions?: Snippet
|
||||
/** Orders these buttons among the other registrations', ascending; registration order breaks a
|
||||
* tie. For a menu that must stay at the far end however many pages register before it. */
|
||||
actionsOrder?: number
|
||||
/** Rendered right after the page's name in the breadcrumb, for what reads as part of that name
|
||||
* rather than as an action — a page's documentation tooltip. */
|
||||
hint?: Snippet
|
||||
@@ -94,10 +97,14 @@ export const pageHeader = {
|
||||
* put buttons in the band, and each renders with the contexts its own registration named.
|
||||
*/
|
||||
get actions(): { render: Snippet; contexts?: Map<any, any> }[] {
|
||||
return entries.flatMap((e) => {
|
||||
const c = e.get()
|
||||
return c.actions ? [{ render: c.actions, contexts: c.contexts }] : []
|
||||
})
|
||||
return entries
|
||||
.flatMap((e, i) => {
|
||||
const c = e.get()
|
||||
return c.actions
|
||||
? [{ render: c.actions, contexts: c.contexts, order: c.actionsOrder ?? 0, i }]
|
||||
: []
|
||||
})
|
||||
.sort((a, b) => a.order - b.order || a.i - b.i)
|
||||
},
|
||||
/** Registers on mount and returns the id to release on destroy. */
|
||||
register(get: () => PageHeaderContent): number {
|
||||
|
||||
@@ -170,6 +170,9 @@ export function unsandboxedRawAppHtml(
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>App</title>
|
||||
<!-- Before the app's own stylesheet, so a bundle that wants a margin can still set one: the
|
||||
browser default of 8px would otherwise frame every app with a gap it never asked for. -->
|
||||
<style>html, body { margin: 0; }</style>
|
||||
<link rel="stylesheet" href="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.css" />
|
||||
<script>
|
||||
window.ctx = ${ctx ? JSON.stringify(ctx).replace(/</g, '\\u003c') : 'undefined'};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import SessionsBetaBanner from './SessionsBetaBanner.svelte'
|
||||
import EditableInput from '$lib/components/common/EditableInput.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
@@ -110,6 +111,9 @@
|
||||
// The workspace the session acts on. The page header's breadcrumb scopes itself to it, so a
|
||||
// session running in a fork reads as that fork rather than as the navigation workspace.
|
||||
const actingWorkspaceId = $derived(session ? getEffectiveWorkspaceId(session) : undefined)
|
||||
const actingIsFork = $derived(
|
||||
!!actingWorkspaceId && workspaceIsFork(actingWorkspaceId, $userWorkspaces)
|
||||
)
|
||||
|
||||
// Load copilot config (models, providers) for the workspace the session acts
|
||||
// on, not the navigation workspace — a session deliberately leaves
|
||||
@@ -433,6 +437,20 @@
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
{#if actingIsFork}
|
||||
<!-- Only a fork earns this: on the root the breadcrumb already names the workspace the
|
||||
chat writes to, and repeating it says nothing. The fork is the case worth calling
|
||||
out, and it belongs with the session's name rather than in the breadcrumb, where it
|
||||
would read as if the user had switched workspace. -->
|
||||
<span class="shrink-0 text-2xs text-tertiary pl-1">Acting on</span>
|
||||
<WorkspaceScopeTrigger
|
||||
workspaceId={actingWorkspaceId}
|
||||
showChevron={false}
|
||||
interactive={false}
|
||||
class="min-w-0"
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if headerInPage}
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
compact = false
|
||||
}: Props = $props()
|
||||
|
||||
let menu: Menu | undefined = $state(undefined)
|
||||
|
||||
// The active workspace's family root — shown in the trigger so a forked active workspace still
|
||||
// surfaces its family name here (the fork itself is shown in the breadcrumb). Resolved exactly
|
||||
// like the scope picker right below, so the two never name different heads for one workspace:
|
||||
@@ -37,7 +39,7 @@
|
||||
const ambiguousNames = $derived(ambiguousWorkspaceNames($userWorkspaces))
|
||||
</script>
|
||||
|
||||
<Menu {createMenu} usePointerDownOutside placement="bottom-start">
|
||||
<Menu bind:this={menu} {createMenu} usePointerDownOutside placement="bottom-start">
|
||||
{#snippet triggr({ trigger })}
|
||||
<!-- Family header reflects the family (root) color, not the active
|
||||
workspace's — switching into a fork must not recolor it. -->
|
||||
@@ -73,6 +75,11 @@
|
||||
{#snippet children({ item })}
|
||||
<!-- The only strict caller is a standalone page (svix webhook creation) that owns its
|
||||
own navigation, so there a switch must leave the page where it is. -->
|
||||
<WorkspacePickerBody {item} {strictWorkspaceSelect} keepPageOnSwitch={strictWorkspaceSelect} />
|
||||
<WorkspacePickerBody
|
||||
{item}
|
||||
{strictWorkspaceSelect}
|
||||
keepPageOnSwitch={strictWorkspaceSelect}
|
||||
closeMenu={() => menu?.close()}
|
||||
/>
|
||||
{/snippet}
|
||||
</Menu>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
||||
import { workspaceMenuHref } from './workspaceMenuHref'
|
||||
import { isOperatorInWorkspace } from './operatorRoutes'
|
||||
@@ -48,13 +48,16 @@
|
||||
item: MenubarMenuElements['item']
|
||||
// When used outside of the side bar, where links to workspace settings and such don't make as much sense.
|
||||
strictWorkspaceSelect?: boolean
|
||||
/** Closes the menu this list lives in, so a workspace switch does not run under an open
|
||||
* panel. The host owns the menu, so only it can. */
|
||||
closeMenu?: () => void
|
||||
// Set by a picker embedded in a page that drives its own navigation, so switching
|
||||
// workspaces must not navigate away from it. Separate from strictWorkspaceSelect,
|
||||
// which only strips the sidebar-specific rows.
|
||||
keepPageOnSwitch?: boolean
|
||||
}
|
||||
|
||||
let { item, strictWorkspaceSelect = false, keepPageOnSwitch = false }: Props = $props()
|
||||
let { item, strictWorkspaceSelect = false, keepPageOnSwitch = false, closeMenu }: Props = $props()
|
||||
|
||||
async function toggleSwitchWorkspace(id: string) {
|
||||
if ($workspaceStore === id) {
|
||||
@@ -106,8 +109,13 @@
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
|
||||
return
|
||||
}
|
||||
// This click is handled here rather than by melt, so melt never closes the menu for it and
|
||||
// the panel would sit through the switch — which renames the workspace in the header,
|
||||
// moving the very trigger the panel hangs from, with floating-ui chasing it across the
|
||||
// viewport. Close first, let that reach the DOM, then switch.
|
||||
e.preventDefault()
|
||||
toggleSwitchWorkspace(workspace.id)
|
||||
closeMenu?.()
|
||||
void tick().then(() => toggleSwitchWorkspace(workspace.id))
|
||||
}
|
||||
|
||||
// Family-first picker: list the workspace families (roots) with their forks
|
||||
@@ -139,16 +147,41 @@
|
||||
const canForkHere = $derived(
|
||||
(!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore)
|
||||
)
|
||||
// The row mechanics below — which family to expand, which collapsed row carries the tick — key on
|
||||
// a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the
|
||||
// caller can see: parentless, or with a parent absent from their list. `findWorkspaceAncestors`
|
||||
// stops at that same visibility boundary, so it lands on the same row.
|
||||
const lineageRoot = $derived.by(() => {
|
||||
const id = $workspaceStore ?? undefined
|
||||
if (!id) return undefined
|
||||
const ancestors = findWorkspaceAncestors(id, $userWorkspaces ?? [])
|
||||
return ancestors.at(-1) ?? $userWorkspaces?.find((w) => w.id === id)
|
||||
})
|
||||
|
||||
const familyWorkspaces = $derived.by(() => {
|
||||
if (strictWorkspaceSelect) return hierarchy
|
||||
let rootId: string | undefined
|
||||
return hierarchy.filter((h) => {
|
||||
if (h.depth === 0) {
|
||||
rootId = h.workspace.id
|
||||
return true
|
||||
}
|
||||
return !!rootId && expandedFamilies.has(rootId)
|
||||
})
|
||||
const visible = strictWorkspaceSelect
|
||||
? hierarchy
|
||||
: hierarchy.filter((h) => {
|
||||
if (h.depth === 0) {
|
||||
rootId = h.workspace.id
|
||||
return true
|
||||
}
|
||||
return !!rootId && expandedFamilies.has(rootId)
|
||||
})
|
||||
// The family the user is standing in leads the list, its forks travelling with it: the
|
||||
// workspace they are in is the one they look for first, and the one the tick is on.
|
||||
const activeRoot = lineageRoot?.id
|
||||
if (!activeRoot) return visible
|
||||
const families: { root: string | undefined; rows: typeof visible }[] = []
|
||||
for (const h of visible) {
|
||||
if (h.depth === 0 || families.length === 0) families.push({ root: h.workspace.id, rows: [h] })
|
||||
else families[families.length - 1].rows.push(h)
|
||||
}
|
||||
const at = families.findIndex((f) => f.root === activeRoot)
|
||||
if (at <= 0) return visible
|
||||
const [active] = families.splice(at, 1)
|
||||
return [active, ...families].flatMap((f) => f.rows)
|
||||
})
|
||||
|
||||
// ArrowRight/ArrowLeft expand/collapse the keyboard-highlighted family (melt
|
||||
@@ -172,27 +205,13 @@
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
// The row mechanics below — which family to expand, which collapsed row carries the tick — key on
|
||||
// a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the
|
||||
// caller can see: parentless, or with a parent absent from their list. `findWorkspaceAncestors`
|
||||
// stops at that same visibility boundary, so it lands on the same row.
|
||||
const lineageRoot = $derived.by(() => {
|
||||
const id = $workspaceStore ?? undefined
|
||||
if (!id) return undefined
|
||||
const ancestors = findWorkspaceAncestors(id, $userWorkspaces ?? [])
|
||||
return ancestors.at(-1) ?? $userWorkspaces?.find((w) => w.id === id)
|
||||
})
|
||||
|
||||
const ambiguousNames = $derived(ambiguousWorkspaceNames($userWorkspaces))
|
||||
|
||||
// Seeded once per instance: opening while a fork is active expands that fork's family,
|
||||
// putting the tick on the active fork's own row instead of on its collapsed root. The
|
||||
// host menu renders this only while open, so in practice each open gets a fresh
|
||||
// instance and therefore a fresh expansion state — except across a close and re-open
|
||||
// inside the menu's 100ms outro, where Svelte resumes the same instance and whatever
|
||||
// was expanded by hand survives.
|
||||
// Seeded once per instance: the family the user is standing in opens expanded, so its forks —
|
||||
// the ones they are most likely to switch between — are there without a click, and the tick
|
||||
// sits on the actual workspace rather than on its collapsed root. Other families stay closed.
|
||||
untrack(() => {
|
||||
if (lineageRoot && lineageRoot.id !== $workspaceStore) {
|
||||
if (lineageRoot && familiesWithForks.has(lineageRoot.id)) {
|
||||
expandedFamilies.add(lineageRoot.id)
|
||||
}
|
||||
})
|
||||
@@ -344,11 +363,7 @@
|
||||
</MenuItem>
|
||||
{/if}
|
||||
{#if canForkHere}
|
||||
<MenuItem
|
||||
class={itemClass}
|
||||
onClick={() => (globalForkModal.val = { opened: true })}
|
||||
{item}
|
||||
>
|
||||
<MenuItem class={itemClass} onClick={() => (globalForkModal.val = { opened: true })} {item}>
|
||||
<Plus size={16} />
|
||||
Workspace fork
|
||||
</MenuItem>
|
||||
|
||||
@@ -534,6 +534,12 @@
|
||||
// Docking and detaching move and resize the rail itself between its docked box and the
|
||||
// detached card's (top 44px, bottom and left insets, rounded corners); detaching also slides
|
||||
// it off the left edge, where the closed card lives. No opacity: the rail never fades.
|
||||
// The footer's icon buttons wear the rail's own row box — 32px square, 16px glyph — so the
|
||||
// pair that hides and collapses the sidebar lines up with the links above them, collapsed or
|
||||
// not. Padding alone drifted: two different paddings, two icon sizes and a negative margin.
|
||||
const SIDEBAR_ICON_BUTTON =
|
||||
'h-8 w-8 flex items-center justify-center rounded hover:bg-surface-hover flex-shrink-0'
|
||||
|
||||
const RAIL_MORPH_MS = 200
|
||||
function railMorph(_node: HTMLElement, { offscreen }: { offscreen: boolean }) {
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
@@ -1025,7 +1031,7 @@
|
||||
{@const hidden = navDetached.val}
|
||||
<Tooltip class="flex" placement="bottom" small>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-surface-hover flex-shrink-0"
|
||||
class={SIDEBAR_ICON_BUTTON}
|
||||
aria-label={hidden ? 'Show sidebar' : 'Hide sidebar'}
|
||||
onclick={() => setDetached(!hidden)}
|
||||
>
|
||||
@@ -1415,7 +1421,7 @@
|
||||
{@render sidebarToggle()}
|
||||
{#if !$userStore?.operator}
|
||||
<button
|
||||
class="p-2 -m-2 rounded hover:bg-surface-hover"
|
||||
class={SIDEBAR_ICON_BUTTON}
|
||||
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
onclick={() => {
|
||||
collapsedState = !collapsedState
|
||||
@@ -1424,9 +1430,9 @@
|
||||
}}
|
||||
>
|
||||
{#if isCollapsed}
|
||||
<PanelLeftOpen size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
|
||||
<PanelLeftOpen size={16} class="flex-shrink-0 text-hint" />
|
||||
{:else}
|
||||
<PanelLeftClose size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
|
||||
<PanelLeftClose size={16} class="flex-shrink-0 text-hint" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -1535,26 +1541,24 @@
|
||||
{#if !menuHidden && !devOnly && pageHeader.content?.barPlacement !== 'inline'}
|
||||
{@const rightInset = pageHeader.content?.barRightInset}
|
||||
{@const leftInset = useDrawer ? 0 : railWidth}
|
||||
{#if rightInset != null}
|
||||
<!-- A page owning the right edge from the top (a session's side panel) gets the band
|
||||
as an overlay that stops where that column starts, so the column can reach the
|
||||
top. The page pads its own content to clear it. -->
|
||||
<div
|
||||
class={classNames('absolute top-0 z-30', sidebarTransitionClass)}
|
||||
style:left="{leftInset}rem"
|
||||
style:right="{rightInset}px"
|
||||
>
|
||||
<PageHeaderBar />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- The band spans the content, beside the rail rather than above it. -->
|
||||
<div
|
||||
class={classNames('shrink-0', sidebarTransitionClass)}
|
||||
style:padding-left="{leftInset}rem"
|
||||
>
|
||||
<PageHeaderBar />
|
||||
</div>
|
||||
{/if}
|
||||
<!-- One element for both placements, styled rather than branched: a page registers its
|
||||
inset on mount, and swapping between two `{#if}` arms would destroy the band and
|
||||
build another one a frame later — a blink of no header, with the content sliding
|
||||
up into the gap and the workspace menu losing the trigger it hangs from.
|
||||
In flow, the band spans the content beside the rail. With an inset, a page owns
|
||||
the right edge from the top (a session's side panel): the band floats, stopping
|
||||
where that column starts, and the page pads its own content to clear it. -->
|
||||
<div
|
||||
class={classNames(
|
||||
rightInset != null ? 'absolute top-0 z-30' : 'shrink-0',
|
||||
sidebarTransitionClass
|
||||
)}
|
||||
style:left={rightInset != null ? `${leftInset}rem` : undefined}
|
||||
style:right={rightInset != null ? `${rightInset}px` : undefined}
|
||||
style:padding-left={rightInset != null ? undefined : `${leftInset}rem`}
|
||||
>
|
||||
<PageHeaderBar />
|
||||
</div>
|
||||
{/if}
|
||||
{#if $enterpriseLicense && !menuHidden}
|
||||
<!-- Announcements are an EE feature, so the component never mounts on CE: no
|
||||
|
||||
@@ -19,11 +19,13 @@
|
||||
import { writable } from 'svelte/store'
|
||||
import type { EditorBreakpoint } from '$lib/components/apps/types'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte'
|
||||
import WorkspaceDraftsBanner from '$lib/components/WorkspaceDraftsBanner.svelte'
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
|
||||
import { z } from 'zod'
|
||||
import HomeAIChat from '$lib/components/home/HomeAIChat.svelte'
|
||||
@@ -92,6 +94,22 @@
|
||||
|
||||
let showCreateButtons = $state(false)
|
||||
|
||||
// A workspace switch keeps this page mounted and swaps its contents under the reader, which
|
||||
// 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)
|
||||
const reducedMotion = useReducedMotion()
|
||||
let animatedWorkspace = $workspaceStore
|
||||
$effect(() => {
|
||||
const ws = $workspaceStore
|
||||
if (ws === animatedWorkspace) return
|
||||
animatedWorkspace = ws
|
||||
untrack(() => {
|
||||
if (!pageEl || reducedMotion.val) return
|
||||
pageEl.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500, easing: 'ease-out' })
|
||||
})
|
||||
})
|
||||
|
||||
let operatorTour: OperatorTour | undefined = $state(undefined)
|
||||
|
||||
// Delayed so the tabs the first steps point at exist. `runTutorial` refuses while a tour is
|
||||
@@ -262,10 +280,19 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{#snippet editRuleBadge()}
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
bind:this={pageEl}
|
||||
class="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
|
||||
header with the workspace's own name rather than above the list. -->
|
||||
<PageHeaderContent actions={editRuleBadge} />
|
||||
|
||||
<ForkWorkspaceBanner />
|
||||
<WorkspaceDraftsBanner />
|
||||
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full mb-6">
|
||||
@@ -285,8 +312,6 @@
|
||||
<div class="my-4"></div>
|
||||
{/if}
|
||||
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
|
||||
|
||||
{#if tab == 'hub'}
|
||||
<div class="flex flex-col gap-y-16">
|
||||
<div class="flex flex-col pb-8">
|
||||
|
||||
Reference in New Issue
Block a user