diff --git a/js-packages/web-console/src/lib/components/layout/Drawer.svelte b/js-packages/web-console/src/lib/components/layout/Drawer.svelte
index 54f41532dae..ca53ec42a1c 100644
--- a/js-packages/web-console/src/lib/components/layout/Drawer.svelte
+++ b/js-packages/web-console/src/lib/components/layout/Drawer.svelte
@@ -3,6 +3,7 @@
import OverlayDrawer from '$lib/components/layout/OverlayDrawer.svelte'
import { useIsTablet } from '$lib/compositions/layout/useIsMobile.svelte'
+ import type { Percent } from '$lib/functions/common/percent'
import type { Snippet } from '$lib/types/svelte'
const isTablet = useIsTablet()
@@ -10,16 +11,34 @@
let {
open = $bindable(),
side,
+ main,
children,
- width,
+ width = 'w-[700px]',
onClose,
- inlineClass = ''
+ inlineClass = '',
+ defaultSize = '30%',
+ minSize = '20%',
+ maxSize = '80%',
+ localStorageKey
}: {
open: boolean
side: 'right' | 'left'
+ /**
+ * Always-visible content. On wide screens the drawer resizes against it
+ * via a draggable divider; on tablet/mobile the drawer overlays it.
+ */
+ main: Snippet
+ /** Drawer content. */
children: Snippet
- width: string
+ /** Overlay-mode drawer width on tablet/mobile, e.g. `w-[700px]`. */
+ width?: string
inlineClass?: string
+ /** Inline drawer pane size on open, as a percentage of the container. */
+ defaultSize?: Percent
+ minSize?: Percent
+ maxSize?: Percent
+ /** localStorage key to persist the resized inline layout. */
+ localStorageKey?: string
/**
* Called when the drawer requests dismissal (currently fired by the
* modal-backdrop click in the tablet/mobile overlay variant).
@@ -28,8 +47,8 @@
} = $props()
-
{#if isTablet.current}
+ {@render main()}
{:else}
-
+
{/if}
diff --git a/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte b/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte
index 5a2ccf07b0f..923872b9348 100644
--- a/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte
+++ b/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte
@@ -1,38 +1,75 @@
-
-
+ {@render main()}
+
+{/snippet}
+{#snippet drawerPane()}
+
-
-
+
+{/snippet}
+
+
+ {#if open && side === 'left'}
+ {@render drawerPane()}
+
+ {/if}
+ {@render mainPane()}
+ {#if open && side === 'right'}
+
+ {@render drawerPane()}
+ {/if}
+
diff --git a/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte.spec.ts b/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte.spec.ts
new file mode 100644
index 00000000000..14a4d753738
--- /dev/null
+++ b/js-packages/web-console/src/lib/components/layout/InlineDrawer.svelte.spec.ts
@@ -0,0 +1,54 @@
+/**
+ * Component tests for the resizable inline drawer. The drawer lays its
+ * always-visible `main` content next to a resizable `children` pane using
+ * PaneForge; a draggable separator (`[data-pane-resizer]`) appears only while
+ * the drawer is open. These tests run irrespective of viewport, since
+ * `InlineDrawer` is the wide-screen variant selected by `Drawer`.
+ */
+
+import { createRawSnippet } from 'svelte'
+import { describe, expect, it } from 'vitest'
+import { page } from 'vitest/browser'
+import { render } from 'vitest-browser-svelte'
+import InlineDrawer from './InlineDrawer.svelte'
+
+const textSnippet = (text: string) =>
+ createRawSnippet(() => ({ render: () => `
${text}
` }))
+
+const renderDrawer = (props: { open: boolean; side: 'right' | 'left' }) =>
+ render(InlineDrawer, {
+ ...props,
+ main: textSnippet('MAIN CONTENT'),
+ children: textSnippet('DRAWER CONTENT')
+ })
+
+describe('InlineDrawer', () => {
+ it('shows only the main content and no resize handle when closed', async () => {
+ const { container } = renderDrawer({ open: false, side: 'right' })
+
+ await expect.element(page.getByText('MAIN CONTENT')).toBeInTheDocument()
+ // Closed: the main pane fills the container, so there is nothing to drag.
+ expect(container.querySelector('[data-pane-resizer]')).toBeNull()
+ expect(container.textContent).not.toContain('DRAWER CONTENT')
+ })
+
+ it('renders the drawer content and a draggable resize handle when open', async () => {
+ const { container } = renderDrawer({ open: true, side: 'right' })
+
+ await expect.element(page.getByText('DRAWER CONTENT')).toBeInTheDocument()
+ await expect.element(page.getByText('MAIN CONTENT')).toBeInTheDocument()
+ // The separator is what makes the drawer resizable — the crux of the feature.
+ // Remove the `{#if open}` guard around the resizer to see this assertion fail.
+ expect(container.querySelector('[data-pane-resizer]')).not.toBeNull()
+ })
+
+ it('places the drawer before the main content when anchored to the left', async () => {
+ const { container } = renderDrawer({ open: true, side: 'left' })
+
+ await expect.element(page.getByText('DRAWER CONTENT')).toBeInTheDocument()
+ // Declared `order` keeps the drawer pane first regardless of render order.
+ const panes = [...container.querySelectorAll('[data-pane]')]
+ expect(panes[0]?.textContent).toContain('DRAWER CONTENT')
+ expect(panes[1]?.textContent).toContain('MAIN CONTENT')
+ })
+})
diff --git a/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte b/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte
index 06b6ade767c..7c0c8b76983 100644
--- a/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte
+++ b/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte
@@ -63,7 +63,10 @@
{@render children?.()}
diff --git a/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte.spec.ts b/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte.spec.ts
new file mode 100644
index 00000000000..b3fa7f285cd
--- /dev/null
+++ b/js-packages/web-console/src/lib/components/layout/OverlayDrawer.svelte.spec.ts
@@ -0,0 +1,40 @@
+/**
+ * Component tests for the overlay drawer used on tablet/mobile. The drawer has
+ * a nominal width (e.g. `w-[700px]`) but must never grow wider than the screen,
+ * otherwise its contents scroll the page horizontally. These tests run at the
+ * browser's default narrow viewport, where the nominal width exceeds the screen.
+ */
+
+import { createRawSnippet } from 'svelte'
+import { describe, expect, it } from 'vitest'
+import { render } from 'vitest-browser-svelte'
+import OverlayDrawer from './OverlayDrawer.svelte'
+
+const textSnippet = (text: string) =>
+ createRawSnippet(() => ({ render: () => `
${text}
` }))
+
+const renderOverlay = () =>
+ render(OverlayDrawer, {
+ open: true,
+ side: 'right',
+ modal: true,
+ width: 'w-[700px]',
+ class: 'p-4',
+ children: textSnippet('DRAWER CONTENT')
+ })
+
+describe('OverlayDrawer', () => {
+ it('caps the drawer at the viewport width on narrow screens', async () => {
+ // Precondition: the nominal width is wider than the screen, so an
+ // uncapped drawer would overflow. Guard against a wide test viewport.
+ expect(window.innerWidth).toBeLessThan(700)
+
+ const { container } = renderOverlay()
+ const dialog = container.querySelector('[role="dialog"]') as HTMLElement
+ expect(dialog).not.toBeNull()
+
+ // The dialog shrinks to fit instead of forcing 700px.
+ // (Drop `max-w-full` from the dialog class to see this fail.)
+ expect(dialog.getBoundingClientRect().width).toBeLessThanOrEqual(window.innerWidth)
+ })
+})
diff --git a/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte b/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte
index c05940dd707..cd89e429947 100644
--- a/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte
+++ b/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte
@@ -345,7 +345,11 @@
{/if}
-
+
{#if pipelineBannerMessage}
@@ -353,7 +357,11 @@
{/if}
-
+
{#if pipeline.current}
-
-
pipelineStatus(type).severity}
- getBarColor={(type, h) => pipelineStatus(type).barColor(h)}
- getStatusStyle={(type) => pipelineStatus(type).statusStyle}
- selectedBars={selectedEventTimestamp
- ? { from: selectedEventTimestamp.from, to: selectedEventTimestamp.to }
- : null}
- >
-
- {#if !events && !deleted}
-
- {/if}
-
-
- pipelineStatus(type).iconClass}
- >
- {#snippet noIssues()}
- {#if events}
- The pipeline experienced no issues in the observed period.
- {/if}
- {/snippet}
-
-
-
-
+ {#snippet main()}
+
+
pipelineStatus(type).severity}
+ getBarColor={(type, h) => pipelineStatus(type).barColor(h)}
+ getStatusStyle={(type) => pipelineStatus(type).statusStyle}
+ selectedBars={selectedEventTimestamp
+ ? { from: selectedEventTimestamp.from, to: selectedEventTimestamp.to }
+ : null}
+ >
+
+ {#if !events && !deleted}
+
+ {/if}
+
+
+ pipelineStatus(type).iconClass}
+ >
+ {#snippet noIssues()}
+ {#if events}
+ The pipeline experienced no issues in the observed period.
+ {/if}
+ {/snippet}
+
+
+
+ {/snippet}
{#if selectedEvent}
Pipeline is running, but has not reported usage telemetry yet
{:else}