From 041ba8315af4a2bd924393198f9f3fa5ef46cb34 Mon Sep 17 00:00:00 2001 From: joaner <1726541+joaner@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:36:41 +0900 Subject: [PATCH 01/10] fix(deps): bump source-map-js to 1.2.2 (GHSA-68fv-2mgg-jv7q) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 79d9505..2fad425 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5757,9 +5757,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { From 778854cf05a3e0a4b4865babec1f132fb9411f3e Mon Sep 17 00:00:00 2001 From: joaner <1726541+joaner@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:36:41 +0900 Subject: [PATCH 02/10] feat(viewer): fill the host container and stack the sidebar below the panels in narrow containers --- src/features/viewer/RosViewContent.tsx | 231 +++++++++++------- src/features/viewer/useStackedLayout.test.tsx | 94 +++++++ src/features/viewer/useStackedLayout.ts | 43 ++++ src/features/workspace/sidebar/Sidebar.tsx | 57 ++++- src/index.css | 4 + 5 files changed, 335 insertions(+), 94 deletions(-) create mode 100644 src/features/viewer/useStackedLayout.test.tsx create mode 100644 src/features/viewer/useStackedLayout.ts diff --git a/src/features/viewer/RosViewContent.tsx b/src/features/viewer/RosViewContent.tsx index 3e0e291..4d39ced 100644 --- a/src/features/viewer/RosViewContent.tsx +++ b/src/features/viewer/RosViewContent.tsx @@ -21,6 +21,8 @@ import { useMessagePipelineStore } from '@/core/pipeline/store'; import type { SampleDataset } from '@/services/sampleDatasets'; import type { DatasetHistoryListItem } from '@/shared/utils/datasetHistory'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/shared/ui/resizable'; +import { useGroupRef } from 'react-resizable-panels'; +import { useIsomorphicLayoutEffect, useStackedLayout } from './useStackedLayout'; import { Skeleton } from '@/shared/ui/skeleton'; import type { RosViewExtension } from '@/core/extensions/types'; import { buildExtensionContext } from '@/core/extensions/buildContext'; @@ -35,6 +37,9 @@ import { getInitialSidebarPanelPercent, } from '@/features/layout/sidebarPanelSize'; +/** Sidebar share of the stacked (vertical) split, in percent. */ +const STACKED_SIDEBAR_DEFAULT_PERCENT = 32; + interface RosViewContentProps { player: Player; loadingSourceName?: string; @@ -128,6 +133,29 @@ export const RosViewContent: React.FC = ({ return readPreferences()?.autoDataQualityScan === true; }); const [isTopicDragOver, setIsTopicDragOver] = useState(false); + const contentRef = useRef(null); + const groupRef = useGroupRef(); + + const stacked = useStackedLayout(contentRef, showWelcomeFallback); + + // One panel group for both layouts: switching orientation must not remount + // the panels or the sidebar (Dockview layout, scroll and search state). + const sidebarSizePercent = stacked ? STACKED_SIDEBAR_DEFAULT_PERCENT : sidebarPanelPercent; + const lastStackedRef = useRef(stacked); + useIsomorphicLayoutEffect(() => { + if (lastStackedRef.current === stacked) { + return; + } + lastStackedRef.current = stacked; + if (!showSidebar) { + return; + } + try { + groupRef.current?.setLayout({ sidebar: sidebarSizePercent, main: 100 - sidebarSizePercent }); + } catch { + // The group re-reads defaults on its next layout pass. + } + }, [stacked, showSidebar, sidebarSizePercent, groupRef]); const extensionContext = useMemo( () => buildExtensionContext({ @@ -252,6 +280,73 @@ export const RosViewContent: React.FC = ({ setSidebarPanelPercent(clampSidebarPanelPercent(nextSidebarPercent)); }, []); + const sidebarElement = ( + + ); + + const mainElement = ( +
+ {isTopicDragOver && ( +
+
+
+ {formatMessage({ id: 'sidebar.topicDropTitle' })} +
+
+ {formatMessage({ id: 'sidebar.topicDropSubtitle' })} +
+
+
+ )} + {isReady ? ( + + ) : ( +
+ + +
+ )} + {!isReady ? ( + + ) : ( + + )} +
+ ); + return (
{showWelcomeFallback ? ( @@ -269,7 +364,11 @@ export const RosViewContent: React.FC = ({ />
) : ( -
+
= ({ }} /> - {showSidebar && ( - <> - - - - - - )} - -
- {isTopicDragOver && ( -
-
-
- {formatMessage({ id: 'sidebar.topicDropTitle' })} -
-
- {formatMessage({ id: 'sidebar.topicDropSubtitle' })} -
-
-
- )} - {isReady ? ( - - ) : ( -
- - -
- )} - {!isReady ? ( - - ) : ( - - )} -
-
+ {/* + * Keyed children keep their state while React reorders them: the + * sidebar leads the split layout and follows the panels when stacked, + * so DOM (and focus) order always matches what is on screen. + */} + {(stacked ? ['main', 'handle', 'sidebar'] : ['sidebar', 'handle', 'main']) + .filter((slot) => showSidebar || slot === 'main') + .map((slot) => { + if (slot === 'sidebar') { + return ( + + {sidebarElement} + + ); + } + if (slot === 'handle') { + return ( + + ); + } + return ( + + {mainElement} + + ); + })}
)} diff --git a/src/features/viewer/useStackedLayout.test.tsx b/src/features/viewer/useStackedLayout.test.tsx new file mode 100644 index 0000000..2f22f12 --- /dev/null +++ b/src/features/viewer/useStackedLayout.test.tsx @@ -0,0 +1,94 @@ +/** + * @vitest-environment happy-dom + */ +import { act, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isStackedLayoutWidth, STACKED_LAYOUT_MAX_WIDTH_PX, useStackedLayout } from './useStackedLayout'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe('useStackedLayout', () => { + let container: HTMLDivElement; + let root: Root; + let resizeCallback: ResizeObserverCallback | undefined; + let observed: Element | undefined; + const disconnect = vi.fn(); + let initialWidth = 0; + + beforeEach(() => { + resizeCallback = undefined; + observed = undefined; + disconnect.mockReset(); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(cb: ResizeObserverCallback) { + resizeCallback = cb; + } + observe(el: Element) { + observed = el; + } + unobserve() {} + disconnect() { + disconnect(); + } + }, + ); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + () => ({ width: initialWidth, height: 600, x: 0, y: 0, top: 0, left: 0, right: initialWidth, bottom: 600, toJSON() {} }), + ); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + function Probe() { + const ref = useRef(null); + const stacked = useStackedLayout(ref); + return
; + } + + const layout = () => container.querySelector('[data-layout]')?.getAttribute('data-layout'); + const resizeTo = (width: number) => + act(() => { + resizeCallback?.([{ contentRect: { width } } as ResizeObserverEntry], {} as ResizeObserver); + }); + + it('decides the layout from the first measurement', () => { + initialWidth = 390; + act(() => root.render()); + expect(layout()).toBe('stacked'); + }); + + it('follows the container across the 640px breakpoint and observes the container', () => { + initialWidth = 1175; + act(() => root.render()); + expect(layout()).toBe('split'); + expect(observed).toBe(container.firstElementChild); + resizeTo(500); + expect(layout()).toBe('stacked'); + resizeTo(STACKED_LAYOUT_MAX_WIDTH_PX); + expect(layout()).toBe('split'); + }); + + it('disconnects the observer on unmount', () => { + initialWidth = 900; + act(() => root.render()); + act(() => root.render(<>)); + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it('keeps the split layout before the container has a width', () => { + expect(isStackedLayoutWidth(0)).toBe(false); + expect(isStackedLayoutWidth(Number.NaN)).toBe(false); + expect(isStackedLayoutWidth(639)).toBe(true); + }); +}); diff --git a/src/features/viewer/useStackedLayout.ts b/src/features/viewer/useStackedLayout.ts new file mode 100644 index 0000000..b1bcbc6 --- /dev/null +++ b/src/features/viewer/useStackedLayout.ts @@ -0,0 +1,43 @@ +import { useEffect, useLayoutEffect, useState, type RefObject } from 'react'; + +/** Container width (px) below which the sidebar stacks under the panels. */ +export const STACKED_LAYOUT_MAX_WIDTH_PX = 640; + +export function isStackedLayoutWidth(width: number): boolean { + return Number.isFinite(width) && width > 0 && width < STACKED_LAYOUT_MAX_WIDTH_PX; +} + +export const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; + +/** + * Narrow hosts (phones, split panes) stack the sidebar under the panels so the + * panels and the playback bar keep the full width. Driven by the container + * width, not the window, because the viewer is usually embedded. Measured in + * a layout effect so the first paint already has the right layout. + * + * @param remeasureKey change it when the observed element is replaced + */ +export function useStackedLayout(ref: RefObject, remeasureKey?: unknown): boolean { + const [stacked, setStacked] = useState(false); + useIsomorphicLayoutEffect(() => { + const el = ref.current; + if (!el) { + return; + } + setStacked(isStackedLayoutWidth(el.getBoundingClientRect().width)); + if (typeof ResizeObserver === 'undefined') { + return; + } + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + setStacked(isStackedLayoutWidth(entry.contentRect.width)); + } + }); + observer.observe(el); + return () => { + observer.disconnect(); + }; + }, [ref, remeasureKey]); + return stacked; +} diff --git a/src/features/workspace/sidebar/Sidebar.tsx b/src/features/workspace/sidebar/Sidebar.tsx index db44454..33c7a16 100644 --- a/src/features/workspace/sidebar/Sidebar.tsx +++ b/src/features/workspace/sidebar/Sidebar.tsx @@ -21,8 +21,17 @@ import { groupDatasets } from '@/shared/utils/datasetSources'; import { formatBytes } from '@/shared/utils/formatBytes'; import { cn } from '@/shared/lib/utils'; +/** + * Tabs grow to share the row but never shrink below their label, so a narrow + * sidebar or an extension tab never clips a label ("Annotat…"). The tab list + * scrolls horizontally instead; in the compact sidebar built-in tabs drop to + * icon-only (see `.sidebar-tab-label` in `src/index.css`). + */ const SIDEBAR_TAB_TRIGGER_CLASS = - 'flex flex-1 items-center justify-center gap-1 border-b-2 border-transparent px-2 py-2 text-xs font-medium transition-colors hover:bg-accent/70 data-[state=active]:border-primary data-[state=active]:bg-background data-[state=active]:text-primary'; + 'flex flex-[1_0_auto] items-center justify-center gap-1 whitespace-nowrap border-b-2 border-transparent px-2 py-2 text-xs font-medium transition-colors hover:bg-accent/70 data-[state=active]:border-primary data-[state=active]:bg-background data-[state=active]:text-primary'; + +const SIDEBAR_TAB_LIST_CLASS = + 'flex shrink-0 overflow-x-auto overflow-y-hidden border-b border-border/60 bg-muted/20 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'; const TOPIC_LIST_SKELETON_ROWS = 8; @@ -50,6 +59,8 @@ interface SidebarProps { extensions?: RosViewExtension[]; /** Tab id to select once on mount when listed in extension or built-in tabs. */ initialSidebarTab?: string; + /** True when the sidebar sits under the panels (narrow container). */ + stacked?: boolean; } export const Sidebar: React.FC = ({ @@ -63,6 +74,7 @@ export const Sidebar: React.FC = ({ extensionContext, extensions = [], initialSidebarTab, + stacked = false, }) => { const { formatMessage } = useIntl(); const topics = useMessagePipeline((state: MessagePipelineState) => state.sortedTopics); @@ -146,7 +158,10 @@ export const Sidebar: React.FC = ({ return (