diff --git a/web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx b/web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx
index d7d0718ab014..e10f8032c68d 100644
--- a/web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx
+++ b/web/apps/labelstudio/src/pages/CreateProject/Config/Config.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from "react";
+import React, { useEffect, useMemo, useState, useRef } from "react";
import CM from "codemirror";
import { Button, cnm } from "@humansignal/ui";
import { IconTrash } from "@humansignal/icons";
@@ -18,6 +18,8 @@ import tags from "@humansignal/core/lib/utils/schema/tags.json";
import { UnsavedChanges } from "./UnsavedChanges";
import { Checkbox, CodeEditor, Select } from "@humansignal/ui";
import snakeCase from "lodash/snakeCase";
+import { useConfigResizer } from "./useConfigResizer";
+import { ConfigResizer } from "./ConfigResizer";
const wizardClass = cn("wizard");
const configClass = cn("configure");
@@ -352,6 +354,34 @@ const Configurator = ({
const [visualLoaded, loadVisual] = React.useState(configure === "visual");
const [waiting, setWaiting] = React.useState(false);
const [saved, setSaved] = React.useState(false);
+ const containerRef = useRef(null);
+ const [containerWidth, setContainerWidth] = useState(undefined);
+
+ // Resizer hook
+ const { editorWidthPixels, setEditorWidthPixels, constraints } = useConfigResizer({
+ projectId: project?.id,
+ containerWidth,
+ });
+
+ // Track container width for resizer constraints
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ const updateWidth = () => {
+ if (containerRef.current) {
+ setContainerWidth(containerRef.current.clientWidth);
+ }
+ };
+
+ const resizeObserver = new ResizeObserver(updateWidth);
+ resizeObserver.observe(containerRef.current);
+
+ updateWidth();
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, []);
// config update is debounced because of user input
const [configToCheck, setConfigToCheck] = React.useState();
@@ -485,95 +515,120 @@ const Configurator = ({
return (
-
-
Labeling Interface{hasChanges ? " *" : ""}
-
-
-
-
-
- {configure === "code" && (
-
- {
- if (e.code === "Escape") e.stopPropagation();
- }}
- onChange={(editor, data, value) => onChange(value)}
- />
-
- )}
- {visualLoaded && (
-
- {isEmptyConfig(config) && }
-
- {template.controls.map((control) => (
-
- ))}
-
-
- )}
-
- {disableSaveButton !== true && onSaveClick && (
-
- {saved && (
-
- Saved!
-
- )}
+
+
+
Labeling Interface{hasChanges ? " *" : ""}
+
- {isFF(FF_UNSAVED_CHANGES) && }
-
- )}
+
+
+
+ {configure === "code" && (
+
+ {
+ if (e.code === "Escape") e.stopPropagation();
+ }}
+ onChange={(_editor, _data, value) => onChange(value)}
+ />
+
+ )}
+ {visualLoaded && (
+
+ {isEmptyConfig(config) && }
+
+ {template.controls.map((control) => (
+
+ ))}
+
+
+ )}
+
+ {disableSaveButton !== true && onSaveClick && (
+
+ {saved && (
+
+
+ Saved!
+
+
+ )}
+
+ {isFF(FF_UNSAVED_CHANGES) && }
+
+ )}
+
+
-
);
};
@@ -629,7 +684,7 @@ export const ConfigPage = ({
if (externalColumns?.length) setColumns(externalColumns);
}, [externalColumns]);
- const [warning, setWarning] = React.useState();
+ const [warning, _setWarning] = React.useState();
React.useEffect(() => {
const fetchData = async () => {
@@ -644,8 +699,8 @@ export const ConfigPage = ({
setColumns(res.common_data_columns);
}
}
- fetchData();
};
+ fetchData();
}, [columns, project]);
const onSelectRecipe = React.useCallback((recipe) => {
diff --git a/web/apps/labelstudio/src/pages/CreateProject/Config/Config.scss b/web/apps/labelstudio/src/pages/CreateProject/Config/Config.scss
index 9b1e1d9e8158..82541a89b4e8 100644
--- a/web/apps/labelstudio/src/pages/CreateProject/Config/Config.scss
+++ b/web/apps/labelstudio/src/pages/CreateProject/Config/Config.scss
@@ -22,20 +22,26 @@ $scroll-width: 5px;
min-height: 0;
display: flex;
align-items: stretch;
+ position: relative;
>* {
flex: 50%;
+ min-width: 0;
}
}
.wizard .configure__container {
display: flex;
- flex-direction: column;
- padding: 16px 16px - $scroll-width 16px 20px;
+ flex-direction: row;
+ padding: 0;
overflow-y: scroll;
background-color: var(--color-neutral-background);
gap: var(--spacing-base);
+ & > div {
+ padding: 16px 16px - $scroll-width 16px 20px;
+ }
+
&::-webkit-scrollbar {
width: $scroll-width;
}
@@ -250,10 +256,11 @@ $scroll-width: 5px;
line-height: 1;
}
-.configure__container>header {
+.configure__container > div > header {
display: flex;
justify-content: flex-end;
align-items: center;
+ padding: var(--spacing-tight) 0;
}
.configure__editor {
@@ -272,7 +279,7 @@ $scroll-width: 5px;
}
}
-.configure__container>header .toggle-items {
+.configure__container > div > header .toggle-items {
margin-left: auto;
}
@@ -538,18 +545,17 @@ $scroll-width: 5px;
}
.wizard .configure__preview {
- background-color: var(--color-neutral-surface);
flex-grow: 10;
min-width: 500px;
- padding: 16px 16px 0;
+ padding: 0;
overflow-y: auto;
- border-left: 1px solid var(--color-neutral-border);
display: flex;
flex-direction: column;
color: var(--color-neutral-content);
+ height: 100%;
h3 {
- margin: 8px 0 16px;
+ padding: var(--spacing-tight) 0;
font-size: 16px;
color: var(--color-neutral-content);
}
@@ -563,6 +569,10 @@ $scroll-width: 5px;
&-ui {
flex: 1;
min-height: 0;
+ background-color: var(--color-neutral-surface);
+ border: 1px solid var(--color-neutral-border);
+ border-radius: var(--corner-radius-small);
+ padding: var(--spacing-base);
}
&-error {
diff --git a/web/apps/labelstudio/src/pages/CreateProject/Config/ConfigResizer.jsx b/web/apps/labelstudio/src/pages/CreateProject/Config/ConfigResizer.jsx
new file mode 100644
index 000000000000..6236bee48c6c
--- /dev/null
+++ b/web/apps/labelstudio/src/pages/CreateProject/Config/ConfigResizer.jsx
@@ -0,0 +1,70 @@
+import { useCallback, useRef, useState } from "react";
+import { cn } from "../../../utils/bem";
+import "./ConfigResizer.scss";
+
+const calculateEditorWidth = (initialWidth, initialX, currentX, minWidth, maxWidth) => {
+ // Calculate offset from initial position
+ // Dragging right (currentX > initialX) should increase editor width
+ const offset = currentX - initialX;
+ const newWidth = initialWidth + offset;
+ return Math.max(minWidth, Math.min(maxWidth, newWidth));
+};
+
+export const ConfigResizer = ({ containerRef, editorWidthPixels, onResize, onResizeFinished, constraints }) => {
+ const [isResizing, setIsResizing] = useState(false);
+ const handleRef = useRef(null);
+
+ const handleMouseDown = useCallback(
+ (evt) => {
+ evt.stopPropagation();
+ evt.preventDefault();
+
+ const container = containerRef.current;
+ if (!container) return;
+
+ const initialX = evt.pageX;
+ const initialWidth = editorWidthPixels;
+ let newWidth = editorWidthPixels;
+
+ const onMouseMove = (e) => {
+ newWidth = calculateEditorWidth(
+ initialWidth,
+ initialX,
+ e.pageX,
+ constraints.minEditorWidth,
+ constraints.maxEditorWidth,
+ );
+
+ onResize(newWidth);
+ };
+
+ const onMouseUp = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ document.body.style.removeProperty("user-select");
+ document.body.style.removeProperty("cursor");
+
+ setIsResizing(false);
+
+ if (newWidth !== editorWidthPixels && onResizeFinished) {
+ onResizeFinished(newWidth);
+ }
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ document.body.style.userSelect = "none";
+ document.body.style.cursor = "col-resize";
+ setIsResizing(true);
+ },
+ [containerRef, editorWidthPixels, onResize, onResizeFinished, constraints],
+ );
+
+ return (
+
+ );
+};
diff --git a/web/apps/labelstudio/src/pages/CreateProject/Config/ConfigResizer.scss b/web/apps/labelstudio/src/pages/CreateProject/Config/ConfigResizer.scss
new file mode 100644
index 000000000000..aa323e959203
--- /dev/null
+++ b/web/apps/labelstudio/src/pages/CreateProject/Config/ConfigResizer.scss
@@ -0,0 +1,24 @@
+.config-resizer {
+ &__handle {
+ --handle-size: 2px;
+ --handle-size-hover: 3px;
+
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: var(--handle-size);
+ cursor: col-resize;
+ z-index: 100;
+ background: var(--color-neutral-border);
+ transform: translateX(-50%);
+ transition: width 0.15s ease, background-color 0.15s ease;
+
+ &:hover,
+ &_resizing {
+ width: var(--handle-size-hover);
+ background-color: var(--color-primary-border-subtle);
+ }
+ }
+}
+
diff --git a/web/apps/labelstudio/src/pages/CreateProject/Config/useConfigResizer.js b/web/apps/labelstudio/src/pages/CreateProject/Config/useConfigResizer.js
new file mode 100644
index 000000000000..6041393004c1
--- /dev/null
+++ b/web/apps/labelstudio/src/pages/CreateProject/Config/useConfigResizer.js
@@ -0,0 +1,167 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+// Gap between columns (1rem / --spacing-base = 16px)
+const COLUMN_GAP = 16;
+
+// Minimum widths in pixels
+const MIN_EDITOR_WIDTH = 400;
+const MIN_PREVIEW_WIDTH = 500;
+
+// Default editor width (left side)
+const DEFAULT_EDITOR_WIDTH = 500;
+
+export const useConfigResizer = ({ projectId, containerWidth }) => {
+ // Generate storage key based on project ID
+ // This allows separate storage for different projects
+ const storageKey = useMemo(
+ () => (projectId ? `config-editor-width:${projectId}` : "config-editor-width"),
+ [projectId],
+ );
+
+ // Initialize state from localStorage or use default
+ // This runs once on mount and handles initial localStorage read
+ const [editorWidthPixels, setEditorWidthPixelsInternal] = useState(() => {
+ try {
+ const item = window.localStorage.getItem(storageKey);
+ if (item) {
+ return JSON.parse(item);
+ }
+ } catch {
+ // If error reading from localStorage, use default
+ }
+ return DEFAULT_EDITOR_WIDTH;
+ });
+
+ // Track previous storage key to detect key changes (project switches)
+ const prevStorageKeyRef = useRef(storageKey);
+ // Track if we just loaded a value from localStorage (to prevent clamping from resetting it)
+ const justLoadedFromStorageRef = useRef(false);
+
+ // Single effect that handles both:
+ // 1. Reading from localStorage when storage key changes (project switch)
+ // 2. Writing to localStorage when the value changes
+ useEffect(() => {
+ const currentKey = storageKey;
+ const prevKey = prevStorageKeyRef.current;
+
+ // If storage key changed, reload value from localStorage for the new key
+ if (prevKey !== currentKey) {
+ justLoadedFromStorageRef.current = true;
+ try {
+ const item = window.localStorage.getItem(currentKey);
+ if (item) {
+ const parsedValue = JSON.parse(item);
+ setEditorWidthPixelsInternal(parsedValue);
+ } else {
+ // No stored value for this key, use default
+ setEditorWidthPixelsInternal(DEFAULT_EDITOR_WIDTH);
+ }
+ } catch {
+ // Error reading, use default
+ setEditorWidthPixelsInternal(DEFAULT_EDITOR_WIDTH);
+ }
+ prevStorageKeyRef.current = currentKey;
+ } else {
+ // Key hasn't changed, just write current value to localStorage
+ try {
+ window.localStorage.setItem(currentKey, JSON.stringify(editorWidthPixels));
+ } catch {
+ // Ignore write errors (e.g., quota exceeded)
+ }
+ }
+ }, [storageKey, editorWidthPixels]);
+
+ // Calculate min/max constraints based on container width
+ const constraints = useMemo(() => {
+ if (!containerWidth) {
+ return {
+ minEditorWidth: DEFAULT_EDITOR_WIDTH,
+ maxEditorWidth: DEFAULT_EDITOR_WIDTH * 2,
+ };
+ }
+
+ // Minimum editor width
+ const minEditorWidth = MIN_EDITOR_WIDTH;
+
+ // Maximum editor width ensures preview column has minimum width
+ const maxEditorWidth = containerWidth - MIN_PREVIEW_WIDTH - COLUMN_GAP;
+
+ return {
+ minEditorWidth,
+ maxEditorWidth: Math.max(minEditorWidth, maxEditorWidth),
+ };
+ }, [containerWidth]);
+
+ // Track previous container width to only clamp when container actually resizes
+ const prevContainerWidthRef = useRef(containerWidth);
+
+ // Clamp width when container resizes (not when project switches)
+ // This ensures the editor width stays within valid bounds when container size changes
+ useEffect(() => {
+ if (!constraints.minEditorWidth || !constraints.maxEditorWidth) {
+ prevContainerWidthRef.current = containerWidth;
+ return;
+ }
+
+ // Don't clamp if we just loaded a value from localStorage (project switch)
+ // Reset the flag after checking it
+ if (justLoadedFromStorageRef.current) {
+ justLoadedFromStorageRef.current = false;
+ prevContainerWidthRef.current = containerWidth;
+ return;
+ }
+
+ // Only clamp if container width actually changed
+ const containerWidthChanged =
+ prevContainerWidthRef.current !== undefined && prevContainerWidthRef.current !== containerWidth;
+
+ if (containerWidthChanged) {
+ // Check if current width is out of bounds and clamp if needed
+ const clampedWidth = Math.max(
+ constraints.minEditorWidth,
+ Math.min(constraints.maxEditorWidth, editorWidthPixels),
+ );
+
+ // Only update if clamping is needed
+ if (clampedWidth !== editorWidthPixels) {
+ setEditorWidthPixelsInternal(clampedWidth);
+ }
+ }
+
+ prevContainerWidthRef.current = containerWidth;
+ }, [containerWidth, constraints.minEditorWidth, constraints.maxEditorWidth, editorWidthPixels]);
+
+ // Wrapped setter that automatically clamps values to valid constraints
+ // This ensures all width updates respect min/max bounds
+ const setEditorWidthPixels = useCallback(
+ (value) => {
+ setEditorWidthPixelsInternal((prev) => {
+ const newValue = typeof value === "function" ? value(prev) : value;
+
+ // Clamp to constraints if available
+ if (constraints.minEditorWidth !== undefined && constraints.maxEditorWidth !== undefined) {
+ return Math.max(constraints.minEditorWidth, Math.min(constraints.maxEditorWidth, newValue));
+ }
+
+ return newValue;
+ });
+ },
+ [constraints.minEditorWidth, constraints.maxEditorWidth],
+ );
+
+ // Calculate preview width from editor width
+ const previewWidthPixels = useMemo(() => {
+ if (!containerWidth) return 0;
+ return Math.max(MIN_PREVIEW_WIDTH, containerWidth - editorWidthPixels - COLUMN_GAP);
+ }, [containerWidth, editorWidthPixels]);
+
+ return {
+ editorWidthPixels,
+ setEditorWidthPixels,
+ previewWidthPixels,
+ constraints: {
+ minEditorWidth: constraints.minEditorWidth,
+ maxEditorWidth: constraints.maxEditorWidth,
+ },
+ };
+};