diff --git a/.github/workflows/zendesk_issue_commented.yml b/.github/workflows/zendesk_issue_commented.yml deleted file mode 100644 index 539d125b0c04..000000000000 --- a/.github/workflows/zendesk_issue_commented.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: "ZenDesk: Push an issue comment to zendesk ticket" - -on: - issue_comment: - types: - - created - -jobs: - issue_commented: - name: Issue comment - if: ${{ !github.event.issue.pull_request && github.event.comment.user.login != 'heidi-humansignal' }} - runs-on: ubuntu-latest - steps: - - uses: hmarr/debug-action@v3.0.0 - - - env: - ZENDESK_HOST: ${{ vars.ZENDESK_HOST }} - ZENDESK_USER: ${{ vars.ZENDESK_USER }} - ZENDESK_TOKEN: ${{ secrets.ZENDESK_TOKEN }} - ISSUE_URL: ${{ github.event.issue.html_url }} - ISSUE_COMMENT_BODY: ${{ github.event.comment.body }} - ISSUE_USER: ${{ github.event.comment.user.login }} - WORKFLOW_RUN_LINK: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - run: | - echo "Looking up ticket by issue: ${ISSUE_URL}" - tickets=$(curl "https://${ZENDESK_HOST}/api/v2/search.json?query=external_id:${ISSUE_URL}" \ - --user "${ZENDESK_USER}/token:${ZENDESK_TOKEN}" \ - -H "Content-Type: application/json") - ticket_id=$(echo $tickets | jq '.results[0].id') - echo "Found Zendesk ticket ${ticket_id}" - - echo "Looking up user by issuer: ${ISSUE_USER}" - users=$(curl "https://labelstudio.zendesk.com/api/v2/users/search.json?query=$ISSUE_USER@users.noreply.github.com" \ - --user "${ZENDESK_USER}/token:${ZENDESK_TOKEN}" \ - --header "Content-Type: application/json") - user_id=$(echo $users | jq '.users[0].id') - if [[ "$user_id" == "null" ]]; then - echo "Fall back to generic github user" - user_id="388861316959" - else - echo "Found user ${user_id}" - fi - - body=$(jq -n --arg body "$ISSUE_COMMENT_BODY" '{body: $body}' | jq .body) - echo "$body" - - curl "https://${ZENDESK_HOST}/api/v2/tickets/${ticket_id}.json" \ - --request PUT \ - --user "${ZENDESK_USER}/token:${ZENDESK_TOKEN}" \ - --header "Content-Type: application/json" \ - --data-binary @- < Comment by ${comment_author} - > [Workflow Run](${process.env.WORKFLOW_RUN_LINK})`; - - // Add a comment to the GitHub issue - if (comment_body.startsWith('[GITHUB_ISSUE_')) { - core.notice(`Skipping comment creation.`); - } else { - const { data: comment } = await github.rest.issues.createComment({ - owner: issue_owner, - repo: issue_repo, - issue_number: issue_number, - body: formatted_comment_body - }); - core.notice(`Comment created ${comment.html_url}`); - } - - // Extract labels from the custom_field - let new_labels = []; - if (context.payload.inputs.custom_field) { - new_labels = context.payload.inputs.custom_field.split(" ").map(label => label.trim()); - } - - // Get the current labels on the GitHub issue - const { data: current_labels } = await github.rest.issues.listLabelsOnIssue({ - owner: issue_owner, - repo: issue_repo, - issue_number: issue_number - }); - - const current_label_names = current_labels.map(label => label.name); - - // Labels to be added - const labels_to_add = new_labels.filter(label => !current_label_names.includes(label)); - - // Labels to be removed - const labels_to_remove = current_label_names.filter(label => !new_labels.includes(label)); - - // Remove labels that are not in the new labels list - for (const label of labels_to_remove) { - await github.rest.issues.removeLabel({ - owner: issue_owner, - repo: issue_repo, - issue_number: issue_number, - name: label - }); - } - - // Add the new labels - if (labels_to_add.length > 0) { - await github.rest.issues.addLabels({ - owner: issue_owner, - repo: issue_repo, - issue_number: issue_number, - labels: labels_to_add - }); - } diff --git a/.github/workflows/zendesk_task_solve.yml b/.github/workflows/zendesk_task_solve.yml deleted file mode 100644 index 47b180849f78..000000000000 --- a/.github/workflows/zendesk_task_solve.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: "ZenDesk: Close GitHub Issue on Zendesk Ticket Solved" - -on: - workflow_dispatch: - inputs: - external_id: - description: "GitHub issue url" - required: true - type: string - -jobs: - close_issue: - runs-on: ubuntu-latest - steps: - - uses: hmarr/debug-action@v3.0.0 - - - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GIT_PAT_HEIDI }} - script: | - // Extract issue details from the Zendesk external_id - const parts = context.payload.inputs.external_id.split("/"); - const issue_number = parts[parts.length - 1]; - const issue_repo = parts[parts.length - 3]; - const issue_owner = parts[parts.length - 4]; - - // Close the GitHub issue - const { data: issue } await github.rest.issues.update({ - owner: issue_owner, - repo: issue_repo, - issue_number: issue_number, - state: "closed" - }); - - core.info(`GitHub issue ${issue.html_url} closed successfully.`); diff --git a/Dockerfile.development b/Dockerfile.development index 9353d776102e..f5eb44fad39b 100644 --- a/Dockerfile.development +++ b/Dockerfile.development @@ -19,9 +19,34 @@ ARG BRANCH_OVERRIDE FROM --platform=${BUILDPLATFORM} node:${NODE_VERSION} AS frontend-builder WORKDIR /label-studio/web +# Install frontend deps and build production assets so /web/dist exists for the final image +COPY web/package.json web/yarn.lock ./ +RUN corepack enable && yarn install --frozen-lockfile + +COPY web . +COPY pyproject.toml ../pyproject.toml + +RUN yarn build + +################################ Stage: frontend-dev (hot reload frontend) +FROM --platform=${BUILDPLATFORM} node:${NODE_VERSION} AS frontend-dev +WORKDIR /label-studio/web + +# Enable file watching inside Docker on Windows/macOS +ENV HOST=0.0.0.0 \ + PORT=3000 \ + CHOKIDAR_USEPOLLING=1 \ + WATCHPACK_POLLING=true + +COPY web/package.json web/yarn.lock ./ +RUN corepack enable && yarn install + COPY web . COPY pyproject.toml ../pyproject.toml +EXPOSE 3000 +CMD ["yarn", "dev", "--host", "0.0.0.0", "--port", "3000"] + ################################ Stage: venv-builder (prepare the virtualenv) FROM python:${PYTHON_VERSION}-slim AS venv-builder ARG POETRY_VERSION @@ -69,6 +94,22 @@ RUN --mount=type=cache,target=$POETRY_CACHE_DIR,sharing=locked \ poetry install --only-root --extras uwsgi && \ python3 label_studio/manage.py collectstatic --no-input +################################ Stage: backend-dev (hot reload backend) +FROM venv-builder AS backend-dev + +ENV DJANGO_SETTINGS_MODULE=core.settings.label_studio \ + DEBUG=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +# Add file watcher for reliable reloads inside Docker +RUN --mount=type=cache,target=$POETRY_CACHE_DIR,sharing=locked \ + poetry run python -m pip install --no-cache-dir watchdog + +EXPOSE 8080 +# Run migrations automatically before starting the dev server +CMD ["sh", "-c", "poetry run python label_studio/manage.py migrate && poetry run python label_studio/manage.py runserver 0.0.0.0:8080"] + ################################ Stage: py-version-generator FROM venv-builder AS py-version-generator ARG VERSION_OVERRIDE diff --git a/deploy/docker-entrypoint.d/app-docker/05-check-data-permissions.sh b/deploy/docker-entrypoint.d/app-docker/05-check-data-permissions.sh index 48afc64e94bb..c1cce0dc449a 120000 --- a/deploy/docker-entrypoint.d/app-docker/05-check-data-permissions.sh +++ b/deploy/docker-entrypoint.d/app-docker/05-check-data-permissions.sh @@ -1 +1,5 @@ -../common/05-check-data-permissions.sh \ No newline at end of file +#!/bin/bash + +# Delegate to shared data-permissions check script +SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) +exec "$SCRIPT_DIR/../common/05-check-data-permissions.sh" \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 000000000000..9d13d3f47041 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,39 @@ +services: + backend: + build: + context: . + dockerfile: Dockerfile.development + target: backend-dev + args: + - INCLUDE_DEV=true + env_file: + - .env + environment: + - WATCHFILES_FORCE_POLLING=true + command: sh -c "poetry run python label_studio/manage.py migrate && poetry run python label_studio/manage.py runserver 0.0.0.0:8080" + volumes: + - ./label_studio:/label-studio/label_studio + - ./deploy:/label-studio/deploy + ports: + - "8080:8080" + + frontend: + build: + context: . + dockerfile: Dockerfile.development + target: frontend-dev + args: + - INCLUDE_DEV=true + environment: + - CHOKIDAR_USEPOLLING=1 + - WATCHPACK_POLLING=true + - NX_DAEMON=false + - FRONTEND_HMR=true + - FRONTEND_HOSTNAME=http://localhost:3000 + - DJANGO_HOSTNAME=http://backend:8080 + command: yarn dev --host 0.0.0.0 --port 3000 + volumes: + - ./web:/label-studio/web + - /label-studio/web/node_modules + ports: + - "3000:3000" diff --git a/docker-compose.override.example.yml b/docker-compose.override.example.yml deleted file mode 100644 index 29c70153cf6a..000000000000 --- a/docker-compose.override.example.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: "3.9" -services: - app: - build: - args: - - INCLUDE_DEV=true - env_file: - - .env - - nginx: - build: - args: - - INCLUDE_DEV=true \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore index 82545c86fbd7..213e8c1e2b61 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -36,6 +36,9 @@ testem.log /typings .nx/ migrations.json +.claude +.claude/ +/.claude # System Files .DS_Store diff --git a/web/apps/labelstudio/src/components/HeidiTips/liveContent.json b/web/apps/labelstudio/src/components/HeidiTips/liveContent.json index bea4adaa728c..d7dd6e05868a 100644 --- a/web/apps/labelstudio/src/components/HeidiTips/liveContent.json +++ b/web/apps/labelstudio/src/components/HeidiTips/liveContent.json @@ -237,7 +237,7 @@ } }, { - "title": "Behind the benchmark", + "title": "Behind the TestMark", "description": "Learn how Legalbenchmarks.ai built and scaled a benchmark for practical contract drafting tasks using LLM-as-a-judge and human review in Label Studio Enterprise.", "link": { "label": "Learn more", @@ -285,4 +285,4 @@ } } ] -} +} \ No newline at end of file diff --git a/web/apps/labelstudio/src/components/Menubar/MenuSidebar.scss b/web/apps/labelstudio/src/components/Menubar/MenuSidebar.scss index 6ecc7dcb6c81..fdca8e917393 100644 --- a/web/apps/labelstudio/src/components/Menubar/MenuSidebar.scss +++ b/web/apps/labelstudio/src/components/Menubar/MenuSidebar.scss @@ -64,4 +64,33 @@ opacity: 1; transform: rotate(-45deg); } + + &__resize-handle { + position: fixed; + top: var(--header-height); + left: var(--menu-sidebar-width); + width: 6px; + height: calc(100vh - var(--header-height)); + cursor: col-resize; + z-index: 101; + user-select: none; + transform: translateX(-50%); + + &::after { + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 100%; + background: var(--color-primary-border); + opacity: 0.3; + transition: opacity 150ms ease; + } + + &:hover::after { + opacity: 1; + } + } } \ No newline at end of file diff --git a/web/apps/labelstudio/src/pages/Home/HomePage.tsx b/web/apps/labelstudio/src/pages/Home/HomePage.tsx index 7ab42abc1ab3..244ae821aaf7 100644 --- a/web/apps/labelstudio/src/pages/Home/HomePage.tsx +++ b/web/apps/labelstudio/src/pages/Home/HomePage.tsx @@ -139,7 +139,7 @@ export const HomePage: Page = () => { Welcome 👋 - Let's get you started. + Hot reload test: tweak text and save to see live refresh.
diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.scss b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.scss index 0d601ca25c24..7bda0e44fadd 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.scss +++ b/web/libs/editor/src/components/SidePanels/TabPanels/PanelTabsBase.scss @@ -234,7 +234,7 @@ top: calc(var(--size) / 2); width: var(--size); height: calc(100% - var(--size)); - cursor: ew-resize; + cursor: col-resize; } &[data-resize="left"] { @@ -327,11 +327,14 @@ transform: translate(-50%, 0); height: calc(100% + var(--size)); top: calc(var(--size) / 2 * -1); + display: block; + opacity: 0.3; } &:hover::before, &_drag::before { display: block; + opacity: 1; } } diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx b/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx index 7a0642fb0b8a..bad80abc7d05 100644 --- a/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx +++ b/web/libs/editor/src/components/SidePanels/TabPanels/SideTabsPanels.tsx @@ -8,6 +8,7 @@ import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_MAX_HEIGHT, DEFAULT_PANEL_MAX_WIDTH, + DEFAULT_PANEL_MIN_WIDTH, DEFAULT_PANEL_WIDTH, PANEL_HEADER_HEIGHT, } from "../constants"; @@ -315,7 +316,7 @@ const SideTabsPanelsComponent: FC = ({ storedLeft: undefined, storedTop: undefined, maxHeight, - width: clamp(w, DEFAULT_PANEL_WIDTH, panelMaxWidth), + width: clamp(w, DEFAULT_PANEL_MIN_WIDTH, panelMaxWidth), height: panelData[panelKey].detached ? clamp(h, DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_MAX_HEIGHT) : panelData[panelKey].height, @@ -504,14 +505,14 @@ const SideTabsPanelsComponent: FC = ({ viewportSize.current.width = clientWidth ?? 0; viewportSize.current.height = clientHeight ?? 0; setViewportSizeMatch(checkContentFit()); - setPanelMaxWidth(rootRef.current.clientWidth * 0.4); + setPanelMaxWidth(rootRef.current.clientWidth * 0.6); }); }); if (root) { observer.observe(root); setViewportSizeMatch(checkContentFit()); - setPanelMaxWidth(root.clientWidth * 0.4); + setPanelMaxWidth(root.clientWidth * 0.6); setInitialized(true); } diff --git a/web/libs/editor/src/components/SidePanels/TabPanels/__tests__/sidebar-resize.test.ts b/web/libs/editor/src/components/SidePanels/TabPanels/__tests__/sidebar-resize.test.ts new file mode 100644 index 000000000000..d640fbce3431 --- /dev/null +++ b/web/libs/editor/src/components/SidePanels/TabPanels/__tests__/sidebar-resize.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for the resizable sidebar panel feature. + * + * These tests guard against regressions that would break the ability to + * resize the side panels (left/right) in the labeling interface. + */ +import { + DEFAULT_PANEL_HEIGHT, + DEFAULT_PANEL_MAX_WIDTH, + DEFAULT_PANEL_MIN_HEIGHT, + DEFAULT_PANEL_MIN_WIDTH, + DEFAULT_PANEL_WIDTH, +} from "../../constants"; +import { resizePanelColumns, resizers } from "../utils"; +import { type PanelBBox, Side } from "../types"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makePanel = (overrides: Partial = {}): PanelBBox => ({ + top: 0, + left: 0, + order: 0, + relativeLeft: 0, + relativeTop: 0, + zIndex: 1, + width: DEFAULT_PANEL_WIDTH, + height: DEFAULT_PANEL_HEIGHT, + visible: true, + detached: false, + alignment: Side.right, + maxHeight: 1000, + panelViews: [], + ...overrides, +}); + +/** + * Pure reimplementation of the resizer-visibility rule from PanelTabsBase.tsx + * so we can unit-test it without rendering React. + * + * Source: PanelTabsBase.tsx — the `shouldRender` expression inside + * the {resizers.map(…)} block. + */ +const shouldRenderResizer = ( + res: string, + alignment: string, + collapsed: boolean, + detached: boolean, +): boolean => (collapsed ? false : ((res === "left" || res === "right") && alignment !== res) || detached); + +// --------------------------------------------------------------------------- +// 1. Constants +// --------------------------------------------------------------------------- + +describe("Sidebar resize constants", () => { + it("DEFAULT_PANEL_MIN_WIDTH is 180", () => { + expect(DEFAULT_PANEL_MIN_WIDTH).toBe(180); + }); + + it("DEFAULT_PANEL_WIDTH is 320", () => { + expect(DEFAULT_PANEL_WIDTH).toBe(320); + }); + + it("DEFAULT_PANEL_MAX_WIDTH is 500", () => { + expect(DEFAULT_PANEL_MAX_WIDTH).toBe(500); + }); + + it("DEFAULT_PANEL_MIN_WIDTH < DEFAULT_PANEL_WIDTH < DEFAULT_PANEL_MAX_WIDTH", () => { + expect(DEFAULT_PANEL_MIN_WIDTH).toBeLessThan(DEFAULT_PANEL_WIDTH); + expect(DEFAULT_PANEL_WIDTH).toBeLessThan(DEFAULT_PANEL_MAX_WIDTH); + }); + + it("DEFAULT_PANEL_MIN_HEIGHT is positive", () => { + expect(DEFAULT_PANEL_MIN_HEIGHT).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Resizer visibility logic +// --------------------------------------------------------------------------- + +describe("Sidebar resizer visibility", () => { + describe("left-aligned panel", () => { + it("shows the right-side resizer", () => { + expect(shouldRenderResizer("right", "left", false, false)).toBe(true); + }); + + it("does NOT show the left-side resizer (would overlap the panel edge)", () => { + expect(shouldRenderResizer("left", "left", false, false)).toBe(false); + }); + + it("does NOT show top/bottom resizers when attached", () => { + expect(shouldRenderResizer("top", "left", false, false)).toBe(false); + expect(shouldRenderResizer("bottom", "left", false, false)).toBe(false); + }); + }); + + describe("right-aligned panel", () => { + it("shows the left-side resizer", () => { + expect(shouldRenderResizer("left", "right", false, false)).toBe(true); + }); + + it("does NOT show the right-side resizer", () => { + expect(shouldRenderResizer("right", "right", false, false)).toBe(false); + }); + + it("does NOT show top/bottom resizers when attached", () => { + expect(shouldRenderResizer("top", "right", false, false)).toBe(false); + expect(shouldRenderResizer("bottom", "right", false, false)).toBe(false); + }); + }); + + describe("detached (floating) panel", () => { + it("shows ALL resizers", () => { + for (const res of resizers) { + expect(shouldRenderResizer(res, "left", false, true)).toBe(true); + } + }); + }); + + describe("collapsed panel", () => { + it("shows NO resizers regardless of alignment", () => { + for (const res of resizers) { + expect(shouldRenderResizer(res, "left", true, false)).toBe(false); + expect(shouldRenderResizer(res, "right", true, false)).toBe(false); + } + }); + }); +}); + +// --------------------------------------------------------------------------- +// 3. resizePanelColumns — group height resize +// --------------------------------------------------------------------------- + +describe("resizePanelColumns", () => { + it("returns the original state when the panel has no alignment", () => { + const state: Record = { + panel1: makePanel({ alignment: undefined as unknown as Side }), + }; + const result = resizePanelColumns(state, "panel1", 300, 0, 1000); + + expect(result).toBe(state); + }); + + it("clamps the resized panel height to DEFAULT_PANEL_MIN_HEIGHT", () => { + const state: Record = { + panel1: makePanel({ height: 500, order: 0 }), + panel2: makePanel({ height: 500, order: 1 }), + }; + const belowMin = DEFAULT_PANEL_MIN_HEIGHT - 10; + const result = resizePanelColumns(state, "panel2", belowMin, 0, 1000); + + expect(result["panel2"].height).toBeGreaterThanOrEqual(DEFAULT_PANEL_MIN_HEIGHT); + }); + + it("sets the resized panel to the requested height when within bounds", () => { + const state: Record = { + panel1: makePanel({ height: 500, order: 0 }), + panel2: makePanel({ height: 500, order: 1 }), + }; + const newHeight = 300; + const result = resizePanelColumns(state, "panel2", newHeight, 0, 1000); + + expect(result["panel2"].height).toBe(newHeight); + }); + + it("does not exceed availableHeight for any panel", () => { + const availableHeight = 800; + const state: Record = { + panel1: makePanel({ height: 400, order: 0 }), + panel2: makePanel({ height: 400, order: 1 }), + }; + const result = resizePanelColumns(state, "panel2", 700, 0, availableHeight); + + for (const panel of Object.values(result)) { + expect(panel.height).toBeLessThanOrEqual(availableHeight); + } + }); + + it("invisible panels are not resized", () => { + const state: Record = { + panel1: makePanel({ height: 500, order: 0, visible: false }), + panel2: makePanel({ height: 500, order: 1 }), + }; + const originalHeight = state["panel1"].height; + const result = resizePanelColumns(state, "panel2", 300, 0, 1000); + + expect(result["panel1"].height).toBe(originalHeight); + }); +}); diff --git a/web/libs/editor/src/components/SidePanels/constants.ts b/web/libs/editor/src/components/SidePanels/constants.ts index 4fd01bdc3e9c..9ef7b30e6e05 100644 --- a/web/libs/editor/src/components/SidePanels/constants.ts +++ b/web/libs/editor/src/components/SidePanels/constants.ts @@ -1,4 +1,5 @@ export const DEFAULT_PANEL_WIDTH = 320; +export const DEFAULT_PANEL_MIN_WIDTH = 180; export const DEFAULT_PANEL_HEIGHT = 300; export const DEFAULT_PANEL_MAX_WIDTH = 500; export const DEFAULT_PANEL_MAX_HEIGHT = 500;