{userQuestion.question}
flow_env.VARIABLE_NAME or flow_env["VARIABLE_NAME"]. These
variables are available in the property picker and can be used in JavaScript expressions and
- input bindings. String values can link to workspace variables using the `; it highlights code as
+ * escaped text and never renders mermaid on untrusted input (chat opts in).
+ *
* Any Markdown sink that renders user input MUST use this chain rather than
* assembling its own `rehypeRaw` pipeline.
*/
@@ -26,5 +32,6 @@ export const markdownPlugins: Plugin[] = [
gfmPlugin(),
{ rehypePlugin: [rehypeRaw] },
{ rehypePlugin: [rehypeSanitize] },
- { rehypePlugin: [rehypeGithubAlerts] }
+ { rehypePlugin: [rehypeGithubAlerts] },
+ { renderer: { pre: MarkdownCodeBlock } }
]
diff --git a/frontend/src/lib/components/meltComponents/Menu.svelte b/frontend/src/lib/components/meltComponents/Menu.svelte
index e97a3c4c15..a7a4463706 100644
--- a/frontend/src/lib/components/meltComponents/Menu.svelte
+++ b/frontend/src/lib/components/meltComponents/Menu.svelte
@@ -3,6 +3,7 @@
import { createBubbler } from 'svelte/legacy'
const bubble = createBubbler()
+ import { placementFly } from '$lib/utils/placementFly'
import { melt, createSync } from '@melt-ui/svelte'
import type { MenubarBuilders } from '@melt-ui/svelte'
import type { Placement } from '@floating-ui/core'
@@ -60,7 +61,15 @@
fitViewport: true,
strategy: 'fixed'
},
- loop: true
+ loop: true,
+ // Hover tooltips (e.g. NameIdTooltip on menu rows) portal to body, so a
+ // click inside one — like its copy button — registers as an outside click.
+ // Veto the close so interacting with a tooltip doesn't tear the menu down.
+ onOutsideClick: (e) => {
+ if ((e.target as HTMLElement)?.closest?.('[data-melt-tooltip-content]')) {
+ e.preventDefault()
+ }
+ }
})
//Melt
@@ -81,7 +90,11 @@
}
async function getMenuElements(): Promise {
- return Array.from(document.querySelectorAll('[data-menu]')) as HTMLElement[]
+ // Tooltip content counts as menu territory for the same reason as the
+ // onOutsideClick veto above.
+ return Array.from(
+ document.querySelectorAll('[data-menu], [data-melt-tooltip-content]')
+ ) as HTMLElement[]
}
@@ -112,6 +125,7 @@
{@render children?.({ item, open, builders })}
diff --git a/frontend/src/lib/components/meltComponents/Popover.svelte b/frontend/src/lib/components/meltComponents/Popover.svelte
index 87d5ff3f83..884dc35c5e 100644
--- a/frontend/src/lib/components/meltComponents/Popover.svelte
+++ b/frontend/src/lib/components/meltComponents/Popover.svelte
@@ -11,7 +11,7 @@
const bubble = createBubbler()
import { createPopover, createSync, melt } from '@melt-ui/svelte'
- import { fly } from 'svelte/transition'
+ import { placementFly } from '$lib/utils/placementFly'
import { X, Minimize2, Maximize2 } from 'lucide-svelte'
import type { Placement } from '@floating-ui/core'
import { debounce, pointerDownOutside } from '$lib/utils'
@@ -264,7 +264,7 @@
}}
onmouseleave={debounceClose}
use:melt={$_content}
- transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }}
+ transition:placementFly={{ duration: enableFlyTransition ? 100 : 0, placement }}
class={twMerge(
'relative dark:border rounded-md bg-surface-tertiary shadow-lg',
fullScreen
diff --git a/frontend/src/lib/components/meltComponents/Tooltip.svelte b/frontend/src/lib/components/meltComponents/Tooltip.svelte
index 86b0d948fd..68dd81226c 100644
--- a/frontend/src/lib/components/meltComponents/Tooltip.svelte
+++ b/frontend/src/lib/components/meltComponents/Tooltip.svelte
@@ -20,6 +20,10 @@
customBgClass?: string | undefined
style?: string
class?: string
+ // 'cursor' anchors the popup to the pointer position where hover started,
+ // instead of the trigger element's box. Useful for wide/full-width triggers
+ // where an element-anchored tooltip lands far from the cursor.
+ anchor?: 'element' | 'cursor'
children?: import('svelte').Snippet
text?: import('svelte').Snippet
}
@@ -36,6 +40,7 @@
customBgClass = undefined,
style = '',
class: className = '',
+ anchor = 'element',
children,
text
}: Props = $props()
@@ -52,9 +57,55 @@
group: true,
portal: untrack(() => portal)
})
+
+ // Cursor anchoring: floating-ui positions against `reference.getBoundingClientRect()`.
+ // melt uses the trigger element as that reference, so we override its rect to a
+ // zero-size box at the pointer. The coords are frozen while the tooltip is open so
+ // it stays put (letting the pointer travel into the popup to reach the copy button);
+ // they only track the pointer while closed, capturing where the next open will land.
+ let triggerEl = $state(undefined)
+ let cursorX = 0
+ let cursorY = 0
+ // Until a pointer is seen, fall back to the element rect so keyboard-focus opens
+ // don't land the popup at (0, 0).
+ let hasCursor = false
+ $effect(() => {
+ if (anchor !== 'cursor' || !triggerEl) return
+ const el = triggerEl
+ // Listeners added imperatively (not `onpointermove` attrs) so the static span
+ // keeps no interaction handlers, avoiding an a11y_no_static_element warning.
+ const track = (e: PointerEvent) => {
+ if ($open) return
+ cursorX = e.clientX
+ cursorY = e.clientY
+ hasCursor = true
+ }
+ el.addEventListener('pointerenter', track)
+ el.addEventListener('pointermove', track)
+ const original = el.getBoundingClientRect.bind(el)
+ el.getBoundingClientRect = () =>
+ hasCursor
+ ? ({
+ width: 0,
+ height: 0,
+ x: cursorX,
+ y: cursorY,
+ top: cursorY,
+ left: cursorX,
+ right: cursorX,
+ bottom: cursorY,
+ toJSON() {}
+ } as DOMRect)
+ : original()
+ return () => {
+ el.removeEventListener('pointerenter', track)
+ el.removeEventListener('pointermove', track)
+ el.getBoundingClientRect = original
+ }
+ })
-
+
{@render children?.()}
{#if !children}
diff --git a/frontend/src/lib/components/raw_apps/DefaultDatabaseSelector.svelte b/frontend/src/lib/components/raw_apps/DefaultDatabaseSelector.svelte
index 9e91ef55d0..66e3ab0355 100644
--- a/frontend/src/lib/components/raw_apps/DefaultDatabaseSelector.svelte
+++ b/frontend/src/lib/components/raw_apps/DefaultDatabaseSelector.svelte
@@ -10,6 +10,10 @@
toSchemaItems
} from './datatableUtils.svelte'
import { Button } from '../common'
+ import { getRawAppOperatingWorkspace } from './rawAppWorkspace'
+
+ const getOpWs = getRawAppOperatingWorkspace()
+ let opWs = $derived(getOpWs?.() ?? $workspaceStore)
interface Props {
/** Currently selected datatable */
@@ -30,8 +34,11 @@
}: Props = $props()
// Load available datatables and schemas using shared utilities
- const datatables = createDatatablesResource(() => $workspaceStore)
- const schemas = createSchemasResource(() => datatable)
+ const datatables = createDatatablesResource(() => opWs)
+ const schemas = createSchemasResource(
+ () => datatable,
+ () => opWs
+ )
const datatableItems = $derived(toDatatableItems(datatables.current))
const schemaItems = $derived(toSchemaItems(schemas.current))
@@ -49,47 +56,43 @@
{#snippet trigger()}
-
-
-
-
-
+
+
+
{/snippet}
{#snippet content()}
-
-
- Default Datatable & Schema
+
+ Default Datatable & Schema
-
- {description}
-
+
+ {description}
+
-
- Database
-
-
-
- Schema
-
+
+ Database
+
-
+
+
+ Schema
+
+
{/snippet}
diff --git a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte
index aa53999453..6a6a2a646f 100644
--- a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte
+++ b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte
@@ -12,6 +12,10 @@
import DBManagerContent from '../DBManagerContent.svelte'
import type { DbInput } from '../dbTypes'
import type { SelectedTable } from '../DBManager.svelte'
+ import { getRawAppOperatingWorkspace } from './rawAppWorkspace'
+
+ const getOpWs = getRawAppOperatingWorkspace()
+ let opWs = $derived(getOpWs?.() ?? $workspaceStore)
interface Props {
onAdd?: (ref: DataTableRef) => void
@@ -40,11 +44,9 @@
// Load available datatables from workspace
const datatables = resource([], async () => {
- if (!$workspaceStore) return []
+ if (!opWs) return []
try {
- return (await WorkspaceService.listDataTables({ workspace: $workspaceStore })).map(
- (d) => d.name
- )
+ return (await WorkspaceService.listDataTables({ workspace: opWs })).map((d) => d.name)
} catch (e) {
console.error('Failed to load datatables:', e)
return []
@@ -163,11 +165,12 @@
CloseIcon={hasReplResult ? ArrowLeft : undefined}
noPadding
>
- {#if dbInput && $workspaceStore}
+ {#if dbInput && opWs}
{#key selectedDatatable}
void
+ // Condensed top bar: smaller (sm) buttons, a shorter bar, and the
+ // EditorHeader's path/breadcrumb row dropped (summary only). Used by the
+ // session preview to save vertical room.
+ condensedHeader?: boolean
}
let {
@@ -162,10 +167,19 @@
onRuntimeLogRequester = undefined,
onRunsProvider = undefined,
onRestore,
- onSavedNewAppPath
+ onSavedNewAppPath,
+ condensedHeader = false
}: Props = $props()
export const version: number | undefined = undefined
+ // Workspace this editor operates on: the session's acting workspace when
+ // embedded in a session preview (autosaveWorkspace), else the navigation
+ // workspace. Deploy/save/background-runner must target it, not $workspaceStore.
+ const opWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
+ // Expose it to the sidebar sub-components (inline scripts, datatable/shared-UI
+ // drawers, DB selector) so their lookups target the app's workspace too.
+ setRawAppOperatingWorkspace(() => opWorkspace)
+
// Convert to object format for child components
let dataTableRefsObjects = $derived(data.tables.map(parseDataTableRef))
let dataTableWhitelist = $derived(buildDataTableWhitelist(dataTableRefsObjects))
@@ -520,6 +534,24 @@
'boolean'
)
+ // Auto-compact when the editor opens in a narrow container (e.g. the session
+ // preview pane): drop to the merged single-pane view and retract the file
+ // sidebar. Applied once, on the first measured layout — later resizes are the
+ // user's call. The sidebar is set without persisting so a transient narrow
+ // open never overrides the user's saved expand/collapse preference.
+ let rootWidth = $state(0)
+ const NARROW_PX = 900
+ let appliedNarrowDefault = false
+ $effect(() => {
+ const w = rootWidth
+ if (appliedNarrowDefault || w <= 0) return
+ appliedNarrowDefault = true
+ if (w < NARROW_PX) {
+ splitWithPreview = false
+ sidebarCollapsed.setWithoutPersist(true)
+ }
+ })
+
function handleYamlApply(update: RawAppYamlUpdate) {
if (update.summary !== undefined) {
summary = update.summary
@@ -602,10 +634,10 @@
let sharedUiLoaded = $state(false)
async function loadSharedUi() {
- if (!$workspaceStore) return
+ if (!opWorkspace) return
try {
const res = (await WorkspaceService.getSharedUi({
- workspace: $workspaceStore
+ workspace: opWorkspace
})) as { files?: Record; version?: number }
sharedUiFiles = res.files ?? {}
sharedUiVersion = res.version ?? 0
@@ -889,12 +921,12 @@
handleHistorySelect(id)
},
listDatatableTables: async (): Promise => {
- if (!$workspaceStore) {
+ if (!opWorkspace) {
return []
}
const tables = await WorkspaceService.listDataTableTables({
- workspace: $workspaceStore
+ workspace: opWorkspace
})
return filterDatatableTables(tables)
},
@@ -903,7 +935,7 @@
schemaName: string,
tableName: string
): Promise> => {
- if (!$workspaceStore) {
+ if (!opWorkspace) {
return {}
}
@@ -917,7 +949,7 @@
}
const schema = await WorkspaceService.getDataTableTableSchema({
- workspace: $workspaceStore,
+ workspace: opWorkspace,
datatableName,
schemaName,
tableName
@@ -933,13 +965,13 @@
sql: string,
newTable?: { schema: string; name: string }
): Promise<{ success: boolean; result?: Record[]; error?: string }> => {
- if (!$workspaceStore) {
+ if (!opWorkspace) {
return { success: false, error: 'Workspace not available' }
}
try {
const result = await runScriptAndPollResult({
- workspace: $workspaceStore,
+ workspace: opWorkspace,
requestBody: {
language: 'postgresql',
content: sql,
@@ -960,6 +992,7 @@
// Clear the cached schema so it gets refreshed with the new table
const resourcePath = `datatable://${datatableName}`
delete $dbSchemas[resourcePath]
+ delete $dbSchemas[`${opWorkspace}:${resourcePath}`]
}
}
@@ -1540,9 +1573,9 @@
// Force an immediate flush. No toast — the AutosaveIndicator narrates the
// result, and `flush` never rejects (postSave routes errors to the failures map).
function flushDraft() {
- if (!$workspaceStore || !liveEditorDraftStoragePath) return
+ if (!opWorkspace || !liveEditorDraftStoragePath) return
void UserDraftDbSyncer.flush({
- workspace: $workspaceStore,
+ workspace: opWorkspace,
itemKind: 'raw_app',
path: liveEditorDraftStoragePath
})
@@ -1610,7 +1643,7 @@
externalPreviewWindow}
/>
-
+
yamlEditorDrawer?.openDrawer()}
sidebarCollapsed={sidebarCollapsed.val}
onToggleSidebar={() => (sidebarCollapsed.val = !sidebarCollapsed.val)}
+ {condensedHeader}
/>
void
sidebarCollapsed?: boolean
onToggleSidebar?: () => void
+ /** Condensed top bar: smaller (sm) buttons, a shorter bar, and the
+ * EditorHeader's path/breadcrumb row dropped (summary only). Used by the
+ * session preview to save vertical room. */
+ condensedHeader?: boolean
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
liveEditorDraftStoragePath?: string
/** Indicator-only overrides for the sessions preview: the AutosaveIndicator
@@ -192,6 +198,7 @@
onOpenYamlEditor = undefined,
sidebarCollapsed = false,
onToggleSidebar = undefined,
+ condensedHeader = false,
onNavigate = undefined,
liveEditorDraftStoragePath = undefined,
autosaveWorkspace = undefined,
@@ -213,9 +220,25 @@
// The AutosaveIndicator watches these; in the sessions preview they're the
// session's (workspace, path), else the full-page editor's own values.
- const indicatorWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
+ const opWorkspace = $derived(autosaveWorkspace ?? $workspaceStore)
const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath)
+ // Materialize a brand-new app's draft before the session preview loads it by
+ // path — an untouched new app never autosaved, so forcePersist is the only
+ // thing that creates the row (`appPath === indicatorPath` in the full-page
+ // editor). Gated to never-deployed: forcePersist skips the discardIf baseline.
+ async function persistDraftForSession(): Promise {
+ if (!opWorkspace || indicatorPath === undefined) return
+ await UserDraftDbSyncer.flush({
+ workspace: opWorkspace,
+ itemKind: 'raw_app',
+ path: indicatorPath
+ })
+ if (newApp) {
+ await UserDraft.forcePersist('raw_app', indicatorPath, { workspace: opWorkspace })
+ }
+ }
+
$effect(() => {
const typed = newEditedPath
const baseline = savedApp?.path ?? ''
@@ -240,8 +263,8 @@
)
$effect(() => {
- if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return
- const workspace = $workspaceStore
+ if (liveEditorDraftStoragePath === undefined || !opWorkspace) return
+ const workspace = opWorkspace
UserDraft.setLiveEditorDraft({
workspace,
itemKind: 'raw_app',
@@ -280,6 +303,10 @@
let topbarWidth = $state(0)
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720)
+ // Top-bar button size + bar height. Condensed (session preview) uses the
+ // smallest well-supported unified size (`sm`) so the bar is thinner.
+ const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md')
+
async function publishToHub() {
if (!app) return
publishingToHub = true
@@ -331,7 +358,7 @@
try {
const { js, css } = await getBundle()
await AppService.createAppRaw({
- workspace: $workspaceStore!,
+ workspace: opWorkspace!,
formData: {
app: {
value: app,
@@ -349,7 +376,7 @@
})
// New path now exists server-side — drop the autocomplete cache so
// it shows up immediately instead of after the 60s TTL.
- invalidateWorkspacePaths($workspaceStore!)
+ invalidateWorkspacePaths(opWorkspace!)
savedApp = {
summary: summary,
value: structuredClone(stateSnapshot(app)),
@@ -413,7 +440,7 @@
async function syncWithDeployed() {
const deployedApp = await AppService.getAppByPath({
- workspace: $workspaceStore!,
+ workspace: opWorkspace!,
path: appPath!,
withStarredInfo: true
})
@@ -452,7 +479,7 @@
policy.execution_mode = 'publisher'
}
await AppService.updateAppRaw({
- workspace: $workspaceStore!,
+ workspace: opWorkspace!,
path: appPath!,
formData: {
app: {
@@ -472,7 +499,7 @@
css
}
})
- invalidateWorkspacePaths($workspaceStore!)
+ invalidateWorkspacePaths(opWorkspace!)
savedApp = {
summary: summary,
value: structuredClone(stateSnapshot(app)),
@@ -482,7 +509,7 @@
labels: $state.snapshot(labels)
}
const appHistory = await AppService.getAppHistoryByPath({
- workspace: $workspaceStore!,
+ workspace: opWorkspace!,
path: npath
})
version = appHistory[0]?.version
@@ -506,7 +533,7 @@
async function setPublishState(message?: string) {
await computeTriggerables()
await AppService.updateApp({
- workspace: $workspaceStore!,
+ workspace: opWorkspace!,
path: appPath,
requestBody: { policy }
})
@@ -531,7 +558,7 @@
}
try {
const appVersion = await AppService.getAppLatestVersion({
- workspace: $workspaceStore!,
+ workspace: opWorkspace!,
path: appPath
})
onLatest = appVersion?.version === undefined || version === appVersion?.version
@@ -698,6 +725,7 @@
{onLatest}
{savedApp}
rawApp
+ operatingWorkspace={opWorkspace}
bind:summary
bind:customPath
bind:deploymentMsg
@@ -768,9 +796,15 @@
-
+
+
{#if onToggleSidebar}
onToggleSidebar?.()}
/>
{/if}
- (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
- />
- {#if indicatorWorkspace && indicatorPath !== undefined}
+