{
  "schemaVersion": 1,
  "title": "Agent-friendly linting: exact-main audit after mobile drawer duration and file-tree indentation merges",
  "auditLocalDate": "2026-10-23",
  "timezone": "Europe/Stockholm",
  "exactSha": "3083e1aa1660bd45769b01e5ad5ded0b6c4be302",
  "baseline": {
    "trackedChangesBefore": 0,
    "trackedChangesAfter": 0,
    "headUnchanged": true,
    "sourceEdited": false,
    "ruleConfigurationChanged": false,
    "verificationSkillEdited": false
  },
  "environment": {
    "node": "v24.22.1",
    "npm": "11.12.1",
    "eslint": "v9.39.5",
    "packageManagerPin": "npm@11.12.1",
    "nodeEngine": ">=24",
    "volta": {
      "node": "24.21.0",
      "npm": "11.12.1"
    },
    "canvas": "1.32.0",
    "agentServer": "1.58.1",
    "automation": "1.25.1",
    "extensions": "0.35.0",
    "typescriptClient": "1.58.1",
    "shadcnLint": "0.2.1",
    "dependencyRefresh": {
      "command": "npm ci --ignore-scripts",
      "reason": "Replay the exact committed lockfile after current main dependency changes; npm ci does not resolve or rewrite dependency versions.",
      "packageLockSha256": "8240e2e3024d723be7a34e7dcc2309d9e04a4a0de0587d00849ef2a673883e7f",
      "installedLockfileSha256": "b88bc715241e6404d8852c96c680c40657c294ffbd439572096465ad4bc3a2ac"
    }
  },
  "reproduction": {
    "commands": [
      {
        "name": "install",
        "command": [
          "npm",
          "ci",
          "--ignore-scripts"
        ],
        "start": "2026-10-23T05:24:04.083334+00:00",
        "end": "2026-10-23T05:24:25.689330+00:00",
        "exit": 0
      },
      {
        "name": "full-lint",
        "command": [
          "npm",
          "run",
          "lint"
        ],
        "start": "2026-10-23T05:24:25.690103+00:00",
        "end": "2026-10-23T05:26:07.686286+00:00",
        "exit": 0
      },
      {
        "name": "eslint",
        "command": [
          "./node_modules/.bin/eslint",
          "src",
          "--format",
          "json",
          "--output-file",
          "eslint.json"
        ],
        "start": "2026-10-23T05:26:07.687175+00:00",
        "end": "2026-10-23T05:26:59.997507+00:00",
        "exit": 0
      }
    ],
    "fullLintExitCode": 0,
    "machineExitCode": 0,
    "typecheckPassed": true,
    "prettierPassed": true,
    "eslintTarget": "src"
  },
  "totals": {
    "filesLinted": 1420,
    "filesWithDiagnostics": 191,
    "errors": 0,
    "warnings": 611,
    "fatalErrors": 0
  },
  "rules": [
    {
      "ruleId": "shadcn/no-arbitrary-values",
      "errors": 0,
      "warnings": 517,
      "files": 177
    },
    {
      "ruleId": "shadcn/no-inline-styles",
      "errors": 0,
      "warnings": 76,
      "files": 38
    },
    {
      "ruleId": "shadcn/no-raw-colors",
      "errors": 0,
      "warnings": 18,
      "files": 4
    }
  ],
  "allDiagnostics": [
    {
      "file": "src/components/conversation-events/chat/event-message-components/corrective-nudge-message.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"mt-[5px]\" hardcodes an off-token value. Use \"mt-1.25\" instead (same value, on the scale).",
          "line": 19,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 19,
          "endColumn": 38,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "mt-1.25"
              },
              "fix": {
                "range": [
                  616,
                  635
                ],
                "text": "\"mt-1.25 shrink-0\""
              },
              "desc": "Replace with \"mt-1.25\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[word-break:break-word]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 22,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 22,
          "endColumn": 58
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { MessageEvent } from \"#/types/agent-server/core\";\nimport InfoCircleIcon from \"#/icons/info-circle.svg?react\";\nimport { parseMessageFromEvent } from \"../event-content-helpers/parse-message-from-event\";\n\ninterface CorrectiveNudgeMessageProps {\n  event: MessageEvent;\n}\n\nexport function CorrectiveNudgeMessage({ event }: CorrectiveNudgeMessageProps) {\n  return (\n    <div\n      role=\"note\"\n      data-testid=\"corrective-nudge-message\"\n      className=\"mt-6 flex w-full items-start gap-1.5 text-sm italic text-muted last:mb-4\"\n    >\n      <InfoCircleIcon\n        width={14}\n        height={14}\n        className=\"mt-[5px] shrink-0\"\n        aria-hidden\n      />\n      <span className=\"leading-6 [word-break:break-word]\">\n        {parseMessageFromEvent(event)}\n      </span>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/conversation-events/chat/task-tracking/task-item.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#ffffff]\" hardcodes a color. Nearest theme tokens: text-primary, text-content-2. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 21,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 21,
          "endColumn": 62,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-primary"
              },
              "fix": {
                "range": [
                  745,
                  769
                ],
                "text": "\"w-4 h-4 text-primary\""
              },
              "desc": "Replace with \"text-primary\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-content-2"
              },
              "fix": {
                "range": [
                  745,
                  769
                ],
                "text": "\"w-4 h-4 text-content-2\""
              },
              "desc": "Replace with \"text-content-2\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#ffffff]\" hardcodes a color. Nearest theme tokens: text-primary, text-content-2. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 23,
          "column": 47,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 23,
          "endColumn": 71,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-primary"
              },
              "fix": {
                "range": [
                  846,
                  870
                ],
                "text": "\"w-4 h-4 text-primary\""
              },
              "desc": "Replace with \"text-primary\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-content-2"
              },
              "fix": {
                "range": [
                  846,
                  870
                ],
                "text": "\"w-4 h-4 text-content-2\""
              },
              "desc": "Replace with \"text-content-2\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#ffffff]\" hardcodes a color. Nearest theme tokens: text-primary, text-content-2. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 27,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 27,
          "endColumn": 62,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-primary"
              },
              "fix": {
                "range": [
                  1013,
                  1037
                ],
                "text": "\"w-4 h-4 text-primary\""
              },
              "desc": "Replace with \"text-primary\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-content-2"
              },
              "fix": {
                "range": [
                  1013,
                  1037
                ],
                "text": "\"w-4 h-4 text-content-2\""
              },
              "desc": "Replace with \"text-content-2\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 46,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 46,
          "endColumn": 62
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { TaskItem as TaskItemType } from \"#/types/agent-server/core/base/common\";\nimport CircleIcon from \"#/icons/u-circle.svg?react\";\nimport CheckCircleIcon from \"#/icons/u-check-circle.svg?react\";\nimport CheckCircleHalfIcon from \"#/icons/u-check-circle-half.svg?react\";\nimport { cn } from \"#/utils/utils\";\nimport { Typography } from \"#/ui/typography\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\ninterface TaskItemProps {\n  task: TaskItemType;\n}\n\nexport function TaskItem({ task }: TaskItemProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  const icon = useMemo(() => {\n    switch (task.status) {\n      case \"todo\":\n        return <CircleIcon className=\"w-4 h-4 text-[#ffffff]\" />;\n      case \"in_progress\":\n        return <CheckCircleHalfIcon className=\"w-4 h-4 text-[#ffffff]\" />;\n      case \"done\":\n        return <CheckCircleIcon className=\"w-4 h-4 text-muted\" />;\n      default:\n        return <CircleIcon className=\"w-4 h-4 text-[#ffffff]\" />;\n    }\n  }, [task.status]);\n\n  const isDoneStatus = task.status === \"done\";\n\n  return (\n    <div\n      className=\"flex gap-3.5 items-center px-4 py-2 w-full\"\n      data-name=\"item\"\n    >\n      <div className=\"shrink-0\">{icon}</div>\n      <div className=\"flex flex-col items-start justify-center leading-5 text-nowrap whitespace-pre font-normal\">\n        <Typography.Text\n          className={cn(\"text-xs text-contrast\", isDoneStatus && \"text-muted\")}\n        >\n          {task.title}\n        </Typography.Text>\n        {task.notes && (\n          <Typography.Text className=\"text-[10px] text-muted\">\n            {t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}\n          </Typography.Text>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/conversation-events/chat/task-tracking/task-list-section.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 20,
          "column": 36,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 20,
          "endColumn": 130
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"tracking-[0.11px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 20,
          "column": 36,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 20,
          "endColumn": 130
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { TaskItem } from \"./task-item\";\nimport LessonPlanIcon from \"#/icons/lesson-plan.svg?react\";\nimport { TaskItem as TaskItemType } from \"#/types/agent-server/core/base/common\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { Typography } from \"#/ui/typography\";\n\ninterface TaskListSectionProps {\n  taskList: TaskItemType[];\n}\n\nexport function TaskListSection({ taskList }: TaskListSectionProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <div className=\"flex flex-col overflow-clip bg-surface border border-border rounded-xl w-full\">\n      {/* Header Tabs */}\n      <div className=\"flex gap-1 items-center border-b border-border h-10.25 px-2 shrink-0\">\n        <LessonPlanIcon className=\"shrink-0 w-4.5 h-4.5 text-muted\" />\n        <Typography.Text className=\"text-[11px] text-nowrap text-contrast tracking-[0.11px] font-medium leading-4 whitespace-pre\">\n          {t(I18nKey.COMMON$TASKS)}\n        </Typography.Text>\n      </div>\n\n      {/* Task Items */}\n      <div>\n        {taskList.map((task, index) => (\n          <TaskItem key={`task-${index}`} task={task} />\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/add-automation-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-w-[10rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 94,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 94,
          "endColumn": 34
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 145,
          "column": 25,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 145,
          "endColumn": 36
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { ChevronDown, FileUp, Plus } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ContextMenuListItem } from \"#/components/features/context-menu/context-menu-list-item\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { KebabMenuItemContent } from \"./kebab-menu-item-content\";\n\ninterface AddAutomationMenuProps {\n  onAdd: () => void;\n  onImport: () => void;\n  isAddDisabled?: boolean;\n}\n\nexport function AddAutomationMenu({\n  onAdd,\n  onImport,\n  isAddDisabled = false,\n}: AddAutomationMenuProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [open, setOpen] = useState(false);\n  const [portalStyle, setPortalStyle] = useState<React.CSSProperties>();\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const menuRef = useRef<HTMLUListElement>(null);\n\n  useLayoutEffect(() => {\n    if (!open || !triggerRef.current) return undefined;\n\n    const updatePosition = () => {\n      const rect = triggerRef.current?.getBoundingClientRect();\n      if (!rect) return;\n\n      setPortalStyle({\n        position: \"fixed\",\n        zIndex: 9999,\n        top: rect.bottom + 2,\n        right: window.innerWidth - rect.right,\n      });\n    };\n\n    updatePosition();\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [open]);\n\n  useEffect(() => {\n    if (!open) return undefined;\n\n    const handleClickOutside = (event: MouseEvent) => {\n      const target = event.target as Node;\n      if (\n        triggerRef.current?.contains(target) ||\n        menuRef.current?.contains(target)\n      ) {\n        return;\n      }\n      setOpen(false);\n    };\n\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        setOpen(false);\n      }\n    };\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    document.addEventListener(\"keydown\", handleEscape);\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n      document.removeEventListener(\"keydown\", handleEscape);\n    };\n  }, [open]);\n\n  const closeAnd = (action: () => void) => {\n    action();\n    setOpen(false);\n  };\n\n  const menu =\n    open && portalStyle ? (\n      <ContextMenu\n        ref={menuRef}\n        testId=\"automations-add-automation-menu\"\n        theme=\"popover\"\n        position=\"none\"\n        alignment=\"none\"\n        spacing=\"none\"\n        className=\"min-w-[10rem]\"\n      >\n        <li>\n          <ContextMenuListItem\n            testId=\"automations-add-automation-create\"\n            isDisabled={isAddDisabled}\n            className=\"group\"\n            onClick={() => {\n              if (isAddDisabled) return;\n              closeAnd(onAdd);\n            }}\n          >\n            <KebabMenuItemContent\n              icon={<Plus className=\"size-4\" aria-hidden />}\n              label={t(I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON)}\n            />\n          </ContextMenuListItem>\n        </li>\n        <li>\n          <ContextMenuListItem\n            testId=\"automations-import-automation\"\n            className=\"group\"\n            onClick={() => closeAnd(onImport)}\n          >\n            <KebabMenuItemContent\n              icon={<FileUp className=\"size-4\" aria-hidden />}\n              label={t(I18nKey.AUTOMATIONS$IMPORT)}\n            />\n          </ContextMenuListItem>\n        </li>\n      </ContextMenu>\n    ) : null;\n\n  return (\n    <>\n      <BrandButton\n        ref={triggerRef}\n        type=\"button\"\n        variant=\"secondary\"\n        testId=\"automations-add-automation\"\n        className=\"whitespace-nowrap\"\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        onClick={() => setOpen((current) => !current)}\n      >\n        {t(I18nKey.AUTOMATIONS$ADD_AUTOMATION)}\n        <ChevronDown className=\"size-4 shrink-0\" aria-hidden />\n      </BrandButton>\n\n      {open && portalStyle && typeof document !== \"undefined\"\n        ? ReactDOM.createPortal(\n            <div style={portalStyle}>{menu}</div>,\n            document.body,\n          )\n        : null}\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/automation-list-row.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"grid-cols-[auto_minmax(0,1fr)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 146,
          "column": 26,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 146,
          "endColumn": 86
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { Tooltip } from \"@heroui/react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Zap } from \"lucide-react\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type { Automation } from \"#/types/automation\";\nimport { getAutomationRunDisplay } from \"#/utils/automation-run-display\";\nimport { KebabMenu } from \"./kebab-menu\";\nimport {\n  useAutomationPermissions,\n  useIsAutomationOwner,\n} from \"#/hooks/use-automation-permissions\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport { NavigationLink } from \"#/components/shared/navigation-link\";\nimport PlayIcon from \"#/icons/play.svg?react\";\nimport ClockIcon from \"#/icons/clock.svg?react\";\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\nimport { cn } from \"#/utils/utils\";\nimport { formatRelativeTime } from \"#/utils/format-relative-time\";\nimport { extensionModuleCardPillClassName } from \"#/utils/extension-module-card-classes\";\nimport { automationIconActionButtonClassName } from \"./automation-action-button-classes\";\nimport { buildAutomationMenuItems } from \"./build-automation-menu-items\";\nimport { automationActivityRowClassName } from \"./automation-view-mode\";\nimport { RunStatusBadge } from \"./detail/run-status-badge\";\nimport { ActiveStatusBadge } from \"./detail/active-status-badge\";\nimport { AutomationRunActivitySparkline } from \"#/components/features/home/featured-automations/automation-run-activity-sparkline\";\nimport { AutomationHealthIndicator } from \"#/components/features/home/featured-automations/automation-health-indicator\";\nimport {\n  HomeAutomationRunTooltip,\n  getRunStatusLabelKey,\n} from \"#/components/features/home/featured-automations/home-automation-run-tooltip\";\nimport {\n  deriveRunHealth,\n  formatTriggerSourceLabel,\n  getLastRunTimestamp,\n  getTriggerEventLabel,\n  getTriggerScheduleLabel,\n  getTriggerSource,\n} from \"#/components/features/home/featured-automations/automation-run-health\";\nimport type { AutomationInsightsProps } from \"./automation-card\";\nimport { toLatestRunState } from \"./to-latest-run-state\";\nimport { resolveAutomationImpactStatement } from \"#/utils/automation-catalog\";\nimport { isDraftAutomation } from \"#/utils/automation-state\";\n\ninterface AutomationListRowProps {\n  automation: Automation;\n  onToggle: (id: string, enabled: boolean) => void;\n  onRunNow: (id: string) => void;\n  isRunPending?: boolean;\n  onDelete: (id: string) => void;\n  onExport: (automation: Automation) => void;\n  onEdit?: (id: string) => void;\n  insights?: AutomationInsightsProps;\n}\n\nexport function AutomationListRow({\n  automation,\n  onToggle,\n  onRunNow,\n  isRunPending = false,\n  onDelete,\n  onExport,\n  onEdit,\n  insights,\n}: AutomationListRowProps) {\n  const { navigate } = useNavigation();\n  const { t, i18n } = useTranslation(\"openhands\");\n  const { canManage: hasManagePermission } = useAutomationPermissions();\n  const isOwner = useIsAutomationOwner(automation);\n  // Write actions on a specific automation: manage OR creator (escape hatch).\n  const canManage = hasManagePermission || isOwner;\n  const isDraft = isDraftAutomation(automation);\n  // Non-creators may turn an automation off but not back on. Draft test\n  // artifacts are finalized through the draft setup flow, not this toggle.\n  const canToggle = isDraft ? false : automation.enabled ? canManage : isOwner;\n\n  const handleView = () => {\n    navigate?.(`/automations/${automation.id}`);\n  };\n\n  const menuItems = buildAutomationMenuItems({\n    automation,\n    t,\n    canManage,\n    canToggle,\n    onRunNow,\n    isRunPending,\n    onView: handleView,\n    onExport,\n    onEdit,\n    onToggle,\n    onDelete,\n  });\n\n  const isEventTrigger = automation.trigger.type === \"event\";\n  const TriggerIcon = isEventTrigger ? Zap : ClockIcon;\n  const triggerEventLabel = getTriggerEventLabel(automation);\n  const triggerScheduleLabel = getTriggerScheduleLabel(automation);\n  const triggerSource = getTriggerSource(automation);\n  const hasTriggerMeta = Boolean(\n    triggerEventLabel || triggerScheduleLabel || triggerSource,\n  );\n\n  const runState = toLatestRunState(insights?.state);\n  const health = deriveRunHealth(runState);\n  const impactStatement = resolveAutomationImpactStatement(\n    automation,\n    insights?.state?.summary?.completedTotal ?? null,\n  );\n  const latestRun = runState.latestRun;\n  const display = latestRun ? getAutomationRunDisplay(latestRun) : null;\n  const lastRunAt = latestRun\n    ? getLastRunTimestamp(latestRun)\n    : automation.last_triggered_at;\n  const whenLabel = lastRunAt\n    ? formatRelativeTime(lastRunAt, i18n.language, t)\n    : null;\n  const hasMeta =\n    hasTriggerMeta || Boolean(whenLabel) || Boolean(latestRun?.status);\n  const detailHref = `/automations/${encodeURIComponent(automation.id)}`;\n  const statusLabelKey = getRunStatusLabelKey(runState);\n  const disableAnimation = import.meta.env.MODE === \"test\";\n  const showDraftBadge = isDraft;\n\n  return (\n    <li\n      data-testid={`automation-list-row-${automation.id}`}\n      className={automationActivityRowClassName}\n    >\n      <Tooltip\n        content={\n          <HomeAutomationRunTooltip\n            automation={automation}\n            runState={runState}\n          />\n        }\n        placement=\"top-start\"\n        closeDelay={100}\n        disableAnimation={disableAnimation}\n        className=\"rounded-xl border border-border bg-base-secondary p-0 text-contrast shadow-xl\"\n      >\n        <NavigationLink\n          to={detailHref}\n          aria-label={`${automation.name} ${t(statusLabelKey)}`}\n          className=\"flex min-w-0 flex-1 items-center justify-between gap-3 px-3 py-2.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus\"\n        >\n          <div className=\"grid min-w-0 flex-1 grid-cols-[auto_minmax(0,1fr)] gap-x-2\">\n            <span\n              data-testid={`automation-health-${automation.id}`}\n              className=\"inline-flex h-5 w-2.5 shrink-0 items-center justify-center\"\n            >\n              <AutomationHealthIndicator health={health} />\n            </span>\n            <span className=\"flex min-w-0 items-center gap-2\">\n              <span className=\"truncate text-sm font-medium leading-5 text-foreground\">\n                {automation.name}\n              </span>\n              {showDraftBadge ? (\n                <ActiveStatusBadge automation={automation} compact />\n              ) : null}\n            </span>\n            {hasMeta ? (\n              <span className=\"col-start-2 mt-0.5 flex min-w-0 items-center gap-1.5 text-xs leading-4 text-text-secondary\">\n                <TriggerIcon className=\"size-3 shrink-0\" aria-hidden=\"true\" />\n                {triggerEventLabel ? (\n                  <span className=\"truncate\">{triggerEventLabel}</span>\n                ) : null}\n                {triggerScheduleLabel ? (\n                  <span className=\"truncate\">{triggerScheduleLabel}</span>\n                ) : null}\n                {triggerSource ? (\n                  <span\n                    className={cn(\n                      extensionModuleCardPillClassName,\n                      \"shrink-0 px-1.5 py-0 text-text-secondary\",\n                    )}\n                  >\n                    {formatTriggerSourceLabel(triggerSource)}\n                  </span>\n                ) : null}\n                {whenLabel ? (\n                  <>\n                    {hasTriggerMeta ? (\n                      <span className=\"shrink-0\" aria-hidden=\"true\">\n                        \u00b7\n                      </span>\n                    ) : null}\n                    <span\n                      data-testid={`automation-last-run-${automation.id}`}\n                      className=\"truncate\"\n                    >\n                      {whenLabel}\n                    </span>\n                  </>\n                ) : null}\n                {latestRun ? (\n                  <>\n                    {hasTriggerMeta || whenLabel ? (\n                      <span className=\"shrink-0\" aria-hidden=\"true\">\n                        \u00b7\n                      </span>\n                    ) : null}\n                    <RunStatusBadge\n                      status={display?.badgeStatus ?? latestRun.status}\n                      compact\n                    />\n                  </>\n                ) : null}\n                {impactStatement ? (\n                  <>\n                    {hasTriggerMeta || whenLabel || latestRun ? (\n                      <span className=\"shrink-0\" aria-hidden=\"true\">\n                        \u00b7\n                      </span>\n                    ) : null}\n                    <span\n                      data-testid={`automation-impact-${automation.id}`}\n                      className=\"truncate\"\n                    >\n                      {impactStatement}\n                    </span>\n                  </>\n                ) : null}\n              </span>\n            ) : null}\n            <span className=\"sr-only\">{t(statusLabelKey)}</span>\n          </div>\n        </NavigationLink>\n      </Tooltip>\n\n      <div className=\"flex shrink-0 items-center gap-1.5 pr-1.5\">\n        {insights ? (\n          <AutomationRunActivitySparkline\n            automationId={automation.id}\n            runs={runState.recentRuns}\n            testId={`automation-activity-${automation.id}`}\n          />\n        ) : null}\n        {canManage ? (\n          <StyledTooltip\n            content={t(I18nKey.AUTOMATIONS$RUN_NOW)}\n            placement=\"top\"\n          >\n            <button\n              type=\"button\"\n              data-testid={`automation-run-now-${automation.id}`}\n              aria-label={t(I18nKey.AUTOMATIONS$RUN_NOW)}\n              aria-busy={isRunPending}\n              disabled={isRunPending || !automation.enabled}\n              onClick={(event) => {\n                event.stopPropagation();\n                onRunNow(automation.id);\n              }}\n              className={automationIconActionButtonClassName}\n            >\n              <PlayIcon className=\"size-4 shrink-0\" aria-hidden />\n            </button>\n          </StyledTooltip>\n        ) : null}\n        <KebabMenu\n          items={menuItems}\n          triggerClassName=\"opacity-70 group-hover:opacity-100\"\n        />\n      </div>\n    </li>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/automation-view-toggle.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-w-[10rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 108,
          "column": 60,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 108,
          "endColumn": 75
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 155,
          "column": 25,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 155,
          "endColumn": 36
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { Grid2x2, Rows3 } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ContextMenuListItem } from \"#/components/features/context-menu/context-menu-list-item\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { cn } from \"#/utils/utils\";\nimport type { AutomationViewMode } from \"./automation-view-mode\";\nimport { ViewMenuItemContent } from \"./view-menu-item-content\";\n\ninterface AutomationViewToggleProps {\n  view: AutomationViewMode;\n  onChange: (view: AutomationViewMode) => void;\n  disabled?: boolean;\n}\n\nconst VIEW_OPTIONS: {\n  value: AutomationViewMode;\n  icon: typeof Grid2x2;\n  labelKey: I18nKey;\n  testId: string;\n}[] = [\n  {\n    value: \"grid\",\n    icon: Grid2x2,\n    labelKey: I18nKey.AUTOMATIONS$VIEW_GRID,\n    testId: \"automations-view-toggle-grid\",\n  },\n  {\n    value: \"list\",\n    icon: Rows3,\n    labelKey: I18nKey.AUTOMATIONS$VIEW_LIST,\n    testId: \"automations-view-toggle-list\",\n  },\n];\n\nexport function AutomationViewToggle({\n  view,\n  onChange,\n  disabled = false,\n}: AutomationViewToggleProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [open, setOpen] = useState(false);\n  const [portalStyle, setPortalStyle] = useState<React.CSSProperties>();\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const menuRef = useRef<HTMLUListElement>(null);\n\n  const activeOption =\n    VIEW_OPTIONS.find((option) => option.value === view) ?? VIEW_OPTIONS[0]!;\n  const ActiveIcon = activeOption.icon;\n\n  useLayoutEffect(() => {\n    if (!open || !triggerRef.current) return undefined;\n\n    const updatePosition = () => {\n      const rect = triggerRef.current?.getBoundingClientRect();\n      if (!rect) return;\n\n      const gap = 4;\n      setPortalStyle({\n        position: \"fixed\",\n        zIndex: 9999,\n        top: rect.bottom + gap,\n        right: window.innerWidth - rect.right,\n      });\n    };\n\n    updatePosition();\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [open]);\n\n  useEffect(() => {\n    if (!open) return undefined;\n\n    const handleClickOutside = (event: MouseEvent) => {\n      const target = event.target as Node;\n      if (\n        triggerRef.current?.contains(target) ||\n        menuRef.current?.contains(target)\n      ) {\n        return;\n      }\n      setOpen(false);\n    };\n\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        setOpen(false);\n      }\n    };\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    document.addEventListener(\"keydown\", handleEscape);\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n      document.removeEventListener(\"keydown\", handleEscape);\n    };\n  }, [open]);\n\n  const menu =\n    open && portalStyle ? (\n      <ContextMenu ref={menuRef} theme=\"popover\" className=\"min-w-[10rem]\">\n        {VIEW_OPTIONS.map((option) => (\n          <li key={option.value}>\n            <ContextMenuListItem\n              testId={option.testId}\n              onClick={() => {\n                onChange(option.value);\n                setOpen(false);\n              }}\n              className=\"group\"\n            >\n              <ViewMenuItemContent\n                icon={option.icon}\n                label={t(option.labelKey)}\n                isSelected={view === option.value}\n              />\n            </ContextMenuListItem>\n          </li>\n        ))}\n      </ContextMenu>\n    ) : null;\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        data-testid=\"automations-view-toggle\"\n        aria-label={t(I18nKey.AUTOMATIONS$VIEW_MODE)}\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        aria-disabled={disabled}\n        disabled={disabled}\n        onClick={() => {\n          if (disabled) return;\n          setOpen((current) => !current);\n        }}\n        className={cn(\n          \"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-border bg-base-secondary text-contrast transition-colors hover:bg-interactive-hover focus-visible:border-contrast/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-contrast/20\",\n          \"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-base-secondary\",\n        )}\n      >\n        <ActiveIcon className=\"size-4\" aria-hidden />\n      </button>\n\n      {open && portalStyle && typeof document !== \"undefined\"\n        ? ReactDOM.createPortal(\n            <div style={portalStyle}>{menu}</div>,\n            document.body,\n          )\n        : null}\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/create-instructions.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 21,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 21,
          "endColumn": 93
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useState, type ReactNode } from \"react\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport ChevronDownIcon from \"#/icons/chevron-down.svg?react\";\nimport MessageSquareShareIcon from \"#/icons/message-square-share.svg?react\";\nimport { cn } from \"#/utils/utils\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { useStartAutomationSetup } from \"#/hooks/use-start-automation-setup\";\nimport { getAutomationsDocsUrl } from \"#/manifests/automation-interface\";\n\nfunction InlineExampleWrap({ children }: { children?: ReactNode }) {\n  return <span className=\"whitespace-nowrap\">{children}</span>;\n}\n\nfunction InlineCodeChip({ children }: { children?: ReactNode }) {\n  return (\n    <code\n      data-testid=\"automations-create-instructions-example\"\n      className={cn(\n        \"mx-0.5 inline-block rounded-sm border border-border-subtle\",\n        \"bg-surface-raised px-1.5 py-0.5 align-baseline font-mono text-[11px] text-contrast\",\n      )}\n    >\n      {children}\n    </code>\n  );\n}\n\nfunction InlinePunctuation({ children }: { children?: ReactNode }) {\n  return <>{children}</>;\n}\n\nconst CREATE_INSTRUCTIONS_INLINE_COMPONENTS = {\n  example: <InlineExampleWrap />,\n  cmd: <InlineCodeChip />,\n  punct: <InlinePunctuation />,\n};\n\ninterface CreateInstructionsProps {\n  /** If true, the instructions are collapsible and start collapsed */\n  collapsible?: boolean;\n}\n\nexport function CreateInstructionsContent() {\n  const { t } = useTranslation(\"openhands\");\n  const { startSetup, isPending } = useStartAutomationSetup();\n\n  return (\n    <div className=\"flex flex-col gap-5\">\n      <p className=\"text-sm leading-relaxed text-tertiary-light\">\n        <Trans\n          ns=\"openhands\"\n          i18nKey={I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC}\n          components={CREATE_INSTRUCTIONS_INLINE_COMPONENTS}\n        />{\" \"}\n        {t(I18nKey.AUTOMATIONS$CREATE_INSTRUCTIONS_GUIDANCE)}{\" \"}\n        <a\n          href={getAutomationsDocsUrl()}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"text-muted underline transition-colors hover:text-foreground\"\n        >\n          {t(I18nKey.AUTOMATIONS$EMPTY_LEARN_MORE)}\n        </a>\n      </p>\n\n      <div className=\"flex justify-center\">\n        <BrandButton\n          type=\"button\"\n          variant=\"primary\"\n          testId=\"automations-create-automation\"\n          isDisabled={isPending}\n          onClick={startSetup}\n          startContent={\n            <MessageSquareShareIcon className=\"size-4\" aria-hidden />\n          }\n        >\n          {t(I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON)}\n        </BrandButton>\n      </div>\n    </div>\n  );\n}\n\nexport function CreateInstructions({\n  collapsible = false,\n}: CreateInstructionsProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [isExpanded, setIsExpanded] = useState(!collapsible);\n\n  if (collapsible) {\n    return (\n      <div className=\"w-full rounded-lg border border-border bg-surface\">\n        <button\n          type=\"button\"\n          onClick={() => setIsExpanded(!isExpanded)}\n          aria-expanded={isExpanded}\n          className=\"flex w-full items-center justify-between rounded-lg p-4 text-left transition-colors hover:bg-surface-raised\"\n        >\n          <span className=\"text-sm font-normal text-content\">\n            {t(I18nKey.AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE)}\n          </span>\n          <ChevronDownIcon\n            className={cn(\n              \"size-5 text-muted transition-transform\",\n              isExpanded && \"rotate-180\",\n            )}\n          />\n        </button>\n        {isExpanded ? (\n          <div className=\"px-4 pb-4\">\n            <CreateInstructionsContent />\n          </div>\n        ) : null}\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"w-full max-w-2xl\">\n      <CreateInstructionsContent />\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/dashboard/automations-dashboard-controls.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 122,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 122,
          "endColumn": 109
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-w-[16rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 140,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 140,
          "endColumn": 166
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { type ReactNode, useState } from \"react\";\nimport { ChevronDown, ListFilter } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { EnumFilterDropdown } from \"#/components/shared/filters/enum-filter-dropdown\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport type { DashboardSpec } from \"#/manifests/automation-interface\";\nimport type {\n  DashboardCreatedByValue,\n  DashboardSortValue,\n  DashboardStatusValue,\n  DashboardTriggerValue,\n} from \"#/manifests/types\";\nimport { dropdownFilterTriggerClassName } from \"#/utils/dropdown-classes\";\nimport { cn } from \"#/utils/utils\";\n\nfunction FilterField({\n  label,\n  children,\n}: {\n  label: string;\n  children: ReactNode;\n}) {\n  return (\n    <div className=\"flex flex-col gap-1\">\n      <span className=\"text-xs font-medium text-muted\">{label}</span>\n      {children}\n    </div>\n  );\n}\n\nfunction toLabelMap<T extends string>(\n  options: readonly { value: T; label: string }[],\n): Record<T, string> {\n  return Object.fromEntries(\n    options.map((option) => [option.value, option.label]),\n  ) as Record<T, string>;\n}\n\ninterface AutomationsDashboardControlsProps {\n  spec: DashboardSpec;\n  status: DashboardStatusValue;\n  trigger: DashboardTriggerValue;\n  createdBy: DashboardCreatedByValue;\n  canFilterByCreator: boolean;\n  sort: DashboardSortValue;\n  onStatusChange: (value: DashboardStatusValue) => void;\n  onTriggerChange: (value: DashboardTriggerValue) => void;\n  onCreatedByChange: (value: DashboardCreatedByValue) => void;\n  onSortChange: (value: DashboardSortValue) => void;\n}\n\n/**\n * One Filters trigger that nests the manifest-declared status, trigger,\n * created-by, and sort dropdowns. Which filters exist, their options, and\n * every caption stay the manifest's; the predicates and comparators behind\n * the values stay the host's.\n */\nexport function AutomationsDashboardControls({\n  spec,\n  status,\n  trigger,\n  createdBy,\n  canFilterByCreator,\n  sort,\n  onStatusChange,\n  onTriggerChange,\n  onCreatedByChange,\n  onSortChange,\n}: AutomationsDashboardControlsProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [open, setOpen] = useState(false);\n  const containerRef = useClickOutsideElement<HTMLDivElement>(() =>\n    setOpen(false),\n  );\n\n  const statusFilter = spec.filters.find((filter) => filter.id === \"status\");\n  const triggerFilter = spec.filters.find((filter) => filter.id === \"trigger\");\n  const createdByFilter = canFilterByCreator\n    ? spec.filters.find((filter) => filter.id === \"created_by\")\n    : undefined;\n  const activeCount = [\n    statusFilter && status !== statusFilter.options[0]?.value,\n    triggerFilter && trigger !== triggerFilter.options[0]?.value,\n    createdByFilter && createdBy !== createdByFilter.options[0]?.value,\n    sort !== spec.sort.default,\n  ].filter(Boolean).length;\n  const filtersLabel = t(I18nKey.AUTOMATIONS$FILTERS);\n  const defaultStatus = statusFilter?.options[0]?.value;\n  const defaultTrigger = triggerFilter?.options[0]?.value;\n  const defaultCreatedBy = createdByFilter?.options[0]?.value;\n\n  const resetAll = () => {\n    if (defaultStatus) onStatusChange(defaultStatus);\n    if (defaultTrigger) onTriggerChange(defaultTrigger);\n    if (defaultCreatedBy) onCreatedByChange(defaultCreatedBy);\n    onSortChange(spec.sort.default);\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      className=\"relative shrink-0 w-auto\"\n      data-testid=\"automations-filters\"\n    >\n      <button\n        type=\"button\"\n        data-testid=\"dropdown-trigger\"\n        aria-haspopup=\"true\"\n        aria-expanded={open}\n        aria-label={filtersLabel}\n        onClick={() => setOpen((prev) => !prev)}\n        className={cn(\n          dropdownFilterTriggerClassName,\n          \"h-9 py-0\",\n          activeCount > 0 && \"border-contrast/60 bg-contrast/10\",\n        )}\n      >\n        <ListFilter className=\"h-4 w-4 shrink-0\" aria-hidden />\n        <span className=\"whitespace-nowrap\">{filtersLabel}</span>\n        {activeCount > 0 ? (\n          <span className=\"rounded-full bg-contrast px-1.5 text-[11px] font-medium text-contrast-foreground\">\n            {activeCount}\n          </span>\n        ) : null}\n        <ChevronDown\n          className={cn(\n            \"h-4 w-4 shrink-0 text-tertiary-alt transition-transform\",\n            open && \"rotate-180\",\n          )}\n          aria-hidden\n        />\n      </button>\n\n      {open ? (\n        <div\n          role=\"group\"\n          data-testid=\"automations-filters-menu\"\n          aria-label={filtersLabel}\n          className=\"absolute right-0 top-full z-50 mt-1 flex min-w-[16rem] w-max flex-col gap-3 overflow-visible rounded-md bg-tertiary p-3 context-menu-box-shadow\"\n        >\n          {statusFilter ? (\n            <FilterField label={statusFilter.label}>\n              <EnumFilterDropdown\n                testId=\"automations-filter-status\"\n                value={status}\n                onChange={onStatusChange}\n                options={statusFilter.options.map((option) => option.value)}\n                labelByValue={toLabelMap(statusFilter.options)}\n                ariaLabel={statusFilter.label}\n                fullWidth\n              />\n            </FilterField>\n          ) : null}\n          {triggerFilter ? (\n            <FilterField label={triggerFilter.label}>\n              <EnumFilterDropdown\n                testId=\"automations-filter-trigger\"\n                value={trigger}\n                onChange={onTriggerChange}\n                options={triggerFilter.options.map((option) => option.value)}\n                labelByValue={toLabelMap(triggerFilter.options)}\n                ariaLabel={triggerFilter.label}\n                fullWidth\n              />\n            </FilterField>\n          ) : null}\n          {createdByFilter ? (\n            <FilterField label={createdByFilter.label}>\n              <EnumFilterDropdown\n                testId=\"automations-filter-created-by\"\n                value={createdBy}\n                onChange={onCreatedByChange}\n                options={createdByFilter.options.map((option) => option.value)}\n                labelByValue={toLabelMap(createdByFilter.options)}\n                ariaLabel={createdByFilter.label}\n                fullWidth\n              />\n            </FilterField>\n          ) : null}\n          <FilterField label={spec.sort.label}>\n            <EnumFilterDropdown\n              testId=\"automations-sort\"\n              value={sort}\n              onChange={onSortChange}\n              options={spec.sort.options.map((option) => option.value)}\n              labelByValue={toLabelMap(spec.sort.options)}\n              ariaLabel={spec.sort.label}\n              fullWidth\n            />\n          </FilterField>\n          {activeCount > 0 ? (\n            <button\n              type=\"button\"\n              data-testid=\"automations-filters-reset\"\n              onClick={resetAll}\n              className=\"self-start text-sm text-muted hover:text-foreground\"\n            >\n              {t(I18nKey.AUTOMATIONS$RESET_ALL)}\n            </button>\n          ) : null}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/detail/active-status-badge.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 48,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 48,
          "endColumn": 44
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type { Automation } from \"#/types/automation\";\nimport {\n  getAutomationLifecycleState,\n  type AutomationLifecycleState,\n} from \"#/utils/automation-state\";\nimport { cn } from \"#/utils/utils\";\n\ninterface ActiveStatusBadgeProps {\n  automation: Pick<Automation, \"enabled\" | \"state\">;\n  compact?: boolean;\n}\n\nconst STATUS_DISPLAY: Record<\n  AutomationLifecycleState,\n  { testId: string; label: I18nKey; className: string }\n> = {\n  active: {\n    testId: \"active-status-badge-active\",\n    label: I18nKey.AUTOMATIONS$DETAIL$ACTIVE,\n    className: \"bg-semantic-success/15 text-semantic-success\",\n  },\n  inactive: {\n    testId: \"active-status-badge-inactive\",\n    label: I18nKey.AUTOMATIONS$DETAIL$INACTIVE,\n    className: \"bg-surface-raised text-muted\",\n  },\n  draft: {\n    testId: \"active-status-badge-draft\",\n    label: I18nKey.AUTOMATIONS$DETAIL$DRAFT,\n    className: \"bg-[var(--oh-warning)]/15 text-[var(--oh-warning)]\",\n  },\n};\n\nexport function ActiveStatusBadge({\n  automation,\n  compact = false,\n}: ActiveStatusBadgeProps) {\n  const { t } = useTranslation(\"openhands\");\n  const display = STATUS_DISPLAY[getAutomationLifecycleState(automation)];\n\n  return (\n    <span\n      data-testid={display.testId}\n      className={cn(\n        \"inline-flex items-center rounded-full font-medium\",\n        compact ? \"px-2 py-0.5 text-[10px]\" : \"px-3 py-1 text-xs\",\n        display.className,\n      )}\n    >\n      {t(display.label)}\n    </span>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/detail/run-logs-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"sm:grid-cols-[5rem_minmax(0,1fr)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 122,
          "column": 22,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 122,
          "endColumn": 84
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 127,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 127,
          "endColumn": 126
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"sm:grid-cols-[5rem_minmax(0,1fr)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 132,
          "column": 22,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 132,
          "endColumn": 83
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 156,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 156,
          "endColumn": 176
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"sm:grid-cols-[5rem_minmax(0,1fr)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 164,
          "column": 22,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 164,
          "endColumn": 83
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-primary)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 256,
          "column": 26,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 256,
          "endColumn": 68
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[80vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 276,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 276,
          "endColumn": 151
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-h-[12rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 334,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 334,
          "endColumn": 109
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 8,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useEffect, useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport XMarkIcon from \"#/icons/x-mark.svg?react\";\nimport {\n  useBashCommandLogs,\n  type SandboxIssue,\n} from \"#/hooks/query/use-bash-command-logs\";\nimport type { BashOutput } from \"@openhands/typescript-client\";\nimport { modalTitleLgMediumClassName } from \"#/utils/modal-classes\";\nimport {\n  AutomationRunStatus,\n  type Automation,\n  type AutomationRun,\n  type AutomationRunStatusDetail,\n} from \"#/types/automation\";\nimport {\n  getAutomationRunDisplay,\n  getAutomationRunTaskSummary,\n} from \"#/utils/automation-run-display\";\nimport { DebugAutomationButton } from \"./debug-automation-button\";\nimport { RunStatusBadge } from \"./run-status-badge\";\n\n/**\n * Localized empty-state message key for each `SandboxIssue` reason.\n * Centralised so we don't sprinkle conditional renders for each code.\n */\nconst SANDBOX_ISSUE_I18N: Record<SandboxIssue, I18nKey> = {\n  missing: I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_MISSING,\n  paused: I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_PAUSED,\n  starting: I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_STARTING,\n  errored: I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_ERROR,\n  unreachable: I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_UNREACHABLE,\n};\n\ntype LogTab = \"stdout\" | \"stderr\";\n\ninterface RunLogsModalProps {\n  /** Conversation that owns the bash command. */\n  conversationId: string | null;\n  /**\n   * Cloud sandbox that ran the command; the runtime handle for runs with\n   * no conversation (script automations).\n   */\n  sandboxId?: string | null;\n  /** Bash command id to fetch logs for. */\n  bashCommandId: string | null;\n  isOpen: boolean;\n  onClose: () => void;\n  /** The run these logs belong to; enables the debug action for failed runs. */\n  run?: AutomationRun;\n  /** The parent automation, used to add context to the debug prompt. */\n  automation?: Automation;\n}\n\nfunction concatStream(outputs: BashOutput[], key: \"stdout\" | \"stderr\"): string {\n  // Outputs come back from the API sorted by timestamp, but pages can\n  // arrive out-of-order, so re-sort by (timestamp, order) before\n  // concatenating to keep the stream chronological.\n  return [...outputs]\n    .sort((a, b) => {\n      const ts = a.timestamp.localeCompare(b.timestamp);\n      if (ts !== 0) return ts;\n      return (a.order ?? 0) - (b.order ?? 0);\n    })\n    .map((output) => output[key] ?? \"\")\n    .join(\"\");\n}\n\nfunction stringifyDetailValue(value: unknown): string | null {\n  if (typeof value === \"string\") return value.trim() || null;\n  if (typeof value === \"number\" || typeof value === \"boolean\") {\n    return String(value);\n  }\n  return null;\n}\n\nfunction formatStatusDetail(\n  statusDetail: AutomationRunStatusDetail | null | undefined,\n): string | null {\n  if (!statusDetail) return null;\n  const primary =\n    stringifyDetailValue(statusDetail.formatted_detail) ??\n    stringifyDetailValue(statusDetail.detail);\n  const context = [\n    stringifyDetailValue(statusDetail.phase),\n    stringifyDetailValue(statusDetail.kind),\n    stringifyDetailValue(statusDetail.source),\n    stringifyDetailValue(statusDetail.operation),\n    stringifyDetailValue(statusDetail.code),\n    stringifyDetailValue(statusDetail.status_code),\n  ].filter(Boolean);\n\n  if (primary && context.length > 0) {\n    return `${primary} (${context.join(\" \u00b7 \")})`;\n  }\n  if (primary) return primary;\n  if (context.length > 0) return context.join(\" \u00b7 \");\n  return JSON.stringify(statusDetail);\n}\n\nfunction RunInspectionSummary({ run }: { run: AutomationRun | undefined }) {\n  const { t } = useTranslation(\"openhands\");\n  if (!run) return null;\n\n  const display = getAutomationRunDisplay(run);\n  const taskSummary =\n    run.status === AutomationRunStatus.COMPLETED\n      ? display.summary\n      : getAutomationRunTaskSummary(run);\n  const taskMetadataText = display.customTaskMetadataText;\n  const taskStatus =\n    run.status === AutomationRunStatus.COMPLETED || display.taskOutcome\n      ? display.badgeStatus\n      : null;\n  const systemError = run.error_detail?.trim() || null;\n  const statusDetail = formatStatusDetail(run.status_detail);\n  const hasSystemDetails = systemError || statusDetail;\n\n  return (\n    <dl className=\"mt-4 grid gap-3 rounded-lg border border-border bg-black/20 p-3 text-xs\">\n      <div className=\"grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)] sm:items-center\">\n        <dt className=\"text-muted\">\n          {t(I18nKey.AUTOMATIONS$DETAIL$RUN_LABEL)}\n        </dt>\n        <dd>\n          <span className=\"inline-flex rounded-md bg-surface-raised px-2 py-0.5 font-mono text-[10px] font-medium text-muted\">\n            {run.status}\n          </span>\n        </dd>\n      </div>\n      <div className=\"grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)] sm:items-start\">\n        <dt className=\"pt-0.5 text-muted\">\n          {t(I18nKey.AUTOMATIONS$DETAIL$TASK_LABEL)}\n        </dt>\n        <dd className=\"min-w-0 space-y-2\">\n          {taskStatus ? <RunStatusBadge status={taskStatus} compact /> : null}\n          {taskSummary ? (\n            <p className=\"break-words text-content\">{taskSummary}</p>\n          ) : taskMetadataText ? (\n            <p className=\"text-muted\">\n              {t(I18nKey.AUTOMATIONS$DETAIL$CUSTOM_TASK_METADATA)}\n            </p>\n          ) : (\n            <p className=\"text-muted\">\n              {t(I18nKey.AUTOMATIONS$DETAIL$NO_TASK_OUTCOME)}\n            </p>\n          )}\n          {taskMetadataText ? (\n            <div className=\"min-w-0 space-y-1\">\n              <p className=\"text-muted\">\n                {t(I18nKey.AUTOMATIONS$DETAIL$TASK_METADATA)}\n              </p>\n              <pre\n                data-testid=\"automation-task-metadata\"\n                className=\"max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-black/30 p-2 font-mono text-[11px] leading-4 text-content\"\n              >\n                {taskMetadataText}\n              </pre>\n            </div>\n          ) : null}\n        </dd>\n      </div>\n      <div className=\"grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)] sm:items-start\">\n        <dt className=\"pt-0.5 text-muted\">\n          {t(I18nKey.AUTOMATIONS$DETAIL$SYSTEM_LABEL)}\n        </dt>\n        <dd className=\"min-w-0 space-y-1 break-words text-content\">\n          {hasSystemDetails ? (\n            <>\n              {systemError ? (\n                <p>\n                  <span className=\"text-muted\">\n                    {t(I18nKey.COMMON$ERROR)}:{\" \"}\n                  </span>\n                  {systemError}\n                </p>\n              ) : null}\n              {statusDetail ? (\n                <p>\n                  <span className=\"text-muted\">\n                    {t(I18nKey.AUTOMATIONS$DETAIL$STATUS_DETAIL)}:{\" \"}\n                  </span>\n                  {statusDetail}\n                </p>\n              ) : null}\n            </>\n          ) : (\n            <p className=\"text-muted\">\n              {t(I18nKey.AUTOMATIONS$DETAIL$NO_SYSTEM_ISSUES)}\n            </p>\n          )}\n        </dd>\n      </div>\n    </dl>\n  );\n}\n\nexport function RunLogsModal({\n  conversationId,\n  sandboxId,\n  bashCommandId,\n  isOpen,\n  onClose,\n  run,\n  automation,\n}: RunLogsModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [activeTab, setActiveTab] = useState<LogTab>(\"stdout\");\n\n  const {\n    data: outputs,\n    isFetching,\n    isResolvingConversation,\n    sandboxIssue,\n    conversationMissing,\n    error,\n  } = useBashCommandLogs({\n    conversationId,\n    sandboxId,\n    bashCommandId,\n    enabled: isOpen,\n  });\n\n  // Reset to the default tab whenever the modal opens for a different run.\n  useEffect(() => {\n    if (isOpen) setActiveTab(\"stdout\");\n  }, [isOpen, bashCommandId]);\n\n  // Close on Escape.\n  useEffect(() => {\n    if (!isOpen) return undefined;\n    const handler = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") onClose();\n    };\n    window.addEventListener(\"keydown\", handler);\n    return () => window.removeEventListener(\"keydown\", handler);\n  }, [isOpen, onClose]);\n\n  const { stdout, stderr } = useMemo(() => {\n    if (!outputs) return { stdout: \"\", stderr: \"\" };\n    return {\n      stdout: concatStream(outputs, \"stdout\"),\n      stderr: concatStream(outputs, \"stderr\"),\n    };\n  }, [outputs]);\n\n  if (!isOpen) return null;\n\n  const loading = isResolvingConversation || (isFetching && !outputs);\n  const noBashCommand = !bashCommandId;\n  const activeBody = activeTab === \"stdout\" ? stdout : stderr;\n\n  const tabBaseClass =\n    \"border-b-2 px-3 py-2 text-sm font-normal transition-colors focus:outline-none\";\n  const tabActiveClass = \"border-[var(--oh-primary)] text-contrast\";\n  const tabInactiveClass = \"border-transparent text-muted hover:text-content\";\n\n  return (\n    <div\n      className=\"fixed inset-0 z-50 flex items-center justify-center p-4\"\n      role=\"dialog\"\n      aria-modal=\"true\"\n      aria-label={t(I18nKey.AUTOMATIONS$DETAIL$LOGS_TITLE)}\n    >\n      <div\n        className=\"absolute inset-0 bg-black/60\"\n        onClick={onClose}\n        onKeyDown={(e) => {\n          if (e.key === \"Escape\") onClose();\n        }}\n        role=\"presentation\"\n      />\n      <div\n        data-testid=\"run-logs-body\"\n        className=\"relative flex max-h-[80vh] w-full max-w-3xl flex-col overflow-y-auto overscroll-contain rounded-xl border border-border bg-surface\"\n      >\n        <div className=\"sticky top-0 z-10 flex shrink-0 items-center justify-between gap-4 bg-surface px-6 pb-4 pt-6\">\n          <h2 className={modalTitleLgMediumClassName}>\n            {t(I18nKey.AUTOMATIONS$DETAIL$LOGS_TITLE)}\n          </h2>\n          <button\n            type=\"button\"\n            onClick={onClose}\n            className=\"text-muted hover:text-foreground\"\n            aria-label={t(I18nKey.AUTOMATIONS$CANCEL)}\n          >\n            <XMarkIcon className=\"size-5\" />\n          </button>\n        </div>\n\n        <div className=\"px-6 pb-6\">\n          <RunInspectionSummary run={run} />\n\n          <div\n            role=\"tablist\"\n            aria-label={t(I18nKey.AUTOMATIONS$DETAIL$LOGS_TITLE)}\n            className=\"mt-4 flex gap-1 border-b border-border\"\n          >\n            <button\n              type=\"button\"\n              role=\"tab\"\n              aria-selected={activeTab === \"stdout\"}\n              aria-controls=\"run-logs-panel-stdout\"\n              id=\"run-logs-tab-stdout\"\n              tabIndex={activeTab === \"stdout\" ? 0 : -1}\n              onClick={() => setActiveTab(\"stdout\")}\n              className={`${tabBaseClass} ${\n                activeTab === \"stdout\" ? tabActiveClass : tabInactiveClass\n              }`}\n            >\n              {t(I18nKey.AUTOMATIONS$DETAIL$LOGS_TAB_OUTPUT)}\n            </button>\n            <button\n              type=\"button\"\n              role=\"tab\"\n              aria-selected={activeTab === \"stderr\"}\n              aria-controls=\"run-logs-panel-stderr\"\n              id=\"run-logs-tab-stderr\"\n              tabIndex={activeTab === \"stderr\" ? 0 : -1}\n              onClick={() => setActiveTab(\"stderr\")}\n              className={`${tabBaseClass} ${\n                activeTab === \"stderr\" ? tabActiveClass : tabInactiveClass\n              }`}\n            >\n              {t(I18nKey.AUTOMATIONS$DETAIL$LOGS_TAB_ERROR)}\n            </button>\n          </div>\n\n          <div\n            role=\"tabpanel\"\n            id={`run-logs-panel-${activeTab}`}\n            aria-labelledby={`run-logs-tab-${activeTab}`}\n            className=\"mt-3 min-h-[12rem] rounded-lg border border-border bg-black/40 p-4 font-mono text-xs\"\n          >\n            {noBashCommand && (\n              <p className=\"text-muted italic\">\n                {t(I18nKey.AUTOMATIONS$DETAIL$LOGS_NO_COMMAND)}\n              </p>\n            )}\n\n            {!noBashCommand && conversationMissing && (\n              <p className=\"text-muted italic\">\n                {t(I18nKey.AUTOMATIONS$DETAIL$LOGS_CONVERSATION_MISSING)}\n              </p>\n            )}\n\n            {!noBashCommand && !conversationMissing && sandboxIssue && (\n              <p\n                data-testid={`run-logs-sandbox-issue-${sandboxIssue}`}\n                className=\"text-muted italic\"\n              >\n                {t(SANDBOX_ISSUE_I18N[sandboxIssue])}\n              </p>\n            )}\n\n            {!noBashCommand &&\n              !conversationMissing &&\n              !sandboxIssue &&\n              loading && (\n                <p className=\"text-muted italic\">\n                  {t(I18nKey.AUTOMATIONS$DETAIL$LOGS_LOADING)}\n                </p>\n              )}\n\n            {!noBashCommand &&\n              !conversationMissing &&\n              !sandboxIssue &&\n              !loading &&\n              error &&\n              !outputs && (\n                <p className=\"text-danger\">\n                  {t(I18nKey.AUTOMATIONS$DETAIL$LOGS_ERROR)}: {String(error)}\n                </p>\n              )}\n\n            {!loading && !sandboxIssue && outputs && (\n              <pre\n                data-testid={`run-logs-output-${activeTab}`}\n                className={`whitespace-pre-wrap break-words ${\n                  activeTab === \"stderr\" ? \"text-danger\" : \"text-content\"\n                }`}\n              >\n                {activeBody.length > 0 ? (\n                  activeBody\n                ) : (\n                  <span className=\"text-muted italic\">\n                    {t(I18nKey.AUTOMATIONS$DETAIL$LOGS_EMPTY)}\n                  </span>\n                )}\n              </pre>\n            )}\n          </div>\n\n          {run?.status === AutomationRunStatus.FAILED && (\n            <div className=\"mt-4 flex justify-end\">\n              <DebugAutomationButton\n                run={run}\n                automation={automation}\n                stderr={stderr}\n              />\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/detail/run-phase.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[28rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 76,
          "column": 20,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 76,
          "endColumn": 35
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[12rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 76,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 76,
          "endColumn": 53
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { Tooltip } from \"@heroui/react\";\nimport { AutomationRunStatus } from \"#/types/automation\";\nimport { cn } from \"#/utils/utils\";\n\n/**\n * Whether a run's phase is worth showing at all: it answers \"what is it doing\n * now\" or \"where did it stop\", so a finished, cancelled or skipped run has\n * nothing to add. Shared by every surface, so one run cannot show a phase on\n * one screen and hide it on another.\n */\nexport function shouldShowRunPhase(\n  status: AutomationRunStatus | null | undefined,\n): boolean {\n  return (\n    status === AutomationRunStatus.FAILED ||\n    status === AutomationRunStatus.PENDING ||\n    status === AutomationRunStatus.RUNNING\n  );\n}\n\n/**\n * The one place a stored phase becomes text, so a run cannot read one way on\n * a card and another way in that card's own tooltip.\n *\n * Automation \u22651.9.0 sends `current_phase` as already user-facing copy \u2014 not a\n * translation key, never passed through `t()`. Whitespace-only is treated as\n * absent: the service stores such a field as sent, and rendering it would put\n * an empty span on the row.\n */\nexport function resolveRunPhaseText(\n  currentPhase?: string | null,\n): string | null {\n  return currentPhase?.trim() || null;\n}\n\ninterface RunPhaseProps {\n  /** `AutomationRun.current_phase` \u2014 `null`/absent means no phase reported. */\n  currentPhase?: string | null;\n  /** More width before clipping, for rows far wider than a card. */\n  wide?: boolean;\n}\n\n/**\n * A run's current or last-known phase, clipped to the room the surface has\n * with the full text one hover away. The string is data, not interface copy,\n * so it is rendered as-is.\n *\n * The text stays in the accessibility tree rather than hiding behind an\n * accessible name on a focusable wrapper. Every surface nests this inside a\n * link \u2014 the activity log's `<a>`, the cards' `role=\"link\"` \u2014 where a tab\n * stop is invalid interactive nesting, and Enter on it bubbled to the card\n * and navigated the user away from the text they were trying to read.\n * Truncation is CSS only, so the full label is already in the DOM and reads\n * in full; the tooltip is the sighted mouse user's route to it.\n */\nexport function RunPhase({ currentPhase, wide = false }: RunPhaseProps) {\n  const text = resolveRunPhaseText(currentPhase);\n  if (!text) return null;\n\n  return (\n    <Tooltip\n      content={text}\n      placement=\"top\"\n      closeDelay={100}\n      disableAnimation={import.meta.env.MODE === \"test\"}\n      classNames={{\n        content:\n          \"max-w-xs whitespace-pre-wrap break-words rounded-xl border border-border bg-base-secondary px-3 py-2 text-left text-xs text-contrast shadow-xl\",\n      }}\n    >\n      <span className=\"flex min-w-0 cursor-default items-center gap-1\">\n        <span\n          data-testid=\"run-phase\"\n          className={cn(\n            \"min-w-0 truncate text-xs text-muted\",\n            wide ? \"max-w-[28rem]\" : \"max-w-[12rem]\",\n          )}\n        >\n          {text}\n        </span>\n      </span>\n    </Tooltip>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/detail/run-status-badge.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 184,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 184,
          "endColumn": 59
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { CircleAlert, CircleHelp, Loader2 } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\nimport CheckCircleIcon from \"#/icons/check-circle.svg?react\";\nimport XCircleIcon from \"#/icons/x-circle.svg?react\";\nimport ClockIcon from \"#/icons/clock.svg?react\";\nimport { AutomationRunStatus } from \"#/types/automation\";\nimport {\n  getAutomationRunBadgeLabelKey,\n  type AutomationRunBadgeStatus,\n} from \"#/utils/automation-run-display\";\nimport { cn } from \"#/utils/utils\";\n\ninterface RunStatusBadgeProps {\n  status: AutomationRunBadgeStatus;\n  /**\n   * Icon-only mark (no pill). Status text is available via hover tooltip and\n   * `aria-label`, unless {@link showLabel} is also set.\n   */\n  iconOnly?: boolean;\n  /** With `iconOnly`, render the status word next to the icon (still no pill). */\n  showLabel?: boolean;\n  /** Smaller pill for dense rows (e.g. home activity meta line). */\n  compact?: boolean;\n}\n\nconst statusConfig: Record<string, { style: string; iconTone: string }> = {\n  [AutomationRunStatus.COMPLETED]: {\n    style: \"bg-semantic-success/10 text-semantic-success\",\n    iconTone: \"text-semantic-success\",\n  },\n  success: {\n    style: \"bg-semantic-success/10 text-semantic-success\",\n    iconTone: \"text-semantic-success\",\n  },\n  [AutomationRunStatus.FAILED]: {\n    style: \"bg-semantic-danger/10 text-danger\",\n    iconTone: \"text-danger\",\n  },\n  failed: {\n    style: \"bg-semantic-danger/10 text-danger\",\n    iconTone: \"text-danger\",\n  },\n  blocked: {\n    style: \"bg-warning/10 text-warning\",\n    iconTone: \"text-warning\",\n  },\n  partial_success: {\n    style: \"bg-warning/10 text-warning\",\n    iconTone: \"text-warning\",\n  },\n  unknown: {\n    style: \"bg-warning/10 text-warning\",\n    iconTone: \"text-warning\",\n  },\n  [AutomationRunStatus.PENDING]: {\n    style: \"bg-surface-raised text-muted\",\n    iconTone: \"text-muted\",\n  },\n  [AutomationRunStatus.RUNNING]: {\n    style: \"bg-surface-raised text-muted\",\n    iconTone: \"text-muted\",\n  },\n  [AutomationRunStatus.CANCELLED]: {\n    style: \"bg-surface-raised text-muted\",\n    iconTone: \"text-muted\",\n  },\n  [AutomationRunStatus.SKIPPED]: {\n    style: \"bg-surface-raised text-muted\",\n    iconTone: \"text-muted\",\n  },\n};\n\nfunction StatusIcon({\n  status,\n  compact = false,\n}: {\n  status: AutomationRunBadgeStatus;\n  compact?: boolean;\n}) {\n  const iconClass = compact ? \"size-3\" : \"size-3.5\";\n  switch (status) {\n    case AutomationRunStatus.COMPLETED:\n    case \"success\":\n      return (\n        <CheckCircleIcon\n          data-testid=\"run-status-icon-completed\"\n          className={iconClass}\n        />\n      );\n    case AutomationRunStatus.FAILED:\n    case \"failed\":\n      return (\n        <XCircleIcon\n          data-testid=\"run-status-icon-failed\"\n          className={iconClass}\n        />\n      );\n    case AutomationRunStatus.RUNNING:\n      return (\n        <Loader2\n          data-testid=\"run-status-icon-running\"\n          className={cn(iconClass, \"animate-spin motion-reduce:animate-none\")}\n          aria-hidden=\"true\"\n        />\n      );\n    case \"blocked\":\n    case \"partial_success\":\n      return (\n        <CircleAlert\n          data-testid=\"run-status-icon-warning\"\n          className={iconClass}\n          aria-hidden=\"true\"\n        />\n      );\n    case \"unknown\":\n      return (\n        <CircleHelp\n          data-testid=\"run-status-icon-needs-review\"\n          className={iconClass}\n          aria-hidden=\"true\"\n        />\n      );\n    default:\n      return (\n        <ClockIcon\n          data-testid=\"run-status-icon-pending\"\n          className={iconClass}\n        />\n      );\n  }\n}\n\nexport function RunStatusBadge({\n  status,\n  iconOnly = false,\n  showLabel = false,\n  compact = false,\n}: RunStatusBadgeProps) {\n  const { t } = useTranslation(\"openhands\");\n  // Degrade instead of crashing on a status the backend added after this enum.\n  const config =\n    statusConfig[status] ?? statusConfig[AutomationRunStatus.PENDING];\n  const label = t(getAutomationRunBadgeLabelKey(status));\n\n  if (iconOnly) {\n    if (showLabel) {\n      return (\n        <span\n          data-testid=\"run-status-badge-icon\"\n          className={cn(\n            \"inline-flex min-w-0 shrink-0 items-center gap-1.5 text-xs font-medium\",\n            config.iconTone,\n          )}\n        >\n          <StatusIcon status={status} compact={compact} />\n          <span className=\"truncate\">{label}</span>\n        </span>\n      );\n    }\n\n    return (\n      <StyledTooltip content={label} placement=\"top\">\n        <span\n          role=\"img\"\n          aria-label={label}\n          data-testid=\"run-status-badge-icon\"\n          className={cn(\n            \"inline-flex shrink-0 cursor-default items-center justify-center\",\n            config.iconTone,\n          )}\n        >\n          <StatusIcon status={status} compact={compact} />\n        </span>\n      </StyledTooltip>\n    );\n  }\n\n  return (\n    <span\n      className={cn(\n        \"inline-flex items-center rounded-full font-medium\",\n        compact\n          ? \"gap-1 pl-1 pr-1.5 py-0 text-[10px] leading-4\"\n          : \"gap-1.5 pl-2 pr-2.5 py-1 text-xs\",\n        config.style,\n      )}\n    >\n      <StatusIcon status={status} compact={compact} />\n      {label}\n    </span>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/detail/script-section.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 99,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 99,
          "endColumn": 88
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 101,
          "column": 32,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 101,
          "endColumn": 145
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 104,
          "column": 37,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 104,
          "endColumn": 123
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport TerminalIcon from \"#/icons/terminal.svg?react\";\nimport { HighlightedSourceView } from \"#/components/features/files-tab/highlighted-source-view\";\nimport { useAutomationTarballFiles } from \"#/hooks/query/use-automation-tarball-files\";\nimport type { Automation } from \"#/types/automation\";\nimport type { TarEntry } from \"#/utils/tar-unpack\";\nimport { SectionCard } from \"./section-card\";\n\ninterface ScriptSectionProps {\n  automation: Automation;\n}\n\n/**\n * The bundle member the entrypoint runs, if it names one: the first token\n * of the command that is a file in the archive (`python main.py` \u2192 `main.py`).\n */\nfunction findEntrypointPath(\n  entrypoint: string | undefined,\n  entries: TarEntry[],\n): string | null {\n  if (!entrypoint) return null;\n  const paths = new Set(entries.map((entry) => entry.path));\n  for (const token of entrypoint.split(/\\s+/)) {\n    const candidate = token.replace(/^\\.\\//, \"\");\n    if (paths.has(candidate)) return candidate;\n  }\n  return null;\n}\n\n/** Entrypoint first, then the rest by path. */\nfunction orderEntries(\n  entries: TarEntry[],\n  entrypointPath: string | null,\n): TarEntry[] {\n  return [...entries].sort((a, b) => {\n    if (a.path === entrypointPath) return -1;\n    if (b.path === entrypointPath) return 1;\n    return a.path.localeCompare(b.path);\n  });\n}\n\n/**\n * What a script automation runs: the files of its uploaded bundle, read\n * back from the same tarball the kebab menu offers for download. Takes the\n * place of the prompt section for automations that have no prompt.\n */\nexport function ScriptSection({ automation }: ScriptSectionProps) {\n  const { t } = useTranslation(\"openhands\");\n  const {\n    data: entries,\n    isPending,\n    isError,\n  } = useAutomationTarballFiles(automation);\n\n  const entrypointPath = findEntrypointPath(\n    automation.entrypoint,\n    entries ?? [],\n  );\n  // A bundle the service can't hand back (external URL, deleted upload) or\n  // that isn't an archive gets the same answer: download it instead.\n  const unavailable = isError || entries?.length === 0;\n\n  return (\n    <SectionCard\n      icon={<TerminalIcon className=\"size-4\" />}\n      title={t(I18nKey.AUTOMATIONS$DETAIL$SCRIPT)}\n    >\n      <div className=\"flex flex-col gap-4\">\n        {automation.entrypoint ? (\n          <code\n            data-testid=\"automation-script-entrypoint\"\n            className=\"self-start rounded-md bg-surface-raised px-2 py-1 font-mono text-xs text-content\"\n          >\n            {automation.entrypoint}\n          </code>\n        ) : null}\n\n        {isPending ? (\n          <p className=\"text-sm italic text-muted\">\n            {t(I18nKey.AUTOMATIONS$DETAIL$SCRIPT_LOADING)}\n          </p>\n        ) : null}\n\n        {unavailable ? (\n          <p\n            data-testid=\"automation-script-unavailable\"\n            className=\"text-sm text-muted\"\n          >\n            {t(I18nKey.AUTOMATIONS$DETAIL$SCRIPT_UNAVAILABLE)}\n          </p>\n        ) : null}\n\n        {entries && entries.length > 0\n          ? orderEntries(entries, entrypointPath).map((entry) => (\n              <div\n                key={entry.path}\n                data-testid=\"automation-script-file\"\n                className=\"overflow-hidden rounded-lg border border-[var(--oh-border)]\"\n              >\n                <div className=\"flex items-center gap-2 border-b border-[var(--oh-border)] bg-black/20 px-3 py-2 font-mono text-xs text-content\">\n                  <span className=\"truncate\">{entry.path}</span>\n                  {entry.path === entrypointPath ? (\n                    <span className=\"shrink-0 rounded-md bg-surface-raised px-2 py-0.5 text-[10px] font-medium text-muted\">\n                      {t(I18nKey.AUTOMATIONS$DETAIL$SCRIPT_ENTRYPOINT)}\n                    </span>\n                  ) : null}\n                </div>\n                {entry.text === null ? (\n                  <p className=\"px-3 py-2 text-xs italic text-muted\">\n                    {t(I18nKey.AUTOMATIONS$DETAIL$SCRIPT_BINARY_FILE)}\n                  </p>\n                ) : (\n                  <div className=\"max-h-96 overflow-auto\">\n                    <HighlightedSourceView\n                      path={entry.path}\n                      text={entry.text}\n                    />\n                  </div>\n                )}\n              </div>\n            ))\n          : null}\n      </div>\n    </SectionCard>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/import-automation-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[85vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 171,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 171,
          "endColumn": 138
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[min(36rem,calc(100vw-2rem))]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 171,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 171,
          "endColumn": 138
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useRef, useState, type DragEvent } from \"react\";\nimport { FileUp } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { AUTOMATION_FILE_FORMAT_DOCS_URL } from \"#/manifests/automation-interface\";\nimport type { AutomationSpec } from \"#/types/automation\";\nimport { formatEventOn } from \"#/utils/automation-schedule\";\nimport { cn } from \"#/utils/utils\";\nimport { modalTitleLgClassName } from \"#/utils/modal-classes\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\n\ninterface ImportAutomationModalProps {\n  isOpen: boolean;\n  spec: AutomationSpec | null;\n  isImporting: boolean;\n  onClose: () => void;\n  onImport: () => void;\n  onFile: (file: File) => void;\n}\n\nfunction PreviewField({ label, value }: { label: string; value: string }) {\n  return (\n    <div className=\"flex flex-col gap-1\">\n      <dt className=\"text-xs font-medium uppercase tracking-wide text-muted\">\n        {label}\n      </dt>\n      <dd className=\"whitespace-pre-wrap break-words text-sm text-content\">\n        {value}\n      </dd>\n    </div>\n  );\n}\n\nfunction takeDroppedFile(event: DragEvent<HTMLElement>): File | null {\n  const files = event.dataTransfer?.files;\n  return files && files.length > 0 ? files[0]! : null;\n}\n\nfunction ImportAutomationPicker({ onFile }: { onFile: (file: File) => void }) {\n  const { t } = useTranslation(\"openhands\");\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [isDragging, setIsDragging] = useState(false);\n\n  const openFinder = () => inputRef.current?.click();\n\n  return (\n    <div className=\"flex flex-col gap-5 px-6 pb-6\">\n      <p className=\"text-sm leading-relaxed text-tertiary-light\">\n        {t(I18nKey.AUTOMATIONS$IMPORT_EXPLAIN)}{\" \"}\n        <a\n          href={AUTOMATION_FILE_FORMAT_DOCS_URL}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          data-testid=\"import-automation-format-docs\"\n          className=\"text-contrast no-underline transition-colors hover:text-contrast\"\n        >\n          {t(I18nKey.AUTOMATIONS$IMPORT_FORMAT_DOCS)}\n        </a>\n      </p>\n      <p className=\"text-sm text-tertiary-light\">\n        {t(I18nKey.AUTOMATIONS$IMPORT_DISABLED_NOTICE)}\n      </p>\n\n      <input\n        ref={inputRef}\n        type=\"file\"\n        accept=\"application/json,.json\"\n        className=\"hidden\"\n        data-testid=\"automations-import-file\"\n        onChange={() => {\n          const input = inputRef.current;\n          const file = input?.files?.[0];\n          if (input) {\n            input.value = \"\";\n          }\n          if (file) onFile(file);\n        }}\n      />\n\n      <div\n        data-testid=\"import-automation-dropzone\"\n        data-active={isDragging ? \"true\" : \"false\"}\n        onClick={openFinder}\n        onDragEnter={(event) => {\n          event.preventDefault();\n          setIsDragging(true);\n        }}\n        onDragOver={(event) => {\n          event.preventDefault();\n          setIsDragging(true);\n        }}\n        onDragLeave={(event) => {\n          if (event.currentTarget.contains(event.relatedTarget as Node)) {\n            return;\n          }\n          setIsDragging(false);\n        }}\n        onDrop={(event) => {\n          event.preventDefault();\n          setIsDragging(false);\n          const file = takeDroppedFile(event);\n          if (file) onFile(file);\n        }}\n        className={cn(\n          \"flex cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border border-dashed px-6 py-10 text-center\",\n          isDragging\n            ? \"border-focus bg-interactive-hover\"\n            : \"border-border bg-surface\",\n        )}\n      >\n        <FileUp className=\"size-8 text-muted\" aria-hidden />\n        <p className=\"text-sm font-medium text-content\">\n          {t(I18nKey.AUTOMATIONS$IMPORT_DROPZONE)}\n        </p>\n        <p className=\"text-xs text-tertiary-light\">\n          {t(I18nKey.AUTOMATIONS$IMPORT_OR)}\n        </p>\n        <BrandButton\n          testId=\"import-automation-choose-file\"\n          type=\"button\"\n          variant=\"secondary\"\n          onClick={(event) => {\n            event?.stopPropagation();\n            openFinder();\n          }}\n        >\n          {t(I18nKey.AUTOMATIONS$IMPORT_CHOOSE_FILE)}\n        </BrandButton>\n      </div>\n    </div>\n  );\n}\n\nexport function ImportAutomationModal({\n  isOpen,\n  spec,\n  isImporting,\n  onClose,\n  onImport,\n  onFile,\n}: ImportAutomationModalProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  if (!isOpen) return null;\n\n  const trigger = spec\n    ? spec.trigger.type === \"event\"\n      ? [spec.trigger.source, formatEventOn(spec.trigger.on)]\n          .filter(Boolean)\n          .join(\": \")\n      : [\n          spec.trigger.schedule_human ?? spec.trigger.schedule,\n          spec.timezone ?? spec.trigger.timezone,\n        ]\n          .filter(Boolean)\n          .join(\" \u00b7 \")\n    : \"\";\n\n  return (\n    <ModalBackdrop\n      onClose={isImporting ? undefined : onClose}\n      closeOnEscape={!isImporting}\n      closeOnBackdropClick={!isImporting}\n      aria-label={t(I18nKey.AUTOMATIONS$IMPORT)}\n    >\n      <div\n        data-testid=\"import-automation-modal\"\n        data-view={spec ? \"preview\" : \"picker\"}\n        className=\"relative flex max-h-[85vh] w-[min(36rem,calc(100vw-2rem))] flex-col rounded-xl border border-border bg-base-secondary\"\n      >\n        <ModalCloseButton\n          onClose={onClose}\n          testId=\"import-automation-modal-close\"\n          disabled={isImporting}\n        />\n        <header className=\"px-6 pb-4 pt-6\">\n          <h2 className={cn(\"pr-6\", modalTitleLgClassName)}>\n            {t(I18nKey.AUTOMATIONS$IMPORT)}\n          </h2>\n          {spec ? (\n            <p className=\"mt-2 text-sm text-tertiary-light\">\n              {t(I18nKey.AUTOMATIONS$IMPORT_PREVIEW_DESCRIPTION)}\n            </p>\n          ) : null}\n        </header>\n\n        {spec ? (\n          <>\n            <dl className=\"mx-6 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto rounded-xl border border-border bg-surface px-4 py-4\">\n              <PreviewField\n                label={t(I18nKey.AUTOMATIONS$NAME)}\n                value={spec.name}\n              />\n              <PreviewField\n                label={t(I18nKey.AUTOMATIONS$DETAIL$TRIGGER)}\n                value={trigger}\n              />\n              <PreviewField\n                label={t(I18nKey.AUTOMATIONS$PROMPT)}\n                value={spec.prompt ?? \"\"}\n              />\n              {spec.plugins && spec.plugins.length > 0 ? (\n                <PreviewField\n                  label={t(I18nKey.AUTOMATIONS$DETAIL$PLUGINS)}\n                  value={spec.plugins.join(\", \")}\n                />\n              ) : null}\n            </dl>\n\n            <footer className=\"px-6 py-5\">\n              <p className=\"mb-4 text-sm text-tertiary-light\">\n                {t(I18nKey.AUTOMATIONS$IMPORT_DISABLED_NOTICE)}\n              </p>\n              <div className=\"flex justify-end gap-3\">\n                <BrandButton\n                  testId=\"import-automation-cancel\"\n                  type=\"button\"\n                  variant=\"secondary\"\n                  onClick={onClose}\n                  isDisabled={isImporting}\n                >\n                  {t(I18nKey.AUTOMATIONS$CANCEL)}\n                </BrandButton>\n                <BrandButton\n                  testId=\"import-automation-confirm\"\n                  type=\"button\"\n                  variant=\"primary\"\n                  onClick={onImport}\n                  isDisabled={isImporting}\n                  aria-busy={isImporting}\n                >\n                  {isImporting\n                    ? t(I18nKey.AUTOMATIONS$IMPORTING)\n                    : t(I18nKey.AUTOMATIONS$IMPORT)}\n                </BrandButton>\n              </div>\n            </footer>\n          </>\n        ) : (\n          <ImportAutomationPicker onFile={onFile} />\n        )}\n      </div>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/kebab-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-w-[10rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 99,
          "column": 60,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 99,
          "endColumn": 75
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 137,
          "column": 25,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 137,
          "endColumn": 36
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useState, useEffect, useLayoutEffect, useRef } from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport KebabVerticalIcon from \"#/icons/kebab-vertical.svg?react\";\nimport { ContextMenuListItem } from \"#/components/features/context-menu/context-menu-list-item\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { cn } from \"#/utils/utils\";\nimport { automationIconActionButtonClassName } from \"./automation-action-button-classes\";\nimport { KebabMenuItemContent } from \"./kebab-menu-item-content\";\n\nexport interface KebabMenuItem {\n  label: string;\n  icon: React.ReactNode;\n  onClick: () => void;\n  disabled?: boolean;\n}\n\ninterface KebabMenuProps {\n  items: KebabMenuItem[];\n  triggerClassName?: string;\n}\n\nexport function KebabMenu({ items, triggerClassName }: KebabMenuProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [open, setOpen] = useState(false);\n  const [portalStyle, setPortalStyle] = useState<React.CSSProperties>();\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const menuRef = useRef<HTMLUListElement>(null);\n\n  useLayoutEffect(() => {\n    if (!open || !triggerRef.current) return undefined;\n\n    const updatePosition = () => {\n      const rect = triggerRef.current?.getBoundingClientRect();\n      if (!rect) return;\n\n      const gap = 4;\n      // The real height is only measurable once the portal has painted; the\n      // first pass falls back to an items-derived estimate (~36px per row).\n      const measured = menuRef.current?.getBoundingClientRect().height ?? 0;\n      const menuHeight = measured || items.length * 36;\n      const overflowsBelow =\n        rect.bottom + gap + menuHeight > window.innerHeight;\n\n      setPortalStyle({\n        position: \"fixed\",\n        zIndex: 9999,\n        // Flip above the trigger when the menu would clip at the viewport\n        // bottom. Bottom-anchoring keeps it hugging the trigger at any height.\n        ...(overflowsBelow\n          ? { bottom: window.innerHeight - rect.top + gap }\n          : { top: rect.bottom + gap }),\n        right: window.innerWidth - rect.right,\n      });\n    };\n\n    updatePosition();\n    const frame = window.requestAnimationFrame(updatePosition);\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.cancelAnimationFrame(frame);\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [open, items.length]);\n\n  useEffect(() => {\n    if (!open) return undefined;\n\n    const handleClickOutside = (event: MouseEvent) => {\n      const target = event.target as Node;\n      if (\n        triggerRef.current?.contains(target) ||\n        menuRef.current?.contains(target)\n      ) {\n        return;\n      }\n      setOpen(false);\n    };\n\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        setOpen(false);\n      }\n    };\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    document.addEventListener(\"keydown\", handleEscape);\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n      document.removeEventListener(\"keydown\", handleEscape);\n    };\n  }, [open]);\n\n  const menu =\n    open && portalStyle ? (\n      <ContextMenu ref={menuRef} theme=\"popover\" className=\"min-w-[10rem]\">\n        {items.map((item) => (\n          <li key={item.label}>\n            <ContextMenuListItem\n              onClick={(event) => {\n                event.stopPropagation();\n                item.onClick();\n                setOpen(false);\n              }}\n              isDisabled={item.disabled}\n              className=\"group\"\n            >\n              <KebabMenuItemContent icon={item.icon} label={item.label} />\n            </ContextMenuListItem>\n          </li>\n        ))}\n      </ContextMenu>\n    ) : null;\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        onClick={(e) => {\n          e.stopPropagation();\n          setOpen((current) => !current);\n        }}\n        className={cn(automationIconActionButtonClassName, triggerClassName)}\n        aria-label={t(I18nKey.AUTOMATIONS$ACTIONS_MENU)}\n        aria-expanded={open}\n        aria-haspopup=\"menu\"\n      >\n        <KebabVerticalIcon className=\"size-4\" />\n      </button>\n\n      {open && portalStyle && typeof document !== \"undefined\"\n        ? ReactDOM.createPortal(\n            <div style={portalStyle}>{menu}</div>,\n            document.body,\n          )\n        : null}\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/recommended-automations-rail.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"from-[var(--oh-scroll-fade-from,var(--oh-color-base))]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 235,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 235,
          "endColumn": 101
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"from-[var(--oh-scroll-fade-from,var(--oh-color-base))]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 246,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 246,
          "endColumn": 101
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "jsx-a11y/no-noninteractive-element-interactions",
          "severity": 2,
          "message": "Non-interactive elements should not be assigned mouse or keyboard event listeners.",
          "line": 190,
          "column": 9,
          "nodeType": "JSXOpeningElement",
          "endLine": 199,
          "endColumn": 10,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "mouse drag-to-scroll is a pointer convenience; keyboard users scroll the list natively via the focusable cards."
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import {\n  createElement,\n  useCallback,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport type { RecommendedAutomation } from \"@openhands/extensions/automations\";\nimport {\n  INTEGRATION_CATALOG as MCP_MARKETPLACE,\n  type IntegrationCatalogEntry as MarketplaceEntry,\n} from \"@openhands/extensions/integrations\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { McpLogoBadge } from \"#/components/features/mcp-logo-badge\";\nimport { getMarketplaceEntryById } from \"#/utils/mcp-marketplace-utils\";\nimport {\n  getAutomationIcon,\n  getIntegrationIds,\n} from \"#/utils/automation-catalog\";\nimport {\n  flattenRecommendedRailGroups,\n  getRecommendedRailGroups,\n} from \"#/utils/recommended-automation-rail\";\nimport { readScrollFadeState } from \"#/utils/scroll-fade-state\";\nimport { useDragScroll } from \"#/hooks/use-drag-scroll\";\nimport type { Automation } from \"#/types/automation\";\nimport { AUTOMATION_STACK_SECTION_BOTTOM_CLASS } from \"#/utils/automation-stack-section\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  extensionModuleCardInteractiveClassName,\n  extensionModuleCardSurfaceClassName,\n} from \"#/utils/extension-module-card-classes\";\nimport { X } from \"lucide-react\";\nimport { hoverRevealActionClassName } from \"#/utils/hover-reveal-classes\";\n\ninterface RecommendedAutomationsRailProps {\n  installedAutomations: readonly Pick<Automation, \"name\">[];\n  /** Catalog entries the active backend's deployment does not offer. */\n  hiddenAutomationIds?: ReadonlySet<string>;\n  onSelect: (automation: RecommendedAutomation) => void;\n  className?: string;\n  dismissible?: boolean;\n  onDismiss?: () => void;\n}\n\n/** Current catalog tiles reserve 40px for the icon; keep that row height. */\nconst RAIL_ICON_ROW_CLASS_NAME = \"flex h-10 items-start\";\n\nfunction integrationEntries(\n  automation: RecommendedAutomation,\n): MarketplaceEntry[] {\n  return getIntegrationIds(automation).flatMap((id) => {\n    const entry = getMarketplaceEntryById(id, MCP_MARKETPLACE);\n    return entry ? [entry] : [];\n  });\n}\n\nfunction RailIntegrationIcons({\n  automation,\n  entries,\n  testId,\n}: {\n  automation: RecommendedAutomation;\n  entries: MarketplaceEntry[];\n  testId: string;\n}) {\n  const visibleEntries = entries.slice(0, 4);\n  const isOverlap = visibleEntries.length > 1;\n  // A declared glyph replaces the logos rather than joining them: it is the\n  // whole identity of an entry that names one.\n  const Icon = getAutomationIcon(automation);\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-testid={testId}\n      data-layout={isOverlap && !Icon ? \"overlap\" : undefined}\n      className={cn(\n        RAIL_ICON_ROW_CLASS_NAME,\n        isOverlap && !Icon && \"-space-x-2\",\n      )}\n    >\n      {Icon ? (\n        <McpLogoBadge\n          entry={null}\n          size=\"base\"\n          fallback={createElement(Icon, {\n            className: \"h-5 w-5\",\n            strokeWidth: 2.25,\n          })}\n        />\n      ) : visibleEntries.length === 0 ? (\n        <McpLogoBadge entry={null} size=\"base\" />\n      ) : (\n        visibleEntries.map((entry) => (\n          <McpLogoBadge\n            key={entry.id}\n            entry={entry}\n            size=\"base\"\n            className={isOverlap ? \"ring-2 ring-base-secondary\" : undefined}\n          />\n        ))\n      )}\n    </span>\n  );\n}\n\nexport function RecommendedAutomationsRail({\n  installedAutomations,\n  hiddenAutomationIds,\n  onSelect,\n  className,\n  dismissible = false,\n  onDismiss,\n}: RecommendedAutomationsRailProps) {\n  const { t } = useTranslation(\"openhands\");\n  const items = flattenRecommendedRailGroups(\n    getRecommendedRailGroups(installedAutomations, hiddenAutomationIds),\n  );\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { handleMouseDown, handleClickCapture, handleDragStart } =\n    useDragScroll(scrollRef);\n  const [fadeState, setFadeState] = useState({ left: false, right: false });\n\n  const updateFadeState = useCallback(() => {\n    const element = scrollRef.current;\n    if (!element) return;\n    const next = readScrollFadeState(element);\n    setFadeState((current) =>\n      current.left === next.left && current.right === next.right\n        ? current\n        : next,\n    );\n  }, []);\n\n  const itemIds = items.map((item) => item.id).join(\",\");\n\n  useLayoutEffect(() => {\n    updateFadeState();\n\n    const element = scrollRef.current;\n    if (!element) return undefined;\n\n    const resizeObserver = new ResizeObserver(updateFadeState);\n    resizeObserver.observe(element);\n    Array.from(element.children).forEach((child) => {\n      resizeObserver.observe(child);\n    });\n\n    return () => resizeObserver.disconnect();\n  }, [updateFadeState, itemIds]);\n\n  if (items.length === 0) return null;\n\n  return (\n    <section\n      data-testid=\"recommended-automations-rail\"\n      aria-label={t(I18nKey.RECOMMENDED_AUTOMATIONS$SECTION_LABEL)}\n      className={cn(\n        \"group w-full\",\n        AUTOMATION_STACK_SECTION_BOTTOM_CLASS,\n        className,\n      )}\n    >\n      <div className=\"mb-2 flex items-center justify-between gap-2\">\n        <h2 className=\"text-sm font-medium text-foreground\">\n          {t(I18nKey.RECOMMENDED_AUTOMATIONS$SECTION_LABEL)}\n        </h2>\n        {/* @spec HL-002 \u2014 Named X, revealed on hover or focus, always visible on touch */}\n        {dismissible && onDismiss && (\n          <button\n            type=\"button\"\n            data-testid=\"recommended-automations-rail-dismiss\"\n            aria-label={t(I18nKey.RECOMMENDED_AUTOMATIONS$DISMISS_LABEL)}\n            onClick={onDismiss}\n            className={cn(\n              \"inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted\",\n              \"transition-opacity motion-reduce:transition-none hover:text-content\",\n              hoverRevealActionClassName(),\n            )}\n          >\n            <X className=\"size-4\" />\n          </button>\n        )}\n      </div>\n\n      <div className=\"relative\">\n        {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- mouse drag-to-scroll is a pointer convenience; keyboard users scroll the list natively via the focusable cards. */}\n        <div\n          ref={scrollRef}\n          role=\"list\"\n          data-testid=\"recommended-automations-rail-scroll\"\n          onScroll={updateFadeState}\n          onMouseDown={handleMouseDown}\n          onClickCapture={handleClickCapture}\n          onDragStart={handleDragStart}\n          className=\"flex flex-nowrap gap-3 overflow-x-auto scrollbar-hide select-none\"\n        >\n          {items.map((automation) => (\n            <div key={automation.id} role=\"listitem\">\n              <button\n                type=\"button\"\n                data-testid={`recommended-automation-rail-card-${automation.id}`}\n                onClick={() => onSelect(automation)}\n                className={cn(\n                  \"flex w-55 shrink-0 flex-col gap-3 p-3 text-left\",\n                  extensionModuleCardSurfaceClassName,\n                  extensionModuleCardInteractiveClassName,\n                )}\n              >\n                <RailIntegrationIcons\n                  automation={automation}\n                  entries={integrationEntries(automation)}\n                  testId={`recommended-automation-rail-icon-${automation.id}`}\n                />\n                <div className=\"min-w-0\">\n                  <h3 className=\"truncate text-sm font-semibold text-contrast\">\n                    {automation.name}\n                  </h3>\n                  <p className=\"mt-0.5 line-clamp-2 text-xs leading-relaxed text-tertiary-light\">\n                    {automation.description}\n                  </p>\n                </div>\n              </button>\n            </div>\n          ))}\n        </div>\n        <div\n          aria-hidden\n          data-testid=\"recommended-automations-rail-fade-left\"\n          data-visible={fadeState.left ? \"true\" : \"false\"}\n          className={cn(\n            \"pointer-events-none absolute inset-y-0 left-0 z-10 w-10\",\n            \"bg-gradient-to-r from-[var(--oh-scroll-fade-from,var(--oh-color-base))] to-transparent\",\n            \"transition-opacity duration-300 ease-out motion-reduce:transition-none\",\n            fadeState.left ? \"opacity-100\" : \"opacity-0\",\n          )}\n        />\n        <div\n          aria-hidden\n          data-testid=\"recommended-automations-rail-fade-right\"\n          data-visible={fadeState.right ? \"true\" : \"false\"}\n          className={cn(\n            \"pointer-events-none absolute inset-y-0 right-0 z-10 w-10\",\n            \"bg-gradient-to-l from-[var(--oh-scroll-fade-from,var(--oh-color-base))] to-transparent\",\n            \"transition-opacity duration-300 ease-out motion-reduce:transition-none\",\n            fadeState.right ? \"opacity-100\" : \"opacity-0\",\n          )}\n        />\n      </div>\n    </section>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/setup/automation-setup-panel.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 142,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 142,
          "endColumn": 175
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-success)]/10\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 466,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 466,
          "endColumn": 61
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-success)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 466,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 466,
          "endColumn": 61
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 472,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 472,
          "endColumn": 115
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-surface)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 472,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 472,
          "endColumn": 115
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 602,
          "column": 24,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 602,
          "endColumn": 85
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 607,
          "column": 42,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 607,
          "endColumn": 78
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 616,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 616,
          "endColumn": 145
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-surface)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 616,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 616,
          "endColumn": 145
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1853,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1853,
          "endColumn": 62
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1944,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1944,
          "endColumn": 148
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[scrollbar-gutter:stable]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1956,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1956,
          "endColumn": 105
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[800px]\" hardcodes an off-token value. Use \"max-w-200\" instead (same value, on the scale).",
          "line": 1960,
          "column": 26,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 1960,
          "endColumn": 84,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "max-w-200"
              },
              "fix": {
                "range": [
                  63777,
                  63835
                ],
                "text": "\"mx-auto flex w-full min-w-0 max-w-200 flex-col gap-6\""
              },
              "desc": "Replace with \"max-w-200\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2016,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2016,
          "endColumn": 127
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-surface)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2016,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2016,
          "endColumn": 127
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-warning)]/40\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2025,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2025,
          "endColumn": 148
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-warning)]/10\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2025,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2025,
          "endColumn": 148
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-warning)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2025,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2025,
          "endColumn": 148
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2040,
          "column": 39,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2040,
          "endColumn": 75
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2240,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2240,
          "endColumn": 118
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"md:grid-cols-[2fr_1fr]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2250,
          "column": 40,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2250,
          "endColumn": 90
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2347,
          "column": 39,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2347,
          "endColumn": 165
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2383,
          "column": 36,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2383,
          "endColumn": 140
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2390,
          "column": 41,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2390,
          "endColumn": 73
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2398,
          "column": 35,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2398,
          "endColumn": 160
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[15px]\" hardcodes an off-token value. Nearest on the scale: rounded-2xl (16px), rounded-xl (12px).",
          "line": 2521,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 2521,
          "endColumn": 93
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2521,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2521,
          "endColumn": 93
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-surface)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2521,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2521,
          "endColumn": 93
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2530,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2530,
          "endColumn": 116
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2540,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2540,
          "endColumn": 144
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-surface-raised)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2540,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2540,
          "endColumn": 144
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"-mb-[15px]\" hardcodes an off-token value. Use \"-mb-3.75\" instead (same value, on the scale).",
          "line": 2616,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 2616,
          "endColumn": 124,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "-mb-3.75"
              },
              "fix": {
                "range": [
                  91435,
                  91548
                ],
                "text": "\"relative z-10 -mb-3.75 overflow-hidden rounded-[15px] border border-[var(--oh-border)] bg-[var(--oh-surface)]\""
              },
              "desc": "Replace with \"-mb-3.75\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[15px]\" hardcodes an off-token value. Nearest on the scale: rounded-2xl (16px), rounded-xl (12px).",
          "line": 2616,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 2616,
          "endColumn": 124
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2616,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2616,
          "endColumn": 124
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-surface)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2616,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2616,
          "endColumn": 124
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-h-[256px]\" hardcodes an off-token value. Use \"min-h-64\" instead (same value, on the scale).",
          "line": 2623,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 2623,
          "endColumn": 78,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "min-h-64"
              },
              "fix": {
                "range": [
                  91759,
                  91816
                ],
                "text": "\"custom-scrollbar-always min-h-64 overflow-x-hidden\""
              },
              "desc": "Replace with \"min-h-64\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[1]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2643,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2643,
          "endColumn": 76
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[2]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2651,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2651,
          "endColumn": 120
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-b-[15px]\" hardcodes an off-token value. Nearest on the scale: rounded-b-2xl (16px), rounded-b-xl (12px).",
          "line": 2661,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 2661,
          "endColumn": 144
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2661,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2661,
          "endColumn": 144
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-surface-raised)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2661,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2661,
          "endColumn": 144
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"pt-[calc(15px+0.5rem)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2661,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2661,
          "endColumn": 144
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2673,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2673,
          "endColumn": 119
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"md:grid-cols-[2fr_1fr]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2682,
          "column": 28,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2682,
          "endColumn": 63
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-interactive-hover)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2779,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2779,
          "endColumn": 62
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2781,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2781,
          "endColumn": 78
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2850,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2850,
          "endColumn": 73
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-surface-raised)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2861,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2861,
          "endColumn": 118
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[14rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2893,
          "column": 56,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2893,
          "endColumn": 67
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[15rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2908,
          "column": 56,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2908,
          "endColumn": 67
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[9.5rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2923,
          "column": 56,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2923,
          "endColumn": 68
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2955,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2955,
          "endColumn": 120
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[16rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2966,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2966,
          "endColumn": 42
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2971,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2971,
          "endColumn": 115
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2991,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2991,
          "endColumn": 116
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 2996,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 2996,
          "endColumn": 80
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3069,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3069,
          "endColumn": 178
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[60]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3080,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3080,
          "endColumn": 171
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3210,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3210,
          "endColumn": 90
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3229,
          "column": 33,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3229,
          "endColumn": 69
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3264,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3264,
          "endColumn": 164
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3296,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3296,
          "endColumn": 167
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3328,
          "column": 32,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3328,
          "endColumn": 81
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3347,
          "column": 35,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3347,
          "endColumn": 89
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3457,
          "column": 34,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3457,
          "endColumn": 81
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-success)]/40\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3464,
          "column": 33,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3464,
          "endColumn": 155
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-success)]/10\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3464,
          "column": 33,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3464,
          "endColumn": 155
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3496,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3496,
          "endColumn": 163
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-surface)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3496,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3496,
          "endColumn": 163
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3496,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3496,
          "endColumn": 163
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3513,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3513,
          "endColumn": 125
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3539,
          "column": 32,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3539,
          "endColumn": 74
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3589,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3589,
          "endColumn": 65
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-warning)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3597,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3597,
          "endColumn": 65
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-interactive-hover)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3629,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3629,
          "endColumn": 82
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3630,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3630,
          "endColumn": 135
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"hover:border-[var(--oh-interactive-hover)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3630,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3630,
          "endColumn": 135
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-text-tertiary)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 3637,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 3637,
          "endColumn": 78
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 78,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\";\nimport toast from \"react-hot-toast\";\nimport { useTranslation } from \"react-i18next\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport {\n  CalendarDays,\n  ChevronDown,\n  Code2,\n  FileText,\n  Globe2,\n  Info,\n  X,\n  Zap,\n} from \"lucide-react\";\nimport AutomationService, {\n  type CustomWebhookCreateResponse,\n} from \"#/api/automation-service/automation-service.api\";\nimport AgentServerConversationService from \"#/api/conversation-service/agent-server-conversation-service.api\";\nimport {\n  clearAutomationFormSession,\n  getAutomationFormSession,\n  initializeAutomationFormSession,\n  isPendingAutomationSetupId,\n  patchAutomationFormSession,\n  setAutomationFormSessionRepositories,\n  subscribeAutomationFormSession,\n} from \"#/api/automation-form-session\";\nimport type {\n  AutomationSetupDraft,\n  AutomationSetupField,\n  AutomationSetupFormPatch,\n  AutomationSetupFormValues,\n} from \"#/api/automation-setup-types\";\nimport {\n  DEFAULT_AUTOMATION_PLUGIN_REF,\n  resolveAutomationSetupPlugins,\n  serializeAutomationSetupPluginList,\n  type AutomationSetupPluginEntry,\n} from \"#/api/automation-setup-plugins\";\nimport {\n  automationDetailPath,\n  automationListPath,\n} from \"#/manifests/automation-interface\";\nimport { packTarGzip } from \"#/utils/tar-gzip\";\nimport { AvailableLanguages } from \"#/i18n\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport SparkleIcon from \"#/icons/sparkle.svg?react\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { BackNavButton } from \"#/components/shared/buttons/back-nav-button\";\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\nimport { OptionalTag } from \"#/components/features/settings/optional-tag\";\nimport { AutomationSetupPromptStack } from \"#/components/features/automations/setup/automation-setup-prompt-stack\";\nimport { ContextMenuListItem } from \"#/components/features/context-menu/context-menu-list-item\";\nimport { formatTriggerSourceLabel } from \"#/components/features/home/featured-automations/automation-run-health\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { useSettings } from \"#/hooks/query/use-settings\";\nimport { usePromptTextareaResize } from \"#/hooks/use-prompt-textarea-resize\";\nimport { useSendMessage } from \"#/hooks/use-send-message\";\nimport {\n  formControlBorderClassName,\n  formControlFieldClassName,\n  formControlFocusWithinClassName,\n  formControlHeightClassName,\n  formControlMultilineFieldClassName,\n  formControlRadiusClassName,\n  formControlSurfaceClassName,\n  formControlTransitionClassName,\n} from \"#/utils/form-control-classes\";\nimport { Divider } from \"#/ui/divider\";\nimport { cn } from \"#/utils/utils\";\nimport { useDeploymentCapabilities } from \"#/hooks/query/use-manifest-capabilities\";\nimport { AUTOMATION_DETAIL_QUERY_KEY } from \"#/hooks/query/use-automation-detail\";\nimport { AUTOMATIONS_QUERY_KEY } from \"#/hooks/query/use-automations\";\nimport {\n  invalidateConversationQueries,\n  patchConversationInCache,\n} from \"#/hooks/mutation/conversation-mutation-utils\";\nimport { displayErrorToast } from \"#/utils/custom-toast-handlers\";\nimport { getApiErrorMessage } from \"#/utils/api-error-message\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport type {\n  AutomationDraftApiResponse,\n  SetupRequestBody,\n} from \"#/manifests/types\";\nimport type { Automation, AutomationRun } from \"#/types/automation\";\nimport { formatRelativeTime } from \"#/utils/format-relative-time\";\nimport { ActivityLogItem } from \"../detail/activity-log-item\";\nimport { requestAutomationSetupAgent } from \"./automation-setup-agent-request\";\nimport {\n  draftEndpoint,\n  draftRunPageErrors,\n  draftValidationEndpoint,\n  extractDraftDispatchErrors,\n  formFromServerDraft,\n  getDraftExecutionStatusText,\n  getResponseStatus,\n  isDraftEndpointUnavailable,\n  presetKindForEndpoint,\n} from \"./automation-setup-draft-service\";\nimport {\n  buildAutomationDraftTags,\n  buildAutomationSetupModeTags,\n  getAutomationDraftIdFromTags,\n  getAutomationEditIdFromTags,\n  hasAutomationSetupModeTag,\n  removeAutomationDraftTags,\n} from \"#/utils/automation-draft-tags\";\nimport { setupDraftFromAutomation } from \"#/utils/automation-edit-draft\";\nimport { withPresetSources } from \"#/utils/automation-preset-sources\";\nimport {\n  automationCloneProviders,\n  automationRepositoriesChanged,\n  buildAutomationRepoSources,\n} from \"#/utils/automation-repo-sources\";\n\nconst DEFAULT_TIMEZONE = \"America/New_York\";\nconst DEFAULT_TIME = \"09:00\";\nconst DEFAULT_CUSTOM_SCHEDULE = \"0 9 * * *\";\nconst DEFAULT_EVENT_SOURCE = \"github\";\n// Capability feature: preset repositories can be changed with PATCH.\nconst REPO_UPDATE_FEATURE = \"repoUpdate\";\n/** Used when the deployment has not advertised its event sources yet. */\nconst FALLBACK_EVENT_SOURCES = [\"github\", \"gitlab\", \"slack\", \"linear\", \"jira\"];\nconst DEFAULT_EVENT_KEY = \"issue_comment.created\";\nconst DEFAULT_CUSTOM_ENTRYPOINT = \"python3 main.py\";\nconst MAIN_PY_FILENAME = \"main.py\";\nconst DEFAULT_CUSTOM_SETUP_SCRIPT_PATH = \"setup.sh\";\nconst DEFAULT_CUSTOM_SETUP_SCRIPT = `#!/usr/bin/env bash\n:\n`;\nconst addOptionButtonClassName = cn(\n  \"inline-flex w-fit shrink-0 cursor-pointer items-center rounded-lg border border-[var(--oh-border)] bg-tertiary px-3 py-1.5 text-sm text-content hover:bg-interactive-hover\",\n  formControlTransitionClassName,\n);\nconst DEFAULT_TIMEOUT_SECONDS = \"600\";\nconst PREFLIGHT_TARBALL_PATH =\n  \"oh-internal://uploads/00000000-0000-0000-0000-000000000000\";\nexport const AGENT_FIELD_STREAM_CHARACTER_DELAY_MS = 12;\nexport const AGENT_FIELD_STREAM_SETTLE_DELAY_MS = 160;\n\nconst FREQUENCIES = [\n  \"once\",\n  \"hourly\",\n  \"daily\",\n  \"weekdays\",\n  \"weekly\",\n  \"custom\",\n] as const;\nconst TIMEZONE_OPTIONS = [\n  \"America/New_York\",\n  \"America/Chicago\",\n  \"America/Denver\",\n  \"America/Los_Angeles\",\n  \"America/Sao_Paulo\",\n  \"Europe/London\",\n  \"Europe/Paris\",\n  \"Europe/Berlin\",\n  \"Asia/Tokyo\",\n  \"Asia/Shanghai\",\n  \"Asia/Kolkata\",\n  \"Australia/Sydney\",\n  \"UTC\",\n] as const;\nconst AUTOMATION_SETUP_FIELD_RENDER_ORDER: AutomationSetupField[] = [\n  \"kind\",\n  \"name\",\n  \"prompt\",\n  \"pluginSource\",\n  \"pluginRef\",\n  \"pluginList\",\n  \"repository\",\n  \"customCode\",\n  \"entrypoint\",\n  \"setupScriptPath\",\n  \"setupScript\",\n  \"triggerKind\",\n  \"frequency\",\n  \"time\",\n  \"weekday\",\n  \"scheduleDateTime\",\n  \"timezone\",\n  \"customSchedule\",\n  \"eventSource\",\n  \"eventKey\",\n  \"eventFilter\",\n  \"model\",\n  \"agentProfileId\",\n  \"showTimeout\",\n  \"timeoutSeconds\",\n];\n\ntype CustomWebhookSignatureScheme =\n  | \"hmac_sha256_hex\"\n  | \"standard_webhooks\"\n  | \"slack_v0\";\n\ninterface CustomWebhookFormState {\n  enabled: boolean;\n  name: string;\n  eventKeyExpr: string;\n  signatureHeader: string;\n  signatureScheme: CustomWebhookSignatureScheme;\n  webhookSecret: string;\n}\n\nconst DEFAULT_CUSTOM_WEBHOOK_EVENT_KEY_EXPR = \"type\";\nconst DEFAULT_CUSTOM_WEBHOOK_SIGNATURE_HEADER = \"X-Signature-256\";\nconst DEFAULT_CUSTOM_WEBHOOK_SIGNATURE_SCHEME: CustomWebhookSignatureScheme =\n  \"hmac_sha256_hex\";\n\nconst NON_CHARACTER_STREAM_FIELDS = new Set<AutomationSetupField>([\n  \"kind\",\n  \"triggerKind\",\n  \"frequency\",\n  \"weekday\",\n  \"model\",\n  \"agentProfileId\",\n  \"showTimeout\",\n  \"time\",\n  \"pluginList\",\n]);\nconst streamingFieldHighlightClassName =\n  \"rounded-xl ring-2 ring-[#D5C76B]/80 ring-offset-2 ring-offset-base shadow-[0_0_24px_rgba(213,199,107,0.24)]\";\n\nexport function parseAutomationSetupRepositories(value: string): string[] {\n  const seen = new Set<string>();\n  return value\n    .split(/[\\n,]+/)\n    .map((entry) => entry.trim())\n    .filter((entry) => {\n      if (!entry || seen.has(entry)) return false;\n      seen.add(entry);\n      return true;\n    });\n}\n\ntype Frequency = (typeof FREQUENCIES)[number];\ntype StatusMessage = { kind: \"success\" | \"error\"; text: string } | null;\n\ninterface AutomationSetupPanelProps {\n  draft: AutomationSetupDraft;\n  conversationId?: string | null;\n  conversationTags?: Record<string, string> | null;\n  toolbarPortal?: HTMLElement | null;\n  showInlineHeader?: boolean;\n  reserveComposerSpace?: boolean;\n}\n\nfunction titleCase(value: string): string {\n  return value\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 4)\n    .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n    .join(\" \");\n}\n\nfunction deriveName(prompt: string): string {\n  const withoutLead = prompt\n    .replace(/^create\\s+(an?\\s+)?automation\\s+(that|to)?\\s*/i, \"\")\n    .replace(/[.!?].*$/, \"\")\n    .trim();\n  const name = titleCase(withoutLead || prompt);\n  return name || \"New Automation\";\n}\n\nfunction defaultScheduleDateTimeLocal(): string {\n  const date = new Date();\n  date.setSeconds(0, 0);\n  date.setMinutes(0);\n  date.setHours(date.getHours() + 1);\n  const month = String(date.getMonth() + 1).padStart(2, \"0\");\n  const day = String(date.getDate()).padStart(2, \"0\");\n  const hour = String(date.getHours()).padStart(2, \"0\");\n  const minute = String(date.getMinutes()).padStart(2, \"0\");\n  return `${date.getFullYear()}-${month}-${day}T${hour}:${minute}`;\n}\n\nfunction onceCron(scheduleDateTime: string): string {\n  const match = scheduleDateTime.match(\n    /^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2})$/,\n  );\n  if (!match) return DEFAULT_CUSTOM_SCHEDULE;\n  const minute = Number(match[5]);\n  const hour = Number(match[4]);\n  const day = Number(match[3]);\n  const month = Number(match[2]);\n  return `${minute} ${hour} ${day} ${month} *`;\n}\n\nfunction eventKeyPayload(value: string): string | string[] {\n  const keys = value\n    .split(\",\")\n    .map((key) => key.trim())\n    .filter(Boolean);\n  if (keys.length <= 1) return keys[0] ?? DEFAULT_EVENT_KEY;\n  return keys;\n}\n\nfunction cronWeekday(weekday: string): number {\n  const value = Number(weekday);\n  if (!Number.isInteger(value) || value < 0 || value > 6) return 1;\n  return value;\n}\n\nfunction toCron(\n  time: string,\n  frequency: Frequency,\n  customSchedule: string,\n  scheduleDateTime: string,\n  weekday: string,\n): string {\n  if (frequency === \"custom\") return customSchedule || DEFAULT_CUSTOM_SCHEDULE;\n  if (frequency === \"once\") return onceCron(scheduleDateTime);\n  if (frequency === \"hourly\") return \"0 * * * *\";\n\n  const [hour = \"9\", minute = \"0\"] = time.split(\":\");\n  const cronTime = `${Number(minute)} ${Number(hour)}`;\n  if (frequency === \"weekdays\") return `${cronTime} * * 1-5`;\n  if (frequency === \"weekly\") return `${cronTime} * * ${cronWeekday(weekday)}`;\n  return `${cronTime} * * *`;\n}\n\nfunction buildStarterPython(prompt: string): string {\n  return `import json\\nimport os\\nimport urllib.request\\n\\n\\ndef fire_callback(status=\"COMPLETED\", error=None):\\n    url = os.environ.get(\"AUTOMATION_CALLBACK_URL\", \"\")\\n    if not url:\\n        return\\n    body = {\"status\": status, \"run_id\": os.environ.get(\"AUTOMATION_RUN_ID\", \"\")}\\n    if error:\\n        body[\"error\"] = error\\n    request = urllib.request.Request(\\n        url,\\n        data=json.dumps(body).encode(),\\n        headers={\\n            \"Content-Type\": \"application/json\",\\n            \"Authorization\": f\"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}\",\\n        },\\n    )\\n    urllib.request.urlopen(request, timeout=10)\\n\\n\\ndef main():\\n    prompt = ${JSON.stringify(prompt)}\\n    print(f\"Automation prompt: {prompt}\")\\n\\n\\nif __name__ == \"__main__\":\\n    try:\\n        main()\\n        fire_callback(\"COMPLETED\")\\n    except Exception as exc:\\n        fire_callback(\"FAILED\", str(exc))\\n        raise\\n`;\n}\n\nfunction existingTarballDisplayName(path: string): string {\n  const trimmed = path.trim();\n  if (!trimmed) return \"\";\n  const name = trimmed.split(\"/\").filter(Boolean).pop() ?? trimmed;\n  try {\n    return decodeURIComponent(name);\n  } catch {\n    return name;\n  }\n}\n\nfunction sortFieldsByRenderOrder(\n  fields: AutomationSetupField[],\n): AutomationSetupField[] {\n  return [...fields].sort(\n    (first, second) =>\n      AUTOMATION_SETUP_FIELD_RENDER_ORDER.indexOf(first) -\n      AUTOMATION_SETUP_FIELD_RENDER_ORDER.indexOf(second),\n  );\n}\n\nfunction shouldCharacterStreamField(\n  field: AutomationSetupField,\n  value: AutomationSetupFormValues[AutomationSetupField],\n): value is string {\n  return typeof value === \"string\" && !NON_CHARACTER_STREAM_FIELDS.has(field);\n}\n\nfunction streamingHighlightClassName(isStreaming: boolean) {\n  return isStreaming ? streamingFieldHighlightClassName : undefined;\n}\n\nfunction buildDefaultEventTestPayload(\n  eventSource: string = DEFAULT_EVENT_SOURCE,\n  eventKey: string = DEFAULT_EVENT_KEY,\n): string {\n  return `${JSON.stringify(\n    {\n      source: eventSource || DEFAULT_EVENT_SOURCE,\n      event: eventKey || DEFAULT_EVENT_KEY,\n      action: \"test\",\n    },\n    null,\n    2,\n  )}\\n`;\n}\n\nfunction parseEventTestPayload(value: string): Record<string, unknown> | null {\n  const trimmed = value.trim();\n  if (!trimmed) return null;\n  try {\n    const parsed = JSON.parse(trimmed) as unknown;\n    if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n      return null;\n    }\n    return parsed as Record<string, unknown>;\n  } catch {\n    return null;\n  }\n}\n\nfunction buildInitialForm(\n  draft: AutomationSetupDraft,\n): AutomationSetupFormValues {\n  const form = draft.form ?? {};\n  const prompt = form.prompt ?? draft.prompt;\n  const kind = form.kind ?? draft.kind;\n  const pluginEntries = resolveAutomationSetupPlugins(form, draft.plugins);\n  return {\n    kind,\n    name: form.name ?? deriveName(prompt),\n    prompt,\n    repository: form.repository ?? \"\",\n    pluginSource: pluginEntries[0]?.source ?? \"\",\n    pluginRef: pluginEntries[0]?.ref ?? \"\",\n    pluginList:\n      pluginEntries.length > 0\n        ? serializeAutomationSetupPluginList(pluginEntries)\n        : \"\",\n    customCode: form.customCode ?? buildStarterPython(prompt),\n    entrypoint: form.entrypoint ?? DEFAULT_CUSTOM_ENTRYPOINT,\n    setupScriptPath: form.setupScriptPath ?? DEFAULT_CUSTOM_SETUP_SCRIPT_PATH,\n    setupScript: form.setupScript ?? DEFAULT_CUSTOM_SETUP_SCRIPT,\n    triggerKind: form.triggerKind ?? \"cron\",\n    frequency: form.frequency ?? \"daily\",\n    time: form.time ?? DEFAULT_TIME,\n    weekday: form.weekday ?? \"1\",\n    scheduleDateTime: form.scheduleDateTime ?? \"\",\n    timezone: form.timezone ?? DEFAULT_TIMEZONE,\n    customSchedule: form.customSchedule ?? DEFAULT_CUSTOM_SCHEDULE,\n    eventSource: form.eventSource ?? DEFAULT_EVENT_SOURCE,\n    eventKey: form.eventKey ?? DEFAULT_EVENT_KEY,\n    eventFilter: form.eventFilter ?? \"\",\n    model: form.model ?? \"\",\n    agentProfileId: form.agentProfileId ?? \"\",\n    showTimeout: form.showTimeout ?? false,\n    timeoutSeconds: form.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS,\n  };\n}\n\nfunction DraftStatusBar({\n  draft,\n  runs,\n  isSubmitting,\n  canTest,\n  saveFailed,\n  onTest,\n  onViewRuns,\n}: {\n  draft: AutomationDraftApiResponse;\n  runs: AutomationRun[];\n  isSubmitting: boolean;\n  canTest: boolean;\n  /** The latest save failed, so `draft` no longer describes the form. */\n  saveFailed: boolean;\n  onTest: () => void;\n  onViewRuns: () => void;\n}) {\n  const { t, i18n } = useTranslation(\"openhands\");\n  const validationMessage = draft.validationErrors?.[0]?.message ?? null;\n  const statusText = getDraftExecutionStatusText(draft, runs, t);\n\n  // @spec FF-001 \u2014 Theme-aware validation and load errors\n  // A 5% tint keeps the error text at 4.5:1 on every theme's surface.\n  const validityClassName = cn(\n    \"max-w-full min-w-0 whitespace-normal rounded-full px-2.5 py-0.5 text-left text-xs leading-4\",\n    validationMessage\n      ? \"w-fit bg-feedback-error/5 text-feedback-error\"\n      : \"bg-[var(--oh-success)]/10 text-[var(--oh-success)]\",\n  );\n\n  return (\n    <section\n      data-testid=\"automation-setup-draft-details\"\n      className=\"flex flex-col gap-2 rounded-xl border border-[var(--oh-border)] bg-[var(--oh-surface)] px-3 py-2\"\n    >\n      <div className=\"flex items-center justify-between gap-2\">\n        <div className=\"flex min-w-0 items-center gap-2\">\n          <h3 className=\"truncate text-sm text-content\">\n            {t(I18nKey.AUTOMATION_SETUP$DRAFT_STATUS_TITLE)}\n          </h3>\n          <span className=\"truncate text-xs text-muted\">\n            {formatRelativeTime(\n              draft.updatedAt,\n              i18n?.language ?? AvailableLanguages[0].value,\n              t,\n            )}\n          </span>\n        </div>\n        <div className=\"flex shrink-0 items-center gap-1.5\">\n          {validationMessage || saveFailed ? null : (\n            <span\n              data-testid=\"automation-setup-draft-validity\"\n              className={validityClassName}\n            >\n              {statusText}\n            </span>\n          )}\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            testId=\"automation-setup-view-test-runs\"\n            className=\"!h-7 !min-h-7 !px-2.5 !text-xs\"\n            onClick={onViewRuns}\n          >\n            {t(I18nKey.AUTOMATION_SETUP$VIEW_TEST_RUNS)}\n          </BrandButton>\n          <BrandButton\n            type=\"button\"\n            variant=\"primary\"\n            testId=\"automation-setup-draft-test\"\n            className=\"!h-7 !min-h-7 shrink-0 !px-2.5 !text-xs\"\n            isDisabled={isSubmitting || !canTest}\n            aria-busy={isSubmitting}\n            onClick={onTest}\n          >\n            {t(I18nKey.AUTOMATION_SETUP$TEST)}\n          </BrandButton>\n        </div>\n      </div>\n      {validationMessage ? (\n        <p\n          data-testid=\"automation-setup-draft-validity\"\n          className={validityClassName}\n        >\n          {statusText}\n        </p>\n      ) : null}\n    </section>\n  );\n}\n\nfunction DraftRunTestButton({\n  isSubmitting,\n  canTest,\n  onTest,\n}: {\n  isSubmitting: boolean;\n  canTest: boolean;\n  onTest: () => void;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  return (\n    <BrandButton\n      type=\"button\"\n      variant=\"primary\"\n      testId=\"automation-setup-test\"\n      isDisabled={isSubmitting || !canTest}\n      aria-busy={isSubmitting}\n      onClick={onTest}\n    >\n      {t(I18nKey.AUTOMATION_SETUP$TEST)}\n    </BrandButton>\n  );\n}\n\nfunction DraftRunsPage({\n  runs,\n  errors,\n  isSubmitting,\n  canTest,\n  onBack,\n  onTest,\n}: {\n  runs: AutomationRun[];\n  errors: string[];\n  isSubmitting: boolean;\n  canTest: boolean;\n  onBack: () => void;\n  onTest: () => void;\n}) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <section\n      data-testid=\"automation-setup-draft-runs-page\"\n      className=\"flex flex-col gap-4\"\n    >\n      <BackNavButton testId=\"automation-setup-draft-runs-back\" onClick={onBack}>\n        {t(I18nKey.BUTTON$BACK)}\n      </BackNavButton>\n      <div className=\"flex items-center justify-between gap-3\">\n        <h3 className=\"text-xl font-medium text-content\">\n          {t(I18nKey.AUTOMATION_SETUP$TEST_RUNS)}\n        </h3>\n        {runs.length > 0 ? (\n          <DraftRunTestButton\n            canTest={canTest}\n            isSubmitting={isSubmitting}\n            onTest={onTest}\n          />\n        ) : null}\n      </div>\n      {errors.map((message) => (\n        <p\n          key={message}\n          role=\"alert\"\n          data-testid=\"automation-setup-draft-runs-error\"\n          className=\"rounded-xl border border-feedback-error/40 bg-feedback-error/10 px-4 py-3 text-sm text-feedback-error\"\n        >\n          {message}\n        </p>\n      ))}\n      {runs.length > 0 ? (\n        <div className=\"overflow-hidden rounded-xl border border-[var(--oh-border)]\">\n          {runs.map((run, index) => (\n            <div\n              key={run.id}\n              data-testid=\"automation-setup-draft-run\"\n              className={cn(index > 0 && \"border-t border-[var(--oh-border)]\")}\n            >\n              <ActivityLogItem run={run} />\n            </div>\n          ))}\n        </div>\n      ) : (\n        <div\n          data-testid=\"automation-setup-draft-runs-empty\"\n          className=\"flex flex-col items-center gap-3 rounded-xl border border-[var(--oh-border)] bg-[var(--oh-surface)] px-5 py-10 text-center\"\n        >\n          <p className=\"text-sm text-content\">\n            {t(I18nKey.AUTOMATIONS$DETAIL$NO_RUNS)}\n          </p>\n          <p className=\"text-sm text-muted\">\n            {t(I18nKey.AUTOMATION_SETUP$TEST_RUNS_EMPTY_DESCRIPTION)}\n          </p>\n          <DraftRunTestButton\n            canTest={canTest}\n            isSubmitting={isSubmitting}\n            onTest={onTest}\n          />\n        </div>\n      )}\n    </section>\n  );\n}\n\nfunction frequencyLabelKey(frequency: Frequency): I18nKey {\n  switch (frequency) {\n    case \"once\":\n      return I18nKey.AUTOMATION_SETUP$FREQUENCY_ONCE;\n    case \"hourly\":\n      return I18nKey.AUTOMATION_SETUP$FREQUENCY_HOURLY;\n    case \"daily\":\n      return I18nKey.AUTOMATIONS$FREQUENCY_DAILY;\n    case \"weekdays\":\n      return I18nKey.AUTOMATION_SETUP$FREQUENCY_WEEKDAYS;\n    case \"weekly\":\n      return I18nKey.AUTOMATION_SETUP$FREQUENCY_WEEKLY;\n    case \"custom\":\n      return I18nKey.AUTOMATION_SETUP$TYPE_CUSTOM;\n  }\n}\n\nexport function AutomationSetupPanel({\n  draft,\n  conversationId,\n  conversationTags,\n  toolbarPortal,\n  showInlineHeader = true,\n  reserveComposerSpace = false,\n}: AutomationSetupPanelProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { navigate } = useNavigation();\n  const deploymentCapabilities = useDeploymentCapabilities();\n  // Connected self-hosted providers name their hosts here; a repository on\n  // one of them is sent with its provider so the run can authenticate, as\n  // long as the service can clone from that provider at all.\n  const providerHosts = useSettings().data?.provider_tokens_set;\n  const cloneProviders = automationCloneProviders(\n    deploymentCapabilities.data?.repoProviders,\n  );\n  const eventSourceOptions = deploymentCapabilities.data?.eventSources ?? [];\n  const eventTypeOptions = deploymentCapabilities.data?.eventTypes ?? [];\n  const initialDraft = conversationId\n    ? (getAutomationFormSession(conversationId) ?? draft)\n    : draft;\n  const [form, setForm] = useState(() => buildInitialForm(initialDraft));\n  const [eventTestPayload, setEventTestPayload] = useState(() =>\n    buildDefaultEventTestPayload(DEFAULT_EVENT_SOURCE, DEFAULT_EVENT_KEY),\n  );\n  const [isEventTestPayloadDirty, setIsEventTestPayloadDirty] = useState(false);\n  const [customWebhook, setCustomWebhook] = useState<CustomWebhookFormState>({\n    enabled: false,\n    name: \"\",\n    eventKeyExpr: DEFAULT_CUSTOM_WEBHOOK_EVENT_KEY_EXPR,\n    signatureHeader: DEFAULT_CUSTOM_WEBHOOK_SIGNATURE_HEADER,\n    signatureScheme: DEFAULT_CUSTOM_WEBHOOK_SIGNATURE_SCHEME,\n    webhookSecret: \"\",\n  });\n  const [customWebhookRegistration, setCustomWebhookRegistration] =\n    useState<CustomWebhookCreateResponse | null>(null);\n\n  const [fieldMetadata, setFieldMetadata] = useState(\n    () => initialDraft.fieldMetadata ?? {},\n  );\n  const [statusMessage, setStatusMessage] = useState<StatusMessage>(null);\n  const toastedStatusRef = useRef<StatusMessage>(null);\n  useEffect(() => {\n    if (!statusMessage || statusMessage === toastedStatusRef.current) return;\n    toastedStatusRef.current = statusMessage;\n    if (statusMessage.kind === \"success\") {\n      toast.success(statusMessage.text);\n      return;\n    }\n    displayErrorToast(statusMessage.text);\n  }, [statusMessage]);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const [streamingField, setStreamingField] =\n    useState<AutomationSetupField | null>(null);\n  // Server-backed draft from OpenHands/automation PR #439. Null until the\n  // first save creates the draft row; the local store stays the reactive layer\n  // the agent streams into, and the server row is the persisted source of truth.\n  const [serverDraft, setServerDraft] =\n    useState<AutomationDraftApiResponse | null>(null);\n  const [draftRuns, setDraftRuns] = useState<AutomationRun[]>([]);\n  const [isViewingDraftRuns, setIsViewingDraftRuns] = useState(false);\n  const [isHydratingServerDraft, setIsHydratingServerDraft] = useState(false);\n  const [saveState, setSaveState] = useState<\n    \"idle\" | \"saving\" | \"saved\" | \"error\"\n  >(\"idle\");\n  // Request body from the last successful save. Compared with the form so\n  // Test stays inactive while there are unsaved edits, even if the service\n  // returns a normalized draft that does not byte-match the request.\n  const [savedDraftRequestKey, setSavedDraftRequestKey] = useState<\n    string | null\n  >(null);\n  const [isTaggedDraftMissing, setIsTaggedDraftMissing] = useState(false);\n  const queryClient = useQueryClient();\n  const latestConversationTagsRef = useRef(conversationTags);\n  const conversationTagUpdateQueueRef = useRef<Promise<void>>(\n    Promise.resolve(),\n  );\n  const propTaggedServerDraftId =\n    getAutomationDraftIdFromTags(conversationTags) ??\n    draft.serverDraftId ??\n    null;\n  const [currentTaggedServerDraftId, setCurrentTaggedServerDraftId] = useState(\n    propTaggedServerDraftId,\n  );\n  const taggedServerDraftId = currentTaggedServerDraftId;\n  const serverDraftId =\n    serverDraft?.id ?? (isTaggedDraftMissing ? null : taggedServerDraftId);\n  const editingAutomationId =\n    draft.editingAutomationId?.trim() ||\n    getAutomationEditIdFromTags(conversationTags) ||\n    \"\";\n  const isEditingExisting = editingAutomationId.length > 0;\n  const [existingCustomTarballPath, setExistingCustomTarballPath] = useState(\n    draft.existingCustomTarballPath ?? \"\",\n  );\n  const [existingRepositories, setExistingRepositories] = useState(\n    draft.existingRepositories ?? [],\n  );\n  const [canUpdateRepositories, setCanUpdateRepositories] = useState(\n    draft.canUpdateRepositories ?? false,\n  );\n  // A new automation sends its repositories on create. An existing one can\n  // change them only when the service can rebuild it with them.\n  const repositoriesEditable =\n    !isEditingExisting ||\n    (canUpdateRepositories &&\n      (deploymentCapabilities.data?.features ?? []).includes(\n        REPO_UPDATE_FEATURE,\n      ));\n  const streamQueueRef = useRef<\n    {\n      field: AutomationSetupField;\n      value: AutomationSetupFormValues[AutomationSetupField];\n      metadata?: NonNullable<\n        AutomationSetupDraft[\"fieldMetadata\"]\n      >[AutomationSetupField];\n    }[]\n  >([]);\n  const isStreamProcessingRef = useRef(false);\n  const streamGenerationRef = useRef(0);\n  const streamTimeoutsRef = useRef<number[]>([]);\n  const processQueuedStreamsRef = useRef<() => void>(() => {});\n\n  useEffect(() => {\n    latestConversationTagsRef.current = conversationTags;\n  }, [conversationTags]);\n\n  useEffect(() => {\n    setCurrentTaggedServerDraftId(propTaggedServerDraftId);\n  }, [propTaggedServerDraftId]);\n\n  useEffect(() => {\n    setExistingCustomTarballPath(draft.existingCustomTarballPath ?? \"\");\n  }, [draft.existingCustomTarballPath]);\n\n  useEffect(() => {\n    setExistingRepositories(draft.existingRepositories ?? []);\n    setCanUpdateRepositories(draft.canUpdateRepositories ?? false);\n  }, [draft.existingRepositories, draft.canUpdateRepositories]);\n\n  const updateConversationTags = useCallback(\n    async (\n      buildNextTags: (\n        tags: Record<string, string> | null | undefined,\n      ) => Record<string, string>,\n    ) => {\n      if (!conversationId) return null;\n\n      const update = conversationTagUpdateQueueRef.current\n        .catch(() => undefined)\n        .then(async () => {\n          const nextTags = buildNextTags(latestConversationTagsRef.current);\n          const updatedConversation =\n            await AgentServerConversationService.updateConversationTags(\n              conversationId,\n              nextTags,\n            );\n          const savedTags = updatedConversation.tags ?? nextTags;\n          latestConversationTagsRef.current = savedTags;\n          patchConversationInCache(queryClient, conversationId, {\n            tags: savedTags,\n          });\n          invalidateConversationQueries(queryClient, conversationId);\n          return updatedConversation;\n        });\n\n      conversationTagUpdateQueueRef.current = update.then(\n        () => undefined,\n        () => undefined,\n      );\n      return update;\n    },\n    [conversationId, queryClient],\n  );\n\n  useEffect(() => {\n    if (\n      !conversationId ||\n      isPendingAutomationSetupId(conversationId) ||\n      conversationTags === undefined ||\n      hasAutomationSetupModeTag(conversationTags)\n    ) {\n      return;\n    }\n    updateConversationTags((tags) => buildAutomationSetupModeTags(tags)).catch(\n      (error: unknown) => {\n        displayErrorToast(getApiErrorMessage(error, t(I18nKey.ERROR$GENERIC)));\n      },\n    );\n  }, [conversationId, conversationTags, updateConversationTags]);\n\n  const saveDraftInFormSession = useCallback(\n    (\n      saved: AutomationDraftApiResponse,\n      nextForm: AutomationSetupFormValues,\n    ) => {\n      if (!conversationId) return;\n      initializeAutomationFormSession(conversationId, {\n        ...draft,\n        prompt: nextForm.prompt ?? draft.prompt,\n        kind: nextForm.kind ?? draft.kind,\n        form: nextForm,\n        fieldMetadata,\n        serverDraftId: saved.id,\n        ...(saved.materializedAutomationId\n          ? { materializedAutomationId: saved.materializedAutomationId }\n          : {}),\n      });\n    },\n    [conversationId, draft, fieldMetadata],\n  );\n\n  const updateConversationDraftTags = useCallback(\n    async (draftId: string | null) => {\n      if (!conversationId || isPendingAutomationSetupId(conversationId)) return;\n      await updateConversationTags((tags) =>\n        draftId\n          ? buildAutomationDraftTags(tags, draftId)\n          : removeAutomationDraftTags(tags),\n      );\n      setCurrentTaggedServerDraftId(draftId);\n    },\n    [conversationId, updateConversationTags],\n  );\n\n  useEffect(() => {\n    if (!isEditingExisting || draft.form || !conversationId) return undefined;\n    let cancelled = false;\n    AutomationService.getAutomation(editingAutomationId)\n      .then((automation) => {\n        if (cancelled) return;\n        const next = setupDraftFromAutomation(automation);\n        const nextForm = buildInitialForm(next);\n        initializeAutomationFormSession(conversationId, next);\n        setExistingCustomTarballPath(next.existingCustomTarballPath ?? \"\");\n        setExistingRepositories(next.existingRepositories ?? []);\n        setCanUpdateRepositories(next.canUpdateRepositories ?? false);\n        setForm(nextForm);\n      })\n      .catch((error: unknown) => {\n        if (!cancelled) {\n          displayErrorToast(\n            getApiErrorMessage(error, t(I18nKey.ERROR$GENERIC)),\n          );\n        }\n      });\n    return () => {\n      cancelled = true;\n    };\n  }, [conversationId, draft.form, editingAutomationId, isEditingExisting]);\n\n  useEffect(() => {\n    if (!taggedServerDraftId || serverDraft?.id === taggedServerDraftId) {\n      return undefined;\n    }\n\n    let cancelled = false;\n    setIsHydratingServerDraft(true);\n    setSavedDraftRequestKey(null);\n    setIsTaggedDraftMissing(false);\n\n    AutomationService.getServerDraft(taggedServerDraftId)\n      .then((saved) => {\n        if (cancelled) return;\n        const nextForm = formFromServerDraft(saved, buildInitialForm(draft));\n        setServerDraft(saved);\n        setDraftRuns([]);\n        setForm(nextForm);\n        saveDraftInFormSession(saved, nextForm);\n      })\n      .catch((error: unknown) => {\n        if (cancelled) return;\n        if (getResponseStatus(error) === 404) {\n          setServerDraft(null);\n          setIsTaggedDraftMissing(true);\n          return;\n        }\n        displayErrorToast(getApiErrorMessage(error, t(I18nKey.ERROR$GENERIC)));\n      })\n      .finally(() => {\n        if (!cancelled) setIsHydratingServerDraft(false);\n      });\n\n    return () => {\n      cancelled = true;\n    };\n  }, [\n    conversationId,\n    draft,\n    saveDraftInFormSession,\n    serverDraft?.id,\n    taggedServerDraftId,\n  ]);\n\n  const {\n    kind,\n    name,\n    prompt,\n    repository,\n    pluginSource,\n    pluginRef,\n    pluginList,\n    customCode,\n    entrypoint,\n    setupScriptPath,\n    setupScript,\n    triggerKind,\n    frequency,\n    time,\n    weekday,\n    scheduleDateTime,\n    timezone,\n    customSchedule,\n    eventSource,\n    eventKey,\n    eventFilter,\n    model,\n    agentProfileId,\n    showTimeout,\n    timeoutSeconds,\n  } = form;\n  const hasExistingCustomBundle = Boolean(\n    isEditingExisting && kind === \"custom\" && existingCustomTarballPath.trim(),\n  );\n\n  useEffect(() => {\n    if (isEventTestPayloadDirty) return;\n    setEventTestPayload(buildDefaultEventTestPayload(eventSource, eventKey));\n  }, [eventSource, eventKey, isEventTestPayloadDirty]);\n\n  const clearQueuedStreams = useCallback(() => {\n    streamGenerationRef.current += 1;\n    for (const timeoutId of streamTimeoutsRef.current) {\n      window.clearTimeout(timeoutId);\n    }\n    streamTimeoutsRef.current = [];\n    streamQueueRef.current = [];\n    isStreamProcessingRef.current = false;\n    setStreamingField(null);\n  }, []);\n\n  const scheduleStreamStep = useCallback(\n    (callback: () => void, delay: number, generation: number) => {\n      const timeoutId = window.setTimeout(() => {\n        streamTimeoutsRef.current = streamTimeoutsRef.current.filter(\n          (queuedTimeoutId) => queuedTimeoutId !== timeoutId,\n        );\n        if (generation !== streamGenerationRef.current) return;\n        callback();\n      }, delay);\n      streamTimeoutsRef.current.push(timeoutId);\n    },\n    [],\n  );\n\n  const finishCurrentStream = useCallback(\n    (\n      field: AutomationSetupField,\n      metadata:\n        | NonNullable<\n            AutomationSetupDraft[\"fieldMetadata\"]\n          >[AutomationSetupField]\n        | undefined,\n      generation: number,\n    ) => {\n      if (metadata) {\n        setFieldMetadata((previous) => ({ ...previous, [field]: metadata }));\n      }\n      isStreamProcessingRef.current = false;\n      scheduleStreamStep(\n        () => processQueuedStreamsRef.current(),\n        AGENT_FIELD_STREAM_SETTLE_DELAY_MS,\n        generation,\n      );\n    },\n    [scheduleStreamStep],\n  );\n\n  const processQueuedStreams = useCallback(() => {\n    if (isStreamProcessingRef.current) return;\n    const nextStream = streamQueueRef.current.shift();\n    if (!nextStream) {\n      setStreamingField(null);\n      return;\n    }\n\n    const generation = streamGenerationRef.current;\n    isStreamProcessingRef.current = true;\n    setStreamingField(nextStream.field);\n\n    if (!shouldCharacterStreamField(nextStream.field, nextStream.value)) {\n      setForm((previous) => ({\n        ...previous,\n        [nextStream.field]: nextStream.value,\n      }));\n      finishCurrentStream(nextStream.field, nextStream.metadata, generation);\n      return;\n    }\n\n    const streamValue = nextStream.value;\n    setForm((previous) => ({ ...previous, [nextStream.field]: \"\" }));\n    let nextCharacterIndex = 0;\n    const streamNextCharacter = () => {\n      nextCharacterIndex += 1;\n      setForm((previous) => ({\n        ...previous,\n        [nextStream.field]: streamValue.slice(0, nextCharacterIndex),\n      }));\n      if (nextCharacterIndex < streamValue.length) {\n        scheduleStreamStep(\n          streamNextCharacter,\n          AGENT_FIELD_STREAM_CHARACTER_DELAY_MS,\n          generation,\n        );\n        return;\n      }\n      finishCurrentStream(nextStream.field, nextStream.metadata, generation);\n    };\n\n    scheduleStreamStep(\n      streamNextCharacter,\n      AGENT_FIELD_STREAM_CHARACTER_DELAY_MS,\n      generation,\n    );\n  }, [finishCurrentStream, scheduleStreamStep]);\n\n  processQueuedStreamsRef.current = processQueuedStreams;\n\n  useEffect(\n    () => () => {\n      clearQueuedStreams();\n    },\n    [clearQueuedStreams],\n  );\n\n  useEffect(() => {\n    if (!conversationId) return undefined;\n    initializeAutomationFormSession(conversationId, {\n      ...draft,\n      form,\n      fieldMetadata,\n    });\n    return () => clearAutomationFormSession(conversationId);\n  }, [conversationId]);\n\n  useEffect(() => {\n    if (!conversationId) return undefined;\n    return subscribeAutomationFormSession(\n      conversationId,\n      (nextDraft, result) => {\n        if (!nextDraft) return;\n        const nextForm = buildInitialForm(nextDraft);\n        const nextMetadata = nextDraft.fieldMetadata ?? {};\n        setExistingCustomTarballPath(nextDraft.existingCustomTarballPath ?? \"\");\n        const agentFields = sortFieldsByRenderOrder(\n          (result?.applied ?? []).filter(\n            (field) => nextMetadata[field]?.updatedBy === \"agent\",\n          ),\n        );\n\n        if (agentFields.length === 0) {\n          clearQueuedStreams();\n          setForm(nextForm);\n          setFieldMetadata(nextMetadata);\n          return;\n        }\n\n        const protectedFields = new Set<AutomationSetupField>([\n          ...agentFields,\n          ...streamQueueRef.current.map((queuedStream) => queuedStream.field),\n          ...(streamingField ? [streamingField] : []),\n        ]);\n        setForm((previous) => {\n          const syncedForm = { ...nextForm };\n          for (const field of protectedFields) {\n            (syncedForm as Record<string, unknown>)[field] = previous[field];\n          }\n          return syncedForm;\n        });\n        setFieldMetadata((previous) => {\n          const syncedMetadata = { ...nextMetadata };\n          for (const field of agentFields) {\n            if (previous[field]) {\n              syncedMetadata[field] = previous[field];\n            } else {\n              delete syncedMetadata[field];\n            }\n          }\n          return syncedMetadata;\n        });\n        streamQueueRef.current.push(\n          ...agentFields.map((field) => ({\n            field,\n            value: nextForm[field],\n            metadata: nextMetadata[field],\n          })),\n        );\n        processQueuedStreamsRef.current();\n      },\n    );\n  }, [clearQueuedStreams, conversationId, streamingField]);\n\n  const updateField = <FieldName extends AutomationSetupField>(\n    field: FieldName,\n    value: AutomationSetupFormValues[FieldName],\n  ) => {\n    clearQueuedStreams();\n    setStatusMessage(null);\n    setSaveState(\"idle\");\n    setForm((previous) => ({ ...previous, [field]: value }));\n    if (!conversationId) return;\n    patchAutomationFormSession(\n      conversationId,\n      { [field]: value } as AutomationSetupFormPatch,\n      { source: \"user\" },\n    );\n  };\n\n  const pluginEntries = useMemo(\n    () =>\n      resolveAutomationSetupPlugins(\n        { pluginList, pluginSource, pluginRef },\n        draft.plugins,\n      ),\n    [draft.plugins, pluginList, pluginRef, pluginSource],\n  );\n  const configuredPlugins = pluginEntries.flatMap((entry) => {\n    const source = entry.source.trim();\n    if (!source) return [];\n    const ref = entry.ref.trim();\n    return [{ source, ...(ref ? { ref } : {}) }];\n  });\n  const pluginEndpointSource = configuredPlugins[0]?.source ?? \"\";\n\n  const writePlugins = (entries: AutomationSetupPluginEntry[]) => {\n    const nextList =\n      entries.length > 0 ? serializeAutomationSetupPluginList(entries) : \"\";\n    const nextSource = entries[0]?.source ?? \"\";\n    const nextRef = entries[0]?.ref ?? \"\";\n    clearQueuedStreams();\n    setStatusMessage(null);\n    setSaveState(\"idle\");\n    setForm((previous) => ({\n      ...previous,\n      pluginList: nextList,\n      pluginSource: nextSource,\n      pluginRef: nextRef,\n    }));\n    if (!conversationId) return;\n    patchAutomationFormSession(\n      conversationId,\n      {\n        pluginList: nextList,\n        pluginSource: nextSource,\n        pluginRef: nextRef,\n      },\n      { source: \"user\" },\n    );\n  };\n\n  const agentUpdatedSuffix = (field: AutomationSetupField) =>\n    fieldMetadata[field]?.updatedBy === \"agent\"\n      ? t(I18nKey.AUTOMATION_SETUP$FILLED_BY_OPENHANDS)\n      : undefined;\n\n  const updateCustomWebhook = <FieldName extends keyof CustomWebhookFormState>(\n    field: FieldName,\n    value: CustomWebhookFormState[FieldName],\n  ) => {\n    setStatusMessage(null);\n    setSaveState(\"idle\");\n    setCustomWebhookRegistration(null);\n    setCustomWebhook((previous) => ({ ...previous, [field]: value }));\n  };\n\n  const normalizedName = () => name.trim() || deriveName(prompt);\n  const buildTrigger = () =>\n    triggerKind === \"event\"\n      ? {\n          type: \"event\",\n          source: eventSource.trim() || DEFAULT_EVENT_SOURCE,\n          on: eventKeyPayload(eventKey),\n          ...(eventFilter.trim() ? { filter: eventFilter.trim() } : {}),\n        }\n      : {\n          type: \"cron\",\n          schedule: toCron(\n            time,\n            frequency,\n            customSchedule.trim(),\n            scheduleDateTime,\n            weekday,\n          ),\n          timezone: timezone.trim() || DEFAULT_TIMEZONE,\n        };\n  const buildPresetBody = (): SetupRequestBody => {\n    const trimmedPrompt = prompt.trim();\n    const body: SetupRequestBody = {\n      name: normalizedName(),\n      ...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),\n      trigger: buildTrigger(),\n      enabled: false,\n    } as SetupRequestBody;\n    const repositories = parseAutomationSetupRepositories(repository);\n    if (repositories.length > 0) {\n      body.repos = buildAutomationRepoSources(\n        repositories,\n        [],\n        providerHosts,\n        cloneProviders,\n      );\n    }\n    if (showTimeout && timeoutSeconds.trim())\n      body.timeout = Number(timeoutSeconds);\n    if (configuredPlugins.length > 0) {\n      body.plugins = configuredPlugins;\n    }\n    if (model.trim()) body.model = model.trim();\n    return body;\n  };\n  const buildCustomBody = (tarballPath: string): SetupRequestBody =>\n    ({\n      name: normalizedName(),\n      trigger: buildTrigger(),\n      tarball_path: tarballPath,\n      entrypoint: entrypoint.trim(),\n      setup_script_path: setupScriptPath.trim(),\n      ...(showTimeout && timeoutSeconds.trim()\n        ? { timeout: Number(timeoutSeconds) }\n        : {}),\n    }) as SetupRequestBody;\n  /**\n   * The request body sent to a server-backed draft. Custom drafts reference a\n   * tarball path, but a draft is saved before the upload happens, so the\n   * preflight stand-in path stands in until dispatch. Preset drafts carry the\n   * same body the final create call would, minus the upload-only fields.\n   */\n  const draftRequestBody = (\n    tarballPath: string = PREFLIGHT_TARBALL_PATH,\n  ): SetupRequestBody =>\n    kind === \"custom\" ? buildCustomBody(tarballPath) : buildPresetBody();\n  const draftValidationByField = useMemo(() => {\n    const byField = new Map<string, string>();\n    for (const error of serverDraft?.validationErrors ?? []) {\n      if (error.field && !byField.has(error.field)) {\n        byField.set(error.field, error.message);\n      }\n    }\n    return byField;\n  }, [serverDraft?.validationErrors]);\n  const fieldError = (field: string): string | undefined =>\n    draftValidationByField.get(field);\n  const normalizeDraftForDirtyCheck = (body: SetupRequestBody) => {\n    if (kind !== \"custom\" || typeof body !== \"object\" || body === null) {\n      return body;\n    }\n    return { ...body, tarball_path: PREFLIGHT_TARBALL_PATH };\n  };\n  const draftRequestKey = (\n    endpoint: string,\n    draftName: string,\n    body: SetupRequestBody,\n  ) =>\n    JSON.stringify({\n      endpoint,\n      name: draftName,\n      draft: normalizeDraftForDirtyCheck(body),\n      customSource:\n        kind === \"custom\"\n          ? {\n              customCode,\n              setupScript,\n            }\n          : undefined,\n    });\n  const currentDraftRequestKey = useMemo(\n    () =>\n      draftRequestKey(\n        draftEndpoint(kind, pluginEndpointSource),\n        normalizedName(),\n        draftRequestBody(),\n      ),\n    [\n      kind,\n      name,\n      prompt,\n      repository,\n      pluginSource,\n      pluginRef,\n      pluginList,\n      customCode,\n      entrypoint,\n      setupScriptPath,\n      setupScript,\n      triggerKind,\n      frequency,\n      time,\n      weekday,\n      scheduleDateTime,\n      timezone,\n      customSchedule,\n      eventSource,\n      eventKey,\n      eventFilter,\n      model,\n      agentProfileId,\n      showTimeout,\n      timeoutSeconds,\n    ],\n  );\n  const isDraftDirty = savedDraftRequestKey\n    ? savedDraftRequestKey !== currentDraftRequestKey\n    : !serverDraft ||\n      draftRequestKey(\n        serverDraft.endpoint,\n        serverDraft.name ?? \"\",\n        serverDraft.draft,\n      ) !== currentDraftRequestKey;\n  const wasHydratingServerDraftRef = useRef(false);\n  useEffect(() => {\n    if (isHydratingServerDraft) {\n      wasHydratingServerDraftRef.current = true;\n      return;\n    }\n    if (!wasHydratingServerDraftRef.current || !serverDraft) return;\n    wasHydratingServerDraftRef.current = false;\n    setSavedDraftRequestKey(currentDraftRequestKey);\n  }, [currentDraftRequestKey, isHydratingServerDraft, serverDraft]);\n\n  const ensureCustomWebhookSource = async (): Promise<boolean> => {\n    if (triggerKind !== \"event\" || !customWebhook.enabled) return true;\n    const source = eventSource.trim();\n    if (!source) {\n      setSaveState(\"error\");\n      setStatusMessage({\n        kind: \"error\",\n        text: t(I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_SOURCE_REQUIRED),\n      });\n      return false;\n    }\n    if (customWebhookRegistration?.source === source) return true;\n\n    try {\n      const webhook = await AutomationService.createCustomWebhook({\n        name:\n          customWebhook.name.trim() ||\n          t(I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_DEFAULT_NAME, { source }),\n        source,\n        event_key_expr:\n          customWebhook.eventKeyExpr.trim() ||\n          DEFAULT_CUSTOM_WEBHOOK_EVENT_KEY_EXPR,\n        signature_header:\n          customWebhook.signatureHeader.trim() ||\n          DEFAULT_CUSTOM_WEBHOOK_SIGNATURE_HEADER,\n        signature_scheme: customWebhook.signatureScheme,\n        ...(customWebhook.webhookSecret.trim()\n          ? { webhook_secret: customWebhook.webhookSecret.trim() }\n          : {}),\n      });\n      setCustomWebhookRegistration(webhook);\n      return true;\n    } catch (error) {\n      if (getResponseStatus(error) === 409) {\n        setStatusMessage({\n          kind: \"success\",\n          text: t(I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_ALREADY_EXISTS, {\n            source,\n          }),\n        });\n        return true;\n      }\n      setSaveState(\"error\");\n      displayErrorToast(getApiErrorMessage(error, t(I18nKey.ERROR$GENERIC)));\n      return false;\n    }\n  };\n\n  const uploadCustomArchive = async (): Promise<string> => {\n    const archive = await packTarGzip([\n      { name: MAIN_PY_FILENAME, content: customCode, mode: 0o644 },\n      {\n        name: setupScriptPath.trim(),\n        content: setupScript,\n        mode: 0o755,\n      },\n    ]);\n    return AutomationService.uploadAutomationTarball(normalizedName(), archive);\n  };\n  const persistServerDraft = async (\n    tarballPath?: string,\n  ): Promise<AutomationDraftApiResponse> => {\n    const body = draftRequestBody(tarballPath);\n    const request = {\n      endpoint: draftEndpoint(kind, pluginEndpointSource),\n      draft: body,\n      name: normalizedName(),\n    };\n    const saved = serverDraftId\n      ? await AutomationService.updateServerDraft(serverDraftId, request)\n      : await AutomationService.createServerDraft(request);\n    setServerDraft(saved);\n    saveDraftInFormSession(saved, form);\n    setSavedDraftRequestKey(\n      draftRequestKey(request.endpoint, request.name ?? \"\", request.draft),\n    );\n    setIsTaggedDraftMissing(false);\n    if (isPendingAutomationSetupId(conversationId) && !serverDraftId) {\n      navigate(`/automations/setup?draftId=${encodeURIComponent(saved.id)}`, {\n        replace: true,\n      });\n    }\n    await updateConversationDraftTags(saved.id);\n    return saved;\n  };\n  const runPreflightValidation = async () => {\n    const result = await AutomationService.validateDraft({\n      endpoint: draftValidationEndpoint(kind, pluginEndpointSource),\n      draft:\n        kind === \"custom\"\n          ? buildCustomBody(PREFLIGHT_TARBALL_PATH)\n          : buildPresetBody(),\n    });\n    setStatusMessage({\n      kind: result.valid ? \"success\" : \"error\",\n      text: result.valid\n        ? t(I18nKey.AUTOMATION_SETUP$READY_TO_TEST)\n        : result.errors[0]?.message || t(I18nKey.SETUP$SUBMIT_FAILED),\n    });\n  };\n  const validateRequiredFields = (): boolean => {\n    if (!prompt.trim() && kind !== \"custom\") {\n      setStatusMessage({\n        kind: \"error\",\n        text: t(I18nKey.AUTOMATION_SETUP$PROMPT_REQUIRED),\n      });\n      return false;\n    }\n    if (\n      kind !== \"custom\" &&\n      pluginEntries.some((entry) => entry.ref.trim() && !entry.source.trim())\n    ) {\n      setStatusMessage({\n        kind: \"error\",\n        text: t(I18nKey.AUTOMATION_SETUP$PLUGIN_REQUIRED),\n      });\n      return false;\n    }\n    if (kind === \"custom\" && !hasExistingCustomBundle && !customCode.trim()) {\n      setStatusMessage({\n        kind: \"error\",\n        text: t(I18nKey.AUTOMATION_SETUP$CODE_REQUIRED),\n      });\n      return false;\n    }\n    if (kind === \"custom\" && !entrypoint.trim()) {\n      setStatusMessage({\n        kind: \"error\",\n        text: t(I18nKey.AUTOMATION_SETUP$ENTRYPOINT_REQUIRED),\n      });\n      return false;\n    }\n    if (\n      kind === \"custom\" &&\n      !hasExistingCustomBundle &&\n      !setupScriptPath.trim()\n    ) {\n      setStatusMessage({\n        kind: \"error\",\n        text: t(I18nKey.AUTOMATION_SETUP$SETUP_SCRIPT_PATH_REQUIRED),\n      });\n      return false;\n    }\n    if (kind === \"custom\" && !hasExistingCustomBundle && !setupScript.trim()) {\n      setStatusMessage({\n        kind: \"error\",\n        text: t(I18nKey.AUTOMATION_SETUP$SETUP_SCRIPT_REQUIRED),\n      });\n      return false;\n    }\n    return true;\n  };\n  const handleSaveDraft = async () => {\n    setIsSubmitting(true);\n    setSaveState(\"saving\");\n    try {\n      if (!(await ensureCustomWebhookSource())) return;\n      if (!draftsSupported) {\n        setSaveState(\"saved\");\n        setStatusMessage(null);\n        toast.success(t(I18nKey.AUTOMATION_SETUP$DRAFT_SAVED));\n        return;\n      }\n      const tarballPath =\n        kind === \"custom\" ? await uploadCustomArchive() : undefined;\n      await persistServerDraft(tarballPath);\n      setSaveState(\"saved\");\n      setStatusMessage(null);\n      toast.success(t(I18nKey.AUTOMATION_SETUP$DRAFT_SAVED));\n    } catch (error) {\n      if (isDraftEndpointUnavailable(error)) {\n        setSaveState(\"saved\");\n        setStatusMessage(null);\n        toast.success(t(I18nKey.AUTOMATION_SETUP$DRAFT_SAVED));\n        return;\n      }\n      setSaveState(\"error\");\n      displayErrorToast(\n        getApiErrorMessage(error, t(I18nKey.AUTOMATION_SETUP$SAVE_FAILED)),\n      );\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n  const handleTest = async () => {\n    if (!validateRequiredFields()) return;\n    const eventPayload =\n      triggerKind === \"event\" ? parseEventTestPayload(eventTestPayload) : null;\n    if (triggerKind === \"event\" && eventPayload === null) {\n      setStatusMessage({\n        kind: \"error\",\n        text: t(I18nKey.AUTOMATION_SETUP$TEST_EVENT_PAYLOAD_INVALID),\n      });\n      return;\n    }\n    setIsSubmitting(true);\n    setSaveState(\"saving\");\n    try {\n      if (!(await ensureCustomWebhookSource())) return;\n      if (!draftsSupported) {\n        setSaveState(\"saved\");\n        await runPreflightValidation();\n        return;\n      }\n\n      // Persist the current form state as a draft first, then dispatch it.\n      // The service materializes the validated draft body into a disabled\n      // automation and starts a manual run; the draft row stays as source\n      // of truth for further edits.\n      const tarballPath =\n        kind === \"custom\" ? await uploadCustomArchive() : undefined;\n      const saved = await persistServerDraft(tarballPath);\n      setSaveState(\"saved\");\n\n      if (!saved.dispatchable) {\n        // The draft bar already shows the validation error. A second copy\n        // at the bottom of the form just repeats it.\n        setStatusMessage(\n          saved.validationErrors?.[0]?.message\n            ? null\n            : {\n                kind: \"error\",\n                text: t(I18nKey.SETUP$SUBMIT_FAILED),\n              },\n        );\n        return;\n      }\n\n      const run = eventPayload\n        ? await AutomationService.dispatchServerDraft(saved.id, {\n            eventPayload,\n          })\n        : await AutomationService.dispatchServerDraft(saved.id);\n      const materializedAutomationId =\n        typeof (run as unknown as Record<string, unknown>).automation_id ===\n        \"string\"\n          ? String((run as unknown as Record<string, unknown>).automation_id)\n          : saved.materializedAutomationId;\n      setServerDraft({\n        ...saved,\n        materializedAutomationId,\n        lastTestRunId: run.id,\n      });\n      setDraftRuns((previous) => [\n        run,\n        ...previous.filter((existing) => existing.id !== run.id),\n      ]);\n      setStatusMessage({\n        kind: \"success\",\n        text: t(I18nKey.AUTOMATION_SETUP$TEST_DISPATCHED),\n      });\n    } catch (error) {\n      if (isDraftEndpointUnavailable(error)) {\n        setSaveState(\"saved\");\n        await runPreflightValidation();\n        return;\n      }\n      setSaveState(\"error\");\n      const dispatchError = extractDraftDispatchErrors(error);\n      if (dispatchError) {\n        setStatusMessage({\n          kind: \"error\",\n          text: dispatchError,\n        });\n      } else {\n        displayErrorToast(\n          getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR)),\n        );\n      }\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n  const handleCreate = async () => {\n    if (!validateRequiredFields()) return;\n    setIsSubmitting(true);\n    setSaveState(serverDraftId ? \"saving\" : saveState);\n    try {\n      if (!(await ensureCustomWebhookSource())) return;\n      let created: Record<string, unknown>;\n      if (kind === \"custom\") {\n        const tarballPath = await uploadCustomArchive();\n        created = await AutomationService.createAutomationDraft(\n          buildCustomBody(tarballPath),\n          kind,\n        );\n      } else {\n        created = await AutomationService.createAutomationDraft(\n          buildPresetBody(),\n          presetKindForEndpoint(kind, pluginEndpointSource),\n        );\n      }\n      setSaveState(\"saved\");\n      toast.success(t(I18nKey.AUTOMATION_SETUP$CREATED));\n      // The draft has been finalized into a real automation; drop the\n      // persisted draft row so it does not linger as an incomplete setup.\n      if (serverDraftId) {\n        try {\n          await AutomationService.deleteServerDraft(serverDraftId);\n        } catch {\n          // Cleanup is best-effort; the automation was created either way.\n        }\n        try {\n          await updateConversationDraftTags(null);\n        } catch {\n          // Tag cleanup is best-effort once the automation exists.\n        }\n      }\n      if (typeof created.id === \"string\")\n        navigate(automationDetailPath(created.id));\n    } catch (error) {\n      displayErrorToast(\n        getApiErrorMessage(error, t(I18nKey.SETUP$SUBMIT_FAILED)),\n      );\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  useEffect(() => {\n    const automationId = serverDraft?.materializedAutomationId;\n    if (!automationId) {\n      setDraftRuns([]);\n      return undefined;\n    }\n\n    let cancelled = false;\n    AutomationService.listAutomationRuns(automationId, { limit: 10, offset: 0 })\n      .then((response) => {\n        if (!cancelled && response.runs.length > 0) setDraftRuns(response.runs);\n      })\n      .catch(() => {\n        if (!cancelled) setDraftRuns((previous) => previous);\n      });\n\n    return () => {\n      cancelled = true;\n    };\n  }, [serverDraft?.materializedAutomationId]);\n\n  const saveStateLabel = () => {\n    if (saveState === \"saving\") return t(I18nKey.AUTOMATION_SETUP$SAVING);\n    if (saveState === \"error\") return t(I18nKey.AUTOMATION_SETUP$SAVE_FAILED);\n    if (isEditingExisting) {\n      return saveState === \"saved\"\n        ? t(I18nKey.AUTOMATION_SETUP$SAVED_JUST_NOW)\n        : null;\n    }\n    if (serverDraft && !isDraftDirty) {\n      return t(I18nKey.AUTOMATION_SETUP$SAVED_JUST_NOW);\n    }\n    if (isDraftDirty) return t(I18nKey.AUTOMATION_SETUP$UNSAVED_CHANGES);\n    return null;\n  };\n\n  /**\n   * Only fields accepted by the backend PATCH contract are sent when editing an\n   * existing automation. Plugins and uploaded code are create-time\n   * configuration, and repositories are sent only when they changed and the\n   * service can rebuild this automation with them.\n   */\n  const buildExistingAutomationBody = () => {\n    const body: Record<string, unknown> = {\n      name: normalizedName(),\n      trigger: buildTrigger(),\n      model: model.trim() || null,\n      timeout:\n        showTimeout && timeoutSeconds.trim() ? Number(timeoutSeconds) : null,\n    };\n    if (kind === \"custom\")\n      body.agent_profile_id = agentProfileId.trim() || null;\n    else body.prompt = prompt.trim();\n    const repositories = parseAutomationSetupRepositories(repository);\n    if (\n      repositoriesEditable &&\n      automationRepositoriesChanged(repositories, existingRepositories)\n    ) {\n      body.repos = buildAutomationRepoSources(\n        repositories,\n        existingRepositories,\n        providerHosts,\n        cloneProviders,\n      );\n    }\n    return body as Partial<Automation>;\n  };\n\n  const handleSaveExisting = async () => {\n    if (!validateRequiredFields()) return;\n    setIsSubmitting(true);\n    setSaveState(\"saving\");\n    try {\n      if (!(await ensureCustomWebhookSource())) return;\n      const updated = await AutomationService.updateAutomation(\n        editingAutomationId,\n        buildExistingAutomationBody(),\n      );\n      const saved = withPresetSources(updated);\n      const savedRepositories = saved.repositories ?? [];\n      // The session re-supplies this draft on every edit, so it must hold the\n      // saved list too, or the next change is compared with the old one.\n      if (conversationId) {\n        setAutomationFormSessionRepositories(conversationId, savedRepositories);\n      }\n      setExistingRepositories(savedRepositories);\n      // The automation's page opens Edit from its cached copy, so replace that\n      // copy with the saved automation before anything can reopen it.\n      const detailKey = [...AUTOMATION_DETAIL_QUERY_KEY, editingAutomationId];\n      queryClient.setQueriesData({ queryKey: detailKey }, saved);\n      queryClient.invalidateQueries({ queryKey: detailKey });\n      queryClient.invalidateQueries({ queryKey: AUTOMATIONS_QUERY_KEY });\n      setSaveState(\"saved\");\n      toast.success(t(I18nKey.AUTOMATIONS$EDIT_SUCCESS));\n    } catch (error) {\n      setSaveState(\"error\");\n      displayErrorToast(\n        getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$EDIT_ERROR)),\n      );\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  const handleTestExisting = async () => {\n    setIsSubmitting(true);\n    try {\n      const run =\n        await AutomationService.dispatchAutomation(editingAutomationId);\n      setDraftRuns((previous) => [\n        run,\n        ...previous.filter((existing) => existing.id !== run.id),\n      ]);\n      setStatusMessage({\n        kind: \"success\",\n        text: t(I18nKey.AUTOMATION_SETUP$TEST_DISPATCHED),\n      });\n    } catch (error) {\n      displayErrorToast(\n        getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR)),\n      );\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  const closeEditor = () => {\n    navigate(automationListPath());\n  };\n\n  const compactToolbarButtonClassName = \"!h-7 !min-h-7 !px-2.5 !text-xs\";\n  const visiblePluginEntries = kind === \"custom\" ? [] : pluginEntries;\n  const addPlugin = () =>\n    writePlugins([\n      ...pluginEntries,\n      { source: \"\", ref: DEFAULT_AUTOMATION_PLUGIN_REF },\n    ]);\n  const saveStateText = saveStateLabel();\n  const draftsSupported = AutomationService.supportsAutomationDrafts(\n    deploymentCapabilities.data,\n  );\n  // Server drafts have to be saved before a test can run. Until then the\n  // toolbar button stays visible and inactive.\n  const canTestDraft =\n    !draftsSupported || (serverDraft !== null && !isDraftDirty);\n\n  const renderToolbarActions = () => (\n    <div className=\"flex shrink-0 items-center gap-1.5\">\n      {saveStateText ? (\n        <span\n          data-testid=\"automation-setup-save-state\"\n          className=\"shrink-0 text-xs text-[var(--oh-muted)]\"\n        >\n          {saveStateText}\n        </span>\n      ) : null}\n      {isEditingExisting ? (\n        <>\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            testId=\"automation-setup-test\"\n            className={compactToolbarButtonClassName}\n            isDisabled={isSubmitting}\n            aria-busy={isSubmitting}\n            onClick={handleTestExisting}\n          >\n            {t(I18nKey.AUTOMATION_SETUP$TEST)}\n          </BrandButton>\n          <BrandButton\n            type=\"button\"\n            variant=\"primary\"\n            testId=\"automation-setup-save\"\n            className={compactToolbarButtonClassName}\n            isDisabled={isSubmitting}\n            aria-busy={isSubmitting}\n            onClick={handleSaveExisting}\n          >\n            {t(I18nKey.BUTTON$SAVE)}\n          </BrandButton>\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            testId=\"automation-setup-close\"\n            className={compactToolbarButtonClassName}\n            onClick={closeEditor}\n          >\n            {t(I18nKey.BUTTON$CLOSE)}\n          </BrandButton>\n        </>\n      ) : (\n        <>\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            testId=\"automation-setup-save-draft\"\n            className={compactToolbarButtonClassName}\n            isDisabled={isSubmitting}\n            onClick={handleSaveDraft}\n          >\n            {t(I18nKey.AUTOMATION_SETUP$SAVE_DRAFT)}\n          </BrandButton>\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            testId=\"automation-setup-test\"\n            className={compactToolbarButtonClassName}\n            isDisabled={isSubmitting || !canTestDraft}\n            aria-busy={isSubmitting}\n            onClick={() => {\n              setIsViewingDraftRuns(true);\n              void handleTest();\n            }}\n          >\n            {t(I18nKey.AUTOMATION_SETUP$TEST)}\n          </BrandButton>\n          <BrandButton\n            type=\"button\"\n            variant=\"primary\"\n            testId=\"automation-setup-create\"\n            className={compactToolbarButtonClassName}\n            isDisabled={isSubmitting}\n            aria-busy={isSubmitting}\n            onClick={handleCreate}\n          >\n            {t(I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON)}\n          </BrandButton>\n        </>\n      )}\n    </div>\n  );\n\n  return (\n    <>\n      {toolbarPortal\n        ? createPortal(renderToolbarActions(), toolbarPortal)\n        : null}\n      <div\n        data-testid=\"automation-setup-panel\"\n        className=\"flex h-full min-h-0 flex-col bg-canvas-base\"\n      >\n        {showInlineHeader ? (\n          <header className=\"flex h-10 min-h-10 shrink-0 items-center justify-between gap-2 border-b border-[var(--oh-border)] bg-canvas-base px-3\">\n            <div className=\"flex min-w-0 items-center gap-2\">\n              <h2 className=\"min-w-0 truncate text-sm font-medium text-content\">\n                {name.trim() || t(I18nKey.AUTOMATION_SETUP$TITLE)}\n              </h2>\n            </div>\n            {renderToolbarActions()}\n          </header>\n        ) : null}\n\n        <div\n          className={cn(\n            \"custom-scrollbar-always min-h-0 flex-1 overflow-y-auto px-5 pt-5 [scrollbar-gutter:stable]\",\n            reserveComposerSpace ? \"pb-52\" : \"pb-5\",\n          )}\n        >\n          <div className=\"mx-auto flex w-full min-w-0 max-w-[800px] flex-col gap-6\">\n            {serverDraft && !isViewingDraftRuns ? (\n              <DraftStatusBar\n                draft={serverDraft}\n                runs={draftRuns}\n                isSubmitting={isSubmitting}\n                canTest={isEditingExisting || canTestDraft}\n                saveFailed={saveState === \"error\"}\n                onTest={() => {\n                  setIsViewingDraftRuns(true);\n                  void (isEditingExisting\n                    ? handleTestExisting()\n                    : handleTest());\n                }}\n                onViewRuns={() => setIsViewingDraftRuns(true)}\n              />\n            ) : null}\n            {isViewingDraftRuns ? (\n              <DraftRunsPage\n                runs={draftRuns}\n                errors={draftRunPageErrors(\n                  serverDraft,\n                  statusMessage,\n                  draftRuns,\n                )}\n                isSubmitting={isSubmitting}\n                canTest={isEditingExisting || canTestDraft}\n                onBack={() => setIsViewingDraftRuns(false)}\n                onTest={isEditingExisting ? handleTestExisting : handleTest}\n              />\n            ) : null}\n            <div\n              data-testid=\"automation-setup-form\"\n              className={cn(\n                \"flex flex-col gap-6\",\n                isViewingDraftRuns && \"hidden\",\n              )}\n            >\n              <Field\n                label={t(I18nKey.AUTOMATIONS$NAME)}\n                suffix={agentUpdatedSuffix(\"name\")}\n                isStreaming={streamingField === \"name\"}\n                errorText={fieldError(\"name\")}\n              >\n                <input\n                  data-testid=\"automation-setup-name\"\n                  value={name}\n                  placeholder={t(I18nKey.AUTOMATION_SETUP$NAME_PLACEHOLDER)}\n                  onChange={(event) => updateField(\"name\", event.target.value)}\n                  className={formControlFieldClassName}\n                />\n              </Field>\n\n              {isHydratingServerDraft ? (\n                <p\n                  data-testid=\"automation-setup-draft-loading\"\n                  className=\"rounded-2xl border border-[var(--oh-border)] bg-[var(--oh-surface)] px-5 py-4 text-sm text-muted\"\n                >\n                  {t(I18nKey.AUTOMATION_SETUP$LOADING_DRAFT)}\n                </p>\n              ) : null}\n\n              {isTaggedDraftMissing ? (\n                <p\n                  data-testid=\"automation-setup-draft-missing\"\n                  className=\"rounded-2xl border border-[var(--oh-warning)]/40 bg-[var(--oh-warning)]/10 px-5 py-4 text-sm text-[var(--oh-warning)]\"\n                >\n                  {t(I18nKey.AUTOMATION_SETUP$DRAFT_MISSING)}\n                </p>\n              ) : null}\n\n              <div className=\"flex flex-col gap-2.5\">\n                <div className=\"flex w-full items-center gap-2\">\n                  <span className=\"flex items-center gap-2 text-sm\">\n                    {kind === \"custom\"\n                      ? t(I18nKey.AUTOMATION_SETUP$CUSTOM_PYTHON)\n                      : t(I18nKey.AUTOMATIONS$PROMPT)}\n                    {agentUpdatedSuffix(\n                      kind === \"custom\" ? \"customCode\" : \"prompt\",\n                    ) ? (\n                      <span className=\"font-normal text-[var(--oh-muted)]\">\n                        {agentUpdatedSuffix(\n                          kind === \"custom\" ? \"customCode\" : \"prompt\",\n                        )}\n                      </span>\n                    ) : null}\n                  </span>\n                  <button\n                    type=\"button\"\n                    data-testid={\n                      kind === \"custom\"\n                        ? \"automation-setup-kind-prompt\"\n                        : \"automation-setup-kind-custom\"\n                    }\n                    onClick={() =>\n                      updateField(\n                        \"kind\",\n                        kind === \"custom\" ? \"prompt\" : \"custom\",\n                      )\n                    }\n                    className={cn(\n                      addOptionButtonClassName,\n                      \"ml-auto gap-1.5\",\n                      streamingHighlightClassName(streamingField === \"kind\"),\n                    )}\n                  >\n                    {kind === \"custom\" ? (\n                      <FileText className=\"size-4\" aria-hidden />\n                    ) : (\n                      <Code2 className=\"size-4\" aria-hidden />\n                    )}\n                    {kind === \"custom\"\n                      ? t(I18nKey.AUTOMATIONS$PROMPT)\n                      : t(I18nKey.AUTOMATION_SETUP$TYPE_CUSTOM)}\n                  </button>\n                </div>\n                <SetupKindCrossfade\n                  view={kind === \"custom\" ? \"custom\" : \"prompt\"}\n                >\n                  {kind !== \"custom\" ? (\n                    <AutomationSetupPromptStack\n                      prompt={prompt}\n                      repository={repository}\n                      updatedSuffix={agentUpdatedSuffix(\"prompt\")}\n                      repositorySuffix={agentUpdatedSuffix(\"repository\")}\n                      isStreaming={streamingField === \"prompt\"}\n                      errorText={fieldError(\"prompt\")}\n                      showTitle={false}\n                      onPromptChange={(value) => updateField(\"prompt\", value)}\n                      onRepositoryChange={(value) =>\n                        updateField(\"repository\", value)\n                      }\n                      repositoryReadOnly={!repositoriesEditable}\n                      model={model}\n                      onModelChange={(value) => updateField(\"model\", value)}\n                    />\n                  ) : hasExistingCustomBundle ? (\n                    <ExistingCustomBundleSummary\n                      tarballPath={existingCustomTarballPath}\n                    />\n                  ) : (\n                    <CustomCodeFields\n                      code={customCode}\n                      entrypoint={entrypoint}\n                      setupScriptPath={setupScriptPath}\n                      setupScript={setupScript}\n                      updatedSuffixes={{\n                        customCode: agentUpdatedSuffix(\"customCode\"),\n                        entrypoint: agentUpdatedSuffix(\"entrypoint\"),\n                        setupScriptPath: agentUpdatedSuffix(\"setupScriptPath\"),\n                        setupScript: agentUpdatedSuffix(\"setupScript\"),\n                      }}\n                      streamingField={streamingField}\n                      onCodeChange={(value) => updateField(\"customCode\", value)}\n                      onEntrypointChange={(value) =>\n                        updateField(\"entrypoint\", value)\n                      }\n                      onSetupScriptPathChange={(value) =>\n                        updateField(\"setupScriptPath\", value)\n                      }\n                      onSetupScriptChange={(value) =>\n                        updateField(\"setupScript\", value)\n                      }\n                    />\n                  )}\n                </SetupKindCrossfade>\n              </div>\n\n              <section className=\"flex flex-col gap-2.5\">\n                <div\n                  role=\"radiogroup\"\n                  aria-label={t(I18nKey.AUTOMATIONS$DETAIL$TRIGGER)}\n                  className={cn(\n                    \"grid grid-cols-2 gap-2\",\n                    streamingHighlightClassName(\n                      streamingField === \"triggerKind\",\n                    ),\n                  )}\n                >\n                  <TriggerCard\n                    icon={<CalendarDays className=\"size-4\" aria-hidden />}\n                    title={t(I18nKey.AUTOMATION_SETUP$SCHEDULE)}\n                    description={t(\n                      I18nKey.AUTOMATION_SETUP$SCHEDULE_DESCRIPTION,\n                    )}\n                    selected={triggerKind === \"cron\"}\n                    onClick={() => updateField(\"triggerKind\", \"cron\")}\n                  />\n                  <TriggerCard\n                    icon={<Zap className=\"size-4\" aria-hidden />}\n                    title={t(I18nKey.AUTOMATIONS$DETAIL$TRIGGER_EVENT)}\n                    description={t(I18nKey.AUTOMATION_SETUP$EVENT_DESCRIPTION)}\n                    selected={triggerKind === \"event\"}\n                    onClick={() => updateField(\"triggerKind\", \"event\")}\n                  />\n                </div>\n              </section>\n\n              {triggerKind === \"cron\" ? (\n                <ScheduleFields\n                  frequency={frequency}\n                  time={time}\n                  weekday={weekday}\n                  scheduleDateTime={scheduleDateTime}\n                  timezone={timezone}\n                  customSchedule={customSchedule}\n                  updatedSuffixes={{\n                    frequency: agentUpdatedSuffix(\"frequency\"),\n                    time: agentUpdatedSuffix(\"time\"),\n                    timezone: agentUpdatedSuffix(\"timezone\"),\n                    customSchedule: agentUpdatedSuffix(\"customSchedule\"),\n                  }}\n                  streamingField={streamingField}\n                  setFrequency={(value) => {\n                    if (value === \"custom\" && frequency !== \"custom\") {\n                      // Start Custom from the preset the user configured.\n                      updateField(\n                        \"customSchedule\",\n                        toCron(\n                          time,\n                          frequency,\n                          customSchedule,\n                          scheduleDateTime || defaultScheduleDateTimeLocal(),\n                          weekday,\n                        ),\n                      );\n                    }\n                    updateField(\"frequency\", value);\n                    if (value === \"once\" && !scheduleDateTime.trim()) {\n                      updateField(\n                        \"scheduleDateTime\",\n                        defaultScheduleDateTimeLocal(),\n                      );\n                    }\n                  }}\n                  setTime={(value) => updateField(\"time\", value)}\n                  setWeekday={(value) => updateField(\"weekday\", value)}\n                  setScheduleDateTime={(value) =>\n                    updateField(\"scheduleDateTime\", value)\n                  }\n                  setTimezone={(value) => updateField(\"timezone\", value)}\n                  setCustomSchedule={(value) =>\n                    updateField(\"customSchedule\", value)\n                  }\n                />\n              ) : (\n                <EventFields\n                  eventSource={eventSource}\n                  eventKey={eventKey}\n                  eventFilter={eventFilter}\n                  eventTestPayload={eventTestPayload}\n                  eventSourceOptions={eventSourceOptions}\n                  eventTypeOptions={eventTypeOptions}\n                  customWebhook={customWebhook}\n                  customWebhookRegistration={customWebhookRegistration}\n                  usesExistingAutomationTestPayload={isEditingExisting}\n                  updatedSuffixes={{\n                    eventSource: agentUpdatedSuffix(\"eventSource\"),\n                    eventKey: agentUpdatedSuffix(\"eventKey\"),\n                    eventFilter: agentUpdatedSuffix(\"eventFilter\"),\n                  }}\n                  streamingField={streamingField}\n                  setEventSource={(value) => updateField(\"eventSource\", value)}\n                  setEventKey={(value) => updateField(\"eventKey\", value)}\n                  setEventFilter={(value) => updateField(\"eventFilter\", value)}\n                  setEventTestPayload={(value) => {\n                    setIsEventTestPayloadDirty(true);\n                    setEventTestPayload(value);\n                  }}\n                  setCustomWebhookField={updateCustomWebhook}\n                />\n              )}\n\n              <section className=\"flex flex-col gap-2.5\">\n                <span className=\"text-sm\">\n                  {t(I18nKey.AUTOMATION_SETUP$ADDITIONAL_OPTIONS)}\n                </span>\n                {visiblePluginEntries.length > 0 ? (\n                  <div\n                    data-testid=\"automation-setup-plugin-module\"\n                    className=\"flex flex-col gap-3 rounded-xl border border-[var(--oh-border)] bg-base-secondary p-4\"\n                  >\n                    {visiblePluginEntries.map((entry, index) => (\n                      <div\n                        key={`plugin-${index}`}\n                        className={cn(\n                          \"flex min-w-0 gap-2\",\n                          index === 0 ? \"items-end\" : \"items-center\",\n                        )}\n                      >\n                        <div className=\"grid min-w-0 flex-1 gap-3 md:grid-cols-[2fr_1fr]\">\n                          <Field\n                            label={t(I18nKey.AUTOMATION_SETUP$PLUGIN_SOURCE)}\n                            showLabel={index === 0}\n                            suffix={\n                              index === 0\n                                ? agentUpdatedSuffix(\"pluginSource\")\n                                : undefined\n                            }\n                            isStreaming={\n                              index === 0 && streamingField === \"pluginSource\"\n                            }\n                          >\n                            <input\n                              data-testid={\n                                index === 0\n                                  ? \"automation-setup-plugin-source\"\n                                  : `automation-setup-plugin-source-${index}`\n                              }\n                              value={entry.source}\n                              aria-label={\n                                index === 0\n                                  ? undefined\n                                  : t(I18nKey.AUTOMATION_SETUP$PLUGIN_SOURCE)\n                              }\n                              placeholder={t(\n                                I18nKey.AUTOMATION_SETUP$PLUGIN_SOURCE_PLACEHOLDER,\n                              )}\n                              onChange={(event) =>\n                                writePlugins(\n                                  pluginEntries.map((plugin, entryIndex) =>\n                                    entryIndex === index\n                                      ? {\n                                          ...plugin,\n                                          source: event.target.value,\n                                        }\n                                      : plugin,\n                                  ),\n                                )\n                              }\n                              className={formControlFieldClassName}\n                            />\n                          </Field>\n                          <Field\n                            label={t(I18nKey.AUTOMATION_SETUP$PLUGIN_REF)}\n                            showLabel={index === 0}\n                            suffix={\n                              index === 0\n                                ? agentUpdatedSuffix(\"pluginRef\")\n                                : undefined\n                            }\n                            isStreaming={\n                              index === 0 && streamingField === \"pluginRef\"\n                            }\n                          >\n                            <input\n                              data-testid={\n                                index === 0\n                                  ? \"automation-setup-plugin-ref\"\n                                  : `automation-setup-plugin-ref-${index}`\n                              }\n                              value={entry.ref}\n                              aria-label={\n                                index === 0\n                                  ? undefined\n                                  : t(I18nKey.AUTOMATION_SETUP$PLUGIN_REF)\n                              }\n                              placeholder={t(\n                                I18nKey.AUTOMATION_SETUP$PLUGIN_REF_PLACEHOLDER,\n                              )}\n                              onChange={(event) =>\n                                writePlugins(\n                                  pluginEntries.map((plugin, entryIndex) =>\n                                    entryIndex === index\n                                      ? { ...plugin, ref: event.target.value }\n                                      : plugin,\n                                  ),\n                                )\n                              }\n                              className={formControlFieldClassName}\n                            />\n                          </Field>\n                        </div>\n                        <div\n                          className={cn(\n                            \"flex shrink-0 items-center\",\n                            index === 0 && formControlHeightClassName,\n                          )}\n                        >\n                          <button\n                            type=\"button\"\n                            data-testid={\n                              index === 0\n                                ? \"automation-setup-plugin-remove\"\n                                : `automation-setup-plugin-remove-${index}`\n                            }\n                            aria-label={`${t(I18nKey.COMMON$REMOVE)} ${t(I18nKey.AUTOMATION_SETUP$TYPE_PLUGIN)}`}\n                            className=\"inline-flex size-6 shrink-0 items-center justify-center rounded-md text-[var(--oh-muted)] hover:bg-white/10 hover:text-white\"\n                            onMouseDown={(event) => event.preventDefault()}\n                            onClick={() => {\n                              const nextEntries = pluginEntries.filter(\n                                (_, entryIndex) => entryIndex !== index,\n                              );\n                              writePlugins(nextEntries);\n                              if (\n                                nextEntries.length === 0 &&\n                                kind === \"plugin\"\n                              ) {\n                                updateField(\"kind\", \"prompt\");\n                              }\n                            }}\n                          >\n                            <X className=\"size-4\" aria-hidden />\n                          </button>\n                        </div>\n                      </div>\n                    ))}\n                    <button\n                      type=\"button\"\n                      data-testid=\"automation-setup-add-plugin\"\n                      onClick={addPlugin}\n                      className={cn(\n                        addOptionButtonClassName,\n                        streamingHighlightClassName(\n                          streamingField === \"pluginSource\",\n                        ),\n                      )}\n                    >\n                      {`${t(I18nKey.BUTTON$ADD)} ${t(I18nKey.AUTOMATION_SETUP$TYPE_PLUGIN)}`}\n                    </button>\n                  </div>\n                ) : null}\n                {showTimeout ? (\n                  <label className=\"flex w-full min-w-0 flex-col gap-2.5 rounded-xl border border-[var(--oh-border)] bg-base-secondary p-4\">\n                    <div className=\"flex w-full items-center gap-2\">\n                      <span className=\"text-sm font-normal text-content\">\n                        {t(I18nKey.AUTOMATION_SETUP$TIMEOUT_SECONDS)}\n                      </span>\n                      <OptionalTag />\n                      {agentUpdatedSuffix(\"timeoutSeconds\") ? (\n                        <span className=\"text-xs text-[var(--oh-muted)]\">\n                          {agentUpdatedSuffix(\"timeoutSeconds\")}\n                        </span>\n                      ) : null}\n                      <button\n                        type=\"button\"\n                        data-testid=\"automation-setup-timeout-remove\"\n                        aria-label={`${t(I18nKey.COMMON$REMOVE)} ${t(I18nKey.AUTOMATION_SETUP$TIMEOUT_SECONDS)}`}\n                        className=\"ml-auto inline-flex size-6 items-center justify-center rounded-md text-[var(--oh-muted)] hover:bg-white/10 hover:text-white\"\n                        onMouseDown={(event) => event.preventDefault()}\n                        onClick={() => {\n                          updateField(\"showTimeout\", false);\n                          updateField(\"timeoutSeconds\", \"\");\n                        }}\n                      >\n                        <X className=\"size-4\" aria-hidden />\n                      </button>\n                    </div>\n                    <input\n                      data-testid=\"automation-setup-timeout\"\n                      type=\"number\"\n                      min=\"1\"\n                      name=\"timeout\"\n                      value={timeoutSeconds}\n                      onChange={(event) =>\n                        updateField(\"timeoutSeconds\", event.target.value)\n                      }\n                      className={cn(\n                        formControlFieldClassName,\n                        streamingHighlightClassName(\n                          streamingField === \"timeoutSeconds\",\n                        ),\n                      )}\n                    />\n                  </label>\n                ) : null}\n                {kind !== \"custom\" || !showTimeout ? (\n                  <div className=\"flex flex-wrap items-center gap-2\">\n                    {kind !== \"custom\" && visiblePluginEntries.length === 0 ? (\n                      <button\n                        type=\"button\"\n                        data-testid=\"automation-setup-add-plugin\"\n                        onClick={addPlugin}\n                        className={cn(\n                          addOptionButtonClassName,\n                          streamingHighlightClassName(\n                            streamingField === \"pluginSource\",\n                          ),\n                        )}\n                      >\n                        {`${t(I18nKey.BUTTON$ADD)} ${t(I18nKey.AUTOMATION_SETUP$TYPE_PLUGIN)}`}\n                      </button>\n                    ) : null}\n                    {!showTimeout ? (\n                      <button\n                        type=\"button\"\n                        data-testid=\"automation-setup-add-timeout\"\n                        onClick={() => updateField(\"showTimeout\", true)}\n                        className={cn(\n                          addOptionButtonClassName,\n                          streamingHighlightClassName(\n                            streamingField === \"showTimeout\",\n                          ),\n                        )}\n                      >\n                        {t(I18nKey.AUTOMATION_SETUP$ADD_TIMEOUT)}\n                      </button>\n                    ) : null}\n                  </div>\n                ) : null}\n              </section>\n            </div>\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n\nconst SETUP_KIND_CROSSFADE_SECONDS = 0.15;\n\nfunction SetupKindCrossfade({\n  view,\n  children,\n}: {\n  view: \"prompt\" | \"custom\";\n  children: ReactNode;\n}) {\n  const reduceMotion = useReducedMotion();\n  if (reduceMotion || import.meta.env.MODE === \"test\") {\n    return children;\n  }\n\n  return (\n    <div className=\"relative\">\n      <AnimatePresence initial={false}>\n        <motion.div\n          key={view}\n          data-testid=\"automation-setup-kind-crossfade\"\n          data-kind={view}\n          className=\"w-full\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{\n            opacity: 0,\n            position: \"absolute\",\n            top: 0,\n            left: 0,\n            right: 0,\n            pointerEvents: \"none\",\n          }}\n          transition={{\n            duration: SETUP_KIND_CROSSFADE_SECONDS,\n            ease: \"easeInOut\",\n          }}\n        >\n          {children}\n        </motion.div>\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction ExistingCustomBundleSummary({ tarballPath }: { tarballPath: string }) {\n  const { t } = useTranslation(\"openhands\");\n  const bundleName = existingTarballDisplayName(tarballPath) || tarballPath;\n  const helpText = t(I18nKey.AUTOMATION_SETUP$EXISTING_BUNDLE_HELP);\n\n  return (\n    <div\n      data-testid=\"automation-setup-existing-bundle\"\n      className=\"rounded-[15px] border border-[var(--oh-border)] bg-[var(--oh-surface)] p-4\"\n    >\n      <div className=\"mb-2 flex items-center gap-2 text-sm text-content\">\n        <span>{t(I18nKey.AUTOMATION_SETUP$EXISTING_BUNDLE)}</span>\n        <StyledTooltip content={helpText} placement=\"top\">\n          <button\n            type=\"button\"\n            aria-label={helpText}\n            data-testid=\"automation-setup-existing-bundle-info\"\n            className=\"inline-flex size-5 shrink-0 items-center justify-center rounded-full text-[var(--oh-muted)]\"\n            onMouseDown={(event) => event.preventDefault()}\n          >\n            <Info className=\"size-4\" aria-hidden />\n          </button>\n        </StyledTooltip>\n      </div>\n      <div\n        data-testid=\"automation-setup-existing-bundle-name\"\n        title={tarballPath}\n        className=\"truncate rounded-lg border border-[var(--oh-border)] bg-[var(--oh-surface-raised)] px-3 py-2 font-mono text-sm text-content\"\n      >\n        {bundleName}\n      </div>\n    </div>\n  );\n}\n\nconst CUSTOM_CODE_LINE_HEIGHT_PX = 20;\nconst CUSTOM_CODE_PAD_PX = 16;\nconst CUSTOM_CODE_VISIBLE_LINES = 12;\n/** Top inset plus whole lines, so the scrollbar sits on the card edge and the clip falls between lines. */\nconst CUSTOM_CODE_MIN_HEIGHT =\n  CUSTOM_CODE_PAD_PX + CUSTOM_CODE_VISIBLE_LINES * CUSTOM_CODE_LINE_HEIGHT_PX;\nconst CUSTOM_CODE_MAX_HEIGHT =\n  CUSTOM_CODE_PAD_PX + 28 * CUSTOM_CODE_LINE_HEIGHT_PX;\n\nfunction CustomCodeFields({\n  code,\n  entrypoint,\n  setupScriptPath,\n  setupScript,\n  updatedSuffixes,\n  streamingField,\n  onCodeChange,\n  onEntrypointChange,\n  onSetupScriptPathChange,\n  onSetupScriptChange,\n}: {\n  code: string;\n  entrypoint: string;\n  setupScriptPath: string;\n  setupScript: string;\n  updatedSuffixes: Partial<\n    Record<\n      \"customCode\" | \"entrypoint\" | \"setupScriptPath\" | \"setupScript\",\n      string | undefined\n    >\n  >;\n  streamingField: AutomationSetupField | null;\n  onCodeChange: (value: string) => void;\n  onEntrypointChange: (value: string) => void;\n  onSetupScriptPathChange: (value: string) => void;\n  onSetupScriptChange: (value: string) => void;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const reduceMotion = useReducedMotion();\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const frameRef = useRef<HTMLDivElement>(null);\n  const [detailsOpen, setDetailsOpen] = useState(\n    () =>\n      entrypoint !== DEFAULT_CUSTOM_ENTRYPOINT ||\n      setupScriptPath !== DEFAULT_CUSTOM_SETUP_SCRIPT_PATH ||\n      setupScript !== DEFAULT_CUSTOM_SETUP_SCRIPT,\n  );\n  const { gripRef, isGripDragging, handleGripMouseDown, handleGripTouchStart } =\n    usePromptTextareaResize(frameRef, {\n      minHeight: CUSTOM_CODE_MIN_HEIGHT,\n      maxHeight: CUSTOM_CODE_MAX_HEIGHT,\n      contentKey: code,\n    });\n\n  useLayoutEffect(() => {\n    const textarea = textareaRef.current;\n    if (!textarea) return;\n    textarea.style.height = \"0px\";\n    textarea.style.height = `${textarea.scrollHeight}px`;\n  }, [code]);\n\n  return (\n    <div className=\"relative w-full\">\n      <div\n        data-streaming-active={\n          streamingField === \"customCode\" ? \"true\" : undefined\n        }\n        className={cn(\n          \"relative z-10 -mb-[15px] overflow-hidden rounded-[15px] border border-[var(--oh-border)] bg-[var(--oh-surface)]\",\n          streamingHighlightClassName(streamingField === \"customCode\"),\n        )}\n      >\n        <div\n          ref={frameRef}\n          data-testid=\"automation-setup-custom-code-scroll\"\n          className=\"custom-scrollbar-always min-h-[256px] overflow-x-hidden\"\n        >\n          <div className=\"px-4 pt-4 pb-4\">\n            <textarea\n              ref={textareaRef}\n              data-testid=\"automation-setup-custom-code\"\n              aria-label={t(I18nKey.AUTOMATION_SETUP$CUSTOM_PYTHON)}\n              rows={CUSTOM_CODE_VISIBLE_LINES}\n              value={code}\n              onChange={(event) => onCodeChange(event.target.value)}\n              spellCheck={false}\n              className=\"block w-full resize-none overflow-hidden border-0 bg-transparent p-0 font-mono text-sm leading-5 text-content outline-none\"\n            />\n          </div>\n        </div>\n        <div\n          data-testid=\"automation-setup-custom-code-grip\"\n          className=\"group absolute bottom-0 left-0 z-20 h-3 w-full\"\n        >\n          <div\n            className=\"absolute inset-0 z-[1] cursor-ns-resize select-none\"\n            onMouseDown={handleGripMouseDown}\n            onTouchStart={handleGripTouchStart}\n            aria-hidden\n          />\n          <div\n            ref={gripRef}\n            className={cn(\n              \"pointer-events-none absolute bottom-0 left-0 z-[2] h-px w-full bg-white transition-opacity duration-200\",\n              isGripDragging\n                ? \"opacity-100\"\n                : \"opacity-0 group-hover:opacity-100\",\n            )}\n          />\n        </div>\n      </div>\n      <div\n        data-testid=\"automation-setup-custom-details-drawer\"\n        className=\"rounded-b-[15px] border-x border-b border-[var(--oh-border)] bg-[var(--oh-surface-raised)] px-4 pb-2 pt-[calc(15px+0.5rem)]\"\n      >\n        <button\n          type=\"button\"\n          data-testid=\"automation-setup-custom-details-toggle\"\n          aria-expanded={detailsOpen}\n          aria-controls=\"automation-setup-custom-details\"\n          onClick={() => setDetailsOpen((open) => !open)}\n          className=\"flex w-full items-center gap-2 text-left text-sm\"\n        >\n          <ChevronDown\n            className={cn(\n              \"size-4 shrink-0 text-[var(--oh-muted)] transition-transform duration-200 motion-reduce:transition-none\",\n              detailsOpen && \"rotate-180\",\n            )}\n            aria-hidden\n          />\n          {t(I18nKey.AUTOMATION_SETUP$SHOW_ENTRYPOINT_AND_SETUP)}\n        </button>\n        <CustomDetailsReveal open={detailsOpen} instant={reduceMotion}>\n          <div className=\"flex flex-col gap-4 pt-3\">\n            <div className=\"grid gap-3 md:grid-cols-[2fr_1fr]\">\n              <Field\n                label={t(I18nKey.AUTOMATION_SETUP$ENTRYPOINT)}\n                suffix={updatedSuffixes.entrypoint}\n                isStreaming={streamingField === \"entrypoint\"}\n              >\n                <input\n                  data-testid=\"automation-setup-entrypoint\"\n                  value={entrypoint}\n                  onChange={(event) => onEntrypointChange(event.target.value)}\n                  className={formControlFieldClassName}\n                />\n              </Field>\n              <Field\n                label={t(I18nKey.AUTOMATION_SETUP$SETUP_SCRIPT_PATH)}\n                suffix={updatedSuffixes.setupScriptPath}\n                isStreaming={streamingField === \"setupScriptPath\"}\n              >\n                <input\n                  data-testid=\"automation-setup-setup-script-path\"\n                  value={setupScriptPath}\n                  onChange={(event) =>\n                    onSetupScriptPathChange(event.target.value)\n                  }\n                  className={formControlFieldClassName}\n                />\n              </Field>\n            </div>\n            <Field\n              label={t(I18nKey.AUTOMATION_SETUP$SETUP_SCRIPT)}\n              suffix={updatedSuffixes.setupScript}\n              isStreaming={streamingField === \"setupScript\"}\n            >\n              <textarea\n                data-testid=\"automation-setup-setup-script\"\n                rows={4}\n                value={setupScript}\n                onChange={(event) => onSetupScriptChange(event.target.value)}\n                spellCheck={false}\n                className={cn(\n                  formControlMultilineFieldClassName,\n                  \"font-mono text-xs\",\n                )}\n              />\n            </Field>\n          </div>\n        </CustomDetailsReveal>\n      </div>\n    </div>\n  );\n}\n\nconst CUSTOM_DETAILS_REVEAL_SECONDS = 0.22;\n\nfunction CustomDetailsReveal({\n  open,\n  instant,\n  children,\n}: {\n  open: boolean;\n  instant: boolean | null;\n  children: ReactNode;\n}) {\n  if (instant || import.meta.env.MODE === \"test\") {\n    if (!open) return null;\n    return <div id=\"automation-setup-custom-details\">{children}</div>;\n  }\n\n  return (\n    <AnimatePresence initial={false}>\n      {open ? (\n        <motion.div\n          id=\"automation-setup-custom-details\"\n          initial={{ height: 0, opacity: 0 }}\n          animate={{ height: \"auto\", opacity: 1 }}\n          exit={{ height: 0, opacity: 0 }}\n          transition={{\n            duration: CUSTOM_DETAILS_REVEAL_SECONDS,\n            ease: \"easeInOut\",\n          }}\n          className=\"overflow-hidden\"\n        >\n          {children}\n        </motion.div>\n      ) : null}\n    </AnimatePresence>\n  );\n}\n\nfunction frequencyTabClassName(selected: boolean) {\n  return cn(\n    \"inline-flex h-full shrink-0 items-center rounded-md px-1.5 text-sm\",\n    formControlTransitionClassName,\n    selected\n      ? cn(\n          formControlBorderClassName,\n          formControlSurfaceClassName,\n          \"border-[var(--oh-interactive-hover)] text-content\",\n        )\n      : \"border border-transparent text-[var(--oh-muted)] hover:text-content\",\n  );\n}\n\nconst WEEKDAY_KEYS: I18nKey[] = [\n  I18nKey.AUTOMATIONS$WEEKDAY_SUN,\n  I18nKey.AUTOMATIONS$WEEKDAY_MON,\n  I18nKey.AUTOMATIONS$WEEKDAY_TUE,\n  I18nKey.AUTOMATIONS$WEEKDAY_WED,\n  I18nKey.AUTOMATIONS$WEEKDAY_THU,\n  I18nKey.AUTOMATIONS$WEEKDAY_FRI,\n  I18nKey.AUTOMATIONS$WEEKDAY_SAT,\n];\n\nfunction ScheduleFields({\n  frequency,\n  time,\n  weekday,\n  scheduleDateTime,\n  timezone,\n  customSchedule,\n  updatedSuffixes,\n  streamingField,\n  setFrequency,\n  setTime,\n  setWeekday,\n  setScheduleDateTime,\n  setTimezone,\n  setCustomSchedule,\n}: {\n  frequency: Frequency;\n  time: string;\n  weekday: string;\n  scheduleDateTime: string;\n  timezone: string;\n  customSchedule: string;\n  updatedSuffixes: Partial<\n    Record<\n      \"frequency\" | \"time\" | \"scheduleDateTime\" | \"timezone\" | \"customSchedule\",\n      string | undefined\n    >\n  >;\n  streamingField: AutomationSetupField | null;\n  setFrequency: (value: Frequency) => void;\n  setTime: (value: string) => void;\n  setWeekday: (value: string) => void;\n  setScheduleDateTime: (value: string) => void;\n  setTimezone: (value: string) => void;\n  setCustomSchedule: (value: string) => void;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const atUpdatedSuffix =\n    updatedSuffixes.time ??\n    updatedSuffixes.scheduleDateTime ??\n    updatedSuffixes.timezone;\n  const showTime =\n    frequency === \"daily\" || frequency === \"weekdays\" || frequency === \"weekly\";\n  const timezoneChoices = TIMEZONE_OPTIONS.includes(\n    timezone as (typeof TIMEZONE_OPTIONS)[number],\n  )\n    ? TIMEZONE_OPTIONS\n    : [timezone, ...TIMEZONE_OPTIONS];\n\n  return (\n    <section className=\"flex flex-col gap-2.5\">\n      <div className=\"flex min-w-0 items-center gap-2\">\n        <span className=\"flex shrink-0 items-center gap-2 text-sm\">\n          <span>{t(I18nKey.AUTOMATION_SETUP$FREQUENCY)}</span>\n          {updatedSuffixes.frequency && (\n            <span className=\"text-xs font-normal text-[var(--oh-muted)]\">\n              {updatedSuffixes.frequency}\n            </span>\n          )}\n        </span>\n        <div\n          role=\"radiogroup\"\n          aria-label={t(I18nKey.AUTOMATION_SETUP$FREQUENCY)}\n          className={cn(\n            formControlHeightClassName,\n            formControlRadiusClassName,\n            \"inline-flex max-w-full min-w-0 items-center gap-0.5 overflow-x-auto bg-[var(--oh-surface-raised)] p-0.5\",\n            streamingHighlightClassName(streamingField === \"frequency\"),\n          )}\n        >\n          {FREQUENCIES.map((item) => (\n            <button\n              key={item}\n              type=\"button\"\n              role=\"radio\"\n              data-testid={`automation-setup-frequency-${item}`}\n              aria-checked={frequency === item}\n              onClick={() => setFrequency(item)}\n              className={frequencyTabClassName(frequency === item)}\n            >\n              {t(frequencyLabelKey(item))}\n            </button>\n          ))}\n        </div>\n      </div>\n      <div\n        data-testid=\"automation-setup-at-row\"\n        className=\"flex w-full min-w-0 items-center gap-4 overflow-x-auto\"\n      >\n        {frequency === \"custom\" ? (\n          <label className=\"flex shrink-0 items-center gap-2.5\">\n            <span className=\"shrink-0 text-sm text-content\">\n              {t(I18nKey.AUTOMATION_SETUP$TYPE_CUSTOM)}\n            </span>\n            <input\n              data-testid=\"automation-setup-custom-schedule\"\n              value={customSchedule}\n              onChange={(event) => setCustomSchedule(event.target.value)}\n              className={cn(formControlFieldClassName, \"w-[14rem]\")}\n            />\n          </label>\n        ) : null}\n        {frequency === \"once\" ? (\n          <div className=\"flex shrink-0 items-center gap-2.5\">\n            <span className=\"shrink-0 text-sm text-content\">\n              {t(I18nKey.AUTOMATION_SETUP$AT)}\n            </span>\n            <input\n              aria-label={t(I18nKey.AUTOMATION_SETUP$AT)}\n              data-testid=\"automation-setup-datetime\"\n              type=\"datetime-local\"\n              value={scheduleDateTime || defaultScheduleDateTimeLocal()}\n              onChange={(event) => setScheduleDateTime(event.target.value)}\n              className={cn(formControlFieldClassName, \"w-[15rem]\")}\n            />\n          </div>\n        ) : null}\n        {showTime ? (\n          <div className=\"flex shrink-0 items-center gap-2.5\">\n            <span className=\"shrink-0 text-sm text-content\">\n              {t(I18nKey.AUTOMATION_SETUP$AT)}\n            </span>\n            <input\n              aria-label={t(I18nKey.AUTOMATION_SETUP$AT)}\n              data-testid=\"automation-setup-time\"\n              type=\"time\"\n              value={time}\n              onChange={(event) => setTime(event.target.value)}\n              className={cn(formControlFieldClassName, \"w-[9.5rem]\")}\n            />\n          </div>\n        ) : null}\n        {frequency === \"weekly\" ? (\n          <div className=\"flex shrink-0 items-center gap-2.5\">\n            <span className=\"shrink-0 text-sm text-content\">\n              {t(I18nKey.AUTOMATION_SETUP$ON)}\n            </span>\n            <div\n              className={cn(\n                \"relative w-fit shrink-0\",\n                streamingHighlightClassName(streamingField === \"weekday\"),\n              )}\n            >\n              <select\n                aria-label={t(I18nKey.AUTOMATIONS$WEEKDAY)}\n                data-testid=\"automation-setup-weekday\"\n                value={weekday}\n                onChange={(event) => setWeekday(event.target.value)}\n                className={cn(\n                  formControlFieldClassName,\n                  \"w-auto appearance-none pr-8\",\n                )}\n              >\n                {WEEKDAY_KEYS.map((key, index) => (\n                  <option key={key} value={String(index)}>\n                    {t(key)}\n                  </option>\n                ))}\n              </select>\n              <ChevronDown\n                className=\"pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[var(--oh-muted)]\"\n                aria-hidden\n              />\n            </div>\n          </div>\n        ) : null}\n        <div\n          data-streaming-active={\n            streamingField === \"timezone\" ? \"true\" : undefined\n          }\n          className={cn(\n            \"relative w-[16rem] shrink-0\",\n            streamingHighlightClassName(streamingField === \"timezone\"),\n          )}\n        >\n          <Globe2\n            className=\"pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--oh-muted)]\"\n            aria-hidden\n          />\n          <select\n            aria-label={t(I18nKey.AUTOMATIONS$TIMEZONE)}\n            data-testid=\"automation-setup-timezone\"\n            value={timezone}\n            onChange={(event) => setTimezone(event.target.value)}\n            className={cn(\n              formControlFieldClassName,\n              \"appearance-none pl-9 pr-8\",\n            )}\n          >\n            {timezoneChoices.map((option) => (\n              <option key={option} value={option}>\n                {option}\n              </option>\n            ))}\n          </select>\n          <ChevronDown\n            className=\"pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[var(--oh-muted)]\"\n            aria-hidden\n          />\n        </div>\n        {atUpdatedSuffix && (\n          <span className=\"shrink-0 text-xs font-normal text-[var(--oh-muted)]\">\n            {atUpdatedSuffix}\n          </span>\n        )}\n      </div>\n    </section>\n  );\n}\n\nfunction knownEventSources(options: string[]) {\n  return options.length > 0 ? options : FALLBACK_EVENT_SOURCES;\n}\n\nfunction eventSourceInputValue(value: string, knownSources: string[]) {\n  const match = knownSources.find(\n    (source) => source.toLowerCase() === value.trim().toLowerCase(),\n  );\n  return match ? formatTriggerSourceLabel(match) : value;\n}\n\nfunction commitEventSourceInput(typed: string, knownSources: string[]) {\n  const normalized = typed.trim().toLowerCase();\n  const match = knownSources.find((source) => {\n    return (\n      source.toLowerCase() === normalized ||\n      formatTriggerSourceLabel(source).toLowerCase() === normalized\n    );\n  });\n  return match ?? typed;\n}\n\nfunction EventSourceControl({\n  value,\n  options,\n  onChange,\n  onSelectCustom,\n}: {\n  value: string;\n  options: string[];\n  onChange: (value: string) => void;\n  onSelectCustom: () => void;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const knownSources = knownEventSources(options);\n  const [isMenuOpen, setIsMenuOpen] = useState(false);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const menuRef = useClickOutsideElement<HTMLDivElement>(\n    () => setIsMenuOpen(false),\n    triggerRef,\n  );\n\n  return (\n    <div className=\"relative\">\n      <input\n        ref={inputRef}\n        data-testid=\"automation-setup-event-source\"\n        value={eventSourceInputValue(value, knownSources)}\n        placeholder={t(\n          I18nKey.AUTOMATION_SETUP$EVENT_SOURCE_CUSTOM_PLACEHOLDER,\n        )}\n        onChange={(event) =>\n          onChange(commitEventSourceInput(event.target.value, knownSources))\n        }\n        className={cn(formControlFieldClassName, \"pr-9\")}\n      />\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        data-testid=\"automation-setup-event-source-toggle\"\n        aria-label={t(I18nKey.AUTOMATION_SETUP$EVENT_SOURCE)}\n        aria-haspopup=\"menu\"\n        aria-expanded={isMenuOpen}\n        className=\"absolute top-1/2 right-2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md text-[var(--oh-muted)] hover:bg-white/10 hover:text-white\"\n        onMouseDown={(event) => event.preventDefault()}\n        onClick={() => setIsMenuOpen((open) => !open)}\n      >\n        <ChevronDown className=\"size-4 shrink-0\" aria-hidden />\n      </button>\n      {isMenuOpen ? (\n        <div\n          ref={menuRef}\n          role=\"menu\"\n          data-testid=\"automation-setup-event-source-menu\"\n          className=\"absolute top-full left-0 z-[60] mt-1 flex max-h-72 w-full flex-col overflow-hidden rounded-md border border-border-subtle bg-tertiary py-1 shadow-lg\"\n        >\n          <div className=\"min-h-0 flex-1 overflow-y-auto px-1\">\n            {knownSources.map((source) => (\n              <ContextMenuListItem\n                key={source}\n                testId={`automation-setup-event-source-option-${source}`}\n                onClick={(event) => {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  onChange(source);\n                  setIsMenuOpen(false);\n                }}\n                className=\"flex w-full items-center\"\n              >\n                <span className=\"truncate\">\n                  {formatTriggerSourceLabel(source)}\n                </span>\n              </ContextMenuListItem>\n            ))}\n          </div>\n          <Divider inset=\"menu\" />\n          <div className=\"shrink-0 px-1\">\n            <ContextMenuListItem\n              testId=\"automation-setup-event-source-custom\"\n              onClick={(event) => {\n                event.preventDefault();\n                event.stopPropagation();\n                onSelectCustom();\n                setIsMenuOpen(false);\n                queueMicrotask(() => inputRef.current?.focus());\n              }}\n              className=\"flex w-full items-center\"\n            >\n              <span className=\"truncate\">\n                {t(I18nKey.AUTOMATION_SETUP$TYPE_CUSTOM)}\n              </span>\n            </ContextMenuListItem>\n          </div>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nfunction EventFields({\n  eventSource,\n  eventKey,\n  eventFilter,\n  eventTestPayload,\n  eventSourceOptions,\n  eventTypeOptions,\n  customWebhook,\n  customWebhookRegistration,\n  usesExistingAutomationTestPayload,\n  updatedSuffixes,\n  streamingField,\n  setEventSource,\n  setEventKey,\n  setEventFilter,\n  setEventTestPayload,\n  setCustomWebhookField,\n}: {\n  eventSource: string;\n  eventKey: string;\n  eventFilter: string;\n  eventTestPayload: string;\n  eventSourceOptions: string[];\n  eventTypeOptions: string[];\n  customWebhook: CustomWebhookFormState;\n  customWebhookRegistration: CustomWebhookCreateResponse | null;\n  usesExistingAutomationTestPayload: boolean;\n  updatedSuffixes: Partial<\n    Record<\"eventSource\" | \"eventKey\" | \"eventFilter\", string | undefined>\n  >;\n  streamingField: AutomationSetupField | null;\n  setEventSource: (value: string) => void;\n  setEventKey: (value: string) => void;\n  setEventFilter: (value: string) => void;\n  setEventTestPayload: (value: string) => void;\n  setCustomWebhookField: <FieldName extends keyof CustomWebhookFormState>(\n    field: FieldName,\n    value: CustomWebhookFormState[FieldName],\n  ) => void;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const { send } = useSendMessage();\n  const [isEventFilterOpen, setIsEventFilterOpen] = useState(\n    () => eventFilter.trim().length > 0,\n  );\n  const [isTestPayloadOpen, setIsTestPayloadOpen] = useState(false);\n  const showEventFilter =\n    isEventFilterOpen ||\n    eventFilter.trim().length > 0 ||\n    streamingField === \"eventFilter\";\n  const knownSources = knownEventSources(eventSourceOptions);\n  const sourceIsKnown = (source: string) =>\n    knownSources.some(\n      (option) => option.toLowerCase() === source.trim().toLowerCase(),\n    );\n  const handleEventSourceChange = (value: string) => {\n    const leavingKnownSource =\n      sourceIsKnown(eventSource) && !sourceIsKnown(value);\n    setEventSource(value);\n    if (leavingKnownSource) setCustomWebhookField(\"enabled\", true);\n    if (sourceIsKnown(value)) setCustomWebhookField(\"enabled\", false);\n  };\n  const selectCustomEventSource = () => {\n    setEventSource(\"\");\n    setCustomWebhookField(\"enabled\", true);\n  };\n  return (\n    <div className=\"@container min-w-0\">\n      <section className=\"grid gap-3 @min-[640px]:grid-cols-2\">\n        <div className=\"flex min-w-0 flex-col gap-1.5\">\n          <Field\n            label={t(I18nKey.AUTOMATION_SETUP$EVENT_SOURCE)}\n            suffix={updatedSuffixes.eventSource}\n            isStreaming={streamingField === \"eventSource\"}\n          >\n            <EventSourceControl\n              value={eventSource}\n              options={eventSourceOptions}\n              onChange={handleEventSourceChange}\n              onSelectCustom={selectCustomEventSource}\n            />\n          </Field>\n          <p\n            role=\"note\"\n            data-testid=\"automation-setup-event-public-url-notice\"\n            className=\"flex items-start gap-1.5 text-xs leading-4 text-[var(--oh-muted)]\"\n          >\n            <Info className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden />\n            {t(I18nKey.AUTOMATION_SETUP$EVENT_PUBLIC_URL_NOTICE)}\n          </p>\n        </div>\n        <div className=\"flex min-w-0 flex-wrap items-end gap-2 self-start\">\n          <div\n            className={cn(\n              \"flex min-w-48 flex-1 flex-col gap-2\",\n              streamingHighlightClassName(streamingField === \"eventKey\"),\n            )}\n          >\n            <label\n              htmlFor=\"automation-setup-event-key\"\n              className=\"flex shrink-0 items-center gap-2 text-sm\"\n            >\n              {t(I18nKey.AUTOMATION_SETUP$EVENT_KEY)}\n              {updatedSuffixes.eventKey ? (\n                <span className=\"font-normal text-[var(--oh-muted)]\">\n                  {updatedSuffixes.eventKey}\n                </span>\n              ) : null}\n            </label>\n            <div\n              className={cn(\n                \"flex w-full min-w-0 overflow-hidden\",\n                formControlHeightClassName,\n                formControlRadiusClassName,\n                formControlBorderClassName,\n                formControlSurfaceClassName,\n                formControlTransitionClassName,\n                formControlFocusWithinClassName,\n                streamingHighlightClassName(streamingField === \"eventFilter\"),\n              )}\n            >\n              <input\n                id=\"automation-setup-event-key\"\n                data-testid=\"automation-setup-event-key\"\n                value={eventKey}\n                list=\"automation-setup-event-key-options\"\n                onChange={(event) => setEventKey(event.target.value)}\n                className=\"min-w-0 flex-1 border-0 bg-transparent px-3 text-sm text-contrast outline-none\"\n              />\n              <datalist id=\"automation-setup-event-key-options\">\n                {eventTypeOptions.map((eventType) => (\n                  <option key={eventType} value={eventType} />\n                ))}\n              </datalist>\n              {showEventFilter ? null : (\n                <button\n                  type=\"button\"\n                  data-testid=\"automation-setup-add-event-filter\"\n                  onClick={() => setIsEventFilterOpen(true)}\n                  className=\"inline-flex shrink-0 items-center border-l border-[var(--oh-border)] bg-tertiary px-3 text-sm text-content hover:bg-interactive-hover\"\n                >\n                  {t(I18nKey.AUTOMATION_SETUP$ADD_FILTER)}\n                </button>\n              )}\n            </div>\n          </div>\n          <button\n            type=\"button\"\n            data-testid=\"automation-setup-ask-agent\"\n            className={cn(addOptionButtonClassName, \"h-9 gap-1.5\")}\n            onClick={() => {\n              requestAutomationSetupAgent();\n              void send({\n                action: \"message\",\n                args: {\n                  content: t(I18nKey.AUTOMATION_SETUP$ASK_AGENT_EVENT_PROMPT),\n                },\n              });\n            }}\n          >\n            <SparkleIcon className=\"size-3.5 shrink-0\" aria-hidden />\n            {t(I18nKey.AUTOMATION_SETUP$ASK_AGENT)}\n          </button>\n        </div>\n        {showEventFilter ? (\n          <div className=\"@min-[640px]:col-span-2\">\n            <div className=\"relative\">\n              <button\n                type=\"button\"\n                data-testid=\"automation-setup-event-filter-remove\"\n                aria-label={`${t(I18nKey.COMMON$REMOVE)} ${t(I18nKey.AUTOMATION_SETUP$EVENT_FILTER)}`}\n                className=\"absolute right-0 top-0 inline-flex size-6 items-center justify-center rounded-md text-[var(--oh-muted)] hover:bg-white/10 hover:text-white\"\n                onClick={() => {\n                  setIsEventFilterOpen(false);\n                  setEventFilter(\"\");\n                }}\n              >\n                <X className=\"size-4\" aria-hidden />\n              </button>\n              <Field\n                label={t(I18nKey.AUTOMATION_SETUP$EVENT_FILTER)}\n                suffix={\n                  updatedSuffixes.eventFilter ?? t(I18nKey.COMMON$OPTIONAL)\n                }\n                isStreaming={streamingField === \"eventFilter\"}\n              >\n                <input\n                  data-testid=\"automation-setup-event-filter\"\n                  value={eventFilter}\n                  onChange={(event) => setEventFilter(event.target.value)}\n                  className={formControlFieldClassName}\n                />\n              </Field>\n            </div>\n          </div>\n        ) : null}\n        {eventSource.trim() !== \"\" &&\n        !(\n          eventSourceOptions.length > 0\n            ? eventSourceOptions\n            : FALLBACK_EVENT_SOURCES\n        ).includes(eventSource.trim()) ? (\n          <div className=\"@min-[640px]:col-span-2\">\n            <section className=\"rounded-xl border border-[var(--oh-border)] p-3\">\n              <div className=\"flex items-start gap-3 text-sm\">\n                <input\n                  id=\"automation-setup-custom-webhook-enabled\"\n                  type=\"checkbox\"\n                  data-testid=\"automation-setup-custom-webhook-enabled\"\n                  checked={customWebhook.enabled}\n                  onChange={(event) =>\n                    setCustomWebhookField(\"enabled\", event.target.checked)\n                  }\n                  className=\"mt-1\"\n                />\n                <label\n                  htmlFor=\"automation-setup-custom-webhook-enabled\"\n                  className=\"flex flex-col gap-1\"\n                >\n                  <span>\n                    {t(I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_TITLE)}\n                  </span>\n                  <span className=\"text-xs font-normal leading-5 text-[var(--oh-muted)]\">\n                    {t(I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_DESCRIPTION)}\n                  </span>\n                </label>\n              </div>\n              {customWebhook.enabled && (\n                <div className=\"mt-3 grid gap-3 md:grid-cols-2\">\n                  <Field\n                    label={t(I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_NAME)}\n                  >\n                    <input\n                      data-testid=\"automation-setup-custom-webhook-name\"\n                      value={customWebhook.name}\n                      onChange={(event) =>\n                        setCustomWebhookField(\"name\", event.target.value)\n                      }\n                      className={formControlFieldClassName}\n                      placeholder={t(\n                        I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_NAME_PLACEHOLDER,\n                      )}\n                    />\n                  </Field>\n                  <Field\n                    label={t(\n                      I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_EVENT_KEY_EXPR,\n                    )}\n                  >\n                    <input\n                      data-testid=\"automation-setup-custom-webhook-event-key-expr\"\n                      value={customWebhook.eventKeyExpr}\n                      onChange={(event) =>\n                        setCustomWebhookField(\n                          \"eventKeyExpr\",\n                          event.target.value,\n                        )\n                      }\n                      className={formControlFieldClassName}\n                    />\n                  </Field>\n                  <Field\n                    label={t(\n                      I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_SIGNATURE_HEADER,\n                    )}\n                  >\n                    <input\n                      data-testid=\"automation-setup-custom-webhook-signature-header\"\n                      value={customWebhook.signatureHeader}\n                      onChange={(event) =>\n                        setCustomWebhookField(\n                          \"signatureHeader\",\n                          event.target.value,\n                        )\n                      }\n                      className={formControlFieldClassName}\n                    />\n                  </Field>\n                  <Field\n                    label={t(\n                      I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_SIGNATURE_SCHEME,\n                    )}\n                  >\n                    <select\n                      data-testid=\"automation-setup-custom-webhook-signature-scheme\"\n                      value={customWebhook.signatureScheme}\n                      onChange={(event) =>\n                        setCustomWebhookField(\n                          \"signatureScheme\",\n                          event.target.value as CustomWebhookSignatureScheme,\n                        )\n                      }\n                      className={formControlFieldClassName}\n                    >\n                      <option value=\"hmac_sha256_hex\">\n                        {t(\n                          I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_SIGNATURE_SCHEME_HMAC,\n                        )}\n                      </option>\n                      <option value=\"standard_webhooks\">\n                        {t(\n                          I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_SIGNATURE_SCHEME_STANDARD,\n                        )}\n                      </option>\n                      <option value=\"slack_v0\">\n                        {t(\n                          I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_SIGNATURE_SCHEME_SLACK,\n                        )}\n                      </option>\n                    </select>\n                  </Field>\n                  <div className=\"md:col-span-2\">\n                    <Field\n                      label={t(I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_SECRET)}\n                      suffix={t(I18nKey.COMMON$OPTIONAL)}\n                    >\n                      <input\n                        data-testid=\"automation-setup-custom-webhook-secret\"\n                        type=\"password\"\n                        value={customWebhook.webhookSecret}\n                        onChange={(event) =>\n                          setCustomWebhookField(\n                            \"webhookSecret\",\n                            event.target.value,\n                          )\n                        }\n                        className={formControlFieldClassName}\n                        placeholder={t(\n                          I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_SECRET_PLACEHOLDER,\n                        )}\n                      />\n                    </Field>\n                    <p className=\"mt-2 text-xs leading-5 text-[var(--oh-muted)]\">\n                      {t(I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_SECRET_HELP)}\n                    </p>\n                  </div>\n                  {customWebhookRegistration && (\n                    <div\n                      data-testid=\"automation-setup-custom-webhook-result\"\n                      className=\"md:col-span-2 rounded-lg border border-[var(--oh-success)]/40 bg-[var(--oh-success)]/10 p-3 text-xs leading-5 text-white\"\n                    >\n                      <p className=\"font-medium\">\n                        {t(I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_CREATED)}\n                      </p>\n                      <p className=\"break-all\">\n                        {t(I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_URL, {\n                          url: customWebhookRegistration.webhook_url,\n                        })}\n                      </p>\n                      {customWebhookRegistration.webhook_secret && (\n                        <p className=\"break-all\">\n                          {t(\n                            I18nKey.AUTOMATION_SETUP$CUSTOM_WEBHOOK_GENERATED_SECRET,\n                            {\n                              secret: customWebhookRegistration.webhook_secret,\n                            },\n                          )}\n                        </p>\n                      )}\n                    </div>\n                  )}\n                </div>\n              )}\n            </section>\n          </div>\n        ) : null}\n        <div className=\"@min-[640px]:col-span-2\">\n          {usesExistingAutomationTestPayload ? (\n            <p\n              role=\"note\"\n              data-testid=\"automation-setup-existing-test-payload-note\"\n              className=\"flex items-start gap-1.5 rounded-lg border border-[var(--oh-border)] bg-[var(--oh-surface)] p-3 text-xs leading-5 text-[var(--oh-muted)]\"\n            >\n              <Info className=\"mt-0.5 size-3.5 shrink-0\" aria-hidden />\n              {t(I18nKey.AUTOMATION_SETUP$EXISTING_TEST_PAYLOAD_NOTICE)}\n            </p>\n          ) : (\n            <>\n              <button\n                type=\"button\"\n                data-testid=\"automation-setup-event-test-payload-toggle\"\n                aria-expanded={isTestPayloadOpen}\n                aria-controls=\"automation-setup-event-test-payload-panel\"\n                onClick={() => setIsTestPayloadOpen((open) => !open)}\n                className=\"flex w-full items-center gap-2 text-left text-sm\"\n              >\n                <ChevronDown\n                  className={cn(\n                    \"size-4 shrink-0 text-[var(--oh-muted)] transition-transform duration-200 motion-reduce:transition-none\",\n                    isTestPayloadOpen && \"rotate-180\",\n                  )}\n                  aria-hidden\n                />\n                {t(I18nKey.AUTOMATION_SETUP$TEST_EVENT_PAYLOAD)}\n              </button>\n              {isTestPayloadOpen ? (\n                <div\n                  id=\"automation-setup-event-test-payload-panel\"\n                  className=\"flex flex-col gap-2 pt-2\"\n                >\n                  <textarea\n                    data-testid=\"automation-setup-event-test-payload\"\n                    value={eventTestPayload}\n                    onChange={(event) =>\n                      setEventTestPayload(event.target.value)\n                    }\n                    className={cn(\n                      formControlMultilineFieldClassName,\n                      \"min-h-44 font-mono\",\n                    )}\n                    placeholder={t(\n                      I18nKey.AUTOMATION_SETUP$TEST_EVENT_PAYLOAD_PLACEHOLDER,\n                    )}\n                  />\n                  <p className=\"text-xs leading-5 text-[var(--oh-muted)]\">\n                    {t(I18nKey.AUTOMATION_SETUP$TEST_EVENT_PAYLOAD_DESCRIPTION)}\n                  </p>\n                </div>\n              ) : null}\n            </>\n          )}\n        </div>\n      </section>\n    </div>\n  );\n}\n\nfunction Field({\n  label,\n  showLabel = true,\n  labelClassName,\n  suffix,\n  horizontal = false,\n  isStreaming = false,\n  errorText,\n  children,\n}: {\n  label: string;\n  showLabel?: boolean;\n  labelClassName?: string;\n  suffix?: string;\n  horizontal?: boolean;\n  isStreaming?: boolean;\n  errorText?: string;\n  children: ReactNode;\n}) {\n  return (\n    <label\n      data-streaming-active={isStreaming ? \"true\" : undefined}\n      className={cn(\n        \"flex gap-2\",\n        horizontal ? \"items-center\" : \"flex-col\",\n        streamingHighlightClassName(isStreaming),\n      )}\n    >\n      {showLabel ? (\n        <span\n          className={cn(\n            \"flex shrink-0 items-center gap-2 text-sm\",\n            labelClassName,\n          )}\n        >\n          <span>{label}</span>\n          {suffix && (\n            <span className=\"font-normal text-[var(--oh-muted)]\">{suffix}</span>\n          )}\n        </span>\n      ) : null}\n      <div className=\"min-w-0 flex-1\">{children}</div>\n      {errorText ? (\n        <span\n          role=\"alert\"\n          className=\"text-xs leading-5 text-[var(--oh-warning)]\"\n        >\n          {errorText}\n        </span>\n      ) : null}\n    </label>\n  );\n}\n\nfunction TriggerCard({\n  icon,\n  title,\n  description,\n  selected,\n  onClick,\n}: {\n  icon: ReactNode;\n  title: string;\n  description: string;\n  selected: boolean;\n  onClick: () => void;\n}) {\n  return (\n    <button\n      type=\"button\"\n      role=\"radio\"\n      aria-checked={selected}\n      onClick={onClick}\n      className={cn(\n        \"rounded-lg border px-3 py-2.5 text-left\",\n        formControlTransitionClassName,\n        selected\n          ? \"border-[var(--oh-interactive-hover)] bg-surface-raised text-content\"\n          : \"border-[var(--oh-border)] bg-transparent text-content hover:border-[var(--oh-interactive-hover)] hover:bg-surface-raised\",\n      )}\n    >\n      <span className=\"flex items-start gap-2\">\n        <span className=\"mt-0.5 text-muted\">{icon}</span>\n        <span className=\"min-w-0\">\n          <span className=\"block text-sm font-medium\">{title}</span>\n          <span className=\"mt-1 block text-xs text-[var(--oh-text-tertiary)]\">\n            {description}\n          </span>\n        </span>\n      </span>\n    </button>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/automations/setup/automation-setup-prompt-stack.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[200px]\" hardcodes an off-token value. Use \"max-w-50\" instead (same value, on the scale).",
          "line": 92,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 92,
          "endColumn": 39,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "max-w-50"
              },
              "fix": {
                "range": [
                  3839,
                  3865
                ],
                "text": "\"max-w-50 rounded-lg\""
              },
              "desc": "Replace with \"max-w-50\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[60]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 119,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 119,
          "endColumn": 91
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-w-[200px]\" hardcodes an off-token value. Use \"min-w-50\" instead (same value, on the scale).",
          "line": 119,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 119,
          "endColumn": 91,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "min-w-50"
              },
              "fix": {
                "range": [
                  4737,
                  4807
                ],
                "text": "\"z-[60] mb-2 min-w-50 max-w-[320px] max-h-[60vh] overflow-y-auto\""
              },
              "desc": "Replace with \"min-w-50\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[320px]\" hardcodes an off-token value. Use \"max-w-80\" instead (same value, on the scale).",
          "line": 119,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 119,
          "endColumn": 91,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "max-w-80"
              },
              "fix": {
                "range": [
                  4737,
                  4807
                ],
                "text": "\"z-[60] mb-2 min-w-[200px] max-w-80 max-h-[60vh] overflow-y-auto\""
              },
              "desc": "Replace with \"max-w-80\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[60vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 119,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 119,
          "endColumn": 91
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-interactive-hover)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 131,
          "column": 37,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 131,
          "endColumn": 71
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-interactive-hover)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 154,
          "column": 33,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 154,
          "endColumn": 67
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 164,
          "column": 35,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 164,
          "endColumn": 86
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[520px]\" hardcodes an off-token value. Use \"w-130\" instead (same value, on the scale).",
          "line": 205,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 205,
          "endColumn": 141,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "w-130"
              },
              "fix": {
                "range": [
                  7645,
                  7767
                ],
                "text": "\"relative flex w-130 max-w-[90vw] max-h-[85vh] flex-col rounded-xl border border-[var(--oh-border)] bg-base-secondary\""
              },
              "desc": "Replace with \"w-130\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[90vw]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 205,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 205,
          "endColumn": 141
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[85vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 205,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 205,
          "endColumn": 141
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 205,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 205,
          "endColumn": 141
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 401,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 401,
          "endColumn": 67
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"-mb-[15px]\" hardcodes an off-token value. Use \"-mb-3.75\" instead (same value, on the scale).",
          "line": 421,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 421,
          "endColumn": 138,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "-mb-3.75"
              },
              "fix": {
                "range": [
                  15693,
                  15808
                ],
                "text": "\"relative z-10 -mb-3.75 flex flex-col rounded-[15px] border border-[var(--oh-border)] bg-[var(--oh-surface)] p-4\""
              },
              "desc": "Replace with \"-mb-3.75\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[15px]\" hardcodes an off-token value. Nearest on the scale: rounded-2xl (16px), rounded-xl (12px).",
          "line": 421,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 421,
          "endColumn": 138
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 421,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 421,
          "endColumn": 138
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-surface)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 421,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 421,
          "endColumn": 138
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-h-[120px]\" hardcodes an off-token value. Use \"min-h-30\" instead (same value, on the scale).",
          "line": 431,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 431,
          "endColumn": 151,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "min-h-30"
              },
              "fix": {
                "range": [
                  16174,
                  16300
                ],
                "text": "\"min-h-30 w-full resize-none border-0 bg-transparent p-0 text-sm text-content outline-none placeholder:text-tertiary-alt\""
              },
              "desc": "Replace with \"min-h-30\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[1]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 441,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 441,
          "endColumn": 80
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[2]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 449,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 449,
          "endColumn": 124
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-b-[15px]\" hardcodes an off-token value. Nearest on the scale: rounded-b-2xl (16px), rounded-b-xl (12px).",
          "line": 459,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 459,
          "endColumn": 174
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 459,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 459,
          "endColumn": 174
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-surface-raised)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 459,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 459,
          "endColumn": 174
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"pt-[calc(15px+0.5rem)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 459,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 459,
          "endColumn": 174
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 470,
          "column": 35,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 470,
          "endColumn": 67
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 489,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 489,
          "endColumn": 157
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 512,
          "column": 33,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 512,
          "endColumn": 126
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[60]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 524,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 524,
          "endColumn": 206
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[min(28rem,calc(100vw-3rem))]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 524,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 524,
          "endColumn": 206
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-warning)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 596,
          "column": 37,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 596,
          "endColumn": 81
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-warning)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 708,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 708,
          "endColumn": 67
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 31,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useRef, useState, type ReactNode } from \"react\";\nimport { Info, Plus, X } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { getActiveBackend } from \"#/api/backend-registry/active-store\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { SettingsInput } from \"#/components/features/settings/settings-input\";\nimport { LoadingSpinner } from \"#/components/shared/loading-spinner\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { ContextMenuListItem } from \"#/components/features/context-menu/context-menu-list-item\";\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\nimport { useGitRepositories } from \"#/hooks/query/use-git-repositories\";\nimport { useSearchRepositories } from \"#/hooks/query/use-search-repositories\";\nimport { useLlmProfiles } from \"#/hooks/query/use-llm-profiles\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { useDebounce } from \"#/hooks/use-debounce\";\nimport { useUserProviders } from \"#/hooks/use-user-providers\";\nimport { useDeploymentCapabilities } from \"#/hooks/query/use-manifest-capabilities\";\nimport { useSettings } from \"#/hooks/query/use-settings\";\nimport { usePromptTextareaResize } from \"#/hooks/use-prompt-textarea-resize\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport CheckIcon from \"#/icons/checkmark.svg?react\";\nimport SearchIcon from \"#/icons/search.svg?react\";\nimport { ComboboxCaretInline } from \"#/ui/combobox-caret\";\nimport { Divider } from \"#/ui/divider\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { extensionModuleCardPillClassName } from \"#/utils/extension-module-card-classes\";\nimport { chatInputPillButtonClassName } from \"#/utils/form-control-classes\";\nimport { modalTitleLgClassName } from \"#/utils/modal-classes\";\nimport { cn, getProviderName } from \"#/utils/utils\";\nimport { formatModelNameForDisplay } from \"#/utils/format-model-name\";\nimport {\n  automationCloneProviders,\n  automationRepositoryAddress,\n  providerHostKnown,\n} from \"#/utils/automation-repo-sources\";\nimport type { Provider } from \"#/types/settings\";\n\nconst PROFILE_LABEL_MAX_CHARS = 18;\nconst REPOSITORY_SEARCH_DEBOUNCE_MS = 300;\n\nfunction truncateLabel(label: string): string {\n  return label.length <= PROFILE_LABEL_MAX_CHARS\n    ? label\n    : `${label.slice(0, PROFILE_LABEL_MAX_CHARS)}\u2026`;\n}\n\nfunction parseRepositories(value: string): string[] {\n  const seen = new Set<string>();\n  return value\n    .split(/[\\n,]+/)\n    .map((entry) => entry.trim())\n    .filter((entry) => {\n      if (!entry || seen.has(entry)) return false;\n      seen.add(entry);\n      return true;\n    });\n}\n\nfunction SetupModelPill({\n  value,\n  onChange,\n}: {\n  value: string;\n  onChange: (profileName: string) => void;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const { data, isLoading } = useLlmProfiles();\n  const profiles = data?.profiles ?? [];\n  const activeProfileName = data?.active_profile ?? null;\n  const [isOpen, setIsOpen] = useState(false);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const popoverRef = useClickOutsideElement<HTMLUListElement>(\n    () => setIsOpen(false),\n    triggerRef,\n  );\n  const pinnedProfileName = value.trim() || null;\n  const displayName = pinnedProfileName ?? activeProfileName;\n  const pillLabel =\n    displayName ?? t(I18nKey.AUTOMATION_SETUP$MODEL_PLACEHOLDER);\n  const canOpen = !isLoading && profiles.length > 0;\n  const tooltip = t(I18nKey.LLM$MODEL);\n\n  return (\n    <div className=\"relative min-w-0\">\n      <StyledTooltip content={tooltip} placement=\"top\">\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          className={cn(\n            chatInputPillButtonClassName,\n            \"max-w-[200px] rounded-lg\",\n          )}\n          data-testid=\"automation-setup-model\"\n          aria-label={displayName ?? pillLabel}\n          aria-expanded={isOpen}\n          aria-haspopup=\"dialog\"\n          onMouseDown={(event) => event.preventDefault()}\n          onClick={(event) => {\n            event.preventDefault();\n            event.stopPropagation();\n            if (!canOpen) return;\n            setIsOpen((open) => !open);\n          }}\n        >\n          <span className=\"truncate\">\n            {displayName ? truncateLabel(pillLabel) : pillLabel}\n          </span>\n          <ComboboxCaretInline isOpen={isOpen} />\n        </button>\n      </StyledTooltip>\n      {isOpen && canOpen ? (\n        <ContextMenu\n          ref={popoverRef}\n          testId=\"automation-setup-model-popover\"\n          position=\"top\"\n          alignment=\"left\"\n          spacing=\"none\"\n          className=\"z-[60] mb-2 min-w-[200px] max-w-[320px] max-h-[60vh] overflow-y-auto\"\n        >\n          <ContextMenuListItem\n            testId=\"automation-setup-model-option-active\"\n            onClick={(event) => {\n              event.preventDefault();\n              event.stopPropagation();\n              onChange(\"\");\n              setIsOpen(false);\n            }}\n            className={cn(\n              \"flex items-center justify-between gap-2\",\n              !pinnedProfileName && \"bg-[var(--oh-interactive-hover)]\",\n            )}\n          >\n            <span className=\"truncate\">{t(I18nKey.COMMON$ACTIVE_PROFILE)}</span>\n            {!pinnedProfileName ? (\n              <CheckIcon className=\"size-4 shrink-0\" aria-hidden />\n            ) : null}\n          </ContextMenuListItem>\n          {profiles.map((item) => {\n            const isSelected = item.name === pinnedProfileName;\n            const displayModel = formatModelNameForDisplay(item.model);\n            return (\n              <ContextMenuListItem\n                key={item.name}\n                testId={`automation-setup-model-option-${item.name}`}\n                onClick={(event) => {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  onChange(item.name);\n                  setIsOpen(false);\n                }}\n                className={cn(\n                  \"flex flex-col items-stretch gap-0.5\",\n                  isSelected && \"bg-[var(--oh-interactive-hover)]\",\n                )}\n              >\n                <div className=\"flex min-w-0 items-center justify-between gap-2\">\n                  <span className=\"truncate\">{item.name}</span>\n                  {isSelected ? (\n                    <CheckIcon className=\"size-4 shrink-0\" aria-hidden />\n                  ) : null}\n                </div>\n                {displayModel ? (\n                  <span className=\"truncate text-xs leading-4 text-[var(--oh-muted)]\">\n                    {displayModel}\n                  </span>\n                ) : null}\n              </ContextMenuListItem>\n            );\n          })}\n        </ContextMenu>\n      ) : null}\n    </div>\n  );\n}\n\nfunction AddRepositoryModal({\n  isOpen,\n  onClose,\n  onAdd,\n}: {\n  isOpen: boolean;\n  onClose: () => void;\n  onAdd: (address: string) => void;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const [address, setAddress] = useState(\"\");\n  const title = `${t(I18nKey.BUTTON$ADD)} ${t(I18nKey.AUTOMATIONS$DETAIL$REPOSITORIES)}`;\n\n  if (!isOpen) return null;\n\n  const trimmedAddress = address.trim();\n\n  return (\n    <ModalBackdrop onClose={onClose} aria-label={title}>\n      <form\n        onSubmit={(event) => {\n          event.preventDefault();\n          if (!trimmedAddress) return;\n          onAdd(trimmedAddress);\n          setAddress(\"\");\n          onClose();\n        }}\n        data-testid=\"automation-setup-add-repository-modal\"\n        className=\"relative flex w-[520px] max-w-[90vw] max-h-[85vh] flex-col rounded-xl border border-[var(--oh-border)] bg-base-secondary\"\n      >\n        <ModalCloseButton\n          onClose={onClose}\n          testId=\"automation-setup-add-repository-modal-close\"\n        />\n        <header className=\"flex-shrink-0 px-6 pb-4 pt-6\">\n          <h2 className={cn(\"pr-6\", modalTitleLgClassName)}>{title}</h2>\n        </header>\n        <div className=\"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 custom-scrollbar\">\n          <SettingsInput\n            testId=\"automation-setup-repository-address\"\n            label={t(I18nKey.CONVERSATION$REPOSITORY)}\n            type=\"text\"\n            value={address}\n            onChange={setAddress}\n            placeholder={t(I18nKey.SETUP$REPOSITORY_PLACEHOLDER)}\n            showRequiredTag\n          />\n        </div>\n        <footer className=\"flex flex-shrink-0 justify-end gap-2 px-6 pb-6 pt-4\">\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={onClose}\n            testId=\"automation-setup-add-repository-modal-dismiss\"\n          >\n            {t(I18nKey.BUTTON$CLOSE)}\n          </BrandButton>\n          <BrandButton\n            type=\"submit\"\n            variant=\"primary\"\n            testId=\"automation-setup-repository-submit\"\n            isDisabled={!trimmedAddress}\n          >\n            {t(I18nKey.BUTTON$ADD)}\n          </BrandButton>\n        </footer>\n      </form>\n    </ModalBackdrop>\n  );\n}\n\nexport function AutomationSetupPromptStack({\n  prompt,\n  repository,\n  updatedSuffix,\n  repositorySuffix,\n  isStreaming,\n  errorText,\n  titleAction,\n  showTitle = true,\n  model,\n  repositoryReadOnly = false,\n  onPromptChange,\n  onRepositoryChange,\n  onModelChange,\n}: {\n  prompt: string;\n  repository: string;\n  updatedSuffix?: string;\n  repositorySuffix?: string;\n  isStreaming: boolean;\n  errorText?: string;\n  /** Sits on the Prompt title row, outside the field label. */\n  titleAction?: ReactNode;\n  /** The setup form owns the title when the kind toggle stays outside the crossfade. */\n  showTitle?: boolean;\n  model: string;\n  /** Show the repositories without letting them be added or removed. */\n  repositoryReadOnly?: boolean;\n  onPromptChange: (value: string) => void;\n  onRepositoryChange: (value: string) => void;\n  onModelChange: (profileName: string) => void;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const { gripRef, isGripDragging, handleGripMouseDown, handleGripTouchStart } =\n    usePromptTextareaResize(textareaRef, { contentKey: prompt });\n  const [isRepositoryModalOpen, setIsRepositoryModalOpen] = useState(false);\n  const [isRepositoryMenuOpen, setIsRepositoryMenuOpen] = useState(false);\n  const [repositorySearch, setRepositorySearch] = useState(\"\");\n  const repositoryAddRef = useRef<HTMLButtonElement>(null);\n  const repositoryMenuRef = useClickOutsideElement<HTMLDivElement>(\n    () => setIsRepositoryMenuOpen(false),\n    repositoryAddRef,\n  );\n  const isLocalBackend = getActiveBackend().backend.kind === \"local\";\n  const { providers } = useUserProviders();\n  const { data: settings } = useSettings();\n  const providerHosts = settings?.provider_tokens_set ?? {};\n  const deploymentCapabilities = useDeploymentCapabilities();\n  // The service says whose repositories its runs clone; older services predate\n  // the field and clone from the three hosted providers.\n  const cloneProviders = automationCloneProviders(\n    deploymentCapabilities.data?.repoProviders,\n  );\n  // Whose repositories the picker can offer: a connected provider the runs\n  // clone from, whose host is known (a GitLab connection needs the server to\n  // report its host, or the form could not tell gitlab.com from an instance).\n  const canListProvider = (provider: Provider) =>\n    cloneProviders.includes(provider) &&\n    providerHostKnown(provider, providerHosts[provider]);\n  const connectedCloneProviders = providers.filter(canListProvider);\n  const [selectedProvider, setSelectedProvider] = useState<Provider | null>(\n    null,\n  );\n  const defaultProvider: Provider | null = connectedCloneProviders.includes(\n    \"github\",\n  )\n    ? \"github\"\n    : (connectedCloneProviders[0] ??\n      providers[0] ??\n      (isLocalBackend ? \"github\" : null));\n  const repositoryProvider: Provider | null =\n    selectedProvider && connectedCloneProviders.includes(selectedProvider)\n      ? selectedProvider\n      : defaultProvider;\n  // What the form stores for a listed repository: GitHub's shorthand, or the\n  // full URL on the provider's host for anything else.\n  const addressFor = (name: string): string | null =>\n    repositoryProvider\n      ? automationRepositoryAddress(\n          repositoryProvider,\n          name,\n          providerHosts[repositoryProvider],\n        )\n      : name;\n  const repositoryQuery = useGitRepositories({\n    provider: repositoryProvider,\n    enabled: isRepositoryMenuOpen && repositoryProvider !== null,\n    allowMissingProvider: isLocalBackend,\n  });\n  const repositories = parseRepositories(repository);\n  const repositoryPages = repositoryQuery.data?.pages ?? [];\n  const isLocalGithubTokenMissing =\n    isLocalBackend && repositoryPages.some((page) => page.missing_token);\n  const isRepositoryListLoading = repositoryQuery.isLoading;\n  const isRepositoryListError = repositoryQuery.isError;\n  const repositoryNames = repositoryPages\n    .flatMap((page) => page.items)\n    .map((repo) => repo.full_name);\n  const repositorySearchText = repositorySearch.trim().toLowerCase();\n  // The loaded pages may not hold every match, so a search also asks the\n  // provider.\n  const debouncedRepositorySearch = useDebounce(\n    repositorySearch.trim(),\n    REPOSITORY_SEARCH_DEBOUNCE_MS,\n  );\n  const repositorySearchQuery = useSearchRepositories(\n    debouncedRepositorySearch,\n    repositoryProvider,\n    !isRepositoryMenuOpen,\n  );\n  const isRepositorySearchLoading =\n    repositorySearchText !== \"\" &&\n    (repositorySearchQuery.isFetching ||\n      debouncedRepositorySearch !== repositorySearch.trim());\n  const searchedRepositoryNames = repositorySearchText\n    ? (repositorySearchQuery.data ?? []).map((repo) => repo.full_name)\n    : [];\n  const listedRepositoryNames = [\n    ...repositoryNames,\n    ...searchedRepositoryNames,\n  ].filter((name, index, names) => {\n    const address = addressFor(name);\n    return (\n      (address === null || !repositories.includes(address)) &&\n      names.indexOf(name) === index\n    );\n  });\n  const visibleRepositoryNames = repositorySearchText\n    ? listedRepositoryNames.filter((name) =>\n        name.toLowerCase().includes(repositorySearchText),\n      )\n    : listedRepositoryNames;\n  const addLabel = `${t(I18nKey.BUTTON$ADD)} ${t(I18nKey.AUTOMATIONS$DETAIL$REPOSITORIES)}`;\n  const isRepositoryProviderUnsupported =\n    repositoryProvider !== null && !canListProvider(repositoryProvider);\n\n  const writeRepositories = (next: string[]) => {\n    onRepositoryChange(next.join(\", \"));\n  };\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full min-w-0 flex-col gap-2.5\",\n        isRepositoryMenuOpen && \"relative z-30\",\n      )}\n    >\n      {showTitle ? (\n        <div className=\"flex w-full items-center gap-2\">\n          <span className=\"flex items-center gap-2 text-sm\">\n            {t(I18nKey.AUTOMATIONS$PROMPT)}\n            {updatedSuffix ? (\n              <span className=\"font-normal text-[var(--oh-muted)]\">\n                {updatedSuffix}\n              </span>\n            ) : null}\n          </span>\n          {titleAction ? (\n            <div className=\"ml-auto shrink-0\">{titleAction}</div>\n          ) : null}\n        </div>\n      ) : null}\n      <label\n        data-streaming-active={isStreaming ? \"true\" : undefined}\n        className=\"flex w-full min-w-0 flex-col gap-2.5\"\n      >\n        <div\n          data-testid=\"automation-setup-prompt-stack\"\n          className=\"relative w-full\"\n        >\n          <div\n            data-testid=\"automation-setup-prompt-container\"\n            className=\"relative z-10 -mb-[15px] flex flex-col rounded-[15px] border border-[var(--oh-border)] bg-[var(--oh-surface)] p-4\"\n          >\n            <textarea\n              ref={textareaRef}\n              data-testid=\"automation-setup-prompt\"\n              name=\"prompt\"\n              value={prompt}\n              onChange={(event) => onPromptChange(event.target.value)}\n              placeholder={t(I18nKey.HOME$AUTOMATE_PROMPT_PLACEHOLDER)}\n              rows={5}\n              className=\"min-h-[120px] w-full resize-none border-0 bg-transparent p-0 text-sm text-content outline-none placeholder:text-tertiary-alt\"\n            />\n            <div className=\"flex min-w-0 items-center gap-2 pt-2\">\n              <SetupModelPill value={model} onChange={onModelChange} />\n            </div>\n            <div\n              data-testid=\"automation-setup-prompt-grip\"\n              className=\"group absolute bottom-0 left-0 z-20 h-3 w-full\"\n            >\n              <div\n                className=\"absolute inset-0 z-[1] cursor-ns-resize select-none\"\n                onMouseDown={handleGripMouseDown}\n                onTouchStart={handleGripTouchStart}\n                aria-hidden\n              />\n              <div\n                ref={gripRef}\n                className={cn(\n                  \"pointer-events-none absolute bottom-0 left-0 z-[2] h-px w-full bg-white transition-opacity duration-200\",\n                  isGripDragging\n                    ? \"opacity-100\"\n                    : \"opacity-0 group-hover:opacity-100\",\n                )}\n              />\n            </div>\n          </div>\n          <div\n            data-testid=\"automation-setup-prompt-drawer\"\n            className=\"flex min-h-9 items-center rounded-b-[15px] border-x border-b border-[var(--oh-border)] bg-[var(--oh-surface-raised)] px-4 pb-2 pt-[calc(15px+0.5rem)]\"\n          >\n            <div\n              data-testid=\"automation-setup-repository\"\n              className=\"flex w-full min-w-0 items-start gap-1\"\n            >\n              <div className=\"relative flex shrink-0 items-center gap-2\">\n                <span className=\"text-sm\">\n                  {t(I18nKey.AUTOMATIONS$DETAIL$REPOSITORIES)}\n                </span>\n                {repositorySuffix ? (\n                  <span className=\"text-xs text-[var(--oh-muted)]\">\n                    {repositorySuffix}\n                  </span>\n                ) : null}\n                {repositoryReadOnly ? (\n                  <span\n                    data-testid=\"automation-setup-repository-read-only\"\n                    className=\"text-xs text-muted\"\n                  >\n                    {t(I18nKey.AUTOMATION_SETUP$REPOSITORIES_READ_ONLY)}\n                  </span>\n                ) : (\n                  <button\n                    ref={repositoryAddRef}\n                    type=\"button\"\n                    data-testid=\"automation-setup-repository-add\"\n                    aria-label={addLabel}\n                    aria-haspopup=\"menu\"\n                    aria-expanded={isRepositoryMenuOpen}\n                    className=\"inline-flex size-6 shrink-0 items-center justify-center rounded-md text-[var(--oh-muted)] hover:bg-white/10 hover:text-white\"\n                    onMouseDown={(event) => event.preventDefault()}\n                    onClick={() => {\n                      setRepositorySearch(\"\");\n                      setIsRepositoryMenuOpen((open) => !open);\n                    }}\n                  >\n                    <Plus className=\"size-4\" aria-hidden />\n                  </button>\n                )}\n                {isLocalGithubTokenMissing && !repositoryReadOnly ? (\n                  <StyledTooltip\n                    content={t(\n                      I18nKey.AUTOMATION_SETUP$GITHUB_TOKEN_REQUIRED_TOOLTIP,\n                    )}\n                    placement=\"top\"\n                  >\n                    <button\n                      type=\"button\"\n                      aria-label={t(\n                        I18nKey.AUTOMATION_SETUP$GITHUB_TOKEN_REQUIRED_TOOLTIP,\n                      )}\n                      data-testid=\"automation-setup-repository-token-tooltip\"\n                      className=\"inline-flex size-5 shrink-0 items-center justify-center rounded-full text-[var(--oh-muted)]\"\n                      onMouseDown={(event) => event.preventDefault()}\n                    >\n                      <Info className=\"size-4\" aria-hidden />\n                    </button>\n                  </StyledTooltip>\n                ) : null}\n                {isRepositoryMenuOpen ? (\n                  <div\n                    ref={repositoryMenuRef}\n                    role=\"menu\"\n                    data-testid=\"automation-setup-repository-menu\"\n                    className=\"absolute top-full left-0 z-[60] mt-1 flex max-h-72 w-[min(28rem,calc(100vw-3rem))] flex-col overflow-hidden rounded-md border border-border-subtle bg-tertiary py-1 shadow-lg\"\n                  >\n                    <div className=\"shrink-0 px-2\">\n                      <div className=\"relative\">\n                        <SearchIcon\n                          width={16}\n                          height={16}\n                          aria-hidden\n                          className=\"pointer-events-none absolute top-1/2 left-0 -translate-y-1/2 text-muted\"\n                        />\n                        <input\n                          type=\"text\"\n                          data-testid=\"automation-setup-repository-search\"\n                          value={repositorySearch}\n                          onChange={(event) =>\n                            setRepositorySearch(event.target.value)\n                          }\n                          onKeyDown={(event) => event.stopPropagation()}\n                          placeholder={t(I18nKey.COMMON$SEARCH_REPOSITORIES)}\n                          aria-label={t(I18nKey.COMMON$SEARCH_REPOSITORIES)}\n                          className=\"w-full border-0 bg-transparent py-1.5 pr-0 pl-6 text-sm text-contrast outline-none placeholder:text-muted focus:ring-0 focus:outline-none\"\n                        />\n                      </div>\n                    </div>\n                    {connectedCloneProviders.length > 1 ? (\n                      <div className=\"shrink-0 px-2 pb-1\">\n                        <select\n                          data-testid=\"automation-setup-repository-provider\"\n                          aria-label={t(I18nKey.CONVERSATION$GIT_PROVIDER)}\n                          value={repositoryProvider ?? \"\"}\n                          onChange={(event) => {\n                            setSelectedProvider(event.target.value as Provider);\n                            setRepositorySearch(\"\");\n                          }}\n                          onKeyDown={(event) => event.stopPropagation()}\n                          className=\"w-full rounded-md border border-border-subtle bg-tertiary px-2 py-1 text-sm text-content outline-none\"\n                        >\n                          {connectedCloneProviders.map((provider) => (\n                            <option key={provider} value={provider}>\n                              {getProviderName(provider)}\n                            </option>\n                          ))}\n                        </select>\n                      </div>\n                    ) : null}\n                    <Divider inset=\"menu\" />\n                    <div\n                      className=\"min-h-0 flex-1 overflow-y-auto px-1\"\n                      onScroll={(event) => {\n                        const list = event.currentTarget;\n                        if (\n                          list.scrollTop + list.clientHeight >=\n                          list.scrollHeight - 24\n                        ) {\n                          repositoryQuery.onLoadMore();\n                        }\n                      }}\n                    >\n                      {isRepositoryProviderUnsupported && repositoryProvider ? (\n                        <p\n                          data-testid=\"automation-setup-repository-unsupported\"\n                          className=\"px-2 py-2 text-sm text-muted\"\n                        >\n                          {t(\n                            I18nKey.AUTOMATION_SETUP$REPOSITORY_PROVIDER_UNSUPPORTED,\n                            { provider: getProviderName(repositoryProvider) },\n                          )}\n                        </p>\n                      ) : null}\n                      {isRepositoryListError ? (\n                        <p\n                          data-testid=\"automation-setup-repository-error\"\n                          className=\"px-2 py-2 text-sm text-[var(--oh-warning)]\"\n                        >\n                          {t(I18nKey.HOME$FAILED_TO_LOAD_REPOSITORIES)}\n                        </p>\n                      ) : (isRepositoryListLoading ||\n                          isRepositorySearchLoading) &&\n                        visibleRepositoryNames.length === 0 ? (\n                        <div\n                          data-testid=\"automation-setup-repository-loading\"\n                          className=\"flex items-center justify-center py-3\"\n                        >\n                          <LoadingSpinner size=\"small\" />\n                        </div>\n                      ) : (\n                        visibleRepositoryNames.map((name) => (\n                          <ContextMenuListItem\n                            key={name}\n                            testId={`automation-setup-repository-option-${name}`}\n                            onClick={(event) => {\n                              event.preventDefault();\n                              event.stopPropagation();\n                              const address = addressFor(name);\n                              if (address && !repositories.includes(address)) {\n                                writeRepositories([...repositories, address]);\n                              }\n                              setIsRepositoryMenuOpen(false);\n                            }}\n                            isDisabled={isRepositoryProviderUnsupported}\n                            className=\"flex w-full items-center text-wrap\"\n                          >\n                            {/* Organization/project/repository names can be\n                                long; wrap them so every option reads in full. */}\n                            <span className=\"min-w-0 break-all\" title={name}>\n                              {name}\n                            </span>\n                          </ContextMenuListItem>\n                        ))\n                      )}\n                      {repositorySearchText &&\n                      !isRepositoryListLoading &&\n                      !isRepositorySearchLoading &&\n                      !isRepositoryListError &&\n                      visibleRepositoryNames.length === 0 ? (\n                        <p className=\"px-2 py-2 text-sm text-muted italic\">\n                          {t(I18nKey.GITHUB$NO_RESULTS)}\n                        </p>\n                      ) : null}\n                    </div>\n                    <Divider\n                      inset=\"menu\"\n                      testId=\"automation-setup-repository-custom-divider\"\n                    />\n                    <div className=\"shrink-0 px-1\">\n                      <ContextMenuListItem\n                        testId=\"automation-setup-repository-custom\"\n                        onClick={(event) => {\n                          event.preventDefault();\n                          event.stopPropagation();\n                          setIsRepositoryMenuOpen(false);\n                          setIsRepositoryModalOpen(true);\n                        }}\n                        className=\"flex w-full items-center\"\n                      >\n                        <span className=\"truncate\">\n                          {t(I18nKey.AUTOMATION_SETUP$TYPE_CUSTOM)}\n                        </span>\n                      </ContextMenuListItem>\n                    </div>\n                  </div>\n                ) : null}\n              </div>\n              <div\n                data-testid=\"automation-setup-repository-values\"\n                className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1\"\n              >\n                {repositories.map((item) => (\n                  <span\n                    key={item}\n                    data-testid=\"automation-setup-repository-value\"\n                    className={cn(\n                      extensionModuleCardPillClassName,\n                      \"gap-1 pr-1\",\n                    )}\n                  >\n                    <span className=\"truncate\" title={item}>\n                      {item}\n                    </span>\n                    {repositoryReadOnly ? null : (\n                      <button\n                        type=\"button\"\n                        data-testid=\"automation-setup-repository-remove\"\n                        aria-label={`${t(I18nKey.COMMON$REMOVE)} ${item}`}\n                        className=\"inline-flex size-4 items-center justify-center rounded-full text-tertiary-light hover:bg-white/10 hover:text-white\"\n                        onMouseDown={(event) => event.preventDefault()}\n                        onClick={() =>\n                          writeRepositories(\n                            repositories.filter((entry) => entry !== item),\n                          )\n                        }\n                      >\n                        <X className=\"size-3\" aria-hidden />\n                      </button>\n                    )}\n                  </span>\n                ))}\n              </div>\n            </div>\n          </div>\n        </div>\n        {errorText ? (\n          <span\n            role=\"alert\"\n            className=\"text-xs leading-5 text-[var(--oh-warning)]\"\n          >\n            {errorText}\n          </span>\n        ) : null}\n      </label>\n      <AddRepositoryModal\n        isOpen={isRepositoryModalOpen}\n        onClose={() => setIsRepositoryModalOpen(false)}\n        onAdd={(address) => {\n          if (repositories.includes(address)) return;\n          writeRepositories([...repositories, address]);\n        }}\n      />\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/backends/backend-form-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"transition-[grid-template-rows]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1062,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1062,
          "endColumn": 109
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"grid-rows-[1fr]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1063,
          "column": 34,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1063,
          "endColumn": 51
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"grid-rows-[0fr]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1063,
          "column": 54,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1063,
          "endColumn": 71
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets height. Style through classes; use CSS custom properties for dynamic values.",
          "line": 1205,
          "column": 51,
          "nodeType": "Identifier",
          "messageId": "inlineStyle",
          "endLine": 1205,
          "endColumn": 57
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"transition-[height]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1206,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1206,
          "endColumn": 109
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"transition-[grid-template-rows]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1288,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1288,
          "endColumn": 101
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"grid-rows-[1fr]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1289,
          "column": 24,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1289,
          "endColumn": 41
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"grid-rows-[0fr]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1289,
          "column": 44,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1289,
          "endColumn": 61
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-h-[13.5rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1405,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1405,
          "endColumn": 127
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"before:bg-[radial-gradient(75%_75%_at_50%_50%,rgba(255,255,255,0.1)_0%,rgba(255,255,255,0)_70%)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1409,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1409,
          "endColumn": 109
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"align-[-0.125em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1653,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1653,
          "endColumn": 70
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[92vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1735,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1735,
          "endColumn": 130
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[92dvh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1763,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1763,
          "endColumn": 109
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "i18next/no-literal-string",
          "severity": 2,
          "message": "disallow literal string: placeholder=\"Production\"",
          "line": 616,
          "column": 23,
          "nodeType": "Literal",
          "endLine": 616,
          "endColumn": 35,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "example placeholder, not user-facing copy"
            }
          ]
        },
        {
          "ruleId": "i18next/no-literal-string",
          "severity": 2,
          "message": "disallow literal string: placeholder=\"e.g. My Server\"",
          "line": 877,
          "column": 21,
          "nodeType": "Literal",
          "endLine": 877,
          "endColumn": 37,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "example placeholder, not user-facing copy"
            }
          ]
        },
        {
          "ruleId": "i18next/no-literal-string",
          "severity": 2,
          "message": "disallow literal string: placeholder=\"http://localhost:8000\"",
          "line": 893,
          "column": 21,
          "nodeType": "Literal",
          "endLine": 893,
          "endColumn": 44,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "example value, not translatable"
            }
          ]
        },
        {
          "ruleId": "i18next/no-literal-string",
          "severity": 2,
          "message": "disallow literal string: placeholder=\"sk-\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\"",
          "line": 924,
          "column": 21,
          "nodeType": "Literal",
          "endLine": 924,
          "endColumn": 36,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "example value, not translatable"
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 13,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  ChevronDown,\n  ChevronRight,\n  CloudCog,\n  Globe,\n  Info,\n  Monitor,\n} from \"lucide-react\";\nimport { ServerClient } from \"@openhands/typescript-client/clients\";\nimport OpenHandsLogoWhite from \"#/assets/branding/openhands-logo-white.svg?react\";\nimport DigitalOceanLogo from \"#/assets/branding/digitalocean-logo.svg?react\";\nimport { getMarsBridge } from \"#/api/mars/mars-tunnel-backend\";\nimport { DigitalOceanConnectPanel } from \"./managed-agents/digitalocean-connect-panel\";\nimport { BackNavButton } from \"#/components/shared/buttons/back-nav-button\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  modalWidthClassName,\n} from \"#/components/shared/modals/modal-body\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { SettingsInput } from \"#/components/features/settings/settings-input\";\nimport { SegmentedToggle } from \"#/components/features/files-tab/segmented-toggle\";\nimport { useActiveBackendContext } from \"#/contexts/active-backend-context\";\nimport { useBackendsHealth } from \"#/hooks/query/use-backends-health\";\nimport { useTracking } from \"#/hooks/use-tracking\";\nimport type { CloudConnectionSource } from \"#/services/cloud-funnel-analytics\";\nimport { getAgentServerClientOptions } from \"#/api/agent-server-client-options\";\nimport { getLockedCloudHost } from \"#/api/agent-server-config\";\nimport { isOpenHandsCloudHost } from \"#/api/device-flow-client\";\nimport {\n  getDisplayAgentServerVersion,\n  validateLocalBackend,\n} from \"#/api/agent-server-compatibility\";\nimport ChevronDownSmallIcon from \"#/icons/chevron-down-small.svg?react\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type { Backend, BackendKind } from \"#/api/backend-registry/types\";\nimport { getUserFacingConnectionErrorMessage } from \"#/utils/user-facing-error\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  modalTitleLgClassName,\n  modalTitleLgMediumClassName,\n} from \"#/utils/modal-classes\";\nimport ExternalLinkIcon from \"#/icons/external-link.svg?react\";\nimport ServerIcon from \"#/icons/server.svg?react\";\nimport { getBackendStatusLabel } from \"./backend-status-label\";\nimport { BackendStatusDot } from \"./backend-status-dot\";\nimport { DeviceFlowAuth } from \"./device-flow-auth\";\nimport { useBackendSwitchRedirect } from \"./use-backend-switch-redirect\";\n\nexport type BackendFormMode = \"add\" | \"edit\";\n\ninterface BackendConnectionTestMetadata {\n  agentServerVersion: string | null;\n}\n\ninterface BackendFormModalProps {\n  mode: BackendFormMode;\n  /** Required when `mode === \"edit\"`. */\n  backend?: Backend;\n  onClose: () => void;\n  /** Analytics surface for the `backend_added` event (add mode only). */\n  source?: BackendAddedSource;\n  /** Hide the close button and disable backdrop/escape dismissal. Used for locked Cloud first-run. */\n  hideCloseButton?: boolean;\n}\n\n/**\n * Seed the default backend kind from the host. Uses proper hostname-suffix\n * matching (via {@link isOpenHandsCloudHost}) rather than a substring test, so\n * a look-alike host such as `all-hands-testing.dev` isn't misread as cloud.\n *\n * This is only a *default*: a self-hosted OpenHands Cloud/Enterprise instance\n * on a truly custom domain is indistinguishable from a local agent-server by\n * host alone, so the manual add form lets the user override the kind\n * explicitly (see the Type selector in ManualConnectionColumn).\n */\nfunction inferKindFromHost(host: string): BackendKind {\n  return isOpenHandsCloudHost(host) ? \"cloud\" : \"local\";\n}\n\n/**\n * Returns true for hostnames that represent a local / private-network address.\n * Used by normalizeHost to choose http:// instead of https://.\n */\nfunction isLocalAddress(hostname: string): boolean {\n  // Strip IPv6 bracket notation: [::1] \u2192 ::1\n  const h = hostname.toLowerCase().replace(/^\\[|\\]$/g, \"\");\n  // IPv6 loopback, any-address, and named loopback\n  if (h === \"localhost\" || h === \"::1\" || h === \"::\" || h === \"0.0.0.0\")\n    return true;\n  // 127.x.x.x loopback range + IPv4-mapped loopback (::ffff:127.x.x.x)\n  if (/^127\\./.test(h) || /^::ffff:127\\./i.test(h)) return true;\n  // RFC 1918 private ranges\n  if (/^10\\./.test(h)) return true;\n  if (/^192\\.168\\./.test(h)) return true;\n  if (/^172\\.(1[6-9]|2\\d|3[01])\\./.test(h)) return true;\n  // IPv6 link-local (fe80::/10) and unique local (fc00::/7)\n  if (/^fe[89ab][0-9a-f]:/i.test(h)) return true;\n  if (/^f[cd][0-9a-f]{2}:/i.test(h)) return true;\n  // mDNS / Bonjour (.local)\n  if (h.endsWith(\".local\")) return true;\n  // Single-label hostnames (no dots, no colons) are local network names.\n  // Colons are excluded so bare IPv6 addresses don't accidentally match.\n  if (!h.includes(\".\") && !h.includes(\":\")) return true;\n  return false;\n}\n\nfunction normalizeHost(host: string): string {\n  const trimmed = host.trim().replace(/\\/+$/, \"\");\n  if (!trimmed) return \"\";\n  // Already has an explicit scheme \u2014 respect it.\n  if (/^https?:\\/\\//i.test(trimmed)) return trimmed;\n  // Extract the pure hostname for scheme selection, handling three cases:\n  //   [::1]:8080  \u2192 bracket IPv6 notation \u2192 extract ::1\n  //   ::1         \u2192 bare IPv6 (multiple colons, no bracket) \u2192 whole string\n  //   host:port   \u2192 regular host:port \u2192 part before the colon\n  const bracketMatch = trimmed.match(/^\\[([^\\]]+)\\]/);\n  const hostname = bracketMatch\n    ? bracketMatch[1]\n    : (trimmed.match(/:/g) ?? []).length > 1\n      ? trimmed\n      : trimmed.split(\":\")[0];\n  const scheme = isLocalAddress(hostname) ? \"http\" : \"https\";\n  return `${scheme}://${trimmed}`;\n}\n\n/**\n * Returns true when `host` represents a reachable backend URL.\n *\n * Rules (applied in order):\n *   1. Must be non-empty after trimming.\n *   2. Must contain no whitespace \u2014 spaces can never appear in a host/port.\n *   3. After normalisation (bare hosts get `https://` prepended), must parse\n *      as a valid http or https URL with a non-empty hostname.\n */\nfunction isValidHostUrl(host: string): boolean {\n  const trimmed = host.trim();\n  if (!trimmed) return false;\n  // Spaces anywhere in the input are an immediate rejection.\n  if (/\\s/.test(trimmed)) return false;\n  const normalized = normalizeHost(trimmed);\n  if (!normalized) return false;\n  try {\n    const url = new URL(normalized);\n    return (\n      (url.protocol === \"http:\" || url.protocol === \"https:\") &&\n      url.hostname.length > 0\n    );\n  } catch {\n    return false;\n  }\n}\n\nconst DEFAULT_OPENHANDS_CLOUD_HOST = \"https://app.all-hands.dev\";\nconst LOCAL_BACKEND_COMMAND = \"agent-canvas --backend-only --port 8001\";\nconst LOCAL_AGENT_SERVER_DOCS_URL =\n  \"https://github.com/OpenHands/OpenHands/blob/main/docs/DEVELOPMENT.md#alternative-development-workflows\";\nconst REMOTE_AGENT_SERVER_DOCS_URL =\n  \"https://github.com/OpenHands/OpenHands/blob/main/docs/SELF_HOSTING.md\";\nconst DEPLOYMENT_OPTIONS_URL =\n  \"https://docs.openhands.dev/overview/introduction\";\nexport type BackendConnectionMethod = \"manual\" | \"cloud_login\";\n\nexport type BackendAddedSource = CloudConnectionSource;\ntype AddBackendOption = \"cloud\" | \"agent-server\" | \"providers\";\ntype AgentServerLocation = \"local\" | \"remote\";\n\nfunction getConnectionTestFailedTitle(\n  t: ReturnType<typeof useTranslation>[\"t\"],\n  host: string,\n): string {\n  return t(I18nKey.BACKEND$CONNECTION_TEST_FAILED, {\n    host,\n    interpolation: { escapeValue: false },\n  });\n}\n\nfunction getConnectionErrorDetail(error: unknown): string | null {\n  return getUserFacingConnectionErrorMessage(error);\n}\n\nfunction getConnectionTestFailedMessage(title: string, error: unknown): string {\n  const detail = getConnectionErrorDetail(error);\n  return detail ? `${title}\\n${detail}` : title;\n}\n\nasync function testBackendConnection(\n  backend: Pick<Backend, \"host\" | \"apiKey\" | \"kind\">,\n): Promise<BackendConnectionTestMetadata> {\n  // Cloud backends authenticate via OAuth; preflight GET is not applicable.\n  if (backend.kind !== \"local\") return { agentServerVersion: null };\n  const agentServerVersion = await validateLocalBackend(backend, 5000);\n  return { agentServerVersion };\n}\n\n/**\n * Live status row for the edit form: shows a connection dot, a\n * \"Local\"/\"Cloud\" label, and the agent server's reported version when\n * available. Replaces the legacy local/cloud radio fieldset (kind is\n * now inferred from the host).\n */\nfunction BackendStatusBadge({\n  backend,\n  testIdRoot,\n}: {\n  backend: Backend;\n  testIdRoot: string;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const healthByBackendId = useBackendsHealth([backend]);\n  const health = healthByBackendId[backend.id];\n  const isConnected = health?.isConnected ?? null;\n  const disabled = health?.disabled === true;\n  const consecutiveFailures = health?.consecutiveFailures ?? 0;\n  const lastError = health?.lastError ?? null;\n\n  const { data: version } = useQuery({\n    queryKey: [\"backend-version\", backend.host, backend.apiKey],\n    queryFn: async () => {\n      const info = await new ServerClient(\n        getAgentServerClientOptions({\n          host: backend.host,\n          sessionApiKey: backend.apiKey || null,\n          timeout: 5000,\n        }),\n      ).getServerInfo();\n      return getDisplayAgentServerVersion(info);\n    },\n    retry: false,\n    staleTime: 60_000,\n    enabled: backend.kind === \"local\" && !disabled,\n  });\n\n  const statusLabel = getBackendStatusLabel(t, backend, health);\n\n  const kindLabel =\n    backend.kind === \"cloud\"\n      ? t(I18nKey.BACKEND$KIND_CLOUD)\n      : t(I18nKey.BACKEND$KIND_LOCAL);\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <div\n        data-testid={`${testIdRoot}-status`}\n        className=\"flex items-center gap-3 text-sm\"\n      >\n        <BackendStatusDot isConnected={isConnected} />\n        <span\n          className=\"text-contrast\"\n          data-testid={`${testIdRoot}-status-label`}\n        >\n          {statusLabel}\n        </span>\n        <span className=\"text-tertiary-alt\">\u00b7</span>\n        <span className=\"text-text-tertiary\">{kindLabel}</span>\n        {version ? (\n          <span\n            className=\"text-xs text-muted\"\n            data-testid={`${testIdRoot}-version`}\n          >\n            {t(I18nKey.BACKEND$VERSION_LABEL, { version })}\n          </span>\n        ) : null}\n      </div>\n\n      {/* @spec BM-004 \u2014 Theme-aware backend connection feedback */}\n      {disabled ? (\n        <div\n          data-testid={`${testIdRoot}-status-error`}\n          className=\"flex flex-col gap-1 rounded-md border border-semantic-danger/40 bg-semantic-danger/5 p-3 text-sm\"\n        >\n          <span className=\"font-semibold text-feedback-error\">\n            {t(I18nKey.BACKEND$HEALTH_FAILED_TITLE)}\n          </span>\n          <span className=\"text-xs text-text-tertiary\">\n            {t(I18nKey.BACKEND$HEALTH_FAILED_DETAIL, {\n              count: consecutiveFailures,\n            })}\n          </span>\n          {lastError ? (\n            <span\n              data-testid={`${testIdRoot}-status-error-message`}\n              className=\"text-xs text-feedback-error whitespace-pre-wrap break-words\"\n            >\n              {lastError}\n            </span>\n          ) : null}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nexport interface BackendFormSubmitPayload {\n  name: string;\n  host: string;\n  apiKey: string;\n  kind: BackendKind;\n}\n\ninterface UseBackendFormOptions {\n  initialName?: string;\n  initialHost?: string;\n  initialApiKey?: string;\n  /**\n   * Called to test the connection. The hook does NOT call\n   * `testBackendConnection` directly so callers can inject a\n   * wrapped version (e.g. with extra logging or different timeout).\n   * Should throw on failure.\n   */\n  onTestConnection: (\n    payload: BackendFormSubmitPayload,\n  ) => Promise<BackendConnectionTestMetadata>;\n  /** Called after a successful connection test and persistence. */\n  onSuccess: (metadata: BackendConnectionTestMetadata) => void;\n  /** Require a non-empty API key even when the host looks local. */\n  requireApiKey?: boolean;\n  /**\n   * When provided, completely replaces the default submit flow\n   * (onTestConnection + onSuccess). The hook still manages form state\n   * and canSubmit validation, but the caller owns error handling and\n   * success side effects. Should throw on failure.\n   */\n  onSubmitOverride?: (payload: BackendFormSubmitPayload) => Promise<void>;\n  /** Fix the persisted backend kind instead of inferring it from the host. */\n  fixedKind?: BackendKind;\n}\n\n/**\n * Shared hook for the backend-form state used by both `BackendForm`\n * (edit/add mode) and `ManualConnectionColumn` (add-mode-only column).\n * Encapsulates name / host / apiKey fields, `connectionError`,\n * `isSubmitting`, and the shared `handleSubmit` flow.\n */\nfunction useBackendForm({\n  initialName = \"\",\n  initialHost = \"\",\n  initialApiKey = \"\",\n  onTestConnection,\n  onSuccess,\n  requireApiKey = false,\n  onSubmitOverride,\n  fixedKind,\n}: UseBackendFormOptions) {\n  const { t } = useTranslation(\"openhands\");\n\n  const [name, setName] = React.useState(initialName);\n  const [host, setHost] = React.useState(initialHost);\n  const [apiKey, setApiKey] = React.useState(initialApiKey);\n  const [connectionError, setConnectionError] = React.useState<string | null>(\n    null,\n  );\n  const [isSubmitting, setIsSubmitting] = React.useState(false);\n  const [kindOverride, setKindOverride] = React.useState<BackendKind | null>(\n    null,\n  );\n\n  // Kind follows host inference until the user explicitly picks a type, then\n  // respects that choice. A custom-domain OHE can't be distinguished from a\n  // custom-domain local agent-server by host alone, so ManualConnectionColumn\n  // exposes `setKind` (a Type selector) to let the user declare it.\n  const kind = fixedKind ?? kindOverride ?? inferKindFromHost(host);\n  const needsApiKey = requireApiKey || kind !== \"local\";\n  const canSubmit =\n    name.trim().length > 0 &&\n    isValidHostUrl(host) &&\n    (!needsApiKey || apiKey.trim().length > 0);\n\n  const handleSubmit = React.useCallback(\n    async (e: React.FormEvent<HTMLFormElement>) => {\n      e.preventDefault();\n      if (!canSubmit || isSubmitting) return;\n\n      const payload: BackendFormSubmitPayload = {\n        name: name.trim(),\n        host: normalizeHost(host),\n        apiKey: apiKey.trim(),\n        kind,\n      };\n\n      setConnectionError(null);\n      setIsSubmitting(true);\n\n      try {\n        // When onSubmitOverride is provided, it completely replaces the\n        // default flow (onTestConnection + onSuccess).\n        if (onSubmitOverride) {\n          await onSubmitOverride(payload);\n        } else {\n          const metadata = await onTestConnection(payload);\n          onSuccess(metadata);\n        }\n      } catch (error) {\n        setConnectionError(\n          getConnectionTestFailedMessage(\n            getConnectionTestFailedTitle(t, payload.host),\n            error,\n          ),\n        );\n      } finally {\n        setIsSubmitting(false);\n      }\n    },\n    [\n      canSubmit,\n      isSubmitting,\n      name,\n      host,\n      apiKey,\n      kind,\n      onTestConnection,\n      onSuccess,\n      requireApiKey,\n      onSubmitOverride,\n      fixedKind,\n      t,\n    ],\n  );\n\n  return {\n    name,\n    setName,\n    host,\n    setHost,\n    apiKey,\n    setApiKey,\n    connectionError,\n    setConnectionError,\n    isSubmitting,\n    kind,\n    setKind: setKindOverride,\n    canSubmit,\n    handleSubmit,\n  };\n}\n\nexport interface BackendFormProps {\n  mode: BackendFormMode;\n  /** Required when `mode === \"edit\"`. */\n  backend?: Backend;\n  /**\n   * Called after the form is submitted and the backend has been\n   * persisted. Use this to dismiss a containing modal, advance an\n   * onboarding step, etc.\n   */\n  onSubmitted: () => void;\n  /**\n   * Optional render slot rendered in place of the default\n   * Save / Cancel button row, so callers (e.g. the onboarding flow)\n   * can re-skin the action area while still owning submission via the\n   * standard `<form onSubmit>` flow. Receives the form's submit-ready\n   * state.\n   */\n  renderActions?: (state: {\n    canSubmit: boolean;\n    isSubmitting: boolean;\n    testIdRoot: string;\n  }) => React.ReactNode;\n  /** Used to disambiguate test ids across the same screen. */\n  testIdRoot?: string;\n  /** When true, the host field is pre-filled and disabled. */\n  hostReadOnly?: boolean;\n  /**\n   * When true, a non-empty API key is required for submission regardless\n   * of the inferred backend kind.  The standard add form allows empty\n   * keys for local backends; the auth-gate screen needs to enforce one.\n   */\n  requireApiKey?: boolean;\n  /**\n   * When true, hides the name/host/API-key inputs (and related inline\n   * errors) while keeping the action row visible \u2014 used by onboarding\n   * after a successful connection probe.\n   */\n  hideConfigurationFields?: boolean;\n  /**\n   * Replace the default synchronous add/update-and-close submit with a\n   * custom async handler.  The form builds the payload, validates\n   * client-side, then hands it to this callback. If the callback throws,\n   * the form remains open so the caller can surface errors.\n   */\n  onSubmitOverride?: (payload: BackendFormSubmitPayload) => Promise<void>;\n}\n\n/**\n * Reusable form body for adding / editing a backend. Renders the\n * common name / host / API-key inputs plus the kind selector\n * (radio buttons in `add` mode, status badge in `edit` mode).\n *\n * Rendered as a `<form>`, so consumers should put any extra controls\n * either inside `renderActions` or as siblings inside a wrapping\n * element \u2014 but submission flows through the standard form submit so\n * Enter-to-submit still works.\n */\nexport function BackendForm({\n  mode,\n  backend,\n  onSubmitted,\n  renderActions,\n  testIdRoot: explicitTestIdRoot,\n  hostReadOnly,\n  requireApiKey,\n  hideConfigurationFields = false,\n  onSubmitOverride,\n}: BackendFormProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { addBackend, updateBackend } = useActiveBackendContext();\n\n  // In edit mode preserve the existing backend's kind so that renaming or\n  // rotating the API key on a cloud backend (e.g. an OHE/enterprise instance\n  // on a custom domain) does not silently downgrade it to \"local\" and switch\n  // the auth header from `Authorization: Bearer` to `X-Session-API-Key`.\n  // Only infer from the host when adding a new backend.\n  const fixedKind: BackendKind | null =\n    mode === \"edit\" && backend ? backend.kind : null;\n\n  const {\n    name,\n    setName,\n    host,\n    setHost,\n    apiKey,\n    setApiKey,\n    connectionError,\n    setConnectionError,\n    isSubmitting,\n    kind: inferredKind,\n    handleSubmit: runSubmit,\n  } = useBackendForm({\n    initialName: backend?.name ?? \"\",\n    initialHost: backend?.host ?? \"\",\n    initialApiKey: backend?.apiKey ?? \"\",\n    onTestConnection: testBackendConnection,\n    onSuccess: async () => {\n      const payload: BackendFormSubmitPayload = {\n        name: name.trim(),\n        host: normalizeHost(host),\n        apiKey: apiKey.trim(),\n        kind: fixedKind ?? inferredKind,\n      };\n      if (mode === \"edit\" && backend) {\n        updateBackend(backend.id, payload);\n      } else {\n        addBackend(payload);\n      }\n      onSubmitted();\n    },\n    requireApiKey,\n    onSubmitOverride,\n  });\n\n  // Inline validation: only show errors after the user has left a field.\n  const [nameTouched, setNameTouched] = React.useState(false);\n  const [hostTouched, setHostTouched] = React.useState(false);\n\n  const kind = fixedKind ?? inferredKind;\n  const testIdRoot =\n    explicitTestIdRoot ?? (mode === \"edit\" ? \"edit-backend\" : \"add-backend\");\n\n  const needsApiKey = requireApiKey || kind !== \"local\";\n  const canSubmit =\n    name.trim().length > 0 &&\n    isValidHostUrl(host) &&\n    (!needsApiKey || apiKey.trim().length > 0);\n\n  // Error messages \u2014 only surfaced after the user has blurred the field.\n  const nameError =\n    nameTouched && !name.trim() ? t(I18nKey.BACKEND$NAME_REQUIRED) : undefined;\n  const hostError = hostTouched\n    ? !host.trim()\n      ? t(I18nKey.BACKEND$HOST_REQUIRED)\n      : !isValidHostUrl(host)\n        ? t(I18nKey.BACKEND$HOST_INVALID)\n        : undefined\n    : undefined;\n\n  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {\n    if (!canSubmit) {\n      // Mark all validated fields as touched so inline errors become visible\n      // (e.g. user pressed Enter before filling required fields).\n      setNameTouched(true);\n      setHostTouched(true);\n      return;\n    }\n    await runSubmit(event);\n  };\n\n  return (\n    <form\n      data-testid={`${testIdRoot}-form`}\n      onSubmit={handleSubmit}\n      className=\"flex flex-col gap-4\"\n    >\n      <div\n        data-testid={`${testIdRoot}-configuration-fields`}\n        className={cn(\n          \"flex flex-col gap-4\",\n          hideConfigurationFields && \"hidden\",\n        )}\n      >\n        <SettingsInput\n          testId={`${testIdRoot}-name`}\n          name={`${testIdRoot}-name`}\n          type=\"text\"\n          label={t(I18nKey.BACKEND$NAME_LABEL)}\n          value={name}\n          onChange={(value) => {\n            setName(value);\n            setConnectionError(null);\n          }}\n          onBlur={() => setNameTouched(true)}\n          // eslint-disable-next-line i18next/no-literal-string -- example placeholder, not user-facing copy\n          placeholder=\"Production\"\n          className=\"w-full\"\n          showRequiredTag\n          error={nameError}\n        />\n\n        <SettingsInput\n          testId={`${testIdRoot}-host`}\n          name={`${testIdRoot}-host`}\n          type=\"text\"\n          label={t(I18nKey.BACKEND$HOST_LABEL)}\n          value={host}\n          onChange={\n            hostReadOnly\n              ? undefined\n              : (value) => {\n                  setHost(value);\n                  setConnectionError(null);\n                }\n          }\n          onBlur={() => setHostTouched(true)}\n          placeholder={DEFAULT_OPENHANDS_CLOUD_HOST}\n          className=\"w-full\"\n          showRequiredTag\n          error={hostError}\n          isDisabled={hostReadOnly}\n        />\n\n        <SettingsInput\n          testId={`${testIdRoot}-api-key`}\n          name={`${testIdRoot}-api-key`}\n          type=\"password\"\n          label={t(I18nKey.BACKEND$KEY_LABEL)}\n          value={apiKey}\n          onChange={(value) => {\n            setApiKey(value);\n            setConnectionError(null);\n          }}\n          placeholder=\"\"\n          className=\"w-full\"\n        />\n\n        {connectionError ? (\n          <div\n            role=\"alert\"\n            data-testid={`${testIdRoot}-error`}\n            className=\"rounded-md border border-semantic-danger/40 bg-semantic-danger/5 p-3 text-sm text-feedback-error whitespace-pre-wrap break-words\"\n          >\n            {connectionError}\n          </div>\n        ) : null}\n\n        {mode === \"edit\" && backend && (\n          <BackendStatusBadge backend={backend} testIdRoot={testIdRoot} />\n        )}\n      </div>\n\n      {renderActions ? (\n        renderActions({\n          canSubmit: canSubmit && !isSubmitting,\n          isSubmitting,\n          testIdRoot,\n        })\n      ) : (\n        <div className=\"flex justify-end gap-2 mt-2 w-full\">\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={onSubmitted}\n            testId={`${testIdRoot}-cancel`}\n          >\n            {t(I18nKey.BUTTON$CANCEL)}\n          </BrandButton>\n          <BrandButton\n            type=\"submit\"\n            variant=\"primary\"\n            isDisabled={!canSubmit || isSubmitting}\n            testId={`${testIdRoot}-submit`}\n          >\n            {t(I18nKey.BACKEND$SAVE)}\n          </BrandButton>\n        </div>\n      )}\n    </form>\n  );\n}\n\n// \u2500\u2500 Add-mode two-column layout \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\ninterface BackendConnectionOptionsProps {\n  onConnected: (\n    payload: BackendFormSubmitPayload,\n    connectionMethod: BackendConnectionMethod,\n    metadata?: BackendConnectionTestMetadata,\n  ) => void;\n  testIdRoot?: string;\n  initialManualBackend?: Partial<\n    Pick<BackendFormSubmitPayload, \"name\" | \"host\" | \"apiKey\">\n  >;\n  requireManualApiKey?: boolean;\n  manualSubmitLabel?: React.ReactNode;\n  manualSubmittingLabel?: React.ReactNode;\n  manualSubmitTestId?: string;\n  analyticsSource?: CloudConnectionSource;\n}\n\n/**\n * Manual agent-server connection plus OpenHands Cloud OAuth login.\n * Used by both the Add Backend modal and the onboarding backend step so\n * supported backend choices stay consistent across first-run and settings UI.\n */\nexport function BackendConnectionOptions({\n  onConnected,\n  testIdRoot = \"add-backend\",\n  initialManualBackend,\n  requireManualApiKey = false,\n  manualSubmitLabel,\n  manualSubmittingLabel,\n  manualSubmitTestId,\n  analyticsSource,\n}: BackendConnectionOptionsProps) {\n  const { t } = useTranslation(\"openhands\");\n  const lockedCloudHost = getLockedCloudHost();\n\n  if (lockedCloudHost) {\n    return (\n      <div\n        data-testid={`${testIdRoot}-connection-options`}\n        className=\"flex justify-center\"\n      >\n        <div className=\"w-full\">\n          <CloudLoginColumn\n            onConnected={onConnected}\n            testIdRoot={testIdRoot}\n            lockedHost={lockedCloudHost}\n            analyticsSource={analyticsSource}\n          />\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      data-testid={`${testIdRoot}-connection-options`}\n      className=\"flex flex-col gap-6 md:flex-row\"\n    >\n      <div className=\"flex-1 min-w-0\">\n        <ManualConnectionColumn\n          onConnected={onConnected}\n          testIdRoot={testIdRoot}\n          initialBackend={initialManualBackend}\n          requireApiKey={requireManualApiKey}\n          submitLabel={manualSubmitLabel ?? t(I18nKey.BACKEND$CONNECT)}\n          submittingLabel={\n            manualSubmittingLabel ??\n            t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING)\n          }\n          submitTestId={manualSubmitTestId}\n        />\n      </div>\n\n      <div className=\"flex-1 min-w-0\">\n        <CloudLoginColumn\n          onConnected={onConnected}\n          testIdRoot={testIdRoot}\n          analyticsSource={analyticsSource}\n        />\n      </div>\n    </div>\n  );\n}\n\ninterface ManualConnectionColumnProps {\n  onConnected: (\n    payload: BackendFormSubmitPayload,\n    connectionMethod: BackendConnectionMethod,\n    metadata?: BackendConnectionTestMetadata,\n  ) => void;\n  testIdRoot: string;\n  initialBackend?: Partial<\n    Pick<BackendFormSubmitPayload, \"name\" | \"host\" | \"apiKey\">\n  >;\n  requireApiKey: boolean;\n  submitLabel: React.ReactNode;\n  submittingLabel: React.ReactNode;\n  submitTestId?: string;\n  fixedKind?: BackendKind;\n  showKindSelector?: boolean;\n}\n\n/**\n * Manual connection via Host + API Key. Designed for self-hosted agent servers\n * and self-hosted OpenHands Cloud with API key auth.\n */\nfunction ManualConnectionColumn({\n  onConnected,\n  testIdRoot,\n  initialBackend,\n  requireApiKey,\n  submitLabel,\n  submittingLabel,\n  submitTestId,\n  fixedKind,\n  showKindSelector = true,\n}: ManualConnectionColumnProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  const {\n    name,\n    setName,\n    host,\n    setHost,\n    apiKey,\n    setApiKey,\n    connectionError,\n    setConnectionError,\n    isSubmitting,\n    kind,\n    setKind,\n    canSubmit,\n    handleSubmit,\n  } = useBackendForm({\n    initialName: initialBackend?.name ?? \"\",\n    initialHost: initialBackend?.host ?? \"\",\n    initialApiKey: initialBackend?.apiKey ?? \"\",\n    onTestConnection: testBackendConnection,\n    onSuccess: (metadata) => {\n      onConnected(\n        {\n          name: name.trim(),\n          host: normalizeHost(host),\n          apiKey: apiKey.trim(),\n          kind,\n        },\n        \"manual\",\n        metadata,\n      );\n    },\n    requireApiKey,\n    fixedKind,\n  });\n\n  return (\n    <form\n      data-testid={`${testIdRoot}-form`}\n      onSubmit={handleSubmit}\n      className=\"flex flex-col gap-4 flex-1 min-w-0\"\n    >\n      <SettingsInput\n        testId={`${testIdRoot}-name`}\n        name={`${testIdRoot}-name`}\n        type=\"text\"\n        label={t(I18nKey.BACKEND$NAME_LABEL)}\n        hint={t(I18nKey.BACKEND$NAME_HELPER)}\n        value={name}\n        onChange={(value) => {\n          setName(value);\n          setConnectionError(null);\n        }}\n        // eslint-disable-next-line i18next/no-literal-string -- example placeholder, not user-facing copy\n        placeholder=\"e.g. My Server\"\n        className=\"w-full\"\n      />\n\n      <SettingsInput\n        testId={`${testIdRoot}-host`}\n        name={`${testIdRoot}-host`}\n        type=\"text\"\n        label={t(I18nKey.BACKEND$HOST_LABEL)}\n        hint={t(I18nKey.BACKEND$HOST_HELPER)}\n        value={host}\n        onChange={(value) => {\n          setHost(value);\n          setConnectionError(null);\n        }}\n        // eslint-disable-next-line i18next/no-literal-string -- example value, not translatable\n        placeholder=\"http://localhost:8000\"\n        className=\"w-full\"\n      />\n\n      {showKindSelector ? (\n        <div className=\"flex flex-col items-start gap-2.5\">\n          <span className=\"text-sm\">{t(I18nKey.BACKEND$KIND_LABEL)}</span>\n          <SegmentedToggle<BackendKind>\n            value={kind}\n            options={[\n              { value: \"local\", label: t(I18nKey.BACKEND$KIND_LOCAL) },\n              { value: \"cloud\", label: t(I18nKey.BACKEND$KIND_CLOUD) },\n            ]}\n            onChange={(value) => setKind(value)}\n            ariaLabel={t(I18nKey.BACKEND$KIND_LABEL)}\n            testId={`${testIdRoot}-kind`}\n          />\n        </div>\n      ) : null}\n\n      <SettingsInput\n        testId={`${testIdRoot}-api-key`}\n        name={`${testIdRoot}-api-key`}\n        type=\"password\"\n        label={t(I18nKey.BACKEND$KEY_LABEL)}\n        value={apiKey}\n        onChange={(value) => {\n          setApiKey(value);\n          setConnectionError(null);\n        }}\n        // eslint-disable-next-line i18next/no-literal-string -- example value, not translatable\n        placeholder=\"sk-\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\"\n        className=\"w-full\"\n      />\n\n      {connectionError ? (\n        <div\n          role=\"alert\"\n          data-testid={`${testIdRoot}-error`}\n          className=\"rounded-md border border-semantic-danger/40 bg-semantic-danger/5 p-3 text-sm text-feedback-error whitespace-pre-wrap break-words\"\n        >\n          {connectionError}\n        </div>\n      ) : null}\n\n      <BrandButton\n        type=\"submit\"\n        variant=\"secondary\"\n        isDisabled={!canSubmit || isSubmitting}\n        testId={submitTestId ?? `${testIdRoot}-submit`}\n        className=\"w-full text-center\"\n      >\n        {isSubmitting ? submittingLabel : submitLabel}\n      </BrandButton>\n    </form>\n  );\n}\n\ninterface CloudLoginColumnProps {\n  onConnected: (\n    payload: BackendFormSubmitPayload,\n    connectionMethod: BackendConnectionMethod,\n    metadata?: BackendConnectionTestMetadata,\n  ) => void;\n  testIdRoot: string;\n  lockedHost?: string;\n  analyticsSource?: CloudConnectionSource;\n  /** Omit repeated branding when a surrounding chooser already names Cloud. */\n  showBranding?: boolean;\n}\n\n/**\n * One-click OAuth login with OpenHands Cloud. Includes an \"Advanced\"\n * disclosure for users who self-host OpenHands Cloud and need to override the\n * host.\n */\nfunction CloudLoginColumn({\n  onConnected,\n  testIdRoot,\n  lockedHost,\n  analyticsSource,\n  showBranding = true,\n}: CloudLoginColumnProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  const [advancedOpen, setAdvancedOpen] = React.useState(false);\n  const [customHost, setCustomHost] = React.useState(\"\");\n  const advancedPanelId = `${testIdRoot}-advanced-panel`;\n\n  const effectiveHost =\n    lockedHost ?? (customHost.trim() || DEFAULT_OPENHANDS_CLOUD_HOST);\n\n  const handleLoginSuccess = (apiKey: string) => {\n    onConnected(\n      {\n        name: \"OpenHands Cloud\",\n        host: normalizeHost(effectiveHost),\n        apiKey,\n        kind: \"cloud\",\n      },\n      \"cloud_login\",\n    );\n  };\n\n  return (\n    <div className=\"flex w-full min-w-0 flex-col items-center gap-3\">\n      {showBranding ? (\n        <div className=\"flex flex-col items-center gap-1\">\n          <OpenHandsLogoWhite\n            width={56}\n            height={56}\n            className=\"text-contrast [&_path:not([fill=transparent])]:fill-current\"\n            aria-hidden\n          />\n\n          <h4\n            className={modalTitleLgMediumClassName}\n            data-testid={`${testIdRoot}-cloud-title`}\n          >\n            {t(I18nKey.BACKEND$CLOUD_TITLE)}\n          </h4>\n        </div>\n      ) : null}\n\n      <DeviceFlowAuth\n        host={effectiveHost}\n        onSuccess={handleLoginSuccess}\n        testIdRoot={testIdRoot}\n        analyticsSource={analyticsSource}\n        className=\"w-full items-center\"\n        idleDescription={\n          <p\n            className=\"text-center text-sm leading-relaxed text-muted\"\n            data-testid={`${testIdRoot}-cloud-description`}\n          >\n            {t(I18nKey.BACKEND$CLOUD_DESCRIPTION)}\n          </p>\n        }\n        // Host overriding only matters before the flow starts, so it rides the\n        // same idle-only slot as the description instead of sitting under the\n        // authorization status.\n        idleFooter={\n          lockedHost ? null : (\n            // Keep the Advanced disclosure at the original compact width even\n            // though the surrounding cloud panel spans the full chooser.\n            <div className=\"mx-auto w-full max-w-md\">\n              <button\n                type=\"button\"\n                onClick={() => setAdvancedOpen((open) => !open)}\n                aria-expanded={advancedOpen}\n                aria-controls={advancedPanelId}\n                data-testid={`${testIdRoot}-advanced-toggle`}\n                className=\"flex w-full cursor-pointer items-center justify-center gap-1 text-center text-xs text-muted transition-colors hover:text-content-2\"\n              >\n                <span>{t(I18nKey.BACKEND$ADVANCED)}</span>\n                <ChevronDownSmallIcon\n                  className={cn(\n                    \"h-4 w-4 shrink-0 text-muted transition-transform duration-200 ease-out\",\n                    advancedOpen && \"rotate-180\",\n                  )}\n                  aria-hidden\n                />\n              </button>\n              {/* Height animates through `grid-template-rows` (0fr \u2194 1fr) so no\n                  max-height guess is needed; the inner clip is what makes `0fr`\n                  collapse. Content stays mounted so a typed host survives, and\n                  `inert` keeps the collapsed field out of the tab order. */}\n              <div\n                className={cn(\n                  \"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none\",\n                  advancedOpen ? \"grid-rows-[1fr]\" : \"grid-rows-[0fr]\",\n                )}\n              >\n                <div className=\"overflow-hidden\">\n                  <div\n                    id={advancedPanelId}\n                    data-testid={`${testIdRoot}-advanced-panel`}\n                    aria-hidden={!advancedOpen}\n                    inert={!advancedOpen ? true : undefined}\n                    className={cn(\n                      \"pt-3 transition-opacity duration-200 ease-out motion-reduce:transition-none\",\n                      advancedOpen ? \"opacity-100\" : \"opacity-0\",\n                    )}\n                  >\n                    <SettingsInput\n                      testId={`${testIdRoot}-cloud-host`}\n                      name={`${testIdRoot}-cloud-host`}\n                      type=\"text\"\n                      label={t(I18nKey.BACKEND$HOST_LABEL)}\n                      value={customHost}\n                      onChange={setCustomHost}\n                      placeholder={DEFAULT_OPENHANDS_CLOUD_HOST}\n                      className=\"w-full\"\n                    />\n                    <p className=\"mt-1 text-xs text-muted\">\n                      {t(I18nKey.BACKEND$LOGIN_CLOUD_HINT)}\n                    </p>\n                  </div>\n                </div>\n              </div>\n            </div>\n          )\n        }\n      />\n    </div>\n  );\n}\n\ninterface BackendOptionTabProps {\n  value: AddBackendOption;\n  selectedValue: AddBackendOption;\n  title: string;\n  description: string;\n  icon: React.ReactNode;\n  onSelect: (value: AddBackendOption) => void;\n  panelId: string;\n  testId: string;\n}\n\n/**\n * Presents a connection-method tab: icon, title, and a one-line subtitle.\n *\n * The shared tablist owns the outer border, so these buttons meet cleanly at\n * the center. Selection uses a bottom bar instead of recoloring the full\n * outline, preserving the group as one visual control.\n */\nfunction BackendOptionTab({\n  value,\n  selectedValue,\n  title,\n  description,\n  icon,\n  onSelect,\n  panelId,\n  testId,\n}: BackendOptionTabProps) {\n  const isSelected = value === selectedValue;\n  const tabId = `${testId}-tab`;\n\n  return (\n    <button\n      id={tabId}\n      type=\"button\"\n      role=\"tab\"\n      aria-selected={isSelected}\n      aria-controls={panelId}\n      aria-describedby={`${tabId}-description`}\n      data-testid={testId}\n      onClick={() => onSelect(value)}\n      className={cn(\n        \"relative flex min-h-14 w-full cursor-pointer items-center gap-2.5 px-2.5 py-2.5 text-left transition-colors\",\n        \"not-last:border-r not-last:border-r-border\",\n        \"focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus\",\n        isSelected\n          ? \"bg-surface-raised text-contrast after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-primary\"\n          : \"text-muted hover:bg-surface-raised hover:text-contrast\",\n      )}\n    >\n      <span\n        className=\"flex size-7 shrink-0 items-center justify-center\"\n        aria-hidden\n      >\n        {icon}\n      </span>\n      <span className=\"min-w-0 flex-1\">\n        <span className=\"block truncate text-sm font-medium\">{title}</span>\n        <span id={`${tabId}-description`} className=\"sr-only\">\n          {description}\n        </span>\n      </span>\n    </button>\n  );\n}\n\ninterface AnimatedPanelHeightProps {\n  children: React.ReactNode;\n}\n\n/**\n * Animates a panel between content-driven heights.\n *\n * A ResizeObserver keeps the wrapper synchronized as tabs or nested\n * disclosures change. The transition class remains mounted before the\n * observer publishes a new height; adding it in the same render as the height\n * would give the browser no previous painted value to interpolate from.\n */\nfunction AnimatedPanelHeight({ children }: AnimatedPanelHeightProps) {\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const [height, setHeight] = React.useState<number>();\n\n  React.useLayoutEffect(() => {\n    const content = contentRef.current;\n    if (!content) return undefined;\n\n    const measure = () => {\n      const nextHeight = content.getBoundingClientRect().height;\n      // jsdom reports zero-sized layout boxes; leaving height automatic there\n      // keeps component tests representative without changing browser behavior.\n      setHeight(nextHeight > 0 ? nextHeight : undefined);\n    };\n\n    measure();\n    if (typeof ResizeObserver === \"undefined\") return undefined;\n\n    const observer = new ResizeObserver(measure);\n    observer.observe(content);\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <div\n      data-testid=\"add-backend-panel-height\"\n      style={height === undefined ? undefined : { height }}\n      className=\"overflow-hidden transition-[height] duration-300 ease-in-out motion-reduce:transition-none\"\n    >\n      <div ref={contentRef}>{children}</div>\n    </div>\n  );\n}\n\n/**\n * Collects every setup instruction for the selected location into one\n * collapsible note.\n *\n * All of the guidance lives here \u2014 the intro, the example command (local) or\n * the host formats to use with a tunnel (remote), and the docs link \u2014 so users\n * never have to leave the modal to find out what belongs in the host field.\n * It starts collapsed so the connection form remains compact; users can\n * reveal the complete instructions without leaving the modal.\n *\n * Open/close uses a `grid-template-rows` transition (`0fr` \u2194 `1fr`) so the\n * height animates without a fixed `max-height` guess. Content stays mounted\n * so the toggle does not remount the docs link on every expand.\n */\nfunction AgentServerGuidance({ location }: { location: AgentServerLocation }) {\n  const { t } = useTranslation(\"openhands\");\n  const [isExpanded, setIsExpanded] = React.useState(false);\n  const isRemote = location === \"remote\";\n  const title = isRemote\n    ? t(I18nKey.BACKEND$REMOTE_SETUP_TITLE)\n    : t(I18nKey.BACKEND$BEFORE_CONNECT_TITLE);\n  const description = isRemote\n    ? t(I18nKey.BACKEND$REMOTE_SETUP_DESCRIPTION)\n    : t(I18nKey.BACKEND$LOCAL_SETUP_DESCRIPTION);\n  const docsHref = isRemote\n    ? REMOTE_AGENT_SERVER_DOCS_URL\n    : LOCAL_AGENT_SERVER_DOCS_URL;\n  const docsLabel = isRemote\n    ? t(I18nKey.BACKEND$REMOTE_SETUP_DOCS)\n    : t(I18nKey.BACKEND$LOCAL_SETUP_DOCS);\n  const testIdRoot = isRemote ? \"add-backend-remote\" : \"add-backend-local\";\n  const toggleId = `${testIdRoot}-guidance-toggle`;\n  const bodyId = `${testIdRoot}-guidance-body`;\n\n  return (\n    <aside\n      data-testid={`${testIdRoot}-guidance`}\n      className=\"rounded-lg bg-surface-raised text-sm text-muted\"\n    >\n      {/* Heading wraps the button so the accordion keeps a real heading in the\n          document outline while the whole row stays clickable. */}\n      <h4 className=\"text-contrast\">\n        <button\n          id={toggleId}\n          type=\"button\"\n          onClick={() => setIsExpanded((expanded) => !expanded)}\n          aria-expanded={isExpanded}\n          aria-controls={bodyId}\n          data-testid={toggleId}\n          className={cn(\n            // Padding lives on the button rather than the card so the hover\n            // fill spans the full row.\n            \"flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-3 py-2 text-left font-medium\",\n            \"transition-colors hover:bg-interactive-hover\",\n            \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus\",\n          )}\n        >\n          <span className=\"flex min-w-0 items-center gap-2\">\n            <Info className=\"size-4 shrink-0 text-muted\" aria-hidden />\n            <span className=\"truncate\">{title}</span>\n          </span>\n          <ChevronDown\n            className={cn(\n              \"size-5 shrink-0 text-muted transition-transform duration-200 ease-out\",\n              isExpanded && \"rotate-180\",\n            )}\n            aria-hidden\n          />\n        </button>\n      </h4>\n\n      {/* Outer grid owns the height animation; the inner overflow clip is\n          required so `1fr` measures the content while `0fr` fully collapses. */}\n      <div\n        className={cn(\n          \"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none\",\n          isExpanded ? \"grid-rows-[1fr]\" : \"grid-rows-[0fr]\",\n        )}\n      >\n        <div className=\"overflow-hidden\">\n          <div\n            id={bodyId}\n            data-testid={bodyId}\n            role=\"region\"\n            aria-labelledby={toggleId}\n            aria-hidden={!isExpanded}\n            inert={!isExpanded ? true : undefined}\n            className={cn(\n              \"flex flex-col gap-2 px-3 pb-3 transition-opacity duration-200 ease-out motion-reduce:transition-none\",\n              isExpanded ? \"opacity-100\" : \"opacity-0\",\n            )}\n          >\n            <p className=\"leading-5\">{description}</p>\n\n            {isRemote ? (\n              <div>\n                <h5 className=\"font-medium text-contrast\">\n                  {t(I18nKey.BACKEND$REMOTE_CONNECTION_TITLE)}\n                </h5>\n                <p className=\"mt-1 leading-5\">\n                  {t(I18nKey.BACKEND$REMOTE_CONNECTION_DESCRIPTION)}\n                </p>\n              </div>\n            ) : (\n              <code className=\"block break-words font-mono text-xs text-contrast\">\n                {LOCAL_BACKEND_COMMAND}\n              </code>\n            )}\n\n            <a\n              href={docsHref}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              data-testid={`${testIdRoot}-docs-link`}\n              tabIndex={isExpanded ? undefined : -1}\n              className=\"inline-flex w-fit items-center gap-1.5 text-primary hover:underline\"\n            >\n              <span>{docsLabel}</span>\n              <ExternalLinkIcon className=\"size-4 shrink-0\" aria-hidden />\n            </a>\n          </div>\n        </div>\n      </div>\n    </aside>\n  );\n}\n\nfunction AddBackendChooser({\n  onConnected,\n  onSelectProvider,\n  source,\n}: {\n  onConnected: (\n    payload: BackendFormSubmitPayload,\n    connectionMethod: BackendConnectionMethod,\n    metadata?: BackendConnectionTestMetadata,\n  ) => void;\n  onSelectProvider: () => void;\n  source: BackendAddedSource;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const [selectedOption, setSelectedOption] =\n    React.useState<AddBackendOption>(\"cloud\");\n  const [agentServerLocation, setAgentServerLocation] =\n    React.useState<AgentServerLocation>(\"local\");\n  const panelId = \"add-backend-selected-panel\";\n  const selectedTabId = `add-backend-option-${selectedOption}-tab`;\n  const isCloudSelected = selectedOption === \"cloud\";\n  const canUseDigitalOcean = getMarsBridge() !== null;\n\n  let panel: React.ReactNode;\n  if (selectedOption === \"providers\") {\n    panel = (\n      <div\n        data-testid=\"add-backend-providers-panel\"\n        className=\"flex flex-col gap-4\"\n      >\n        <button\n          type=\"button\"\n          data-testid=\"add-backend-provider-digitalocean\"\n          onClick={onSelectProvider}\n          className=\"flex w-full cursor-pointer items-center gap-3 rounded-xl border border-border bg-base-secondary px-4 py-3 text-left transition-colors hover:bg-surface-raised focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus\"\n        >\n          <span\n            className=\"flex size-10 shrink-0 items-center justify-center rounded-lg bg-surface-raised\"\n            aria-hidden\n          >\n            <DigitalOceanLogo width={32} height={32} />\n          </span>\n          <span className=\"min-w-0 flex-1\">\n            <span className=\"block truncate text-sm font-medium text-contrast\">\n              {t(I18nKey.DO_AGENTS$BADGE)}\n            </span>\n            <span className=\"mt-0.5 block text-xs leading-tight text-muted\">\n              {t(I18nKey.DO_AGENTS$TAB_DESCRIPTION)}\n            </span>\n          </span>\n          <ChevronRight className=\"size-4 shrink-0 text-muted\" aria-hidden />\n        </button>\n        <p className=\"text-xs text-muted\">\n          {t(I18nKey.BACKEND$PROVIDERS_COMING_SOON)}\n        </p>\n      </div>\n    );\n  } else if (isCloudSelected) {\n    /* Padding keeps the CTA and Advanced host field from hugging the\n       border; when Advanced expands the panel grows with the content\n       instead of squeezing it into the reserved min-height. */\n    panel = (\n      <div\n        data-testid=\"add-backend-cloud-panel\"\n        className={cn(\n          \"relative isolate flex min-h-[13.5rem] w-full items-center justify-center rounded-xl border border-border px-5 py-6\",\n          // A soft greyscale halo sits behind the CTA; -z-10 keeps it under the\n          // content rather than washing over it.\n          \"before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:rounded-xl\",\n          \"before:bg-[radial-gradient(75%_75%_at_50%_50%,rgba(255,255,255,0.1)_0%,rgba(255,255,255,0)_70%)]\",\n        )}\n      >\n        <CloudLoginColumn\n          onConnected={onConnected}\n          testIdRoot=\"add-backend\"\n          analyticsSource={source}\n          showBranding={false}\n        />\n      </div>\n    );\n  } else {\n    panel = (\n      <div\n        data-testid=\"add-backend-agent-server-panel\"\n        className=\"mx-auto w-full max-w-xl\"\n      >\n        {/* Rules on either side center the toggle and read as a\n            divider between the chooser and the connection form. */}\n        <div className=\"flex items-center gap-3\">\n          <span className=\"h-px flex-1 bg-border\" aria-hidden />\n          <SegmentedToggle<AgentServerLocation>\n            value={agentServerLocation}\n            options={[\n              {\n                value: \"local\",\n                label: t(I18nKey.BACKEND$KIND_LOCAL),\n                icon: <Monitor aria-hidden />,\n              },\n              {\n                value: \"remote\",\n                label: t(I18nKey.BACKEND$KIND_REMOTE),\n                icon: <Globe aria-hidden />,\n              },\n            ]}\n            onChange={setAgentServerLocation}\n            ariaLabel={t(I18nKey.BACKEND$AGENT_SERVER_LOCATION)}\n            testId=\"add-backend-location\"\n          />\n          <span className=\"h-px flex-1 bg-border\" aria-hidden />\n        </div>\n\n        <div className=\"mt-4 flex flex-col gap-4\">\n          <AgentServerGuidance\n            key={agentServerLocation}\n            location={agentServerLocation}\n          />\n          <ManualConnectionColumn\n            onConnected={onConnected}\n            testIdRoot=\"add-backend\"\n            requireApiKey={agentServerLocation === \"remote\"}\n            submitLabel={t(I18nKey.BACKEND$CONNECT)}\n            submittingLabel={t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING)}\n            fixedKind=\"local\"\n            showKindSelector={false}\n          />\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div data-testid=\"add-backend-chooser\" className=\"flex flex-col\">\n      <div\n        role=\"tablist\"\n        aria-label={t(I18nKey.BACKEND$CHOOSER_TITLE)}\n        className={cn(\n          \"grid overflow-hidden rounded-lg border border-border\",\n          canUseDigitalOcean ? \"grid-cols-3\" : \"grid-cols-2\",\n        )}\n      >\n        <BackendOptionTab\n          value=\"cloud\"\n          selectedValue={selectedOption}\n          title={t(I18nKey.BACKEND$CLOUD_TITLE)}\n          description={t(I18nKey.BACKEND$CLOUD_OPTION_DESCRIPTION)}\n          icon={\n            <OpenHandsLogoWhite\n              width={32}\n              height={32}\n              data-testid=\"add-backend-option-cloud-logo\"\n              className=\"text-contrast [&_path:not([fill=transparent])]:fill-current\"\n            />\n          }\n          onSelect={setSelectedOption}\n          panelId={panelId}\n          testId=\"add-backend-option-cloud\"\n        />\n        <BackendOptionTab\n          value=\"agent-server\"\n          selectedValue={selectedOption}\n          title={t(I18nKey.BACKEND$AGENT_SERVER_TITLE)}\n          description={t(I18nKey.BACKEND$AGENT_SERVER_OPTION_DESCRIPTION)}\n          icon={<ServerIcon className=\"size-6\" />}\n          onSelect={setSelectedOption}\n          panelId={panelId}\n          testId=\"add-backend-option-agent-server\"\n        />\n        {canUseDigitalOcean ? (\n          <BackendOptionTab\n            value=\"providers\"\n            selectedValue={selectedOption}\n            title={t(I18nKey.BACKEND$PROVIDERS_TITLE)}\n            description={t(I18nKey.BACKEND$PROVIDERS_OPTION_DESCRIPTION)}\n            icon={<CloudCog className=\"size-6\" aria-hidden />}\n            onSelect={setSelectedOption}\n            panelId={panelId}\n            testId=\"add-backend-option-providers\"\n          />\n        ) : null}\n      </div>\n\n      <div className=\"mt-6\">\n        <AnimatedPanelHeight>\n          <section\n            id={panelId}\n            role=\"tabpanel\"\n            aria-labelledby={selectedTabId}\n            className=\"min-w-0\"\n          >\n            {panel}\n          </section>\n        </AnimatedPanelHeight>\n      </div>\n    </div>\n  );\n}\n\nfunction AddBackendConnectionOptions({\n  onClose,\n  source,\n  onSelectProvider,\n}: {\n  onClose: () => void;\n  source: BackendAddedSource;\n  onSelectProvider: () => void;\n}) {\n  const { addBackend } = useActiveBackendContext();\n  // @spec BM-002 \u2014 Adding a backend auto-switches the active selection to it\n  // (BM-001), so leave any detail page that belongs to the previous backend.\n  const redirectAfterAdd = useBackendSwitchRedirect();\n  const { trackBackendAdded } = useTracking();\n  const lockedCloudHost = getLockedCloudHost();\n\n  const handleConnected = React.useCallback(\n    (\n      payload: BackendFormSubmitPayload,\n      connectionMethod: BackendConnectionMethod,\n      metadata?: BackendConnectionTestMetadata,\n    ) => {\n      addBackend(payload);\n      trackBackendAdded({\n        backendKind: payload.kind,\n        connectionMethod,\n        hasApiKey: Boolean(payload.apiKey),\n        source,\n        agentServerVersion: metadata?.agentServerVersion,\n      });\n      redirectAfterAdd();\n      onClose();\n    },\n    [addBackend, redirectAfterAdd, onClose, trackBackendAdded, source],\n  );\n\n  if (lockedCloudHost) {\n    return (\n      <CloudLoginColumn\n        onConnected={handleConnected}\n        testIdRoot=\"add-backend\"\n        lockedHost={lockedCloudHost}\n        analyticsSource={source}\n      />\n    );\n  }\n\n  return (\n    <AddBackendChooser\n      onConnected={handleConnected}\n      onSelectProvider={onSelectProvider}\n      source={source}\n    />\n  );\n}\n\nfunction AddBackendAddModeBody({\n  hideCloseButton,\n  onClose,\n  source,\n}: {\n  hideCloseButton: boolean;\n  onClose: () => void;\n  source: BackendAddedSource;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const [providerOpen, setProviderOpen] = React.useState(false);\n  const railRef = React.useRef<HTMLDivElement>(null);\n\n  React.useEffect(() => {\n    const focusTarget = providerOpen\n      ? \"add-backend-providers-back\"\n      : \"add-backend-provider-digitalocean\";\n    railRef.current\n      ?.querySelector<HTMLButtonElement>(`[data-testid=\"${focusTarget}\"]`)\n      ?.focus();\n  }, [providerOpen]);\n\n  const goBack = () => setProviderOpen(false);\n\n  return (\n    <AnimatedPanelHeight>\n      <div\n        ref={railRef}\n        data-testid=\"add-backend-slide-rail\"\n        className=\"relative overflow-hidden\"\n      >\n        <div\n          aria-hidden={providerOpen}\n          inert={providerOpen ? true : undefined}\n          className={cn(\n            \"w-full transition-transform duration-300 ease-out motion-reduce:transition-none\",\n            providerOpen ? \"-translate-x-full\" : \"translate-x-0\",\n            providerOpen && \"pointer-events-none absolute inset-0\",\n          )}\n        >\n          {hideCloseButton ? null : (\n            <div className=\"pr-8\">\n              <h2 className={modalTitleLgClassName}>\n                {t(I18nKey.BACKEND$CHOOSER_TITLE)}\n              </h2>\n              <p\n                className=\"mt-2 text-sm leading-6 text-muted\"\n                data-testid=\"add-backend-description\"\n              >\n                {t(I18nKey.BACKEND$CHOOSER_DESCRIPTION)}{\" \"}\n                <a\n                  href={DEPLOYMENT_OPTIONS_URL}\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  aria-label={t(I18nKey.BACKEND$DEPLOYMENT_OPTIONS)}\n                  data-testid=\"add-backend-deployment-options-link\"\n                  className=\"text-primary hover:underline\"\n                >\n                  {t(I18nKey.CTA$LEARN_MORE)}\n                  <ExternalLinkIcon\n                    className=\"ml-1 inline size-3.5 align-[-0.125em]\"\n                    aria-hidden\n                  />\n                </a>\n              </p>\n            </div>\n          )}\n\n          <div className={hideCloseButton ? undefined : \"mt-5\"}>\n            <AddBackendConnectionOptions\n              onClose={onClose}\n              source={source}\n              onSelectProvider={() => setProviderOpen(true)}\n            />\n          </div>\n        </div>\n        <div\n          aria-hidden={!providerOpen}\n          inert={!providerOpen ? true : undefined}\n          className={cn(\n            \"w-full transition-transform duration-300 ease-out motion-reduce:transition-none\",\n            providerOpen ? \"translate-x-0\" : \"translate-x-full\",\n            !providerOpen && \"pointer-events-none absolute inset-0\",\n          )}\n        >\n          {providerOpen ? (\n            <div className=\"flex flex-col gap-4\">\n              <BackNavButton\n                onClick={goBack}\n                testId=\"add-backend-providers-back\"\n              >\n                {t(I18nKey.BUTTON$BACK)}\n              </BackNavButton>\n              <div className=\"flex min-w-0 items-center gap-3 pr-8\">\n                <span\n                  className=\"flex size-10 shrink-0 items-center justify-center rounded-lg bg-surface-raised\"\n                  aria-hidden\n                >\n                  <DigitalOceanLogo width={32} height={32} />\n                </span>\n                <h2 className={modalTitleLgMediumClassName}>\n                  {t(I18nKey.DO_AGENTS$BADGE)}\n                </h2>\n              </div>\n              <DigitalOceanConnectPanel onConnected={onClose} />\n            </div>\n          ) : null}\n        </div>\n      </div>\n    </AnimatedPanelHeight>\n  );\n}\n\n// \u2500\u2500 Modal wrappers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Modal wrapper. In **add** mode it renders a two-column layout\n * (manual connection | OR | Cloud login). In **edit** mode it wraps\n * the standard `BackendForm`.\n */\nexport function BackendFormModal({\n  mode,\n  backend,\n  onClose,\n  source = \"add_backend_modal\",\n  hideCloseButton = false,\n}: BackendFormModalProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  if (mode === \"add\") {\n    return (\n      <ModalBackdrop\n        onClose={hideCloseButton ? undefined : onClose}\n        closeOnEscape={false}\n        closeOnBackdropClick={!hideCloseButton}\n        aria-label={t(I18nKey.BACKEND$ADD_TITLE)}\n      >\n        <div\n          data-testid={\n            hideCloseButton ? \"onboarding-modal\" : \"add-backend-modal\"\n          }\n          className={cn(\n            \"relative max-h-[92vh] w-180 overflow-x-hidden overflow-y-auto rounded-xl border border-border bg-base-secondary p-6\",\n            MODAL_MAX_WIDTH_VIEWPORT,\n          )}\n        >\n          {hideCloseButton ? null : (\n            <ModalCloseButton onClose={onClose} testId=\"add-backend-close\" />\n          )}\n          <AddBackendAddModeBody\n            hideCloseButton={hideCloseButton}\n            onClose={onClose}\n            source={source}\n          />\n        </div>\n      </ModalBackdrop>\n    );\n  }\n\n  // Edit mode \u2014 keep the header visible while a tall form scrolls.\n  const testIdRoot = \"edit-backend\";\n  return (\n    <ModalBackdrop\n      onClose={onClose}\n      closeOnEscape={false}\n      aria-label={t(I18nKey.BACKEND$EDIT_TITLE)}\n    >\n      <div\n        data-testid={`${testIdRoot}-modal`}\n        className={cn(\n          \"relative max-h-[92dvh] bg-base-secondary p-6 rounded-xl flex flex-col gap-4 border border-border\",\n          modalWidthClassName(\"md\"),\n          MODAL_MAX_WIDTH_VIEWPORT,\n        )}\n      >\n        <ModalCloseButton onClose={onClose} testId={`${testIdRoot}-close`} />\n        <h2 className={cn(\"shrink-0 pr-6\", modalTitleLgClassName)}>\n          {t(I18nKey.BACKEND$EDIT_TITLE)}\n        </h2>\n        <div className=\"min-h-0 overflow-y-auto\">\n          <BackendForm\n            mode=\"edit\"\n            backend={backend}\n            onSubmitted={onClose}\n            testIdRoot={testIdRoot}\n          />\n        </div>\n      </div>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/backends/backend-row.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 122,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 122,
          "endColumn": 136
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { Pencil, RefreshCw, Trash2 } from \"lucide-react\";\n\nimport { type Backend } from \"#/api/backend-registry/types\";\nimport {\n  isCloudBackendLoggedOutHealthError,\n  isInvalidBackendApiKeyHealthError,\n  type BackendHealth,\n} from \"#/hooks/query/use-backends-health\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { BackendStatusDot } from \"./backend-status-dot\";\nimport { BackendVersion } from \"./backend-version\";\nimport { DeviceFlowAuth } from \"./device-flow-auth\";\nimport { getBackendStatusLabel } from \"./backend-status-label\";\nimport { getLockedCloudHost } from \"#/api/agent-server-config\";\n\nconst ROW_ACTION_BUTTON_CLASS =\n  \"inline-flex cursor-pointer items-center justify-center rounded-md p-1 text-muted transition-colors hover:bg-interactive-hover hover:text-contrast\";\n\ninterface BackendRowProps {\n  backend: Backend;\n  health: BackendHealth | undefined;\n  orgLabel?: string;\n  onSelect: () => void;\n  onEdit: () => void;\n  onRemove: () => void;\n  onLogin?: (apiKey: string) => void;\n}\n\nexport function BackendRow({\n  backend,\n  health,\n  orgLabel,\n  onSelect,\n  onEdit,\n  onRemove,\n  onLogin,\n}: BackendRowProps) {\n  const { t } = useTranslation(\"openhands\");\n  const isInvalidApiKey = isInvalidBackendApiKeyHealthError(health?.lastError);\n  const isCloudLoggedOut =\n    backend.kind === \"cloud\" &&\n    isCloudBackendLoggedOutHealthError(health?.lastError);\n  const statusDetail =\n    !isInvalidApiKey &&\n    !isCloudLoggedOut &&\n    health?.isConnected === false &&\n    health.lastError\n      ? health.lastError\n      : null;\n  const statusLabel = isCloudLoggedOut\n    ? t(I18nKey.BACKEND$LOGGED_OUT)\n    : getBackendStatusLabel(t, backend, health);\n  const statusClassName =\n    health?.isConnected === true\n      ? \"text-feedback-success\"\n      : health?.isConnected === false\n        ? \"text-feedback-error\"\n        : \"text-muted\";\n  const dotStatus = isInvalidApiKey ? false : (health?.isConnected ?? null);\n  const lockedCloudHost = getLockedCloudHost();\n  // The host is a tunnel's loopback port, owned by the Managed Agents screen.\n  const isMarsSession = Boolean(backend.marsSessionId);\n  // A MARS session reads offline whenever its tunnel is gone (e.g. after a\n  // restart), and selecting it is what reopens the tunnel \u2014 so it stays\n  // selectable rather than forcing a new session.\n  const canSelect =\n    (health?.isConnected === true || isMarsSession) && !isInvalidApiKey;\n\n  return (\n    <li\n      className=\"flex items-stretch\"\n      data-testid={`manage-backends-row-${backend.name}`}\n    >\n      <button\n        type=\"button\"\n        disabled={!canSelect}\n        onClick={onSelect}\n        className={cn(\n          \"flex min-w-0 flex-1 items-center gap-3 px-3 py-3 text-left\",\n          canSelect\n            ? \"cursor-pointer transition-colors hover:bg-interactive-hover focus-visible:bg-interactive-hover focus-visible:outline-none\"\n            : \"cursor-default\",\n        )}\n      >\n        <BackendStatusDot isConnected={dotStatus} />\n        <div className=\"flex min-w-0 flex-1 flex-col\">\n          <div className=\"flex min-w-0 items-center gap-2\">\n            <span className=\"truncate text-sm text-contrast\">\n              {backend.name}\n            </span>\n            <BackendVersion backend={backend} />\n          </div>\n          {orgLabel ? (\n            <span\n              data-testid={`manage-backends-org-${backend.name}`}\n              className=\"truncate text-xs text-text-secondary\"\n            >\n              {orgLabel}\n            </span>\n          ) : null}\n          <span className=\"truncate text-xs text-muted\">\n            {isMarsSession ? t(I18nKey.DO_AGENTS$HOSTED_SESSION) : backend.host}\n          </span>\n          <span\n            data-testid={`manage-backends-status-${backend.name}`}\n            className={cn(\"truncate text-xs\", statusClassName)}\n          >\n            {statusLabel}\n          </span>\n          {statusDetail ? (\n            <span\n              data-testid={`manage-backends-status-detail-${backend.name}`}\n              title={statusDetail}\n              className=\"text-xs text-feedback-error whitespace-normal break-words\"\n            >\n              {statusDetail}\n            </span>\n          ) : null}\n        </div>\n        <span className=\"px-2 py-1 rounded-full text-[11px] uppercase tracking-wide text-text-tertiary bg-surface border border-border\">\n          {isMarsSession\n            ? t(I18nKey.DO_AGENTS$BADGE)\n            : backend.kind === \"cloud\"\n              ? t(I18nKey.BACKEND$KIND_CLOUD)\n              : t(I18nKey.BACKEND$KIND_LOCAL)}\n        </span>\n      </button>\n      <div className=\"flex shrink-0 items-center gap-2 px-3 py-3\">\n        {isCloudLoggedOut && onLogin ? (\n          <DeviceFlowAuth\n            host={backend.host}\n            onSuccess={onLogin}\n            testIdRoot={`manage-backends-login-${backend.id}`}\n            idleButtonLabel={t(I18nKey.BACKEND$LOG_BACK_IN)}\n            idleButtonContent={\n              <RefreshCw className=\"size-4\" strokeWidth={2} aria-hidden />\n            }\n            className=\"w-auto\"\n            buttonVariant=\"unstyled\"\n            buttonClassName={ROW_ACTION_BUTTON_CLASS}\n            statusDisplay=\"modal\"\n            analyticsSource=\"manage_backends_modal\"\n          />\n        ) : null}\n        {!lockedCloudHost && !isMarsSession && (\n          <button\n            type=\"button\"\n            onClick={onEdit}\n            aria-label={t(I18nKey.BACKEND$EDIT)}\n            data-testid={`manage-backends-edit-${backend.name}`}\n            className={ROW_ACTION_BUTTON_CLASS}\n          >\n            <Pencil aria-hidden className=\"size-4\" strokeWidth={2} />\n          </button>\n        )}\n        {!lockedCloudHost && (\n          <button\n            type=\"button\"\n            onClick={onRemove}\n            aria-label={t(I18nKey.BACKEND$REMOVE)}\n            data-testid={`manage-backends-remove-${backend.name}`}\n            className={ROW_ACTION_BUTTON_CLASS}\n          >\n            <Trash2 aria-hidden className=\"size-4\" strokeWidth={2} />\n          </button>\n        )}\n      </div>\n    </li>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/backends/backend-version.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 34,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 34,
          "endColumn": 162
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { useQuery } from \"@tanstack/react-query\";\n\nimport { ServerClient } from \"@openhands/typescript-client/clients\";\nimport { type Backend } from \"#/api/backend-registry/types\";\nimport { getAgentServerClientOptions } from \"#/api/agent-server-client-options\";\nimport { getDisplayAgentServerVersion } from \"#/api/agent-server-compatibility\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\nexport function BackendVersion({ backend }: { backend: Backend }) {\n  const { t } = useTranslation(\"openhands\");\n  const { data: version } = useQuery({\n    queryKey: [\"backend-version\", backend.host, backend.apiKey],\n    queryFn: async () => {\n      const info = await new ServerClient(\n        getAgentServerClientOptions({\n          host: backend.host,\n          sessionApiKey: backend.apiKey || null,\n          timeout: 5000,\n        }),\n      ).getServerInfo();\n      return getDisplayAgentServerVersion(info);\n    },\n    retry: false,\n    staleTime: 60_000,\n    enabled: backend.kind === \"local\",\n    meta: { disableToast: true },\n  });\n\n  if (!version) return null;\n\n  return (\n    <span\n      className=\"inline-flex shrink-0 items-center rounded-full border border-border bg-surface px-1.5 py-0.5 text-[10px] font-medium leading-none text-text-dim\"\n      data-testid={`manage-backends-version-${backend.name}`}\n    >\n      {t(I18nKey.BACKEND$VERSION_LABEL, { version })}\n    </span>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/backends/environment-switch-overlay.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[2147483646]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 156,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 156,
          "endColumn": 127
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport {\n  getEnvironmentSwitchSnapshot,\n  subscribeEnvironmentSwitch,\n} from \"./environment-switch-store\";\n\n// Re-export the store API so existing call sites and tests that import from\n// this module keep working unchanged.\nexport {\n  ENVIRONMENT_SWITCH_DURATION_MS,\n  ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS,\n  triggerEnvironmentSwitch,\n  dismissEnvironmentSwitch,\n  __resetEnvironmentSwitchOverlayForTests,\n} from \"./environment-switch-store\";\n\nfunction EnvironmentSwitchIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 74.17 22\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n      aria-hidden\n    >\n      <g>\n        <rect\n          x=\"1\"\n          y=\"1\"\n          width=\"20\"\n          height=\"8\"\n          rx=\"2\"\n          ry=\"2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          x=\"1\"\n          y=\"13\"\n          width=\"20\"\n          height=\"8\"\n          rx=\"2\"\n          ry=\"2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          x1=\"5\"\n          y1=\"5\"\n          x2=\"5.01\"\n          y2=\"5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n        />\n        <line\n          x1=\"5\"\n          y1=\"17\"\n          x2=\"5.01\"\n          y2=\"17\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n        />\n      </g>\n      <g>\n        <rect\n          x=\"53.17\"\n          y=\"1\"\n          width=\"20\"\n          height=\"8\"\n          rx=\"2\"\n          ry=\"2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          x=\"53.17\"\n          y=\"13\"\n          width=\"20\"\n          height=\"8\"\n          rx=\"2\"\n          ry=\"2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          x1=\"57.17\"\n          y1=\"5\"\n          x2=\"57.18\"\n          y2=\"5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n        />\n        <line\n          x1=\"57.17\"\n          y1=\"17\"\n          x2=\"57.18\"\n          y2=\"17\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n        />\n      </g>\n      <g>\n        <path\n          d=\"M43.09,7l4,4-4,4\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M27.09,11h20\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M31.09,7l-4,4,4,4\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n    </svg>\n  );\n}\n\nexport function EnvironmentSwitchOverlay() {\n  const { t } = useTranslation(\"openhands\");\n  const { visible, target } = React.useSyncExternalStore(\n    subscribeEnvironmentSwitch,\n    getEnvironmentSwitchSnapshot,\n    getEnvironmentSwitchSnapshot,\n  );\n\n  if (!visible || typeof document === \"undefined\") return null;\n\n  return createPortal(\n    <div\n      data-testid=\"environment-switch-overlay\"\n      data-target={target}\n      className=\"environment-switch-overlay pointer-events-none fixed inset-0 z-[2147483646] flex items-center justify-center\"\n      aria-live=\"polite\"\n      aria-atomic=\"true\"\n    >\n      <div className=\"pointer-events-none flex min-w-70 max-w-105 flex-col items-center gap-2 rounded-xl border border-border bg-surface px-5 py-4 text-foreground shadow-2xl\">\n        <EnvironmentSwitchIcon className=\"mb-2 h-6 w-20 shrink-0 text-foreground\" />\n        <p className=\"text-center text-sm font-medium\">\n          {t(I18nKey.BACKEND$SWITCHING_TO, { environment: target })}\n        </p>\n      </div>\n    </div>,\n    document.body,\n  );\n}\n\n// React.lazy() expects a default export.\nexport default EnvironmentSwitchOverlay;\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/backends/manage-backends-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"h-[80vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 171,
          "column": 41,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 171,
          "endColumn": 51
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[70vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 171,
          "column": 54,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 171,
          "endColumn": 68
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Plus } from \"lucide-react\";\n\nimport { getLockedCloudHost } from \"#/api/agent-server-config\";\nimport { type Backend } from \"#/api/backend-registry/types\";\nimport { getMarsBridge } from \"#/api/mars/mars-tunnel-backend\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { ConfirmationModal } from \"#/components/shared/modals/confirmation-modal\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  modalWidthClassName,\n} from \"#/components/shared/modals/modal-body\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { useActiveBackendContext } from \"#/contexts/active-backend-context\";\nimport { useBackendsHealth } from \"#/hooks/query/use-backends-health\";\nimport { useAllCloudOrganizations } from \"#/hooks/query/use-cloud-organizations\";\nimport { useCloudCurrentUserId } from \"#/hooks/query/use-cloud-current-user-id\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { modalTitleLgClassName } from \"#/utils/modal-classes\";\nimport { BackendFormModal } from \"./backend-form-modal\";\nimport { BackendRow } from \"./backend-row\";\nimport { DeviceFlowAuth } from \"./device-flow-auth\";\nimport { ManagedAgentsView } from \"./managed-agents/managed-agents-view\";\nimport { MarsBackendRemovalModal } from \"./managed-agents/mars-backend-removal-modal\";\nimport { useBackendSwitchRedirect } from \"./use-backend-switch-redirect\";\n\ninterface ManageBackendsModalProps {\n  onClose: () => void;\n  /**\n   * Recovery mode is used by the root unavailable-backend gate. There is no\n   * app shell behind the modal, so dismiss controls would be misleading.\n   */\n  recoveryMode?: boolean;\n  /** Open straight onto the DigitalOcean Managed Agents screen. */\n  initialView?: ManageBackendsView;\n}\n\nexport type ManageBackendsView = \"backends\" | \"managed-agents\";\n\ninterface PendingRemoval {\n  id: string;\n  name: string;\n  marsSessionId?: string;\n}\n\n/**\n * Resolve the connected organization label for a backend row, mirroring the\n * per-org labelling used by the backend selector. Cloud API keys are bound to\n * a single org (legacy keys may expose several, joined here); local backends\n * and unresolved/errored lookups yield no label.\n */\nfunction resolveBackendOrgLabel(\n  backend: Backend,\n  cloudOrgs: ReturnType<typeof useAllCloudOrganizations>,\n  currentUserIds: ReturnType<typeof useCloudCurrentUserId>,\n  personalWorkspaceLabel: string,\n): string | undefined {\n  if (backend.kind !== \"cloud\") return undefined;\n  const entry = cloudOrgs[backend.id];\n  if (!entry || entry.orgs.length === 0) return undefined;\n  const userId = currentUserIds[backend.id]?.userId ?? null;\n  return entry.orgs\n    .map((org) =>\n      !!userId && userId === org.id ? personalWorkspaceLabel : org.name,\n    )\n    .join(\", \");\n}\n\nexport function ManageBackendsModal({\n  onClose,\n  recoveryMode = false,\n  initialView = \"backends\",\n}: ManageBackendsModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { backends, active, removeBackend, setActive, updateBackend } =\n    useActiveBackendContext();\n  const healthByBackendId = useBackendsHealth(backends, {\n    probeDisabledOnce: true,\n  });\n  const cloudOrgs = useAllCloudOrganizations();\n  const currentUserIds = useCloudCurrentUserId();\n  const personalWorkspaceLabel = t(I18nKey.BACKEND$PERSONAL_WORKSPACE);\n  const lockedCloudHost = getLockedCloudHost();\n  const isLockedToCloud = lockedCloudHost !== null;\n  // Cookie-auth (OHE-hosted) backends have no API key to refresh \u2014 re-auth is\n  // the main-app login, so the device-flow reconnect controls don't apply.\n  const isCookieAuthBackend = active.backend.authMode === \"cookie\";\n  const lockedCloudBackend =\n    isLockedToCloud && !isCookieAuthBackend && active.backend.kind === \"cloud\"\n      ? active.backend\n      : null;\n  const lockedCloudReconnectHost =\n    lockedCloudHost ?? lockedCloudBackend?.host ?? \"\";\n  const modalTitle =\n    isLockedToCloud && !isCookieAuthBackend\n      ? t(I18nKey.BACKEND$RECONNECT_CLOUD_TITLE)\n      : t(I18nKey.BACKEND$MANAGE_TITLE);\n  const [pendingRemoval, setPendingRemoval] =\n    React.useState<PendingRemoval | null>(null);\n  const [editingBackend, setEditingBackend] = React.useState<Backend | null>(\n    null,\n  );\n  const [showAddForm, setShowAddForm] = React.useState(false);\n  // The tunnel lives in the Electron main process, so the browser and library\n  // builds have nothing to offer here.\n  const showManagedAgents = getMarsBridge() !== null && !isLockedToCloud;\n  const [view, setView] = React.useState<ManageBackendsView>(\n    showManagedAgents ? initialView : \"backends\",\n  );\n  const handleConfirmRemoval = () => {\n    if (!pendingRemoval) return;\n    removeBackend(pendingRemoval.id);\n    setPendingRemoval(null);\n  };\n\n  const redirectAfterSwitch = useBackendSwitchRedirect();\n\n  const handleSelectBackend = React.useCallback(\n    (backend: Backend) => {\n      if (active.backend.id !== backend.id || active.orgId !== null) {\n        // @spec BM-002 \u2014 Switching backends keeps the user on the same page\n        redirectAfterSwitch();\n        setActive(backend.id);\n      }\n      onClose();\n    },\n    [active.backend.id, active.orgId, onClose, redirectAfterSwitch, setActive],\n  );\n\n  const handleCloudLogin = React.useCallback(\n    (backend: Backend, apiKey: string) => {\n      updateBackend(backend.id, { apiKey });\n    },\n    [updateBackend],\n  );\n\n  const handleLockedCloudReconnect = React.useCallback(\n    (apiKey: string) => {\n      if (!lockedCloudBackend) return;\n      updateBackend(lockedCloudBackend.id, { apiKey });\n    },\n    [lockedCloudBackend, updateBackend],\n  );\n\n  // A modal stacked on top (removal confirmation, Add or Edit form) owns Escape\n  // and outside clicks; closing this modal under it would unmount it, losing a\n  // form draft or swallowing a refused destroy mid-stop.\n  const isClosable =\n    !recoveryMode &&\n    pendingRemoval === null &&\n    editingBackend === null &&\n    !showAddForm;\n\n  return (\n    <>\n      <ModalBackdrop\n        onClose={isClosable ? onClose : undefined}\n        closeOnEscape={isClosable}\n        closeOnBackdropClick={isClosable}\n        aria-label={modalTitle}\n      >\n        <div\n          data-testid=\"manage-backends-modal\"\n          className={cn(\n            \"relative flex flex-col bg-surface border border-border rounded-xl\",\n            modalWidthClassName(view === \"managed-agents\" ? \"xl\" : \"lg\"),\n            MODAL_MAX_WIDTH_VIEWPORT,\n            view === \"managed-agents\" ? \"h-[80vh]\" : \"max-h-[70vh]\",\n          )}\n        >\n          {recoveryMode ? null : (\n            <ModalCloseButton\n              onClose={onClose}\n              testId=\"close-manage-backends-modal\"\n            />\n          )}\n          {view === \"managed-agents\" ? (\n            <ManagedAgentsView\n              onBack={() => setView(\"backends\")}\n              onDone={onClose}\n            />\n          ) : (\n            <>\n              <div className={cn(\"p-5\", !recoveryMode && \"pr-12\")}>\n                <h2 className={modalTitleLgClassName}>{modalTitle}</h2>\n              </div>\n\n              <div className=\"flex min-h-0 flex-1 flex-col px-5\">\n                <div\n                  className=\"flex-1 overflow-auto rounded-md border border-border bg-surface-raised custom-scrollbar-always\"\n                  data-testid=\"manage-backends-list\"\n                >\n                  {backends.length === 0 ? (\n                    <p className=\"px-3 py-6 text-center text-sm text-text-secondary\">\n                      {t(I18nKey.BACKEND$MANAGE_EMPTY)}\n                    </p>\n                  ) : (\n                    <ul className=\"divide-y divide-border\">\n                      {backends.map((backend) => (\n                        <BackendRow\n                          key={backend.id}\n                          backend={backend}\n                          health={healthByBackendId[backend.id]}\n                          orgLabel={resolveBackendOrgLabel(\n                            backend,\n                            cloudOrgs,\n                            currentUserIds,\n                            personalWorkspaceLabel,\n                          )}\n                          onSelect={() => handleSelectBackend(backend)}\n                          onEdit={() => setEditingBackend(backend)}\n                          onRemove={() =>\n                            setPendingRemoval({\n                              id: backend.id,\n                              name: backend.name,\n                              marsSessionId: backend.marsSessionId,\n                            })\n                          }\n                          onLogin={\n                            backend.authMode === \"cookie\"\n                              ? undefined\n                              : (apiKey) => handleCloudLogin(backend, apiKey)\n                          }\n                        />\n                      ))}\n                    </ul>\n                  )}\n                </div>\n              </div>\n\n              <div className=\"flex justify-end gap-2 p-5\">\n                {isLockedToCloud ? (\n                  lockedCloudBackend ? (\n                    <DeviceFlowAuth\n                      host={lockedCloudReconnectHost}\n                      onSuccess={handleLockedCloudReconnect}\n                      testIdRoot=\"manage-backends-reconnect-cloud\"\n                      idleButtonLabel={t(I18nKey.BACKEND$RECONNECT_CLOUD)}\n                      className=\"w-full sm:w-auto\"\n                      buttonClassName=\"w-full sm:w-auto\"\n                      statusDisplay=\"modal\"\n                    />\n                  ) : null\n                ) : (\n                  <BrandButton\n                    type=\"button\"\n                    variant={recoveryMode ? \"primary\" : \"secondary\"}\n                    onClick={() => setShowAddForm(true)}\n                    testId=\"manage-backends-add\"\n                    startContent={<Plus width={14} height={14} />}\n                  >\n                    {t(I18nKey.BACKEND$ADD)}\n                  </BrandButton>\n                )}\n                {recoveryMode ? null : (\n                  <BrandButton\n                    type=\"button\"\n                    variant=\"primary\"\n                    onClick={onClose}\n                    testId=\"manage-backends-done\"\n                  >\n                    {t(I18nKey.HOME$DONE)}\n                  </BrandButton>\n                )}\n              </div>\n            </>\n          )}\n        </div>\n      </ModalBackdrop>\n\n      {showAddForm ? (\n        <BackendFormModal\n          mode=\"add\"\n          source=\"manage_backends_modal\"\n          onClose={() => setShowAddForm(false)}\n        />\n      ) : null}\n\n      {editingBackend ? (\n        <BackendFormModal\n          mode=\"edit\"\n          backend={editingBackend}\n          onClose={() => setEditingBackend(null)}\n        />\n      ) : null}\n\n      {pendingRemoval?.marsSessionId ? (\n        <MarsBackendRemovalModal\n          name={pendingRemoval.name}\n          sessionId={pendingRemoval.marsSessionId}\n          onRemove={handleConfirmRemoval}\n          onCancel={() => setPendingRemoval(null)}\n        />\n      ) : pendingRemoval ? (\n        <ConfirmationModal\n          text={t(I18nKey.BACKEND$REMOVE_CONFIRMATION, {\n            name: pendingRemoval.name,\n          })}\n          onConfirm={handleConfirmRemoval}\n          onCancel={() => setPendingRemoval(null)}\n        />\n      ) : null}\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/backends/managed-agents/digitalocean-connect-panel.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-h-[13.5rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 38,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 38,
          "endColumn": 73
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"focus-visible:outline-blue-300\" uses the raw Tailwind palette. Nearest theme tokens: focus-visible:outline-markdown-alert-note, focus-visible:outline-cool-grey-300. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 40,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 40,
          "endColumn": 275,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "focus-visible:outline-markdown-alert-note"
              },
              "fix": {
                "range": [
                  1201,
                  1473
                ],
                "text": "\"inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted hover:bg-interactive-hover hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-markdown-alert-note disabled:cursor-not-allowed disabled:opacity-40\""
              },
              "desc": "Replace with \"focus-visible:outline-markdown-alert-note\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "focus-visible:outline-cool-grey-300"
              },
              "fix": {
                "range": [
                  1201,
                  1473
                ],
                "text": "\"inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted hover:bg-interactive-hover hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cool-grey-300 disabled:cursor-not-allowed disabled:opacity-40\""
              },
              "desc": "Replace with \"focus-visible:outline-cool-grey-300\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"focus-visible:outline-blue-300\" uses the raw Tailwind palette. Nearest theme tokens: focus-visible:outline-markdown-alert-note, focus-visible:outline-cool-grey-300. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 63,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 63,
          "endColumn": 268,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "focus-visible:outline-markdown-alert-note"
              },
              "fix": {
                "range": [
                  2201,
                  2450
                ],
                "text": "\"flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-left hover:bg-surface-raised focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-markdown-alert-note disabled:cursor-not-allowed disabled:opacity-60\""
              },
              "desc": "Replace with \"focus-visible:outline-markdown-alert-note\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "focus-visible:outline-cool-grey-300"
              },
              "fix": {
                "range": [
                  2201,
                  2450
                ],
                "text": "\"flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-left hover:bg-surface-raised focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cool-grey-300 disabled:cursor-not-allowed disabled:opacity-60\""
              },
              "desc": "Replace with \"focus-visible:outline-cool-grey-300\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"focus-visible:outline-blue-300\" uses the raw Tailwind palette. Nearest theme tokens: focus-visible:outline-markdown-alert-note, focus-visible:outline-cool-grey-300. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 157,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 157,
          "endColumn": 248,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "focus-visible:outline-markdown-alert-note"
              },
              "fix": {
                "range": [
                  5403,
                  5630
                ],
                "text": "\"flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-lg px-2 py-2.5 text-left hover:bg-surface-raised focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-markdown-alert-note disabled:cursor-not-allowed\""
              },
              "desc": "Replace with \"focus-visible:outline-markdown-alert-note\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "focus-visible:outline-cool-grey-300"
              },
              "fix": {
                "range": [
                  5403,
                  5630
                ],
                "text": "\"flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-lg px-2 py-2.5 text-left hover:bg-surface-raised focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cool-grey-300 disabled:cursor-not-allowed\""
              },
              "desc": "Replace with \"focus-visible:outline-cool-grey-300\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"focus-visible:outline-blue-300\" uses the raw Tailwind palette. Nearest theme tokens: focus-visible:outline-markdown-alert-note, focus-visible:outline-cool-grey-300. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 332,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 332,
          "endColumn": 302,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "focus-visible:outline-markdown-alert-note"
              },
              "fix": {
                "range": [
                  11209,
                  11498
                ],
                "text": "\"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-muted hover:bg-interactive-hover hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-markdown-alert-note disabled:cursor-not-allowed disabled:opacity-40\""
              },
              "desc": "Replace with \"focus-visible:outline-markdown-alert-note\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "focus-visible:outline-cool-grey-300"
              },
              "fix": {
                "range": [
                  11209,
                  11498
                ],
                "text": "\"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-muted hover:bg-interactive-hover hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cool-grey-300 disabled:cursor-not-allowed disabled:opacity-40\""
              },
              "desc": "Replace with \"focus-visible:outline-cool-grey-300\"."
            }
          ]
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 5,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ChevronRight, Loader2, Plus, RefreshCw } from \"lucide-react\";\n\nimport {\n  getMarsErrorMessage,\n  getSessionPhase,\n  isConnectableSession,\n  type MarsAgentConfig,\n  type MarsSession,\n  type NewMarsAgentInput,\n} from \"#/api/mars/mars-tunnel-backend\";\nimport {\n  getUsableConnectionId,\n  useMarsAgents,\n  useMarsAuthState,\n  type MarsAgentGroup,\n} from \"#/hooks/query/use-mars-agents\";\nimport { useLaunchMarsSession } from \"#/hooks/use-launch-mars-session\";\nimport { useMarsSignIn } from \"#/hooks/use-mars-sign-in\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { DigitalOceanNewAgentForm } from \"./digitalocean-new-agent-form\";\nimport {\n  agentLabel,\n  SESSION_COUNT_I18N_KEY,\n  STAGE_LABEL_KEY,\n} from \"./managed-agents-labels\";\nimport { ManagedAgentsSignIn } from \"./managed-agents-sign-in\";\nimport { SessionStatusDot } from \"./session-status-dot\";\nimport {\n  getConnectStage,\n  useSessionActivityLabel,\n  useSessionMeta,\n} from \"./use-session-meta\";\n\nconst PANEL_CLASS =\n  \"flex min-h-[13.5rem] w-full flex-col rounded-xl border border-border\";\nconst ICON_BUTTON_CLASS =\n  \"inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted hover:bg-interactive-hover hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-300 disabled:cursor-not-allowed disabled:opacity-40\";\n\ntype Launcher = ReturnType<typeof useLaunchMarsSession>;\n\ninterface SessionChildRowProps {\n  session: MarsSession;\n  launcher: Launcher;\n  onOpen: () => void;\n}\n\nfunction SessionChildRow({ session, launcher, onOpen }: SessionChildRowProps) {\n  const { t } = useTranslation(\"openhands\");\n  const stage = getConnectStage(launcher.connecting, session);\n  const { name, phase, meta, stageLabel } = useSessionMeta(session, stage);\n\n  return (\n    <li>\n      <button\n        type=\"button\"\n        onClick={onOpen}\n        disabled={launcher.isBusy || !isConnectableSession(session)}\n        aria-label={t(I18nKey.DO_AGENTS$OPEN_AGENT, { name })}\n        data-testid={`digitalocean-session-${session.session_id}`}\n        className=\"flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-left hover:bg-surface-raised focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-300 disabled:cursor-not-allowed disabled:opacity-60\"\n      >\n        <SessionStatusDot phase={phase} className=\"size-1.5\" />\n        <span className=\"min-w-0 flex-1 truncate text-xs text-white\">\n          {name}\n        </span>\n        <span className=\"shrink-0 truncate text-xs tabular-nums text-muted\">\n          {stageLabel ?? meta}\n        </span>\n        {stage ? (\n          <Loader2\n            className=\"size-3.5 shrink-0 animate-spin text-muted\"\n            aria-hidden\n          />\n        ) : null}\n      </button>\n    </li>\n  );\n}\n\ninterface AgentRowProps {\n  group: MarsAgentGroup;\n  launcher: Launcher;\n  error: string | null;\n  onOpen: () => void;\n  onOpenSession: (session: MarsSession) => void;\n  onNewSession: () => void;\n}\n\n/** An agent with its sessions nested beneath it, collapsed by default. */\nfunction AgentRow({\n  group,\n  launcher,\n  error,\n  onOpen,\n  onOpenSession,\n  onNewSession,\n}: AgentRowProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [isExpanded, setIsExpanded] = React.useState(false);\n  const { config, sessions } = group;\n  const name = agentLabel(config);\n  const latest = sessions[0];\n  const activity = useSessionActivityLabel(latest);\n  const sessionIds = new Set(sessions.map((s) => s.session_id));\n  const stage =\n    launcher.connecting && sessionIds.has(launcher.connecting.sessionId)\n      ? launcher.connecting.stage\n      : null;\n  const isLaunching = launcher.launchingConfigId === config.id;\n  const isWorking = isLaunching || stage !== null;\n  const sessionsId = `digitalocean-agent-sessions-${config.id}`;\n\n  let meta: string;\n  if (isLaunching) meta = t(I18nKey.BACKEND$DIGITALOCEAN_CREATING_SESSION);\n  else if (stage) meta = t(STAGE_LABEL_KEY[stage]);\n  else if (!latest) meta = t(I18nKey.DO_AGENTS$NO_SESSIONS_SHORT);\n  else {\n    meta = [t(SESSION_COUNT_I18N_KEY, { count: sessions.length }), activity]\n      .filter(Boolean)\n      .join(\" \u00b7 \");\n  }\n\n  return (\n    <li data-testid={`digitalocean-agent-${config.id}`}>\n      <div className=\"flex items-center gap-1 pl-1 pr-2\">\n        <button\n          type=\"button\"\n          onClick={() => setIsExpanded((value) => !value)}\n          aria-expanded={isExpanded}\n          aria-controls={sessionsId}\n          aria-label={t(\n            isExpanded\n              ? I18nKey.DO_AGENTS$HIDE_SESSIONS\n              : I18nKey.DO_AGENTS$SHOW_SESSIONS,\n            { name },\n          )}\n          data-testid={`digitalocean-agent-toggle-${config.id}`}\n          className={cn(ICON_BUTTON_CLASS, \"size-6\")}\n        >\n          <ChevronRight\n            className={cn(\n              \"size-4 transition-transform duration-150\",\n              isExpanded && \"rotate-90\",\n            )}\n            aria-hidden\n          />\n        </button>\n        <button\n          type=\"button\"\n          onClick={onOpen}\n          disabled={launcher.isBusy}\n          aria-label={t(I18nKey.DO_AGENTS$OPEN_AGENT, { name })}\n          data-testid={`digitalocean-agent-open-${config.id}`}\n          className=\"flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-lg px-2 py-2.5 text-left hover:bg-surface-raised focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-300 disabled:cursor-not-allowed\"\n        >\n          <span\n            aria-hidden\n            className=\"relative flex size-8 shrink-0 items-center justify-center rounded-md bg-surface-raised text-sm font-medium text-white\"\n          >\n            {name.charAt(0).toUpperCase()}\n            {latest ? (\n              <SessionStatusDot\n                phase={getSessionPhase(latest.status)}\n                className=\"absolute -bottom-0.5 -right-0.5 ring-2 ring-surface\"\n              />\n            ) : null}\n          </span>\n          <span className=\"flex min-w-0 flex-1 flex-col\">\n            <span className=\"truncate text-sm text-white\">{name}</span>\n            <span className=\"truncate text-xs tabular-nums text-muted\">\n              {meta}\n            </span>\n          </span>\n          {isWorking ? (\n            <Loader2\n              className=\"size-4 shrink-0 animate-spin text-muted\"\n              aria-hidden\n            />\n          ) : null}\n        </button>\n        <button\n          type=\"button\"\n          onClick={onNewSession}\n          disabled={launcher.isBusy}\n          aria-label={t(I18nKey.DO_AGENTS$NEW_SESSION_FOR, { name })}\n          title={t(I18nKey.DO_AGENTS$NEW_SESSION)}\n          data-testid={`digitalocean-agent-new-${config.id}`}\n          className={ICON_BUTTON_CLASS}\n        >\n          <Plus className=\"size-4\" aria-hidden />\n        </button>\n      </div>\n      {error ? (\n        <p\n          role=\"alert\"\n          data-testid={`digitalocean-agent-error-${config.id}`}\n          className=\"mx-3 mb-2 text-pretty text-xs text-status-error\"\n        >\n          {error}\n        </p>\n      ) : null}\n      {isExpanded ? (\n        <ul\n          id={sessionsId}\n          className=\"mb-1 ml-14 mr-2 flex flex-col border-l border-border pl-2\"\n        >\n          {sessions.length === 0 ? (\n            <li className=\"px-2 py-1.5 text-xs text-muted\">\n              {t(I18nKey.DO_AGENTS$NO_SESSIONS_SHORT)}\n            </li>\n          ) : (\n            sessions.map((session) => (\n              <SessionChildRow\n                key={session.session_id}\n                session={session}\n                launcher={launcher}\n                onOpen={() => onOpenSession(session)}\n              />\n            ))\n          )}\n        </ul>\n      ) : null}\n    </li>\n  );\n}\n\ninterface DigitalOceanConnectPanelProps {\n  /** A session is open and the user is now in its conversation. */\n  onConnected: () => void;\n}\n\n/**\n * The \"DigitalOcean\" option in the connect chooser: sign in once, then pick\n * one of the team's OpenHands agents (or create one) to open. Each agent\n * lists its sessions beneath it; opening the agent itself resumes its latest\n * session (or starts its first).\n */\nexport function DigitalOceanConnectPanel({\n  onConnected,\n}: DigitalOceanConnectPanelProps) {\n  const { t } = useTranslation(\"openhands\");\n  const authQuery = useMarsAuthState();\n  const authState = authQuery.data;\n  const connectionId = getUsableConnectionId(authState);\n  const agentsQuery = useMarsAgents(connectionId);\n  const signIn = useMarsSignIn();\n  const launcher = useLaunchMarsSession();\n  const [failure, setFailure] = React.useState<{\n    configId: string;\n    message: string;\n  } | null>(null);\n  const [isNewAgentOpen, setIsNewAgentOpen] = React.useState(false);\n  const [newAgentError, setNewAgentError] = React.useState<string | null>(null);\n\n  const run = async (configId: string, action: () => Promise<unknown>) => {\n    setFailure(null);\n    try {\n      await action();\n      onConnected();\n    } catch (error) {\n      setFailure({ configId, message: launcher.describeError(error) });\n    }\n  };\n\n  const createAgent = async (input: NewMarsAgentInput) => {\n    setNewAgentError(null);\n    let config: MarsAgentConfig;\n    try {\n      config = await launcher.createAgent(input);\n    } catch (error) {\n      setNewAgentError(launcher.describeError(error));\n      return;\n    }\n    setIsNewAgentOpen(false);\n    await run(config.id, () => launcher.launch(config));\n  };\n\n  const closeNewAgent = () => {\n    setIsNewAgentOpen(false);\n    setNewAgentError(null);\n  };\n\n  if (authQuery.isPending) {\n    return (\n      <div className={cn(PANEL_CLASS, \"items-center justify-center\")}>\n        <Loader2 className=\"size-5 animate-spin text-muted\" aria-hidden />\n      </div>\n    );\n  }\n\n  if (!connectionId) {\n    return (\n      <div\n        data-testid=\"digitalocean-connect-panel\"\n        className={cn(PANEL_CLASS, \"items-center justify-center px-5\")}\n      >\n        <ManagedAgentsSignIn\n          showBranding={false}\n          canUseOAuth={authState?.canUseOAuth ?? false}\n          isExpired={authState?.active?.isExpired === true}\n          isSigningIn={signIn.isSigningIn}\n          isSavingToken={signIn.isSavingToken}\n          error={signIn.error}\n          onSignInWithOAuth={signIn.signInWithOAuth}\n          onSaveToken={signIn.saveToken}\n        />\n      </div>\n    );\n  }\n\n  const groups = agentsQuery.data?.groups ?? [];\n  const active = authState?.active;\n\n  return (\n    <div data-testid=\"digitalocean-connect-panel\" className={PANEL_CLASS}>\n      <div className=\"flex items-center gap-2 px-4 pb-2 pt-3\">\n        <h3 className=\"min-w-0 flex-1 truncate text-sm font-medium text-white\">\n          {t(I18nKey.DO_AGENTS$AGENTS_HEADING)}\n        </h3>\n        <button\n          type=\"button\"\n          onClick={() =>\n            isNewAgentOpen ? closeNewAgent() : setIsNewAgentOpen(true)\n          }\n          disabled={launcher.isBusy}\n          aria-expanded={isNewAgentOpen}\n          data-testid=\"digitalocean-new-agent\"\n          className={cn(\n            \"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-muted hover:bg-interactive-hover hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-300 disabled:cursor-not-allowed disabled:opacity-40\",\n            isNewAgentOpen && \"bg-interactive-hover text-white\",\n          )}\n        >\n          <Plus className=\"size-4\" aria-hidden />\n          {t(I18nKey.DO_AGENTS$NEW_AGENT)}\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => void agentsQuery.refetch()}\n          aria-label={t(I18nKey.BACKEND$DIGITALOCEAN_AGENTS_REFRESH)}\n          title={t(I18nKey.BACKEND$DIGITALOCEAN_AGENTS_REFRESH)}\n          data-testid=\"digitalocean-agents-refresh\"\n          className={ICON_BUTTON_CLASS}\n        >\n          <RefreshCw\n            className={cn(\"size-4\", agentsQuery.isFetching && \"animate-spin\")}\n            aria-hidden\n          />\n        </button>\n      </div>\n\n      {isNewAgentOpen ? (\n        <DigitalOceanNewAgentForm\n          isCreating={launcher.isCreatingAgent}\n          error={newAgentError}\n          onSubmit={(input) => void createAgent(input)}\n          onCancel={closeNewAgent}\n        />\n      ) : null}\n\n      <div className=\"max-h-72 min-h-0 flex-1 overflow-y-auto px-1 custom-scrollbar-always\">\n        {agentsQuery.isPending ? (\n          <p className=\"px-3 py-6 text-center text-sm text-muted\">\n            {t(I18nKey.BACKEND$DIGITALOCEAN_AGENTS_LOADING)}\n          </p>\n        ) : null}\n        {agentsQuery.error ? (\n          <p\n            role=\"alert\"\n            className=\"mx-3 my-2 text-pretty text-sm text-status-error\"\n          >\n            {getMarsErrorMessage(agentsQuery.error) ??\n              t(I18nKey.BACKEND$DIGITALOCEAN_AGENTS_FAILED)}\n          </p>\n        ) : null}\n        {agentsQuery.data && groups.length === 0 && !isNewAgentOpen ? (\n          <p\n            data-testid=\"digitalocean-agents-empty\"\n            className=\"mx-auto max-w-sm px-3 py-6 text-center text-pretty text-sm text-muted\"\n          >\n            {t(I18nKey.DO_AGENTS$NO_OPENHANDS_AGENTS)}\n          </p>\n        ) : null}\n        {groups.length > 0 ? (\n          <ul className=\"flex flex-col\">\n            {groups.map((group) => (\n              <AgentRow\n                key={group.config.id}\n                group={group}\n                launcher={launcher}\n                error={\n                  failure?.configId === group.config.id ? failure.message : null\n                }\n                onOpen={() =>\n                  void run(group.config.id, () => launcher.openAgent(group))\n                }\n                onOpenSession={(session) =>\n                  void run(group.config.id, () =>\n                    launcher.open(session, group.config),\n                  )\n                }\n                onNewSession={() =>\n                  void run(group.config.id, () => launcher.launch(group.config))\n                }\n              />\n            ))}\n          </ul>\n        ) : null}\n      </div>\n\n      <div className=\"mt-2 flex items-center gap-3 border-t border-border px-4 py-2.5 text-xs\">\n        <span\n          aria-hidden\n          className=\"size-1.5 shrink-0 rounded-full bg-status-success\"\n        />\n        <span\n          data-testid=\"digitalocean-account\"\n          className=\"min-w-0 flex-1 truncate text-muted\"\n        >\n          {active?.teamName ?? active?.label}\n        </span>\n        <button\n          type=\"button\"\n          onClick={signIn.signOut}\n          disabled={signIn.isSigningOut}\n          data-testid=\"digitalocean-sign-out\"\n          className=\"shrink-0 cursor-pointer text-muted hover:text-white disabled:cursor-not-allowed\"\n        >\n          {t(I18nKey.BACKEND$DIGITALOCEAN_SIGN_OUT)}\n        </button>\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/backends/managed-agents/managed-agents-session-row.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 90,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 90,
          "endColumn": 76
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 111,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 111,
          "endColumn": 131
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { Loader2, Pause, Square, Unplug } from \"lucide-react\";\n\nimport {\n  isConnectableSession,\n  type MarsSession,\n} from \"#/api/mars/mars-tunnel-backend\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport type { MarsConnectStage } from \"#/hooks/use-connect-mars-session\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { shortSessionId } from \"./managed-agents-labels\";\nimport { SessionStatusDot } from \"./session-status-dot\";\nimport { useSessionMeta } from \"./use-session-meta\";\n\nconst ICON_BUTTON_CLASS =\n  \"inline-flex size-8 cursor-pointer items-center justify-center rounded-md text-muted transition-colors hover:bg-interactive-hover hover:text-white disabled:cursor-not-allowed disabled:opacity-40\";\n\ninterface ManagedAgentsSessionRowProps {\n  session: MarsSession;\n  isConnected: boolean;\n  /** Set while this row's connect flow is running. */\n  connectStage: MarsConnectStage | null;\n  /** Another row is connecting or this row has a pause or stop in flight. */\n  isBusy: boolean;\n  error: string | null;\n  onConnect: () => void;\n  onOpen: () => void;\n  onDisconnect: () => void;\n  onPause: () => void;\n  /** Ends the session for good; the parent confirms before calling. */\n  onStop: () => void;\n}\n\nexport function ManagedAgentsSessionRow({\n  session,\n  isConnected,\n  connectStage,\n  isBusy,\n  error,\n  onConnect,\n  onOpen,\n  onDisconnect,\n  onPause,\n  onStop,\n}: ManagedAgentsSessionRowProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { name, phase, meta, stageLabel } = useSessionMeta(\n    session,\n    connectStage,\n  );\n  const isConnecting = connectStage !== null;\n  const canConnect = isConnectableSession(session);\n  // Anything not already over can be stopped, including a failed or stuck\n  // provisioning session whose sandbox still needs tearing down.\n  const canStop = phase !== \"ended\";\n  const stopButton = canStop ? (\n    <button\n      type=\"button\"\n      onClick={onStop}\n      disabled={isBusy}\n      aria-label={t(I18nKey.DO_AGENTS$STOP)}\n      title={t(I18nKey.DO_AGENTS$STOP)}\n      data-testid={`managed-agents-stop-${session.session_id}`}\n      className={ICON_BUTTON_CLASS}\n    >\n      <Square className=\"size-4\" aria-hidden />\n    </button>\n  ) : null;\n\n  return (\n    <li\n      data-testid={`managed-agents-session-${session.session_id}`}\n      className={cn(\n        \"flex flex-col gap-1 border-l-2 px-3 py-2.5\",\n        isConnected ? \"border-l-primary\" : \"border-l-transparent\",\n      )}\n    >\n      <div className=\"flex items-center gap-3\">\n        <SessionStatusDot phase={phase} />\n        <div className=\"flex min-w-0 flex-1 flex-col\">\n          <span className=\"truncate text-sm text-white\" title={name}>\n            {name}\n          </span>\n          <span\n            className=\"truncate text-xs text-muted\"\n            data-testid={`managed-agents-session-status-${session.session_id}`}\n          >\n            {meta}\n            <span className=\"ml-2 font-mono text-[11px] text-text-tertiary\">\n              {shortSessionId(session)}\n            </span>\n          </span>\n        </div>\n\n        <div className=\"flex shrink-0 items-center gap-1.5\">\n          {isConnecting ? (\n            <span\n              role=\"status\"\n              aria-live=\"polite\"\n              data-testid={`managed-agents-connecting-${session.session_id}`}\n              className=\"flex items-center gap-2 text-xs text-muted\"\n            >\n              <Loader2 className=\"size-4 animate-spin\" aria-hidden />\n              {stageLabel}\n            </span>\n          ) : null}\n\n          {!isConnecting && isConnected ? (\n            <>\n              <span className=\"rounded-full border border-primary/40 px-2 py-0.5 text-[11px] uppercase tracking-wide text-primary\">\n                {t(I18nKey.DO_AGENTS$CONNECTED)}\n              </span>\n              <button\n                type=\"button\"\n                onClick={onDisconnect}\n                disabled={isBusy}\n                aria-label={t(I18nKey.BUTTON$DISCONNECT)}\n                title={t(I18nKey.BUTTON$DISCONNECT)}\n                data-testid={`managed-agents-disconnect-${session.session_id}`}\n                className={ICON_BUTTON_CLASS}\n              >\n                <Unplug className=\"size-4\" aria-hidden />\n              </button>\n              {stopButton}\n              <BrandButton\n                type=\"button\"\n                variant=\"secondary\"\n                onClick={onOpen}\n                testId={`managed-agents-open-${session.session_id}`}\n                className=\"h-8\"\n              >\n                {t(I18nKey.DO_AGENTS$OPEN)}\n              </BrandButton>\n            </>\n          ) : null}\n\n          {!isConnecting && !isConnected && canConnect ? (\n            <>\n              {phase === \"ready\" ? (\n                <button\n                  type=\"button\"\n                  onClick={onPause}\n                  disabled={isBusy}\n                  aria-label={t(I18nKey.DO_AGENTS$PAUSE)}\n                  title={t(I18nKey.DO_AGENTS$PAUSE)}\n                  data-testid={`managed-agents-pause-${session.session_id}`}\n                  className={ICON_BUTTON_CLASS}\n                >\n                  <Pause className=\"size-4\" aria-hidden />\n                </button>\n              ) : null}\n              {stopButton}\n              <BrandButton\n                type=\"button\"\n                variant=\"primary\"\n                onClick={onConnect}\n                isDisabled={isBusy}\n                testId={`managed-agents-connect-${session.session_id}`}\n                className=\"h-8\"\n              >\n                {phase === \"paused\"\n                  ? t(I18nKey.DO_AGENTS$RESUME_AND_CONNECT)\n                  : t(I18nKey.BACKEND$CONNECT)}\n              </BrandButton>\n            </>\n          ) : null}\n\n          {/* The main action (Open / Connect) is always rightmost, with Stop\n              just left of it (rendered above), so the destructive action is\n              never mistaken for part of it. */}\n          {!isConnecting && !isConnected && !canConnect ? stopButton : null}\n        </div>\n      </div>\n\n      {error ? (\n        <p\n          role=\"alert\"\n          data-testid={`managed-agents-session-error-${session.session_id}`}\n          className=\"pl-5 text-xs text-status-error whitespace-pre-wrap break-words\"\n        >\n          {error}\n        </p>\n      ) : null}\n    </li>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/canvas-extensions/add-canvas-extension-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[90vw]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 51,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 51,
          "endColumn": 125
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[85vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 51,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 51,
          "endColumn": 125
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { SettingsInput } from \"#/components/features/settings/settings-input\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { useInstallCanvasExtension } from \"#/hooks/mutation/use-manage-canvas-extensions\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { parseGitTreeUrl } from \"#/utils/parse-git-tree-url\";\nimport { cn } from \"#/utils/utils\";\nimport { modalTitleLgClassName } from \"#/utils/modal-classes\";\n\ninterface AddCanvasExtensionModalProps {\n  onClose: () => void;\n}\n\nexport function AddCanvasExtensionModal({\n  onClose,\n}: AddCanvasExtensionModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const install = useInstallCanvasExtension();\n  const [source, setSource] = React.useState(\"\");\n  const [ref, setRef] = React.useState(\"\");\n  const [repoPath, setRepoPath] = React.useState(\"\");\n\n  const trimmedSource = source.trim();\n  const canSubmit = trimmedSource.length > 0 && !install.isPending;\n\n  const handleSubmit = (event: React.FormEvent) => {\n    event.preventDefault();\n    if (!canSubmit) return;\n    const treeUrl = parseGitTreeUrl(trimmedSource);\n    install.mutate(\n      {\n        source: treeUrl?.source ?? trimmedSource,\n        ref: ref.trim() || treeUrl?.ref || null,\n        repo_path: repoPath.trim() || treeUrl?.repoPath || null,\n      },\n      { onSuccess: onClose },\n    );\n  };\n\n  return (\n    <ModalBackdrop\n      onClose={onClose}\n      aria-label={t(I18nKey.SETTINGS$APPS_ADD_BUTTON)}\n    >\n      <form\n        onSubmit={handleSubmit}\n        data-testid=\"add-canvas-extension-modal\"\n        className=\"relative flex w-130 max-w-[90vw] max-h-[85vh] flex-col rounded-xl border border-border bg-base-secondary\"\n      >\n        <ModalCloseButton\n          onClose={onClose}\n          testId=\"add-canvas-extension-modal-close\"\n        />\n        <header className=\"flex-shrink-0 px-6 pb-4 pt-6\">\n          <h2 className={cn(\"pr-6\", modalTitleLgClassName)}>\n            {t(I18nKey.SETTINGS$APPS_ADD_BUTTON)}\n          </h2>\n          <p className=\"mt-4 text-sm text-tertiary-light\">\n            {t(I18nKey.SETTINGS$APPS_ADD_MODAL_INTRO)}\n          </p>\n        </header>\n        <div className=\"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 custom-scrollbar\">\n          <SettingsInput\n            testId=\"add-canvas-extension-source-input\"\n            label={t(I18nKey.SETTINGS$APPS_SOURCE_LABEL)}\n            type=\"text\"\n            value={source}\n            onChange={setSource}\n            placeholder={t(I18nKey.SETTINGS$PLUGINS_SOURCE_PLACEHOLDER)}\n            showRequiredTag\n          />\n          <SettingsInput\n            testId=\"add-canvas-extension-ref-input\"\n            label={t(I18nKey.SETTINGS$PLUGINS_REF_LABEL)}\n            type=\"text\"\n            value={ref}\n            onChange={setRef}\n            showOptionalTag\n          />\n          <SettingsInput\n            testId=\"add-canvas-extension-repo-path-input\"\n            label={t(I18nKey.SETTINGS$PLUGINS_REPO_PATH_LABEL)}\n            type=\"text\"\n            value={repoPath}\n            onChange={setRepoPath}\n            showOptionalTag\n          />\n        </div>\n        <footer className=\"flex flex-shrink-0 justify-end gap-2 px-6 pb-6 pt-4\">\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={onClose}\n            testId=\"add-canvas-extension-dismiss\"\n          >\n            {t(I18nKey.BUTTON$CLOSE)}\n          </BrandButton>\n          <BrandButton\n            type=\"submit\"\n            variant=\"primary\"\n            testId=\"add-canvas-extension-submit\"\n            isDisabled={!canSubmit}\n          >\n            {t(\n              install.isPending\n                ? I18nKey.SETTINGS$PLUGINS_INSTALLING\n                : I18nKey.SETTINGS$PLUGINS_INSTALL,\n            )}\n          </BrandButton>\n        </footer>\n      </form>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/canvas-extensions/canvas-extension-card.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"grid-cols-[auto_1fr]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 85,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 85,
          "endColumn": 80
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { ToggleSwitch } from \"#/ui/toggle-switch\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type { InstalledCanvasExtensionInfo } from \"#/types/canvas-extension\";\nimport { CanvasExtensionIcon } from \"./canvas-extension-icon\";\nimport {\n  extensionModuleCardPillClassName,\n  extensionModuleCardSurfaceClassName,\n} from \"#/utils/extension-module-card-classes\";\n\ninterface CanvasExtensionCardProps {\n  extension: InstalledCanvasExtensionInfo;\n  isBusy: boolean;\n  onToggle: () => void;\n  onRefresh: () => void;\n  onUninstall: () => void;\n}\n\nexport function CanvasExtensionCard({\n  extension,\n  isBusy,\n  onToggle,\n  onRefresh,\n  onUninstall,\n}: CanvasExtensionCardProps) {\n  const { t } = useTranslation(\"openhands\");\n  const pages = extension.manifest?.contributes?.pages ?? [];\n  const displayName = extension.manifest?.display_name || extension.name;\n\n  return (\n    <article\n      data-testid={`canvas-extension-card-${extension.name}`}\n      className={`flex min-w-0 flex-col gap-4 p-4 ${extensionModuleCardSurfaceClassName}`}\n    >\n      <header className=\"flex items-start justify-between gap-4\">\n        <CanvasExtensionIcon\n          extension={extension}\n          size={20}\n          className=\"mt-0.5 shrink-0\"\n        />\n        <div className=\"min-w-0 flex-1\">\n          <h3 className=\"truncate text-sm font-semibold text-contrast\">\n            {displayName}\n          </h3>\n          <p className=\"mt-0.5 truncate text-xs text-tertiary-alt\">\n            {extension.source}\n          </p>\n        </div>\n        <ToggleSwitch\n          enabled={extension.enabled}\n          label={t(\n            extension.enabled ? I18nKey.COMMON$DISABLE : I18nKey.COMMON$ENABLE,\n          )}\n          onToggle={onToggle}\n          disabled={isBusy}\n          className={isBusy ? \"pointer-events-none opacity-50\" : undefined}\n        />\n      </header>\n\n      {extension.description || extension.manifest?.description ? (\n        <p className=\"text-xs leading-relaxed text-tertiary-light\">\n          {extension.description || extension.manifest?.description}\n        </p>\n      ) : null}\n\n      <div className=\"flex flex-wrap gap-2\">\n        <span className={extensionModuleCardPillClassName}>\n          {extension.enabled\n            ? t(I18nKey.SETTINGS$SKILLS_ENABLED)\n            : t(I18nKey.SETTINGS$SKILLS_DISABLED)}\n        </span>\n        <span className={extensionModuleCardPillClassName}>\n          {t(I18nKey.SETTINGS$SKILLS_VERSION, {\n            version: extension.version,\n          })}\n        </span>\n        {pages.length ? (\n          <span className={extensionModuleCardPillClassName}>\n            {t(I18nKey.SETTINGS$APPS_PAGES)}: {pages.length}\n          </span>\n        ) : null}\n      </div>\n\n      <dl className=\"grid min-w-0 grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs\">\n        {extension.resolved_ref ? (\n          <>\n            <dt className=\"text-tertiary-alt\">\n              {t(I18nKey.SETTINGS$PLUGINS_REF_LABEL)}\n            </dt>\n            <dd className=\"min-w-0 truncate font-mono text-tertiary-light\">\n              {extension.resolved_ref}\n            </dd>\n          </>\n        ) : null}\n        {extension.repo_path ? (\n          <>\n            <dt className=\"text-tertiary-alt\">\n              {t(I18nKey.SETTINGS$PLUGINS_REPO_PATH_LABEL)}\n            </dt>\n            <dd className=\"min-w-0 truncate text-tertiary-light\">\n              {extension.repo_path}\n            </dd>\n          </>\n        ) : null}\n      </dl>\n\n      {pages.length ? (\n        <section className=\"flex min-w-0 flex-col gap-2\">\n          <ul className=\"flex flex-wrap gap-2\">\n            {pages.map((page) => (\n              <li key={page.id} className={extensionModuleCardPillClassName}>\n                {page.title}\n              </li>\n            ))}\n          </ul>\n        </section>\n      ) : null}\n\n      <footer className=\"flex justify-end gap-2 border-t border-border pt-3\">\n        <BrandButton\n          type=\"button\"\n          variant=\"secondary\"\n          testId={`canvas-extension-refresh-${extension.name}`}\n          isDisabled={isBusy}\n          onClick={onRefresh}\n        >\n          {t(I18nKey.SETTINGS$PLUGINS_REFRESH)}\n        </BrandButton>\n        <BrandButton\n          type=\"button\"\n          variant=\"ghost-danger\"\n          testId={`canvas-extension-uninstall-${extension.name}`}\n          isDisabled={isBusy}\n          onClick={onUninstall}\n        >\n          {t(I18nKey.SETTINGS$PLUGINS_UNINSTALL)}\n        </BrandButton>\n      </footer>\n    </article>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/btw-messages.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[40vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 22,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 22,
          "endColumn": 111
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useBtwStore } from \"#/stores/btw-store\";\nimport { GenericEventMessage } from \"./generic-event-message\";\nimport { GotItButton } from \"./got-it-button\";\n\nexport interface BtwMessagesProps {\n  conversationId: string | null | undefined;\n}\n\nexport function BtwMessages({ conversationId }: BtwMessagesProps) {\n  const { t } = useTranslation(\"openhands\");\n  const entriesById = useBtwStore((s) => s.entriesByConversation);\n  const dismiss = useBtwStore((s) => s.dismiss);\n  const entries = conversationId ? (entriesById[conversationId] ?? []) : [];\n\n  if (!conversationId || entries.length === 0) return null;\n\n  return (\n    <div\n      data-testid=\"btw-messages\"\n      className=\"custom-scrollbar-always flex max-h-[40vh] w-full flex-col overflow-y-auto overscroll-contain\"\n    >\n      {entries.map((entry) => {\n        const isPending = entry.status === \"pending\";\n        return (\n          <GenericEventMessage\n            key={entry.id}\n            title={\n              <span className=\"flex items-center gap-2\">\n                <span className=\"opacity-60\">\n                  {t(I18nKey.CHAT_INTERFACE$BTW_PREFIX)}\n                </span>\n                <span>{entry.question}</span>\n                {isPending && (\n                  <span\n                    data-testid=\"btw-spinner\"\n                    className=\"inline-block w-3.5 h-3.5 ml-2 rounded-full border-2 border-transparent border-t-border-input animate-spin\"\n                  />\n                )}\n              </span>\n            }\n            details={\n              isPending\n                ? t(I18nKey.CHAT_INTERFACE$BTW_WAITING_FOR_ANSWER)\n                : (entry.response ?? \"\")\n            }\n            initiallyExpanded={!isPending}\n            chevronPosition=\"before\"\n            titleTrailing={\n              !isPending && (\n                <GotItButton\n                  onClick={() => dismiss(conversationId, entry.id)}\n                />\n              )\n            }\n          />\n        );\n      })}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/change-agent-button.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[100px]\" hardcodes an off-token value. Nearest on the scale: rounded-4xl (32px), rounded-3xl (24px).",
          "line": 168,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 168,
          "endColumn": 46
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[#597FF4]\" hardcodes a color and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css.",
          "line": 172,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryColorFar",
          "endLine": 172,
          "endColumn": 64
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[#4A67BD]\" hardcodes a color. Nearest theme tokens: bg-cool-grey-600. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 172,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 172,
          "endColumn": 64,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-cool-grey-600"
              },
              "fix": {
                "range": [
                  5706,
                  5755
                ],
                "text": "\"border border-[#597FF4] bg-cool-grey-600 text-white\""
              },
              "desc": "Replace with \"bg-cool-grey-600\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"hover:bg-[#597FF4]\" hardcodes a color and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css.",
          "line": 178,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryColorFar",
          "endLine": 178,
          "endColumn": 48
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 188,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 188,
          "endColumn": 97
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 5,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { useMemo, useEffect, useState, useRef } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { Typography } from \"#/ui/typography\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { ComboboxCaretInline } from \"#/ui/combobox-caret\";\nimport LessonPlanIcon from \"#/icons/lesson-plan.svg?react\";\nimport { CodePillIcon } from \"#/icons/code-pill\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport { ChangeAgentContextMenu } from \"./change-agent-context-menu\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  formControlMutedHoverClassName,\n  formControlTransitionClassName,\n} from \"#/utils/form-control-classes\";\nimport { useAgentState } from \"#/hooks/use-agent-state\";\nimport { AgentState } from \"#/types/agent-state\";\nimport { useActiveConversation } from \"#/hooks/query/use-active-conversation\";\nimport { useUnifiedWebSocketStatus } from \"#/hooks/use-unified-websocket-status\";\nimport { useSubConversationTaskPolling } from \"#/hooks/query/use-sub-conversation-task-polling\";\nimport { useHandlePlanClick } from \"#/hooks/use-handle-plan-click\";\nimport { useOptionalConversationId } from \"#/hooks/use-conversation-id\";\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\n\nexport function ChangeAgentButton() {\n  const [contextMenuOpen, setContextMenuOpen] = useState<boolean>(false);\n\n  const { conversationMode, setConversationMode, subConversationTaskId } =\n    useConversationStore();\n\n  const { conversationId } = useOptionalConversationId();\n\n  const isHomePage = !conversationId;\n\n  const webSocketStatus = useUnifiedWebSocketStatus();\n\n  const isWebSocketConnected = webSocketStatus === \"OPEN\";\n\n  const { curAgentState } = useAgentState();\n\n  const { t } = useTranslation(\"openhands\");\n\n  const isAgentRunning = curAgentState === AgentState.RUNNING;\n\n  const { data: conversation } = useActiveConversation();\n\n  const queryClient = useQueryClient();\n\n  // Track the last invalidated task ID to prevent duplicate invalidations\n  const lastInvalidatedTaskIdRef = useRef<string | null>(null);\n\n  // Poll sub-conversation task status\n  const { taskStatus, subConversationId } = useSubConversationTaskPolling(\n    subConversationTaskId,\n    conversation?.id || null,\n  );\n\n  // Invalidate parent conversation cache when task is ready (only once per task)\n  useEffect(() => {\n    if (\n      taskStatus === \"READY\" &&\n      subConversationId &&\n      conversation?.id &&\n      subConversationTaskId &&\n      lastInvalidatedTaskIdRef.current !== subConversationTaskId\n    ) {\n      // Mark this task as invalidated to prevent duplicate calls\n      lastInvalidatedTaskIdRef.current = subConversationTaskId;\n      // Invalidate the parent conversation to refetch with updated sub_conversation_ids\n      queryClient.invalidateQueries({\n        queryKey: [\"user\", \"conversation\", conversation.id],\n      });\n    }\n  }, [\n    taskStatus,\n    subConversationId,\n    conversation?.id,\n    subConversationTaskId,\n    queryClient,\n  ]);\n\n  // Get handlePlanClick and isCreatingConversation from custom hook\n  const { handlePlanClick, isCreatingConversation } = useHandlePlanClick();\n\n  // Close context menu when agent starts running\n  useEffect(() => {\n    if ((isAgentRunning || !isWebSocketConnected) && contextMenuOpen) {\n      setContextMenuOpen(false);\n    }\n  }, [isAgentRunning, contextMenuOpen, isWebSocketConnected]);\n\n  const isButtonDisabled =\n    isHomePage ||\n    isAgentRunning ||\n    isCreatingConversation ||\n    !isWebSocketConnected;\n\n  // Handle Shift + Tab keyboard shortcut to cycle through modes\n  useEffect(() => {\n    if (isButtonDisabled) {\n      return undefined;\n    }\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      // Check for Shift + Tab combination\n      if (event.shiftKey && event.key === \"Tab\") {\n        // Prevent default tab navigation behavior\n        event.preventDefault();\n        event.stopPropagation();\n\n        // Cycle between modes: code -> plan -> code\n        const nextMode = conversationMode === \"code\" ? \"plan\" : \"code\";\n        if (nextMode === \"plan\") {\n          handlePlanClick(event);\n        } else {\n          setConversationMode(nextMode);\n        }\n      }\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [\n    isButtonDisabled,\n    conversationMode,\n    setConversationMode,\n    handlePlanClick,\n  ]);\n\n  const handleButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    setContextMenuOpen(!contextMenuOpen);\n  };\n\n  const handleCodeClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    setConversationMode(\"code\");\n  };\n\n  const isExecutionAgent = conversationMode === \"code\";\n\n  const buttonLabel = useMemo(() => {\n    if (isExecutionAgent) {\n      return t(I18nKey.COMMON$CODE);\n    }\n    return t(I18nKey.COMMON$PLAN);\n  }, [isExecutionAgent, t]);\n\n  const buttonIcon = useMemo(() => {\n    if (isExecutionAgent) {\n      return <CodePillIcon className=\"h-2.75 w-2.75 shrink-0\" />;\n    }\n    return <LessonPlanIcon width={18} height={18} color=\"currentColor\" />;\n  }, [isExecutionAgent]);\n\n  const button = (\n    <div className=\"relative\">\n      <button\n        type=\"button\"\n        onClick={handleButtonClick}\n        disabled={isButtonDisabled}\n        className={cn(\n          \"flex items-center rounded-[100px]\",\n          formControlTransitionClassName,\n          isExecutionAgent\n            ? \"border border-transparent text-muted\"\n            : \"border border-[#597FF4] bg-[#4A67BD] text-white\",\n          !isButtonDisabled &&\n            isExecutionAgent &&\n            cn(\"cursor-pointer\", formControlMutedHoverClassName),\n          !isButtonDisabled &&\n            !isExecutionAgent &&\n            \"cursor-pointer hover:bg-[#597FF4]\",\n          isButtonDisabled &&\n            cn(\n              \"opacity-50 cursor-not-allowed\",\n              isExecutionAgent && \"border-transparent\",\n            ),\n        )}\n      >\n        <div className=\"flex items-center gap-1 pl-1.5\">\n          {buttonIcon}\n          <Typography.Text className=\"text-[11px] text-inherit not-italic font-normal leading-5\">\n            {buttonLabel}\n          </Typography.Text>\n        </div>\n        <ComboboxCaretInline isOpen={contextMenuOpen} />\n      </button>\n      {contextMenuOpen && (\n        <ChangeAgentContextMenu\n          activeMode={conversationMode}\n          onClose={() => setContextMenuOpen(false)}\n          onCodeClick={handleCodeClick}\n          onPlanClick={handlePlanClick}\n        />\n      )}\n    </div>\n  );\n\n  if (isHomePage) {\n    return (\n      <StyledTooltip\n        content={t(I18nKey.CHANGE_AGENT$SWITCH_AFTER_CONVERSATION)}\n        placement=\"top\"\n      >\n        {button}\n      </StyledTooltip>\n    );\n  }\n\n  return button;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/chat-markdown-path-code.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"px-[0.4em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 96,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 96,
          "endColumn": 144
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"py-[0.2em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 96,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 96,
          "endColumn": 144
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { createContext, useContext } from \"react\";\nimport type { ExtraProps } from \"react-markdown\";\nimport ConversationService from \"#/api/conversation-service/conversation-service.api\";\nimport { useOptionalConversationId } from \"#/hooks/use-conversation-id\";\nimport { useWorkspaceFiles } from \"#/hooks/query/use-workspace-files\";\nimport { openWorkspaceFile } from \"#/services/canvas-ui\";\nimport { looksLikeWorkspaceFilePath, toFilesTabPath } from \"#/utils/path-utils\";\nimport { cn } from \"#/utils/utils\";\nimport { code as defaultCode } from \"../markdown/code\";\nimport { anchor as defaultAnchor } from \"../markdown/anchor\";\n\n/** True while rendering Markdown descendants of an existing `<a>`. */\nconst InMarkdownLinkContext = createContext(false);\n\n/**\n * Workspace paths available for chat Markdown linking. Absent outside\n * ChatInterface (e.g. unit tests) so ChatCode never requires QueryClient.\n */\nexport const WorkspaceFilesForChatContext = createContext<string[] | undefined>(\n  undefined,\n);\n\n/** Fetches workspace files once for the chat tree and exposes them to path links. */\nexport function WorkspaceFilesForChatProvider({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const { data } = useWorkspaceFiles();\n  return (\n    <WorkspaceFilesForChatContext.Provider value={data}>\n      {children}\n    </WorkspaceFilesForChatContext.Provider>\n  );\n}\n\ntype CodeProps = React.ClassAttributes<HTMLElement> &\n  React.HTMLAttributes<HTMLElement> &\n  ExtraProps;\n\ntype AnchorProps = React.ClassAttributes<HTMLAnchorElement> &\n  React.AnchorHTMLAttributes<HTMLAnchorElement> &\n  ExtraProps;\n\ntype StrongProps = React.ClassAttributes<HTMLElement> &\n  React.HTMLAttributes<HTMLElement> &\n  ExtraProps;\n\n/** Flatten React children to plain text, or null if mixed nodes. */\nfunction getPlainText(children: React.ReactNode): string | null {\n  if (children == null || typeof children === \"boolean\") return \"\";\n  if (typeof children === \"string\" || typeof children === \"number\") {\n    return String(children);\n  }\n  if (Array.isArray(children)) {\n    const parts = children.map(getPlainText);\n    if (parts.some((part) => part === null)) return null;\n    return parts.join(\"\");\n  }\n  return null;\n}\n\n/**\n * Only link paths that currently exist in the conversation workspace.\n * workingDir comes from ConversationService \u2014 same source as navigate_to_file.\n */\nfunction useExistingWorkspacePath(candidate: string): string | null {\n  const files = useContext(WorkspaceFilesForChatContext);\n  if (!files?.length || !looksLikeWorkspaceFilePath(candidate)) return null;\n\n  const workingDir =\n    ConversationService.getCurrentConversation()?.workspace?.working_dir;\n  const normalized = toFilesTabPath(candidate, workingDir);\n  if (!normalized) return null;\n  return files.includes(normalized) ? normalized : null;\n}\n\nfunction ChatMarkdownPathLink({\n  path,\n  className,\n  children,\n}: {\n  path: string;\n  className?: string;\n  children: React.ReactNode;\n}) {\n  const { conversationId } = useOptionalConversationId();\n\n  return (\n    <button\n      type=\"button\"\n      data-testid=\"markdown-file-path-link\"\n      title={path}\n      className={cn(\n        className,\n        \"cursor-pointer rounded border border-surface-raised bg-surface-raised px-[0.4em] py-[0.2em] font-mono text-foreground hover:underline\",\n      )}\n      onClick={(event) => {\n        event.stopPropagation();\n        openWorkspaceFile(path, conversationId);\n      }}\n    >\n      {children}\n    </button>\n  );\n}\n\n/**\n * Chat-only inline code: existing workspace paths become Files-drawer buttons.\n * Nested under an existing Markdown link \u2192 stay plain `<code>`.\n */\nexport function ChatCode(props: CodeProps) {\n  const { children, className } = props;\n  const inLink = useContext(InMarkdownLinkContext);\n  const match = /language-(\\w+)/.exec(className || \"\");\n  const codeString = String(children).replace(/\\n$/, \"\");\n  const isMultiline = String(children).includes(\"\\n\");\n  const existingPath = useExistingWorkspacePath(\n    !match && !isMultiline && !inLink ? codeString : \"\",\n  );\n\n  if (existingPath) {\n    return (\n      <ChatMarkdownPathLink path={existingPath} className={className}>\n        {children}\n      </ChatMarkdownPathLink>\n    );\n  }\n\n  return defaultCode(props);\n}\n\n/**\n * Agents often emphasize paths with `**file.md**` instead of backticks.\n * Link those only when the whole strong span is an existing workspace file.\n */\nexport function ChatStrong(props: StrongProps) {\n  const { children } = props;\n  const inLink = useContext(InMarkdownLinkContext);\n  const text = getPlainText(children)?.trim() ?? \"\";\n  const existingPath = useExistingWorkspacePath(!inLink ? text : \"\");\n\n  if (existingPath) {\n    return (\n      <ChatMarkdownPathLink path={existingPath}>\n        {children}\n      </ChatMarkdownPathLink>\n    );\n  }\n\n  return <strong>{children}</strong>;\n}\n\n/** Marks descendants so nested path tokens are not turned into a button. */\nexport function ChatAnchor(props: AnchorProps) {\n  return (\n    <InMarkdownLinkContext.Provider value>\n      {defaultAnchor(props)}\n    </InMarkdownLinkContext.Provider>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/chat-message.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[word-break:break-word]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 133,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 133,
          "endColumn": 86
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[#597ff4]\" hardcodes a color and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css.",
          "line": 162,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryColorFar",
          "endLine": 162,
          "endColumn": 57
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[1]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 239,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 239,
          "endColumn": 99
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { cn } from \"#/utils/utils\";\nimport { CopyToClipboardButton } from \"#/components/shared/buttons/copy-to-clipboard-button\";\nimport type { SourceType } from \"#/types/agent-server/core/base/common\";\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { TextShimmer } from \"#/components/shared/text-shimmer\";\nimport { formatEventTimestamp } from \"#/utils/format-event-timestamp\";\nimport { MarkdownRenderer } from \"../markdown/markdown-renderer\";\nimport { PendingStopIcon } from \"./pending-stop-icon\";\nimport {\n  UserMessageBody,\n  chatBubbleMarkdownComponents,\n  USER_MESSAGE_LINE_HEIGHT_PX,\n} from \"./user-message-body\";\n\nexport type ChatMessagePendingStatus = \"sending\" | \"error\";\n\ninterface ChatMessageProps {\n  type: SourceType;\n  message: string;\n  actions?: Array<{\n    icon: React.ReactNode;\n    onClick: () => void;\n    tooltip?: string;\n  }>;\n  isFromPlanningAgent?: boolean;\n  pendingStatus?: ChatMessagePendingStatus;\n  onRetry?: () => void;\n  onDismiss?: () => void;\n  onStop?: () => void;\n  timestamp?: string;\n  /** The message is a reply still being streamed in. */\n  isStreaming?: boolean;\n}\n\nexport function ChatMessage({\n  type,\n  message,\n  children,\n  actions,\n  isFromPlanningAgent = false,\n  pendingStatus,\n  onRetry,\n  onDismiss,\n  onStop,\n  timestamp,\n  isStreaming = false,\n}: React.PropsWithChildren<ChatMessageProps>) {\n  const { t, i18n } = useTranslation(\"openhands\");\n  const [isHovering, setIsHovering] = React.useState(false);\n  const [isCopy, setIsCopy] = React.useState(false);\n  const [isExpanded, setIsExpanded] = React.useState(false);\n  const [isTruncatable, setIsTruncatable] = React.useState(false);\n  const [isSingleLinePendingMessage, setIsSingleLinePendingMessage] =\n    React.useState(true);\n  const pendingMessageContentRef = React.useRef<HTMLDivElement>(null);\n  const timestampLabel = formatEventTimestamp(timestamp, i18n?.language);\n\n  React.useEffect(() => {\n    setIsExpanded(false);\n  }, [message]);\n\n  const handleCopyToClipboard = async () => {\n    await navigator.clipboard.writeText(message);\n    setIsCopy(true);\n  };\n\n  React.useEffect(() => {\n    let timeout: NodeJS.Timeout;\n\n    if (isCopy) {\n      timeout = setTimeout(() => {\n        setIsCopy(false);\n      }, 2000);\n    }\n\n    return () => {\n      clearTimeout(timeout);\n    };\n  }, [isCopy]);\n\n  const isPendingUserMessage =\n    type === \"user\" &&\n    (pendingStatus === \"error\" || pendingStatus === \"sending\");\n  const canStopPendingMessage = pendingStatus === \"sending\" && onStop != null;\n  const showStopButton = canStopPendingMessage && isHovering;\n  const useTruncatedUserBody = type === \"user\" && pendingStatus == null;\n  const isCollapsed = useTruncatedUserBody && isTruncatable && !isExpanded;\n  const hasBubbleChildren = React.Children.count(children) > 0;\n\n  React.useLayoutEffect(() => {\n    if (!canStopPendingMessage || useTruncatedUserBody) {\n      return undefined;\n    }\n\n    const content = pendingMessageContentRef.current;\n    if (!content) {\n      return undefined;\n    }\n\n    const measure = () => {\n      const lineHeight = Number.parseFloat(\n        getComputedStyle(content).lineHeight,\n      );\n      const linePx =\n        Number.isFinite(lineHeight) && lineHeight > 0\n          ? lineHeight\n          : USER_MESSAGE_LINE_HEIGHT_PX;\n\n      setIsSingleLinePendingMessage(content.scrollHeight <= linePx + 1);\n    };\n\n    measure();\n\n    const observer = new ResizeObserver(measure);\n    observer.observe(content);\n\n    return () => observer.disconnect();\n  }, [message, canStopPendingMessage, useTruncatedUserBody]);\n\n  const messageContent = useTruncatedUserBody ? (\n    <UserMessageBody\n      message={message}\n      isHovering={isHovering}\n      isExpanded={isExpanded}\n      onTruncatableChange={setIsTruncatable}\n    />\n  ) : (\n    <div\n      ref={pendingMessageContentRef}\n      className=\"min-w-0 text-sm leading-6 whitespace-normal [word-break:break-word]\"\n    >\n      <MarkdownRenderer\n        includeStandard\n        includeHeadings\n        allowHtml={type !== \"user\"}\n        components={chatBubbleMarkdownComponents}\n        streaming={isStreaming}\n      >\n        {message}\n      </MarkdownRenderer>\n    </div>\n  );\n\n  const renderedMessageContent = messageContent;\n\n  const messageBubble = (\n    <article\n      data-testid={`${type}-message`}\n      data-pending-status={pendingStatus}\n      onMouseEnter={() => setIsHovering(true)}\n      onMouseLeave={() => setIsHovering(false)}\n      className={cn(\n        \"rounded-xl relative w-fit max-w-full flex flex-col\",\n        hasBubbleChildren && \"gap-2\",\n        type === \"user\" && \"mt-6 bg-tertiary self-end px-4 py-2.5\",\n        type === \"agent\" && \"mt-6 w-full max-w-full bg-transparent\",\n        isFromPlanningAgent &&\n          type === \"agent\" &&\n          \"border border-[#597ff4] bg-tertiary p-4 mt-2\",\n        pendingStatus === \"error\" && \"border border-status-error/40\",\n        !isPendingUserMessage && \"last:mb-4\",\n      )}\n    >\n      <div\n        className={cn(\n          \"absolute -top-2.5 -right-2.5 z-10\",\n          !isHovering || pendingStatus === \"sending\" ? \"hidden\" : \"flex\",\n          \"items-center gap-1\",\n        )}\n        onClick={(event) => event.stopPropagation()}\n      >\n        {actions?.map((action, index) =>\n          action.tooltip ? (\n            <StyledTooltip key={index} content={action.tooltip} placement=\"top\">\n              <button\n                type=\"button\"\n                onClick={action.onClick}\n                className=\"button-base p-1 cursor-pointer\"\n                aria-label={action.tooltip}\n              >\n                {action.icon}\n              </button>\n            </StyledTooltip>\n          ) : (\n            <button\n              key={index}\n              type=\"button\"\n              onClick={action.onClick}\n              className=\"button-base p-1 cursor-pointer\"\n              aria-label={`Action ${index + 1}`}\n            >\n              {action.icon}\n            </button>\n          ),\n        )}\n\n        <CopyToClipboardButton\n          isHidden={!isHovering}\n          isDisabled={isCopy}\n          onClick={handleCopyToClipboard}\n          mode={isCopy ? \"copied\" : \"copy\"}\n        />\n      </div>\n\n      {renderedMessageContent}\n\n      {canStopPendingMessage ? (\n        <button\n          type=\"button\"\n          onClick={(event) => {\n            event.stopPropagation();\n            onStop?.();\n          }}\n          data-testid=\"chat-message-stop\"\n          aria-label={t(I18nKey.BUTTON$STOP)}\n          aria-hidden={!showStopButton}\n          tabIndex={showStopButton ? 0 : -1}\n          className={cn(\n            \"group absolute z-10 inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-full bg-tertiary text-foreground transition-opacity duration-150\",\n            isSingleLinePendingMessage\n              ? \"right-3 top-1/2 -translate-y-1/2\"\n              : \"right-3 bottom-2.5\",\n            showStopButton ? \"opacity-100\" : \"pointer-events-none opacity-0\",\n          )}\n        >\n          <PendingStopIcon className=\"block h-7 w-7 max-w-none\" />\n        </button>\n      ) : null}\n\n      {isCollapsed ? (\n        <button\n          type=\"button\"\n          data-testid=\"chat-message-expand\"\n          aria-expanded={false}\n          aria-label={t(I18nKey.COMMON$VIEW_MORE)}\n          className=\"absolute inset-0 z-[1] cursor-pointer rounded-xl border-0 bg-transparent p-0\"\n          onClick={() => setIsExpanded(true)}\n        />\n      ) : null}\n\n      {children}\n    </article>\n  );\n\n  const messageBubbleWithTimestamp = timestampLabel ? (\n    <StyledTooltip\n      content={<time dateTime={timestamp}>{timestampLabel}</time>}\n      placement=\"top\"\n      isOpen={isHovering}\n    >\n      {messageBubble}\n    </StyledTooltip>\n  ) : (\n    messageBubble\n  );\n\n  if (type === \"user\" && pendingStatus === \"error\") {\n    return (\n      <div className=\"flex w-fit max-w-full flex-col items-end gap-1.5 self-end last:mb-4\">\n        {messageBubbleWithTimestamp}\n        <div\n          role=\"alert\"\n          data-testid=\"chat-message-error\"\n          className=\"flex items-center gap-2 text-xs text-status-error\"\n        >\n          <span>{t(I18nKey.CHAT_INTERFACE$MESSAGE_SEND_FAILED)}</span>\n          {onRetry ? (\n            <button\n              type=\"button\"\n              onClick={onRetry}\n              className=\"cursor-pointer rounded-md border border-border px-2 py-1 text-xs font-normal text-foreground hover:bg-interactive-hover\"\n              data-testid=\"chat-message-retry\"\n            >\n              {t(I18nKey.CHAT_INTERFACE$MESSAGE_RETRY)}\n            </button>\n          ) : null}\n          {onDismiss ? (\n            <button\n              type=\"button\"\n              onClick={onDismiss}\n              className=\"cursor-pointer rounded-md border border-border px-2 py-1 text-xs font-normal text-foreground hover:bg-interactive-hover\"\n              data-testid=\"chat-message-dismiss\"\n            >\n              {t(I18nKey.CHAT_INTERFACE$MESSAGE_DISMISS)}\n            </button>\n          ) : null}\n        </div>\n      </div>\n    );\n  }\n\n  if (type === \"user\" && pendingStatus === \"sending\") {\n    return (\n      <div className=\"flex w-full max-w-full flex-col last:mb-4\">\n        {messageBubbleWithTimestamp}\n        <div className=\"my-1 w-full py-1 text-sm\">\n          <TextShimmer\n            as=\"p\"\n            role=\"status\"\n            aria-live=\"polite\"\n            data-testid=\"chat-message-sending\"\n            className=\"block w-full text-sm font-normal\"\n            duration={1}\n            spread={2}\n          >\n            {t(I18nKey.CHAT_INTERFACE$MESSAGE_SENDING)}\n          </TextShimmer>\n        </div>\n      </div>\n    );\n  }\n\n  return messageBubbleWithTimestamp;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/chat-send-button.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 41,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 41,
          "endColumn": 65
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { ArrowUp, Loader2 } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\n\nexport interface ChatSendButtonProps {\n  buttonClassName: string;\n  handleSubmit: () => void;\n  disabled: boolean;\n  isPending?: boolean;\n}\n\nexport function ChatSendButton({\n  buttonClassName,\n  handleSubmit,\n  disabled,\n  isPending = false,\n}: ChatSendButtonProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        \"flex items-center justify-center rounded-full border border-contrast size-8\",\n        disabled\n          ? \"cursor-not-allowed border-muted\"\n          : \"cursor-pointer hover:bg-contrast/10\",\n        buttonClassName,\n      )}\n      data-name=\"arrow-up-circle-fill\"\n      data-testid=\"submit-button\"\n      onClick={handleSubmit}\n      disabled={disabled}\n      aria-busy={isPending}\n      aria-label={t(I18nKey.CHAT_INTERFACE$TOOLTIP_SEND_MESSAGE)}\n    >\n      {isPending ? (\n        <Loader2\n          data-testid=\"submit-button-pending-icon\"\n          className=\"size-4 animate-spin text-[var(--oh-muted)]\"\n          aria-hidden\n        />\n      ) : (\n        <ArrowUp\n          className=\"w-4 h-4\"\n          color={disabled ? \"var(--oh-muted)\" : \"white\"}\n        />\n      )}\n    </button>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/chat-status-indicator.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[100px]\" hardcodes an off-token value. Nearest on the scale: rounded-4xl (32px), rounded-3xl (24px).",
          "line": 18,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 18,
          "endColumn": 83
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"animate-[pulse_1.2s_ease-in-out_infinite]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 25,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 25,
          "endColumn": 78
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 41,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 41,
          "endColumn": 105
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cn } from \"@heroui/react\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport DebugStackframeDot from \"#/icons/debug-stackframe-dot.svg?react\";\n\ninterface ChatStatusIndicatorProps {\n  status: string;\n  statusColor: string;\n}\n\nfunction ChatStatusIndicator({\n  status,\n  statusColor,\n}: ChatStatusIndicatorProps) {\n  return (\n    <div\n      data-testid=\"chat-status-indicator\"\n      className={cn(\n        \"w-full max-w-full rounded-[100px] p-1 bg-surface flex items-center gap-1\",\n      )}\n    >\n      <AnimatePresence mode=\"wait\">\n        {/* Dot */}\n        <motion.span\n          key={`dot-${status}`}\n          className=\"flex-shrink-0 animate-[pulse_1.2s_ease-in-out_infinite]\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={{ duration: 0.3 }}\n        >\n          <DebugStackframeDot className=\"w-4 h-4\" color={statusColor} />\n        </motion.span>\n\n        {/* Text */}\n        <motion.span\n          key={`text-${status}`}\n          initial={{ opacity: 0, y: -2 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: 2 }}\n          transition={{ duration: 0.3 }}\n          className=\"pr-1.5 font-normal text-[11px] leading-4 normal-case break-words whitespace-normal\"\n        >\n          {status}\n        </motion.span>\n      </AnimatePresence>\n    </div>\n  );\n}\n\nexport default ChatStatusIndicator;\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/chat-suggestions.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[32px]\" hardcodes an off-token value. Nearest on the scale: text-3xl (30px), text-4xl (36px).",
          "line": 28,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 28,
          "endColumn": 83
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport { Suggestions } from \"#/components/features/suggestions/suggestions\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { SUGGESTIONS } from \"#/utils/suggestions\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\n\ninterface ChatSuggestionsProps {\n  onSuggestionsClick: (value: string) => void;\n}\n\nexport function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { shouldHideSuggestions } = useConversationStore();\n\n  return (\n    <AnimatePresence>\n      {!shouldHideSuggestions && (\n        <motion.div\n          data-testid=\"chat-suggestions\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={{ duration: 0.3, ease: \"easeInOut\" }}\n          className=\"pointer-events-auto absolute inset-x-4 bottom-37.75 top-0 flex flex-col items-center justify-center md:inset-x-8\"\n        >\n          <div className=\"flex flex-col items-center p-4 rounded-xl w-full\">\n            <span className=\"pb-6 text-[32px] font-medium leading-5 text-contrast\">\n              {t(I18nKey.LANDING$TITLE)}\n            </span>\n          </div>\n          <Suggestions\n            suggestions={Object.entries(SUGGESTIONS.repo)\n              .slice(0, 4)\n              .map(([label, value]) => ({\n                label,\n                value,\n              }))}\n            onSuggestionClick={onSuggestionsClick}\n          />\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/components/chat-input-actions.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"top-[-4px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 383,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 383,
          "endColumn": 127
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"top-[-4px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 449,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 449,
          "endColumn": 125
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[60vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 463,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 463,
          "endColumn": 79
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 568,
          "column": 31,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 568,
          "endColumn": 50
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport { Cpu } from \"lucide-react\";\nimport { AgentStatus } from \"#/components/features/controls/agent-status\";\nimport { ChangeAgentButton } from \"../change-agent-button\";\nimport { ChatInputAgentProfilePicker } from \"./chat-input-profile-picker\";\nimport { ChatInputModel, ChatInputModelMenuContent } from \"./chat-input-model\";\nimport {\n  ChatInputLlmProfilePicker,\n  ChatInputLlmProfileMenuContent,\n} from \"./chat-input-llm-profile-picker\";\nimport { resolvePickerKind } from \"./resolve-picker-kind\";\nimport { ChatAddFileButton } from \"../chat-add-file-button\";\nimport { ChatSendButton } from \"../chat-send-button\";\nimport { ChatDictationButton } from \"../chat-dictation-button\";\nimport { ContextWindowMeter } from \"./context-window-meter\";\nimport CarretRightFillIcon from \"#/icons/carret-right-fill.svg?react\";\nimport LessonPlanIcon from \"#/icons/lesson-plan.svg?react\";\nimport ThreeDotsVerticalIcon from \"#/icons/three-dots-vertical.svg?react\";\nimport { CodePillIcon } from \"#/icons/code-pill\";\nimport { useUnifiedPauseConversation } from \"#/hooks/mutation/use-unified-stop-conversation\";\nimport { useOptionalConversationId } from \"#/hooks/use-conversation-id\";\nimport { usePauseConversation } from \"#/hooks/mutation/use-pause-conversation\";\nimport { useResumeConversation } from \"#/hooks/mutation/use-resume-conversation\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useAgentProfiles } from \"#/hooks/query/use-agent-profiles\";\nimport { useChatInputModelState } from \"#/hooks/use-chat-input-model-state\";\nimport { useChatInputLlmProfileState } from \"#/hooks/use-chat-input-llm-profile-state\";\nimport { getLockedCloudHost } from \"#/api/agent-server-config\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport { useAgentState } from \"#/hooks/use-agent-state\";\nimport { AgentState } from \"#/types/agent-state\";\nimport { useUnifiedWebSocketStatus } from \"#/hooks/use-unified-websocket-status\";\nimport { useHandlePlanClick } from \"#/hooks/use-handle-plan-click\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { ToolsContextMenuIconText } from \"../../controls/tools-context-menu-icon-text\";\nimport { ContextMenuListItem } from \"../../context-menu/context-menu-list-item\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { useCloseOnEscape } from \"#/hooks/use-close-on-escape\";\nimport { cn } from \"#/utils/utils\";\nimport { useComposerDockedMinimal } from \"#/context/composer-docked-context\";\nimport {\n  chatInputIconButtonClassName,\n  formControlTransitionClassName,\n} from \"#/utils/form-control-classes\";\n\ninterface ChatInputActionsProps {\n  disabled: boolean;\n  canSubmit?: boolean;\n  hasStartedConversation?: boolean;\n  onAddFileClick?: () => void;\n  showButton?: boolean;\n  buttonClassName?: string;\n  handleSubmit?: () => void;\n  onDictationTranscript?: (text: string) => void;\n  /** Tracks the text field's editability, not submit availability. */\n  isDictationDisabled?: boolean;\n}\n\nexport function ChatInputActions({\n  disabled,\n  canSubmit = true,\n  hasStartedConversation,\n  onAddFileClick = () => {},\n  showButton = true,\n  buttonClassName = \"\",\n  handleSubmit = () => {},\n  onDictationTranscript,\n  isDictationDisabled = false,\n}: ChatInputActionsProps) {\n  const { t } = useTranslation(\"openhands\");\n  const isComposerDockedMinimal = useComposerDockedMinimal();\n  const unifiedPauseMutation = useUnifiedPauseConversation();\n  const pauseConversationMutation = usePauseConversation();\n  const resumeConversationMutation = useResumeConversation();\n  const { conversationId } = useOptionalConversationId();\n  const { backend } = useActiveBackend();\n  const isCloud = backend.kind === \"cloud\";\n  const modelState = useChatInputModelState();\n  const llmProfileState = useChatInputLlmProfileState();\n  // Agent-profile switching lives in the \"+\" tools menu while the conversation\n  // hasn't started (OSS-5735) \u2014 the pill itself is always an LLM selector. The\n  // gate is computed here (not in the menu) so ToolsContextMenu only mounts the\n  // profile submenu when it can actually be used: pre-start, not on a task\n  // route, and only when the backend has profiles (#1571 fallback). Fetch is\n  // limited to the pre-start window.\n  const isPreStart = !conversationId || hasStartedConversation === false;\n  const agentProfilesForStart = useAgentProfiles({ enabled: isPreStart });\n  const showAgentProfileSwitch =\n    isPreStart &&\n    !(conversationId?.startsWith(\"task-\") ?? false) &&\n    (agentProfilesForStart.data?.profiles?.length ?? 0) > 0;\n  // Code/Plan mode switching is a cloud OpenHands feature \u2014 it doesn't apply\n  // to ACP conversations (which have no \"plan\" mode), so hide it when ACP.\n  const showChangeAgentButton = isCloud && !modelState.isAcpContext;\n  const webSocketStatus = useUnifiedWebSocketStatus();\n  const { curAgentState } = useAgentState();\n  const { conversationMode, setConversationMode } = useConversationStore();\n  const { handlePlanClick, isCreatingConversation } = useHandlePlanClick();\n\n  const actionsRowRef = React.useRef<HTMLDivElement>(null);\n  const rightSectionRef = React.useRef<HTMLDivElement>(null);\n  const addFileRef = React.useRef<HTMLDivElement>(null);\n  const codeRef = React.useRef<HTMLDivElement>(null);\n  const modelRef = React.useRef<HTMLDivElement>(null);\n  const agentProfileRef = React.useRef<HTMLDivElement>(null);\n  const overflowTriggerRef = React.useRef<HTMLButtonElement>(null);\n  const [actionsRowWidth, setActionsRowWidth] = React.useState<number>(\n    Number.POSITIVE_INFINITY,\n  );\n  const [rightSectionWidth, setRightSectionWidth] = React.useState(0);\n  const [addFileWidth, setAddFileWidth] = React.useState(32);\n  const [codeWidth, setCodeWidth] = React.useState(96);\n  const [modelWidth, setModelWidth] = React.useState(120);\n  const [agentProfileWidth, setAgentProfileWidth] = React.useState(110);\n  const [isOverflowOpen, setIsOverflowOpen] = React.useState(false);\n  const [activeSubmenu, setActiveSubmenu] = React.useState<\n    \"agent\" | \"model\" | null\n  >(null);\n  const [overflowPortalStyle, setOverflowPortalStyle] =\n    React.useState<React.CSSProperties>();\n\n  React.useEffect(() => {\n    const rowEl = actionsRowRef.current;\n    const rightEl = rightSectionRef.current;\n    const addEl = addFileRef.current;\n    const codeEl = codeRef.current;\n    const modelEl = modelRef.current;\n    const agentProfileEl = agentProfileRef.current;\n\n    if (\n      !rowEl ||\n      !rightEl ||\n      !addEl ||\n      !modelEl ||\n      (showChangeAgentButton && !codeEl) ||\n      typeof ResizeObserver === \"undefined\"\n    ) {\n      return;\n    }\n\n    const syncWidths = () => {\n      const nextRowWidth = rowEl.getBoundingClientRect().width;\n      const nextRightWidth = rightEl.getBoundingClientRect().width;\n      const nextAddWidth = addEl.getBoundingClientRect().width;\n      const nextModelWidth = modelEl.getBoundingClientRect().width;\n\n      if (nextRowWidth > 0) setActionsRowWidth(nextRowWidth);\n      if (nextRightWidth > 0) setRightSectionWidth(nextRightWidth);\n      if (nextAddWidth > 0) setAddFileWidth(nextAddWidth);\n      if (nextModelWidth > 0) setModelWidth(nextModelWidth);\n\n      if (codeEl) {\n        const nextCodeWidth = codeEl.getBoundingClientRect().width;\n        if (nextCodeWidth > 0) setCodeWidth(nextCodeWidth);\n      }\n      if (agentProfileEl) {\n        const nextAgentWidth = agentProfileEl.getBoundingClientRect().width;\n        if (nextAgentWidth > 0) setAgentProfileWidth(nextAgentWidth);\n      }\n    };\n\n    const observer = new ResizeObserver(() => {\n      syncWidths();\n    });\n\n    observer.observe(rowEl);\n    observer.observe(rightEl);\n    observer.observe(addEl);\n    observer.observe(modelEl);\n    if (codeEl) {\n      observer.observe(codeEl);\n    }\n    if (agentProfileEl) {\n      observer.observe(agentProfileEl);\n    }\n\n    syncWidths();\n\n    return () => observer.disconnect();\n  }, [showChangeAgentButton, showAgentProfileSwitch]);\n\n  const handlePauseAgent = () => {\n    if (!conversationId) return;\n    pauseConversationMutation.mutate({ conversationId });\n  };\n\n  const handleResumeAgentClick = () => {\n    if (!conversationId) return;\n    resumeConversationMutation.mutate({ conversationId });\n  };\n\n  const isPausing =\n    unifiedPauseMutation.isPending || pauseConversationMutation.isPending;\n\n  const OVERFLOW_BUTTON_WIDTH = 28;\n  const INLINE_GAP = 12;\n  const ROOT_GAP = 8;\n\n  const fitOptionalItems = React.useCallback(\n    (availableWidth: number) => {\n      let remaining = availableWidth;\n      const next = {\n        showCodeInline: false,\n        showModelInline: false,\n        showAgentProfileInline: false,\n      };\n\n      if (showChangeAgentButton && remaining >= codeWidth) {\n        next.showCodeInline = true;\n        remaining -= codeWidth + INLINE_GAP;\n      }\n\n      if (remaining >= modelWidth) {\n        next.showModelInline = true;\n        remaining -= modelWidth + INLINE_GAP;\n      }\n\n      // Drops first: the \"+\" menu also switches the agent profile.\n      if (showAgentProfileSwitch && remaining >= agentProfileWidth) {\n        next.showAgentProfileInline = true;\n      }\n\n      return next;\n    },\n    [\n      showChangeAgentButton,\n      codeWidth,\n      modelWidth,\n      showAgentProfileSwitch,\n      agentProfileWidth,\n    ],\n  );\n\n  const leftBaseWidth =\n    actionsRowWidth - rightSectionWidth - ROOT_GAP - addFileWidth - INLINE_GAP;\n\n  const fitWithoutOverflow = fitOptionalItems(leftBaseWidth);\n  const allOptionalFit =\n    (!showChangeAgentButton || fitWithoutOverflow.showCodeInline) &&\n    fitWithoutOverflow.showModelInline;\n\n  const fitWithOverflow = allOptionalFit\n    ? fitWithoutOverflow\n    : fitOptionalItems(leftBaseWidth - OVERFLOW_BUTTON_WIDTH - INLINE_GAP);\n\n  const showCodeInline = !showChangeAgentButton\n    ? false\n    : fitWithOverflow.showCodeInline;\n  const showModelInline = fitWithOverflow.showModelInline;\n  const showAgentProfileInline = fitWithOverflow.showAgentProfileInline;\n  const showAddFileInline = true;\n  const showAgentStatusInline = actionsRowWidth >= 360;\n\n  // Which chat-input LLM picker to show \u2014 the constrained ACP model picker or\n  // the LLM-profile picker (unit-tested in `resolve-picker-kind.test.ts`).\n  const pickerKind = resolvePickerKind({ isAcp: modelState.isAcpContext });\n\n  // Locked-to-Cloud drops the settings link from the Model menu (OHE-3457),\n  // so the overflow entry stays only while the menu still has rows \u2014 the same\n  // conditions ChatInputModelMenuContent / ChatInputLlmProfileMenuContent use.\n  const hasModelMenuRows =\n    pickerKind === \"model\"\n      ? modelState.showAcpPicker || Boolean(modelState.displayModel)\n      : (llmProfileState.canSwitchProfile &&\n          llmProfileState.profiles.length > 0) ||\n        (!llmProfileState.canSwitchProfile &&\n          Boolean(llmProfileState.currentProfileName));\n  const showOverflowModel =\n    !showModelInline && (getLockedCloudHost() === null || hasModelMenuRows);\n\n  const hasOverflowItems =\n    !showAddFileInline ||\n    (showChangeAgentButton && !showCodeInline) ||\n    showOverflowModel;\n\n  React.useEffect(() => {\n    if (!hasOverflowItems) {\n      setIsOverflowOpen(false);\n      setActiveSubmenu(null);\n    }\n  }, [hasOverflowItems]);\n\n  // The trigger toggles the menu itself. The app hydrates on `document`, so\n  // its stopPropagation() can't keep the click from this document listener;\n  // without the ignore ref, the opening click immediately closes the menu.\n  const overflowMenuRef = useClickOutsideElement<HTMLUListElement>(() => {\n    setIsOverflowOpen(false);\n    setActiveSubmenu(null);\n  }, overflowTriggerRef);\n  useCloseOnEscape(\n    isOverflowOpen,\n    () => {\n      setIsOverflowOpen(false);\n      setActiveSubmenu(null);\n    },\n    overflowTriggerRef,\n  );\n\n  const isAgentSwitcherDisabled =\n    curAgentState === AgentState.RUNNING ||\n    isCreatingConversation ||\n    webSocketStatus !== \"OPEN\";\n\n  const closeOverflowMenus = () => {\n    setActiveSubmenu(null);\n    setIsOverflowOpen(false);\n  };\n\n  // Shared styling for the settings link inside the overflow submenu content.\n  const overflowSettingsLinkClassName = cn(\n    \"group\",\n    formControlTransitionClassName,\n  );\n  const overflowSettingsIconClassName = cn(\n    \"text-muted group-hover:text-foreground\",\n    formControlTransitionClassName,\n  );\n\n  React.useLayoutEffect(() => {\n    if (!isOverflowOpen || !overflowTriggerRef.current) {\n      return;\n    }\n\n    const trigger = overflowTriggerRef.current;\n\n    const updatePosition = () => {\n      const rect = trigger.getBoundingClientRect();\n      const GAP = 8;\n      setOverflowPortalStyle({\n        position: \"fixed\",\n        top: rect.top - GAP,\n        left: rect.left,\n        transform: \"translateY(-100%)\",\n        zIndex: 9999,\n      });\n    };\n\n    updatePosition();\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n\n    return () => {\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [isOverflowOpen]);\n\n  const overflowMenu = (\n    <ContextMenu\n      ref={overflowMenuRef}\n      testId=\"chat-input-overflow-menu\"\n      position=\"top\"\n      alignment=\"left\"\n      className=\"!static !top-auto !bottom-auto !left-auto !right-auto !mt-0 overflow-visible min-w-50\"\n    >\n      {showChangeAgentButton && !showCodeInline && (\n        <div className=\"relative group/overflow-agent\">\n          <ContextMenuListItem\n            testId=\"overflow-agent-button\"\n            onClick={() =>\n              setActiveSubmenu((current) =>\n                current === \"agent\" ? null : \"agent\",\n              )\n            }\n            isDisabled={isAgentSwitcherDisabled}\n          >\n            <ToolsContextMenuIconText\n              icon={<CodePillIcon className=\"h-2.75 w-2.75\" />}\n              text={\n                conversationMode === \"code\"\n                  ? t(I18nKey.COMMON$CODE)\n                  : t(I18nKey.COMMON$PLAN)\n              }\n              rightIcon={<CarretRightFillIcon width={10} height={10} />}\n            />\n          </ContextMenuListItem>\n          {!isAgentSwitcherDisabled && (\n            <div\n              className={cn(\n                \"absolute left-full top-[-4px] z-60 opacity-0 invisible pointer-events-none transition-all duration-200 ml-px\",\n                \"group-hover/overflow-agent:opacity-100 group-hover/overflow-agent:visible group-hover/overflow-agent:pointer-events-auto\",\n                \"hover:opacity-100 hover:visible hover:pointer-events-auto\",\n                activeSubmenu === \"agent\" &&\n                  \"opacity-100 visible pointer-events-auto\",\n              )}\n            >\n              <ContextMenu\n                testId=\"overflow-agent-submenu\"\n                className=\"overflow-visible min-w-48.75\"\n              >\n                <ContextMenuListItem\n                  testId=\"overflow-agent-code\"\n                  onClick={(event) => {\n                    event.preventDefault();\n                    event.stopPropagation();\n                    setConversationMode(\"code\");\n                    closeOverflowMenus();\n                  }}\n                >\n                  <ToolsContextMenuIconText\n                    icon={<CodePillIcon className=\"h-2.75 w-2.75\" />}\n                    text={t(I18nKey.COMMON$CODE)}\n                  />\n                </ContextMenuListItem>\n                <ContextMenuListItem\n                  testId=\"overflow-agent-plan\"\n                  onClick={(event) => {\n                    handlePlanClick(event);\n                    closeOverflowMenus();\n                  }}\n                >\n                  <ToolsContextMenuIconText\n                    icon={\n                      <LessonPlanIcon\n                        width={16}\n                        height={16}\n                        color=\"currentColor\"\n                      />\n                    }\n                    text={t(I18nKey.COMMON$PLAN)}\n                  />\n                </ContextMenuListItem>\n              </ContextMenu>\n            </div>\n          )}\n        </div>\n      )}\n      {showOverflowModel && (\n        <div className=\"relative group/overflow-model\">\n          <ContextMenuListItem\n            testId=\"overflow-model-button\"\n            onClick={() =>\n              setActiveSubmenu((current) =>\n                current === \"model\" ? null : \"model\",\n              )\n            }\n          >\n            <ToolsContextMenuIconText\n              icon={<Cpu width={16} height={16} strokeWidth={2} aria-hidden />}\n              text={t(I18nKey.SETTINGS$AGENT_MODEL)}\n              rightIcon={<CarretRightFillIcon width={10} height={10} />}\n            />\n          </ContextMenuListItem>\n          <div\n            className={cn(\n              \"absolute left-full top-[-4px] z-60 opacity-0 invisible pointer-events-none transition-all duration-200 ml-px\",\n              \"group-hover/overflow-model:opacity-100 group-hover/overflow-model:visible group-hover/overflow-model:pointer-events-auto\",\n              \"hover:opacity-100 hover:visible hover:pointer-events-auto\",\n              activeSubmenu === \"model\" &&\n                \"opacity-100 visible pointer-events-auto\",\n            )}\n          >\n            {/* overflow-y-auto (not overflow-visible) so a long ACP model list\n                scrolls within the menu instead of overflowing the viewport.\n                Safe because this menu has no floating children (tooltips /\n                nested popovers) that would be clipped \u2014 only a flat model list\n                + Settings link. Revisit if floating children are added here. */}\n            <ContextMenu\n              testId=\"overflow-model-submenu\"\n              className=\"min-w-55 max-w-80 max-h-[60vh] overflow-y-auto gap-0\"\n            >\n              {pickerKind === \"model\" ? (\n                <ChatInputModelMenuContent\n                  model={modelState}\n                  onClose={closeOverflowMenus}\n                  dividerInset=\"menu\"\n                  settingsLinkClassName={overflowSettingsLinkClassName}\n                  settingsIconClassName={overflowSettingsIconClassName}\n                />\n              ) : (\n                <ChatInputLlmProfileMenuContent\n                  onClose={closeOverflowMenus}\n                  dividerInset=\"menu\"\n                  settingsLinkClassName={overflowSettingsLinkClassName}\n                  settingsIconClassName={overflowSettingsIconClassName}\n                />\n              )}\n            </ContextMenu>\n          </div>\n        </div>\n      )}\n    </ContextMenu>\n  );\n\n  if (isComposerDockedMinimal) {\n    return (\n      <div\n        data-testid=\"chat-input-actions-minimal\"\n        className=\"w-full min-w-0 flex items-center justify-end gap-2\"\n      >\n        {showButton ? (\n          <ChatSendButton\n            buttonClassName={buttonClassName}\n            handleSubmit={handleSubmit}\n            disabled={disabled || !canSubmit}\n          />\n        ) : null}\n      </div>\n    );\n  }\n\n  return (\n    <div\n      ref={actionsRowRef}\n      className=\"w-full min-w-0 flex items-center justify-between gap-2\"\n    >\n      <div className=\"flex min-w-0 items-center gap-1\">\n        <div className=\"flex min-w-0 items-center gap-3\">\n          <div ref={addFileRef} className={cn(!showAddFileInline && \"hidden\")}>\n            <ChatAddFileButton\n              disabled={disabled}\n              handleFileIconClick={onAddFileClick}\n              showAgentProfileSwitch={showAgentProfileSwitch}\n            />\n          </div>\n          {showChangeAgentButton && (\n            <div ref={codeRef} className={cn(!showCodeInline && \"hidden\")}>\n              <ChangeAgentButton />\n            </div>\n          )}\n          {showAgentProfileSwitch && (\n            <div\n              ref={agentProfileRef}\n              className={cn(!showAgentProfileInline && \"hidden\")}\n            >\n              <ChatInputAgentProfilePicker />\n            </div>\n          )}\n          <div ref={modelRef} className={cn(!showModelInline && \"hidden\")}>\n            {/* Picker depends on backend + ACP context; see the `pickerKind`\n                cases above. */}\n            {pickerKind === \"model\" ? (\n              <ChatInputModel />\n            ) : (\n              <ChatInputLlmProfilePicker />\n            )}\n          </div>\n\n          {hasOverflowItems && (\n            <div className=\"relative shrink-0\">\n              <button\n                ref={overflowTriggerRef}\n                type=\"button\"\n                className={cn(chatInputIconButtonClassName, \"size-6\")}\n                aria-label={t(I18nKey.CHAT_INTERFACE$MORE_INPUT_ACTIONS)}\n                aria-expanded={isOverflowOpen}\n                aria-haspopup=\"menu\"\n                onClick={(event) => {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  setIsOverflowOpen((open) => !open);\n                }}\n              >\n                <ThreeDotsVerticalIcon\n                  width={16}\n                  height={16}\n                  color=\"currentColor\"\n                />\n              </button>\n\n              {isOverflowOpen &&\n                typeof document !== \"undefined\" &&\n                overflowPortalStyle &&\n                ReactDOM.createPortal(\n                  <div style={overflowPortalStyle}>{overflowMenu}</div>,\n                  document.body,\n                )}\n            </div>\n          )}\n        </div>\n      </div>\n      <div\n        ref={rightSectionRef}\n        className=\"ml-auto flex shrink-0 items-center gap-2\"\n      >\n        {showAgentStatusInline && conversationId && (\n          <AgentStatus\n            handleStop={handlePauseAgent}\n            handleResumeAgent={handleResumeAgentClick}\n            disabled={disabled}\n            isPausing={isPausing}\n          />\n        )}\n        <ContextWindowMeter />\n        {onDictationTranscript && (\n          <ChatDictationButton\n            onTranscript={onDictationTranscript}\n            disabled={isDictationDisabled}\n          />\n        )}\n        {showButton && (\n          <ChatSendButton\n            buttonClassName={buttonClassName}\n            handleSubmit={handleSubmit}\n            disabled={disabled || !canSubmit}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/components/chat-input-container.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[15px]\" hardcodes an off-token value. Nearest on the scale: rounded-2xl (16px), rounded-xl (12px).",
          "line": 73,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 73,
          "endColumn": 124
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[#597FF4]\" hardcodes a color and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css.",
          "line": 74,
          "column": 40,
          "nodeType": "Literal",
          "messageId": "arbitraryColorFar",
          "endLine": 74,
          "endColumn": 65
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { DragOver } from \"../drag-over\";\nimport { UploadedFiles } from \"../uploaded-files\";\nimport { ChatInputRow } from \"./chat-input-row\";\nimport { ChatInputActions } from \"./chat-input-actions\";\nimport { SlashCommandMenu } from \"./slash-command-menu\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport { cn } from \"#/utils/utils\";\nimport { SlashCommandItem } from \"#/hooks/chat/use-slash-command\";\nimport { insertTextAtCaret } from \"../utils/chat-input.utils\";\n\ninterface ChatInputContainerProps {\n  chatContainerRef: React.RefObject<HTMLDivElement | null>;\n  isDragOver: boolean;\n  disabled: boolean;\n  canSubmit: boolean;\n  hasStartedConversation?: boolean;\n  isNewConversationPending?: boolean;\n  showButton: boolean;\n  buttonClassName: string;\n  chatInputRef: React.RefObject<HTMLDivElement | null>;\n  placeholder?: string;\n  handleFileIconClick: (isDisabled: boolean) => void;\n  handleSubmit: () => void;\n  onDragOver: (e: React.DragEvent, isDisabled: boolean) => void;\n  onDragLeave: (e: React.DragEvent, isDisabled: boolean) => void;\n  onDrop: (e: React.DragEvent, isDisabled: boolean) => void;\n  onInput: () => void;\n  onPaste: (e: React.ClipboardEvent) => void;\n  onKeyDown: (e: React.KeyboardEvent) => void;\n  onFocus?: () => void;\n  onBlur?: () => void;\n  isSlashMenuOpen?: boolean;\n  slashItems?: SlashCommandItem[];\n  slashSelectedIndex?: number;\n  onSlashSelect?: (item: SlashCommandItem) => void;\n}\n\nexport function ChatInputContainer({\n  chatContainerRef,\n  isDragOver,\n  disabled,\n  canSubmit,\n  hasStartedConversation,\n  isNewConversationPending = false,\n  showButton,\n  buttonClassName,\n  chatInputRef,\n  placeholder,\n  handleFileIconClick,\n  handleSubmit,\n  onDragOver,\n  onDragLeave,\n  onDrop,\n  onInput,\n  onPaste,\n  onKeyDown,\n  onFocus,\n  onBlur,\n  isSlashMenuOpen = false,\n  slashItems = [],\n  slashSelectedIndex = 0,\n  onSlashSelect,\n}: ChatInputContainerProps) {\n  const conversationMode = useConversationStore(\n    (state) => state.conversationMode,\n  );\n\n  return (\n    <div\n      ref={chatContainerRef}\n      className={cn(\n        \"bg-surface box-border content-stretch flex flex-col items-start justify-center p-4 relative rounded-[15px] w-full\",\n        conversationMode === \"plan\" && \"border border-[#597FF4]\",\n      )}\n      onDragOver={(e) => onDragOver(e, disabled)}\n      onDragLeave={(e) => onDragLeave(e, disabled)}\n      onDrop={(e) => onDrop(e, disabled)}\n    >\n      {/* Drag Over UI */}\n      {isDragOver && <DragOver />}\n\n      <UploadedFiles />\n\n      {/* Wrapper so the slash menu anchors just above the input row,\n          not above the entire (possibly resized) container */}\n      <div className=\"relative w-full\">\n        {isSlashMenuOpen && onSlashSelect && (\n          <SlashCommandMenu\n            items={slashItems}\n            selectedIndex={slashSelectedIndex}\n            onSelect={onSlashSelect}\n          />\n        )}\n\n        <ChatInputRow\n          chatInputRef={chatInputRef}\n          isNewConversationPending={isNewConversationPending}\n          placeholder={placeholder}\n          onInput={onInput}\n          onPaste={onPaste}\n          onKeyDown={onKeyDown}\n          onFocus={onFocus}\n          onBlur={onBlur}\n        />\n      </div>\n\n      <ChatInputActions\n        disabled={disabled}\n        canSubmit={canSubmit}\n        hasStartedConversation={hasStartedConversation}\n        onAddFileClick={() => handleFileIconClick(disabled)}\n        showButton={showButton}\n        buttonClassName={buttonClassName}\n        handleSubmit={handleSubmit}\n        onDictationTranscript={(text) =>\n          insertTextAtCaret(chatInputRef.current, text)\n        }\n        isDictationDisabled={isNewConversationPending}\n      />\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/components/chat-input-llm-profile-picker.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 79,
          "column": 40,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 79,
          "endColumn": 113
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets maxHeight. Style through classes; use CSS custom properties for dynamic values.",
          "line": 257,
          "column": 31,
          "nodeType": "Identifier",
          "messageId": "inlineStyle",
          "endLine": 257,
          "endColumn": 47
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[60]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 261,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 261,
          "endColumn": 81
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[60vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 261,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 261,
          "endColumn": 81
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useChatInputLlmProfileState } from \"#/hooks/use-chat-input-llm-profile-state\";\nimport { ComboboxCaretInline } from \"#/ui/combobox-caret\";\nimport SettingsGearIcon from \"#/icons/settings-gear.svg?react\";\nimport CheckIcon from \"#/icons/checkmark.svg?react\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { NavigationLink } from \"#/components/shared/navigation-link\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { ContextMenuListItem } from \"#/components/features/context-menu/context-menu-list-item\";\nimport { Divider } from \"#/ui/divider\";\nimport { Typography } from \"#/ui/typography\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { getLockedCloudHost } from \"#/api/agent-server-config\";\nimport { chatInputPillButtonClassName } from \"#/utils/form-control-classes\";\nimport { useFreeModels } from \"#/hooks/query/use-free-models\";\nimport { formatModelPillLabel } from \"#/utils/format-model-name\";\nimport { useModelCatalogWarning } from \"#/hooks/use-model-catalog-warning\";\nimport { ModelCatalogWarning } from \"#/components/shared/model-catalog-warning\";\n\nconst PROFILE_LABEL_MAX_CHARS = 18;\n\nfunction truncateLabel(label: string): string {\n  return label.length <= PROFILE_LABEL_MAX_CHARS\n    ? label\n    : `${label.slice(0, PROFILE_LABEL_MAX_CHARS)}\u2026`;\n}\n\ninterface ChatInputLlmProfileMenuContentProps {\n  onClose: () => void;\n  dividerInset?: \"menu\";\n  settingsLinkClassName?: string;\n  settingsIconClassName?: string;\n}\n\n/**\n * The in-conversation OpenHands LLM-profile switcher list. Selecting a profile\n * live-swaps the running conversation's LLM via `/switch_profile` (the ACP\n * analog is {@link ChatInputModelMenuContent}). Shared by the inline pill and\n * the chat-input overflow submenu.\n */\nexport function ChatInputLlmProfileMenuContent({\n  onClose,\n  dividerInset,\n  settingsLinkClassName,\n  settingsIconClassName,\n}: ChatInputLlmProfileMenuContentProps) {\n  const { t } = useTranslation(\"openhands\");\n  const freeModels = useFreeModels();\n  const isModelUnlisted = useModelCatalogWarning();\n  const {\n    profiles,\n    currentProfileName,\n    currentProfileModel,\n    canSwitchProfile,\n    selectProfile,\n  } = useChatInputLlmProfileState();\n  // A read-only surface (a cloud start-task route) still names the active\n  // profile \u2014 the same shape ChatInputModelMenuContent uses when the ACP\n  // picker is gated off.\n  const showProfileList = canSwitchProfile && profiles.length > 0;\n  const readOnlyProfileName = canSwitchProfile ? null : currentProfileName;\n  // Locked-to-Cloud blocks the Canvas LLM page this links to (OHE-3457).\n  const showSettingsLink = getLockedCloudHost() === null;\n\n  const handleSelect = (profileName: string) => {\n    selectProfile(profileName);\n    onClose();\n  };\n\n  return (\n    <>\n      {showProfileList && (\n        <>\n          {/* role=\"presentation\" keeps this a valid <li> child of the\n              ContextMenu <ul> without exposing the label as a menu item. */}\n          <li role=\"presentation\" className=\"px-2 pt-1 pb-0.5\">\n            <Typography.Text className=\"text-[11px] font-medium text-text-dim uppercase tracking-wide leading-4\">\n              {t(I18nKey.SETTINGS$AVAILABLE_PROFILES)}\n            </Typography.Text>\n          </li>\n          {profiles.map((profile) => {\n            const isCurrent = profile.name === currentProfileName;\n            const displayModel = formatModelPillLabel(\n              profile.model,\n              freeModels,\n            );\n            return (\n              <ContextMenuListItem\n                key={profile.name}\n                testId={`chat-input-llm-profile-option-${profile.name}`}\n                onClick={(event) => {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  if (isCurrent) {\n                    onClose();\n                    return;\n                  }\n                  handleSelect(profile.name);\n                }}\n                className={cn(\n                  \"flex flex-col items-stretch gap-0.5\",\n                  isCurrent && \"bg-interactive-hover\",\n                )}\n              >\n                <span className=\"flex items-center gap-2\">\n                  <span\n                    className=\"flex-1 truncate text-sm leading-5\"\n                    title={profile.model ?? profile.name}\n                  >\n                    {profile.name}\n                  </span>\n                  {isCurrent && (\n                    <CheckIcon\n                      width={14}\n                      height={14}\n                      className=\"shrink-0\"\n                      aria-hidden\n                    />\n                  )}\n                </span>\n                {displayModel && (\n                  <span className=\"block truncate text-xs leading-4 text-muted\">\n                    {displayModel}\n                  </span>\n                )}\n                {isModelUnlisted(profile.model) && <ModelCatalogWarning />}\n              </ContextMenuListItem>\n            );\n          })}\n        </>\n      )}\n      {readOnlyProfileName && (\n        <li className=\"text-sm\" data-testid=\"chat-input-llm-profile-current\">\n          <div className=\"flex flex-col gap-0.5 p-2 leading-5 text-foreground\">\n            <span className=\"truncate\" title={readOnlyProfileName}>\n              {readOnlyProfileName}\n            </span>\n            {currentProfileModel && (\n              <span className=\"truncate text-xs leading-4 text-muted\">\n                {formatModelPillLabel(currentProfileModel, freeModels)}\n              </span>\n            )}\n            {isModelUnlisted(currentProfileModel) && <ModelCatalogWarning />}\n          </div>\n        </li>\n      )}\n      {showSettingsLink && (showProfileList || readOnlyProfileName) && (\n        <Divider inset={dividerInset} />\n      )}\n      {showSettingsLink && (\n        <li className=\"text-sm\">\n          <NavigationLink\n            to=\"/settings/llm\"\n            onClick={onClose}\n            className={cn(\n              \"flex h-7.5 items-center gap-2 rounded p-2 leading-5 text-foreground hover:bg-interactive-hover transition-colors\",\n              settingsLinkClassName,\n            )}\n          >\n            <SettingsGearIcon\n              width={16}\n              height={16}\n              className={cn(\"shrink-0\", settingsIconClassName)}\n              aria-hidden\n            />\n            <span>{t(I18nKey.SETTINGS$LLM_PROFILES)}</span>\n          </NavigationLink>\n        </li>\n      )}\n    </>\n  );\n}\n\nexport function ChatInputLlmProfilePicker() {\n  const { t } = useTranslation(\"openhands\");\n  const {\n    profiles,\n    currentProfileName,\n    currentProfileModel,\n    isLoading,\n    isSwitching,\n  } = useChatInputLlmProfileState();\n  const isModelUnlisted = useModelCatalogWarning();\n  const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const [popoverMaxHeight, setPopoverMaxHeight] = React.useState<number>();\n  const popoverRef = useClickOutsideElement<HTMLUListElement>(\n    () => setIsPopoverOpen(false),\n    triggerRef,\n  );\n\n  React.useLayoutEffect(() => {\n    if (!isPopoverOpen) return undefined;\n    const measureAvailableSpace = () => {\n      const trigger = triggerRef.current;\n      if (!trigger) return;\n      // Leave room for the menu margin and an 8px viewport gutter.\n      setPopoverMaxHeight(\n        Math.max(\n          0,\n          Math.min(\n            window.innerHeight * 0.6,\n            trigger.getBoundingClientRect().top - 16,\n          ),\n        ),\n      );\n    };\n    measureAvailableSpace();\n    window.addEventListener(\"resize\", measureAvailableSpace);\n    window.addEventListener(\"scroll\", measureAvailableSpace, true);\n    return () => {\n      window.removeEventListener(\"resize\", measureAvailableSpace);\n      window.removeEventListener(\"scroll\", measureAvailableSpace, true);\n    };\n  }, [isPopoverOpen]);\n\n  // No LLM profiles yet (or the agent-server lacks the surface): stay out of\n  // the way, exactly like the ACP/AgentProfile pickers.\n  if (isLoading || profiles.length === 0) {\n    return null;\n  }\n\n  const label = currentProfileName ?? t(I18nKey.LLM$SELECT_MODEL_PLACEHOLDER);\n\n  return (\n    <div className=\"relative min-w-0\">\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        className={cn(chatInputPillButtonClassName, \"max-w-50\")}\n        title={currentProfileName ?? undefined}\n        data-testid=\"chat-input-llm-profile\"\n        aria-expanded={isPopoverOpen}\n        aria-haspopup=\"dialog\"\n        // Disabled mid-switch so re-opening can't fire a second /switch_profile.\n        disabled={isSwitching}\n        aria-busy={isSwitching}\n        onClick={(event) => {\n          event.preventDefault();\n          event.stopPropagation();\n          setIsPopoverOpen((open) => !open);\n        }}\n      >\n        <span className=\"truncate\">{truncateLabel(label)}</span>\n        {isModelUnlisted(currentProfileModel) && (\n          <ModelCatalogWarning compact />\n        )}\n        <ComboboxCaretInline isOpen={isPopoverOpen} />\n      </button>\n\n      {isPopoverOpen && (\n        <ContextMenu\n          ref={popoverRef}\n          testId=\"chat-input-llm-profile-popover\"\n          style={{ maxHeight: popoverMaxHeight }}\n          position=\"top\"\n          alignment=\"left\"\n          spacing=\"none\"\n          className=\"z-[60] mb-2 min-w-50 max-w-80 max-h-[60vh] overflow-y-auto\"\n        >\n          <ChatInputLlmProfileMenuContent\n            onClose={() => setIsPopoverOpen(false)}\n          />\n        </ContextMenu>\n      )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/components/chat-input-model.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 77,
          "column": 40,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 77,
          "endColumn": 113
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[60]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 197,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 197,
          "endColumn": 81
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[60vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 197,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 197,
          "endColumn": 81
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport {\n  useChatInputModelState,\n  type ChatInputModelState,\n} from \"#/hooks/use-chat-input-model-state\";\nimport { useSwitchAcpModel } from \"#/hooks/mutation/use-switch-acp-model\";\nimport { ComboboxCaretInline } from \"#/ui/combobox-caret\";\nimport SettingsGearIcon from \"#/icons/settings-gear.svg?react\";\nimport CheckIcon from \"#/icons/checkmark.svg?react\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { NavigationLink } from \"#/components/shared/navigation-link\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { ContextMenuListItem } from \"#/components/features/context-menu/context-menu-list-item\";\nimport { Divider } from \"#/ui/divider\";\nimport { Typography } from \"#/ui/typography\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { getLockedCloudHost } from \"#/api/agent-server-config\";\nimport { chatInputPillButtonClassName } from \"#/utils/form-control-classes\";\nimport React from \"react\";\n\nconst MODEL_LABEL_MAX_CHARS = 10;\n// ACP surfaces show the provider's human label (e.g. \"Claude Opus 4.7\"),\n// which is longer than a raw model id, so the inline button gets a wider cap\n// before truncating. The full string still shows in the title + popover.\nconst ACP_MODEL_LABEL_MAX_CHARS = 22;\n\nfunction truncateModelLabel(\n  model: string,\n  maxChars: number = MODEL_LABEL_MAX_CHARS,\n): string {\n  if (model.length <= maxChars) {\n    return model;\n  }\n  return `${model.slice(0, maxChars)}\u2026`;\n}\n\ninterface ChatInputModelMenuContentProps {\n  model: ChatInputModelState;\n  onClose: () => void;\n  dividerInset?: \"menu\";\n  settingsLinkClassName?: string;\n  settingsIconClassName?: string;\n}\n\nexport function ChatInputModelMenuContent({\n  model,\n  onClose,\n  dividerInset,\n  settingsLinkClassName,\n  settingsIconClassName,\n}: ChatInputModelMenuContentProps) {\n  const { t } = useTranslation(\"openhands\");\n  const switchAcpModel = useSwitchAcpModel();\n  const hasModelRows = model.showAcpPicker || Boolean(model.displayModel);\n  // Locked-to-Cloud blocks the Canvas settings pages this links to (OHE-3457).\n  const showSettingsLink = getLockedCloudHost() === null;\n\n  const handleSelectAcpModel = (modelId: string | null) => {\n    if (modelId !== model.selectedModelId) {\n      switchAcpModel.mutate({\n        conversationId: model.switchConversationId,\n        model: modelId,\n      });\n    }\n    onClose();\n  };\n\n  return (\n    <>\n      {model.showAcpPicker ? (\n        <>\n          {/* role=\"presentation\" keeps this a valid <li> child of the\n              ContextMenu <ul> without exposing the section label as a\n              selectable menu item (the label text is still announced). */}\n          <li role=\"presentation\" className=\"px-2 pt-1 pb-0.5\">\n            <Typography.Text className=\"text-[11px] font-medium text-text-dim uppercase tracking-wide leading-4\">\n              {t(I18nKey.MODEL$AVAILABLE_MODELS)}\n            </Typography.Text>\n          </li>\n          {[\n            ...(model.agentDefaultLabel\n              ? [{ id: null, label: model.agentDefaultLabel }]\n              : []),\n            ...model.availableAcpModels,\n          ].map((option) => {\n            const isSelected = option.id === model.selectedModelId;\n            return (\n              <ContextMenuListItem\n                key={option.id ?? \"\"}\n                testId={`chat-input-acp-model-option-${option.id ?? \"agent-default\"}`}\n                onClick={(event) => {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  handleSelectAcpModel(option.id);\n                }}\n                className={cn(\n                  \"flex items-center gap-2\",\n                  isSelected && \"bg-interactive-hover\",\n                )}\n              >\n                <span\n                  className=\"flex-1 truncate text-sm leading-5\"\n                  title={option.label}\n                >\n                  {option.label}\n                </span>\n                {isSelected && (\n                  <CheckIcon\n                    width={14}\n                    height={14}\n                    className=\"shrink-0\"\n                    aria-hidden\n                  />\n                )}\n              </ContextMenuListItem>\n            );\n          })}\n        </>\n      ) : model.displayModel ? (\n        <li className=\"text-sm\">\n          <div className=\"p-2 leading-5 text-foreground break-all\">\n            {model.displayModel}\n          </div>\n        </li>\n      ) : null}\n      {showSettingsLink && hasModelRows && <Divider inset={dividerInset} />}\n      {showSettingsLink && (\n        <li className=\"text-sm\">\n          <NavigationLink\n            to={model.destinationPath}\n            onClick={onClose}\n            className={cn(\n              \"flex h-7.5 items-center gap-2 rounded p-2 leading-5 text-foreground hover:bg-interactive-hover transition-colors\",\n              settingsLinkClassName,\n            )}\n          >\n            <SettingsGearIcon\n              width={16}\n              height={16}\n              className={cn(\"shrink-0\", settingsIconClassName)}\n              aria-hidden\n            />\n            <span>{model.destinationLabel}</span>\n          </NavigationLink>\n        </li>\n      )}\n    </>\n  );\n}\n\nexport function ChatInputModel() {\n  const model = useChatInputModelState();\n  const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const popoverRef = useClickOutsideElement<HTMLUListElement>(\n    () => setIsPopoverOpen(false),\n    triggerRef,\n  );\n\n  if (!model.displayModel) {\n    return null;\n  }\n\n  const truncatedModelLabel = truncateModelLabel(\n    model.displayModel,\n    model.isAcpContext ? ACP_MODEL_LABEL_MAX_CHARS : MODEL_LABEL_MAX_CHARS,\n  );\n\n  return (\n    <div className=\"relative min-w-0\">\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        className={chatInputPillButtonClassName}\n        title={model.displayModel}\n        data-testid=\"chat-input-llm-model\"\n        aria-expanded={isPopoverOpen}\n        aria-haspopup=\"dialog\"\n        onClick={(event) => {\n          event.preventDefault();\n          event.stopPropagation();\n          setIsPopoverOpen((open) => !open);\n        }}\n      >\n        <span>{truncatedModelLabel}</span>\n        <ComboboxCaretInline isOpen={isPopoverOpen} />\n      </button>\n\n      {isPopoverOpen && (\n        <ContextMenu\n          ref={popoverRef}\n          testId=\"chat-input-llm-model-popover\"\n          position=\"top\"\n          alignment=\"left\"\n          spacing=\"none\"\n          className=\"z-[60] mb-2 min-w-50 max-w-80 max-h-[60vh] overflow-y-auto\"\n        >\n          <ChatInputModelMenuContent\n            model={model}\n            onClose={() => setIsPopoverOpen(false)}\n          />\n        </ContextMenu>\n      )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/components/chat-input-profile-picker.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 64,
          "column": 40,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 64,
          "endColumn": 113
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 109,
          "column": 42,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 109,
          "endColumn": 79
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[60]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 187,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 187,
          "endColumn": 81
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[60vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 187,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 187,
          "endColumn": 81
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useChatInputProfileState } from \"#/hooks/use-chat-input-profile-state\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport RobotIcon from \"#/icons/u-robot.svg?react\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { ComboboxCaretInline } from \"#/ui/combobox-caret\";\nimport { chatInputPillButtonClassName } from \"#/utils/form-control-classes\";\nimport SettingsGearIcon from \"#/icons/settings-gear.svg?react\";\nimport CheckIcon from \"#/icons/checkmark.svg?react\";\nimport { NavigationLink } from \"#/components/shared/navigation-link\";\nimport { ContextMenuListItem } from \"#/components/features/context-menu/context-menu-list-item\";\nimport { Divider } from \"#/ui/divider\";\nimport { Typography } from \"#/ui/typography\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { getLockedCloudHost } from \"#/api/agent-server-config\";\n\ninterface ChatInputProfileMenuContentProps {\n  onClose: () => void;\n  dividerInset?: \"menu\";\n  settingsLinkClassName?: string;\n  settingsIconClassName?: string;\n}\n\n/**\n * The agent-profile list rendered inside the \"+\" tools menu's \"Switch agent\n * profile\" submenu, offered only while starting a new conversation (OSS-5735 \u2014\n * the profile is locked once a conversation starts). Selecting a profile\n * activates it (home) or recreates the blank conversation with it (see\n * `useChatInputProfileState`). Also the menu of the\n * {@link ChatInputAgentProfilePicker} pill.\n */\nexport function ChatInputProfileMenuContent({\n  onClose,\n  dividerInset,\n  settingsLinkClassName,\n  settingsIconClassName,\n}: ChatInputProfileMenuContentProps) {\n  const { t } = useTranslation(\"openhands\");\n  const {\n    profiles,\n    currentProfileId,\n    isInConversation,\n    isSwitching,\n    selectProfile,\n  } = useChatInputProfileState();\n  // Locked-to-Cloud blocks the Canvas agent profiles page this links to\n  // (OHE-3457).\n  const showSettingsLink = getLockedCloudHost() === null;\n\n  const handleSelect = (profile: (typeof profiles)[number]) => {\n    selectProfile(profile);\n    onClose();\n  };\n\n  return (\n    <>\n      {profiles.length > 0 && (\n        <>\n          {/* role=\"presentation\" keeps this a valid <li> child of the\n              ContextMenu <ul> without exposing the label as a menu item. */}\n          <li role=\"presentation\" className=\"px-2 pt-1 pb-0.5\">\n            <Typography.Text className=\"text-[11px] font-medium text-text-dim uppercase tracking-wide leading-4\">\n              {t(I18nKey.SETTINGS$AVAILABLE_PROFILES)}\n            </Typography.Text>\n          </li>\n          {profiles.map((profile) => {\n            const isCurrent =\n              profile.id != null && profile.id === currentProfileId;\n            return (\n              <ContextMenuListItem\n                key={profile.id ?? profile.name}\n                testId={`chat-input-agent-profile-option-${profile.name}`}\n                isDisabled={isSwitching}\n                onClick={(event) => {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  if (isCurrent) {\n                    onClose();\n                    return;\n                  }\n                  handleSelect(profile);\n                }}\n                className={cn(\n                  \"flex items-center gap-2\",\n                  isCurrent && \"bg-interactive-hover\",\n                )}\n              >\n                <span\n                  className=\"flex-1 truncate text-sm leading-5\"\n                  title={profile.name}\n                >\n                  {profile.name}\n                </span>\n                {isCurrent && (\n                  <CheckIcon\n                    width={14}\n                    height={14}\n                    className=\"shrink-0\"\n                    aria-hidden\n                  />\n                )}\n              </ContextMenuListItem>\n            );\n          })}\n          {isInConversation && (\n            <li role=\"presentation\" className=\"px-2 pt-0.5 pb-1\">\n              <Typography.Text className=\"text-[11px] text-text-dim leading-4\">\n                {t(I18nKey.CHAT$START_NEW_WITH_PROFILE_HINT)}\n              </Typography.Text>\n            </li>\n          )}\n        </>\n      )}\n      {showSettingsLink && profiles.length > 0 && (\n        <Divider inset={dividerInset} />\n      )}\n      {showSettingsLink && (\n        <li className=\"text-sm\">\n          <NavigationLink\n            to=\"/settings/agents\"\n            onClick={onClose}\n            className={cn(\n              \"flex h-7.5 items-center gap-2 rounded p-2 leading-5 text-foreground hover:bg-interactive-hover transition-colors\",\n              settingsLinkClassName,\n            )}\n          >\n            <SettingsGearIcon\n              width={16}\n              height={16}\n              className={cn(\"shrink-0\", settingsIconClassName)}\n              aria-hidden\n            />\n            <span>{t(I18nKey.CHAT$MANAGE_AGENT_PROFILES)}</span>\n          </NavigationLink>\n        </li>\n      )}\n    </>\n  );\n}\n\n/** Names the agent profile a new conversation will run, and switches it. */\nexport function ChatInputAgentProfilePicker() {\n  const { t } = useTranslation(\"openhands\");\n  const { currentProfileName, isLoading, isSwitching } =\n    useChatInputProfileState();\n  const [isOpen, setIsOpen] = React.useState(false);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const popoverRef = useClickOutsideElement<HTMLUListElement>(\n    () => setIsOpen(false),\n    triggerRef,\n  );\n\n  if (isLoading || !currentProfileName) return null;\n\n  return (\n    <div className=\"relative min-w-0\">\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        className={cn(chatInputPillButtonClassName, \"max-w-40\")}\n        title={t(I18nKey.CHAT$SWITCH_AGENT_PROFILE)}\n        aria-label={`${t(I18nKey.CHAT$SWITCH_AGENT_PROFILE)}: ${currentProfileName}`}\n        data-testid=\"chat-input-agent-profile\"\n        aria-expanded={isOpen}\n        aria-haspopup=\"dialog\"\n        disabled={isSwitching}\n        aria-busy={isSwitching}\n        onClick={(event) => {\n          event.preventDefault();\n          event.stopPropagation();\n          setIsOpen((open) => !open);\n        }}\n      >\n        <RobotIcon width={14} height={14} className=\"shrink-0\" aria-hidden />\n        <span className=\"truncate\">{currentProfileName}</span>\n        <ComboboxCaretInline isOpen={isOpen} />\n      </button>\n      {isOpen && (\n        <ContextMenu\n          ref={popoverRef}\n          testId=\"chat-input-agent-profile-popover\"\n          position=\"top\"\n          alignment=\"left\"\n          spacing=\"none\"\n          className=\"z-[60] mb-2 min-w-50 max-w-80 max-h-[60vh] overflow-y-auto\"\n        >\n          <ChatInputProfileMenuContent onClose={() => setIsOpen(false)} />\n        </ContextMenu>\n      )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/components/context-window-meter.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[60]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 104,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 104,
          "endColumn": 60
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets borderColor. Style through classes; use CSS custom properties for dynamic values.",
          "line": 122,
          "column": 37,
          "nodeType": "Identifier",
          "messageId": "inlineStyle",
          "endLine": 122,
          "endColumn": 63
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 135,
          "column": 33,
          "nodeType": "TemplateLiteral",
          "messageId": "inlineStyle",
          "endLine": 135,
          "endColumn": 69
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { Gauge, Loader2, Minimize } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useContextWindowUsage } from \"#/hooks/use-context-window-usage\";\nimport { useSelectConversationTab } from \"#/hooks/use-select-conversation-tab\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { useCloseOnEscape } from \"#/hooks/use-close-on-escape\";\nimport { useCompactContextAction } from \"#/hooks/use-compact-context-action\";\nimport { ContextMenuListItem } from \"#/components/features/context-menu/context-menu-list-item\";\nimport { ConversationNameContextMenuIconText } from \"#/components/features/conversation/conversation-name-context-menu-icon-text\";\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { Divider } from \"#/ui/divider\";\nimport { cn } from \"#/utils/utils\";\nimport { chatInputIconButtonClassName } from \"#/utils/form-control-classes\";\nimport {\n  formatCompactTokenCount,\n  getContextWindowUsagePercentage,\n} from \"#/utils/format-token-count\";\nimport { getContextFillTone } from \"#/components/features/conversation/usage-panel/context-meter\";\nimport {\n  ContextWindowRing,\n  CONTEXT_WINDOW_TRACK_COLOR,\n} from \"./context-window-ring\";\n\nconst TONE_BAR_CLASS = {\n  neutral: \"bg-foreground\",\n  warning: \"bg-feedback-warning\",\n  danger: \"bg-status-error\",\n} as const;\n\nconst TONE_LABEL_CLASS = {\n  neutral: \"text-muted\",\n  warning: \"text-feedback-warning\",\n  danger: \"text-feedback-error\",\n} as const;\n\nexport function ContextWindowMeter() {\n  const { t } = useTranslation(\"openhands\");\n  const usage = useContextWindowUsage();\n  const { navigateToTab } = useSelectConversationTab();\n  const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const popoverRef = useClickOutsideElement<HTMLDivElement>(\n    () => setIsPopoverOpen(false),\n    triggerRef,\n  );\n  useCloseOnEscape(isPopoverOpen, () => setIsPopoverOpen(false), triggerRef);\n  const { handleCompact, isCompacting, isDisabled } = useCompactContextAction(\n    usage?.perTurnToken ?? 0,\n  );\n\n  if (!usage) {\n    return null;\n  }\n\n  const usagePercentage = getContextWindowUsagePercentage(\n    usage.perTurnToken,\n    usage.contextWindow,\n  );\n  const roundedPercentage = Math.round(usagePercentage);\n  const remainingPercentage = Math.max(0, 100 - roundedPercentage);\n  const usagePercentLabel = `${roundedPercentage}% ${t(I18nKey.CONVERSATION$USED)} (${remainingPercentage}% ${t(I18nKey.CONVERSATION$LEFT)})`;\n  const usageTokenSummary = `${formatCompactTokenCount(usage.perTurnToken)} / ${formatCompactTokenCount(usage.contextWindow)}`;\n  const tone = getContextFillTone(usagePercentage);\n\n  const openUsagePanel = () => {\n    setIsPopoverOpen(false);\n    navigateToTab(\"usage\");\n  };\n\n  return (\n    <div className=\"relative shrink-0\">\n      <StyledTooltip\n        content={t(I18nKey.CHAT_INTERFACE$SHOW_CONTEXT)}\n        placement=\"top\"\n        // While the popover is open the hint is redundant, and an open\n        // tooltip would swallow the Escape meant for the popover.\n        isDisabled={isPopoverOpen}\n      >\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          className={cn(chatInputIconButtonClassName, \"size-8\")}\n          aria-label={`${t(I18nKey.CHAT_INTERFACE$CONTEXT_WINDOW_METER_LABEL)}: ${usagePercentLabel}`}\n          aria-expanded={isPopoverOpen}\n          aria-haspopup=\"dialog\"\n          data-testid=\"context-window-meter\"\n          onClick={(event) => {\n            event.preventDefault();\n            event.stopPropagation();\n            setIsPopoverOpen((open) => !open);\n          }}\n        >\n          <ContextWindowRing percentage={usagePercentage} />\n        </button>\n      </StyledTooltip>\n\n      {isPopoverOpen && (\n        <div\n          ref={popoverRef}\n          data-testid=\"context-window-meter-popover\"\n          className={cn(\n            \"absolute bottom-full right-0 z-[60] mb-2 w-70\",\n            \"flex flex-col gap-0.5 rounded-md border border-border-subtle bg-surface-raised px-1 py-1 shadow-lg\",\n          )}\n        >\n          <div className=\"flex flex-col gap-2 px-2 py-1.5\">\n            <div className=\"flex items-center justify-between gap-2 text-sm\">\n              <span className=\"font-semibold text-foreground\">\n                {t(I18nKey.CONVERSATION$CONTEXT_WINDOW)}\n              </span>\n              <span className={cn(\"shrink-0 text-xs\", TONE_LABEL_CLASS[tone])}>\n                {usagePercentLabel}\n              </span>\n            </div>\n\n            <button\n              type=\"button\"\n              data-testid=\"context-window-meter-bar-button\"\n              className=\"relative h-1.5 w-full rounded-full border bg-tertiary cursor-pointer\"\n              style={{ borderColor: CONTEXT_WINDOW_TRACK_COLOR }}\n              aria-label={t(I18nKey.COMMON$USAGE)}\n              onClick={(event) => {\n                event.preventDefault();\n                event.stopPropagation();\n                openUsagePanel();\n              }}\n            >\n              <span\n                className={cn(\n                  \"absolute inset-y-0 left-0 rounded-full transition-all duration-300\",\n                  TONE_BAR_CLASS[tone],\n                )}\n                style={{ width: `${Math.min(100, usagePercentage)}%` }}\n              />\n            </button>\n\n            <div className=\"flex items-center justify-between gap-2\">\n              <button\n                type=\"button\"\n                data-testid=\"context-window-compact-button\"\n                disabled={isDisabled}\n                aria-busy={isCompacting}\n                aria-label={t(I18nKey.CONVERSATION$COMPACT_CONTEXT)}\n                className={cn(\n                  \"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs\",\n                  \"text-muted hover:bg-interactive-hover hover:text-foreground\",\n                  \"transition-colors disabled:cursor-not-allowed disabled:opacity-50\",\n                )}\n                onClick={(event) => {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  handleCompact();\n                }}\n              >\n                {isCompacting ? (\n                  <Loader2 className=\"size-3 animate-spin\" aria-hidden />\n                ) : (\n                  <Minimize className=\"size-3\" aria-hidden />\n                )}\n                <span>{t(I18nKey.CONVERSATION$COMPACT_CONTEXT)}</span>\n              </button>\n              <span className=\"text-xs text-muted\">{usageTokenSummary}</span>\n            </div>\n          </div>\n\n          <Divider inset=\"menu\" />\n\n          <ContextMenuListItem\n            testId=\"context-window-plan-usage\"\n            onClick={(event) => {\n              event.preventDefault();\n              event.stopPropagation();\n              openUsagePanel();\n            }}\n          >\n            <ConversationNameContextMenuIconText\n              icon={<Gauge size={16} />}\n              text={t(I18nKey.COMMON$USAGE)}\n            />\n          </ContextMenuListItem>\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/components/context-window-ring.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets stroke. Style through classes; use CSS custom properties for dynamic values.",
          "line": 62,
          "column": 26,
          "nodeType": "Identifier",
          "messageId": "inlineStyle",
          "endLine": 62,
          "endColumn": 52
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"transition-[stroke-dashoffset,stroke]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 77,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 77,
          "endColumn": 71
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cn } from \"#/utils/utils\";\nimport { getContextFillTone } from \"#/components/features/conversation/usage-panel/context-meter\";\n\nconst CONTEXT_WINDOW_RING_SIZE = 16;\nconst CONTEXT_WINDOW_RING_STROKE = 2;\n\n/**\n * Default opacity of the ring's unfilled track on dark themes.\n *\n * The track is derived from the foreground rather than pinned to a scale stop.\n * It carries information (the arc's proportion is only readable against it), so\n * it is a foreground element, and every stop in the surface family sits close\n * to the surfaces it delimits. Drawing it with `--oh-border` put it in that\n * family: it was 1.57:1 against the composer at rest, and the trigger's hover\n * fill resolves to the same stop, taking it to 1.00:1. No stop in that family\n * fixes it, and no fixed stop holds across the three palettes in\n * `color-themes.ts`, whose scales differ.\n *\n * Light palettes override the foreground and weight tokens because a single\n * translucent mix cannot preserve 3:1 contrast on both dark and light\n * surfaces. `context-window-ring.test.tsx` asserts the effective values for\n * every shipped theme.\n */\n/** Shared by the ring's track and the popover's usage bar, which had the same defect. */\nexport const CONTEXT_WINDOW_TRACK_COLOR =\n  \"color-mix(in srgb, var(--oh-context-window-foreground) var(--oh-context-window-track-weight), transparent)\";\n\nconst TONE_STROKE = {\n  neutral: \"var(--oh-context-window-foreground)\",\n  warning: \"#f59e0b\", // amber-500\n  danger: \"#ef4444\", // red-500\n} as const;\n\ninterface ContextWindowRingProps {\n  percentage: number;\n  className?: string;\n}\n\nexport function ContextWindowRing({\n  percentage,\n  className,\n}: ContextWindowRingProps) {\n  const radius = (CONTEXT_WINDOW_RING_SIZE - CONTEXT_WINDOW_RING_STROKE) / 2;\n  const circumference = 2 * Math.PI * radius;\n  const clampedPercentage = Math.min(100, Math.max(0, percentage));\n  const dashOffset = circumference - (clampedPercentage / 100) * circumference;\n  const tone = getContextFillTone(clampedPercentage);\n\n  return (\n    <svg\n      width={CONTEXT_WINDOW_RING_SIZE}\n      height={CONTEXT_WINDOW_RING_SIZE}\n      viewBox={`0 0 ${CONTEXT_WINDOW_RING_SIZE} ${CONTEXT_WINDOW_RING_SIZE}`}\n      className={cn(\"shrink-0\", className)}\n      aria-hidden\n    >\n      <circle\n        cx={CONTEXT_WINDOW_RING_SIZE / 2}\n        cy={CONTEXT_WINDOW_RING_SIZE / 2}\n        r={radius}\n        fill=\"none\"\n        style={{ stroke: CONTEXT_WINDOW_TRACK_COLOR }}\n        strokeWidth={CONTEXT_WINDOW_RING_STROKE}\n        data-testid=\"context-window-ring-track\"\n      />\n      <circle\n        cx={CONTEXT_WINDOW_RING_SIZE / 2}\n        cy={CONTEXT_WINDOW_RING_SIZE / 2}\n        r={radius}\n        fill=\"none\"\n        stroke={TONE_STROKE[tone]}\n        strokeWidth={CONTEXT_WINDOW_RING_STROKE}\n        strokeLinecap=\"round\"\n        strokeDasharray={circumference}\n        strokeDashoffset={dashOffset}\n        transform={`rotate(-90 ${CONTEXT_WINDOW_RING_SIZE / 2} ${CONTEXT_WINDOW_RING_SIZE / 2})`}\n        className=\"transition-[stroke-dashoffset,stroke] duration-300\"\n        data-testid=\"context-window-ring-arc\"\n      />\n    </svg>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/drag-over.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rotate-[-27deg]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 16,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 16,
          "endColumn": 74
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"top-[-40px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 16,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 16,
          "endColumn": 74
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"left-[-10px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 16,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 16,
          "endColumn": 74
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"top-[-16px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 21,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 21,
          "endColumn": 58
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"left-[-20px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 21,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 21,
          "endColumn": 58
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 5,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport ImageIcon from \"#/icons/image.svg?react\";\nimport ArrowDownCurveIcon from \"#/icons/arrow-down-curve.svg?react\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\nexport function DragOver() {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <div className=\"drag-over\">\n      <div className=\"drag-over-content-wrapper\">\n        <div className=\"relative\">\n          <ImageIcon\n            width={36}\n            height={36}\n            className=\"rotate-[-27deg] absolute top-[-40px] left-[-10px]\"\n          />\n          <ArrowDownCurveIcon\n            width={16}\n            height={16}\n            className=\"absolute top-[-16px] left-[-20px]\"\n          />\n        </div>\n        <div>\n          <p>{t(I18nKey.COMMON$DROP_YOUR_FILES_HERE)}</p>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/git-control-bar-branch-button.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[100px]\" hardcodes an off-token value. Nearest on the scale: rounded-4xl (32px), rounded-3xl (24px).",
          "line": 49,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 49,
          "endColumn": 147
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[rgba(71,74,84,0.50)]\" hardcodes a color. Nearest theme tokens: border-border, border-border-subtle. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 52,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 52,
          "endColumn": 93,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-border"
              },
              "fix": {
                "range": [
                  1882,
                  1962
                ],
                "text": "\"border border-border bg-transparent cursor-not-allowed min-w-27\""
              },
              "desc": "Replace with \"border-border\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-border-subtle"
              },
              "fix": {
                "range": [
                  1882,
                  1962
                ],
                "text": "\"border border-border-subtle bg-transparent cursor-not-allowed min-w-27\""
              },
              "desc": "Replace with \"border-border-subtle\"."
            }
          ]
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport BranchIcon from \"#/icons/u-code-branch.svg?react\";\nimport { constructBranchUrl, cn } from \"#/utils/utils\";\nimport { Provider } from \"#/types/settings\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { GitExternalLinkIcon } from \"./git-external-link-icon\";\nimport { useSettings } from \"#/hooks/query/use-settings\";\n\ninterface GitControlBarBranchButtonProps {\n  selectedBranch: string | null | undefined;\n  selectedRepository: string | null | undefined;\n  gitProvider: Provider | null | undefined;\n}\n\nexport function GitControlBarBranchButton({\n  selectedBranch,\n  selectedRepository,\n  gitProvider,\n}: GitControlBarBranchButtonProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { data: settings } = useSettings();\n\n  const providerHost = gitProvider\n    ? settings?.provider_tokens_set[gitProvider]\n    : null;\n\n  // Render the linked styling only when we have enough info to build a URL.\n  // Local-workspace fallbacks may give us a branch name without a known\n  // provider \u2014 show the branch text in that case but skip the external link.\n  const hasLinkableBranch =\n    !!selectedBranch && !!selectedRepository && !!gitProvider;\n  const branchUrl = hasLinkableBranch\n    ? constructBranchUrl(\n        gitProvider,\n        selectedRepository,\n        selectedBranch,\n        providerHost,\n      )\n    : undefined;\n\n  const buttonText = selectedBranch || t(I18nKey.COMMON$NO_BRANCH);\n\n  return (\n    <a\n      href={hasLinkableBranch ? branchUrl : undefined}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className={cn(\n        \"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] w-fit flex-shrink-0 max-w-50 truncate relative\",\n        hasLinkableBranch\n          ? \"border border-border bg-transparent hover:border-border-subtle cursor-pointer\"\n          : \"border border-[rgba(71,74,84,0.50)] bg-transparent cursor-not-allowed min-w-27\",\n      )}\n    >\n      <div className=\"w-3 h-3 flex items-center justify-center flex-shrink-0 text-contrast\">\n        <BranchIcon width={12} height={12} />\n      </div>\n      <div\n        className=\"font-normal text-contrast text-sm leading-5 truncate\"\n        title={buttonText}\n      >\n        {buttonText}\n      </div>\n      {hasLinkableBranch && <GitExternalLinkIcon />}\n    </a>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/git-control-bar-repo-button.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[100px]\" hardcodes an off-token value. Nearest on the scale: rounded-4xl (32px), rounded-3xl (24px).",
          "line": 57,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 57,
          "endColumn": 127
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[100px]\" hardcodes an off-token value. Nearest on the scale: rounded-4xl (32px), rounded-3xl (24px).",
          "line": 84,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 84,
          "endColumn": 118
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[rgba(71,74,84,0.50)]\" hardcodes a color. Nearest theme tokens: border-border, border-border-subtle. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 85,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 85,
          "endColumn": 61,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-border"
              },
              "fix": {
                "range": [
                  2928,
                  2980
                ],
                "text": "\"border border-border bg-transparent\""
              },
              "desc": "Replace with \"border-border\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-border-subtle"
              },
              "fix": {
                "range": [
                  2928,
                  2980
                ],
                "text": "\"border border-border-subtle bg-transparent\""
              },
              "desc": "Replace with \"border-border-subtle\"."
            }
          ]
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { FolderOpen } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { constructRepositoryUrl, cn } from \"#/utils/utils\";\nimport { Provider } from \"#/types/settings\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { GitProviderIcon } from \"#/components/shared/git-provider-icon\";\nimport { GitExternalLinkIcon } from \"./git-external-link-icon\";\nimport RepoForkedIcon from \"#/icons/repo-forked.svg?react\";\nimport { useSettings } from \"#/hooks/query/use-settings\";\n\ninterface GitControlBarRepoButtonProps {\n  selectedRepository: string | null | undefined;\n  gitProvider: Provider | null | undefined;\n  workspaceName?: string | null;\n  emptyStateLabel?: string;\n  onClick?: () => void;\n  disabled?: boolean;\n}\n\nexport function GitControlBarRepoButton({\n  selectedRepository,\n  gitProvider,\n  workspaceName,\n  emptyStateLabel: _emptyStateLabel,\n  onClick,\n  disabled,\n}: GitControlBarRepoButtonProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { data: settings } = useSettings();\n\n  // Render as an external link only when we know both the repo and the\n  // provider (so the URL is well-defined). Local-workspace fallbacks may\n  // populate `selectedRepository` without a provider; in that case we still\n  // show the repo name but keep the button non-link styling.\n  const hasLinkableRepo = !!selectedRepository && !!gitProvider;\n\n  // Get the host for the current provider from settings\n  const providerHost = gitProvider\n    ? settings?.provider_tokens_set[gitProvider]\n    : null;\n\n  const repositoryUrl = hasLinkableRepo\n    ? constructRepositoryUrl(gitProvider, selectedRepository, providerHost)\n    : undefined;\n\n  const showConnectRepoCta = !selectedRepository && !workspaceName;\n  const buttonText =\n    selectedRepository || workspaceName || t(I18nKey.COMMON$CONNECT_REPO);\n\n  if (hasLinkableRepo) {\n    return (\n      <a\n        href={repositoryUrl}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className={cn(\n          \"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] flex-1 truncate relative\",\n          \"border border-border bg-transparent hover:border-border-subtle cursor-pointer\",\n        )}\n      >\n        <div className=\"w-3 h-3 flex items-center justify-center flex-shrink-0\">\n          <GitProviderIcon\n            gitProvider={gitProvider as Provider}\n            className=\"w-3 h-3 inline-flex\"\n          />\n        </div>\n        <div\n          className=\"font-normal text-contrast text-sm leading-5 truncate flex-1 min-w-0\"\n          title={buttonText}\n        >\n          {buttonText}\n        </div>\n        <GitExternalLinkIcon />\n      </a>\n    );\n  }\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      disabled={disabled}\n      className={cn(\n        \"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] truncate relative\",\n        \"border border-[rgba(71,74,84,0.50)] bg-transparent\",\n        disabled\n          ? \"cursor-not-allowed opacity-50\"\n          : \"cursor-pointer hover:border-border-subtle\",\n      )}\n    >\n      <div className=\"w-3 h-3 flex items-center justify-center flex-shrink-0 text-contrast\">\n        {showConnectRepoCta ? (\n          <FolderOpen\n            className=\"w-3 h-3\"\n            strokeWidth={2}\n            aria-hidden\n            data-testid=\"git-control-bar-connect-repo-icon\"\n          />\n        ) : (\n          <RepoForkedIcon width={12} height={12} />\n        )}\n      </div>\n      <div\n        className=\"font-normal text-contrast text-sm leading-5 truncate flex-1 min-w-0\"\n        title={buttonText}\n      >\n        {buttonText}\n      </div>\n    </button>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/plan-preview.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[#597FF4]\" hardcodes a color and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css.",
          "line": 70,
          "column": 20,
          "nodeType": "Literal",
          "messageId": "arbitraryColorFar",
          "endLine": 70,
          "endColumn": 79
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 74,
          "column": 36,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 74,
          "endColumn": 103
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"tracking-[0.11px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 74,
          "column": 36,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 74,
          "endColumn": 103
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 84,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 84,
          "endColumn": 105
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"tracking-[0.11px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 84,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 84,
          "endColumn": 105
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[15px]\" hardcodes an off-token value. Nearest on the scale: text-sm (14px), text-base (16px).",
          "line": 94,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 94,
          "endColumn": 85
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#4a67bd]\" hardcodes a color. Nearest theme tokens: text-cool-grey-600. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 108,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 108,
          "endColumn": 84,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-600"
              },
              "fix": {
                "range": [
                  3887,
                  3944
                ],
                "text": "\"text-cool-grey-600 cursor-pointer hover:underline text-left\""
              },
              "desc": "Replace with \"text-cool-grey-600\"."
            }
          ]
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "i18next/no-literal-string",
          "severity": 2,
          "message": "disallow literal string: <Typography.Text className=\"font-normal text-contrast-foreground\">\n              \u2318\u21a9\n            </Typography.Text>",
          "line": 134,
          "column": 79,
          "nodeType": "JSXText",
          "endLine": 136,
          "endColumn": 13,
          "suppressions": [
            {
              "kind": "directive",
              "justification": ""
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 7,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useMemo, useCallback } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ArrowUpRight } from \"lucide-react\";\nimport LessonPlanIcon from \"#/icons/lesson-plan.svg?react\";\nimport { Typography } from \"#/ui/typography\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { MarkdownRenderer } from \"#/components/features/markdown/markdown-renderer\";\nimport { useHandleBuildPlanClick } from \"#/hooks/use-handle-build-plan-click\";\nimport { cn } from \"#/utils/utils\";\nimport { useSelectConversationTab } from \"#/hooks/use-select-conversation-tab\";\nimport {\n  planComponents,\n  createPlanComponents,\n} from \"#/components/features/markdown/plan-components\";\nimport { useScrollContext } from \"#/context/scroll-context\";\n\nconst MAX_CONTENT_LENGTH = 300;\n\n// Shine effect class for streaming text\nconst SHINE_TEXT_CLASS = \"shine-text\";\n\n// Plan components with shine effect applied for streaming state\nconst shineComponents = createPlanComponents(SHINE_TEXT_CLASS);\n\ninterface PlanPreviewProps {\n  /** Raw plan content from PLAN.md file */\n  planContent?: string | null;\n  /** Whether the plan content is actively being streamed */\n  isStreaming?: boolean;\n  /** Whether the Build button should be disabled (e.g., while streaming) */\n  isBuildDisabled?: boolean;\n}\n\n/* eslint-disable i18next/no-literal-string */\nexport function PlanPreview({\n  planContent,\n  isStreaming,\n  isBuildDisabled,\n}: PlanPreviewProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { navigateToTab } = useSelectConversationTab();\n  const { handleBuildPlanClick } = useHandleBuildPlanClick();\n  const { scrollDomToBottom } = useScrollContext();\n\n  const handleViewClick = () => {\n    navigateToTab(\"planner\");\n  };\n\n  // Handle Build action with scroll to bottom\n  const handleBuildClick = useCallback(\n    (event?: React.MouseEvent<HTMLButtonElement>) => {\n      handleBuildPlanClick(event);\n      scrollDomToBottom();\n    },\n    [handleBuildPlanClick, scrollDomToBottom],\n  );\n\n  // Truncate plan content for preview\n  const truncatedContent = useMemo(() => {\n    if (!planContent) return \"\";\n    if (planContent.length <= MAX_CONTENT_LENGTH) return planContent;\n    return `${planContent.slice(0, MAX_CONTENT_LENGTH)}...`;\n  }, [planContent]);\n\n  if (!planContent) {\n    return null;\n  }\n\n  return (\n    <div className=\"bg-surface border border-[#597FF4] rounded-xl w-full mt-2\">\n      {/* Header */}\n      <div className=\"border-b border-border flex h-10.25 items-center px-2 gap-1\">\n        <LessonPlanIcon width={18} height={18} color=\"var(--oh-muted)\" />\n        <Typography.Text className=\"font-normal text-[11px] text-contrast tracking-[0.11px] leading-4\">\n          {t(I18nKey.COMMON$PLAN_MD)}\n        </Typography.Text>\n        <div className=\"flex-1\" />\n        <button\n          type=\"button\"\n          onClick={handleViewClick}\n          className=\"flex items-center gap-1 hover:opacity-80 transition-opacity cursor-pointer\"\n          data-testid=\"plan-preview-view-button\"\n        >\n          <Typography.Text className=\"font-normal text-[11px] text-contrast tracking-[0.11px] leading-4\">\n            {t(I18nKey.COMMON$VIEW)}\n          </Typography.Text>\n          <ArrowUpRight className=\"text-contrast\" size={18} />\n        </button>\n      </div>\n\n      {/* Content */}\n      <div\n        data-testid=\"plan-preview-content\"\n        className=\"flex flex-col gap-2.5 p-4 text-[15px] text-contrast leading-7.25\"\n      >\n        {truncatedContent && (\n          <>\n            <MarkdownRenderer\n              includeStandard\n              components={isStreaming ? shineComponents : planComponents}\n            >\n              {truncatedContent}\n            </MarkdownRenderer>\n            {planContent && planContent.length > MAX_CONTENT_LENGTH && (\n              <button\n                type=\"button\"\n                onClick={handleViewClick}\n                className=\"text-[#4a67bd] cursor-pointer hover:underline text-left\"\n                data-testid=\"plan-preview-read-more-button\"\n              >\n                {t(I18nKey.COMMON$READ_MORE)}\n              </button>\n            )}\n          </>\n        )}\n      </div>\n\n      {/* Footer */}\n      <div className=\"border-t border-border flex h-13.5 items-center justify-start px-4\">\n        <button\n          type=\"button\"\n          onClick={handleBuildClick}\n          disabled={isBuildDisabled}\n          className={cn(\n            \"bg-contrast flex items-center justify-center h-6.5 px-2 rounded-sm w-23.25 transition-opacity\",\n            isBuildDisabled\n              ? \"opacity-50 cursor-not-allowed\"\n              : \"hover:opacity-90 cursor-pointer\",\n          )}\n          data-testid=\"plan-preview-build-button\"\n        >\n          <Typography.Text className=\"font-normal text-sm text-contrast-foreground leading-5\">\n            {t(I18nKey.COMMON$BUILD)}{\" \"}\n            <Typography.Text className=\"font-normal text-contrast-foreground\">\n              \u2318\u21a9\n            </Typography.Text>\n          </Typography.Text>\n        </button>\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/task-tracking/task-item.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#ffffff]\" hardcodes a color. Nearest theme tokens: text-primary, text-content-2. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 25,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 25,
          "endColumn": 62,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-primary"
              },
              "fix": {
                "range": [
                  756,
                  780
                ],
                "text": "\"w-4 h-4 text-primary\""
              },
              "desc": "Replace with \"text-primary\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-content-2"
              },
              "fix": {
                "range": [
                  756,
                  780
                ],
                "text": "\"w-4 h-4 text-content-2\""
              },
              "desc": "Replace with \"text-content-2\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#ffffff]\" hardcodes a color. Nearest theme tokens: text-primary, text-content-2. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 27,
          "column": 47,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 27,
          "endColumn": 71,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-primary"
              },
              "fix": {
                "range": [
                  857,
                  881
                ],
                "text": "\"w-4 h-4 text-primary\""
              },
              "desc": "Replace with \"text-primary\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-content-2"
              },
              "fix": {
                "range": [
                  857,
                  881
                ],
                "text": "\"w-4 h-4 text-content-2\""
              },
              "desc": "Replace with \"text-content-2\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#ffffff]\" hardcodes a color. Nearest theme tokens: text-primary, text-content-2. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 31,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 31,
          "endColumn": 62,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-primary"
              },
              "fix": {
                "range": [
                  1024,
                  1048
                ],
                "text": "\"w-4 h-4 text-primary\""
              },
              "desc": "Replace with \"text-primary\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-content-2"
              },
              "fix": {
                "range": [
                  1024,
                  1048
                ],
                "text": "\"w-4 h-4 text-content-2\""
              },
              "desc": "Replace with \"text-content-2\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 50,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 50,
          "endColumn": 62
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport CircleIcon from \"#/icons/u-circle.svg?react\";\nimport CheckCircleIcon from \"#/icons/u-check-circle.svg?react\";\nimport CheckCircleHalfIcon from \"#/icons/u-check-circle-half.svg?react\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { Typography } from \"#/ui/typography\";\n\ninterface TaskItemProps {\n  task: {\n    id: string;\n    title: string;\n    status: \"todo\" | \"in_progress\" | \"done\";\n    notes?: string;\n  };\n}\n\nexport function TaskItem({ task }: TaskItemProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  const icon = useMemo(() => {\n    switch (task.status) {\n      case \"todo\":\n        return <CircleIcon className=\"w-4 h-4 text-[#ffffff]\" />;\n      case \"in_progress\":\n        return <CheckCircleHalfIcon className=\"w-4 h-4 text-[#ffffff]\" />;\n      case \"done\":\n        return <CheckCircleIcon className=\"w-4 h-4 text-muted\" />;\n      default:\n        return <CircleIcon className=\"w-4 h-4 text-[#ffffff]\" />;\n    }\n  }, [task.status]);\n\n  const isDoneStatus = task.status === \"done\";\n\n  return (\n    <div className=\"flex gap-2 items-center w-full\" data-name=\"item\">\n      <div className=\"shrink-0\">{icon}</div>\n      <div className=\"flex flex-col items-start justify-center leading-4 text-nowrap whitespace-pre font-normal\">\n        <Typography.Text\n          className={cn(\n            \"text-xs\",\n            isDoneStatus ? \"text-muted\" : \"text-contrast\",\n          )}\n        >\n          {task.title}\n        </Typography.Text>\n        {task.notes && (\n          <Typography.Text className=\"text-[10px] text-muted\">\n            {t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}\n          </Typography.Text>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/tool-visualizers/primitives/code-block.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 55,
          "column": 14,
          "nodeType": "CallExpression",
          "messageId": "dynamicStyle",
          "endLine": 55,
          "endColumn": 51
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { SyntaxHighlighter } from \"../../../markdown/syntax-highlighter\";\nimport { CopyableContentWrapper } from \"#/components/shared/buttons/copyable-content-wrapper\";\nimport { MAX_CONTENT_LENGTH } from \"#/components/conversation-events/chat/event-content-helpers/shared\";\nimport { useColorTheme } from \"#/hooks/use-color-theme\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { getSyntaxHighlighterTheme } from \"#/themes/syntax-highlighter-themes\";\n\ninterface CodeBlockProps {\n  code: string;\n  /** Prism language hint (e.g. \"bash\", \"python\"). */\n  language?: string;\n  /** Show a copy button on hover. Defaults to true. */\n  copy?: boolean;\n  /** Text shown when code is empty. */\n  placeholder?: string;\n  /** Let truncated content be expanded inline. Defaults to false. */\n  expandable?: boolean;\n  /** Wrap long lines instead of horizontal-only scrolling. Defaults to false. */\n  wrapLongLines?: boolean;\n}\n\n/**\n * Syntax-highlighted code block with an optional hover copy button. Long\n * content is truncated to the same limit the markdown path uses, optionally\n * with an inline expand control. The copy button always yields the full,\n * untruncated text.\n */\nexport function CodeBlock({\n  code,\n  language,\n  copy = true,\n  placeholder,\n  expandable = false,\n  wrapLongLines = false,\n}: CodeBlockProps) {\n  const { t } = useTranslation(\"openhands\");\n  const colorTheme = useColorTheme();\n  const [isExpanded, setIsExpanded] = React.useState(false);\n  const isTruncated = code.length > MAX_CONTENT_LENGTH;\n  const display =\n    isTruncated && !(expandable && isExpanded)\n      ? `${code.slice(0, MAX_CONTENT_LENGTH)}\u2026`\n      : code;\n  const text = display.trim() || placeholder || \"\";\n  const canCopy = copy && code.trim().length > 0;\n  const toggleLabel = isExpanded\n    ? t(I18nKey.BUTTON$COLLAPSE)\n    : t(I18nKey.BUTTON$EXPAND);\n\n  const block = (\n    <SyntaxHighlighter\n      className=\"rounded-lg text-xs\"\n      style={getSyntaxHighlighterTheme(colorTheme)}\n      language={language}\n      PreTag=\"div\"\n      wrapLongLines={wrapLongLines}\n      customStyle={wrapLongLines ? { whiteSpace: \"pre-wrap\" } : undefined}\n      codeTagProps={\n        wrapLongLines ? { style: { whiteSpace: \"pre-wrap\" } } : undefined\n      }\n    >\n      {text}\n    </SyntaxHighlighter>\n  );\n\n  return (\n    <div className=\"flex flex-col gap-1\">\n      {canCopy ? (\n        <CopyableContentWrapper text={code}>{block}</CopyableContentWrapper>\n      ) : (\n        block\n      )}\n      {expandable && isTruncated && (\n        <button\n          type=\"button\"\n          onClick={() => setIsExpanded((prev) => !prev)}\n          className=\"self-start text-xs text-muted transition-colors hover:text-contrast hover:underline\"\n        >\n          {toggleLabel}\n        </button>\n      )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/tool-visualizers/primitives/key-value-grid.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"grid-cols-[auto_1fr]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 14,
          "column": 20,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 14,
          "endColumn": 71
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\n\ninterface KeyValueRow {\n  /** Already-translated label. */\n  label: string;\n  value: React.ReactNode;\n}\n\n/**\n * Two-column label / value grid for compact parameter displays.\n */\nexport function KeyValueGrid({ rows }: { rows: KeyValueRow[] }) {\n  return (\n    <div className=\"grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs\">\n      {rows.map(({ label, value }) => (\n        <React.Fragment key={label}>\n          <span className=\"text-muted\">{label}</span>\n          <span className=\"break-all font-mono text-foreground\">{value}</span>\n        </React.Fragment>\n      ))}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/tool-visualizers/primitives/markdown-file-preview.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--oh-scroll-fade-from:var(--oh-surface)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 171,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 171,
          "endColumn": 135
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 186,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 186,
          "endColumn": 109
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"tracking-[0.11px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 186,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 186,
          "endColumn": 109
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 197,
          "column": 40,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 197,
          "endColumn": 95
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"tracking-[0.11px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 197,
          "column": 40,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 197,
          "endColumn": 95
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 5,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "/**\n * Inline markdown artifact preview for chat tool cards.\n *\n * Shows a height-limited scrollable rich preview so long reports stay compact\n * in the stream; the footer View action deep-links into the Files drawer for\n * the full document. Markdown *create* file-editor events stay expanded and\n * ungrouped so the preview is visible without an extra click.\n */\nimport { useTranslation } from \"react-i18next\";\nimport { ArrowUpRight } from \"lucide-react\";\nimport FileIcon from \"#/icons/file.svg?react\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { MarkdownRenderer } from \"#/components/features/markdown/markdown-renderer\";\nimport { planComponents } from \"#/components/features/markdown/plan-components\";\nimport { Typography } from \"#/ui/typography\";\nimport { isMarkdownFilePath } from \"#/utils/is-markdown-file-path\";\nimport type {\n  ActionEvent,\n  ObservationEvent,\n  OpenHandsEvent,\n} from \"#/types/agent-server/core\";\nimport {\n  isActionEvent,\n  isObservationEvent,\n} from \"#/types/agent-server/type-guards\";\nimport type {\n  FileEditorAction,\n  StrReplaceEditorAction,\n} from \"#/types/agent-server/core/base/action\";\nimport type {\n  FileEditorObservation,\n  StrReplaceEditorObservation,\n} from \"#/types/agent-server/core/base/observation\";\n\nexport { isMarkdownFilePath } from \"#/utils/is-markdown-file-path\";\n\nconst FILE_EDITOR_ACTION_KINDS = new Set([\n  \"FileEditorAction\",\n  \"StrReplaceEditorAction\",\n]);\nconst FILE_EDITOR_OBSERVATION_KINDS = new Set([\n  \"FileEditorObservation\",\n  \"StrReplaceEditorObservation\",\n]);\n\ninterface MarkdownFilePreviewProps {\n  content: string;\n  path: string;\n  /** When omitted (e.g. in-flight create), the View affordance is hidden. */\n  onView?: () => void;\n}\n\n/**\n * Resolves the file path for a file-editor action/observation, including the\n * observation's originating action when the observation omits `path`.\n */\nfunction getFileEditorEventPath(\n  event: OpenHandsEvent,\n  correspondingAction?: ActionEvent,\n): string | null {\n  if (isActionEvent(event) && FILE_EDITOR_ACTION_KINDS.has(event.action.kind)) {\n    return (\n      (event as ActionEvent<FileEditorAction | StrReplaceEditorAction>).action\n        .path || null\n    );\n  }\n\n  if (\n    isObservationEvent(event) &&\n    FILE_EDITOR_OBSERVATION_KINDS.has(event.observation.kind)\n  ) {\n    const path = (\n      event as ObservationEvent<\n        FileEditorObservation | StrReplaceEditorObservation\n      >\n    ).observation.path;\n    if (path) return path;\n    if (\n      correspondingAction &&\n      FILE_EDITOR_ACTION_KINDS.has(correspondingAction.action.kind)\n    ) {\n      return (\n        (\n          correspondingAction as ActionEvent<\n            FileEditorAction | StrReplaceEditorAction\n          >\n        ).action.path || null\n      );\n    }\n  }\n\n  return null;\n}\n\n/**\n * Resolves the file-editor command (`create` / `view` / \u2026), including the\n * observation's originating action when the observation omits `command`.\n */\nfunction getFileEditorEventCommand(\n  event: OpenHandsEvent,\n  correspondingAction?: ActionEvent,\n): string | null {\n  if (isActionEvent(event) && FILE_EDITOR_ACTION_KINDS.has(event.action.kind)) {\n    return (\n      (event as ActionEvent<FileEditorAction | StrReplaceEditorAction>).action\n        .command || null\n    );\n  }\n\n  if (\n    isObservationEvent(event) &&\n    FILE_EDITOR_OBSERVATION_KINDS.has(event.observation.kind)\n  ) {\n    const command = (\n      event as ObservationEvent<\n        FileEditorObservation | StrReplaceEditorObservation\n      >\n    ).observation.command;\n    if (command) return command;\n    if (\n      correspondingAction &&\n      FILE_EDITOR_ACTION_KINDS.has(correspondingAction.action.kind)\n    ) {\n      return (\n        (\n          correspondingAction as ActionEvent<\n            FileEditorAction | StrReplaceEditorAction\n          >\n        ).action.command || null\n      );\n    }\n  }\n\n  return null;\n}\n\n/**\n * True for file-editor *create* events whose path is a markdown artifact.\n *\n * Used to keep those cards expanded and outside collapsed action groups so\n * the clipped preview is visible by default. Reads/edits of `.md` files stay\n * on the normal groupable path.\n */\nexport function isMarkdownFileEditorEvent(\n  event: OpenHandsEvent,\n  correspondingAction?: ActionEvent,\n): boolean {\n  const path = getFileEditorEventPath(event, correspondingAction);\n  const command = getFileEditorEventCommand(event, correspondingAction);\n  return Boolean(path && command === \"create\" && isMarkdownFilePath(path));\n}\n\n/**\n * Height-clipped markdown card with an optional View bar that opens the file.\n */\nexport function MarkdownFilePreview({\n  content,\n  path,\n  onView,\n}: MarkdownFilePreviewProps) {\n  const { t } = useTranslation(\"openhands\");\n  const fileName = path.split(\"/\").pop() || path;\n\n  return (\n    <div\n      className=\"w-full overflow-hidden rounded-xl border border-border bg-surface\"\n      data-testid=\"markdown-file-preview\"\n    >\n      <div\n        data-testid=\"markdown-file-preview-content\"\n        className=\"max-h-40 overflow-y-auto px-4 py-3 text-contrast custom-scrollbar-always [--oh-scroll-fade-from:var(--oh-surface)]\"\n      >\n        {/* Deliberately compact: the clipped in-stream card reuses the plan\n            preview's small typography; the Files drawer renders the same\n            artifact full-size with the default components. */}\n        <MarkdownRenderer\n          content={content}\n          includeStandard\n          includeHeadings\n          components={planComponents}\n        />\n      </div>\n      <div className=\"flex h-10 items-center justify-between gap-2 border-t border-border px-3\">\n        <div className=\"flex min-w-0 items-center gap-1.5\">\n          <FileIcon className=\"h-3.5 w-3.5 flex-shrink-0 text-muted\" />\n          <Typography.Text className=\"truncate font-mono text-[11px] leading-4 tracking-[0.11px] text-muted\">\n            {fileName}\n          </Typography.Text>\n        </div>\n        {onView ? (\n          <button\n            type=\"button\"\n            onClick={onView}\n            className=\"flex shrink-0 cursor-pointer items-center gap-1 transition-opacity hover:opacity-80\"\n            data-testid=\"markdown-file-preview-view\"\n          >\n            <Typography.Text className=\"text-[11px] leading-4 tracking-[0.11px] text-contrast\">\n              {t(I18nKey.COMMON$VIEW)}\n            </Typography.Text>\n            <ArrowUpRight className=\"text-contrast\" size={16} />\n          </button>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/uploaded-file.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[9px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 43,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 43,
          "endColumn": 72
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { LoaderCircle } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport FileIcon from \"#/icons/file.svg?react\";\nimport { RemoveFileButton } from \"./remove-file-button\";\nimport { cn, getFileExtension } from \"#/utils/utils\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\ninterface UploadedFileProps {\n  file: File;\n  onRemove: () => void;\n  isLoading?: boolean;\n}\n\nexport function UploadedFile({\n  file,\n  onRemove,\n  isLoading = false,\n}: UploadedFileProps) {\n  const { t } = useTranslation(\"openhands\");\n  const fileExtension = getFileExtension(file.name);\n\n  return (\n    <div className=\"group flex gap-2 rounded-lg bg-interactive-hover max-w-40 px-3 py-1 relative\">\n      <div className=\"flex flex-col justify-center gap-0.25\">\n        <RemoveFileButton\n          onClick={onRemove}\n          ariaLabel={t(I18nKey.CHAT_INTERFACE$REMOVE_FILE, {\n            fileName: file.name,\n          })}\n        />\n        <div className=\"flex items-center gap-2 w-full\">\n          <span\n            className={cn(\n              \"text-sm font-normal leading-5 flex-1 max-w-34 truncate\",\n              isLoading ? \"max-w-27 text-muted\" : \"text-contrast\",\n            )}\n          >\n            {file.name}\n          </span>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <FileIcon width={12} height={12} color=\"var(--oh-muted)\" />\n          <span className=\"text-[9px] font-normal leading-5 text-muted\">\n            {fileExtension}\n          </span>\n        </div>\n      </div>\n      {isLoading && (\n        <div className=\"flex items-center justify-center\">\n          <LoaderCircle className=\"animate-spin w-5 h-5\" color=\"white\" />\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/user-message-body.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[word-break:break-word]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 81,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 81,
          "endColumn": 72
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { cn } from \"#/utils/utils\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { MarkdownRenderer } from \"../markdown/markdown-renderer\";\nimport { ChatAnchor, ChatCode, ChatStrong } from \"./chat-markdown-path-code\";\n\nconst USER_MESSAGE_MAX_LINES = 5;\nconst USER_MESSAGE_LENGTH_THRESHOLD = 360;\nexport const USER_MESSAGE_LINE_HEIGHT_PX = 24;\n\nexport const chatBubbleMarkdownComponents = {\n  p: ({ children }: React.ComponentProps<\"p\">) => (\n    <p className=\"m-0 leading-6\">{children}</p>\n  ),\n  code: ChatCode,\n  a: ChatAnchor,\n  strong: ChatStrong,\n};\n\nexport function UserMessageBody({\n  message,\n  isHovering,\n  isExpanded,\n  onTruncatableChange,\n}: {\n  message: string;\n  isHovering: boolean;\n  isExpanded: boolean;\n  onTruncatableChange: (truncatable: boolean) => void;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const [isTruncatable, setIsTruncatable] = React.useState(false);\n\n  React.useEffect(() => {\n    onTruncatableChange(isTruncatable);\n  }, [isTruncatable, onTruncatableChange]);\n\n  React.useLayoutEffect(() => {\n    const content = contentRef.current;\n    if (!content || isExpanded) {\n      setIsTruncatable(false);\n      return undefined;\n    }\n\n    const measure = () => {\n      const lineHeight = Number.parseFloat(\n        getComputedStyle(content).lineHeight,\n      );\n      const linePx =\n        Number.isFinite(lineHeight) && lineHeight > 0\n          ? lineHeight\n          : USER_MESSAGE_LINE_HEIGHT_PX;\n      const maxHeight = USER_MESSAGE_MAX_LINES * linePx;\n      const newlineCount = (message.match(/\\n/g) ?? []).length;\n\n      const truncatable =\n        content.scrollHeight > maxHeight + 1 ||\n        newlineCount >= USER_MESSAGE_MAX_LINES ||\n        message.trim().length > USER_MESSAGE_LENGTH_THRESHOLD;\n\n      setIsTruncatable(truncatable);\n    };\n\n    measure();\n\n    const observer = new ResizeObserver(measure);\n    observer.observe(content);\n\n    return () => observer.disconnect();\n  }, [message, isExpanded]);\n\n  const isCollapsed = isTruncatable && !isExpanded;\n\n  return (\n    <div className=\"relative min-w-0\">\n      <div\n        ref={contentRef}\n        className={cn(\n          \"text-sm leading-6 whitespace-normal [word-break:break-word]\",\n          isCollapsed && \"line-clamp-5\",\n        )}\n      >\n        <MarkdownRenderer\n          includeStandard\n          includeHeadings\n          allowHtml={false}\n          components={chatBubbleMarkdownComponents}\n        >\n          {message}\n        </MarkdownRenderer>\n      </div>\n\n      {isCollapsed ? (\n        <>\n          <div\n            aria-hidden\n            data-testid=\"chat-message-truncation-gradient\"\n            className=\"pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-tertiary to-transparent\"\n          />\n          <span\n            data-testid=\"chat-message-view-more\"\n            className={cn(\n              \"pointer-events-none absolute bottom-1 left-1/2 z-10 inline-flex -translate-x-1/2 items-center rounded-full border border-border-subtle bg-surface-raised px-2.5 py-0.5 text-xs font-normal text-foreground transition-opacity duration-150\",\n              isHovering ? \"opacity-100\" : \"opacity-0\",\n            )}\n          >\n            {t(I18nKey.COMMON$VIEW_MORE)}\n          </span>\n        </>\n      ) : null}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/chat/workspace-mode-selector.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[100px]\" hardcodes an off-token value. Nearest on the scale: rounded-4xl (32px), rounded-3xl (24px).",
          "line": 75,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 75,
          "endColumn": 118
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[rgba(71,74,84,0.50)]\" hardcodes a color. Nearest theme tokens: border-border, border-border-subtle. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 76,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 76,
          "endColumn": 77,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-border"
              },
              "fix": {
                "range": [
                  2366,
                  2432
                ],
                "text": "\"border border-border bg-transparent text-contrast\""
              },
              "desc": "Replace with \"border-border\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-border-subtle"
              },
              "fix": {
                "range": [
                  2366,
                  2432
                ],
                "text": "\"border border-border-subtle bg-transparent text-contrast\""
              },
              "desc": "Replace with \"border-border-subtle\"."
            }
          ]
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useEffect, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ChevronDown } from \"lucide-react\";\nimport type { BackendKind } from \"#/api/backend-registry/types\";\nimport type { WorkspaceMode } from \"#/api/conversation-metadata-store\";\nimport { getWorkspaceModeI18nKey } from \"#/utils/workspace-mode\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  dropdownMenuListClassName,\n  dropdownMenuRowClassName,\n  dropdownMenuRowIconWrapperClassName,\n} from \"#/utils/dropdown-classes\";\nimport { WorkspaceModeIcon } from \"./workspace-mode-icon\";\n\ninterface WorkspaceModeSelectorProps {\n  value: WorkspaceMode;\n  backendKind: BackendKind;\n  onChange: (value: WorkspaceMode) => void;\n  disabled?: boolean;\n  testId?: string;\n}\n\nconst WORKSPACE_MODE_OPTIONS: WorkspaceMode[] = [\"local_repo\", \"new_worktree\"];\n\nexport function WorkspaceModeSelector({\n  value,\n  backendKind,\n  onChange,\n  disabled = false,\n  testId = \"workspace-mode-selector\",\n}: WorkspaceModeSelectorProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [open, setOpen] = useState(false);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const label = t(getWorkspaceModeI18nKey(value, backendKind));\n\n  useEffect(() => {\n    if (!open) return undefined;\n\n    const onMouseDown = (event: MouseEvent) => {\n      if (\n        containerRef.current &&\n        !containerRef.current.contains(event.target as Node)\n      ) {\n        setOpen(false);\n      }\n    };\n\n    document.addEventListener(\"mousedown\", onMouseDown);\n    return () => document.removeEventListener(\"mousedown\", onMouseDown);\n  }, [open]);\n\n  useEffect(() => {\n    if (!open) return undefined;\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") setOpen(false);\n    };\n\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [open]);\n\n  return (\n    <div ref={containerRef} className=\"relative inline-flex\">\n      <button\n        type=\"button\"\n        data-testid={testId}\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        aria-label={label}\n        disabled={disabled}\n        onClick={() => setOpen((next) => !next)}\n        className={cn(\n          \"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2 py-1 rounded-[100px] truncate relative\",\n          \"border border-[rgba(71,74,84,0.50)] bg-transparent text-contrast\",\n          disabled\n            ? \"cursor-not-allowed opacity-50\"\n            : \"cursor-pointer hover:border-border-subtle\",\n        )}\n      >\n        <span className=\"flex size-3 shrink-0 items-center justify-center\">\n          <WorkspaceModeIcon mode={value} />\n        </span>\n        <span className=\"min-w-0 flex-1 truncate text-sm font-normal leading-5\">\n          {label}\n        </span>\n        <ChevronDown className=\"size-3 shrink-0 text-contrast\" aria-hidden />\n      </button>\n\n      {open ? (\n        <div\n          role=\"menu\"\n          data-testid={`${testId}-menu`}\n          className={cn(\n            \"absolute left-0 top-full z-50 mt-1 min-w-45 rounded-lg border border-border bg-surface p-1 shadow-lg\",\n            dropdownMenuListClassName,\n          )}\n        >\n          {WORKSPACE_MODE_OPTIONS.map((option) => {\n            const optionLabel = t(getWorkspaceModeI18nKey(option, backendKind));\n            return (\n              <button\n                key={option}\n                type=\"button\"\n                role=\"menuitemradio\"\n                aria-checked={option === value}\n                data-testid={`${testId}-option-${option}`}\n                onClick={() => {\n                  onChange(option);\n                  setOpen(false);\n                }}\n                className={dropdownMenuRowClassName}\n              >\n                <span className={dropdownMenuRowIconWrapperClassName}>\n                  <WorkspaceModeIcon mode={option} />\n                </span>\n                <span className=\"truncate\">{optionLabel}</span>\n              </button>\n            );\n          })}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/command-menu/command-menu-trigger.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 66,
          "column": 24,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 66,
          "endColumn": 151
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"tracking-[0.12em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 66,
          "column": 24,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 66,
          "endColumn": 151
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { Search } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\nimport { useCommandMenuStore } from \"#/stores/command-menu-store\";\nimport { cn } from \"#/utils/utils\";\nimport { SidebarCollapsedIconSlot } from \"#/components/features/sidebar/sidebar-collapsed-icon-slot\";\nimport {\n  SIDEBAR_ICON_SLOT_CLASS,\n  sidebarNavLabelClassName,\n  sidebarNavRowClassName,\n} from \"#/components/features/sidebar/sidebar-layout\";\nimport { getCommandMenuShortcutKey } from \"./command-menu-items\";\n\ninterface CommandMenuTriggerProps {\n  collapsed: boolean;\n}\n\nconst COMMAND_MENU_TRIGGER_TEST_ID = \"command-menu-trigger\";\nconst COMMAND_MENU_TRIGGER_ICON_SIZE = 18;\n\nexport function CommandMenuTrigger({ collapsed }: CommandMenuTriggerProps) {\n  const { t } = useTranslation(\"openhands\");\n  const open = useCommandMenuStore((state) => state.open);\n  const label = t(I18nKey.COMMAND_MENU$OPEN_LABEL);\n\n  const trigger = (\n    <button\n      type=\"button\"\n      data-testid={COMMAND_MENU_TRIGGER_TEST_ID}\n      aria-label={label}\n      onClick={open}\n      className={cn(\n        sidebarNavRowClassName({ collapsed }),\n        collapsed\n          ? \"cursor-pointer\"\n          : \"group justify-between border border-border-subtle bg-surface/50 hover:border-border hover:bg-surface-raised\",\n      )}\n    >\n      <span className=\"flex min-w-0 items-center gap-2\">\n        {collapsed ? (\n          <SidebarCollapsedIconSlot active={false}>\n            <Search\n              width={COMMAND_MENU_TRIGGER_ICON_SIZE}\n              height={COMMAND_MENU_TRIGGER_ICON_SIZE}\n            />\n          </SidebarCollapsedIconSlot>\n        ) : (\n          <span\n            className={cn(\n              SIDEBAR_ICON_SLOT_CLASS,\n              \"text-muted group-hover:text-contrast\",\n            )}\n            aria-hidden=\"true\"\n          >\n            <Search\n              width={COMMAND_MENU_TRIGGER_ICON_SIZE}\n              height={COMMAND_MENU_TRIGGER_ICON_SIZE}\n            />\n          </span>\n        )}\n        <span className={sidebarNavLabelClassName(collapsed)}>{label}</span>\n      </span>\n      {!collapsed ? (\n        <kbd className=\"rounded-md border border-border bg-black/20 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-text-dim\">\n          {t(getCommandMenuShortcutKey())}\n        </kbd>\n      ) : null}\n    </button>\n  );\n\n  if (collapsed) {\n    return (\n      <StyledTooltip content={label} placement=\"right\">\n        {trigger}\n      </StyledTooltip>\n    );\n  }\n\n  return trigger;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/command-menu/command-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[70]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 201,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 201,
          "endColumn": 94
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"pt-[10vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 201,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 201,
          "endColumn": 94
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"backdrop-blur-[2px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 209,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 209,
          "endColumn": 84
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[min(720px,78vh)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 215,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 215,
          "endColumn": 104
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"shadow-[0_24px_90px_rgba(0,0,0,0.52),0_0_0_1px_rgba(255,255,255,0.03)_inset]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 217,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 217,
          "endColumn": 89
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 251,
          "column": 26,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 251,
          "endColumn": 171
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"tracking-[0.18em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 251,
          "column": 26,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 251,
          "endColumn": 171
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 285,
          "column": 34,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 285,
          "endColumn": 118
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"tracking-[0.18em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 285,
          "column": 34,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 285,
          "endColumn": 118
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"shadow-[0_0_0_1px_color-mix(in_srgb,var(--oh-contrast)_8%,transparent)_inset]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 305,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 305,
          "endColumn": 141
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 338,
          "column": 43,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 338,
          "endColumn": 185
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"tracking-[0.14em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 338,
          "column": 43,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 338,
          "endColumn": 185
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 398,
          "column": 24,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 398,
          "endColumn": 86
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 13,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Search, X } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport { useCloseOnEscape } from \"#/hooks/use-close-on-escape\";\nimport { useCommandMenuStore } from \"#/stores/command-menu-store\";\nimport { useSidebarStore } from \"#/stores/sidebar-store\";\nimport { cn } from \"#/utils/utils\";\nimport { buildAgentCanvasPath } from \"#/utils/base-path\";\nimport {\n  COMMAND_MENU_GROUP_LABELS,\n  COMMAND_MENU_GROUP_ORDER,\n  type CommandMenuItemDefinition,\n  commandMenuItemCopy,\n  createCommandMenuItems,\n  getCommandMenuShortcutKey,\n} from \"./command-menu-items\";\n\nconst COMMAND_MENU_SEARCH_INPUT_ID = \"command-menu-search\";\nconst COMMAND_MENU_LISTBOX_ID = \"command-menu-results\";\nconst COMMAND_MENU_OPTION_ID_PREFIX = \"command-menu-option\";\nconst COMMAND_MENU_TEST_ID = \"command-menu\";\nconst COMMAND_MENU_SHORTCUT_KEY = \"k\";\nconst COMMAND_MENU_ARROW_DOWN_KEY = \"ArrowDown\";\nconst COMMAND_MENU_ARROW_UP_KEY = \"ArrowUp\";\nconst COMMAND_MENU_ENTER_KEY = \"Enter\";\nconst EMPTY_QUERY = \"\";\nconst EMPTY_RESULTS_ACTIVE_INDEX = -1;\n\nfunction getOptionId(item: CommandMenuItemDefinition) {\n  return `${COMMAND_MENU_OPTION_ID_PREFIX}-${item.id}`;\n}\n\nfunction matchesQuery({\n  item,\n  query,\n  translate,\n}: {\n  item: CommandMenuItemDefinition;\n  query: string;\n  translate: (key: I18nKey) => string;\n}) {\n  const terms = query.trim().toLocaleLowerCase().split(/\\s+/).filter(Boolean);\n\n  if (terms.length === 0) {\n    return true;\n  }\n\n  const searchableText = [\n    commandMenuItemCopy(item.title, item.titleKey, translate),\n    commandMenuItemCopy(item.description, item.descriptionKey, translate),\n    commandMenuItemCopy(item.keywords, item.keywordsKey, translate),\n  ]\n    .join(\" \")\n    .toLocaleLowerCase();\n\n  return terms.every((term) => searchableText.includes(term));\n}\n\nexport function CommandMenu() {\n  const { t } = useTranslation(\"openhands\");\n  const { navigate } = useNavigation();\n  const isOpen = useCommandMenuStore((state) => state.isOpen);\n  const open = useCommandMenuStore((state) => state.open);\n  const close = useCommandMenuStore((state) => state.close);\n  const [query, setQuery] = React.useState(EMPTY_QUERY);\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const optionRefs = React.useRef(new Map<string, HTMLElement>());\n\n  React.useEffect(() => {\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (\n        (event.metaKey || event.ctrlKey) &&\n        event.key.toLocaleLowerCase() === COMMAND_MENU_SHORTCUT_KEY\n      ) {\n        event.preventDefault();\n        open();\n      }\n    };\n\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [open]);\n\n  // Escape closes the modal from any element inside it, not only the input.\n  useCloseOnEscape(isOpen, close);\n\n  React.useEffect(() => {\n    if (!isOpen) {\n      setQuery(EMPTY_QUERY);\n      setActiveIndex(0);\n      optionRefs.current.clear();\n      return undefined;\n    }\n\n    // Return focus to whatever had it (for example the composer) on close.\n    const previouslyFocused =\n      document.activeElement instanceof HTMLElement\n        ? document.activeElement\n        : null;\n    const frame = requestAnimationFrame(() => inputRef.current?.focus());\n    return () => {\n      cancelAnimationFrame(frame);\n      if (previouslyFocused?.isConnected) {\n        previouslyFocused.focus();\n      }\n    };\n  }, [isOpen]);\n\n  const items = React.useMemo(\n    () =>\n      createCommandMenuItems({\n        toggleSidebar: () => useSidebarStore.getState().toggleCollapsed(),\n      }),\n    [],\n  );\n\n  const filteredItems = React.useMemo(\n    () =>\n      items.filter((item) =>\n        matchesQuery({ item, query, translate: (key) => t(key) }),\n      ),\n    [items, query, t],\n  );\n\n  React.useEffect(() => {\n    setActiveIndex((currentIndex) => {\n      if (filteredItems.length === 0) {\n        return EMPTY_RESULTS_ACTIVE_INDEX;\n      }\n      return Math.min(Math.max(currentIndex, 0), filteredItems.length - 1);\n    });\n  }, [filteredItems.length]);\n\n  React.useEffect(() => {\n    const activeItem = filteredItems[activeIndex];\n    if (!activeItem) {\n      return;\n    }\n\n    const activeNode = optionRefs.current.get(activeItem.id);\n    if (typeof activeNode?.scrollIntoView === \"function\") {\n      activeNode.scrollIntoView({\n        block: \"nearest\",\n      });\n    }\n  }, [activeIndex, filteredItems]);\n\n  const runItem = React.useCallback(\n    (item: CommandMenuItemDefinition | undefined) => {\n      if (!item) {\n        return;\n      }\n\n      close();\n      if (item.to) {\n        navigate(item.to);\n        return;\n      }\n      item.perform?.();\n    },\n    [close, navigate],\n  );\n\n  const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === COMMAND_MENU_ARROW_DOWN_KEY) {\n      event.preventDefault();\n      setActiveIndex((index) =>\n        filteredItems.length === 0 ? index : (index + 1) % filteredItems.length,\n      );\n      return;\n    }\n\n    if (event.key === COMMAND_MENU_ARROW_UP_KEY) {\n      event.preventDefault();\n      setActiveIndex((index) =>\n        filteredItems.length === 0\n          ? index\n          : (index - 1 + filteredItems.length) % filteredItems.length,\n      );\n      return;\n    }\n\n    if (event.key === COMMAND_MENU_ENTER_KEY) {\n      event.preventDefault();\n      runItem(filteredItems[activeIndex]);\n    }\n  };\n\n  if (!isOpen || typeof document === \"undefined\") {\n    return null;\n  }\n\n  const activeItem = filteredItems[activeIndex];\n\n  return createPortal(\n    <div\n      className=\"fixed inset-0 z-[70] flex items-start justify-center px-3 pt-[10vh] sm:px-6\"\n      data-testid={COMMAND_MENU_TEST_ID}\n      role=\"dialog\"\n      aria-modal=\"true\"\n      aria-label={t(I18nKey.COMMAND_MENU$ARIA_LABEL)}\n    >\n      <button\n        type=\"button\"\n        className=\"absolute inset-0 cursor-default bg-black/65 backdrop-blur-[2px]\"\n        aria-label={t(I18nKey.COMMAND_MENU$CLOSE_LABEL)}\n        onClick={close}\n      />\n      <div\n        className={cn(\n          \"relative flex max-h-[min(720px,78vh)] w-full max-w-2xl flex-col overflow-hidden rounded-2xl\",\n          \"border border-border bg-surface\",\n          \"shadow-[0_24px_90px_rgba(0,0,0,0.52),0_0_0_1px_rgba(255,255,255,0.03)_inset]\",\n        )}\n      >\n        <div className=\"relative flex items-center gap-3 border-b border-border px-4 py-3\">\n          <Search className=\"size-5 shrink-0 text-text-dim\" />\n          <input\n            ref={inputRef}\n            id={COMMAND_MENU_SEARCH_INPUT_ID}\n            className=\"h-11 min-w-0 flex-1 bg-transparent text-base text-contrast outline-none placeholder:text-text-dim\"\n            placeholder={t(I18nKey.COMMAND_MENU$PLACEHOLDER)}\n            aria-label={t(I18nKey.COMMAND_MENU$SEARCH_LABEL)}\n            role=\"combobox\"\n            aria-expanded=\"true\"\n            aria-controls={COMMAND_MENU_LISTBOX_ID}\n            aria-activedescendant={\n              activeItem ? getOptionId(activeItem) : undefined\n            }\n            value={query}\n            onChange={(event) => setQuery(event.target.value)}\n            onKeyDown={handleInputKeyDown}\n          />\n          {query ? (\n            <button\n              type=\"button\"\n              className=\"inline-flex size-8 items-center justify-center rounded-lg text-muted hover:bg-surface-raised hover:text-contrast\"\n              aria-label={t(I18nKey.COMMAND_MENU$CLEAR_SEARCH_LABEL)}\n              onClick={() => {\n                setQuery(EMPTY_QUERY);\n                inputRef.current?.focus();\n              }}\n            >\n              <X className=\"size-4\" />\n            </button>\n          ) : null}\n          <kbd className=\"hidden rounded-md border border-border bg-black/25 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-text-dim sm:inline-flex\">\n            {t(getCommandMenuShortcutKey())}\n          </kbd>\n        </div>\n\n        <div\n          id={COMMAND_MENU_LISTBOX_ID}\n          role=\"listbox\"\n          className=\"relative min-h-0 flex-1 overflow-y-auto px-2 py-2 custom-scrollbar\"\n        >\n          {filteredItems.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center gap-2 px-6 py-16 text-center\">\n              <div className=\"flex size-11 items-center justify-center rounded-2xl border border-dashed border-border text-text-dim\">\n                <Search className=\"size-5\" />\n              </div>\n              <p className=\"text-sm font-medium text-contrast\">\n                {t(I18nKey.COMMAND_MENU$NO_RESULTS_TITLE)}\n              </p>\n              <p className=\"max-w-sm text-xs leading-5 text-muted\">\n                {t(I18nKey.COMMAND_MENU$NO_RESULTS_DESCRIPTION)}\n              </p>\n            </div>\n          ) : (\n            COMMAND_MENU_GROUP_ORDER.map((groupId) => {\n              const groupItems = filteredItems.filter(\n                (item) => item.group === groupId,\n              );\n\n              if (groupItems.length === 0) {\n                return null;\n              }\n\n              return (\n                <section key={groupId} className=\"py-1\">\n                  <div className=\"px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-text-dim\">\n                    {t(COMMAND_MENU_GROUP_LABELS[groupId])}\n                  </div>\n                  <div className=\"space-y-1\">\n                    {groupItems.map((item) => {\n                      const itemIndex = filteredItems.indexOf(item);\n                      const isActive = itemIndex === activeIndex;\n                      const to = item.to;\n\n                      const assignRef = (node: HTMLElement | null) => {\n                        if (node) {\n                          optionRefs.current.set(item.id, node);\n                        } else {\n                          optionRefs.current.delete(item.id);\n                        }\n                      };\n\n                      const optionClassName = cn(\n                        \"group flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-150\",\n                        isActive\n                          ? \"bg-contrast/[0.09] text-contrast shadow-[0_0_0_1px_color-mix(in_srgb,var(--oh-contrast)_8%,transparent)_inset]\"\n                          : \"text-muted hover:bg-contrast/[0.05] hover:text-contrast\",\n                      );\n\n                      const content = (\n                        <>\n                          <span\n                            className={cn(\n                              \"flex size-9 shrink-0 items-center justify-center rounded-lg border transition-colors duration-150\",\n                              isActive\n                                ? \"border-accent bg-accent/15 text-contrast\"\n                                : \"border-border bg-black/15 text-text-dim group-hover:text-contrast\",\n                            )}\n                            aria-hidden=\"true\"\n                          >\n                            {item.icon}\n                          </span>\n                          <span className=\"min-w-0 flex-1\">\n                            <span className=\"block truncate text-sm font-medium text-current\">\n                              {commandMenuItemCopy(\n                                item.title,\n                                item.titleKey,\n                                t,\n                              )}\n                            </span>\n                            <span className=\"mt-0.5 block truncate text-xs text-text-dim\">\n                              {commandMenuItemCopy(\n                                item.description,\n                                item.descriptionKey,\n                                t,\n                              )}\n                            </span>\n                          </span>\n                          <span className=\"hidden shrink-0 rounded-md border border-border px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-dim sm:inline-flex\">\n                            {to\n                              ? t(I18nKey.COMMAND_MENU$GO_HINT)\n                              : t(I18nKey.COMMAND_MENU$RUN_HINT)}\n                          </span>\n                        </>\n                      );\n\n                      if (to) {\n                        return (\n                          <a\n                            key={item.id}\n                            ref={assignRef}\n                            id={getOptionId(item)}\n                            href={buildAgentCanvasPath(to)}\n                            role=\"option\"\n                            aria-selected={isActive}\n                            onMouseEnter={() => setActiveIndex(itemIndex)}\n                            onClick={(event) => {\n                              if (\n                                event.metaKey ||\n                                event.ctrlKey ||\n                                event.shiftKey ||\n                                event.altKey\n                              ) {\n                                return;\n                              }\n                              event.preventDefault();\n                              runItem(item);\n                            }}\n                            className={optionClassName}\n                          >\n                            {content}\n                          </a>\n                        );\n                      }\n\n                      return (\n                        <button\n                          key={item.id}\n                          ref={assignRef}\n                          id={getOptionId(item)}\n                          type=\"button\"\n                          role=\"option\"\n                          aria-selected={isActive}\n                          onMouseEnter={() => setActiveIndex(itemIndex)}\n                          onClick={() => runItem(item)}\n                          className={optionClassName}\n                        >\n                          {content}\n                        </button>\n                      );\n                    })}\n                  </div>\n                </section>\n              );\n            })\n          )}\n        </div>\n\n        <div className=\"border-t border-border px-4 py-2.5 text-[11px] text-text-dim\">\n          {t(I18nKey.COMMAND_MENU$FOOTER_HINT)}\n        </div>\n      </div>\n    </div>,\n    document.body,\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/context-menu/context-menu-icon-text-with-description.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 37,
          "column": 34,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 37,
          "endColumn": 102
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { ContextMenuIconText } from \"./context-menu-icon-text\";\nimport { Typography } from \"#/ui/typography\";\nimport { cn } from \"#/utils/utils\";\n\ninterface ContextMenuIconTextWithDescriptionProps {\n  icon: React.ComponentType<{ className?: string }>;\n  title: string;\n  description: string;\n  className?: string;\n  iconClassName?: string;\n  isActive?: boolean;\n}\n\nexport function ContextMenuIconTextWithDescription({\n  icon,\n  title,\n  description,\n  className,\n  iconClassName,\n  isActive = false,\n}: ContextMenuIconTextWithDescriptionProps) {\n  return (\n    <div\n      className={cn(\n        \"flex min-w-0 w-full flex-col justify-center gap-1\",\n        className,\n      )}\n    >\n      <ContextMenuIconText\n        icon={icon}\n        text={title}\n        className=\"px-0\"\n        iconClassName={iconClassName}\n        isActive={isActive}\n      />\n      <Typography.Text className=\"text-muted text-[10px] font-normal whitespace-pre-wrap break-words\">\n        {description}\n      </Typography.Text>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/controls/agent-status.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 150,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 150,
          "endColumn": 93
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[100px]\" hardcodes an off-token value. Nearest on the scale: rounded-4xl (32px), rounded-3xl (24px).",
          "line": 157,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 157,
          "endColumn": 268
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { useEffect, useState } from \"react\";\nimport { CircleCheck } from \"lucide-react\";\nimport { useActiveConversation } from \"#/hooks/query/use-active-conversation\";\nimport { getStatusCode } from \"#/utils/status\";\nimport { ChatStopButton } from \"../chat/chat-stop-button\";\nimport { AgentState } from \"#/types/agent-state\";\nimport ClockIcon from \"#/icons/u-clock-three.svg?react\";\nimport { ChatResumeAgentButton } from \"../chat/chat-play-button\";\nimport { cn, isTaskPolling } from \"#/utils/utils\";\nimport { AgentLoading } from \"./agent-loading\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport CircleErrorIcon from \"#/icons/circle-error.svg?react\";\nimport { useAgentState } from \"#/hooks/use-agent-state\";\nimport {\n  useHasConnectedOnceWebSocket,\n  useUnifiedWebSocketStatus,\n} from \"#/hooks/use-unified-websocket-status\";\nimport { useTaskPolling } from \"#/hooks/query/use-task-polling\";\nimport { useSubConversationTaskPolling } from \"#/hooks/query/use-sub-conversation-task-polling\";\nimport { useAgentNotification } from \"#/hooks/use-agent-notification\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\nexport interface AgentStatusProps {\n  className?: string;\n  handleStop: () => void;\n  handleResumeAgent: () => void;\n  disabled?: boolean;\n  isPausing?: boolean;\n}\n\nexport function AgentStatus({\n  className = \"\",\n  handleStop,\n  handleResumeAgent,\n  disabled = false,\n  isPausing = false,\n}: AgentStatusProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { setShouldShownAgentLoading } = useConversationStore();\n  const { curAgentState, executionStatus } = useAgentState();\n\n  // Trigger browser tab flash and notification sound on state changes\n  useAgentNotification(curAgentState);\n  const webSocketStatus = useUnifiedWebSocketStatus();\n  const hasConnectedOnce = useHasConnectedOnceWebSocket();\n  const { data: conversation } = useActiveConversation();\n  const { taskStatus } = useTaskPolling();\n\n  const { subConversationTaskId } = useConversationStore();\n\n  // Poll sub-conversation task to track its loading state\n  const { taskStatus: subConversationTaskStatus } =\n    useSubConversationTaskPolling(\n      subConversationTaskId,\n      conversation?.id || null,\n    );\n\n  const statusCode = getStatusCode(\n    webSocketStatus,\n    executionStatus ?? null,\n    taskStatus,\n    subConversationTaskStatus,\n    hasConnectedOnce,\n  );\n\n  const shouldShownAgentLoading =\n    curAgentState === AgentState.INIT ||\n    curAgentState === AgentState.LOADING ||\n    (webSocketStatus === \"CONNECTING\" && taskStatus !== \"ERROR\") ||\n    isTaskPolling(taskStatus) ||\n    isTaskPolling(subConversationTaskStatus);\n\n  // For UI rendering - includes pause state\n  const isLoading = shouldShownAgentLoading || isPausing;\n\n  const shouldShownAgentError =\n    curAgentState === AgentState.ERROR ||\n    curAgentState === AgentState.RATE_LIMITED ||\n    webSocketStatus === \"CLOSED\" ||\n    taskStatus === \"ERROR\";\n\n  const shouldShownAgentStop =\n    !shouldShownAgentError && curAgentState === AgentState.RUNNING;\n\n  const shouldShownAgentResume =\n    !shouldShownAgentError &&\n    (curAgentState === AgentState.STOPPED ||\n      curAgentState === AgentState.PAUSED);\n  const isInteractive =\n    !isLoading && (shouldShownAgentStop || shouldShownAgentResume);\n  const isDoneStatus =\n    statusCode === I18nKey.CHAT_INTERFACE$AGENT_FINISHED_MESSAGE;\n  const isReadyStatus = statusCode === I18nKey.AGENT_STATUS$WAITING_FOR_TASK;\n  const isTransientCheckStatus = isDoneStatus || isReadyStatus;\n  const [shouldRenderDoneStatus, setShouldRenderDoneStatus] = useState(true);\n  const [shouldFadeDoneStatus, setShouldFadeDoneStatus] = useState(false);\n\n  // Update global state when agent loading condition changes\n  useEffect(() => {\n    setShouldShownAgentLoading(!!shouldShownAgentLoading);\n  }, [shouldShownAgentLoading, setShouldShownAgentLoading]);\n\n  // This status is the flag's only writer, and the phone panel page renders\n  // the drawer without it. Clear the flag on unmount so a value captured while\n  // the chat was still connecting cannot cover the panel's tab content.\n  useEffect(\n    () => () => setShouldShownAgentLoading(false),\n    [setShouldShownAgentLoading],\n  );\n\n  useEffect(() => {\n    if (!isTransientCheckStatus) {\n      setShouldRenderDoneStatus(true);\n      setShouldFadeDoneStatus(false);\n      return;\n    }\n\n    setShouldRenderDoneStatus(true);\n    setShouldFadeDoneStatus(false);\n\n    const fadeTimer = window.setTimeout(() => {\n      setShouldFadeDoneStatus(true);\n    }, 1000);\n\n    const hideTimer = window.setTimeout(() => {\n      setShouldRenderDoneStatus(false);\n    }, 1500);\n\n    return () => {\n      window.clearTimeout(fadeTimer);\n      window.clearTimeout(hideTimer);\n    };\n  }, [isTransientCheckStatus]);\n\n  if (isTransientCheckStatus && !shouldRenderDoneStatus) {\n    return null;\n  }\n\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-1 min-w-0\",\n        isTransientCheckStatus && \"transition-opacity duration-500\",\n        shouldFadeDoneStatus && \"opacity-0\",\n        className,\n      )}\n    >\n      <span\n        className=\"text-[11px] text-muted font-normal leading-5 min-w-0 max-w-full truncate\"\n        title={t(statusCode)}\n      >\n        {t(statusCode)}\n      </span>\n      <div\n        className={cn(\n          \"box-border content-stretch flex flex-row gap-0.75 items-center justify-center overflow-clip px-0.5 py-1 relative rounded-[100px] shrink-0 size-6 transition-all duration-200 active:scale-95 bg-transparent text-muted hover:bg-contrast/10 hover:text-contrast\",\n          isInteractive ? \"cursor-pointer\" : \"cursor-default\",\n        )}\n      >\n        {isLoading && <AgentLoading />}\n        {!isLoading && shouldShownAgentStop && (\n          <ChatStopButton handleStop={handleStop} />\n        )}\n        {!isLoading && shouldShownAgentResume && (\n          <ChatResumeAgentButton\n            onAgentResumed={handleResumeAgent}\n            disabled={disabled}\n          />\n        )}\n        {!isLoading && shouldShownAgentError && (\n          <CircleErrorIcon\n            className=\"w-4 h-4 text-current\"\n            data-testid=\"circle-error-icon\"\n          />\n        )}\n        {!isLoading &&\n          !shouldShownAgentStop &&\n          !shouldShownAgentResume &&\n          !shouldShownAgentError &&\n          (isTransientCheckStatus ? (\n            <CircleCheck className=\"w-4 h-4 text-current\" />\n          ) : (\n            <ClockIcon className=\"w-4 h-4 text-current\" />\n          ))}\n      </div>\n    </div>\n  );\n}\n\nexport default AgentStatus;\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/controls/server-status.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[13px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 57,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 57,
          "endColumn": 64
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport DebugStackframeDot from \"#/icons/debug-stackframe-dot.svg?react\";\nimport { AgentState } from \"#/types/agent-state\";\nimport { useAgentState } from \"#/hooks/use-agent-state\";\nimport { useTaskPolling } from \"#/hooks/query/use-task-polling\";\nimport { getStatusColor, getStatusText } from \"#/utils/utils\";\nimport { useErrorMessageStore } from \"#/stores/error-message-store\";\nimport { ExecutionStatus } from \"#/types/agent-server/core/base/common\";\nimport { isExecutionErrored, isExecutionPaused } from \"#/utils/status\";\n\nexport interface ServerStatusProps {\n  className?: string;\n  executionStatus: ExecutionStatus | null;\n  isPausing?: boolean;\n}\n\nexport function ServerStatus({\n  className = \"\",\n  executionStatus,\n  isPausing = false,\n}: ServerStatusProps) {\n  const { curAgentState } = useAgentState();\n  const { isTask, taskStatus, taskDetail } = useTaskPolling();\n  const { t } = useTranslation(\"openhands\");\n  const { errorMessage } = useErrorMessageStore();\n\n  const isStartingStatus =\n    curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;\n  const isStopStatus =\n    isExecutionErrored(executionStatus) || isExecutionPaused(executionStatus);\n\n  const statusColor = getStatusColor({\n    isPausing,\n    isTask,\n    taskStatus,\n    isStartingStatus,\n    isStopStatus,\n    curAgentState,\n  });\n\n  const statusText = getStatusText({\n    isPausing,\n    isTask,\n    taskStatus,\n    taskDetail,\n    isStartingStatus,\n    isStopStatus,\n    curAgentState,\n    errorMessage,\n    t,\n  });\n\n  return (\n    <div className={className} data-testid=\"server-status\">\n      <div className=\"flex items-center\">\n        <DebugStackframeDot className=\"w-6 h-6 shrink-0\" color={statusColor} />\n        <span className=\"text-[13px] text-contrast font-normal\">\n          {statusText}\n        </span>\n      </div>\n    </div>\n  );\n}\n\nexport default ServerStatus;\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/controls/tools-context-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"left-[-16px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 96,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 96,
          "endColumn": 74
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"top-[-4px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 117,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 117,
          "endColumn": 125
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[60vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 129,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 129,
          "endColumn": 79
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"top-[-6px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 159,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 159,
          "endColumn": 127
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"top-[-4px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 190,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 190,
          "endColumn": 125
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"stroke-[1.75]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 215,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 215,
          "endColumn": 42
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 6,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { useActiveConversation } from \"#/hooks/query/use-active-conversation\";\nimport { useUserProviders } from \"#/hooks/use-user-providers\";\nimport { cn } from \"#/utils/utils\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { ContextMenuListItem } from \"../context-menu/context-menu-list-item\";\nimport { Divider } from \"#/ui/divider\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\nimport CodeBranchIcon from \"#/icons/u-code-branch.svg?react\";\nimport SkillsIcon from \"#/icons/skills.svg?react\";\nimport PuzzleIcon from \"#/icons/u-puzzle-piece.svg?react\";\nimport FishingHookIcon from \"#/icons/fishing-hook.svg?react\";\nimport ToolsIcon from \"#/icons/u-tools.svg?react\";\nimport RobotIcon from \"#/icons/u-robot.svg?react\";\nimport SettingsIcon from \"#/icons/settings.svg?react\";\nimport CarretRightFillIcon from \"#/icons/carret-right-fill.svg?react\";\nimport { ToolsContextMenuIconText } from \"./tools-context-menu-icon-text\";\nimport { GitToolsSubmenu } from \"./git-tools-submenu\";\nimport { MacrosSubmenu } from \"./macros-submenu\";\nimport { ChatInputProfileMenuContent } from \"#/components/features/chat/components/chat-input-profile-picker\";\nimport { ArchivedDisabledTooltip } from \"../context-menu/archived-disabled-tooltip\";\nimport { useIsArchivedConversation } from \"#/hooks/use-is-archived-conversation\";\n\ninterface ToolsContextMenuProps {\n  onClose: () => void;\n  onShowSkills: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onShowPlugins: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onShowHooks: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onShowAgentTools: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  shouldShowAgentTools?: boolean;\n  shouldShowHooks?: boolean;\n  shouldShowPlugins?: boolean;\n  /**\n   * Offer the \"Switch agent profile\" submenu (OSS-5735). The caller owns the\n   * gating (pre-start only + profiles available) so this menu stays renderable\n   * without query/navigation providers when the item is off.\n   */\n  showAgentProfileSwitch?: boolean;\n  /** When set, renders a divider and this action as the last menu item. */\n  footerAction?: {\n    testId: string;\n    icon: React.ReactNode;\n    label: string;\n    onClick: () => void;\n  };\n}\n\nexport function ToolsContextMenu({\n  onClose,\n  onShowSkills,\n  onShowPlugins,\n  onShowHooks,\n  onShowAgentTools,\n  shouldShowAgentTools = true,\n  shouldShowHooks = false,\n  shouldShowPlugins = false,\n  showAgentProfileSwitch = false,\n  footerAction,\n}: ToolsContextMenuProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { data: conversation } = useActiveConversation();\n  const { providers } = useUserProviders();\n  const isArchivedConversation = useIsArchivedConversation();\n\n  const [activeSubmenu, setActiveSubmenu] = useState<\n    \"git\" | \"macros\" | \"agent-profile\" | null\n  >(null);\n\n  const hasRepository = !!conversation?.selected_repository;\n  const providersAreSet = providers.length > 0;\n  const showGitTools = hasRepository && providersAreSet;\n\n  const handleSubmenuClick = (submenu: \"git\" | \"macros\" | \"agent-profile\") => {\n    if (isArchivedConversation) {\n      return;\n    }\n    setActiveSubmenu(activeSubmenu === submenu ? null : submenu);\n  };\n\n  const handleClose = () => {\n    setActiveSubmenu(null);\n    onClose();\n  };\n\n  const ref = useClickOutsideElement<HTMLUListElement>(handleClose);\n\n  return (\n    <ContextMenu\n      ref={ref}\n      testId=\"tools-context-menu\"\n      position=\"top\"\n      alignment=\"left\"\n      className=\"left-[-16px] mb-2 bottom-full overflow-visible min-w-50\"\n    >\n      {/* Switch agent profile \u2014 only while starting a new conversation; the\n          profile is locked once the conversation starts (OSS-5735). Selecting\n          a profile activates it (home) or recreates the blank conversation\n          with it (see ChatInputProfileMenuContent). No archived gating: this\n          never renders in a started (archivable) conversation. */}\n      {showAgentProfileSwitch && (\n        <div className=\"relative group/agent-profile\">\n          <ContextMenuListItem\n            testId=\"switch-agent-profile-button\"\n            onClick={() => handleSubmenuClick(\"agent-profile\")}\n          >\n            <ToolsContextMenuIconText\n              icon={<RobotIcon width={16} height={16} aria-hidden />}\n              text={t(I18nKey.CHAT$SWITCH_AGENT_PROFILE)}\n              rightIcon={<CarretRightFillIcon width={10} height={10} />}\n            />\n          </ContextMenuListItem>\n          <div\n            className={cn(\n              \"absolute left-full top-[-4px] z-60 opacity-0 invisible pointer-events-none transition-all duration-200 ml-px\",\n              \"group-hover/agent-profile:opacity-100 group-hover/agent-profile:visible group-hover/agent-profile:pointer-events-auto\",\n              \"hover:opacity-100 hover:visible hover:pointer-events-auto\",\n              activeSubmenu === \"agent-profile\" &&\n                \"opacity-100 visible pointer-events-auto\",\n            )}\n          >\n            {/* overflow-y-auto so a long profile list scrolls within the menu;\n                safe because the content has no floating children \u2014 only the\n                flat profile list + Manage link. */}\n            <ContextMenu\n              testId=\"agent-profile-submenu\"\n              className=\"min-w-55 max-w-80 max-h-[60vh] overflow-y-auto gap-0\"\n            >\n              <ChatInputProfileMenuContent\n                onClose={handleClose}\n                dividerInset=\"menu\"\n              />\n            </ContextMenu>\n          </div>\n        </div>\n      )}\n\n      {/* Git Tools */}\n      {showGitTools && (\n        <div className=\"relative group/git\">\n          <ArchivedDisabledTooltip isDisabled={isArchivedConversation}>\n            <ContextMenuListItem\n              testId=\"git-tools-button\"\n              onClick={() => handleSubmenuClick(\"git\")}\n              isDisabled={isArchivedConversation}\n            >\n              <ToolsContextMenuIconText\n                icon={<CodeBranchIcon width={16} height={16} />}\n                text={t(I18nKey.COMMON$GIT_TOOLS)}\n                rightIcon={<CarretRightFillIcon width={10} height={10} />}\n              />\n            </ContextMenuListItem>\n          </ArchivedDisabledTooltip>\n          {!isArchivedConversation && (\n            <div\n              className={cn(\n                \"absolute left-full top-[-6px] z-60 opacity-0 invisible pointer-events-none transition-all duration-200 ml-px\",\n                \"group-hover/git:opacity-100 group-hover/git:visible group-hover/git:pointer-events-auto\",\n                \"hover:opacity-100 hover:visible hover:pointer-events-auto\",\n                activeSubmenu === \"git\" &&\n                  \"opacity-100 visible pointer-events-auto\",\n              )}\n            >\n              <GitToolsSubmenu onClose={handleClose} />\n            </div>\n          )}\n        </div>\n      )}\n\n      {/* Macros */}\n      <div className=\"relative group/macros\">\n        <ArchivedDisabledTooltip isDisabled={isArchivedConversation}>\n          <ContextMenuListItem\n            testId=\"macros-button\"\n            onClick={() => handleSubmenuClick(\"macros\")}\n            isDisabled={isArchivedConversation}\n          >\n            <ToolsContextMenuIconText\n              icon={<SettingsIcon width={16} height={16} />}\n              text={t(I18nKey.COMMON$MACROS)}\n              rightIcon={<CarretRightFillIcon width={10} height={10} />}\n            />\n          </ContextMenuListItem>\n        </ArchivedDisabledTooltip>\n        {!isArchivedConversation && (\n          <div\n            className={cn(\n              \"absolute left-full top-[-4px] z-60 opacity-0 invisible pointer-events-none transition-all duration-200 ml-px\",\n              \"group-hover/macros:opacity-100 group-hover/macros:visible group-hover/macros:pointer-events-auto\",\n              \"hover:opacity-100 hover:visible hover:pointer-events-auto\",\n              activeSubmenu === \"macros\" &&\n                \"opacity-100 visible pointer-events-auto\",\n            )}\n          >\n            <MacrosSubmenu onClose={handleClose} />\n          </div>\n        )}\n      </div>\n\n      {shouldShowAgentTools && <Divider inset=\"menu\" />}\n\n      <ArchivedDisabledTooltip isDisabled={isArchivedConversation}>\n        <ContextMenuListItem\n          testId=\"show-skills-button\"\n          onClick={onShowSkills}\n          isDisabled={isArchivedConversation}\n        >\n          <ToolsContextMenuIconText\n            icon={\n              <SkillsIcon\n                width={16}\n                height={16}\n                className=\"stroke-[1.75]\"\n                aria-hidden\n              />\n            }\n            text={t(I18nKey.CONVERSATION$SHOW_SKILLS)}\n          />\n        </ContextMenuListItem>\n      </ArchivedDisabledTooltip>\n\n      {/* Show Plugins - only when this conversation has attached plugins */}\n      {shouldShowPlugins && (\n        <ArchivedDisabledTooltip isDisabled={isArchivedConversation}>\n          <ContextMenuListItem\n            testId=\"show-plugins-button\"\n            onClick={onShowPlugins}\n            isDisabled={isArchivedConversation}\n          >\n            <ToolsContextMenuIconText\n              icon={<PuzzleIcon width={16} height={16} aria-hidden />}\n              text={t(I18nKey.CONVERSATION$SHOW_PLUGINS)}\n            />\n          </ContextMenuListItem>\n        </ArchivedDisabledTooltip>\n      )}\n\n      {/* Show Hooks - Only show for V1 conversations */}\n      {shouldShowHooks && (\n        <ArchivedDisabledTooltip isDisabled={isArchivedConversation}>\n          <ContextMenuListItem\n            testId=\"show-hooks-button\"\n            onClick={onShowHooks}\n            isDisabled={isArchivedConversation}\n          >\n            <ToolsContextMenuIconText\n              icon={<FishingHookIcon width={16} height={16} aria-hidden />}\n              text={t(I18nKey.CONVERSATION$SHOW_HOOKS)}\n            />\n          </ContextMenuListItem>\n        </ArchivedDisabledTooltip>\n      )}\n\n      {/* Show Agent Tools and Metadata - Only show if system message is available */}\n      {shouldShowAgentTools && (\n        <ArchivedDisabledTooltip isDisabled={isArchivedConversation}>\n          <ContextMenuListItem\n            testId=\"show-agent-tools-button\"\n            onClick={onShowAgentTools}\n            isDisabled={isArchivedConversation}\n          >\n            <ToolsContextMenuIconText\n              icon={<ToolsIcon width={16} height={16} />}\n              text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}\n            />\n          </ContextMenuListItem>\n        </ArchivedDisabledTooltip>\n      )}\n\n      {footerAction && (\n        <>\n          <Divider />\n          <ContextMenuListItem\n            testId={footerAction.testId}\n            onClick={(event) => {\n              event.preventDefault();\n              event.stopPropagation();\n              footerAction.onClick();\n              handleClose();\n            }}\n          >\n            <ToolsContextMenuIconText\n              icon={footerAction.icon}\n              text={footerAction.label}\n            />\n          </ContextMenuListItem>\n        </>\n      )}\n    </ContextMenu>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/advanced-conversation-options-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[calc(100%+3rem)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 131,
          "column": 24,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 131,
          "endColumn": 68
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[60vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 150,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 150,
          "endColumn": 103
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  Archive,\n  Bot,\n  CalendarArrowDown,\n  Clock3,\n  ClockArrowDown,\n  EyeOff,\n  Folder,\n  GitBranch,\n  MessageCircle,\n  MousePointerClick,\n  Star,\n  Tag,\n} from \"lucide-react\";\nimport AutomationsIcon from \"#/icons/automations.svg?react\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type { BackendKind } from \"#/api/backend-registry/types\";\nimport { useConversationPanelPreferencesStore } from \"#/stores/conversation-panel-preferences-store\";\nimport {\n  BaseModalDescription,\n  BaseModalTitle,\n} from \"#/components/shared/modals/confirmation-modals/base-modal\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  ModalBody,\n} from \"#/components/shared/modals/modal-body\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { cn } from \"#/utils/utils\";\nimport { readVerticalScrollEdgeState } from \"#/utils/scroll-fade-state\";\nimport { MenuHeading } from \"./menu-heading\";\nimport { MenuSeparator } from \"./menu-separator\";\nimport { MenuRow } from \"./menu-row\";\nimport { HideOlderConversationsRow } from \"./hide-older-conversations-row\";\nimport { UNNAMED_AUTOMATION_FACET } from \"./conversation-panel-list-helpers\";\n\nexport interface AdvancedConversationOptionsModalProps {\n  open: boolean;\n  onClose: () => void;\n  backendKind: BackendKind;\n  /**\n   * Distinct automation names among the loaded conversations. Rendered as\n   * selectable rows under the Automations section in `only-automations`\n   * mode \u2014 the only control for `selectedAutomationNames`, which is\n   * persisted and narrows the list on its own.\n   */\n  automationNameFacets: readonly string[];\n}\n\n/**\n * The full preference surface, promoted from the old hamburger filter menu\n * into its own modal (design from the 2026-08-14 PM walkthrough video).\n * Rows apply immediately and stay open \u2014 the modal closes only via the\n * top-right X, footer Close, Escape, or backdrop click.\n */\nexport function AdvancedConversationOptionsModal({\n  open,\n  onClose,\n  backendKind,\n  automationNameFacets,\n}: AdvancedConversationOptionsModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const preferences = useConversationPanelPreferencesStore();\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const [scrollEdges, setScrollEdges] = React.useState({\n    top: false,\n    bottom: false,\n  });\n\n  const updateScrollEdges = React.useCallback(() => {\n    const element = scrollRef.current;\n    if (!element) {\n      return;\n    }\n    setScrollEdges(readVerticalScrollEdgeState(element));\n  }, []);\n\n  React.useLayoutEffect(() => {\n    if (!open) {\n      return undefined;\n    }\n    updateScrollEdges();\n\n    const element = scrollRef.current;\n    if (!element) {\n      return undefined;\n    }\n\n    const resizeObserver = new ResizeObserver(updateScrollEdges);\n    resizeObserver.observe(element);\n    if (element.firstElementChild) {\n      resizeObserver.observe(element.firstElementChild);\n    }\n\n    return () => resizeObserver.disconnect();\n  }, [open, updateScrollEdges, preferences.automationFilterMode]);\n\n  if (!open) return null;\n\n  const groupedLabel =\n    backendKind === \"local\"\n      ? t(I18nKey.CONVERSATION_PANEL$BY_WORKSPACE)\n      : t(I18nKey.CONVERSATION_PANEL$BY_REPOSITORY);\n\n  return (\n    <ModalBackdrop onClose={onClose}>\n      <ModalBody\n        width=\"md\"\n        className={cn(\n          \"relative items-start overflow-hidden border border-border\",\n          MODAL_MAX_WIDTH_VIEWPORT,\n        )}\n        testID=\"advanced-conversation-options-modal\"\n      >\n        <ModalCloseButton\n          onClose={onClose}\n          testId=\"advanced-options-modal-close\"\n        />\n        <div className=\"flex w-full flex-col gap-2 pr-8\">\n          <BaseModalTitle\n            title={t(I18nKey.CONVERSATION_PANEL$ADVANCED_OPTIONS)}\n          />\n          <BaseModalDescription>\n            {t(I18nKey.CONVERSATION_PANEL$ADVANCED_OPTIONS_DESCRIPTION)}\n          </BaseModalDescription>\n        </div>\n\n        <div className=\"relative -mx-6 w-[calc(100%+3rem)] min-h-0\">\n          <div\n            aria-hidden\n            data-testid=\"advanced-options-scroll-edge-top\"\n            data-visible={scrollEdges.top ? \"true\" : \"false\"}\n            className={cn(\n              \"pointer-events-none absolute inset-x-0 top-0 z-10 h-px bg-border\",\n              scrollEdges.top ? \"opacity-100\" : \"opacity-0\",\n            )}\n          />\n          <div\n            ref={scrollRef}\n            role=\"menu\"\n            aria-orientation=\"vertical\"\n            aria-label={t(I18nKey.CONVERSATION_PANEL$ADVANCED_OPTIONS)}\n            // Same idiom as the old filter menu: focusable for jsx-a11y but\n            // out of the natural Tab order (the rows are tabbable buttons).\n            tabIndex={-1}\n            data-testid=\"advanced-options-scroll\"\n            className=\"custom-scrollbar-always flex max-h-[60vh] w-full flex-col overflow-y-auto px-6\"\n            onClick={(event) => event.stopPropagation()}\n            onScroll={updateScrollEdges}\n          >\n            <MenuHeading>{t(I18nKey.CONVERSATION_PANEL$ORGANIZE)}</MenuHeading>\n            <MenuRow\n              icon={Folder}\n              label={groupedLabel}\n              selected={preferences.organizeMode === \"grouped\"}\n              testId=\"organize-grouped\"\n              onClick={() => preferences.setOrganizeMode(\"grouped\")}\n            />\n            <MenuRow\n              icon={Clock3}\n              label={t(I18nKey.CONVERSATION_PANEL$CHRONOLOGICAL)}\n              selected={preferences.organizeMode === \"chronological\"}\n              testId=\"organize-chronological\"\n              onClick={() => preferences.setOrganizeMode(\"chronological\")}\n            />\n\n            <MenuSeparator />\n            <MenuHeading>{t(I18nKey.CONVERSATION_PANEL$SORT_BY)}</MenuHeading>\n            <MenuRow\n              icon={CalendarArrowDown}\n              label={t(I18nKey.CONVERSATION_PANEL$SORT_CREATED)}\n              selected={preferences.conversationSort === \"created\"}\n              testId=\"sort-created\"\n              onClick={() => preferences.setConversationSort(\"created\")}\n            />\n            <MenuRow\n              icon={ClockArrowDown}\n              label={t(I18nKey.CONVERSATION_PANEL$SORT_UPDATED)}\n              selected={preferences.conversationSort === \"updated\"}\n              testId=\"sort-updated\"\n              onClick={() => preferences.setConversationSort(\"updated\")}\n            />\n\n            <MenuSeparator />\n            <MenuHeading>{t(I18nKey.CONVERSATION_PANEL$THREADS)}</MenuHeading>\n            <MenuRow\n              icon={MessageCircle}\n              label={t(I18nKey.CONVERSATION_PANEL$ALL_THREADS)}\n              selected={preferences.threadScope === \"all\"}\n              testId=\"scope-all\"\n              onClick={() => preferences.setThreadScope(\"all\")}\n            />\n            <MenuRow\n              icon={Star}\n              label={t(I18nKey.CONVERSATION_PANEL$RELEVANT_THREADS)}\n              selected={preferences.threadScope === \"relevant\"}\n              testId=\"scope-relevant\"\n              onClick={() => preferences.setThreadScope(\"relevant\")}\n            />\n\n            <MenuSeparator />\n            <MenuHeading>{t(I18nKey.CONVERSATION_PANEL$SHOW)}</MenuHeading>\n            <MenuRow\n              icon={Archive}\n              label={t(I18nKey.CONVERSATION_PANEL$SHOW_ARCHIVED)}\n              selected={preferences.showArchivedConversations}\n              variant=\"toggle\"\n              testId=\"toggle-show-archived\"\n              onClick={preferences.toggleShowArchivedConversations}\n            />\n            <HideOlderConversationsRow />\n            <MenuSeparator />\n            <MenuHeading>\n              {t(I18nKey.CONVERSATION_PANEL$AUTOMATIONS)}\n            </MenuHeading>\n            <MenuRow\n              icon={MessageCircle}\n              label={t(I18nKey.CONVERSATION_PANEL$AUTOMATIONS_ALL)}\n              selected={preferences.automationFilterMode === \"all\"}\n              testId=\"automation-filter-all\"\n              onClick={() => preferences.setAutomationFilterMode(\"all\")}\n            />\n            <MenuRow\n              icon={EyeOff}\n              label={t(I18nKey.CONVERSATION_PANEL$AUTOMATIONS_HIDE)}\n              selected={preferences.automationFilterMode === \"hide-automations\"}\n              testId=\"automation-filter-hide\"\n              onClick={() =>\n                preferences.setAutomationFilterMode(\"hide-automations\")\n              }\n            />\n            <MenuRow\n              icon={AutomationsIcon}\n              label={t(I18nKey.CONVERSATION_PANEL$AUTOMATIONS_ONLY)}\n              selected={preferences.automationFilterMode === \"only-automations\"}\n              testId=\"automation-filter-only\"\n              onClick={() =>\n                preferences.setAutomationFilterMode(\"only-automations\")\n              }\n            />\n            {/* Name rows only make sense while the list is scoped to\n              automations \u2014 and `setAutomationFilterMode` clears the selection\n              on the way out, so a hidden row can never keep narrowing. */}\n            {preferences.automationFilterMode === \"only-automations\"\n              ? automationNameFacets.map((facet) => (\n                  <MenuRow\n                    key={facet}\n                    icon={Tag}\n                    label={\n                      facet === UNNAMED_AUTOMATION_FACET\n                        ? t(I18nKey.CONVERSATION_PANEL$AUTOMATION_UNNAMED)\n                        : facet\n                    }\n                    selected={preferences.selectedAutomationNames.includes(\n                      facet,\n                    )}\n                    variant=\"toggle\"\n                    testId={`automation-name-row-${facet}`}\n                    onClick={() => preferences.toggleAutomationName(facet)}\n                  />\n                ))\n              : null}\n\n            <MenuSeparator />\n            <MenuHeading>{t(I18nKey.CONVERSATION_PANEL$METADATA)}</MenuHeading>\n            <MenuRow\n              icon={GitBranch}\n              label={t(I18nKey.CONVERSATION_PANEL$REPO_BRANCH)}\n              selected={preferences.showRepoBranchMetadata}\n              variant=\"toggle\"\n              testId=\"toggle-repo-branch-metadata\"\n              onClick={preferences.toggleShowRepoBranchMetadata}\n            />\n            <MenuRow\n              icon={Bot}\n              label={t(I18nKey.CONVERSATION_PANEL$LLM_MODEL)}\n              selected={preferences.showLlmProfiles}\n              variant=\"toggle\"\n              testId=\"toggle-llm-profiles\"\n              onClick={preferences.toggleShowLlmProfiles}\n            />\n            <MenuRow\n              icon={Tag}\n              label={t(I18nKey.CONVERSATION_PANEL$TAG_CHIPS)}\n              selected={preferences.showTagsMetadata}\n              variant=\"toggle\"\n              testId=\"toggle-tags-metadata\"\n              onClick={preferences.toggleShowTagsMetadata}\n            />\n            <MenuRow\n              icon={MousePointerClick}\n              label={t(I18nKey.CONVERSATION_PANEL$HOVER_METADATA)}\n              selected={preferences.showHoverMetadata}\n              variant=\"toggle\"\n              testId=\"toggle-hover-metadata\"\n              onClick={preferences.toggleShowHoverMetadata}\n            />\n          </div>\n          <div\n            aria-hidden\n            data-testid=\"advanced-options-scroll-edge-bottom\"\n            data-visible={scrollEdges.bottom ? \"true\" : \"false\"}\n            className={cn(\n              \"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-px bg-border\",\n              scrollEdges.bottom ? \"opacity-100\" : \"opacity-0\",\n            )}\n          />\n        </div>\n\n        <div\n          className=\"flex w-full justify-end\"\n          onClick={(event) => event.stopPropagation()}\n        >\n          <BrandButton\n            type=\"button\"\n            variant=\"primary\"\n            onClick={onClose}\n            testId=\"advanced-options-close\"\n          >\n            {t(I18nKey.BUTTON$CLOSE)}\n          </BrandButton>\n        </div>\n      </ModalBody>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/budget-progress-bar.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"bg-red-500\" uses the raw Tailwind palette. Nearest theme tokens: bg-status-error, bg-danger. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 20,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 20,
          "endColumn": 37,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-status-error"
              },
              "fix": {
                "range": [
                  539,
                  551
                ],
                "text": "\"bg-status-error\""
              },
              "desc": "Replace with \"bg-status-error\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-danger"
              },
              "fix": {
                "range": [
                  539,
                  551
                ],
                "text": "\"bg-danger\""
              },
              "desc": "Replace with \"bg-danger\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"bg-blue-500\" uses the raw Tailwind palette and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css for a new color.",
          "line": 20,
          "column": 40,
          "nodeType": "Literal",
          "messageId": "paletteClassFar",
          "endLine": 20,
          "endColumn": 53
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 24,
          "column": 18,
          "nodeType": "TemplateLiteral",
          "messageId": "inlineStyle",
          "endLine": 24,
          "endColumn": 54
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cn } from \"#/utils/utils\";\n\ninterface BudgetProgressBarProps {\n  currentCost: number;\n  maxBudget: number;\n}\n\nexport function BudgetProgressBar({\n  currentCost,\n  maxBudget,\n}: BudgetProgressBarProps) {\n  const usagePercentage = (currentCost / maxBudget) * 100;\n  const isNearLimit = usagePercentage > 80;\n\n  return (\n    <div className=\"relative mt-1 h-1.5 w-full rounded-full bg-tertiary\">\n      <div\n        className={cn(\n          \"absolute inset-y-0 left-0 rounded-full transition-all duration-300\",\n          isNearLimit ? \"bg-red-500\" : \"bg-blue-500\",\n        )}\n        // runtime usage-percentage width\n        style={{\n          width: `${Math.min(100, usagePercentage)}%`,\n        }}\n      />\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/cloud-new-conversation-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets position. Style through classes; use CSS custom properties for dynamic values.",
          "line": 230,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 230,
          "endColumn": 28
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets top. Style through classes; use CSS custom properties for dynamic values.",
          "line": 231,
          "column": 16,
          "nodeType": "MemberExpression",
          "messageId": "inlineStyle",
          "endLine": 231,
          "endColumn": 28
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets left. Style through classes; use CSS custom properties for dynamic values.",
          "line": 232,
          "column": 17,
          "nodeType": "MemberExpression",
          "messageId": "inlineStyle",
          "endLine": 232,
          "endColumn": 30
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 233,
          "column": 18,
          "nodeType": "MemberExpression",
          "messageId": "inlineStyle",
          "endLine": 233,
          "endColumn": 32
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[40vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 319,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 319,
          "endColumn": 81
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#F87171]\" hardcodes a color. Nearest theme tokens: text-status-error, text-danger. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 333,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 333,
          "endColumn": 61,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-status-error"
              },
              "fix": {
                "range": [
                  10547,
                  10581
                ],
                "text": "\"px-2 py-2 text-sm text-status-error\""
              },
              "desc": "Replace with \"text-status-error\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-danger"
              },
              "fix": {
                "range": [
                  10547,
                  10581
                ],
                "text": "\"px-2 py-2 text-sm text-danger\""
              },
              "desc": "Replace with \"text-danger\"."
            }
          ]
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 6,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\nimport { useCreateConversation } from \"#/hooks/mutation/use-create-conversation\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport { useIsCreatingConversation } from \"#/hooks/use-is-creating-conversation\";\nimport { useGitRepositories } from \"#/hooks/query/use-git-repositories\";\nimport { useSearchRepositories } from \"#/hooks/query/use-search-repositories\";\nimport { useUserProviders } from \"#/hooks/use-user-providers\";\nimport { useDebounce } from \"#/hooks/use-debounce\";\nimport { useHomeStore } from \"#/stores/home-store\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  dropdownMenuRowClassName,\n  dropdownInstantColorClassName,\n  dropdownMenuListClassName,\n  dropdownMenuRowIconWrapperClassName,\n} from \"#/utils/dropdown-classes\";\nimport { GitRepository } from \"#/types/git\";\nimport { Provider } from \"#/types/settings\";\nimport RepoIcon from \"#/icons/repo.svg?react\";\nimport SearchIcon from \"#/icons/search.svg?react\";\nimport { GitProviderIcon } from \"#/components/shared/git-provider-icon\";\nimport { Divider } from \"#/ui/divider\";\nimport { NEW_CONVERSATION_DROPDOWN_SURFACE } from \"./new-conversation-dropdown-styles\";\nimport { usePopoverFixedPlacement } from \"#/hooks/use-popover-fixed-placement\";\n\nexport type CloudNewConversationMenuTriggerProps = {\n  onClick: () => void;\n  \"aria-expanded\": boolean;\n  \"aria-haspopup\": \"menu\";\n  disabled?: boolean;\n};\n\nexport interface CloudNewConversationMenuProps {\n  trigger: (props: CloudNewConversationMenuTriggerProps) => React.ReactNode;\n  className?: string;\n  popoverClassName: string;\n  popoverTestId?: string;\n  useFixedPlacement?: boolean;\n}\n\ninterface RepoListItemProps {\n  repo: GitRepository;\n  disabled: boolean;\n  onSelect: (repo: GitRepository) => void;\n  itemClass: string;\n}\n\nfunction RepoListItem({\n  repo,\n  disabled,\n  onSelect,\n  itemClass,\n}: RepoListItemProps) {\n  return (\n    <li>\n      <button\n        type=\"button\"\n        disabled={disabled}\n        data-testid=\"launch-repository\"\n        data-repo-name={repo.full_name}\n        onClick={() => onSelect(repo)}\n        className={itemClass}\n      >\n        <span className={dropdownMenuRowIconWrapperClassName} aria-hidden>\n          <RepoIcon width={14} height={14} />\n        </span>\n        <span className=\"truncate\">{repo.full_name}</span>\n      </button>\n    </li>\n  );\n}\n\n/**\n * Repository search + launch flow for cloud backends.\n * Shared by the sidebar \"+ New conversation\" control and the conversation\n * panel \"new thread folder\" opener.\n */\nexport function CloudNewConversationMenu({\n  trigger,\n  className,\n  popoverClassName,\n  popoverTestId = \"new-conversation-popover\",\n  useFixedPlacement = false,\n}: CloudNewConversationMenuProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { navigate } = useNavigation();\n\n  const { providers } = useUserProviders();\n  const { lastSelectedProvider, setLastSelectedProvider } = useHomeStore();\n\n  const [open, setOpen] = React.useState(false);\n  const popoverRef = React.useRef<HTMLDivElement>(null);\n  const triggerWrapRef = React.useRef<HTMLSpanElement>(null);\n  const fixedBox = usePopoverFixedPlacement(triggerWrapRef, {\n    open,\n    enabled: useFixedPlacement,\n  });\n  const [selectedProvider, setSelectedProvider] =\n    React.useState<Provider | null>(lastSelectedProvider ?? null);\n  const [query, setQuery] = React.useState(\"\");\n  const debouncedQuery = useDebounce(query, 300);\n\n  React.useEffect(() => {\n    if (providers.length === 0) {\n      if (selectedProvider !== null) setSelectedProvider(null);\n      return;\n    }\n    if (selectedProvider && providers.includes(selectedProvider)) return;\n    const fallback =\n      lastSelectedProvider && providers.includes(lastSelectedProvider)\n        ? lastSelectedProvider\n        : providers[0];\n    setSelectedProvider(fallback);\n  }, [providers, selectedProvider, lastSelectedProvider]);\n\n  const repositoryQueriesEnabled = open && !!selectedProvider;\n  const {\n    data: repoPages,\n    isLoading,\n    isError,\n    hasNextPage,\n    isFetchingNextPage,\n    fetchNextPage,\n  } = useGitRepositories({\n    provider: selectedProvider,\n    enabled: repositoryQueriesEnabled,\n  });\n\n  const { data: searchResults, isLoading: isSearchLoading } =\n    useSearchRepositories(\n      debouncedQuery,\n      selectedProvider,\n      !repositoryQueriesEnabled,\n    );\n\n  const allRepositories = React.useMemo(\n    () => repoPages?.pages.flatMap((page) => page.items) ?? [],\n    [repoPages],\n  );\n\n  const repositories = debouncedQuery ? (searchResults ?? []) : allRepositories;\n\n  const { mutate: createConversation, isPending } = useCreateConversation();\n  const isCreatingElsewhere = useIsCreatingConversation();\n  const isCreating = isPending || isCreatingElsewhere;\n\n  React.useEffect(() => {\n    if (!open) return undefined;\n    const onDown = (e: MouseEvent) => {\n      if (\n        popoverRef.current &&\n        !popoverRef.current.contains(e.target as Node)\n      ) {\n        setOpen(false);\n      }\n    };\n    document.addEventListener(\"mousedown\", onDown);\n    return () => document.removeEventListener(\"mousedown\", onDown);\n  }, [open]);\n\n  React.useEffect(() => {\n    if (!open) return undefined;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [open]);\n\n  const launchRepository = (repo: GitRepository) => {\n    if (isCreating) return;\n    createConversation(\n      {\n        repository: {\n          name: repo.full_name,\n          gitProvider: repo.git_provider,\n          branch: repo.main_branch ?? \"main\",\n        },\n        entryPoint: \"sidebar_cloud_menu\",\n      },\n      {\n        onSuccess: (data) => {\n          setOpen(false);\n          navigate(`/conversations/${data.conversation_id}`);\n        },\n      },\n    );\n  };\n\n  const handleProviderChange = (provider: Provider) => {\n    setSelectedProvider(provider);\n    setLastSelectedProvider(provider);\n    setQuery(\"\");\n  };\n\n  const itemClass = dropdownMenuRowClassName;\n\n  React.useEffect(() => {\n    if (!open) return;\n    if (debouncedQuery) return;\n    if (!hasNextPage || isFetchingNextPage || isLoading) return;\n    if (repositories.length === 0 || repositories.length >= 10) return;\n    fetchNextPage();\n  }, [\n    open,\n    debouncedQuery,\n    hasNextPage,\n    isFetchingNextPage,\n    isLoading,\n    repositories.length,\n    fetchNextPage,\n  ]);\n\n  const isListLoading = debouncedQuery ? isSearchLoading : isLoading;\n  const showLoadMore =\n    !debouncedQuery && hasNextPage && repositories.length > 0;\n\n  const toggleOpen = React.useCallback(() => {\n    setOpen((o) => !o);\n  }, []);\n\n  const showPopover = open && (!useFixedPlacement || fixedBox !== null);\n\n  const fixedStyle: React.CSSProperties | undefined =\n    useFixedPlacement && fixedBox\n      ? {\n          position: \"fixed\",\n          top: fixedBox.top,\n          left: fixedBox.left,\n          width: fixedBox.width,\n        }\n      : undefined;\n\n  return (\n    <div\n      className={cn(!useFixedPlacement && \"relative\", className)}\n      ref={popoverRef}\n    >\n      <span ref={triggerWrapRef} className=\"inline-flex\">\n        {trigger({\n          onClick: toggleOpen,\n          \"aria-expanded\": open,\n          \"aria-haspopup\": \"menu\",\n          disabled: isCreating,\n        })}\n      </span>\n\n      {showPopover && (\n        <div\n          data-testid={popoverTestId}\n          className={cn(\n            NEW_CONVERSATION_DROPDOWN_SURFACE,\n            !useFixedPlacement &&\n              cn(\"absolute top-full mt-0\", popoverClassName),\n          )}\n          style={fixedStyle}\n        >\n          {providers.length > 1 && (\n            <div\n              className=\"flex items-center gap-1 px-1 py-1\"\n              data-testid=\"cloud-provider-tabs\"\n            >\n              {providers.map((provider) => {\n                const isActive = provider === selectedProvider;\n                return (\n                  <button\n                    key={provider}\n                    type=\"button\"\n                    data-testid={`cloud-provider-tab-${provider}`}\n                    onClick={() => handleProviderChange(provider)}\n                    className={cn(\n                      \"flex items-center gap-1 rounded border px-2 py-1 text-xs\",\n                      dropdownInstantColorClassName,\n                      isActive\n                        ? \"border-border-subtle bg-interactive-hover text-contrast\"\n                        : \"border-transparent text-text-secondary hover:text-contrast\",\n                    )}\n                  >\n                    <GitProviderIcon gitProvider={provider} />\n                    <span className=\"capitalize\">{provider}</span>\n                  </button>\n                );\n              })}\n            </div>\n          )}\n\n          <div className=\"px-2\">\n            <div className=\"relative\">\n              <SearchIcon\n                width={16}\n                height={16}\n                aria-hidden\n                className=\"pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 text-muted\"\n              />\n              <input\n                type=\"text\"\n                data-testid=\"cloud-repo-search-input\"\n                value={query}\n                onChange={(e) => setQuery(e.target.value)}\n                placeholder={t(I18nKey.COMMON$SEARCH_REPOSITORIES)}\n                disabled={!selectedProvider}\n                className={cn(\n                  \"w-full border-0 bg-transparent py-1.5 pl-6 pr-0 text-sm text-contrast\",\n                  \"outline-none placeholder:text-muted\",\n                  \"focus:outline-none focus:ring-0\",\n                  \"disabled:cursor-not-allowed disabled:opacity-60\",\n                )}\n              />\n            </div>\n          </div>\n\n          <Divider inset=\"menu\" />\n\n          <ul\n            className={cn(\n              \"max-h-[40vh] overflow-y-auto custom-scrollbar-always sm:max-h-70\",\n              dropdownMenuListClassName,\n            )}\n          >\n            {isListLoading && repositories.length === 0 && (\n              <li\n                className=\"px-2 py-2 text-sm text-muted italic\"\n                data-testid=\"cloud-repo-loading\"\n              >\n                {t(I18nKey.HOME$LOADING_REPOSITORIES)}\n              </li>\n            )}\n            {isError && (\n              <li\n                className=\"px-2 py-2 text-sm text-[#F87171]\"\n                data-testid=\"cloud-repo-error\"\n              >\n                {t(I18nKey.HOME$FAILED_TO_LOAD_REPOSITORIES)}\n              </li>\n            )}\n            {!isListLoading &&\n              !isError &&\n              repositories.length === 0 &&\n              !!selectedProvider && (\n                <li\n                  className=\"px-2 py-2 text-sm text-muted italic\"\n                  data-testid=\"cloud-repo-empty\"\n                >\n                  {t(I18nKey.GITHUB$NO_RESULTS)}\n                </li>\n              )}\n            {repositories.map((repo) => (\n              <RepoListItem\n                key={`${repo.git_provider}:${repo.id}`}\n                repo={repo}\n                disabled={isCreating}\n                onSelect={launchRepository}\n                itemClass={itemClass}\n              />\n            ))}\n            {showLoadMore && (\n              <li>\n                <button\n                  type=\"button\"\n                  data-testid=\"cloud-repo-load-more\"\n                  disabled={isFetchingNextPage}\n                  onClick={() => fetchNextPage()}\n                  className={itemClass}\n                >\n                  <span className=\"text-text-secondary\">\n                    {isFetchingNextPage\n                      ? t(I18nKey.HOME$LOADING_MORE_REPOSITORIES)\n                      : t(I18nKey.CONVERSATION$LOAD_MORE)}\n                  </span>\n                </button>\n              </li>\n            )}\n          </ul>\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/conversation-active-tag-filters.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 50,
          "column": 5,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 50,
          "endColumn": 157
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 96,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 96,
          "endColumn": 97
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { Tag, Workflow, X } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport {\n  formatTagFacetLabel,\n  UNNAMED_AUTOMATION_FACET,\n} from \"./conversation-panel-list-helpers\";\n\ninterface ConversationActiveTagFiltersProps {\n  selectedFacets: readonly string[];\n  onToggleFacet: (facet: string) => void;\n  /** Automation names narrowing the list in `only-automations` mode. */\n  selectedAutomationNames: readonly string[];\n  onToggleAutomationName: (name: string) => void;\n  onClearAll: () => void;\n}\n\n/**\n * Always-visible record of which facet selections are narrowing the list \u2014\n * both families: user tags and automation names.\n *\n * The facet rows live two levels inside a menu (tag facets behind a toggle in\n * the layouts menu, automation names inside the advanced-options modal).\n * Without this strip a filter left switched on just makes conversations\n * disappear, with nothing on screen to say why or how to get them back \u2014 and\n * both selections are persisted, so a reload brings the narrowing back with\n * the explanation still buried.\n *\n * The automation *mode* deliberately has no chip here: it is a scope, not a\n * facet selection, and `onClearAll` leaves it alone for the same reason \u2014\n * this strip must not silently switch a surface it doesn't show.\n *\n * Renders nothing when nothing is selected \u2014 an empty bar would cost a row\n * of a narrow sidebar to say \"no news\".\n */\nexport function ConversationActiveTagFilters({\n  selectedFacets,\n  onToggleFacet,\n  selectedAutomationNames,\n  onToggleAutomationName,\n  onClearAll,\n}: ConversationActiveTagFiltersProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  if (selectedFacets.length === 0 && selectedAutomationNames.length === 0) {\n    return null;\n  }\n\n  const chipClassName =\n    \"flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-full bg-surface px-2 py-0.5 text-[10px] leading-4 text-contrast hover:bg-contrast/10\";\n\n  return (\n    <div\n      data-testid=\"conversation-active-tag-filters\"\n      className=\"flex min-w-0 items-start gap-1.5 border-b border-border px-4 py-1.5\"\n    >\n      <Tag className=\"mt-1 h-3 w-3 shrink-0 text-muted\" aria-hidden />\n\n      <div className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1\">\n        {selectedFacets.map((facet) => (\n          <button\n            key={facet}\n            type=\"button\"\n            data-testid={`active-tag-filter-${facet}`}\n            onClick={() => onToggleFacet(facet)}\n            className={chipClassName}\n          >\n            <span className=\"truncate\">{formatTagFacetLabel(facet, t)}</span>\n            <X className=\"h-3 w-3 shrink-0\" aria-hidden />\n          </button>\n        ))}\n\n        {selectedAutomationNames.map((name) => (\n          <button\n            key={name}\n            type=\"button\"\n            data-testid={`active-automation-filter-${name}`}\n            onClick={() => onToggleAutomationName(name)}\n            className={chipClassName}\n          >\n            <Workflow className=\"h-3 w-3 shrink-0\" aria-hidden />\n            <span className=\"truncate\">\n              {name === UNNAMED_AUTOMATION_FACET\n                ? t(I18nKey.CONVERSATION_PANEL$AUTOMATION_UNNAMED)\n                : name}\n            </span>\n            <X className=\"h-3 w-3 shrink-0\" aria-hidden />\n          </button>\n        ))}\n      </div>\n\n      <button\n        type=\"button\"\n        data-testid=\"clear-tag-filters\"\n        onClick={onClearAll}\n        className=\"shrink-0 cursor-pointer text-[10px] leading-5 text-muted hover:text-contrast\"\n      >\n        {t(I18nKey.CONVERSATION_PANEL$CLEAR_FILTERS)}\n      </button>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/conversation-card/conversation-card-context-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 94,
          "column": 27,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 94,
          "endColumn": 40
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[min(280px,100vw-16px)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 101,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 101,
          "endColumn": 64
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[200]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 102,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 102,
          "endColumn": 27
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"stroke-[1.75]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 160,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 160,
          "endColumn": 46
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "i18next/no-literal-string",
          "severity": 2,
          "message": "disallow literal string: generateSection(\n        [\n          onEdit && (\n            <ContextMenuListItem\n              key=\"edit-button\"\n              testId=\"edit-button\"\n              onClick={onEdit}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<EditIcon width={16} height={16} />}\n                text={t(I18nKey.BUTTON$RENAME)}\n              />\n            </ContextMenuListItem>\n          ),\n          onEditTags && (\n            <ContextMenuListItem\n              key=\"edit-tags-button\"\n              testId=\"edit-tags-button\"\n              onClick={onEditTags}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<Tags className=\"h-4 w-4\" aria-hidden />}\n                text={t(I18nKey.CONVERSATION$EDIT_TAGS)}\n              />\n            </ContextMenuListItem>\n          ),\n        ],\n        // eslint-disable-next-line i18next/no-literal-string -- internal section id, not user-facing\n        \"edit-section\",\n      )",
          "line": 133,
          "column": 9,
          "nodeType": "Literal",
          "endLine": 133,
          "endColumn": 23,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "internal section id, not user-facing"
            }
          ]
        },
        {
          "ruleId": "i18next/no-literal-string",
          "severity": 2,
          "message": "disallow literal string: generateSection(\n        [\n          onShowAgentTools && (\n            <ContextMenuListItem\n              key=\"show-agent-tools-button\"\n              testId=\"show-agent-tools-button\"\n              onClick={onShowAgentTools}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<ToolsIcon width={16} height={16} />}\n                text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}\n              />\n            </ContextMenuListItem>\n          ),\n          onShowSkills && (\n            <ContextMenuListItem\n              key=\"show-skills-button\"\n              testId=\"show-skills-button\"\n              onClick={onShowSkills}\n            >\n              <ConversationNameContextMenuIconText\n                icon={\n                  <SkillsIcon\n                    width={16}\n                    height={16}\n                    className=\"stroke-[1.75]\"\n                    aria-hidden\n                  />\n                }\n                text={t(I18nKey.CONVERSATION$SHOW_SKILLS)}\n              />\n            </ContextMenuListItem>\n          ),\n        ],\n        // eslint-disable-next-line i18next/no-literal-string -- internal section id, not user-facing\n        \"tools-section\",\n      )",
          "line": 170,
          "column": 9,
          "nodeType": "Literal",
          "endLine": 170,
          "endColumn": 24,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "internal section id, not user-facing"
            }
          ]
        },
        {
          "ruleId": "i18next/no-literal-string",
          "severity": 2,
          "message": "disallow literal string: generateSection(\n        [\n          onStop && (\n            <ContextMenuListItem\n              key=\"stop-button\"\n              testId=\"stop-button\"\n              onClick={onStop}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<CloseIcon width={16} height={16} />}\n                text={t(stopLabelKey)}\n              />\n            </ContextMenuListItem>\n          ),\n          onDownloadViaVSCode && (\n            <ContextMenuListItem\n              key=\"download-vscode-button\"\n              testId=\"download-vscode-button\"\n              onClick={onDownloadViaVSCode}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<DownloadIcon width={16} height={16} />}\n                text={t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}\n              />\n            </ContextMenuListItem>\n          ),\n          onDownloadConversation && (\n            <ContextMenuListItem\n              key=\"download-trajectory-button\"\n              testId=\"download-trajectory-button\"\n              onClick={onDownloadConversation}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<DownloadIcon width={16} height={16} />}\n                text={t(I18nKey.BUTTON$DOWNLOAD_CONVERSATION_DATA)}\n              />\n            </ContextMenuListItem>\n          ),\n        ],\n        // eslint-disable-next-line i18next/no-literal-string -- internal section id, not user-facing\n        \"control-section\",\n      )",
          "line": 212,
          "column": 9,
          "nodeType": "Literal",
          "endLine": 212,
          "endColumn": 26,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "internal section id, not user-facing"
            }
          ]
        },
        {
          "ruleId": "i18next/no-literal-string",
          "severity": 2,
          "message": "disallow literal string: generateSection(\n        [\n          onDisplayCost && (\n            <ContextMenuListItem\n              key=\"display-cost-button\"\n              testId=\"display-cost-button\"\n              onClick={onDisplayCost}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<Gauge size={16} />}\n                text={t(I18nKey.BUTTON$DISPLAY_COST)}\n              />\n            </ContextMenuListItem>\n          ),\n          onArchive && (\n            <ContextMenuListItem\n              key=\"archive-button\"\n              testId=\"archive-button\"\n              onClick={onArchive}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<Archive className=\"h-4 w-4\" aria-hidden />}\n                text={t(I18nKey.COMMON$ARCHIVE_CONVERSATION)}\n              />\n            </ContextMenuListItem>\n          ),\n          onUnarchive && (\n            <ContextMenuListItem\n              key=\"unarchive-button\"\n              testId=\"unarchive-button\"\n              onClick={onUnarchive}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<ArchiveRestore className=\"h-4 w-4\" aria-hidden />}\n                text={t(I18nKey.COMMON$UNARCHIVE_CONVERSATION)}\n              />\n            </ContextMenuListItem>\n          ),\n          onDelete && (\n            <ContextMenuListItem\n              key=\"delete-button\"\n              testId=\"delete-button\"\n              onClick={onDelete}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<DeleteIcon width={16} height={16} />}\n                text={t(I18nKey.COMMON$DELETE_CONVERSATION)}\n              />\n            </ContextMenuListItem>\n          ),\n        ],\n        // eslint-disable-next-line i18next/no-literal-string -- internal section id, not user-facing\n        \"info-section\",\n        true,\n      )",
          "line": 266,
          "column": 9,
          "nodeType": "Literal",
          "endLine": 266,
          "endColumn": 23,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "internal section id, not user-facing"
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { useCallback } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { ContextMenuListItem } from \"../../context-menu/context-menu-list-item\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { ConversationNameContextMenuIconText } from \"../../conversation/conversation-name-context-menu-icon-text\";\n\nimport { Archive, ArchiveRestore, Gauge, Tags } from \"lucide-react\";\nimport EditIcon from \"#/icons/u-edit.svg?react\";\nimport SkillsIcon from \"#/icons/skills.svg?react\";\nimport ToolsIcon from \"#/icons/u-tools.svg?react\";\nimport DownloadIcon from \"#/icons/u-download.svg?react\";\nimport CloseIcon from \"#/icons/u-close.svg?react\";\nimport DeleteIcon from \"#/icons/u-delete.svg?react\";\nimport { Divider } from \"#/ui/divider\";\n\ninterface ConversationCardContextMenuProps {\n  onClose: () => void;\n  onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onArchive?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onUnarchive?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onEditTags?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  position?: \"top\" | \"bottom\";\n  /**\n   * Fixed coordinates for a portaled menu (conversation list overflow\n   * stacking). When set, theme/position switch to non-absolute layout.\n   */\n  floatingStyle?: React.CSSProperties;\n  ignoreOutsideClickRef?: React.RefObject<HTMLElement | null>;\n}\n\nexport function ConversationCardContextMenu({\n  onClose,\n  onDelete,\n  onArchive,\n  onUnarchive,\n  onStop,\n  onEdit,\n  onEditTags,\n  onDisplayCost,\n  onShowAgentTools,\n  onShowSkills,\n  onDownloadViaVSCode,\n  onDownloadConversation,\n  position = \"bottom\",\n  floatingStyle,\n  ignoreOutsideClickRef,\n}: ConversationCardContextMenuProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { backend } = useActiveBackend();\n  const ref = useClickOutsideElement<HTMLUListElement>(\n    onClose,\n    ignoreOutsideClickRef,\n  );\n  const stopLabelKey =\n    backend.kind === \"cloud\"\n      ? I18nKey.COMMON$CLOSE_CONVERSATION_STOP_RUNTIME\n      : I18nKey.COMMON$STOP_CONVERSATION;\n\n  const generateSection = useCallback(\n    (items: React.ReactNode[], sectionKey: string, isLast?: boolean) => {\n      const filteredItems = items.filter((i) => i != null);\n\n      if (filteredItems.length > 0) {\n        return !isLast ? (\n          <React.Fragment key={sectionKey}>\n            {filteredItems}\n            <Divider inset=\"menu\" />\n          </React.Fragment>\n        ) : (\n          <React.Fragment key={sectionKey}>{filteredItems}</React.Fragment>\n        );\n      }\n      return null;\n    },\n    [],\n  );\n\n  const isPortaled = floatingStyle != null;\n\n  return (\n    <ContextMenu\n      ref={ref}\n      testId=\"context-menu\"\n      style={isPortaled ? floatingStyle : undefined}\n      theme={isPortaled ? \"popover\" : \"default\"}\n      position={isPortaled ? \"none\" : position}\n      alignment={isPortaled ? \"none\" : \"right\"}\n      spacing={isPortaled ? \"none\" : \"default\"}\n      className={\n        isPortaled\n          ? \"mt-0 min-w-50 w-max max-w-[min(280px,100vw-16px)]\"\n          : \"z-[200] mt-0\"\n      }\n    >\n      {generateSection(\n        [\n          onEdit && (\n            <ContextMenuListItem\n              key=\"edit-button\"\n              testId=\"edit-button\"\n              onClick={onEdit}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<EditIcon width={16} height={16} />}\n                text={t(I18nKey.BUTTON$RENAME)}\n              />\n            </ContextMenuListItem>\n          ),\n          onEditTags && (\n            <ContextMenuListItem\n              key=\"edit-tags-button\"\n              testId=\"edit-tags-button\"\n              onClick={onEditTags}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<Tags className=\"h-4 w-4\" aria-hidden />}\n                text={t(I18nKey.CONVERSATION$EDIT_TAGS)}\n              />\n            </ContextMenuListItem>\n          ),\n        ],\n        // eslint-disable-next-line i18next/no-literal-string -- internal section id, not user-facing\n        \"edit-section\",\n      )}\n      {generateSection(\n        [\n          onShowAgentTools && (\n            <ContextMenuListItem\n              key=\"show-agent-tools-button\"\n              testId=\"show-agent-tools-button\"\n              onClick={onShowAgentTools}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<ToolsIcon width={16} height={16} />}\n                text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}\n              />\n            </ContextMenuListItem>\n          ),\n          onShowSkills && (\n            <ContextMenuListItem\n              key=\"show-skills-button\"\n              testId=\"show-skills-button\"\n              onClick={onShowSkills}\n            >\n              <ConversationNameContextMenuIconText\n                icon={\n                  <SkillsIcon\n                    width={16}\n                    height={16}\n                    className=\"stroke-[1.75]\"\n                    aria-hidden\n                  />\n                }\n                text={t(I18nKey.CONVERSATION$SHOW_SKILLS)}\n              />\n            </ContextMenuListItem>\n          ),\n        ],\n        // eslint-disable-next-line i18next/no-literal-string -- internal section id, not user-facing\n        \"tools-section\",\n      )}\n      {generateSection(\n        [\n          onStop && (\n            <ContextMenuListItem\n              key=\"stop-button\"\n              testId=\"stop-button\"\n              onClick={onStop}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<CloseIcon width={16} height={16} />}\n                text={t(stopLabelKey)}\n              />\n            </ContextMenuListItem>\n          ),\n          onDownloadViaVSCode && (\n            <ContextMenuListItem\n              key=\"download-vscode-button\"\n              testId=\"download-vscode-button\"\n              onClick={onDownloadViaVSCode}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<DownloadIcon width={16} height={16} />}\n                text={t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}\n              />\n            </ContextMenuListItem>\n          ),\n          onDownloadConversation && (\n            <ContextMenuListItem\n              key=\"download-trajectory-button\"\n              testId=\"download-trajectory-button\"\n              onClick={onDownloadConversation}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<DownloadIcon width={16} height={16} />}\n                text={t(I18nKey.BUTTON$DOWNLOAD_CONVERSATION_DATA)}\n              />\n            </ContextMenuListItem>\n          ),\n        ],\n        // eslint-disable-next-line i18next/no-literal-string -- internal section id, not user-facing\n        \"control-section\",\n      )}\n      {generateSection(\n        [\n          onDisplayCost && (\n            <ContextMenuListItem\n              key=\"display-cost-button\"\n              testId=\"display-cost-button\"\n              onClick={onDisplayCost}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<Gauge size={16} />}\n                text={t(I18nKey.BUTTON$DISPLAY_COST)}\n              />\n            </ContextMenuListItem>\n          ),\n          onArchive && (\n            <ContextMenuListItem\n              key=\"archive-button\"\n              testId=\"archive-button\"\n              onClick={onArchive}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<Archive className=\"h-4 w-4\" aria-hidden />}\n                text={t(I18nKey.COMMON$ARCHIVE_CONVERSATION)}\n              />\n            </ContextMenuListItem>\n          ),\n          onUnarchive && (\n            <ContextMenuListItem\n              key=\"unarchive-button\"\n              testId=\"unarchive-button\"\n              onClick={onUnarchive}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<ArchiveRestore className=\"h-4 w-4\" aria-hidden />}\n                text={t(I18nKey.COMMON$UNARCHIVE_CONVERSATION)}\n              />\n            </ContextMenuListItem>\n          ),\n          onDelete && (\n            <ContextMenuListItem\n              key=\"delete-button\"\n              testId=\"delete-button\"\n              onClick={onDelete}\n            >\n              <ConversationNameContextMenuIconText\n                icon={<DeleteIcon width={16} height={16} />}\n                text={t(I18nKey.COMMON$DELETE_CONVERSATION)}\n              />\n            </ContextMenuListItem>\n          ),\n        ],\n        // eslint-disable-next-line i18next/no-literal-string -- internal section id, not user-facing\n        \"info-section\",\n        true,\n      )}\n    </ContextMenu>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/conversation-card/conversation-card-preview.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[min(280px,90vw)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 147,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 147,
          "endColumn": 88
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import type { ReactNode } from \"react\";\nimport { Folder } from \"lucide-react\";\nimport { FaBitbucket, FaGithub, FaGitlab } from \"react-icons/fa6\";\nimport { FaCodeBranch } from \"react-icons/fa\";\nimport type { IconType } from \"react-icons/lib\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type { RepositorySelection } from \"#/api/open-hands.types\";\nimport type { Provider } from \"#/types/settings\";\nimport type { ExecutionStatus } from \"#/types/agent-server/core/base/common\";\nimport type { SandboxStatus } from \"#/api/conversation-service/agent-server-conversation-service.types\";\nimport { getDisplayConversationTags } from \"#/api/agent-server-adapter\";\nimport { resolveAcpProviderIcon } from \"#/constants/acp-providers\";\nimport AzureDevOpsLogo from \"#/assets/branding/azure-devops-logo.svg?react\";\nimport { AgentBrandIcon } from \"#/components/shared/agent-brand-icon\";\nimport { ConversationStatusDot } from \"../conversation-status-dot\";\nimport { isMissingSandboxStatus } from \"#/utils/conversation-archive-status\";\nimport { getConversationTagLabel } from \"./conversation-tag-display\";\nimport { getConversationTagIcon } from \"./conversation-tag-icons\";\n\ninterface ConversationCardPreviewProps {\n  title: string;\n  executionStatus?: ExecutionStatus | null;\n  sandboxStatus?: SandboxStatus | null;\n  selectedRepository: RepositorySelection | null;\n  workspaceWorkingDir?: string | null;\n  llmModel?: string | null;\n  /**\n   * High-level kind of the conversation's agent. Drives the model row's brand\n   * mark exactly like the card chip does \u2014 without it an ACP conversation\n   * would show the OpenHands wordmark next to a Claude Code / Codex / Gemini\n   * model, contradicting the chip on the very card being hovered.\n   */\n  agentKind?: \"openhands\" | \"acp\" | null;\n  /** Registry key of the ACP CLI server, resolved to its brand mark. */\n  acpServer?: string | null;\n  createdAt?: string;\n  /**\n   * Server-side conversation tags. Always shown in the hovercard when present\n   * (except keys already covered by repository / branch / directory rows).\n   * Sidebar card chips stay gated by the panel's Tags preference.\n   */\n  tags?: Record<string, string> | null;\n  /**\n   * The row's effective archived state. Keeps the preview's status dot in\n   * agreement with the \"Archived\" chip shown on the card being hovered. Leave\n   * undefined to infer it from `sandboxStatus` (a missing, non-resumable\n   * runtime).\n   */\n  isArchived?: boolean;\n}\n\nconst providerIcon: Partial<Record<Provider, IconType>> = {\n  bitbucket: FaBitbucket,\n  bitbucket_data_center: FaBitbucket,\n  github: FaGithub,\n  gitlab: FaGitlab,\n};\n\ninterface PreviewRowProps {\n  label: string;\n  children: ReactNode;\n}\n\nfunction PreviewRow({ label, children }: PreviewRowProps) {\n  return (\n    <div className=\"flex items-start gap-2 text-xs leading-4\">\n      <span className=\"w-20 shrink-0 whitespace-normal break-words text-muted\">\n        {label}\n      </span>\n      <span className=\"min-w-0 flex-1 overflow-visible whitespace-normal break-words text-foreground\">\n        {children}\n      </span>\n    </div>\n  );\n}\n\n/**\n * Icon + value for preview rows. The icon sits in a box matching the first\n * line's height (``leading-4``) so it stays optically centered when the value\n * wraps to multiple lines. The slot has a 12px *minimum* rather than a fixed\n * width so the wider OpenHands wordmark (18px at ``size={12}``) is not clipped,\n * while the square icons still share one column.\n */\nfunction PreviewValueWithIcon({\n  icon,\n  children,\n  testId,\n  tagKey,\n}: {\n  icon?: ReactNode;\n  children: ReactNode;\n  testId?: string;\n  tagKey?: string;\n}) {\n  return (\n    <span\n      data-testid={testId}\n      data-tag-key={tagKey}\n      className=\"inline-flex min-w-0 max-w-full items-start gap-1.5\"\n    >\n      {icon ? (\n        <span className=\"inline-flex h-4 min-w-3 shrink-0 items-center justify-center [&_svg]:block\">\n          {icon}\n        </span>\n      ) : null}\n      <span className=\"min-w-0 flex-1 whitespace-normal break-all\">\n        {children}\n      </span>\n    </span>\n  );\n}\n\nexport function ConversationCardPreview({\n  title,\n  executionStatus,\n  sandboxStatus,\n  selectedRepository,\n  workspaceWorkingDir,\n  llmModel,\n  agentKind = null,\n  acpServer = null,\n  createdAt,\n  tags = null,\n  isArchived,\n}: ConversationCardPreviewProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  const effectiveArchived = isArchived ?? isMissingSandboxStatus(sandboxStatus);\n  const repository = selectedRepository?.selected_repository ?? null;\n  const branch = selectedRepository?.selected_branch ?? null;\n  const provider = selectedRepository?.git_provider ?? null;\n  const ProviderIcon = provider ? providerIcon[provider] : null;\n\n  const createdLabel = createdAt\n    ? new Date(createdAt).toLocaleString(undefined, {\n        dateStyle: \"medium\",\n        timeStyle: \"short\",\n      })\n    : null;\n\n  const previewTags = getDisplayConversationTags(tags);\n\n  return (\n    <div\n      data-testid=\"conversation-card-preview\"\n      className=\"flex w-70 max-w-[min(280px,90vw)] flex-col gap-3 overflow-visible p-3\"\n    >\n      <div className=\"flex items-start gap-2\">\n        {executionStatus !== undefined ? (\n          <span className=\"inline-flex h-5 w-2.5 shrink-0 items-center justify-center\">\n            <ConversationStatusDot\n              executionStatus={executionStatus}\n              sandboxStatus={sandboxStatus}\n              isArchived={effectiveArchived}\n              showTooltip={false}\n            />\n          </span>\n        ) : null}\n        <span className=\"break-words text-sm font-medium leading-5 text-contrast\">\n          {title}\n        </span>\n      </div>\n\n      <dl className=\"flex flex-col gap-1.5\">\n        {repository ? (\n          <>\n            <PreviewRow label={t(I18nKey.CONVERSATION_PANEL$PREVIEW_REPO)}>\n              <PreviewValueWithIcon\n                icon={\n                  ProviderIcon ? (\n                    <ProviderIcon size={12} />\n                  ) : provider === \"azure_devops\" ? (\n                    <AzureDevOpsLogo className=\"h-3 w-3\" />\n                  ) : undefined\n                }\n              >\n                {repository}\n              </PreviewValueWithIcon>\n            </PreviewRow>\n            {branch ? (\n              <PreviewRow label={t(I18nKey.CONVERSATION_PANEL$PREVIEW_BRANCH)}>\n                <PreviewValueWithIcon icon={<FaCodeBranch size={11} />}>\n                  {branch}\n                </PreviewValueWithIcon>\n              </PreviewRow>\n            ) : null}\n          </>\n        ) : workspaceWorkingDir ? (\n          <PreviewRow label={t(I18nKey.CONVERSATION_PANEL$PREVIEW_DIRECTORY)}>\n            <PreviewValueWithIcon icon={<Folder size={12} />}>\n              {workspaceWorkingDir}\n            </PreviewValueWithIcon>\n          </PreviewRow>\n        ) : null}\n\n        {llmModel ? (\n          <PreviewRow label={t(I18nKey.CONVERSATION_PANEL$PREVIEW_MODEL)}>\n            <PreviewValueWithIcon\n              testId=\"conversation-card-preview-model\"\n              icon={\n                <AgentBrandIcon\n                  kind={\n                    agentKind === \"acp\"\n                      ? resolveAcpProviderIcon(acpServer)\n                      : \"openhands\"\n                  }\n                  size={12}\n                />\n              }\n            >\n              {llmModel}\n            </PreviewValueWithIcon>\n          </PreviewRow>\n        ) : null}\n\n        {previewTags.map(([key, value]) => {\n          const Icon = getConversationTagIcon(key, value);\n          const label = getConversationTagLabel(key, t);\n\n          return (\n            <PreviewRow key={key} label={label}>\n              <PreviewValueWithIcon\n                testId=\"conversation-card-preview-tag-row\"\n                tagKey={key}\n                icon={<Icon aria-hidden className=\"h-3 w-3\" />}\n              >\n                {/* Bare tags (empty value) show an em dash \u2014 the row label\n                    already carries the key, so repeating it would stutter. */}\n                {value || \"\u2014\"}\n              </PreviewValueWithIcon>\n            </PreviewRow>\n          );\n        })}\n\n        {createdLabel ? (\n          <PreviewRow label={t(I18nKey.CONVERSATION$CREATED)}>\n            {createdLabel}\n          </PreviewRow>\n        ) : null}\n      </dl>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/conversation-card/conversation-card.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-w-[3.75rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 306,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 306,
          "endColumn": 34
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { Pin } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport type { ACPModelOption } from \"#/constants/acp-providers\";\nimport { useTracking } from \"#/hooks/use-tracking\";\nimport { cn } from \"#/utils/utils\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { transformVSCodeUrl } from \"#/utils/vscode-url-helper\";\nimport ConversationService from \"#/api/conversation-service/conversation-service.api\";\nimport { getDisplayConversationTags } from \"#/api/agent-server-adapter\";\nimport { ExecutionStatus } from \"#/types/agent-server/core/base/common\";\nimport { SandboxStatus } from \"#/api/conversation-service/agent-server-conversation-service.types\";\nimport { RepositorySelection } from \"#/api/open-hands.types\";\nimport { formatTimeDelta } from \"#/utils/format-time-delta\";\nimport {\n  hoverRevealActionClassName,\n  hoverRevealPinnedTimestampClassName,\n  hoverRevealReserveClassName,\n  hoverRevealYieldClassName,\n} from \"#/utils/hover-reveal-classes\";\nimport { ConversationCardHeader } from \"./conversation-card-header\";\nimport { ConversationCardActions } from \"./conversation-card-actions\";\nimport { ConversationCardFooter } from \"./conversation-card-footer\";\nimport { ConversationStatusBadges } from \"./conversation-status-badges\";\nimport { isMissingSandboxStatus } from \"#/utils/conversation-archive-status\";\nimport { useDownloadConversation } from \"#/hooks/use-download-conversation\";\n\ninterface ConversationCardProps {\n  onClick?: () => void;\n  onDelete?: () => void;\n  onArchive?: () => void;\n  /**\n   * Restores an archived conversation. The panel passes this instead of\n   * `onArchive` for rows that are already archived, so the menu offers exactly\n   * one of the two directions.\n   */\n  onUnarchive?: () => void;\n  onStop?: () => void;\n  onChangeTitle?: (title: string) => void;\n  /**\n   * Opens the tag editor for this conversation. Local agent-server backends\n   * only \u2014 Cloud conversations don't carry server-side tags, so the panel\n   * leaves this undefined there and the menu item disappears.\n   */\n  onEditTags?: () => void;\n  showOptions?: boolean;\n  title: string;\n  selectedRepository: RepositorySelection | null;\n  lastUpdatedAt: string;\n  createdAt?: string;\n  executionStatus?: ExecutionStatus | null;\n  sandboxStatus?: SandboxStatus | null;\n  conversationId?: string;\n  contextMenuOpen?: boolean;\n  onContextMenuToggle?: (isOpen: boolean) => void;\n  isActive?: boolean;\n  workspaceWorkingDir?: string | null;\n  showRepositoryMetadata?: boolean;\n  llmModel?: string | null;\n  showLlmProfiles?: boolean;\n  agentKind?: \"openhands\" | \"acp\" | null;\n  acpServer?: string | null;\n  acpModels?: readonly ACPModelOption[] | null;\n  tags?: Record<string, string> | null;\n  /** Gates the tag-chip row; wired to the panel's \"Tags\" metadata toggle. */\n  showTags?: boolean;\n  /**\n   * The row's effective archived state. Leave undefined to let the card infer\n   * it from `sandboxStatus` (a missing, non-resumable runtime); pass it\n   * explicitly when the panel also knows about an explicit archive so the\n   * chip, dot, and dimmed title stay in agreement.\n   */\n  isArchived?: boolean;\n  isPinned?: boolean;\n  onTogglePin?: () => void;\n  /** When true and pinned, keep the pin icon visible without hovering. */\n  alwaysShowPinIcon?: boolean;\n}\n\nexport function ConversationCard({\n  onClick,\n  onDelete,\n  onArchive,\n  onUnarchive,\n  onStop,\n  onChangeTitle,\n  onEditTags,\n  showOptions,\n  title,\n  selectedRepository,\n  lastUpdatedAt,\n  createdAt,\n  conversationId,\n  executionStatus,\n  sandboxStatus,\n  contextMenuOpen = false,\n  onContextMenuToggle,\n  isActive = false,\n  workspaceWorkingDir,\n  showRepositoryMetadata = true,\n  llmModel = null,\n  showLlmProfiles = false,\n  agentKind = null,\n  acpServer = null,\n  acpModels = null,\n  tags = null,\n  showTags = false,\n  isArchived,\n  isPinned = false,\n  onTogglePin,\n  alwaysShowPinIcon = false,\n}: ConversationCardProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { trackDownloadVsCodeButtonClicked } = useTracking();\n  const [titleMode, setTitleMode] = React.useState<\"view\" | \"edit\">(\"view\");\n  const { mutateAsync: downloadConversation } = useDownloadConversation();\n\n  const displayTags = getDisplayConversationTags(tags);\n  const hasDisplayTags = displayTags.length > 0;\n  const showTagChipRow = showTags && hasDisplayTags;\n\n  // Effective archived state: an explicit prop (the panel passes the user's\n  // archive plus the missing-runtime inference), or the missing-runtime\n  // fallback for standalone callers that only pass `sandboxStatus`.\n  const effectiveArchived = isArchived ?? isMissingSandboxStatus(sandboxStatus);\n\n  const onTitleSave = (newTitle: string) => {\n    if (newTitle !== \"\" && newTitle !== title) {\n      onChangeTitle?.(newTitle);\n    }\n    setTitleMode(\"view\");\n  };\n\n  const handleDelete = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    onDelete?.();\n    onContextMenuToggle?.(false);\n  };\n\n  const handleArchive = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    onArchive?.();\n    onContextMenuToggle?.(false);\n  };\n\n  const handleUnarchive = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    onUnarchive?.();\n    onContextMenuToggle?.(false);\n  };\n\n  const handleStop = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    onStop?.();\n    onContextMenuToggle?.(false);\n  };\n\n  const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    setTitleMode(\"edit\");\n    onContextMenuToggle?.(false);\n  };\n\n  const handleEditTags = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    onEditTags?.();\n    onContextMenuToggle?.(false);\n  };\n\n  const handleDownloadViaVSCode = async (\n    event: React.MouseEvent<HTMLButtonElement>,\n  ) => {\n    event.preventDefault();\n    event.stopPropagation();\n    trackDownloadVsCodeButtonClicked();\n\n    // Fetch the VS Code URL from the API\n    if (conversationId) {\n      try {\n        const data = await ConversationService.getVSCodeUrl(conversationId);\n        if (data.vscode_url) {\n          const transformedUrl = transformVSCodeUrl(data.vscode_url);\n          if (transformedUrl) {\n            window.open(transformedUrl, \"_blank\");\n          }\n        }\n        // VS Code URL not available\n      } catch {\n        // Failed to fetch VS Code URL\n      }\n    }\n\n    onContextMenuToggle?.(false);\n  };\n\n  const handleDownloadConversation = async (\n    event: React.MouseEvent<HTMLButtonElement>,\n  ) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    if (conversationId) {\n      try {\n        await downloadConversation(conversationId);\n      } catch {\n        // useDownloadConversation already showed the error toast; close the\n        // menu as on success instead of leaking the rejection.\n      }\n    }\n    onContextMenuToggle?.(false);\n  };\n\n  const handleTogglePin = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    onTogglePin?.();\n  };\n\n  const renderPinButton = () => (\n    <button\n      type=\"button\"\n      data-testid={\n        conversationId\n          ? `conversation-pin-toggle-${conversationId}`\n          : \"conversation-pin-toggle\"\n      }\n      aria-pressed={isPinned}\n      aria-label={\n        isPinned\n          ? t(I18nKey.CONVERSATION_PANEL$UNPIN_CONVERSATION)\n          : t(I18nKey.CONVERSATION_PANEL$PIN_CONVERSATION)\n      }\n      onClick={handleTogglePin}\n      className={cn(\n        \"flex shrink-0 cursor-pointer items-center justify-center rounded-md p-1\",\n        \"text-muted hover:bg-contrast/10 hover:text-contrast\",\n      )}\n    >\n      <Pin\n        className={cn(\"h-3.5 w-3.5\", isPinned && \"fill-current\")}\n        aria-hidden\n      />\n    </button>\n  );\n\n  const hasContextMenu = !!(\n    onDelete ||\n    onArchive ||\n    onUnarchive ||\n    onChangeTitle ||\n    onEditTags ||\n    showOptions\n  );\n  const hasHoverActions = hasContextMenu || !!onTogglePin;\n  const showPersistentPinIcon = alwaysShowPinIcon && isPinned && !!onTogglePin;\n  const shouldRenderFooter =\n    showRepositoryMetadata ||\n    effectiveArchived ||\n    (showLlmProfiles && (agentKind === \"acp\" || !!llmModel)) ||\n    (showTagChipRow && displayTags.length > 0);\n\n  return (\n    <div\n      data-testid=\"conversation-card\"\n      data-context-menu-open={contextMenuOpen.toString()}\n      data-active={isActive ? \"true\" : \"false\"}\n      onClick={onClick}\n      className={cn(\n        \"group relative h-auto w-full cursor-pointer rounded-md py-1 pl-2 pr-1 transition-colors\",\n        !contextMenuOpen && \"hover:bg-surface\",\n        (isActive || contextMenuOpen) && \"bg-surface\",\n      )}\n    >\n      <div className=\"flex items-center w-full min-w-0\">\n        <div className=\"flex items-center gap-2 flex-1 min-w-0 overflow-hidden\">\n          <ConversationCardHeader\n            title={title}\n            titleMode={titleMode}\n            onTitleSave={onTitleSave}\n            executionStatus={executionStatus}\n            sandboxStatus={sandboxStatus}\n            isArchived={effectiveArchived}\n          />\n          {sandboxStatus === \"ERROR\" && !effectiveArchived && (\n            <ConversationStatusBadges />\n          )}\n        </div>\n\n        <div\n          data-testid=\"conversation-card-trailing-slot\"\n          className={cn(\n            \"relative ml-auto pl-2 flex items-center justify-end shrink-0\",\n            // The hover action overlay (pin + ellipsis) is absolutely\n            // positioned, so reserve its width so the flex-1 title truncates\n            // instead of colliding with the buttons. Pinned cards keep the pin\n            // visible at rest, so reserve the width always for those. Touch /\n            // coarse-pointer devices also keep the reserve so the ellipsis stays\n            // clickable without a hover pass.\n            showPersistentPinIcon\n              ? \"min-w-[3.75rem]\"\n              : hasHoverActions && hoverRevealReserveClassName(contextMenuOpen),\n          )}\n        >\n          {!showPersistentPinIcon && (createdAt ?? lastUpdatedAt) && (\n            <p\n              className={cn(\n                \"text-xs text-muted text-right whitespace-nowrap transition-opacity -translate-x-1.5\",\n                hasHoverActions && hoverRevealYieldClassName(contextMenuOpen),\n              )}\n            >\n              <time>{formatTimeDelta(lastUpdatedAt ?? createdAt)}</time>\n            </p>\n          )}\n\n          {hasHoverActions ? (\n            <div\n              data-testid=\"conversation-card-hover-actions\"\n              className={cn(\n                \"absolute right-0 top-1/2 flex -translate-y-1/2 items-center gap-0.5 transition-opacity\",\n                showPersistentPinIcon\n                  ? \"pointer-events-auto visible opacity-100\"\n                  : hoverRevealActionClassName(contextMenuOpen),\n              )}\n            >\n              {onTogglePin ? renderPinButton() : null}\n              {showPersistentPinIcon &&\n              (createdAt ?? lastUpdatedAt) &&\n              hasContextMenu ? (\n                <div className=\"relative shrink-0\">\n                  <div className={hoverRevealActionClassName(contextMenuOpen)}>\n                    <ConversationCardActions\n                      contextMenuOpen={contextMenuOpen}\n                      onContextMenuToggle={onContextMenuToggle || (() => {})}\n                      onDelete={onDelete && handleDelete}\n                      onArchive={onArchive && handleArchive}\n                      onUnarchive={onUnarchive && handleUnarchive}\n                      onStop={onStop && handleStop}\n                      onEdit={onChangeTitle && handleEdit}\n                      onEditTags={onEditTags && handleEditTags}\n                      onDownloadViaVSCode={handleDownloadViaVSCode}\n                      onDownloadConversation={handleDownloadConversation}\n                      executionStatus={executionStatus}\n                      conversationId={conversationId}\n                      showOptions={showOptions}\n                    />\n                  </div>\n                  <p\n                    className={cn(\n                      \"pointer-events-none absolute inset-0 items-center justify-end\",\n                      \"text-xs text-muted whitespace-nowrap -translate-x-1.5\",\n                      hoverRevealPinnedTimestampClassName(contextMenuOpen),\n                    )}\n                  >\n                    <time>{formatTimeDelta(lastUpdatedAt ?? createdAt)}</time>\n                  </p>\n                </div>\n              ) : null}\n              {!showPersistentPinIcon && hasContextMenu ? (\n                <ConversationCardActions\n                  contextMenuOpen={contextMenuOpen}\n                  onContextMenuToggle={onContextMenuToggle || (() => {})}\n                  onDelete={onDelete && handleDelete}\n                  onArchive={onArchive && handleArchive}\n                  onUnarchive={onUnarchive && handleUnarchive}\n                  onStop={onStop && handleStop}\n                  onEdit={onChangeTitle && handleEdit}\n                  onEditTags={onEditTags && handleEditTags}\n                  onDownloadViaVSCode={handleDownloadViaVSCode}\n                  onDownloadConversation={handleDownloadConversation}\n                  executionStatus={executionStatus}\n                  conversationId={conversationId}\n                  showOptions={showOptions}\n                />\n              ) : null}\n            </div>\n          ) : null}\n        </div>\n      </div>\n\n      {shouldRenderFooter && (\n        <ConversationCardFooter\n          selectedRepository={selectedRepository}\n          lastUpdatedAt={lastUpdatedAt}\n          createdAt={createdAt}\n          executionStatus={executionStatus}\n          workspaceWorkingDir={workspaceWorkingDir}\n          showRepositoryMetadata={showRepositoryMetadata}\n          showTimestamp={false}\n          llmModel={llmModel}\n          showAgentChip={showLlmProfiles}\n          agentKind={agentKind}\n          acpServer={acpServer}\n          acpModels={acpModels}\n          tags={tags}\n          showTags={showTagChipRow}\n          isArchived={effectiveArchived}\n        />\n      )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/conversation-card/conversation-tag-chips.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[-1]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 245,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 245,
          "endColumn": 116
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[9999]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 313,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 313,
          "endColumn": 53
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-w-[10rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 313,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 313,
          "endColumn": 53
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[16rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 313,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 313,
          "endColumn": 53
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets position. Style through classes; use CSS custom properties for dynamic values.",
          "line": 318,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 318,
          "endColumn": 32
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets top. Style through classes; use CSS custom properties for dynamic values.",
          "line": 319,
          "column": 20,
          "nodeType": "MemberExpression",
          "messageId": "inlineStyle",
          "endLine": 319,
          "endColumn": 34
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets left. Style through classes; use CSS custom properties for dynamic values.",
          "line": 320,
          "column": 21,
          "nodeType": "MemberExpression",
          "messageId": "inlineStyle",
          "endLine": 320,
          "endColumn": 36
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"grid-cols-[minmax(0,auto)_minmax(0,1fr)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 333,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 333,
          "endColumn": 106
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 333,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 333,
          "endColumn": 106
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "jsx-a11y/no-noninteractive-element-interactions",
          "severity": 2,
          "message": "Non-interactive elements should not be assigned mouse or keyboard event listeners.",
          "line": 308,
          "column": 11,
          "nodeType": "JSXOpeningElement",
          "endLine": 327,
          "endColumn": 12,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "dialog surface must swallow clicks"
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 9,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  computeVisibleTagChipCount,\n  formatConversationTagTooltip,\n  getConversationTagLabel,\n  getConversationTagValueLabel,\n  truncateTagChipValue,\n} from \"./conversation-tag-display\";\nimport {\n  getConversationTagIcon,\n  type ConversationTagIcon,\n} from \"./conversation-tag-icons\";\nimport {\n  CONVERSATION_CARD_META_CHIP_CLASSNAME,\n  CONVERSATION_CARD_META_CHIP_ICON_CLASSNAME,\n  CONVERSATION_CARD_META_CHIP_ICON_SLOT_CLASSNAME,\n} from \"./conversation-card-meta-chip\";\n\ninterface ConversationTagChipsProps {\n  tags: Array<[string, string]>;\n}\n\n/**\n * Fixed line-height slot so Lucide / react-icons / local SVGs share the same\n * optical center as the ``leading-4`` chip / overflow text (raw ``h-3 w-3``\n * SVGs sit on the baseline and look high or low depending on the glyph).\n */\nfunction TagIconSlot({\n  icon: Icon,\n  keyName,\n  testId,\n}: {\n  icon: ConversationTagIcon;\n  keyName?: string;\n  testId?: string;\n}) {\n  return (\n    <span\n      className={CONVERSATION_CARD_META_CHIP_ICON_SLOT_CLASSNAME}\n      aria-hidden\n    >\n      <Icon\n        data-testid={testId}\n        data-tag-key={keyName}\n        aria-hidden\n        className={CONVERSATION_CARD_META_CHIP_ICON_CLASSNAME}\n      />\n    </span>\n  );\n}\n\nfunction formatTagChipLabel(\n  key: string,\n  value: string,\n  t: (key: I18nKey) => string,\n): string {\n  // Same string as the tooltip, with the value hard-truncated so long chips\n  // still fit. Bare tags (empty value) show the humanized key once.\n  return formatConversationTagTooltip(key, truncateTagChipValue(value), t);\n}\n\nfunction TagChipContent({\n  icon,\n  keyName,\n  chipLabel,\n  iconTestId,\n}: {\n  icon: ConversationTagIcon;\n  keyName: string;\n  chipLabel: string;\n  /**\n   * Only the visible row passes this. The off-screen measure row renders the\n   * same chips, so tagging both would emit every chip test id twice and break\n   * ``getByTestId`` (singular) for any card with tags.\n   */\n  iconTestId?: string;\n}) {\n  return (\n    <>\n      <TagIconSlot icon={icon} keyName={keyName} testId={iconTestId} />\n      <span className=\"truncate leading-4\">{chipLabel}</span>\n    </>\n  );\n}\n\n/**\n * Single-row tag chips for a conversation card. Chip labels show the same\n * humanized ``key: value`` pair as the tooltip (bare tags with an empty\n * value show the key once); chips that do not fit fold behind a ``+N``\n * button that opens a key/value popover.\n *\n * The overflow popover is portaled with ``position: fixed`` so it is not\n * clipped by the chip row's ``overflow-hidden`` or the sidebar scroller.\n */\nexport function ConversationTagChips({ tags }: ConversationTagChipsProps) {\n  const { t, i18n } = useTranslation(\"openhands\");\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const measureRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const popoverRef = React.useRef<HTMLDivElement>(null);\n  const [visibleCount, setVisibleCount] = React.useState(tags.length);\n  const [isOverflowOpen, setIsOverflowOpen] = React.useState(false);\n  const [popoverBox, setPopoverBox] = React.useState<{\n    top: number;\n    left: number;\n  } | null>(null);\n\n  const recomputeVisibleCount = React.useCallback(() => {\n    const container = containerRef.current;\n    const measure = measureRef.current;\n    if (!container || !measure) {\n      return;\n    }\n\n    const widths = Array.from(measure.children).map(\n      (child) => (child as HTMLElement).offsetWidth,\n    );\n    setVisibleCount(computeVisibleTagChipCount(widths, container.clientWidth));\n  }, []);\n\n  const measurePopover = React.useCallback(() => {\n    const trigger = triggerRef.current;\n    if (!trigger) {\n      return;\n    }\n    const rect = trigger.getBoundingClientRect();\n    const gutter = 8;\n    const width = 16 * 16;\n    let left = rect.left;\n    if (left + width > window.innerWidth - gutter) {\n      left = Math.max(gutter, window.innerWidth - gutter - width);\n    }\n    setPopoverBox({ top: rect.bottom + 4, left });\n  }, []);\n\n  // Callers build this array fresh on every render (``getDisplayConversationTags``\n  // returns a new array), so keying the reset effect on ``tags`` identity would\n  // fire on every parent render \u2014 closing an open ``+N`` popover and forcing a\n  // layout read on each 10s panel refetch. Compare tag *content* instead.\n  const tagsKey = tags.map(([key, value]) => `${key}=${value}`).join(\"\\u001f\");\n  const lastTagsKeyRef = React.useRef<string | null>(null);\n\n  React.useLayoutEffect(() => {\n    if (lastTagsKeyRef.current === tagsKey) {\n      return;\n    }\n    lastTagsKeyRef.current = tagsKey;\n    setIsOverflowOpen(false);\n    recomputeVisibleCount();\n  }, [tagsKey, recomputeVisibleCount]);\n\n  // Localized key labels change chip width (e.g. \"Git\" vs a longer\n  // translation), so overflow must be remeasured when the language changes.\n  const language = i18n.resolvedLanguage ?? i18n.language;\n  React.useLayoutEffect(() => {\n    recomputeVisibleCount();\n  }, [language, recomputeVisibleCount]);\n\n  React.useEffect(() => {\n    const container = containerRef.current;\n    if (!container || typeof ResizeObserver === \"undefined\") {\n      return undefined;\n    }\n\n    const observer = new ResizeObserver(() => recomputeVisibleCount());\n    observer.observe(container);\n    return () => observer.disconnect();\n  }, [recomputeVisibleCount]);\n\n  React.useLayoutEffect(() => {\n    if (!isOverflowOpen) {\n      setPopoverBox(null);\n      return undefined;\n    }\n    measurePopover();\n    window.addEventListener(\"resize\", measurePopover);\n    window.addEventListener(\"scroll\", measurePopover, true);\n    return () => {\n      window.removeEventListener(\"resize\", measurePopover);\n      window.removeEventListener(\"scroll\", measurePopover, true);\n    };\n  }, [isOverflowOpen, measurePopover]);\n\n  React.useEffect(() => {\n    if (!isOverflowOpen) {\n      return undefined;\n    }\n    const onPointerDown = (event: MouseEvent) => {\n      const target = event.target as Node;\n      if (triggerRef.current?.contains(target)) {\n        return;\n      }\n      if (popoverRef.current?.contains(target)) {\n        return;\n      }\n      setIsOverflowOpen(false);\n    };\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        setIsOverflowOpen(false);\n      }\n    };\n    // mousedown (not click) so the opening click cannot race-close the panel,\n    // and so NavigationLink activation is easier to cancel on the trigger.\n    document.addEventListener(\"mousedown\", onPointerDown);\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      document.removeEventListener(\"mousedown\", onPointerDown);\n      document.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [isOverflowOpen]);\n\n  if (tags.length === 0) {\n    return null;\n  }\n\n  const hiddenCount = Math.max(0, tags.length - visibleCount);\n  const visibleTags = tags.slice(0, visibleCount);\n  const overflowTags = tags.slice(visibleCount);\n\n  const stopCardNavigation = (event: React.SyntheticEvent) => {\n    // Don't preventDefault on mousedown \u2014 that suppresses the subsequent click\n    // when the chip sits inside a conversation NavigationLink.\n    event.stopPropagation();\n  };\n\n  const activateOverflow = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    setIsOverflowOpen((open) => !open);\n  };\n\n  return (\n    <div\n      data-testid=\"conversation-card-tag-chips\"\n      className=\"relative min-w-0 w-full\"\n    >\n      <div\n        ref={measureRef}\n        aria-hidden\n        className=\"pointer-events-none fixed top-0 -left-2500 z-[-1] flex flex-nowrap items-center gap-1 opacity-0\"\n      >\n        {tags.map(([key, value]) => (\n          <span key={key} className={CONVERSATION_CARD_META_CHIP_CLASSNAME}>\n            <TagChipContent\n              icon={getConversationTagIcon(key, value)}\n              keyName={key}\n              chipLabel={formatTagChipLabel(key, value, t)}\n            />\n          </span>\n        ))}\n      </div>\n\n      <div\n        ref={containerRef}\n        data-testid=\"conversation-card-tag-row\"\n        className=\"flex w-full min-w-0 max-w-full flex-nowrap items-center gap-1 overflow-hidden\"\n      >\n        {visibleTags.map(([key, value]) => (\n          <span\n            key={key}\n            data-testid=\"conversation-card-tag-chip\"\n            title={formatConversationTagTooltip(key, value, t)}\n            className={CONVERSATION_CARD_META_CHIP_CLASSNAME}\n          >\n            <TagChipContent\n              icon={getConversationTagIcon(key, value)}\n              keyName={key}\n              chipLabel={formatTagChipLabel(key, value, t)}\n              iconTestId=\"conversation-card-tag-chip-icon\"\n            />\n          </span>\n        ))}\n        {hiddenCount > 0 ? (\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            data-testid=\"conversation-card-tag-overflow\"\n            aria-expanded={isOverflowOpen}\n            aria-haspopup=\"dialog\"\n            aria-label={t(I18nKey.CONVERSATION$TAGS_OVERFLOW_ARIA, {\n              count: hiddenCount,\n            })}\n            onMouseDown={stopCardNavigation}\n            onClick={activateOverflow}\n            className={cn(\n              CONVERSATION_CARD_META_CHIP_CLASSNAME,\n              \"shrink-0 self-center hover:bg-interactive-hover hover:text-foreground\",\n            )}\n          >\n            {/* Match chip height (icon slot + label) so +N shares the same centerline. */}\n            <span className=\"inline-flex h-4 w-0 shrink-0\" aria-hidden />\n            <span className=\"leading-4\">{`+${hiddenCount}`}</span>\n          </button>\n        ) : null}\n      </div>\n\n      {isOverflowOpen &&\n        popoverBox &&\n        typeof document !== \"undefined\" &&\n        createPortal(\n          // Stop card-level click activation when interacting with the list.\n          // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- dialog surface must swallow clicks\n          <div\n            ref={popoverRef}\n            role=\"dialog\"\n            data-testid=\"conversation-card-tag-overflow-popover\"\n            className={cn(\n              \"z-[9999] min-w-[10rem] max-w-[16rem]\",\n              \"rounded-md border border-border-subtle\",\n              \"bg-surface p-2 shadow-lg\",\n            )}\n            style={{\n              position: \"fixed\",\n              top: popoverBox.top,\n              left: popoverBox.left,\n            }}\n            onMouseDown={stopCardNavigation}\n            onClick={(event) => {\n              event.preventDefault();\n              event.stopPropagation();\n            }}\n          >\n            <dl className=\"m-0 flex flex-col gap-1\">\n              {overflowTags.map(([key, value]) => (\n                <div\n                  key={key}\n                  data-testid=\"conversation-card-tag-overflow-row\"\n                  className=\"grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-2 text-[10px] leading-4\"\n                >\n                  <dt className=\"m-0 inline-flex min-w-0 items-center gap-1 whitespace-normal break-words text-muted\">\n                    <TagIconSlot icon={getConversationTagIcon(key, value)} />\n                    <span className=\"min-w-0 whitespace-normal break-words leading-4\">\n                      {getConversationTagLabel(key, t)}\n                    </span>\n                  </dt>\n                  <dd\n                    className=\"m-0 whitespace-normal break-words text-left leading-4 text-foreground\"\n                    title={value}\n                  >\n                    {getConversationTagValueLabel(key, value, t)}\n                  </dd>\n                </div>\n              ))}\n            </dl>\n          </div>,\n          document.body,\n        )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/conversation-layouts-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 258,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 258,
          "endColumn": 64
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  Clock3,\n  Folder,\n  ListFilter,\n  Shrink,\n  SlidersHorizontal,\n  Star,\n  Tag,\n  Trash2,\n} from \"lucide-react\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type { BackendKind } from \"#/api/backend-registry/types\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  dropdownInstantColorClassName,\n  dropdownMenuListClassName,\n  dropdownMenuViewportScrollClassName,\n} from \"#/utils/dropdown-classes\";\nimport {\n  DEFAULT_LAYOUT_SETTINGS,\n  useConversationPanelPreferencesStore,\n  type LayoutSettingsSlice,\n} from \"#/stores/conversation-panel-preferences-store\";\nimport { formatTagFacetLabel } from \"./conversation-panel-list-helpers\";\nimport { MenuHeading } from \"./menu-heading\";\nimport { MenuSeparator } from \"./menu-separator\";\nimport { MenuRow } from \"./menu-row\";\nimport { AdvancedConversationOptionsModal } from \"./advanced-conversation-options-modal\";\n\ninterface LayoutPreset {\n  id: string;\n  icon: React.ComponentType<{ className?: string; \"aria-hidden\"?: boolean }>;\n  /** Omitted for the backend-dependent first preset (workspace vs repo). */\n  labelKey?: I18nKey;\n  settings: LayoutSettingsSlice;\n}\n\n/**\n * Layout presets are complete preference bundles (design inference from the\n * 2026-08-14 PM walkthrough video; the video names the presets but not their\n * exact bundles). Applying one replaces every layout-controlled field so a\n * previous preset cannot leak into the next one. Any manual deviation reads\n * as \"Custom\".\n */\nconst LAYOUT_PRESETS: LayoutPreset[] = [\n  {\n    id: \"by-workspace\",\n    icon: Folder,\n    settings: {\n      ...DEFAULT_LAYOUT_SETTINGS,\n      organizeMode: \"grouped\",\n    },\n  },\n  {\n    id: \"recent-activity\",\n    icon: Clock3,\n    labelKey: I18nKey.CONVERSATION_PANEL$LAYOUT_RECENT_ACTIVITY,\n    // Exactly the default bundle, so a profile that never changed a layout\n    // preference reads as Recent rather than Custom.\n    settings: DEFAULT_LAYOUT_SETTINGS,\n  },\n  {\n    id: \"focused\",\n    icon: Star,\n    labelKey: I18nKey.CONVERSATION_PANEL$LAYOUT_FOCUSED,\n    settings: {\n      ...DEFAULT_LAYOUT_SETTINGS,\n      threadScope: \"relevant\",\n      showOlderConversations: false,\n    },\n  },\n  {\n    id: \"minimal\",\n    icon: Shrink,\n    labelKey: I18nKey.CONVERSATION_PANEL$LAYOUT_MINIMAL,\n    settings: {\n      ...DEFAULT_LAYOUT_SETTINGS,\n      threadScope: \"relevant\",\n      showOlderConversations: false,\n      showRepoBranchMetadata: false,\n      showLlmProfiles: false,\n      showTagsMetadata: false,\n      showHoverMetadata: false,\n    },\n  },\n];\n\nexport function getActiveLayoutPreset(\n  settings: LayoutSettingsSlice,\n): LayoutPreset | null {\n  return (\n    LAYOUT_PRESETS.find((preset) =>\n      Object.entries(preset.settings).every(\n        ([key, value]) => settings[key as keyof LayoutSettingsSlice] === value,\n      ),\n    ) ?? null\n  );\n}\n\nexport interface ConversationLayoutsMenuProps {\n  menuOpen: boolean;\n  setMenuOpen: (open: boolean) => void;\n  menuRef: React.RefObject<HTMLDivElement | null>;\n  backendKind: BackendKind;\n  /** Distinct user-facing `key=value` facets among the loaded conversations. */\n  tagFacets: readonly string[];\n  /** Distinct automation names; the advanced modal owns their facet rows. */\n  automationNameFacets: readonly string[];\n  totalConversationsCount: number;\n  onRequestDeleteAll: () => void;\n}\n\nexport function ConversationLayoutsMenu({\n  menuOpen,\n  setMenuOpen,\n  menuRef,\n  backendKind,\n  tagFacets,\n  automationNameFacets,\n  totalConversationsCount,\n  onRequestDeleteAll,\n}: ConversationLayoutsMenuProps) {\n  const { t } = useTranslation(\"openhands\");\n  const preferences = useConversationPanelPreferencesStore();\n  const [tagSectionExpanded, setTagSectionExpanded] = React.useState(false);\n  const [advancedOpen, setAdvancedOpen] = React.useState(false);\n\n  const groupedLabel =\n    backendKind === \"local\"\n      ? t(I18nKey.CONVERSATION_PANEL$BY_WORKSPACE)\n      : t(I18nKey.CONVERSATION_PANEL$BY_REPOSITORY);\n\n  const activePreset = getActiveLayoutPreset(preferences);\n\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const menuContentRef = React.useRef<HTMLDivElement>(null);\n\n  // Mirror the filter menu's focus management: focus into the menu on open,\n  // return focus to the trigger on close.\n  const wasOpenRef = React.useRef(menuOpen);\n  React.useEffect(() => {\n    if (menuOpen) {\n      const firstItem =\n        menuContentRef.current?.querySelector<HTMLButtonElement>(\n          '[role=\"menuitem\"], [role=\"menuitemradio\"], [role=\"menuitemcheckbox\"]',\n        );\n      firstItem?.focus();\n    } else if (wasOpenRef.current) {\n      triggerRef.current?.focus();\n    }\n    wasOpenRef.current = menuOpen;\n  }, [menuOpen]);\n\n  const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      setMenuOpen(false);\n      return;\n    }\n    if (event.key !== \"ArrowDown\" && event.key !== \"ArrowUp\") return;\n    const container = menuContentRef.current;\n    if (!container) return;\n    const items = Array.from(\n      container.querySelectorAll<HTMLButtonElement>(\n        '[role=\"menuitem\"], [role=\"menuitemradio\"], [role=\"menuitemcheckbox\"]',\n      ),\n    ).filter((el) => !el.disabled);\n    if (items.length === 0) return;\n    const currentIdx = items.indexOf(\n      document.activeElement as HTMLButtonElement,\n    );\n    const delta = event.key === \"ArrowDown\" ? 1 : -1;\n    const start = currentIdx === -1 ? 0 : currentIdx;\n    const nextIdx = (start + delta + items.length) % items.length;\n    event.preventDefault();\n    items[nextIdx]?.focus();\n  };\n\n  return (\n    <div ref={menuRef} className=\"relative shrink-0 pr-0.5\">\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        data-testid=\"conversation-layouts-toggle\"\n        aria-label={t(I18nKey.CONVERSATION_PANEL$LAYOUTS_HEADING)}\n        aria-haspopup=\"menu\"\n        aria-expanded={menuOpen}\n        onClick={() => setMenuOpen(!menuOpen)}\n        className={cn(\n          \"relative inline-flex h-7 w-7 items-center justify-center rounded-md text-muted hover:text-contrast hover:bg-surface-raised\",\n          dropdownInstantColorClassName,\n        )}\n      >\n        <ListFilter\n          className=\"shrink-0\"\n          size={14}\n          strokeWidth={2}\n          aria-hidden\n        />\n      </button>\n\n      {menuOpen ? (\n        <div\n          ref={menuContentRef}\n          role=\"menu\"\n          aria-orientation=\"vertical\"\n          aria-label={t(I18nKey.CONVERSATION_PANEL$LAYOUTS_HEADING)}\n          tabIndex={-1}\n          data-testid=\"conversation-layouts-menu\"\n          onKeyDown={handleMenuKeyDown}\n          className={cn(\n            \"absolute right-0 top-full z-50 mt-0 w-64 rounded-md border border-border-subtle bg-tertiary px-1 py-1 text-foreground shadow-lg\",\n            dropdownMenuListClassName,\n            dropdownMenuViewportScrollClassName,\n          )}\n        >\n          <MenuHeading>\n            {t(I18nKey.CONVERSATION_PANEL$LAYOUTS_HEADING)}\n          </MenuHeading>\n          {LAYOUT_PRESETS.map((preset) => (\n            <MenuRow\n              key={preset.id}\n              icon={preset.icon}\n              label={preset.labelKey ? t(preset.labelKey) : groupedLabel}\n              selected={activePreset?.id === preset.id}\n              testId={`layout-preset-${preset.id}`}\n              onClick={() => {\n                preferences.applyLayoutSettings(preset.settings);\n                setMenuOpen(false);\n              }}\n            />\n          ))}\n\n          <MenuSeparator />\n          <MenuRow\n            icon={Tag}\n            label={t(I18nKey.CONVERSATION_PANEL$TAG_FILTERS)}\n            testId=\"tag-filters-section\"\n            onClick={() => setTagSectionExpanded(!tagSectionExpanded)}\n          />\n          {tagSectionExpanded ? (\n            tagFacets.length > 0 ? (\n              tagFacets.map((facet) => (\n                <MenuRow\n                  key={facet}\n                  icon={Tag}\n                  label={formatTagFacetLabel(facet, t)}\n                  selected={preferences.selectedTagFacets.includes(facet)}\n                  testId={`tag-facet-row-${facet}`}\n                  onClick={() => preferences.toggleTagFacet(facet)}\n                />\n              ))\n            ) : (\n              <p\n                data-testid=\"tag-filters-empty\"\n                className=\"px-2 py-1 text-[11px] text-muted/70\"\n              >\n                {t(I18nKey.CONVERSATION_PANEL$NO_VISIBLE_TAGS)}\n              </p>\n            )\n          ) : null}\n\n          <MenuSeparator />\n          <MenuRow\n            icon={SlidersHorizontal}\n            label={\n              activePreset\n                ? t(I18nKey.CONVERSATION_PANEL$ADVANCED_OPTIONS)\n                : `${t(I18nKey.CONVERSATION_PANEL$ADVANCED_OPTIONS)} \u00b7 ${t(\n                    I18nKey.CONVERSATION_PANEL$ADVANCED_OPTIONS_CUSTOM,\n                  )}`\n            }\n            testId=\"advanced-options-row\"\n            onClick={() => {\n              setMenuOpen(false);\n              setAdvancedOpen(true);\n            }}\n          />\n\n          <MenuSeparator />\n          <MenuRow\n            testId=\"delete-all-conversations\"\n            icon={Trash2}\n            label={capitalizeLabel(t(I18nKey.CONVERSATION$DELETE_ALL))}\n            disabled={totalConversationsCount === 0}\n            destructive\n            onClick={() => {\n              if (totalConversationsCount === 0) return;\n              onRequestDeleteAll();\n              setMenuOpen(false);\n            }}\n          />\n        </div>\n      ) : null}\n\n      <AdvancedConversationOptionsModal\n        open={advancedOpen}\n        onClose={() => setAdvancedOpen(false)}\n        backendKind={backendKind}\n        automationNameFacets={automationNameFacets}\n      />\n    </div>\n  );\n}\n\nconst capitalizeLabel = (label: string) =>\n  label.length > 0 ? label.charAt(0).toUpperCase() + label.slice(1) : label;\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/conversation-panel-wrapper.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[100]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 23,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 23,
          "endColumn": 77
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import ReactDOM from \"react-dom\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport { cn } from \"#/utils/utils\";\n\ninterface ConversationPanelWrapperProps {\n  isOpen: boolean;\n}\n\nexport function ConversationPanelWrapper({\n  isOpen,\n  children,\n}: React.PropsWithChildren<ConversationPanelWrapperProps>) {\n  const { currentPath } = useNavigation();\n\n  if (!isOpen) return null;\n\n  const portalTarget = document.getElementById(\"root-outlet\");\n  if (!portalTarget) return null;\n\n  return ReactDOM.createPortal(\n    <div\n      className={cn(\n        \"absolute h-full w-full left-0 top-0 z-[100] bg-black/80 rounded-xl\",\n        currentPath === \"/\" && \"bottom-0 top-0 md:top-3 md:bottom-3 h-auto\",\n      )}\n    >\n      {children}\n    </div>,\n    portalTarget,\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/conversation-panel.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[calc(100%+0.625rem)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 1126,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 1126,
          "endColumn": 77
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { Tooltip } from \"@heroui/react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useBackendScopedPath } from \"#/hooks/use-backend-scoped-path\";\nimport { usePaginatedConversations } from \"#/hooks/query/use-paginated-conversations\";\nimport { useResolvedWorkspaces } from \"#/hooks/query/use-resolved-workspaces\";\nimport { useStartTasks } from \"#/hooks/query/use-start-tasks\";\nimport { useDeleteConversation } from \"#/hooks/mutation/use-delete-conversation\";\nimport { useUnifiedPauseConversation } from \"#/hooks/mutation/use-unified-stop-conversation\";\nimport { ConfirmArchiveModal } from \"./confirm-archive-modal\";\nimport { ConfirmDeleteModal } from \"./confirm-delete-modal\";\nimport { ConfirmStopModal } from \"./confirm-stop-modal\";\nimport { NavigationLink } from \"#/components/shared/navigation-link\";\nimport { ExitConversationModal } from \"./exit-conversation-modal\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { Provider } from \"#/types/settings\";\nimport type { LocalWorkspace } from \"#/types/workspace\";\nimport { useUpdateConversation } from \"#/hooks/mutation/use-update-conversation\";\nimport { useUpdateConversationTags } from \"#/hooks/mutation/use-update-conversation-tags\";\nimport { EditConversationTagsModal } from \"./edit-conversation-tags-modal\";\nimport {\n  displayErrorToast,\n  displaySuccessToast,\n} from \"#/utils/custom-toast-handlers\";\nimport { isExecutionActive } from \"#/utils/status\";\nimport { useCreateConversation } from \"#/hooks/mutation/use-create-conversation\";\nimport { useIsCreatingConversation } from \"#/hooks/use-is-creating-conversation\";\nimport { ConversationCard } from \"./conversation-card/conversation-card\";\nimport { ConversationCardPreview } from \"./conversation-card/conversation-card-preview\";\nimport { StartTaskCard } from \"./start-task-card/start-task-card\";\nimport { ConversationCardSkeleton } from \"./conversation-card/conversation-card-skeleton\";\nimport { CompactConversationRow } from \"./compact-conversation-row\";\nimport { useConversationPanelPreferencesStore } from \"#/stores/conversation-panel-preferences-store\";\nimport { cn } from \"#/utils/utils\";\nimport { ConversationLayoutsMenu } from \"./conversation-layouts-menu\";\nimport { ConversationActiveTagFilters } from \"./conversation-active-tag-filters\";\nimport { ConversationPanelNewThreadPicker } from \"./conversation-panel-new-thread-picker\";\nimport { ConversationGroupFolderList } from \"./conversation-group-folder-list\";\nimport { ConversationPanelPinnedSection } from \"./conversation-panel-pinned-section\";\nimport {\n  applyAutomationConversationFilter,\n  applyGroupFolderOrder,\n  applyTagConversationFilter,\n  collectAutomationNameFacets,\n  collectTagFacets,\n  DEFAULT_OLDER_CONVERSATION_CUTOFF,\n  filterOutPinnedConversations,\n  getGroupDiscoveryConversationIds,\n  groupConversations,\n  isOlderConversationCutoff,\n  MAX_PAGES_PER_LOAD_MORE_CLICK,\n  OLDER_CONVERSATION_CUTOFF_MS,\n  partitionByCutoff,\n  resolvePinnedConversations,\n  sortConversationsByField,\n  type ConversationGroupLaunch,\n} from \"./conversation-panel-list-helpers\";\nimport { useArchivedConversationsStore } from \"#/stores/archived-conversations-store\";\nimport { usePinnedConversationsStore } from \"#/stores/pinned-conversations-store\";\nimport { uniqueById } from \"#/utils/unique-by-id\";\nimport {\n  isEffectivelyArchivedConversation,\n  isMissingSandboxStatus,\n} from \"#/utils/conversation-archive-status\";\nimport type { SandboxStatus } from \"#/api/conversation-service/agent-server-conversation-service.types\";\n\ninterface ConversationPanelProps {\n  onClose?: () => void;\n  /**\n   * Render a minimal icon-only variant of each conversation row (used by the\n   * collapsed sidebar). Each row is a single status dot with a hover preview\n   * containing the full card content.\n   */\n  compact?: boolean;\n}\n\nconst noop = () => {};\n\nconst EMPTY_PINNED_CONVERSATION_IDS: readonly string[] = [];\n\nexport function ConversationPanel({\n  onClose,\n  compact = false,\n}: ConversationPanelProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { conversationId: currentConversationId, navigate } = useNavigation();\n  const { backend: activeBackend } = useActiveBackend();\n  const backendScopedPath = useBackendScopedPath();\n  // Click-outside is only relevant in the legacy drawer mode where an\n  // onClose handler is provided. When the panel is rendered inline (e.g.\n  // as the always-visible conversation list pane), clicking outside should\n  // not dismiss the list, so we pass a no-op callback in that case.\n  const ref = useClickOutsideElement<HTMLDivElement>(onClose ?? noop);\n\n  const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =\n    React.useState(false);\n  const [confirmArchiveModalVisible, setConfirmArchiveModalVisible] =\n    React.useState(false);\n  const [editTagsModalVisible, setEditTagsModalVisible] = React.useState(false);\n  const [confirmStopModalVisible, setConfirmStopModalVisible] =\n    React.useState(false);\n  const [\n    confirmExitConversationModalVisible,\n    setConfirmExitConversationModalVisible,\n  ] = React.useState(false);\n  const [confirmDeleteAllVisible, setConfirmDeleteAllVisible] =\n    React.useState(false);\n  const showOlderConversations = useConversationPanelPreferencesStore(\n    (state) => state.showOlderConversations,\n  );\n  const olderConversationCutoff = useConversationPanelPreferencesStore(\n    (state) => state.olderConversationCutoff,\n  );\n  const showArchivedConversations = useConversationPanelPreferencesStore(\n    (state) => state.showArchivedConversations,\n  );\n  const showRepoBranchMetadata = useConversationPanelPreferencesStore(\n    (state) => state.showRepoBranchMetadata,\n  );\n  const showLlmProfiles = useConversationPanelPreferencesStore(\n    (state) => state.showLlmProfiles,\n  );\n  const showTagsMetadata = useConversationPanelPreferencesStore(\n    (state) => state.showTagsMetadata,\n  );\n  const showHoverMetadata = useConversationPanelPreferencesStore(\n    (state) => state.showHoverMetadata,\n  );\n  const organizeMode = useConversationPanelPreferencesStore(\n    (state) => state.organizeMode,\n  );\n  const conversationSort = useConversationPanelPreferencesStore(\n    (state) => state.conversationSort,\n  );\n  const threadScope = useConversationPanelPreferencesStore(\n    (state) => state.threadScope,\n  );\n  const automationFilterMode = useConversationPanelPreferencesStore(\n    (state) => state.automationFilterMode,\n  );\n  const selectedAutomationNames = useConversationPanelPreferencesStore(\n    (state) => state.selectedAutomationNames,\n  );\n  const selectedTagFacets = useConversationPanelPreferencesStore(\n    (state) => state.selectedTagFacets,\n  );\n  const toggleTagFacet = useConversationPanelPreferencesStore(\n    (state) => state.toggleTagFacet,\n  );\n  const toggleAutomationName = useConversationPanelPreferencesStore(\n    (state) => state.toggleAutomationName,\n  );\n  const clearFilterSelections = useConversationPanelPreferencesStore(\n    (state) => state.clearFilterSelections,\n  );\n  const groupFolderOrder = useConversationPanelPreferencesStore(\n    (state) => state.groupFolderOrder,\n  );\n  const setGroupFolderOrder = useConversationPanelPreferencesStore(\n    (state) => state.setGroupFolderOrder,\n  );\n  const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);\n  const [isListScrolled, setIsListScrolled] = React.useState(false);\n  const filterMenuRef = useClickOutsideElement<HTMLDivElement>(() => {\n    setFilterMenuOpen(false);\n  });\n  const [collapsedGroupIds, setCollapsedGroupIds] = React.useState<\n    ReadonlySet<string>\n  >(() => new Set());\n  const [expandedGroupPreviewIds, setExpandedGroupPreviewIds] = React.useState<\n    ReadonlySet<string>\n  >(() => new Set());\n  const [expandedPinnedPreview, setExpandedPinnedPreview] =\n    React.useState(false);\n\n  const pinnedIds = usePinnedConversationsStore(\n    (state) =>\n      state.pinsByBackendId[activeBackend.id] ?? EMPTY_PINNED_CONVERSATION_IDS,\n  );\n  const togglePin = usePinnedConversationsStore((state) => state.togglePin);\n  const unpinConversation = usePinnedConversationsStore(\n    (state) => state.unpinConversation,\n  );\n  const pruneMissingPinnedConversations = usePinnedConversationsStore(\n    (state) => state.pruneMissingConversations,\n  );\n  const archivedIds = useArchivedConversationsStore(\n    (state) =>\n      state.archivesByBackendId[activeBackend.id] ??\n      EMPTY_PINNED_CONVERSATION_IDS,\n  );\n  const archiveConversation = useArchivedConversationsStore(\n    (state) => state.archiveConversation,\n  );\n  const archivedIdSet = React.useMemo(\n    () => new Set(archivedIds),\n    [archivedIds],\n  );\n  const removeArchivedConversation = useArchivedConversationsStore(\n    (state) => state.removeArchivedConversation,\n  );\n\n  // The one predicate every archived presentation reads: a row is effectively\n  // archived when the user archived it, or when its runtime is missing and\n  // non-resumable. List visibility, the \"Archived\" chip, the dimmed title, and\n  // the status dot all derive from this so they cannot disagree.\n  // (`sandbox_status: \"ERROR\"` is intentionally not archived.) Menu availability\n  // is a separate concern and does not read this predicate alone.\n  const isRowArchived = React.useCallback(\n    (conversation: { id: string; sandbox_status?: SandboxStatus | null }) =>\n      isEffectivelyArchivedConversation(\n        conversation.sandbox_status,\n        archivedIdSet.has(conversation.id),\n      ),\n    [archivedIdSet],\n  );\n\n  const toggleGroupCollapsed = React.useCallback((groupId: string) => {\n    setCollapsedGroupIds((prev) => {\n      const next = new Set(prev);\n      if (next.has(groupId)) {\n        next.delete(groupId);\n      } else {\n        next.add(groupId);\n      }\n      return next;\n    });\n  }, []);\n\n  const toggleGroupPreviewExpanded = React.useCallback((groupId: string) => {\n    setExpandedGroupPreviewIds((prev) => {\n      const next = new Set(prev);\n      if (next.has(groupId)) {\n        next.delete(groupId);\n      } else {\n        next.add(groupId);\n      }\n      return next;\n    });\n  }, []);\n\n  React.useEffect(() => {\n    if (organizeMode !== \"grouped\") {\n      setCollapsedGroupIds(new Set());\n      setExpandedGroupPreviewIds(new Set());\n    }\n  }, [organizeMode]);\n\n  const scrollContainerRef = React.useRef<HTMLDivElement>(null);\n\n  const [selectedConversationId, setSelectedConversationId] = React.useState<\n    string | null\n  >(null);\n  const [selectedConversationTitle, setSelectedConversationTitle] =\n    React.useState<string | null>(null);\n  const [openContextMenuId, setOpenContextMenuId] = React.useState<\n    string | null\n  >(null);\n\n  const {\n    data,\n    isLoading,\n    isFetched,\n    hasNextPage,\n    isFetching,\n    isFetchingNextPage,\n    fetchNextPage,\n  } = usePaginatedConversations();\n  const { workspaces: knownWorkspaces } = useResolvedWorkspaces();\n\n  // Fetch in-progress start tasks\n  const { data: startTasks } = useStartTasks();\n\n  // Deduped, archive-unaware collection of every conversation currently loaded\n  // from the backend. Bulk actions like \"Delete all\" must use this list so\n  // hiding archived rows from the UI never shrinks what gets deleted.\n  const allLoadedConversations = React.useMemo(() => {\n    const all = data?.pages.flatMap((page) => page.items) ?? [];\n    // The 10s background refetch re-fetches every loaded page with the\n    // `UPDATED_AT_DESC` cursor. If a conversation's `updated_at` shifts between\n    // page fetches, a later page can overlap an earlier one and surface the\n    // same conversation twice. Dedupe by id (keeping the first/freshest copy)\n    // so the rendered count reflects real growth and React keys stay unique.\n    return uniqueById(all);\n  }, [data]);\n\n  // Grouped pagination is folder-oriented. Record the first backend page for\n  // every conversation so later pages can introduce new folders without\n  // mutating the contents of folders that are already visible.\n  //\n  // Known limitation: the mapping is recomputed from `data.pages` on the 10s\n  // background refetch, so a conversation whose `updated_at` shifts can move\n  // to an earlier page and change its recorded discovery page. The blast\n  // radius is only which rows a collapsed preview shows (expanding a folder\n  // always reveals every loaded conversation); pinning discovery pages across\n  // refetches is not worth the extra bookkeeping today.\n  const conversationPageById = React.useMemo(() => {\n    const pageById = new Map<string, number>();\n    data?.pages.forEach((page, pageIndex) => {\n      page.items.forEach((conversation) => {\n        if (!pageById.has(conversation.id)) {\n          pageById.set(conversation.id, pageIndex);\n        }\n      });\n    });\n    return pageById;\n  }, [data]);\n\n  // Display collection: same loaded pages, with effectively-archived rows\n  // (explicitly archived or missing-runtime) filtered out unless the user has\n  // opted into \"Show archived\".\n  const conversations = React.useMemo(() => {\n    if (showArchivedConversations) {\n      return allLoadedConversations;\n    }\n    return allLoadedConversations.filter(\n      (conversation) => !isRowArchived(conversation),\n    );\n  }, [allLoadedConversations, isRowArchived, showArchivedConversations]);\n\n  // Facets derive from the unfiltered list so the automation-name rows in the\n  // advanced-options modal don't vanish while a narrowing selection is active.\n  const automationNameFacets = React.useMemo(\n    () => collectAutomationNameFacets(conversations),\n    [conversations],\n  );\n\n  const allWorkspacesForGrouping = React.useMemo<\n    readonly LocalWorkspace[]\n  >(() => {\n    if (\n      compact ||\n      organizeMode !== \"grouped\" ||\n      activeBackend.kind !== \"local\"\n    ) {\n      return [];\n    }\n    const normalize = (p: string) => p.trim().replace(/\\/+$/, \"\");\n    const byPath = new Map<string, LocalWorkspace>();\n    for (const ws of knownWorkspaces) {\n      const key = normalize(ws.path);\n      if (key) {\n        byPath.set(key, ws);\n      }\n    }\n    for (const c of conversations) {\n      const normalized = c.selected_workspace\n        ? normalize(c.selected_workspace)\n        : \"\";\n      if (normalized && !byPath.has(normalized)) {\n        const label = normalized.split(\"/\").filter(Boolean).pop() ?? normalized;\n        byPath.set(normalized, {\n          id: normalized,\n          name: label,\n          path: normalized,\n        });\n      }\n    }\n    return Array.from(byPath.values());\n  }, [\n    activeBackend.kind,\n    compact,\n    conversations,\n    knownWorkspaces,\n    organizeMode,\n  ]);\n\n  const automationFilteredConversations = React.useMemo(\n    () =>\n      applyAutomationConversationFilter(\n        conversations,\n        automationFilterMode,\n        selectedAutomationNames,\n        automationNameFacets,\n      ),\n    [\n      automationFilterMode,\n      automationNameFacets,\n      conversations,\n      selectedAutomationNames,\n    ],\n  );\n\n  // Tag facets likewise derive from the unfiltered list so the tag rows in\n  // the layouts menu don't vanish while a narrowing selection is active.\n  const tagFacets = React.useMemo(\n    () => collectTagFacets(conversations),\n    [conversations],\n  );\n\n  // The tag filter applies after the automation filter so a conversation\n  // must pass both.\n  const tagFilteredConversations = React.useMemo(\n    () =>\n      applyTagConversationFilter(\n        automationFilteredConversations,\n        selectedTagFacets,\n        tagFacets,\n      ),\n    [automationFilteredConversations, selectedTagFacets, tagFacets],\n  );\n\n  const pinnedConversations = React.useMemo(\n    () => resolvePinnedConversations(pinnedIds, conversations),\n    [conversations, pinnedIds],\n  );\n\n  React.useEffect(() => {\n    if (!isFetched) {\n      return;\n    }\n    // Prune pins against the unfiltered loaded pages so archived-but-still-\n    // pinned rows are not treated as missing. Archived IDs are intentionally\n    // not pruned here \u2014 pagination would otherwise drop archives that are not\n    // on the currently loaded pages and let them reappear in the list.\n    const loadedIds =\n      data?.pages.flatMap((page) => page.items.map((item) => item.id)) ?? [];\n    pruneMissingPinnedConversations(activeBackend.id, loadedIds);\n  }, [activeBackend.id, data, isFetched, pruneMissingPinnedConversations]);\n\n  React.useEffect(() => {\n    if (pinnedIds.length === 0) {\n      setExpandedPinnedPreview(false);\n    }\n  }, [pinnedIds.length]);\n\n  const scopedConversations = React.useMemo(() => {\n    // The pinned section intentionally bypasses the automation and tag\n    // filters (same exemption the thread scope has): a pin is an explicit\n    // user override.\n    const scopeFiltered =\n      threadScope === \"relevant\"\n        ? tagFilteredConversations.filter((c) =>\n            isExecutionActive(c.execution_status),\n          )\n        : tagFilteredConversations;\n\n    // In the expanded panel, pinned conversations should only appear inside\n    // the dedicated pinned section (not duplicated in grouped/flat lists).\n    if (compact) {\n      return scopeFiltered;\n    }\n\n    return filterOutPinnedConversations(scopeFiltered, pinnedIds);\n  }, [tagFilteredConversations, compact, pinnedIds, threadScope]);\n\n  const { recent: recentScoped, older: olderScoped } = React.useMemo(() => {\n    const cutoff = isOlderConversationCutoff(olderConversationCutoff)\n      ? olderConversationCutoff\n      : DEFAULT_OLDER_CONVERSATION_CUTOFF;\n    return partitionByCutoff(\n      scopedConversations,\n      OLDER_CONVERSATION_CUTOFF_MS[cutoff],\n    );\n  }, [olderConversationCutoff, scopedConversations]);\n\n  // Sort the full visible set as one list. The recent/older partition is\n  // still computed (it gates the \"Show older\" toggle and \"Load more\"\n  // visibility), but the rendering must not use it as a visual boundary \u2014\n  // when sorting by `created`, a stale-but-recently-touched conversation\n  // would otherwise land in `recent` and render above an actually-newer-\n  // by-`created_at` conversation sitting in `older`.\n  const sortedVisibleConversations = React.useMemo(() => {\n    const visible = showOlderConversations\n      ? [...recentScoped, ...olderScoped]\n      : recentScoped;\n    return sortConversationsByField(visible, conversationSort);\n  }, [recentScoped, olderScoped, showOlderConversations, conversationSort]);\n\n  const groupLabels = React.useMemo(\n    () => ({\n      emptyWorkspace: t(I18nKey.CONVERSATION_PANEL$NO_WORKSPACE),\n      emptyRepository: t(I18nKey.CONVERSATION_PANEL$NO_REPOSITORY),\n    }),\n    [t],\n  );\n\n  const groupedSourceConversations = React.useMemo(() => {\n    if (compact || organizeMode !== \"grouped\") {\n      return null;\n    }\n    // Use the unsorted partitions: groupConversations sorts each bucket\n    // internally by `sortField`, so pre-sorting the merged input is wasted\n    // work in grouped mode (the per-group sort overrides any global order).\n    return [...recentScoped, ...(showOlderConversations ? olderScoped : [])];\n  }, [\n    compact,\n    olderScoped,\n    organizeMode,\n    recentScoped,\n    showOlderConversations,\n  ]);\n\n  const conversationGroups = React.useMemo(() => {\n    if (!groupedSourceConversations) {\n      return null;\n    }\n    // Keep every loaded conversation in the group model. Folder discovery only\n    // freezes the collapsed preview \u2014 expanding a folder must reach later-page\n    // rows for that same workspace/repo.\n    return groupConversations(\n      groupedSourceConversations,\n      activeBackend.kind,\n      conversationSort,\n      groupLabels,\n      allWorkspacesForGrouping,\n    );\n  }, [\n    activeBackend.kind,\n    conversationSort,\n    groupLabels,\n    groupedSourceConversations,\n    allWorkspacesForGrouping,\n  ]);\n\n  const groupDiscoveryConversationIds = React.useMemo(() => {\n    if (!groupedSourceConversations) {\n      return null;\n    }\n    return getGroupDiscoveryConversationIds(\n      groupedSourceConversations,\n      conversationPageById,\n      activeBackend.kind,\n      { forceIncludeConversationId: currentConversationId },\n    );\n  }, [\n    activeBackend.kind,\n    conversationPageById,\n    currentConversationId,\n    groupedSourceConversations,\n  ]);\n\n  const orderedConversationGroups = React.useMemo(() => {\n    if (!conversationGroups) {\n      return null;\n    }\n    return applyGroupFolderOrder(conversationGroups, groupFolderOrder);\n  }, [conversationGroups, groupFolderOrder]);\n\n  const conversationGroupIds = React.useMemo(\n    () => conversationGroups?.map((group) => group.id) ?? [],\n    [conversationGroups],\n  );\n\n  const compactVisibleConversations = React.useMemo(\n    () =>\n      sortConversationsByField(\n        recentScoped.filter((conversation) =>\n          isExecutionActive(conversation.execution_status),\n        ),\n        conversationSort,\n      ),\n    [conversationSort, recentScoped],\n  );\n\n  const visibleFlatCount = sortedVisibleConversations.length;\n\n  const visibleGroupCount = orderedConversationGroups?.length ?? 0;\n\n  const listIsEffectivelyEmpty =\n    organizeMode === \"grouped\" && !compact\n      ? visibleGroupCount === 0\n      : visibleFlatCount === 0;\n\n  // Attribution is exact: the automation filter step itself produced zero\n  // rows out of a non-empty loaded set (not merely threadScope/older-cutoff\n  // effects). Used to pick the empty-state message.\n  const emptyDueToAutomationFilter =\n    listIsEffectivelyEmpty &&\n    automationFilterMode !== \"all\" &&\n    conversations.length > 0 &&\n    automationFilteredConversations.length === 0;\n\n  // Same attribution for the tag filter: it produced zero rows out of what\n  // the automation filter left behind.\n  const emptyDueToTagFilter =\n    listIsEffectivelyEmpty &&\n    selectedTagFacets.length > 0 &&\n    automationFilteredConversations.length > 0 &&\n    tagFilteredConversations.length === 0;\n\n  // Grouped pagination prefers discovering another folder; chronological\n  // pagination succeeds when another row appears. Pages that only deepen\n  // already-visible folders are not success for the grouped control \u2014 the\n  // driver walks past them (bounded by the per-click page cap) so a single\n  // click can still reach a folder hiding behind deepen-only pages.\n  const visibleCount =\n    organizeMode === \"grouped\" && !compact\n      ? visibleGroupCount\n      : visibleFlatCount;\n  const loadedPageCount = data?.pages.length ?? 0;\n\n  // KNOWN ISSUE (unresolved as of 2026-05-29): users still report that the\n  // sidebar \"Load more\" sometimes requires two clicks before new conversations\n  // appear. The mitigation below (dedupe by id in `conversations`, plus the\n  // floor-tracking driver that keeps fetching until the visible count grows)\n  // reduced but did NOT fully eliminate the symptom in manual testing. Likely\n  // remaining suspects to investigate next: the agent-server cursor pagination\n  // returning an overlapping/short page under `UPDATED_AT_DESC` while the 10s\n  // `refetchInterval` reorders pages (see `usePaginatedConversations`), or a\n  // React Query state lag where `hasNextPage`/`isFetching` settle a render\n  // after the click. If you pick this up, reproduce against a backend with\n  // >40 conversations and watch the `/api/conversations/search` cursors.\n  //\n  // Robust \"Load more\" driver. A single click can fail to surface new rows for\n  // two reasons: (1) `fetchNextPage()` is silently dropped while the 10s\n  // background refetch is in flight, and (2) a fetched page can yield zero\n  // *visible* rows (filtered out by the active scope, or deduped as overlap),\n  // so the list does not appear to grow. We capture floors at click time and\n  // keep fetching \u2014 once idle \u2014 until the visible count grows, the grouped\n  // per-click page cap is hit, or pages run out. `loadedPageCount` keeps the\n  // driver advancing when a fetched page contains only folders that were\n  // already discovered.\n  const [loadMoreFloor, setLoadMoreFloor] = React.useState<number | null>(null);\n  const [loadMorePageFloor, setLoadMorePageFloor] = React.useState<\n    number | null\n  >(null);\n  const visibleCountRef = React.useRef(visibleCount);\n  visibleCountRef.current = visibleCount;\n  const loadedPageCountRef = React.useRef(loadedPageCount);\n  loadedPageCountRef.current = loadedPageCount;\n\n  const clearLoadMoreRequest = React.useCallback(() => {\n    setLoadMoreFloor(null);\n    setLoadMorePageFloor(null);\n  }, []);\n\n  const requestLoadMore = React.useCallback(() => {\n    if (hasNextPage) {\n      setLoadMoreFloor(visibleCountRef.current);\n      setLoadMorePageFloor(loadedPageCountRef.current);\n    }\n  }, [hasNextPage]);\n\n  React.useEffect(() => {\n    if (loadMoreFloor === null) {\n      return;\n    }\n    // Goal met: the visible list grew past where it was when the user clicked.\n    if (visibleCount > loadMoreFloor) {\n      clearLoadMoreRequest();\n      return;\n    }\n    // Hard cap (grouped only): pages that merely deepen already-visible\n    // folders are walked past \u2014 a new folder may sit right behind them \u2014 but\n    // never unbounded many, so one click cannot drain the whole cursor.\n    // Chronological mode keeps its pre-existing behavior: fetch until a\n    // visible row appears or pages run out.\n    if (\n      organizeMode === \"grouped\" &&\n      !compact &&\n      loadMorePageFloor != null &&\n      loadedPageCount >= loadMorePageFloor + MAX_PAGES_PER_LOAD_MORE_CLICK\n    ) {\n      clearLoadMoreRequest();\n      return;\n    }\n    // Wait for any in-flight fetch (including the background refetch) to settle\n    // before evaluating `hasNextPage`; React Query may transiently clear that\n    // flag while replacing the last page.\n    if (isFetching || isFetchingNextPage) {\n      return;\n    }\n    // Nothing more to fetch \u2014 stop waiting even if the list did not grow.\n    if (!hasNextPage) {\n      clearLoadMoreRequest();\n      return;\n    }\n    fetchNextPage();\n  }, [\n    clearLoadMoreRequest,\n    compact,\n    loadMoreFloor,\n    loadMorePageFloor,\n    visibleCount,\n    loadedPageCount,\n    organizeMode,\n    hasNextPage,\n    isFetching,\n    isFetchingNextPage,\n    fetchNextPage,\n  ]);\n\n  const isLoadingMore = loadMoreFloor !== null || isFetchingNextPage;\n\n  const { mutate: deleteConversation, mutateAsync: deleteConversationAsync } =\n    useDeleteConversation();\n  const { mutate: pauseConversation } = useUnifiedPauseConversation();\n  const { mutate: updateConversation } = useUpdateConversation();\n  const { mutate: updateConversationTags } = useUpdateConversationTags();\n\n  // The next page of conversations is loaded only via the explicit \"Load\n  // more\" link rendered at the end of the list \u2014 there is no scroll-driven\n  // pagination, which previously caused the panel to feel like it had stray\n  // scrollable space at the bottom.\n  const olderHidden = olderScoped.length > 0 && !showOlderConversations;\n  // Compact mode also hides \"Load more\" \u2014 paginating into stale conversations\n  // contradicts the \"active only\" intent of the icon rail.\n  // Availability tracks backend exhaustion, never visible emptiness: a\n  // client-side filter (archiving, the automation filter) can hide every row\n  // of the loaded pages, and hiding the control there would strand the\n  // remaining pages behind an empty-state message with no way forward.\n  // `requestLoadMore`'s floor driver keeps paging until a visible row\n  // appears, so a single click walks past pages that are entirely filtered.\n  const showLoadMore = !!hasNextPage && !olderHidden && !compact;\n\n  const { mutate: createConversation } = useCreateConversation();\n  const isCreatingConversationFlow = useIsCreatingConversation();\n\n  const launchFromGroup = React.useCallback(\n    (launch: ConversationGroupLaunch) => {\n      if (isCreatingConversationFlow) return;\n      createConversation(\n        {\n          workingDir: launch.workingDir,\n          repository: launch.repository,\n          entryPoint: \"sidebar_relaunch_project\",\n        },\n        {\n          onSuccess: (data) => {\n            navigate(`/conversations/${data.conversation_id}`);\n          },\n        },\n      );\n    },\n    [createConversation, isCreatingConversationFlow, navigate],\n  );\n\n  const handleDeleteProject = React.useCallback(\n    (conversationId: string, title: string) => {\n      setConfirmDeleteModalVisible(true);\n      setSelectedConversationId(conversationId);\n      setSelectedConversationTitle(title);\n    },\n    [],\n  );\n\n  const handleArchiveProject = React.useCallback(\n    (conversationId: string, title: string) => {\n      setConfirmArchiveModalVisible(true);\n      setSelectedConversationId(conversationId);\n      setSelectedConversationTitle(title);\n    },\n    [],\n  );\n\n  // Editing tags is a local agent-server affordance: Cloud conversations\n  // don't carry server-side tags (`tags` stays null), so the card leaves\n  // `onEditTags` undefined on Cloud and the menu item never appears there.\n  const handleEditTags = React.useCallback((conversationId: string) => {\n    setEditTagsModalVisible(true);\n    setSelectedConversationId(conversationId);\n  }, []);\n\n  const handleConfirmEditTags = React.useCallback(\n    (mergedTags: Record<string, string>) => {\n      if (!selectedConversationId) {\n        return;\n      }\n      updateConversationTags(\n        { conversationId: selectedConversationId, tags: mergedTags },\n        {\n          onSuccess: () => {\n            displaySuccessToast(t(I18nKey.CONVERSATION$TAGS_UPDATED));\n          },\n        },\n      );\n    },\n    [selectedConversationId, t, updateConversationTags],\n  );\n\n  // Unarchiving needs no confirmation: it restores a row the user can archive\n  // again in one click, and nothing about the conversation itself changes.\n  const handleUnarchiveProject = React.useCallback(\n    (conversationId: string) => {\n      removeArchivedConversation(activeBackend.id, conversationId);\n    },\n    [activeBackend.id, removeArchivedConversation],\n  );\n\n  const handleStopConversation = React.useCallback((conversationId: string) => {\n    setConfirmStopModalVisible(true);\n    setSelectedConversationId(conversationId);\n  }, []);\n\n  const handleConversationTitleChange = React.useCallback(\n    (conversationId: string, newTitle: string) => {\n      updateConversation(\n        { conversationId, newTitle },\n        {\n          onSuccess: () => {\n            displaySuccessToast(t(I18nKey.CONVERSATION$TITLE_UPDATED));\n          },\n        },\n      );\n    },\n    [t, updateConversation],\n  );\n\n  const handleConfirmDelete = () => {\n    if (selectedConversationId) {\n      const conversationId = selectedConversationId;\n      deleteConversation(\n        { conversationId },\n        {\n          onSuccess: () => {\n            removeArchivedConversation(activeBackend.id, conversationId);\n            if (conversationId === currentConversationId) {\n              navigate(\"/conversations\");\n            }\n          },\n        },\n      );\n    }\n  };\n\n  const handleConfirmArchive = () => {\n    if (!selectedConversationId) {\n      return;\n    }\n    archiveConversation(activeBackend.id, selectedConversationId);\n    unpinConversation(activeBackend.id, selectedConversationId);\n    if (selectedConversationId === currentConversationId) {\n      navigate(\"/conversations\");\n    }\n  };\n\n  const handleConfirmStop = () => {\n    if (selectedConversationId) {\n      pauseConversation({\n        conversationId: selectedConversationId,\n      });\n    }\n  };\n\n  const handleConfirmDeleteAll = async () => {\n    // Delete against the unfiltered loaded set so archived (currently hidden)\n    // conversations are still removed from the server \u2014 matching the action's\n    // \"delete all conversations\" label and confirmation count.\n    const idsToDelete = allLoadedConversations.map((c) => c.id);\n    const results = await Promise.allSettled(\n      idsToDelete.map((conversationId) =>\n        deleteConversationAsync({ conversationId }),\n      ),\n    );\n\n    const deletedIds = results.flatMap((result, index) =>\n      result.status === \"fulfilled\" ? [idsToDelete[index]] : [],\n    );\n    const failedCount = results.length - deletedIds.length;\n\n    for (const conversationId of deletedIds) {\n      removeArchivedConversation(activeBackend.id, conversationId);\n    }\n\n    if (\n      currentConversationId !== null &&\n      deletedIds.includes(currentConversationId)\n    ) {\n      navigate(\"/conversations\");\n    }\n\n    if (failedCount > 0) {\n      displayErrorToast(\n        `${failedCount} conversation${failedCount === 1 ? \"\" : \"s\"} could not be deleted.`,\n      );\n    }\n  };\n\n  const renderConversationCard = React.useCallback(\n    (\n      conversation: (typeof conversations)[number],\n      options?: { inPinnedSection?: boolean },\n    ) => {\n      const isPinned = pinnedIds.includes(conversation.id);\n      // Presentation: a row looks archived when the user archived it or its\n      // runtime is missing and non-resumable. This single predicate drives list\n      // visibility, the chip, the dot, and the dimmed title.\n      const isArchived = isRowArchived(conversation);\n      // Action availability is separate from presentation. \"Unarchive\" is only\n      // meaningful when clearing the browser-local flag actually returns the row\n      // to the unarchived state, so it is offered solely for an explicitly\n      // archived row whose runtime is not missing. A missing, non-resumable\n      // runtime cannot be restored from this menu, so such a row offers neither\n      // \"Archive\" nor \"Unarchive\", even when a stored archive flag is set.\n      const explicitlyArchived = archivedIdSet.has(conversation.id);\n      const runtimeMissing = isMissingSandboxStatus(\n        conversation.sandbox_status,\n      );\n      if (compact) {\n        return (\n          <CompactConversationRow\n            key={conversation.id}\n            conversationId={conversation.id}\n            title={conversation.title ?? \"\"}\n            selectedRepository={{\n              selected_repository: conversation.selected_repository,\n              selected_branch: conversation.selected_branch,\n              git_provider: conversation.git_provider as Provider,\n            }}\n            executionStatus={conversation.execution_status}\n            sandboxStatus={conversation.sandbox_status}\n            lastUpdatedAt={conversation.updated_at}\n            createdAt={conversation.created_at}\n            workspaceWorkingDir={\n              conversation.selected_workspace ??\n              conversation.workspace?.working_dir\n            }\n            isActive={conversation.id === currentConversationId}\n            onClose={onClose}\n            showRepositoryMetadata={showRepoBranchMetadata}\n            llmModel={conversation.llm_model}\n            showLlmProfiles={showLlmProfiles}\n            agentKind={conversation.agent_kind}\n            acpServer={conversation.acp_server}\n            acpModels={conversation.acp_available_models}\n            tags={conversation.tags}\n            showTags={showTagsMetadata}\n            isArchived={isArchived}\n          />\n        );\n      }\n      return (\n        <Tooltip\n          key={conversation.id}\n          placement=\"right-start\"\n          delay={1000}\n          closeDelay={100}\n          isDisabled={\n            !showHoverMetadata || openContextMenuId === conversation.id\n          }\n          disableAnimation={import.meta.env.MODE === \"test\"}\n          className=\"max-w-none overflow-visible rounded-xl border border-border bg-base-secondary p-0 text-contrast shadow-xl\"\n          content={\n            <ConversationCardPreview\n              title={conversation.title ?? \"\"}\n              executionStatus={conversation.execution_status}\n              sandboxStatus={conversation.sandbox_status}\n              selectedRepository={{\n                selected_repository: conversation.selected_repository,\n                selected_branch: conversation.selected_branch,\n                git_provider: conversation.git_provider as Provider,\n              }}\n              workspaceWorkingDir={\n                conversation.selected_workspace ??\n                conversation.workspace?.working_dir\n              }\n              llmModel={conversation.llm_model}\n              agentKind={conversation.agent_kind}\n              acpServer={conversation.acp_server}\n              createdAt={conversation.created_at}\n              tags={conversation.tags}\n              isArchived={isArchived}\n            />\n          }\n        >\n          <NavigationLink\n            to={backendScopedPath(`/conversations/${conversation.id}`)}\n            onClick={onClose}\n            className={cn(\n              \"block rounded-md transition-colors\",\n              openContextMenuId !== conversation.id && \"hover:bg-surface\",\n              (conversation.id === currentConversationId ||\n                openContextMenuId === conversation.id) &&\n                \"bg-surface\",\n            )}\n          >\n            <ConversationCard\n              onDelete={() =>\n                handleDeleteProject(conversation.id, conversation.title ?? \"\")\n              }\n              // Archive is offered until the row is effectively archived; the\n              // reverse direction, \"Unarchive\", is offered only when the row is\n              // explicitly archived and its runtime is not missing. A\n              // missing-runtime row offers neither.\n              onArchive={\n                isArchived\n                  ? undefined\n                  : () =>\n                      handleArchiveProject(\n                        conversation.id,\n                        conversation.title ?? \"\",\n                      )\n              }\n              onUnarchive={\n                explicitlyArchived && !runtimeMissing\n                  ? () => handleUnarchiveProject(conversation.id)\n                  : undefined\n              }\n              onStop={() => handleStopConversation(conversation.id)}\n              onEditTags={\n                activeBackend.kind === \"local\"\n                  ? () => handleEditTags(conversation.id)\n                  : undefined\n              }\n              onChangeTitle={(title) =>\n                handleConversationTitleChange(conversation.id, title)\n              }\n              title={conversation.title ?? \"\"}\n              selectedRepository={{\n                selected_repository: conversation.selected_repository,\n                selected_branch: conversation.selected_branch,\n                git_provider: conversation.git_provider as Provider,\n              }}\n              lastUpdatedAt={conversation.updated_at}\n              createdAt={conversation.created_at}\n              executionStatus={conversation.execution_status}\n              sandboxStatus={conversation.sandbox_status}\n              conversationId={conversation.id}\n              contextMenuOpen={openContextMenuId === conversation.id}\n              onContextMenuToggle={(isOpen) =>\n                setOpenContextMenuId(isOpen ? conversation.id : null)\n              }\n              isActive={conversation.id === currentConversationId}\n              workspaceWorkingDir={\n                conversation.selected_workspace ??\n                conversation.workspace?.working_dir\n              }\n              showRepositoryMetadata={showRepoBranchMetadata}\n              llmModel={conversation.llm_model}\n              showLlmProfiles={showLlmProfiles}\n              agentKind={conversation.agent_kind}\n              acpServer={conversation.acp_server}\n              acpModels={conversation.acp_available_models}\n              tags={conversation.tags}\n              showTags={showTagsMetadata}\n              isArchived={isArchived}\n              isPinned={isPinned}\n              onTogglePin={() => togglePin(activeBackend.id, conversation.id)}\n              alwaysShowPinIcon={isPinned && !options?.inPinnedSection}\n            />\n          </NavigationLink>\n        </Tooltip>\n      );\n    },\n    [\n      activeBackend.id,\n      activeBackend.kind,\n      archivedIdSet,\n      compact,\n      currentConversationId,\n      handleArchiveProject,\n      handleConversationTitleChange,\n      handleDeleteProject,\n      handleEditTags,\n      handleStopConversation,\n      handleUnarchiveProject,\n      isRowArchived,\n      onClose,\n      openContextMenuId,\n      pinnedIds,\n      showRepoBranchMetadata,\n      showLlmProfiles,\n      showTagsMetadata,\n      showHoverMetadata,\n      togglePin,\n    ],\n  );\n\n  // Standard layout: panel fills its slot in the sidebar; the inner scroll\n  // child fills the panel and scrolls when its content overflows. Modals are\n  // siblings of the scroll element and are `position: fixed`, so they don't\n  // participate in the panel's scroll geometry.\n  // Gate on `isLoading` / `!isFetched` (true only until the first fetch settles),\n  // not `isFetching` \u2014 the latter flips back to true on every 10s background\n  // refetch, causing the skeleton/empty-state to flicker when the list is empty.\n  const showInitialSkeleton = isLoading || !isFetched;\n  const showPinnedSection =\n    !compact && !showInitialSkeleton && pinnedConversations.length > 0;\n  const hasVisibleGroups =\n    organizeMode === \"grouped\" &&\n    !compact &&\n    orderedConversationGroups != null &&\n    orderedConversationGroups.length > 0;\n  const showEmptyState =\n    isFetched &&\n    !isLoading &&\n    !compact &&\n    listIsEffectivelyEmpty &&\n    !showPinnedSection &&\n    !startTasks?.length &&\n    !hasVisibleGroups;\n\n  // The Conversations header doubles as a bulk control for the grouped view:\n  // collapse every visible folder while any is expanded, expand them all once\n  // none is. Folders outside the current view keep their own state.\n  const allGroupsCollapsed =\n    hasVisibleGroups &&\n    (orderedConversationGroups ?? []).every((group) =>\n      collapsedGroupIds.has(group.id),\n    );\n\n  const toggleAllGroupsCollapsed = React.useCallback(() => {\n    setCollapsedGroupIds((prev) => {\n      const groupIds =\n        orderedConversationGroups?.map((group) => group.id) ?? [];\n      if (groupIds.length === 0) {\n        return prev;\n      }\n      const next = new Set(prev);\n      if (groupIds.every((groupId) => prev.has(groupId))) {\n        groupIds.forEach((groupId) => next.delete(groupId));\n      } else {\n        groupIds.forEach((groupId) => next.add(groupId));\n      }\n      return next;\n    });\n  }, [orderedConversationGroups]);\n\n  const showConversationHeader = !compact;\n\n  return (\n    <div\n      ref={ref}\n      data-testid=\"conversation-panel\"\n      className=\"flex h-full min-h-0 w-full flex-col\"\n    >\n      {showConversationHeader && (\n        <div\n          className={cn(\n            // Pull flush to the sidebar edges: `-ml-2.5` matches aside `pl-2.5`;\n            // width extends by that inset on the right now that aside is `pr-0`.\n            \"-ml-2.5 w-[calc(100%+0.625rem)] max-w-none box-border border-b\",\n            isListScrolled ? \"border-border\" : \"border-transparent\",\n          )}\n        >\n          <div\n            data-testid=\"older-conversations-summary\"\n            className=\"flex min-w-0 flex-nowrap items-center gap-x-2 py-2 pl-4 pr-2.5 text-muted\"\n          >\n            {hasVisibleGroups ? (\n              <button\n                type=\"button\"\n                data-testid=\"conversations-header-toggle\"\n                aria-expanded={!allGroupsCollapsed}\n                onClick={toggleAllGroupsCollapsed}\n                className={cn(\n                  \"min-w-0 cursor-pointer truncate text-left text-sm font-medium\",\n                  \"text-muted transition-colors hover:text-white\",\n                  \"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border\",\n                )}\n              >\n                {t(I18nKey.SIDEBAR$CONVERSATIONS)}\n              </button>\n            ) : (\n              <span className=\"min-w-0 truncate text-sm font-medium text-muted\">\n                {t(I18nKey.SIDEBAR$CONVERSATIONS)}\n              </span>\n            )}\n            <div className=\"ml-auto flex shrink-0 items-center gap-0.5\">\n              <ConversationPanelNewThreadPicker\n                backendKind={activeBackend.kind}\n              />\n              <ConversationLayoutsMenu\n                menuOpen={filterMenuOpen}\n                setMenuOpen={setFilterMenuOpen}\n                menuRef={filterMenuRef}\n                backendKind={activeBackend.kind}\n                tagFacets={tagFacets}\n                automationNameFacets={automationNameFacets}\n                totalConversationsCount={allLoadedConversations.length}\n                onRequestDeleteAll={() => setConfirmDeleteAllVisible(true)}\n              />\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* Sits above the list, not inside the scroll container: a filter that\n          scrolls out of view is a filter the user can't see. */}\n      {!compact && (\n        <ConversationActiveTagFilters\n          selectedFacets={selectedTagFacets}\n          onToggleFacet={toggleTagFacet}\n          selectedAutomationNames={selectedAutomationNames}\n          onToggleAutomationName={toggleAutomationName}\n          onClearAll={clearFilterSelections}\n        />\n      )}\n\n      <div\n        ref={scrollContainerRef}\n        data-testid=\"conversation-panel-list-scroll\"\n        onScroll={(event) => {\n          setIsListScrolled(event.currentTarget.scrollTop > 0);\n        }}\n        className={cn(\n          \"flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden overscroll-contain custom-scrollbar-always\",\n          !compact && \"conversation-panel-list-scroll\",\n        )}\n      >\n        {showInitialSkeleton && <ConversationCardSkeleton compact={compact} />}\n\n        {!compact && showEmptyState && (\n          <div\n            data-testid=\"conversation-panel-empty-state\"\n            className=\"flex min-h-0 flex-1 flex-col items-center justify-center px-4 py-8\"\n          >\n            <p className=\"text-xs text-muted\">\n              {t(\n                emptyDueToAutomationFilter\n                  ? I18nKey.CONVERSATION_PANEL$NO_AUTOMATION_MATCHES\n                  : emptyDueToTagFilter\n                    ? I18nKey.CONVERSATION_PANEL$NO_TAG_MATCHES\n                    : I18nKey.CONVERSATION$NO_CONVERSATIONS,\n              )}\n            </p>\n          </div>\n        )}\n\n        {showPinnedSection ? (\n          <ConversationPanelPinnedSection\n            pinnedConversations={pinnedConversations}\n            isPreviewExpanded={expandedPinnedPreview}\n            onTogglePreviewExpanded={() =>\n              setExpandedPinnedPreview((current) => !current)\n            }\n            activeConversationId={currentConversationId}\n            showDivider={!compact && organizeMode === \"chronological\"}\n            renderConversationCard={(conversation) =>\n              renderConversationCard(conversation, { inPinnedSection: true })\n            }\n          />\n        ) : null}\n\n        {/* Render in-progress start tasks first (skipped in compact mode \u2014\n            their rich card layout doesn't fit in the icon rail). */}\n        {!compact &&\n          startTasks?.map((task) => (\n            <NavigationLink\n              key={task.id}\n              to={backendScopedPath(`/conversations/task-${task.id}`)}\n              onClick={onClose}\n              className=\"block\"\n            >\n              <StartTaskCard task={task} />\n            </NavigationLink>\n          ))}\n\n        {!showInitialSkeleton && compact\n          ? compactVisibleConversations.map((conversation) =>\n              renderConversationCard(conversation),\n            )\n          : null}\n\n        {!showInitialSkeleton &&\n        !compact &&\n        organizeMode === \"grouped\" &&\n        orderedConversationGroups &&\n        orderedConversationGroups.length > 0 ? (\n          <ConversationGroupFolderList\n            groups={orderedConversationGroups}\n            groupIds={conversationGroupIds}\n            groupFolderOrder={groupFolderOrder}\n            setGroupFolderOrder={setGroupFolderOrder}\n            collapsedGroupIds={collapsedGroupIds}\n            expandedGroupPreviewIds={expandedGroupPreviewIds}\n            discoveryConversationIds={groupDiscoveryConversationIds}\n            onToggleGroupCollapsed={toggleGroupCollapsed}\n            onToggleGroupPreviewExpanded={toggleGroupPreviewExpanded}\n            isCreatingConversationFlow={isCreatingConversationFlow}\n            activeConversationId={currentConversationId}\n            onLaunchFromGroup={launchFromGroup}\n            renderConversationCard={(conversation) =>\n              renderConversationCard(conversation)\n            }\n          />\n        ) : null}\n\n        {!showInitialSkeleton &&\n        !compact &&\n        organizeMode === \"chronological\" ? (\n          <div className=\"space-y-0.5\">\n            {sortedVisibleConversations.map((conversation) =>\n              renderConversationCard(conversation),\n            )}\n          </div>\n        ) : null}\n\n        {/* Explicit \"Load more\" trigger. Only shown when more pages exist\n            *and* the older list is currently visible (or there are no older\n            conversations to begin with) \u2014 otherwise the next page would be\n            populated mostly with conversations the user has chosen to hide. */}\n        {showLoadMore &&\n          (isLoadingMore ? (\n            <div className=\"py-1\">\n              <ConversationCardSkeleton compact={compact} />\n            </div>\n          ) : (\n            <div className=\"flex justify-center py-4\">\n              <button\n                type=\"button\"\n                data-testid=\"load-more-conversations\"\n                onClick={requestLoadMore}\n                className=\"text-xs text-muted hover:text-contrast\"\n              >\n                {t(I18nKey.CONVERSATION$LOAD_MORE)}\n              </button>\n            </div>\n          ))}\n      </div>\n\n      {confirmDeleteModalVisible && (\n        <ConfirmDeleteModal\n          onConfirm={() => {\n            handleConfirmDelete();\n            setConfirmDeleteModalVisible(false);\n            setSelectedConversationTitle(null);\n          }}\n          onCancel={() => {\n            setConfirmDeleteModalVisible(false);\n            setSelectedConversationTitle(null);\n          }}\n          conversationTitle={selectedConversationTitle ?? undefined}\n        />\n      )}\n\n      {confirmArchiveModalVisible && (\n        <ConfirmArchiveModal\n          onConfirm={() => {\n            handleConfirmArchive();\n            setConfirmArchiveModalVisible(false);\n            setSelectedConversationTitle(null);\n          }}\n          onCancel={() => {\n            setConfirmArchiveModalVisible(false);\n            setSelectedConversationTitle(null);\n          }}\n          conversationTitle={selectedConversationTitle ?? undefined}\n        />\n      )}\n\n      {editTagsModalVisible && (\n        <EditConversationTagsModal\n          // Read the complete map (including reserved/internal keys) so the\n          // modal can merge user edits without dropping them; look it up in\n          // the unfiltered loaded set so archived rows still resolve.\n          tags={\n            allLoadedConversations.find(\n              (conversation) => conversation.id === selectedConversationId,\n            )?.tags\n          }\n          onConfirm={(mergedTags) => {\n            handleConfirmEditTags(mergedTags);\n            setEditTagsModalVisible(false);\n          }}\n          onCancel={() => setEditTagsModalVisible(false)}\n        />\n      )}\n\n      {confirmDeleteAllVisible && (\n        <ConfirmDeleteModal\n          title={t(I18nKey.CONVERSATION$CONFIRM_DELETE_ALL_TITLE)}\n          description={t(I18nKey.CONVERSATION$CONFIRM_DELETE_ALL_DESC, {\n            count: allLoadedConversations.length,\n          })}\n          onConfirm={async () => {\n            await handleConfirmDeleteAll();\n            setConfirmDeleteAllVisible(false);\n          }}\n          onCancel={() => setConfirmDeleteAllVisible(false)}\n        />\n      )}\n\n      {confirmStopModalVisible && (\n        <ConfirmStopModal\n          onConfirm={() => {\n            handleConfirmStop();\n            setConfirmStopModalVisible(false);\n          }}\n          onCancel={() => setConfirmStopModalVisible(false)}\n        />\n      )}\n\n      {confirmExitConversationModalVisible && (\n        <ExitConversationModal\n          onConfirm={() => {\n            onClose?.();\n          }}\n          onClose={() => setConfirmExitConversationModalVisible(false)}\n          onCancel={() => setConfirmExitConversationModalVisible(false)}\n        />\n      )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/exit-conversation-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[#C63143]\" hardcodes a color and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css.",
          "line": 34,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryColorFar",
          "endLine": 34,
          "endColumn": 37
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalBody } from \"#/components/shared/modals/modal-body\";\nimport { ModalButton } from \"#/components/shared/buttons/modal-button\";\nimport { BaseModalTitle } from \"#/components/shared/modals/confirmation-modals/base-modal\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\ninterface ExitConversationModalProps {\n  onConfirm: () => void;\n  onClose: () => void;\n  onCancel: () => void;\n}\n\nexport function ExitConversationModal({\n  onConfirm,\n  onClose,\n  onCancel,\n}: ExitConversationModalProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <ModalBackdrop onClose={onCancel}>\n      <ModalBody testID=\"confirm-new-conversation-modal\">\n        <BaseModalTitle title={t(I18nKey.CONVERSATION$EXIT_WARNING)} />\n        <div className=\"flex w-full justify-end gap-2\">\n          <ModalButton\n            text={t(I18nKey.BUTTON$CANCEL)}\n            onClick={onClose}\n            className=\"bg-tertiary\"\n          />\n          <ModalButton\n            text={t(I18nKey.ACTION$CONFIRM)}\n            onClick={onConfirm}\n            className=\"bg-[#C63143]\"\n          />\n        </div>\n      </ModalBody>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/hook-event-item.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 24,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 24,
          "endColumn": 149
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { ChevronDown, ChevronRight } from \"lucide-react\";\nimport { Typography } from \"#/ui/typography\";\nimport { HookEvent } from \"#/api/conversation-service/agent-server-conversation-service.types\";\nimport { HookMatcherContent } from \"./hook-matcher-content\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\ninterface HookEventItemProps {\n  hookEvent: HookEvent;\n  isExpanded: boolean;\n  onToggle: (eventType: string) => void;\n}\n\nconst EVENT_TYPE_I18N_KEYS: Record<string, I18nKey> = {\n  pre_tool_use: I18nKey.HOOKS_MODAL$EVENT_PRE_TOOL_USE,\n  post_tool_use: I18nKey.HOOKS_MODAL$EVENT_POST_TOOL_USE,\n  user_prompt_submit: I18nKey.HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT,\n  session_start: I18nKey.HOOKS_MODAL$EVENT_SESSION_START,\n  session_end: I18nKey.HOOKS_MODAL$EVENT_SESSION_END,\n  stop: I18nKey.HOOKS_MODAL$EVENT_STOP,\n};\n\nconst HOOK_PILL_CLASS =\n  \"inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-4 border border-border bg-surface text-tertiary-light\";\n\nconst HOOK_COUNT_I18N_KEY = \"HOOKS_MODAL$HOOK_COUNT\";\n\nexport function HookEventItem({\n  hookEvent,\n  isExpanded,\n  onToggle,\n}: HookEventItemProps) {\n  const { t } = useTranslation(\"openhands\");\n  const i18nKey = EVENT_TYPE_I18N_KEYS[hookEvent.event_type];\n  const eventTypeLabel = i18nKey ? t(i18nKey) : hookEvent.event_type;\n\n  const totalHooks = hookEvent.matchers.reduce(\n    (sum, matcher) => sum + (matcher.hooks ?? []).length,\n    0,\n  );\n\n  return (\n    <div>\n      <button\n        type=\"button\"\n        onClick={() => onToggle(hookEvent.event_type)}\n        className=\"w-full py-3 px-3 text-left flex items-center justify-between hover:bg-tertiary transition-colors\"\n      >\n        <div className=\"flex items-center\">\n          <Typography.Text className=\"font-bold text-content-2\">\n            {eventTypeLabel}\n          </Typography.Text>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <span className={HOOK_PILL_CLASS}>\n            {t(HOOK_COUNT_I18N_KEY, { count: totalHooks })}\n          </span>\n          <Typography.Text className=\"text-text-tertiary\">\n            {isExpanded ? (\n              <ChevronDown size={18} />\n            ) : (\n              <ChevronRight size={18} />\n            )}\n          </Typography.Text>\n        </div>\n      </button>\n\n      {isExpanded && (\n        <div className=\"border-t border-border px-3 pt-3 pb-3\">\n          <div className=\"divide-y divide-border\">\n            {hookEvent.matchers.map((matcher, index) => (\n              <HookMatcherContent\n                key={`${hookEvent.event_type}-${matcher.matcher}-${index}`}\n                matcher={matcher}\n              />\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/hook-matcher-content.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 12,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 12,
          "endColumn": 140
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { Typography } from \"#/ui/typography\";\nimport { Pre } from \"#/ui/pre\";\nimport { HookMatcher } from \"#/api/conversation-service/agent-server-conversation-service.types\";\n\ninterface HookMatcherContentProps {\n  matcher: HookMatcher;\n}\n\nconst HOOK_PILL_CLASS =\n  \"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-4 border border-border bg-surface text-tertiary-light\";\n\nexport function HookMatcherContent({ matcher }: HookMatcherContentProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <div className=\"py-3\">\n      <div className=\"mb-2\">\n        <Typography.Text className=\"text-sm font-semibold text-text-tertiary\">\n          {t(I18nKey.HOOKS_MODAL$MATCHER)}\n        </Typography.Text>\n        <span className={`ml-2 ${HOOK_PILL_CLASS}`}>{matcher.matcher}</span>\n      </div>\n\n      <div className=\"mt-2\">\n        <Typography.Text className=\"text-sm font-semibold text-text-tertiary mb-2\">\n          {t(I18nKey.HOOKS_MODAL$COMMANDS)}\n        </Typography.Text>\n        {(matcher.hooks ?? []).map((hook, index) => {\n          // Pick the field each hook type runs with; an agent hook may also\n          // carry a `prompt`, which it never reads.\n          const text =\n            hook.type === \"agent\"\n              ? hook.system_prompt\n              : hook.type === \"prompt\"\n                ? hook.prompt\n                : hook.command;\n          return (\n            <div key={`${text}-${index}`} className=\"mt-2\">\n              {text ? (\n                <Pre\n                  size=\"small\"\n                  font=\"mono\"\n                  lineHeight=\"relaxed\"\n                  padding=\"medium\"\n                  borderRadius=\"medium\"\n                  maxHeight=\"small\"\n                  overflow=\"auto\"\n                  className=\"border border-border bg-canvas-base text-text-tertiary\"\n                >\n                  {text}\n                </Pre>\n              ) : null}\n              <div className=\"mt-2 flex flex-wrap gap-2 text-xs text-muted\">\n                <span className={HOOK_PILL_CLASS}>\n                  {t(I18nKey.HOOKS_MODAL$TYPE, { type: hook.type })}\n                </span>\n                {hook.timeout !== undefined ? (\n                  <span className={HOOK_PILL_CLASS}>\n                    {t(I18nKey.HOOKS_MODAL$TIMEOUT, { timeout: hook.timeout })}\n                  </span>\n                ) : null}\n                {hook.async ? (\n                  <span className={HOOK_PILL_CLASS}>\n                    {t(I18nKey.HOOKS_MODAL$ASYNC)}\n                  </span>\n                ) : null}\n              </div>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/hooks-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[80vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 50,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 50,
          "endColumn": 81
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"h-[60vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 62,
          "column": 24,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 62,
          "endColumn": 129
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useState } from \"react\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  ModalBody,\n} from \"#/components/shared/modals/modal-body\";\nimport { useConversationHooks } from \"#/hooks/query/use-conversation-hooks\";\nimport { AgentState } from \"#/types/agent-state\";\nimport { HooksModalHeader } from \"./hooks-modal-header\";\nimport { HooksLoadingState } from \"./hooks-loading-state\";\nimport { HooksEmptyState } from \"./hooks-empty-state\";\nimport { HookEventItem } from \"./hook-event-item\";\nimport { RuntimeWaitingState } from \"./runtime-waiting-state\";\nimport { useAgentState } from \"#/hooks/use-agent-state\";\nimport { cn } from \"#/utils/utils\";\n\ninterface HooksModalProps {\n  onClose: () => void;\n}\n\nexport function HooksModal({ onClose }: HooksModalProps) {\n  const { curAgentState } = useAgentState();\n  const [expandedEvents, setExpandedEvents] = useState<Record<string, boolean>>(\n    {},\n  );\n  const {\n    data: hooks,\n    isLoading,\n    isError,\n    refetch,\n    isRefetching,\n  } = useConversationHooks();\n\n  const toggleEvent = (eventType: string) => {\n    setExpandedEvents((prev) => ({\n      ...prev,\n      [eventType]: !prev[eventType],\n    }));\n  };\n\n  const isAgentReady = ![AgentState.LOADING, AgentState.INIT].includes(\n    curAgentState,\n  );\n\n  return (\n    <ModalBackdrop onClose={onClose}>\n      <ModalBody\n        width=\"lg\"\n        className={cn(\n          \"relative max-h-[80vh] flex flex-col items-start border border-border\",\n          MODAL_MAX_WIDTH_VIEWPORT,\n        )}\n        testID=\"hooks-modal\"\n      >\n        <HooksModalHeader\n          isLoading={isLoading}\n          isRefetching={isRefetching}\n          onRefresh={refetch}\n          onClose={onClose}\n        />\n\n        <div className=\"w-full h-[60vh] overflow-auto rounded-md border border-border bg-surface-raised custom-scrollbar-always\">\n          {!isAgentReady ? (\n            <RuntimeWaitingState testId=\"hooks-runtime-waiting\" />\n          ) : isLoading ? (\n            <HooksLoadingState />\n          ) : isError || !hooks || hooks.length === 0 ? (\n            <HooksEmptyState isError={isError} />\n          ) : (\n            <div className=\"divide-y divide-border\">\n              {hooks.map((hookEvent) => {\n                const isExpanded =\n                  expandedEvents[hookEvent.event_type] || false;\n\n                return (\n                  <HookEventItem\n                    key={hookEvent.event_type}\n                    hookEvent={hookEvent}\n                    isExpanded={isExpanded}\n                    onToggle={toggleEvent}\n                  />\n                );\n              })}\n            </div>\n          )}\n        </div>\n      </ModalBody>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/local-new-conversation-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets position. Style through classes; use CSS custom properties for dynamic values.",
          "line": 156,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 156,
          "endColumn": 28
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets top. Style through classes; use CSS custom properties for dynamic values.",
          "line": 157,
          "column": 16,
          "nodeType": "MemberExpression",
          "messageId": "inlineStyle",
          "endLine": 157,
          "endColumn": 28
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets left. Style through classes; use CSS custom properties for dynamic values.",
          "line": 158,
          "column": 17,
          "nodeType": "MemberExpression",
          "messageId": "inlineStyle",
          "endLine": 158,
          "endColumn": 30
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 159,
          "column": 18,
          "nodeType": "MemberExpression",
          "messageId": "inlineStyle",
          "endLine": 159,
          "endColumn": 32
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-text-secondary)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 216,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 216,
          "endColumn": 76
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[40vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 223,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 223,
          "endColumn": 57
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 235,
          "column": 33,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 235,
          "endColumn": 57
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 7,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useConversationWorkspace } from \"#/hooks/query/use-conversation-workspace\";\nimport React from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\nimport { useCreateConversation } from \"#/hooks/mutation/use-create-conversation\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport { useIsCreatingConversation } from \"#/hooks/use-is-creating-conversation\";\nimport {\n  useAddWorkspaces,\n  useAddWorkspaceParents,\n  useRemoveWorkspace,\n  useRemoveWorkspaceParent,\n} from \"#/hooks/mutation/use-local-workspaces-mutations\";\nimport { useLocalWorkspaces } from \"#/hooks/query/use-local-workspaces\";\nimport { useResolvedWorkspaces } from \"#/hooks/query/use-resolved-workspaces\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  dropdownMenuRowClassName,\n  dropdownMenuListClassName,\n  dropdownMenuRowIconWrapperClassName,\n} from \"#/utils/dropdown-classes\";\nimport { getWorkspacesUnsupportedMessage } from \"#/utils/workspaces-compatibility\";\nimport RepoIcon from \"#/icons/repo.svg?react\";\n\nimport { FolderBrowserModal } from \"#/components/features/home/workspace-dropdown/folder-browser-modal\";\nimport { ManageWorkspacesModal } from \"#/components/features/home/workspace-dropdown/manage-workspaces-modal\";\n\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\nimport { Divider } from \"#/ui/divider\";\nimport { NEW_CONVERSATION_DROPDOWN_SURFACE } from \"./new-conversation-dropdown-styles\";\nimport { usePopoverFixedPlacement } from \"#/hooks/use-popover-fixed-placement\";\n\nexport type LocalNewConversationMenuTriggerProps = {\n  onClick: () => void;\n  \"aria-expanded\": boolean;\n  \"aria-haspopup\": \"menu\";\n  disabled?: boolean;\n};\n\nexport interface LocalNewConversationMenuProps {\n  trigger: (props: LocalNewConversationMenuTriggerProps) => React.ReactNode;\n  /** Root wrapper class (e.g. `relative` + alignment in header row) */\n  className?: string;\n  /** Panel positioning / dimensions when using absolute placement (sidebar) */\n  popoverClassName: string;\n  /** Optional test id for the popover surface */\n  popoverTestId?: string;\n  /**\n   * Use `position: fixed` from the trigger rect so the menu is not clipped by\n   * sidebar overflow (conversation panel header).\n   */\n  useFixedPlacement?: boolean;\n}\n\n/**\n * Workspace/repo picker + launch flow for local agent-server backends.\n * Shared by the sidebar \"+ New conversation\" control and the conversation\n * panel \"new thread folder\" control.\n */\nexport function LocalNewConversationMenu({\n  trigger,\n  className,\n  popoverClassName,\n  popoverTestId = \"new-conversation-popover\",\n  useFixedPlacement = false,\n}: LocalNewConversationMenuProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { navigate } = useNavigation();\n\n  const [open, setOpen] = React.useState(false);\n  const popoverRef = React.useRef<HTMLDivElement>(null);\n  const triggerWrapRef = React.useRef<HTMLSpanElement>(null);\n  const fixedBox = usePopoverFixedPlacement(triggerWrapRef, {\n    open,\n    enabled: useFixedPlacement,\n  });\n\n  const { data: workspacesData, error: workspacesError } = useLocalWorkspaces();\n  const workspaceParents = workspacesData?.workspaceParents ?? [];\n  const { mutate: addWorkspaces } = useAddWorkspaces();\n  const { mutate: removeWorkspace } = useRemoveWorkspace();\n  const { mutate: addWorkspaceParents } = useAddWorkspaceParents();\n  const { mutate: removeWorkspaceParent } = useRemoveWorkspaceParent();\n  const { workspaces } = useResolvedWorkspaces();\n  const { isolated, unsupportedMessage: runtimeWorkspaceMessage } =\n    useConversationWorkspace();\n  const workspacesUnsupportedMessage =\n    runtimeWorkspaceMessage ??\n    getWorkspacesUnsupportedMessage(workspacesError, t);\n  const [browserOpen, setBrowserOpen] = React.useState(false);\n  const [manageOpen, setManageOpen] = React.useState(false);\n\n  const { mutate: createConversation, isPending } = useCreateConversation();\n  const isCreatingElsewhere = useIsCreatingConversation();\n  const isCreating = isPending || isCreatingElsewhere;\n\n  React.useEffect(() => {\n    if (!open || browserOpen || manageOpen) return undefined;\n    const onDown = (e: MouseEvent) => {\n      if (\n        popoverRef.current &&\n        !popoverRef.current.contains(e.target as Node)\n      ) {\n        setOpen(false);\n      }\n    };\n    document.addEventListener(\"mousedown\", onDown);\n    return () => document.removeEventListener(\"mousedown\", onDown);\n  }, [open, browserOpen, manageOpen]);\n\n  React.useEffect(() => {\n    if (!open || browserOpen || manageOpen) return undefined;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        setOpen(false);\n      }\n    };\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [open, browserOpen, manageOpen]);\n\n  const launch = (workingDir?: string) => {\n    if (isCreating || (workingDir && workspaceActionsDisabled)) return;\n    createConversation(\n      { workingDir, entryPoint: \"sidebar_local_menu\" },\n      {\n        onSuccess: (data) => {\n          setOpen(false);\n          navigate(`/conversations/${data.conversation_id}`);\n        },\n      },\n    );\n  };\n\n  const itemClass = dropdownMenuRowClassName;\n\n  const keepPopoverOpenOnMouseDown = React.useCallback(\n    (event: React.MouseEvent<HTMLButtonElement>) => {\n      event.preventDefault();\n      event.stopPropagation();\n    },\n    [],\n  );\n\n  const toggleOpen = React.useCallback(() => {\n    setOpen((o) => !o);\n  }, []);\n\n  const showPopover = open && (!useFixedPlacement || fixedBox !== null);\n  const workspaceActionsDisabled = Boolean(workspacesUnsupportedMessage);\n\n  const fixedStyle: React.CSSProperties | undefined =\n    useFixedPlacement && fixedBox\n      ? {\n          position: \"fixed\",\n          top: fixedBox.top,\n          left: fixedBox.left,\n          width: fixedBox.width,\n        }\n      : undefined;\n\n  const addWorkspacesButton = (\n    <button\n      type=\"button\"\n      data-testid=\"add-workspaces-button\"\n      disabled={workspaceActionsDisabled}\n      onMouseDown={keepPopoverOpenOnMouseDown}\n      onClick={(event) => {\n        event.preventDefault();\n        event.stopPropagation();\n        if (workspaceActionsDisabled) return;\n        setBrowserOpen(true);\n      }}\n      className={itemClass}\n    >\n      {t(I18nKey.HOME$ADD_WORKSPACES)}\n    </button>\n  );\n\n  const addWorkspacesControl = workspacesUnsupportedMessage ? (\n    <StyledTooltip content={workspacesUnsupportedMessage} placement=\"top\">\n      <span className=\"block\">{addWorkspacesButton}</span>\n    </StyledTooltip>\n  ) : (\n    addWorkspacesButton\n  );\n\n  return (\n    <div\n      className={cn(!useFixedPlacement && \"relative\", className)}\n      ref={popoverRef}\n    >\n      <span ref={triggerWrapRef} className=\"inline-flex\">\n        {trigger({\n          onClick: toggleOpen,\n          \"aria-expanded\": open,\n          \"aria-haspopup\": \"menu\",\n          disabled: isCreating,\n        })}\n      </span>\n\n      {showPopover && (\n        <div\n          data-testid={popoverTestId}\n          className={cn(\n            NEW_CONVERSATION_DROPDOWN_SURFACE,\n            !useFixedPlacement &&\n              cn(\"absolute top-full mt-0\", popoverClassName),\n          )}\n          style={fixedStyle}\n        >\n          {workspacesUnsupportedMessage && (\n            <p\n              role=\"status\"\n              className=\"px-3 py-2 text-xs text-[var(--oh-text-secondary)]\"\n            >\n              {workspacesUnsupportedMessage}\n            </p>\n          )}\n          <ul\n            className={cn(\n              \"max-h-[40vh] overflow-y-auto sm:max-h-70\",\n              dropdownMenuListClassName,\n            )}\n          >\n            <li>\n              <button\n                type=\"button\"\n                disabled={isCreating}\n                data-testid=\"launch-no-workspace\"\n                onClick={() => launch()}\n                className={itemClass}\n              >\n                <span className=\"text-[var(--oh-muted)]\">\n                  {t(\n                    isolated\n                      ? I18nKey.HOME$ISOLATED_WORKSPACE_NEW\n                      : I18nKey.HOME$NO_WORKSPACE_OPTION,\n                  )}\n                </span>\n              </button>\n            </li>\n            {workspaces.map((w) => (\n              <li key={w.id}>\n                <button\n                  type=\"button\"\n                  disabled={isCreating || workspaceActionsDisabled}\n                  title={workspacesUnsupportedMessage ?? undefined}\n                  data-testid=\"launch-workspace\"\n                  data-workspace-path={w.path}\n                  onClick={() => launch(w.path)}\n                  className={itemClass}\n                >\n                  <span\n                    className={dropdownMenuRowIconWrapperClassName}\n                    aria-hidden\n                  >\n                    <RepoIcon width={14} height={14} />\n                  </span>\n                  <span className=\"truncate\">{w.name}</span>\n                </button>\n              </li>\n            ))}\n          </ul>\n\n          <div\n            className={cn(\"flex flex-col\", dropdownMenuListClassName)}\n            data-testid=\"new-conversation-menu-footer\"\n          >\n            <Divider\n              inset=\"menu\"\n              testId=\"new-conversation-menu-footer-divider\"\n            />\n            {addWorkspacesControl}\n            {(workspaces.length > 0 || workspaceParents.length > 0) && (\n              <button\n                type=\"button\"\n                data-testid=\"manage-workspaces-button\"\n                onMouseDown={keepPopoverOpenOnMouseDown}\n                onClick={(event) => {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  setManageOpen(true);\n                }}\n                className={itemClass}\n              >\n                {t(I18nKey.HOME$MANAGE_WORKSPACES)}\n              </button>\n            )}\n          </div>\n        </div>\n      )}\n\n      <FolderBrowserModal\n        isOpen={browserOpen}\n        onClose={() => setBrowserOpen(false)}\n        onAdd={(items) => addWorkspaces(items)}\n        onAddParent={(items) => addWorkspaceParents(items)}\n      />\n\n      <ManageWorkspacesModal\n        isOpen={manageOpen}\n        workspaces={workspaces}\n        workspaceParents={workspaceParents}\n        onClose={() => setManageOpen(false)}\n        onRemove={(path) => removeWorkspace(path)}\n        onRemoveParent={(path) => removeWorkspaceParent(path)}\n      />\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/menu-heading.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 6,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 6,
          "endColumn": 65
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { cn } from \"#/utils/utils\";\n\nconst MENU_SECTION_HEADING_PADDING = \"px-2 pb-1 pt-1\";\nconst MENU_SECTION_HEADING_TEXT =\n  \"text-[11px] font-semibold uppercase tracking-wide text-muted\";\n\nexport function MenuHeading({\n  children,\n  suffix,\n}: {\n  children: React.ReactNode;\n  suffix?: React.ReactNode;\n}) {\n  if (suffix != null) {\n    return (\n      <div\n        role=\"presentation\"\n        className={cn(\n          \"flex items-baseline justify-between gap-2\",\n          MENU_SECTION_HEADING_PADDING,\n        )}\n      >\n        <span\n          className={cn(\n            \"min-w-0 truncate text-left\",\n            MENU_SECTION_HEADING_TEXT,\n          )}\n        >\n          {children}\n        </span>\n        {suffix}\n      </div>\n    );\n  }\n\n  return (\n    <div\n      role=\"presentation\"\n      className={cn(MENU_SECTION_HEADING_PADDING, MENU_SECTION_HEADING_TEXT)}\n    >\n      {children}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/menu-row.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 71,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 71,
          "endColumn": 69
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { Check } from \"lucide-react\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  dropdownMenuRowClassName,\n  dropdownMenuRowIconClassName,\n} from \"#/utils/dropdown-classes\";\nimport { ToggleSwitchVisual } from \"#/ui/toggle-switch\";\n\nexport function MenuRow({\n  icon: Icon,\n  label,\n  sublabel,\n  selected,\n  onClick,\n  testId,\n  disabled,\n  destructive,\n  variant = \"radio\",\n}: {\n  icon: React.ComponentType<{ className?: string; \"aria-hidden\"?: boolean }>;\n  label: string;\n  /** Muted second line under the label (e.g. a threshold hint). */\n  sublabel?: string;\n  selected?: boolean;\n  onClick: () => void;\n  testId?: string;\n  disabled?: boolean;\n  /** Destructive action rows (delete/reset) render in the danger color. */\n  destructive?: boolean;\n  /**\n   * \"radio\" rows mark selection with a checkmark (mutually exclusive\n   * groups); \"toggle\" rows render a switch pill for independent on/off\n   * preferences that stay put after clicking (modal-style menus).\n   */\n  variant?: \"radio\" | \"toggle\";\n}) {\n  const role =\n    selected === undefined\n      ? \"menuitem\"\n      : variant === \"toggle\"\n        ? \"menuitemcheckbox\"\n        : \"menuitemradio\";\n  return (\n    <button\n      type=\"button\"\n      role={role}\n      aria-checked={selected === undefined ? undefined : Boolean(selected)}\n      data-testid={testId}\n      disabled={disabled}\n      onClick={onClick}\n      className={cn(\n        \"group\",\n        dropdownMenuRowClassName,\n        \"disabled:opacity-50\",\n        destructive ? \"text-danger\" : \"text-foreground\",\n      )}\n    >\n      <Icon\n        className={cn(\n          \"h-3.5 w-3.5\",\n          dropdownMenuRowIconClassName,\n          destructive &&\n            \"text-danger group-hover:text-danger group-focus-visible:text-danger\",\n        )}\n        aria-hidden\n      />\n      <span className=\"min-w-0 flex-1\">\n        <span className=\"block truncate\">{label}</span>\n        {sublabel ? (\n          <span className=\"block truncate text-[10px] text-muted/70\">\n            {sublabel}\n          </span>\n        ) : null}\n      </span>\n      {selected === undefined ? null : variant === \"toggle\" ? (\n        <ToggleSwitchVisual\n          enabled={Boolean(selected)}\n          size=\"sm\"\n          className=\"ml-auto\"\n        />\n      ) : selected ? (\n        <Check\n          className=\"ml-auto h-3.5 w-3.5 shrink-0 text-contrast\"\n          aria-hidden\n        />\n      ) : null}\n    </button>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/plugins-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[80vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 40,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 40,
          "endColumn": 81
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  ModalBody,\n} from \"#/components/shared/modals/modal-body\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { BaseModalTitle } from \"#/components/shared/modals/confirmation-modals/base-modal\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { Typography } from \"#/ui/typography\";\nimport { useConversationPlugins } from \"#/hooks/use-conversation-plugins\";\nimport {\n  getPluginDisplayName,\n  getPluginSourceLabel,\n  isLocalPluginSource,\n  pluginReferenceKey,\n} from \"#/utils/plugin-display\";\nimport { cn } from \"#/utils/utils\";\n\ninterface PluginsModalProps {\n  onClose: () => void;\n}\n\n/**\n * Display-only view of the plugins loaded into the current conversation,\n * captured in client-side metadata at creation (explicitly attached plugins\n * plus the enabled installed plugins the SDK auto-loads). Mirrors\n * {@link SkillsModal}. The agent-server doesn't expose a live conversation's\n * loaded plugins, so this reads that client-side snapshot.\n */\nexport function PluginsModal({ onClose }: PluginsModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const plugins = useConversationPlugins();\n\n  return (\n    <ModalBackdrop onClose={onClose}>\n      <ModalBody\n        width=\"lg\"\n        className={cn(\n          \"relative flex max-h-[80vh] flex-col items-start border border-border\",\n          MODAL_MAX_WIDTH_VIEWPORT,\n        )}\n        testID=\"plugins-modal\"\n      >\n        <ModalCloseButton onClose={onClose} testId=\"close-plugins-modal\" />\n        <div className=\"flex w-full flex-col gap-2 pr-10\">\n          <BaseModalTitle title={t(I18nKey.PLUGINS_MODAL$TITLE)} />\n          <Typography.Text className=\"text-sm text-muted\">\n            {t(I18nKey.PLUGINS_MODAL$DESCRIPTION)}\n          </Typography.Text>\n        </div>\n\n        <div className=\"w-full overflow-auto rounded-md border border-border bg-surface-raised custom-scrollbar-always\">\n          {plugins.length === 0 ? (\n            <div className=\"flex items-center justify-center p-6\">\n              <Typography.Text className=\"text-muted\">\n                {t(I18nKey.PLUGINS_MODAL$EMPTY)}\n              </Typography.Text>\n            </div>\n          ) : (\n            <ul className=\"divide-y divide-border\">\n              {plugins.map((plugin) => (\n                <li\n                  key={pluginReferenceKey(plugin)}\n                  data-testid={`active-plugin-${getPluginDisplayName(plugin)}`}\n                  className=\"flex flex-col gap-1 p-4\"\n                >\n                  <Typography.Text className=\"font-semibold text-contrast\">\n                    {getPluginDisplayName(plugin)}\n                  </Typography.Text>\n                  <Typography.Text className=\"text-xs text-tertiary-alt\">\n                    {isLocalPluginSource(plugin)\n                      ? t(I18nKey.PLUGINS_MODAL$SOURCE_LOCAL)\n                      : getPluginSourceLabel(plugin)}\n                  </Typography.Text>\n                </li>\n              ))}\n            </ul>\n          )}\n        </div>\n      </ModalBody>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/skill-item.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 20,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 20,
          "endColumn": 149
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { ChevronDown, ChevronRight } from \"lucide-react\";\nimport { Typography } from \"#/ui/typography\";\nimport { SkillTriggers } from \"./skill-triggers\";\nimport { SkillContent } from \"./skill-content\";\nimport { SkillInfo } from \"#/types/settings\";\n\ninterface SkillItemProps {\n  skill: SkillInfo & { content?: string };\n  isExpanded: boolean;\n  onToggle: (agentName: string) => void;\n}\n\nconst SKILL_TYPE_LABEL: Record<SkillInfo[\"type\"], string> = {\n  knowledge: \"Knowledge\",\n  repo: \"Repository\",\n  agentskills: \"AgentSkills\",\n};\n\nconst SKILL_PILL_CLASS =\n  \"inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-4 border border-border bg-surface text-tertiary-light\";\n\nexport function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) {\n  return (\n    <div>\n      <button\n        type=\"button\"\n        onClick={() => onToggle(skill.name)}\n        className=\"w-full py-3 px-3 text-left flex items-center justify-between hover:bg-tertiary transition-colors\"\n      >\n        <div className=\"flex items-center\">\n          <Typography.Text className=\"font-bold text-content-2\">\n            {skill.name}\n          </Typography.Text>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <span className={SKILL_PILL_CLASS}>\n            {SKILL_TYPE_LABEL[skill.type]}\n          </span>\n          <Typography.Text className=\"text-text-tertiary\">\n            {isExpanded ? (\n              <ChevronDown size={18} />\n            ) : (\n              <ChevronRight size={18} />\n            )}\n          </Typography.Text>\n        </div>\n      </button>\n\n      {isExpanded && (\n        <div className=\"px-3 pb-3 pt-1 border-t border-border\">\n          <SkillTriggers triggers={skill.triggers ?? []} />\n          <SkillContent content={skill.content ?? \"\"} />\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/skill-triggers.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 25,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 25,
          "endColumn": 160
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { Typography } from \"#/ui/typography\";\n\ninterface SkillTriggersProps {\n  triggers: string[];\n}\n\nexport function SkillTriggers({ triggers }: SkillTriggersProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  if (!triggers || triggers.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className=\"mt-2 mb-3\">\n      <Typography.Text className=\"text-sm font-semibold text-text-tertiary mb-2\">\n        {t(I18nKey.COMMON$TRIGGERS)}\n      </Typography.Text>\n      <div className=\"mt-2 flex flex-wrap gap-1\">\n        {triggers.map((trigger) => (\n          <span\n            key={trigger}\n            className=\"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-4 border border-border bg-surface text-tertiary-light\"\n          >\n            {trigger}\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/skills-modal-section.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"tracking-[0.01em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 19,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 19,
          "endColumn": 97
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 22,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 22,
          "endColumn": 173
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import type { ReactNode } from \"react\";\nimport { Typography } from \"#/ui/typography\";\n\ninterface SkillsModalSectionProps {\n  title: string;\n  count: number;\n  children: ReactNode;\n}\n\nexport function SkillsModalSection({\n  title,\n  count,\n  children,\n}: SkillsModalSectionProps) {\n  return (\n    <section className=\"w-full\">\n      <div className=\"sticky top-0 z-10 border-b border-border bg-surface-raised px-3 py-2\">\n        <div className=\"flex items-center justify-between gap-2\">\n          <Typography.Text className=\"text-xs font-medium tracking-[0.01em] text-tertiary-light\">\n            {title}\n          </Typography.Text>\n          <span className=\"inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-4 border border-border bg-surface text-tertiary-light\">\n            {count}\n          </span>\n        </div>\n      </div>\n      <div className=\"divide-y divide-border\">{children}</div>\n    </section>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/skills-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[80vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 79,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 79,
          "endColumn": 81
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"h-[60vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 91,
          "column": 24,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 91,
          "endColumn": 129
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  ModalBody,\n} from \"#/components/shared/modals/modal-body\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { getAgentServerWorkingDir } from \"#/api/agent-server-config\";\nimport { useConversationSkills } from \"#/hooks/query/use-conversation-skills\";\nimport { useActiveConversation } from \"#/hooks/query/use-active-conversation\";\nimport { useSkillEnabledFilter } from \"#/hooks/use-skill-enablement\";\nimport {\n  groupSkillsByScope,\n  SKILL_SCOPE_ORDER,\n  type SkillScope,\n} from \"#/utils/skill-scope\";\nimport { cn } from \"#/utils/utils\";\nimport { SkillsModalHeader } from \"./skills-modal-header\";\nimport { SkillsModalSection } from \"./skills-modal-section\";\nimport { SkillsLoadingState } from \"./skills-loading-state\";\nimport { SkillsEmptyState } from \"./skills-empty-state\";\nimport { SkillItem } from \"./skill-item\";\n\ninterface SkillsModalProps {\n  onClose: () => void;\n}\n\nconst SECTION_TITLE_KEY: Record<SkillScope, I18nKey> = {\n  project: I18nKey.SKILLS_MODAL$SECTION_PROJECT,\n  personal: I18nKey.SKILLS_MODAL$SECTION_USER,\n  public: I18nKey.SKILLS_MODAL$SECTION_PUBLIC,\n};\n\nexport function SkillsModal({ onClose }: SkillsModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const projectDir = getAgentServerWorkingDir();\n  const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(\n    {},\n  );\n  // Scope the catalog to this conversation's attached workspace so the listed\n  // skills match the project skills actually loaded into the conversation.\n  const {\n    data: skills,\n    isLoading,\n    isError,\n    refetch,\n    isRefetching,\n  } = useConversationSkills();\n  const { data: conversation } = useActiveConversation();\n  const isSkillEnabled = useSkillEnabledFilter(\n    conversation?.launched_agent_profile?.agent_profile_id,\n  );\n\n  // The modal reports what the conversation actually has, so it lists the\n  // enabled set rather than the whole catalog.\n  const visibleSkills = useMemo(\n    () => (skills ?? []).filter(isSkillEnabled),\n    [skills, isSkillEnabled],\n  );\n\n  const groupedSkills = useMemo(\n    () => groupSkillsByScope(visibleSkills, projectDir),\n    [visibleSkills, projectDir],\n  );\n\n  const toggleAgent = (agentName: string) => {\n    setExpandedAgents((prev) => ({\n      ...prev,\n      [agentName]: !prev[agentName],\n    }));\n  };\n\n  return (\n    <ModalBackdrop onClose={onClose}>\n      <ModalBody\n        width=\"lg\"\n        className={cn(\n          \"relative max-h-[80vh] flex flex-col items-start border border-border\",\n          MODAL_MAX_WIDTH_VIEWPORT,\n        )}\n        testID=\"skills-modal\"\n      >\n        <SkillsModalHeader\n          isLoading={isLoading}\n          isRefetching={isRefetching}\n          onRefresh={refetch}\n          onClose={onClose}\n        />\n\n        <div className=\"w-full h-[60vh] overflow-auto rounded-md border border-border bg-surface-raised custom-scrollbar-always\">\n          {isLoading ? (\n            <SkillsLoadingState />\n          ) : isError || !skills || visibleSkills.length === 0 ? (\n            <SkillsEmptyState isError={isError} />\n          ) : (\n            groupedSkills && (\n              <div className=\"divide-y divide-border\">\n                {SKILL_SCOPE_ORDER.map((scope) => {\n                  const scopedSkills = groupedSkills[scope];\n                  if (scopedSkills.length === 0) {\n                    return null;\n                  }\n\n                  return (\n                    <SkillsModalSection\n                      key={scope}\n                      title={t(SECTION_TITLE_KEY[scope])}\n                      count={scopedSkills.length}\n                    >\n                      {scopedSkills.map((skill) => {\n                        const isExpanded = expandedAgents[skill.name] || false;\n\n                        return (\n                          <SkillItem\n                            key={`${scope}-${skill.name}`}\n                            skill={skill}\n                            isExpanded={isExpanded}\n                            onToggle={toggleAgent}\n                          />\n                        );\n                      })}\n                    </SkillsModalSection>\n                  );\n                })}\n              </div>\n            )\n          )}\n        </div>\n      </ModalBody>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/start-task-card/start-task-status-badge.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"bg-green-500/10\" uses the raw Tailwind palette. Nearest theme tokens: bg-status-success/10. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 28,
          "column": 16,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 28,
          "endColumn": 68,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-status-success/10"
              },
              "fix": {
                "range": [
                  929,
                  981
                ],
                "text": "\"bg-status-success/10 text-green-400 border-green-500/20\""
              },
              "desc": "Replace with \"bg-status-success/10\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"text-green-400\" uses the raw Tailwind palette. Nearest theme tokens: text-markdown-alert-tip, text-status-success. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 28,
          "column": 16,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 28,
          "endColumn": 68,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-markdown-alert-tip"
              },
              "fix": {
                "range": [
                  929,
                  981
                ],
                "text": "\"bg-green-500/10 text-markdown-alert-tip border-green-500/20\""
              },
              "desc": "Replace with \"text-markdown-alert-tip\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-status-success"
              },
              "fix": {
                "range": [
                  929,
                  981
                ],
                "text": "\"bg-green-500/10 text-status-success border-green-500/20\""
              },
              "desc": "Replace with \"text-status-success\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"border-green-500/20\" uses the raw Tailwind palette. Nearest theme tokens: border-status-success/20. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 28,
          "column": 16,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 28,
          "endColumn": 68,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-status-success/20"
              },
              "fix": {
                "range": [
                  929,
                  981
                ],
                "text": "\"bg-green-500/10 text-green-400 border-status-success/20\""
              },
              "desc": "Replace with \"border-status-success/20\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"bg-red-500/10\" uses the raw Tailwind palette. Nearest theme tokens: bg-status-error/10, bg-danger/10. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 30,
          "column": 16,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 30,
          "endColumn": 62,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-status-error/10"
              },
              "fix": {
                "range": [
                  1018,
                  1064
                ],
                "text": "\"bg-status-error/10 text-red-400 border-red-500/20\""
              },
              "desc": "Replace with \"bg-status-error/10\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-danger/10"
              },
              "fix": {
                "range": [
                  1018,
                  1064
                ],
                "text": "\"bg-danger/10 text-red-400 border-red-500/20\""
              },
              "desc": "Replace with \"bg-danger/10\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"text-red-400\" uses the raw Tailwind palette. Nearest theme tokens: text-status-error, text-danger. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 30,
          "column": 16,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 30,
          "endColumn": 62,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-status-error"
              },
              "fix": {
                "range": [
                  1018,
                  1064
                ],
                "text": "\"bg-red-500/10 text-status-error border-red-500/20\""
              },
              "desc": "Replace with \"text-status-error\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-danger"
              },
              "fix": {
                "range": [
                  1018,
                  1064
                ],
                "text": "\"bg-red-500/10 text-danger border-red-500/20\""
              },
              "desc": "Replace with \"text-danger\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"border-red-500/20\" uses the raw Tailwind palette. Nearest theme tokens: border-status-error/20, border-danger/20. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 30,
          "column": 16,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 30,
          "endColumn": 62,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-status-error/20"
              },
              "fix": {
                "range": [
                  1018,
                  1064
                ],
                "text": "\"bg-red-500/10 text-red-400 border-status-error/20\""
              },
              "desc": "Replace with \"border-status-error/20\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-danger/20"
              },
              "fix": {
                "range": [
                  1018,
                  1064
                ],
                "text": "\"bg-red-500/10 text-red-400 border-danger/20\""
              },
              "desc": "Replace with \"border-danger/20\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"bg-yellow-500/10\" uses the raw Tailwind palette. Nearest theme tokens: bg-logo/10, bg-accent/10. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 32,
          "column": 16,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 32,
          "endColumn": 71,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-logo/10"
              },
              "fix": {
                "range": [
                  1096,
                  1151
                ],
                "text": "\"bg-logo/10 text-yellow-400 border-yellow-500/20\""
              },
              "desc": "Replace with \"bg-logo/10\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-accent/10"
              },
              "fix": {
                "range": [
                  1096,
                  1151
                ],
                "text": "\"bg-accent/10 text-yellow-400 border-yellow-500/20\""
              },
              "desc": "Replace with \"bg-accent/10\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"text-yellow-400\" uses the raw Tailwind palette. Nearest theme tokens: text-markdown-alert-warning, text-logo. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 32,
          "column": 16,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 32,
          "endColumn": 71,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-markdown-alert-warning"
              },
              "fix": {
                "range": [
                  1096,
                  1151
                ],
                "text": "\"bg-yellow-500/10 text-markdown-alert-warning border-yellow-500/20\""
              },
              "desc": "Replace with \"text-markdown-alert-warning\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-logo"
              },
              "fix": {
                "range": [
                  1096,
                  1151
                ],
                "text": "\"bg-yellow-500/10 text-logo border-yellow-500/20\""
              },
              "desc": "Replace with \"text-logo\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"border-yellow-500/20\" uses the raw Tailwind palette. Nearest theme tokens: border-logo/20, border-accent/20. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 32,
          "column": 16,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 32,
          "endColumn": 71,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-logo/20"
              },
              "fix": {
                "range": [
                  1096,
                  1151
                ],
                "text": "\"bg-yellow-500/10 text-yellow-400 border-logo/20\""
              },
              "desc": "Replace with \"border-logo/20\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-accent/20"
              },
              "fix": {
                "range": [
                  1096,
                  1151
                ],
                "text": "\"bg-yellow-500/10 text-yellow-400 border-accent/20\""
              },
              "desc": "Replace with \"border-accent/20\"."
            }
          ]
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 9,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport type { AppConversationStartTaskStatus } from \"#/api/conversation-service/agent-server-conversation-service.types\";\nimport { cn } from \"#/utils/utils\";\nimport { getTaskStatusI18nKey } from \"#/utils/status\";\n\ninterface StartTaskStatusBadgeProps {\n  taskStatus: AppConversationStartTaskStatus;\n}\n\nexport function StartTaskStatusBadge({\n  taskStatus,\n}: StartTaskStatusBadgeProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  // Don't show badge for WORKING status (most common, clutters UI)\n  if (taskStatus === \"WORKING\") {\n    return null;\n  }\n\n  // Localized status label \u2014 getTaskStatusI18nKey maps every status (including\n  // the terminal READY/ERROR states) to its localized key.\n  const getStatusLabel = () => t(getTaskStatusI18nKey(taskStatus));\n\n  // Get status color\n  const getStatusStyle = () => {\n    switch (taskStatus) {\n      case \"READY\":\n        return \"bg-green-500/10 text-green-400 border-green-500/20\";\n      case \"ERROR\":\n        return \"bg-red-500/10 text-red-400 border-red-500/20\";\n      default:\n        return \"bg-yellow-500/10 text-yellow-400 border-yellow-500/20\";\n    }\n  };\n\n  return (\n    <span\n      className={cn(\n        \"text-xs font-medium px-2 py-0.5 rounded border flex-shrink-0\",\n        getStatusStyle(),\n      )}\n    >\n      {getStatusLabel()}\n    </span>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/start-task-card/start-task-status-indicator.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"bg-green-500\" uses the raw Tailwind palette. Nearest theme tokens: bg-status-success. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 14,
          "column": 16,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 14,
          "endColumn": 30,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-status-success"
              },
              "fix": {
                "range": [
                  437,
                  451
                ],
                "text": "\"bg-status-success\""
              },
              "desc": "Replace with \"bg-status-success\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"bg-red-500\" uses the raw Tailwind palette. Nearest theme tokens: bg-status-error, bg-danger. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 16,
          "column": 16,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 16,
          "endColumn": 28,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-status-error"
              },
              "fix": {
                "range": [
                  488,
                  500
                ],
                "text": "\"bg-status-error\""
              },
              "desc": "Replace with \"bg-status-error\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-danger"
              },
              "fix": {
                "range": [
                  488,
                  500
                ],
                "text": "\"bg-danger\""
              },
              "desc": "Replace with \"bg-danger\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-raw-colors",
          "severity": 1,
          "message": "\"bg-yellow-500\" uses the raw Tailwind palette. Nearest theme tokens: bg-logo, bg-accent. Use one of those, or declare --color-<name> in src/styles.css for a new color.",
          "line": 24,
          "column": 16,
          "nodeType": "Literal",
          "messageId": "paletteClassNear",
          "endLine": 24,
          "endColumn": 45,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-logo"
              },
              "fix": {
                "range": [
                  746,
                  775
                ],
                "text": "\"bg-logo animate-pulse\""
              },
              "desc": "Replace with \"bg-logo\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-accent"
              },
              "fix": {
                "range": [
                  746,
                  775
                ],
                "text": "\"bg-accent animate-pulse\""
              },
              "desc": "Replace with \"bg-accent\"."
            }
          ]
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import type { AppConversationStartTaskStatus } from \"#/api/conversation-service/agent-server-conversation-service.types\";\nimport { cn } from \"#/utils/utils\";\n\ninterface StartTaskStatusIndicatorProps {\n  taskStatus: AppConversationStartTaskStatus;\n}\n\nexport function StartTaskStatusIndicator({\n  taskStatus,\n}: StartTaskStatusIndicatorProps) {\n  const getStatusColor = () => {\n    switch (taskStatus) {\n      case \"READY\":\n        return \"bg-green-500\";\n      case \"ERROR\":\n        return \"bg-red-500\";\n      case \"WORKING\":\n      case \"WAITING_FOR_SANDBOX\":\n      case \"PREPARING_REPOSITORY\":\n      case \"RUNNING_SETUP_SCRIPT\":\n      case \"SETTING_UP_GIT_HOOKS\":\n      case \"SETTING_UP_SKILLS\":\n      case \"STARTING_CONVERSATION\":\n        return \"bg-yellow-500 animate-pulse\";\n      default:\n        return \"bg-interactive-selected\";\n    }\n  };\n\n  return (\n    <div\n      className={cn(\"w-2 h-2 rounded-full flex-shrink-0\", getStatusColor())}\n      aria-label={`Task status: ${taskStatus}`}\n    />\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation-panel/system-message-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[80vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 46,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 46,
          "endColumn": 83
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"h-[60vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 66,
          "column": 28,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 66,
          "endColumn": 126
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useState } from \"react\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  ModalBody,\n} from \"#/components/shared/modals/modal-body\";\nimport { SystemMessageHeader } from \"./system-message-modal/system-message-header\";\nimport { TabNavigation } from \"./system-message-modal/tab-navigation\";\nimport { TabContent } from \"./system-message-modal/tab-content\";\nimport { SystemMessageForModal } from \"#/utils/system-message-adapter\";\nimport { cn } from \"#/utils/utils\";\n\ninterface SystemMessageModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  systemMessage: SystemMessageForModal | null;\n}\n\nexport function SystemMessageModal({\n  isOpen,\n  onClose,\n  systemMessage,\n}: SystemMessageModalProps) {\n  const [activeTab, setActiveTab] = useState<\"system\" | \"tools\">(\"system\");\n  const [expandedTools, setExpandedTools] = useState<Record<number, boolean>>(\n    {},\n  );\n\n  if (!systemMessage) {\n    return null;\n  }\n\n  const toggleTool = (index: number) => {\n    setExpandedTools((prev) => ({\n      ...prev,\n      [index]: !prev[index],\n    }));\n  };\n\n  return (\n    isOpen && (\n      <ModalBackdrop onClose={onClose}>\n        <ModalBody\n          width=\"lg\"\n          className={cn(\n            \"relative max-h-[80vh] flex flex-col items-start border border-border\",\n            MODAL_MAX_WIDTH_VIEWPORT,\n          )}\n          testID=\"system-message-modal\"\n        >\n          <SystemMessageHeader\n            agentClass={systemMessage.agent_class}\n            openhandsVersion={systemMessage.openhands_version}\n            onClose={onClose}\n          />\n\n          <div className=\"w-full\">\n            <TabNavigation\n              activeTab={activeTab}\n              onTabChange={setActiveTab}\n              hasTools={\n                !!(systemMessage.tools && systemMessage.tools.length > 0)\n              }\n            />\n\n            <div className=\"h-[60vh] overflow-auto rounded-md border border-border bg-surface-raised custom-scrollbar-always\">\n              <TabContent\n                activeTab={activeTab}\n                systemMessage={systemMessage}\n                expandedTools={expandedTools}\n                onToggleTool={toggleTool}\n              />\n            </div>\n          </div>\n        </ModalBody>\n      </ModalBackdrop>\n    )\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/conversation-git-actions-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 110,
          "column": 14,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 110,
          "endColumn": 25
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-w-[8rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 111,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 111,
          "endColumn": 37
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { useLayoutEffect, useState } from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { GitCommitHorizontal } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport ArrowDownIcon from \"#/icons/u-arrow-down.svg?react\";\nimport ArrowUpIcon from \"#/icons/u-arrow-up.svg?react\";\nimport CodeBranchIcon from \"#/icons/u-code-branch.svg?react\";\nimport PrIcon from \"#/icons/u-pr.svg?react\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { ContextMenuListItem } from \"#/components/features/context-menu/context-menu-list-item\";\nimport { ToolsContextMenuIconText } from \"#/components/features/controls/tools-context-menu-icon-text\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport {\n  getCreateNewBranchPrompt,\n  getCreatePRPrompt,\n  getGitCommitPrompt,\n  getGitPullPrompt,\n  getGitPushPrompt,\n} from \"#/utils/utils\";\nimport { Provider } from \"#/types/settings\";\n\nconst GIT_MENU_ITEM_CLASSNAME = \"!w-auto whitespace-nowrap\";\n\ninterface ConversationGitActionsMenuProps {\n  anchorRef: React.RefObject<HTMLButtonElement | null>;\n  onClose: () => void;\n  gitProvider: Provider;\n  /** Prefix for menu/item `data-testid` values (suffixes: -menu, -commit, \u2026). */\n  testIdPrefix?: string;\n}\n\n/**\n * Portaled dropdown of agent-prompt git actions (commit, pull, push, PR, branch).\n * Anchored under a trigger; shared by the overview diffs row and header toggle.\n */\nexport function ConversationGitActionsMenu({\n  anchorRef,\n  onClose,\n  gitProvider,\n  testIdPrefix = \"conversation-git-actions\",\n}: ConversationGitActionsMenuProps) {\n  const { t } = useTranslation(\"openhands\");\n  const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);\n  const setMessageToSend = useConversationStore(\n    (state) => state.setMessageToSend,\n  );\n  const [portalStyle, setPortalStyle] = useState<React.CSSProperties>();\n\n  useLayoutEffect(() => {\n    const anchor = anchorRef.current;\n    if (!anchor) {\n      return undefined;\n    }\n\n    const updatePosition = () => {\n      const rect = anchor.getBoundingClientRect();\n      setPortalStyle({\n        position: \"fixed\",\n        top: rect.bottom + 4,\n        right: window.innerWidth - rect.right,\n        zIndex: 50,\n      });\n    };\n\n    updatePosition();\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [anchorRef]);\n\n  const handleCommit = () => {\n    setMessageToSend(getGitCommitPrompt());\n    onClose();\n  };\n\n  const handlePull = () => {\n    setMessageToSend(getGitPullPrompt());\n    onClose();\n  };\n\n  const handlePush = () => {\n    setMessageToSend(getGitPushPrompt(gitProvider));\n    onClose();\n  };\n\n  const handleCreatePr = () => {\n    setMessageToSend(getCreatePRPrompt(gitProvider));\n    onClose();\n  };\n\n  const handleCreateNewBranch = () => {\n    setMessageToSend(getCreateNewBranchPrompt());\n    onClose();\n  };\n\n  if (!portalStyle) {\n    return null;\n  }\n\n  return ReactDOM.createPortal(\n    <ContextMenu\n      ref={menuRef}\n      testId={`${testIdPrefix}-menu`}\n      theme=\"popover\"\n      style={portalStyle}\n      className=\"w-max min-w-[8rem]\"\n    >\n      <ContextMenuListItem\n        testId={`${testIdPrefix}-commit`}\n        onClick={handleCommit}\n        className={GIT_MENU_ITEM_CLASSNAME}\n      >\n        <ToolsContextMenuIconText\n          icon={<GitCommitHorizontal className=\"size-4\" aria-hidden />}\n          text={t(I18nKey.DIFF_VIEWER$COMMITS)}\n        />\n      </ContextMenuListItem>\n      <ContextMenuListItem\n        testId={`${testIdPrefix}-pull`}\n        onClick={handlePull}\n        className={GIT_MENU_ITEM_CLASSNAME}\n      >\n        <ToolsContextMenuIconText\n          icon={<ArrowDownIcon width={16} height={16} aria-hidden />}\n          text={t(I18nKey.COMMON$PULL)}\n        />\n      </ContextMenuListItem>\n      <ContextMenuListItem\n        testId={`${testIdPrefix}-push`}\n        onClick={handlePush}\n        className={GIT_MENU_ITEM_CLASSNAME}\n      >\n        <ToolsContextMenuIconText\n          icon={<ArrowUpIcon width={16} height={16} aria-hidden />}\n          text={t(I18nKey.COMMON$PUSH)}\n        />\n      </ContextMenuListItem>\n      <ContextMenuListItem\n        testId={`${testIdPrefix}-create-pr`}\n        onClick={handleCreatePr}\n        className={GIT_MENU_ITEM_CLASSNAME}\n      >\n        <ToolsContextMenuIconText\n          icon={<PrIcon width={16} height={16} aria-hidden />}\n          text={t(I18nKey.COMMON$CREATE_PR)}\n        />\n      </ContextMenuListItem>\n      <ContextMenuListItem\n        testId={`${testIdPrefix}-create-new-branch`}\n        onClick={handleCreateNewBranch}\n        className={GIT_MENU_ITEM_CLASSNAME}\n      >\n        <ToolsContextMenuIconText\n          icon={<CodeBranchIcon width={16} height={16} aria-hidden />}\n          text={t(I18nKey.COMMON$CREATE_NEW_BRANCH)}\n        />\n      </ContextMenuListItem>\n    </ContextMenu>,\n    document.body,\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/conversation-main/conversation-main.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[scrollbar-gutter:stable]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 61,
          "column": 20,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 61,
          "endColumn": 180
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[800px]\" hardcodes an off-token value. Use \"max-w-200\" instead (same value, on the scale).",
          "line": 62,
          "column": 22,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 62,
          "endColumn": 76,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "max-w-200"
              },
              "fix": {
                "range": [
                  2814,
                  2868
                ],
                "text": "\"relative mx-auto h-full w-full min-w-0 max-w-200\""
              },
              "desc": "Replace with \"max-w-200\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[15px]\" hardcodes an off-token value. Nearest on the scale: rounded-2xl (16px), rounded-xl (12px).",
          "line": 66,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 66,
          "endColumn": 140
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"shadow-[0_12px_40px_rgba(0,0,0,0.55)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 66,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 66,
          "endColumn": 140
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 166,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 166,
          "endColumn": 78
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 191,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 191,
          "endColumn": 102
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 209,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 209,
          "endColumn": 55
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets transitionProperty. Style through classes; use CSS custom properties for dynamic values.",
          "line": 267,
          "column": 37,
          "nodeType": "ConditionalExpression",
          "messageId": "inlineStyle",
          "endLine": 267,
          "endColumn": 64
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"transition-[width]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 286,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 286,
          "endColumn": 66
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 294,
          "column": 21,
          "nodeType": "ConditionalExpression",
          "messageId": "inlineStyle",
          "endLine": 298,
          "endColumn": 33
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets transitionProperty. Style through classes; use CSS custom properties for dynamic values.",
          "line": 300,
          "column": 21,
          "nodeType": "ConditionalExpression",
          "messageId": "inlineStyle",
          "endLine": 300,
          "endColumn": 75
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 377,
          "column": 22,
          "nodeType": "ConditionalExpression",
          "messageId": "inlineStyle",
          "endLine": 381,
          "endColumn": 23
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets transitionProperty. Style through classes; use CSS custom properties for dynamic values.",
          "line": 382,
          "column": 35,
          "nodeType": "ConditionalExpression",
          "messageId": "inlineStyle",
          "endLine": 382,
          "endColumn": 76
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 13,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useEffect, useMemo, useState } from \"react\";\nimport { FileText, MessageSquare } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { cn } from \"#/utils/utils\";\nimport { ChatActionTooltip } from \"#/components/features/chat/chat-action-tooltip\";\nimport BlockDrawerLeftIcon from \"#/icons/block-drawer-left.svg?react\";\nimport { mobileTopBarIconButtonClassName } from \"#/utils/mobile-top-bar-icon-button-classes\";\nimport { ChatInterfaceWrapper } from \"./chat-interface-wrapper\";\nimport { ConversationTabContent } from \"../conversation-tabs/conversation-tab-content/conversation-tab-content\";\nimport { ConversationNameWithStatus } from \"../conversation-name-with-status\";\nimport { ConversationTabs } from \"../conversation-tabs/conversation-tabs\";\nimport { ResizeHandle } from \"../../../ui/resize-handle\";\nimport { useResizablePanels } from \"#/hooks/use-resizable-panels\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport { AUTOMATION_SETUP_SHOW_AGENT_EVENT } from \"#/components/features/automations/setup/automation-setup-agent-request\";\nimport { AutomationSetupPanel } from \"#/components/features/automations/setup/automation-setup-panel\";\nimport { consumeAutomationSetupHandoff } from \"#/api/automation-setup-handoff-store\";\nimport type { AutomationSetupDraft } from \"#/api/automation-setup-types\";\nimport { useConversationId } from \"#/hooks/use-conversation-id\";\nimport {\n  useBreakpoint,\n  SIDEBAR_RAIL_COLLAPSE_MAX_WIDTH,\n} from \"#/hooks/use-breakpoint\";\nimport { SidebarMobileMenuToggle } from \"#/components/features/sidebar/sidebar-mobile-menu-toggle\";\nimport { ConversationOverviewDrawer } from \"../conversation-overview-drawer\";\nimport { useConversationOverviewDrawerOptional } from \"../conversation-overview-drawer-context\";\nimport { useActiveConversation } from \"#/hooks/query/use-active-conversation\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { hasAutomationSetupModeTag } from \"#/utils/automation-draft-tags\";\n\nconst BLANK_AUTOMATION_SETUP_DRAFT: AutomationSetupDraft = {\n  prompt: \"\",\n  kind: \"prompt\",\n};\n\ntype MobileAutomationView = \"form\" | \"conversation\";\n\nconst MOBILE_AUTOMATION_VIEW_OPTIONS: {\n  view: MobileAutomationView;\n  labelKey: I18nKey;\n  Icon: typeof FileText;\n}[] = [\n  {\n    view: \"form\",\n    labelKey: I18nKey.AUTOMATION_SETUP$VIEW_FORM,\n    Icon: FileText,\n  },\n  {\n    view: \"conversation\",\n    labelKey: I18nKey.AUTOMATION_SETUP$VIEW_CONVERSATION,\n    Icon: MessageSquare,\n  },\n];\n\nfunction AutomationSetupDockedComposer({\n  onTarget,\n}: {\n  onTarget: (element: HTMLDivElement | null) => void;\n}) {\n  return (\n    <div className=\"custom-scrollbar-always pointer-events-none absolute inset-0 z-20 overflow-y-scroll px-5 [scrollbar-gutter:stable] [&::-webkit-scrollbar-thumb]:bg-transparent\">\n      <div className=\"relative mx-auto h-full w-full min-w-0 max-w-[800px]\">\n        <div\n          ref={onTarget}\n          data-testid=\"automation-setup-docked-composer\"\n          className=\"pointer-events-auto absolute inset-x-0 bottom-5 overflow-visible rounded-[15px] shadow-[0_12px_40px_rgba(0,0,0,0.55)]\"\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction getDesktopTabPanelClass(isRightPanelShown: boolean) {\n  return isRightPanelShown\n    ? \"translate-x-0 opacity-100\"\n    : \"w-0 translate-x-full opacity-0\";\n}\n\nexport function ConversationMain() {\n  const { t } = useTranslation(\"openhands\");\n  const { conversationId } = useConversationId();\n  const { data: conversation } = useActiveConversation();\n  const isMobile = useBreakpoint();\n  const isSidebarRailHidden = useBreakpoint(SIDEBAR_RAIL_COLLAPSE_MAX_WIDTH);\n  const { isRightPanelShown, setHasRightPanelToggled, setIsRightPanelShown } =\n    useConversationStore();\n  // Derived per conversation during render: holding it in state reset by an\n  // effect let the first render after a conversation switch reuse the previous\n  // conversation's handoff, mounting the setup panel (which tags the\n  // conversation as an automation setup) on the wrong conversation.\n  const isAutomationSetupModeHandoff = useMemo(\n    () => consumeAutomationSetupHandoff(conversationId),\n    [conversationId],\n  );\n  const [automationToolbarElement, setAutomationToolbarElement] =\n    useState<HTMLDivElement | null>(null);\n  const [isAutomationAgentHidden, setIsAutomationAgentHidden] = useState(false);\n  const [mobileAutomationView, setMobileAutomationView] =\n    useState<MobileAutomationView>(\"form\");\n  const [composerDockTarget, setComposerDockTarget] =\n    useState<HTMLDivElement | null>(null);\n  const overviewDrawer = useConversationOverviewDrawerOptional();\n  const isSecondaryDrawerOpen = Boolean(overviewDrawer?.section);\n  const hasTaggedAutomationSetupMode = hasAutomationSetupModeTag(\n    conversation?.tags,\n  );\n  const isAutomationSetupMode =\n    isAutomationSetupModeHandoff || hasTaggedAutomationSetupMode;\n  const automationSetupDraft = isAutomationSetupMode\n    ? BLANK_AUTOMATION_SETUP_DRAFT\n    : null;\n  const showMobileAutomationForm =\n    isMobile && isAutomationSetupMode && mobileAutomationView === \"form\";\n\n  const { leftWidth, rightWidth, isDragging, containerRef, handleMouseDown } =\n    useResizablePanels({\n      defaultLeftWidth: 50,\n      minLeftWidth: 30,\n      maxLeftWidth: 80,\n      storageKey: \"desktop-layout-panel-width\",\n    });\n\n  useEffect(() => {\n    if (!automationSetupDraft) return;\n    setHasRightPanelToggled(true);\n    setIsRightPanelShown(true);\n  }, [automationSetupDraft, setHasRightPanelToggled, setIsRightPanelShown]);\n\n  useEffect(() => {\n    if (!automationSetupDraft) {\n      setIsAutomationAgentHidden(false);\n    }\n  }, [automationSetupDraft]);\n\n  useEffect(() => {\n    const showAgent = () => {\n      setIsAutomationAgentHidden(false);\n      setMobileAutomationView(\"conversation\");\n    };\n    window.addEventListener(AUTOMATION_SETUP_SHOW_AGENT_EVENT, showAgent);\n    return () =>\n      window.removeEventListener(AUTOMATION_SETUP_SHOW_AGENT_EVENT, showAgent);\n  }, []);\n\n  const showDockedComposer = isAutomationSetupMode && isAutomationAgentHidden;\n  const agentToggleLabel = isAutomationAgentHidden\n    ? t(I18nKey.AUTOMATION_SETUP$SHOW_AGENT)\n    : t(I18nKey.AUTOMATION_SETUP$HIDE_AGENT);\n  const setupTitle =\n    automationSetupDraft?.form?.name?.trim() ||\n    conversation?.title ||\n    t(I18nKey.AUTOMATION_SETUP$TITLE);\n\n  return (\n    <div\n      className={cn(\n        isMobile\n          ? \"relative min-h-0 flex-1 flex flex-col\"\n          : \"h-full flex flex-col overflow-hidden\",\n      )}\n    >\n      {isAutomationSetupMode ? (\n        <header\n          data-testid=\"automation-setup-topbar\"\n          className={cn(\n            \"flex shrink-0 border-b border-[var(--oh-border)] bg-canvas-base\",\n            isMobile\n              ? \"flex-col\"\n              : \"h-10 min-h-10 items-center justify-between gap-2 px-3\",\n          )}\n        >\n          <div\n            className={cn(\n              \"flex min-w-0 items-center gap-2\",\n              isMobile && \"h-10 justify-between px-3\",\n            )}\n          >\n            <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n              {isSidebarRailHidden ? <SidebarMobileMenuToggle /> : null}\n              <h2\n                data-testid=\"automation-setup-conversation-title\"\n                className=\"min-w-0 truncate text-sm font-medium text-content\"\n              >\n                {setupTitle}\n              </h2>\n            </div>\n            {isMobile ? (\n              <div\n                role=\"group\"\n                aria-label={`${t(I18nKey.AUTOMATION_SETUP$VIEW_FORM)} / ${t(I18nKey.AUTOMATION_SETUP$VIEW_CONVERSATION)}`}\n                className=\"flex shrink-0 overflow-hidden rounded-lg border border-[var(--oh-border)]\"\n              >\n                {MOBILE_AUTOMATION_VIEW_OPTIONS.map(\n                  ({ view, labelKey, Icon }) => {\n                    const label = t(labelKey);\n                    return (\n                      <button\n                        key={view}\n                        type=\"button\"\n                        data-testid={`automation-setup-mobile-view-${view}`}\n                        aria-label={label}\n                        aria-pressed={mobileAutomationView === view}\n                        title={label}\n                        onClick={() => setMobileAutomationView(view)}\n                        className={cn(\n                          \"inline-flex size-7 items-center justify-center\",\n                          mobileAutomationView === view\n                            ? \"bg-tertiary text-content\"\n                            : \"text-[var(--oh-muted)]\",\n                        )}\n                      >\n                        <Icon className=\"size-4\" aria-hidden />\n                      </button>\n                    );\n                  },\n                )}\n              </div>\n            ) : null}\n          </div>\n          <div\n            className={cn(\n              \"flex min-w-0 items-center gap-1.5\",\n              isMobile ? \"overflow-x-auto px-3 pb-2\" : \"shrink-0\",\n              isMobile && mobileAutomationView === \"conversation\" && \"hidden\",\n            )}\n          >\n            <div\n              ref={setAutomationToolbarElement}\n              data-testid=\"automation-setup-toolbar\"\n              className=\"flex min-w-0 shrink-0 items-center gap-1.5\"\n            />\n            {!isMobile ? (\n              <ChatActionTooltip\n                tooltip={agentToggleLabel}\n                ariaLabel={agentToggleLabel}\n              >\n                <button\n                  type=\"button\"\n                  data-testid=\"automation-setup-agent-toggle\"\n                  aria-label={agentToggleLabel}\n                  aria-pressed={!isAutomationAgentHidden}\n                  onClick={() =>\n                    setIsAutomationAgentHidden((previous) => !previous)\n                  }\n                  className={cn(\n                    mobileTopBarIconButtonClassName,\n                    \"size-7 self-center\",\n                  )}\n                >\n                  <BlockDrawerLeftIcon className=\"size-5 shrink-0\" />\n                </button>\n              </ChatActionTooltip>\n            ) : null}\n          </div>\n        </header>\n      ) : null}\n\n      <div\n        ref={containerRef}\n        className={cn(\n          \"flex flex-1 overflow-hidden\",\n          isMobile ? \"flex-col\" : \"transition-all duration-300 ease-in-out\",\n        )}\n        // transition toggled at runtime based on drag state\n        style={\n          !isMobile\n            ? { transitionProperty: isDragging ? \"none\" : \"all\" }\n            : undefined\n        }\n      >\n        {/* Chat Panel - always mounted, styled differently for mobile/desktop.\n            Owns its own header (name + status) and gets bottom padding so the\n            chat input doesn't slam the floor. */}\n        <div\n          data-testid=\"conversation-chat-panel\"\n          className={cn(\n            \"flex flex-col bg-canvas-base overflow-hidden\",\n            isMobile\n              ? cn(\"flex-1\", showMobileAutomationForm && \"hidden\")\n              : cn(\n                  \"min-w-0\",\n                  isAutomationSetupMode &&\n                    isAutomationAgentHidden &&\n                    \"pointer-events-none opacity-0\",\n                  !isSecondaryDrawerOpen &&\n                    \"transition-[width] duration-300 ease-in-out\",\n                ),\n          )}\n          // panel width computed at runtime by resize hook; transition toggled by drag state\n          style={\n            !isMobile\n              ? {\n                  width:\n                    isAutomationSetupMode && isAutomationAgentHidden\n                      ? \"0%\"\n                      : isRightPanelShown\n                        ? `${leftWidth}%`\n                        : \"100%\",\n                  transitionProperty:\n                    isDragging || isSecondaryDrawerOpen ? \"none\" : \"width\",\n                }\n              : undefined\n          }\n        >\n          {!isAutomationSetupMode ? (\n            <div\n              data-testid=\"chat-pane-header\"\n              className={cn(\n                \"flex h-10 min-h-10 shrink-0 items-center\",\n                isSidebarRailHidden && \"gap-2 pl-2.5\",\n              )}\n            >\n              {isSidebarRailHidden ? <SidebarMobileMenuToggle /> : null}\n              <div className=\"min-w-0 flex-1\">\n                <ConversationNameWithStatus />\n              </div>\n            </div>\n          ) : null}\n          <div className=\"flex-1 min-h-0 flex flex-col\">\n            <ChatInterfaceWrapper\n              isRightPanelShown={!isMobile && isRightPanelShown}\n              showGitControlBar={!isAutomationSetupMode}\n              showEmptyStateSuggestions={!isAutomationSetupMode}\n              composerDockTarget={composerDockTarget}\n              minimalComposer={isMobile && isAutomationSetupMode}\n              composerPlaceholder={\n                isAutomationSetupMode\n                  ? t(\n                      I18nKey.AUTOMATION_SETUP$CONVERSATION_COMPOSER_PLACEHOLDER,\n                    )\n                  : undefined\n              }\n              onDockedComposerSubmit={() => {\n                setIsAutomationAgentHidden(false);\n                setMobileAutomationView(\"conversation\");\n              }}\n            />\n          </div>\n        </div>\n\n        {/* Resize Handle - only shown on desktop when right panel is visible */}\n        {!isMobile &&\n          isRightPanelShown &&\n          !(isAutomationSetupMode && isAutomationAgentHidden) && (\n            <ResizeHandle\n              onMouseDown={handleMouseDown}\n              isDragging={isDragging}\n            />\n          )}\n\n        {showMobileAutomationForm && automationSetupDraft ? (\n          <div\n            data-testid=\"automation-setup-mobile-form\"\n            className=\"relative flex min-h-0 flex-1 flex-col overflow-hidden\"\n          >\n            <AutomationSetupPanel\n              draft={automationSetupDraft}\n              conversationId={conversationId}\n              conversationTags={conversation?.tags}\n              toolbarPortal={automationToolbarElement}\n              showInlineHeader={false}\n              reserveComposerSpace\n            />\n            <AutomationSetupDockedComposer onTarget={setComposerDockTarget} />\n          </div>\n        ) : null}\n\n        {/* Right panel: desktop side drawer. Mobile opens Files/Tools via /panel route. */}\n        {!isMobile && (\n          <div\n            data-testid=\"conversation-right-panel\"\n            className={cn(\n              \"transition-all duration-300 ease-in-out overflow-hidden\",\n              getDesktopTabPanelClass(isRightPanelShown),\n            )}\n            style={{\n              width: isRightPanelShown\n                ? isAutomationSetupMode && isAutomationAgentHidden\n                  ? \"100%\"\n                  : `${rightWidth}%`\n                : \"0%\",\n              transitionProperty: isDragging ? \"opacity, transform\" : \"all\",\n            }}\n          >\n            <div className=\"flex h-full w-full flex-col\">\n              <div\n                className={cn(\n                  \"relative flex flex-col flex-1 min-h-0 bg-surface overflow-hidden\",\n                  !(isAutomationSetupMode && isAutomationAgentHidden) &&\n                    \"border-l border-border\",\n                )}\n              >\n                {automationSetupDraft ? (\n                  <AutomationSetupPanel\n                    draft={automationSetupDraft}\n                    conversationId={conversationId}\n                    conversationTags={conversation?.tags}\n                    toolbarPortal={automationToolbarElement}\n                    showInlineHeader={false}\n                    reserveComposerSpace={showDockedComposer}\n                  />\n                ) : (\n                  <>\n                    <div\n                      data-testid=\"tabs-pane-header\"\n                      className=\"flex shrink-0 flex-col border-b border-border\"\n                    >\n                      <ConversationTabs isPanelResizing={isDragging} />\n                    </div>\n                    <div className=\"flex-1 min-h-0 flex flex-col\">\n                      <ConversationTabContent />\n                    </div>\n                  </>\n                )}\n                {showDockedComposer ? (\n                  // The setup form scrolls in a padded column with a stable\n                  // scrollbar gutter. This overlay has to be a scroll container\n                  // with the same gutter, or the composer centers in a wider\n                  // box and sits off the fields.\n                  <AutomationSetupDockedComposer\n                    onTarget={setComposerDockTarget}\n                  />\n                ) : null}\n              </div>\n            </div>\n          </div>\n        )}\n        <ConversationOverviewDrawer\n          isMobile={isMobile}\n          resizeContainerRef={containerRef}\n        />\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/conversation-name-context-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"translate-x-[calc(1rem-1px)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 83,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 83,
          "endColumn": 65
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"translate-x-[34%]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 189,
          "column": 35,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 189,
          "endColumn": 72
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"stroke-[1.75]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 216,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 216,
          "endColumn": 44
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 369,
          "column": 19,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 369,
          "endColumn": 30
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { ExternalLink, Gauge, Share2 } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useActiveConversation } from \"#/hooks/query/use-active-conversation\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { useBreakpoint } from \"#/hooks/use-breakpoint\";\nimport { clampLeftToViewport } from \"#/hooks/use-popover-fixed-placement\";\nimport { cn } from \"#/utils/utils\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { ContextMenuListItem } from \"../context-menu/context-menu-list-item\";\nimport { Divider } from \"#/ui/divider\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\nimport EditIcon from \"#/icons/u-edit.svg?react\";\nimport SkillsIcon from \"#/icons/skills.svg?react\";\nimport FishingHookIcon from \"#/icons/fishing-hook.svg?react\";\nimport ToolsIcon from \"#/icons/u-tools.svg?react\";\nimport DownloadIcon from \"#/icons/u-download.svg?react\";\nimport CloseIcon from \"#/icons/u-close.svg?react\";\nimport DeleteIcon from \"#/icons/u-delete.svg?react\";\nimport CopyIcon from \"#/icons/copy.svg?react\";\nimport { ConversationNameContextMenuIconText } from \"./conversation-name-context-menu-icon-text\";\nimport { ArchivedDisabledTooltip } from \"../context-menu/archived-disabled-tooltip\";\nimport { useIsArchivedConversation } from \"#/hooks/use-is-archived-conversation\";\n\ninterface ConversationNameContextMenuProps {\n  onClose: () => void;\n  onRename?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onShowHooks?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onTogglePublic?: (nextIsPublic: boolean) => void;\n  onCopyShareLink?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onExportTranscript?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  shareUrl?: string;\n  position?: \"top\" | \"bottom\";\n  /**\n   * Element the menu should anchor against. When provided, the menu renders\n   * into a portal at the document body using fixed positioning so it cannot be\n   * clipped by ancestors with `overflow: hidden` (e.g. the chat panel that\n   * sits next to the right-side tabs panel). Pass the trigger itself: Escape\n   * returns focus to it.\n   */\n  anchorRef?: React.RefObject<HTMLElement | null>;\n}\n\nfunction PublicShareToggle({\n  isPublic,\n  onToggle,\n  ariaLabel,\n}: {\n  isPublic: boolean;\n  onToggle: (nextIsPublic: boolean) => void;\n  ariaLabel: string;\n}) {\n  return (\n    <label className=\"relative inline-flex shrink-0 cursor-pointer items-center\">\n      <input\n        hidden\n        type=\"checkbox\"\n        data-testid=\"share-publicly-button\"\n        checked={isPublic}\n        aria-label={ariaLabel}\n        onChange={(event) => onToggle(event.target.checked)}\n      />\n      <span\n        aria-hidden\n        className={cn(\n          \"inline-flex h-3.5 w-7 items-center rounded-full px-0.5 py-px transition-colors duration-200 ease-in-out\",\n          isPublic ? \"bg-contrast\" : \"bg-base-secondary\",\n        )}\n      >\n        <span\n          className={cn(\n            \"block h-2 w-2 shrink-0 rounded-full transition-transform duration-200 ease-in-out\",\n            isPublic\n              ? \"translate-x-[calc(1rem-1px)] bg-base-secondary\"\n              : \"translate-x-px bg-tertiary-light\",\n          )}\n        />\n      </span>\n    </label>\n  );\n}\n\nexport function ConversationNameContextMenu({\n  onClose,\n  onRename,\n  onDelete,\n  onStop,\n  onDisplayCost,\n  onShowAgentTools,\n  onShowSkills,\n  onShowHooks,\n  onTogglePublic,\n  onCopyShareLink,\n  onExportTranscript,\n  onDownloadConversation,\n  shareUrl,\n  position = \"bottom\",\n  anchorRef,\n}: ConversationNameContextMenuProps) {\n  const isMobile = useBreakpoint();\n\n  const { t } = useTranslation(\"openhands\");\n  const { backend } = useActiveBackend();\n  const { data: conversation } = useActiveConversation();\n  const isArchivedConversation = useIsArchivedConversation();\n  const ref = useClickOutsideElement<HTMLUListElement>(onClose, anchorRef);\n\n  const anchorElement = anchorRef?.current ?? null;\n  const [portalStyle, setPortalStyle] = React.useState<React.CSSProperties>();\n  // The menu's width is only measurable once it is in the portal, so placement\n  // runs again right after the first positioned render, still before paint.\n  const isMenuMounted = portalStyle !== undefined;\n  React.useLayoutEffect(() => {\n    if (!anchorElement) return undefined;\n\n    const updatePosition = () => {\n      const rect = anchorElement.getBoundingClientRect();\n      if (!rect) return;\n      const gap = 8;\n      const style: React.CSSProperties = {\n        position: \"fixed\",\n        zIndex: 9999,\n      };\n      if (position === \"top\") {\n        style.bottom = window.innerHeight - rect.top + gap;\n      } else {\n        style.top = rect.bottom + gap;\n      }\n      // At phone width the trigger sits near the right edge: keep the whole\n      // menu, and every label, inside the viewport.\n      const menuWidth = ref.current?.getBoundingClientRect().width ?? 0;\n      style.left = clampLeftToViewport(rect.left, menuWidth);\n      setPortalStyle(style);\n    };\n\n    updatePosition();\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [anchorElement, position, isMenuMounted, ref]);\n\n  React.useEffect(() => {\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      onClose();\n      anchorElement?.focus();\n    };\n\n    document.addEventListener(\"keydown\", handleEscape);\n    return () => document.removeEventListener(\"keydown\", handleEscape);\n  }, [onClose, anchorElement]);\n  const hasTools = Boolean(onShowAgentTools || onShowSkills || onShowHooks);\n  const hasInfo = Boolean(onDisplayCost);\n  const hasControl = Boolean(onStop || onDelete);\n  const stopLabelKey =\n    backend.kind === \"cloud\"\n      ? I18nKey.COMMON$CLOSE_CONVERSATION_STOP_RUNTIME\n      : I18nKey.COMMON$STOP_CONVERSATION;\n  const shouldShowPublicSharing =\n    backend.kind === \"cloud\" && Boolean(onTogglePublic);\n  const isPublic = conversation?.public || false;\n\n  const isPortaled = Boolean(anchorElement);\n  const portalClassName = isPortaled\n    ? \"!static !top-auto !bottom-auto !left-auto !right-auto !mt-0\"\n    : \"\";\n\n  const menu = (\n    <ContextMenu\n      ref={ref}\n      testId=\"conversation-name-context-menu\"\n      position={position}\n      alignment=\"left\"\n      className={cn(\n        // The mobile offset only applies to the inline, absolutely positioned\n        // menu; the portaled menu gets its coordinates from `portalStyle`.\n        !isPortaled && isMobile ? \"right-0 translate-x-[34%] left-auto\" : \"\",\n        portalClassName,\n      )}\n    >\n      {onRename && (\n        <ContextMenuListItem testId=\"rename-button\" onClick={onRename}>\n          <ConversationNameContextMenuIconText\n            icon={<EditIcon width={16} height={16} />}\n            text={t(I18nKey.BUTTON$RENAME)}\n          />\n        </ContextMenuListItem>\n      )}\n\n      {hasTools && <Divider testId=\"separator-tools\" inset=\"menu\" />}\n\n      {onShowSkills && (\n        <ArchivedDisabledTooltip isDisabled={isArchivedConversation}>\n          <ContextMenuListItem\n            testId=\"show-skills-button\"\n            onClick={onShowSkills}\n            isDisabled={isArchivedConversation}\n          >\n            <ConversationNameContextMenuIconText\n              icon={\n                <SkillsIcon\n                  width={16}\n                  height={16}\n                  className=\"stroke-[1.75]\"\n                  aria-hidden\n                />\n              }\n              text={t(I18nKey.CONVERSATION$SHOW_SKILLS)}\n            />\n          </ContextMenuListItem>\n        </ArchivedDisabledTooltip>\n      )}\n\n      {onShowHooks && (\n        <ArchivedDisabledTooltip isDisabled={isArchivedConversation}>\n          <ContextMenuListItem\n            testId=\"show-hooks-button\"\n            onClick={onShowHooks}\n            isDisabled={isArchivedConversation}\n          >\n            <ConversationNameContextMenuIconText\n              icon={<FishingHookIcon width={16} height={16} aria-hidden />}\n              text={t(I18nKey.CONVERSATION$SHOW_HOOKS)}\n            />\n          </ContextMenuListItem>\n        </ArchivedDisabledTooltip>\n      )}\n\n      {onShowAgentTools && (\n        <ArchivedDisabledTooltip isDisabled={isArchivedConversation}>\n          <ContextMenuListItem\n            testId=\"show-agent-tools-button\"\n            onClick={onShowAgentTools}\n            isDisabled={isArchivedConversation}\n          >\n            <ConversationNameContextMenuIconText\n              icon={<ToolsIcon width={16} height={16} />}\n              text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}\n            />\n          </ContextMenuListItem>\n        </ArchivedDisabledTooltip>\n      )}\n\n      {onExportTranscript && (\n        <ContextMenuListItem\n          testId=\"export-transcript-button\"\n          onClick={onExportTranscript}\n        >\n          <ConversationNameContextMenuIconText\n            icon={<DownloadIcon width={16} height={16} />}\n            text={t(I18nKey.BUTTON$EXPORT_TRANSCRIPT)}\n          />\n        </ContextMenuListItem>\n      )}\n\n      {onDownloadConversation && (\n        <ContextMenuListItem\n          testId=\"download-trajectory-button\"\n          onClick={onDownloadConversation}\n        >\n          <ConversationNameContextMenuIconText\n            icon={<DownloadIcon width={16} height={16} />}\n            text={t(I18nKey.BUTTON$DOWNLOAD_CONVERSATION_DATA)}\n          />\n        </ContextMenuListItem>\n      )}\n\n      {(hasInfo || hasControl) && (\n        <Divider testId=\"separator-info-control\" inset=\"menu\" />\n      )}\n\n      {onDisplayCost && (\n        <ContextMenuListItem\n          testId=\"display-cost-button\"\n          onClick={onDisplayCost}\n        >\n          <ConversationNameContextMenuIconText\n            icon={<Gauge size={16} />}\n            text={t(I18nKey.BUTTON$DISPLAY_COST)}\n          />\n        </ContextMenuListItem>\n      )}\n\n      {shouldShowPublicSharing && onTogglePublic && (\n        <li className=\"flex w-full items-center gap-2 rounded px-2 py-2 hover:bg-interactive-hover\">\n          <span className=\"flex shrink-0 items-center text-muted\" aria-hidden>\n            <Share2 size={16} />\n          </span>\n          <span className=\"min-w-0 flex-1 truncate text-sm\">\n            {t(I18nKey.CONVERSATION$SHARE_PUBLICLY)}\n          </span>\n          <div className=\"flex shrink-0 items-center\">\n            {isPublic && shareUrl && onCopyShareLink && (\n              <div className=\"mr-2 flex items-center gap-0.5\">\n                <button\n                  type=\"button\"\n                  data-testid=\"copy-share-link-button\"\n                  onClick={onCopyShareLink}\n                  className=\"rounded p-0.5 text-muted hover:bg-interactive-selected hover:text-foreground cursor-pointer [&_svg]:text-current\"\n                  title={t(I18nKey.BUTTON$COPY_TO_CLIPBOARD)}\n                >\n                  <CopyIcon width={14} height={14} />\n                </button>\n                <a\n                  data-testid=\"open-share-link-button\"\n                  href={shareUrl}\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  onClick={(event) => event.stopPropagation()}\n                  className=\"rounded p-0.5 text-muted no-underline visited:text-muted hover:bg-interactive-selected hover:text-foreground cursor-pointer [&_svg]:text-current\"\n                  title={t(I18nKey.BUTTON$OPEN_IN_NEW_TAB)}\n                >\n                  <ExternalLink size={14} aria-hidden />\n                </a>\n              </div>\n            )}\n            <PublicShareToggle\n              isPublic={isPublic}\n              onToggle={onTogglePublic}\n              ariaLabel={t(I18nKey.CONVERSATION$SHARE_PUBLICLY)}\n            />\n          </div>\n        </li>\n      )}\n\n      {onStop && (\n        <ArchivedDisabledTooltip isDisabled={isArchivedConversation}>\n          <ContextMenuListItem\n            testId=\"stop-button\"\n            onClick={onStop}\n            isDisabled={isArchivedConversation}\n          >\n            <ConversationNameContextMenuIconText\n              icon={<CloseIcon width={16} height={16} />}\n              text={t(stopLabelKey)}\n            />\n          </ContextMenuListItem>\n        </ArchivedDisabledTooltip>\n      )}\n\n      {onDelete && (\n        <ContextMenuListItem testId=\"delete-button\" onClick={onDelete}>\n          <ConversationNameContextMenuIconText\n            icon={<DeleteIcon width={16} height={16} />}\n            text={t(I18nKey.COMMON$DELETE_CONVERSATION)}\n          />\n        </ContextMenuListItem>\n      )}\n    </ContextMenu>\n  );\n\n  if (isPortaled) {\n    if (typeof document === \"undefined\" || !portalStyle) {\n      return null;\n    }\n    return ReactDOM.createPortal(\n      <div style={portalStyle}>{menu}</div>,\n      document.body,\n    );\n  }\n\n  return menu;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/conversation-name-with-status.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"ml-[3.5px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 126,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 126,
          "endColumn": 60
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useAgentState } from \"#/hooks/use-agent-state\";\nimport { useTaskPolling } from \"#/hooks/query/use-task-polling\";\nimport { useActiveConversation } from \"#/hooks/query/use-active-conversation\";\nimport { useUnifiedPauseConversation } from \"#/hooks/mutation/use-unified-stop-conversation\";\nimport { useUnifiedResumeConversation } from \"#/hooks/mutation/use-unified-start-conversation\";\nimport { useConversationId } from \"#/hooks/use-conversation-id\";\nimport { useCloseOnEscape } from \"#/hooks/use-close-on-escape\";\nimport { useUserProviders } from \"#/hooks/use-user-providers\";\nimport { getStatusColor, cn } from \"#/utils/utils\";\nimport { AgentState } from \"#/types/agent-state\";\nimport DebugStackframeDot from \"#/icons/debug-stackframe-dot.svg?react\";\nimport { ServerStatusContextMenu } from \"../controls/server-status-context-menu\";\nimport { ConversationName } from \"./conversation-name\";\nimport { ConversationGitActionsToggle } from \"./conversation-git-actions-toggle\";\nimport { ConversationOverviewToggle } from \"./conversation-overview-toggle\";\nimport { RightPanelToggle } from \"./right-panel-toggle\";\nimport {\n  isExecutionActive,\n  isExecutionErrored,\n  isExecutionPaused,\n} from \"#/utils/status\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\nexport function ConversationNameWithStatus() {\n  const { t } = useTranslation(\"openhands\");\n  const { conversationId } = useConversationId();\n  const { data: conversation } = useActiveConversation();\n  const { curAgentState } = useAgentState();\n  const { isTask, taskStatus } = useTaskPolling();\n  const { mutate: pauseConversation } = useUnifiedPauseConversation();\n  const { mutate: resumeConversation } = useUnifiedResumeConversation();\n  const { providers } = useUserProviders();\n  const [menuOpen, setMenuOpen] = React.useState(false);\n  const [hoveredOpen, setHoveredOpen] = React.useState(false);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n\n  const executionStatus = conversation?.execution_status ?? null;\n  const isStartingStatus =\n    curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;\n  const isStopStatus = isExecutionErrored(executionStatus);\n\n  const statusColor = getStatusColor({\n    isPausing: false,\n    isTask,\n    taskStatus,\n    isStartingStatus,\n    isStopStatus,\n    curAgentState,\n  });\n\n  const isMenuVisible = menuOpen || hoveredOpen;\n\n  const closeMenu = () => {\n    setMenuOpen(false);\n    setHoveredOpen(false);\n  };\n\n  // A menu opened by click or Enter returns focus to the dot; one opened by\n  // hover closes without moving focus.\n  useCloseOnEscape(isMenuVisible, closeMenu, menuOpen ? triggerRef : undefined);\n\n  const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    if (conversationId) {\n      pauseConversation({ conversationId });\n    }\n    closeMenu();\n  };\n\n  const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    if (conversationId) {\n      resumeConversation({ conversationId, providers });\n    }\n    closeMenu();\n  };\n\n  const handleStatusClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    // Click toggles for touch; on desktop it also dismisses a hover-opened menu\n    // instead of \"pinning\" it open under the cursor.\n    if (isMenuVisible) {\n      closeMenu();\n      return;\n    }\n    setMenuOpen(true);\n  };\n\n  return (\n    <div className=\"flex items-center justify-between w-full\">\n      <div className=\"flex items-center min-w-0\">\n        <div\n          className=\"relative shrink-0\"\n          onPointerEnter={(event) => {\n            // Gate on this event's pointer, not device-primary matchMedia \u2014 hybrid\n            // laptops can emit compatibility mouseenter for touchscreen taps.\n            if (event.pointerType === \"mouse\") {\n              setHoveredOpen(true);\n            }\n          }}\n          onPointerLeave={(event) => {\n            if (event.pointerType === \"mouse\") {\n              setHoveredOpen(false);\n            }\n          }}\n        >\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            data-testid=\"server-status-menu-trigger\"\n            aria-label={t(I18nKey.COMMON$SERVER_STATUS)}\n            aria-expanded={isMenuVisible}\n            aria-haspopup=\"menu\"\n            onClick={handleStatusClick}\n            className={cn(\n              \"flex items-center justify-center rounded-md\",\n              \"text-muted hover:bg-contrast/10\",\n            )}\n          >\n            <DebugStackframeDot\n              className=\"ml-[3.5px] w-6 h-6 cursor-pointer\"\n              color={statusColor}\n              aria-hidden\n            />\n          </button>\n          {isMenuVisible ? (\n            <ServerStatusContextMenu\n              onClose={closeMenu}\n              ignoreOutsideClickRef={triggerRef}\n              onStopServer={\n                isExecutionActive(executionStatus)\n                  ? handleStopServer\n                  : undefined\n              }\n              onStartServer={\n                isExecutionPaused(executionStatus)\n                  ? handleStartServer\n                  : undefined\n              }\n              executionStatus={executionStatus}\n              position=\"bottom\"\n              className=\"bottom-full left-0 mt-0 min-h-fit\"\n              isPausing={false}\n            />\n          ) : null}\n        </div>\n        <ConversationName />\n      </div>\n      <div className=\"mr-2 flex shrink-0 items-center gap-1\">\n        <ConversationGitActionsToggle />\n        <ConversationOverviewToggle />\n        <RightPanelToggle />\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/conversation-overview-context-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[9999]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 362,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 362,
          "endColumn": 37
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 414,
          "column": 19,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 414,
          "endColumn": 30
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { useLayoutEffect, useState } from \"react\";\nimport ReactDOM from \"react-dom\";\nimport {\n  FolderGit2,\n  GitBranch,\n  GitCommitHorizontal,\n  Laptop,\n} from \"lucide-react\";\nimport { LuFileDiff } from \"react-icons/lu\";\nimport { useTranslation } from \"react-i18next\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { Divider } from \"#/ui/divider\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { useConversationId } from \"#/hooks/use-conversation-id\";\nimport { useConversationLocalStorageState } from \"#/utils/conversation-local-storage\";\nimport { useSelectConversationTab } from \"#/hooks/use-select-conversation-tab\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport PrIcon from \"#/icons/u-pr.svg?react\";\nimport PillIcon from \"#/icons/pill.svg?react\";\nimport PillFillIcon from \"#/icons/pill-fill.svg?react\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  dropdownInstantColorClassName,\n  dropdownMenuRowIconWrapperClassName,\n} from \"#/utils/dropdown-classes\";\nimport { useConversationOverviewDrawerOptional } from \"./conversation-overview-drawer-context\";\nimport {\n  CONVERSATION_OVERVIEW_DRAWER_SECTION,\n  type ConversationOverviewDrawerSection,\n} from \"./conversation-overview-drawer.types\";\nimport {\n  CONVERSATION_OVERVIEW_GIT_PART,\n  CONVERSATION_OVERVIEW_SECTION,\n  CONVERSATION_OVERVIEW_SECTION_GROUPS,\n  isOverviewGitPartPinned,\n  isOverviewSectionPinned,\n  type ConversationOverviewGitPart,\n  type ConversationOverviewSection,\n} from \"./conversation-overview-sections\";\n\ninterface ConversationOverviewContextMenuProps {\n  isOpen: boolean;\n  onClose: () => void;\n  ignoreOutsideClickRef?: React.RefObject<HTMLElement | null>;\n  /** Portal anchor so the menu is not clipped by the overview panel. */\n  anchorRef?: React.RefObject<HTMLElement | null>;\n}\n\ntype OverviewMenuItem = {\n  section: ConversationOverviewSection;\n  icon: React.ComponentType<{ className?: string }>;\n  i18nKey: I18nKey;\n};\n\ntype GitPartMenuItem = {\n  part: ConversationOverviewGitPart;\n  icon: React.ComponentType<{ className?: string }>;\n  i18nKey: I18nKey;\n  drawerSection?: ConversationOverviewDrawerSection;\n  opensChanges?: boolean;\n  opensCommits?: boolean;\n};\n\nconst OVERVIEW_MENU_ITEMS_BY_SECTION: Record<\n  Exclude<ConversationOverviewSection, \"git\">,\n  OverviewMenuItem\n> = {\n  [CONVERSATION_OVERVIEW_SECTION.workspace]: {\n    section: CONVERSATION_OVERVIEW_SECTION.workspace,\n    icon: Laptop,\n    i18nKey: I18nKey.CONVERSATION$OVERVIEW_WORKSPACE,\n  },\n};\n\nconst GIT_PART_MENU_ITEMS: GitPartMenuItem[] = [\n  {\n    part: CONVERSATION_OVERVIEW_GIT_PART.changes,\n    icon: LuFileDiff,\n    i18nKey: I18nKey.COMMON$CHANGES,\n    opensChanges: true,\n  },\n  {\n    part: CONVERSATION_OVERVIEW_GIT_PART.repository,\n    icon: FolderGit2,\n    i18nKey: I18nKey.CONVERSATION$REPOSITORY,\n  },\n  {\n    part: CONVERSATION_OVERVIEW_GIT_PART.branch,\n    icon: GitBranch,\n    i18nKey: I18nKey.CONVERSATION$BRANCH,\n  },\n  {\n    part: CONVERSATION_OVERVIEW_GIT_PART.commits,\n    icon: GitCommitHorizontal,\n    i18nKey: I18nKey.DIFF_VIEWER$COMMITS,\n    opensCommits: true,\n  },\n  {\n    part: CONVERSATION_OVERVIEW_GIT_PART.pull_requests,\n    icon: PrIcon,\n    i18nKey: I18nKey.CONVERSATION$OVERVIEW_PULL_REQUESTS,\n    drawerSection: CONVERSATION_OVERVIEW_DRAWER_SECTION.pull_requests,\n  },\n];\n\nfunction OverviewPinButton({\n  testId,\n  pinned,\n  pinnedLabel,\n  unpinnedLabel,\n  onClick,\n}: {\n  testId: string;\n  pinned: boolean;\n  pinnedLabel: string;\n  unpinnedLabel: string;\n  onClick: (event: React.MouseEvent) => void;\n}) {\n  return (\n    <button\n      type=\"button\"\n      data-testid={testId}\n      className={cn(\n        \"flex shrink-0 cursor-pointer items-center justify-center rounded-r px-2 text-contrast\",\n        dropdownInstantColorClassName,\n        \"hover:bg-contrast/10\",\n      )}\n      aria-pressed={pinned}\n      aria-label={pinned ? pinnedLabel : unpinnedLabel}\n      onClick={onClick}\n    >\n      <span\n        className={cn(\n          \"ml-auto overflow-hidden\",\n          dropdownMenuRowIconWrapperClassName,\n        )}\n        aria-hidden\n      >\n        {pinned ? (\n          <PillFillIcon className=\"size-4\" width={16} height={16} />\n        ) : (\n          <PillIcon className=\"size-4\" width={16} height={16} />\n        )}\n      </span>\n    </button>\n  );\n}\n\nexport function ConversationOverviewContextMenu({\n  isOpen,\n  onClose,\n  ignoreOutsideClickRef,\n  anchorRef,\n}: ConversationOverviewContextMenuProps) {\n  const ref = useClickOutsideElement<HTMLUListElement>(\n    onClose,\n    ignoreOutsideClickRef,\n  );\n  const [portalStyle, setPortalStyle] = useState<React.CSSProperties>();\n  const { t } = useTranslation(\"openhands\");\n  const { conversationId } = useConversationId();\n  const { state, setUnpinnedOverviewSections, setUnpinnedOverviewGitParts } =\n    useConversationLocalStorageState(conversationId);\n  const { navigateToChanges, navigateToCommits } = useSelectConversationTab();\n  const overviewDrawer = useConversationOverviewDrawerOptional();\n\n  useLayoutEffect(() => {\n    if (!isOpen || !anchorRef?.current) {\n      setPortalStyle(undefined);\n      return undefined;\n    }\n\n    const updatePosition = () => {\n      const rect = anchorRef.current?.getBoundingClientRect();\n      if (!rect) return;\n\n      const gap = 8;\n      setPortalStyle({\n        position: \"fixed\",\n        zIndex: 9999,\n        top: rect.bottom + gap,\n        right: window.innerWidth - rect.right,\n      });\n    };\n\n    updatePosition();\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [isOpen, anchorRef]);\n\n  const handleOpenGitPart = (item: GitPartMenuItem) => {\n    if (item.opensChanges) {\n      overviewDrawer?.closeDrawer();\n      navigateToChanges();\n      onClose();\n      return;\n    }\n\n    if (item.opensCommits) {\n      overviewDrawer?.closeDrawer();\n      navigateToCommits();\n      onClose();\n      return;\n    }\n\n    if (!item.drawerSection) return;\n    overviewDrawer?.openSection(item.drawerSection);\n    onClose();\n  };\n\n  const handlePinToggle = (\n    section: ConversationOverviewSection,\n    event: React.MouseEvent,\n  ) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    const unpinnedSections = state.unpinnedOverviewSections ?? [];\n    if (unpinnedSections.includes(section)) {\n      setUnpinnedOverviewSections?.(\n        unpinnedSections.filter((item) => item !== section),\n      );\n      return;\n    }\n\n    setUnpinnedOverviewSections?.([...unpinnedSections, section]);\n  };\n\n  const handleGitPartPinToggle = (\n    part: ConversationOverviewGitPart,\n    event: React.MouseEvent,\n  ) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    const unpinnedGitParts = state.unpinnedOverviewGitParts ?? [];\n    if (unpinnedGitParts.includes(part)) {\n      setUnpinnedOverviewGitParts?.(\n        unpinnedGitParts.filter((item) => item !== part),\n      );\n      return;\n    }\n\n    setUnpinnedOverviewGitParts?.([...unpinnedGitParts, part]);\n  };\n\n  if (!isOpen) return null;\n\n  const isPortaled = Boolean(anchorRef?.current && portalStyle);\n  const gitPinned = isOverviewSectionPinned(\n    CONVERSATION_OVERVIEW_SECTION.git,\n    state.unpinnedOverviewSections ?? [],\n  );\n  const pinnedLabel = t(I18nKey.CONVERSATION$UNPIN_OVERVIEW_SECTION);\n  const unpinnedLabel = t(I18nKey.CONVERSATION$PIN_OVERVIEW_SECTION);\n\n  const renderMenuRow = ({\n    key,\n    testIdOpen,\n    testIdPin,\n    icon: Icon,\n    label,\n    pinned,\n    isActionable,\n    indent = false,\n    onOpen,\n    onPinToggle,\n  }: {\n    key: string;\n    testIdOpen: string;\n    testIdPin: string;\n    icon: React.ComponentType<{ className?: string }>;\n    label: string;\n    pinned: boolean;\n    isActionable: boolean;\n    indent?: boolean;\n    onOpen?: () => void;\n    onPinToggle: (event: React.MouseEvent) => void;\n  }) => {\n    const labelContent = (\n      <>\n        <span className={dropdownMenuRowIconWrapperClassName} aria-hidden>\n          <Icon className=\"h-4 w-4\" />\n        </span>\n        <span className=\"text-sm\">{label}</span>\n      </>\n    );\n\n    return (\n      <li key={key} className=\"list-none\">\n        <div\n          className={cn(\n            \"group flex h-7.5 w-full min-w-0 items-stretch rounded hover:bg-interactive-hover\",\n            indent && \"pl-4\",\n          )}\n        >\n          {isActionable ? (\n            <button\n              type=\"button\"\n              data-testid={testIdOpen}\n              className={cn(\n                \"flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-l p-2 text-start text-contrast\",\n                dropdownInstantColorClassName,\n              )}\n              onClick={onOpen}\n            >\n              {labelContent}\n            </button>\n          ) : (\n            <div\n              data-testid={testIdOpen}\n              className={cn(\n                \"flex min-w-0 flex-1 items-center gap-2 rounded-l p-2 text-start text-contrast\",\n                dropdownInstantColorClassName,\n              )}\n            >\n              {labelContent}\n            </div>\n          )}\n          <OverviewPinButton\n            testId={testIdPin}\n            pinned={pinned}\n            pinnedLabel={pinnedLabel}\n            unpinnedLabel={unpinnedLabel}\n            onClick={onPinToggle}\n          />\n        </div>\n      </li>\n    );\n  };\n\n  const renderSectionRow = (section: ConversationOverviewSection) => {\n    if (section === CONVERSATION_OVERVIEW_SECTION.git) return null;\n    const item = OVERVIEW_MENU_ITEMS_BY_SECTION[section];\n    return renderMenuRow({\n      key: section,\n      testIdOpen: `conversation-overview-menu-open-${section}`,\n      testIdPin: `conversation-overview-menu-pin-${section}`,\n      icon: item.icon,\n      label: t(item.i18nKey),\n      pinned: isOverviewSectionPinned(\n        section,\n        state.unpinnedOverviewSections ?? [],\n      ),\n      isActionable: false,\n      onPinToggle: (event) => handlePinToggle(section, event),\n    });\n  };\n\n  const menu = (\n    <ContextMenu\n      ref={ref}\n      testId=\"conversation-overview-context-menu\"\n      theme={isPortaled ? \"popover\" : \"default\"}\n      position={isPortaled ? \"none\" : \"bottom\"}\n      alignment={isPortaled ? \"none\" : \"right\"}\n      spacing={isPortaled ? \"none\" : \"default\"}\n      className={cn(\"z-[9999] w-fit\", isPortaled ? \"mt-0\" : \"mt-2\")}\n    >\n      {CONVERSATION_OVERVIEW_SECTION_GROUPS.map((group, groupIndex) => (\n        <React.Fragment key={group.sections.join(\"-\")}>\n          {groupIndex > 0 ? (\n            <Divider\n              testId={`conversation-overview-menu-divider-${groupIndex}`}\n              inset=\"menu\"\n            />\n          ) : null}\n          {group.sections.map((section) => renderSectionRow(section))}\n        </React.Fragment>\n      ))}\n\n      <Divider testId=\"conversation-overview-menu-divider-git\" inset=\"menu\" />\n\n      {renderMenuRow({\n        key: CONVERSATION_OVERVIEW_SECTION.git,\n        testIdOpen: `conversation-overview-menu-open-${CONVERSATION_OVERVIEW_SECTION.git}`,\n        testIdPin: `conversation-overview-menu-pin-${CONVERSATION_OVERVIEW_SECTION.git}`,\n        icon: GitBranch,\n        label: t(I18nKey.CONVERSATION$OVERVIEW_GIT),\n        pinned: gitPinned,\n        isActionable: false,\n        onPinToggle: (event) =>\n          handlePinToggle(CONVERSATION_OVERVIEW_SECTION.git, event),\n      })}\n\n      {GIT_PART_MENU_ITEMS.map((item) =>\n        renderMenuRow({\n          key: item.part,\n          testIdOpen: `conversation-overview-menu-open-git-${item.part}`,\n          testIdPin: `conversation-overview-menu-pin-git-${item.part}`,\n          icon: item.icon,\n          label: t(item.i18nKey),\n          pinned: isOverviewGitPartPinned(\n            item.part,\n            state.unpinnedOverviewGitParts ?? [],\n          ),\n          isActionable: Boolean(\n            item.drawerSection || item.opensCommits || item.opensChanges,\n          ),\n          indent: true,\n          onOpen: () => handleOpenGitPart(item),\n          onPinToggle: (event) => handleGitPartPinToggle(item.part, event),\n        }),\n      )}\n    </ContextMenu>\n  );\n\n  if (isPortaled && portalStyle && typeof document !== \"undefined\") {\n    return ReactDOM.createPortal(\n      <div style={portalStyle}>{menu}</div>,\n      document.body,\n    );\n  }\n\n  return menu;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/conversation-overview-drawer.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[min(50vh,420px)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 53,
          "column": 52,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 53,
          "endColumn": 107
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"transition-[transform,opacity]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 70,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 70,
          "endColumn": 60
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 75,
          "column": 18,
          "nodeType": "ConditionalExpression",
          "messageId": "inlineStyle",
          "endLine": 75,
          "endColumn": 53
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets transitionProperty. Style through classes; use CSS custom properties for dynamic values.",
          "line": 76,
          "column": 31,
          "nodeType": "ConditionalExpression",
          "messageId": "inlineStyle",
          "endLine": 78,
          "endColumn": 42
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 83,
          "column": 27,
          "nodeType": "TemplateLiteral",
          "messageId": "inlineStyle",
          "endLine": 83,
          "endColumn": 45
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 5,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import type { RefObject } from \"react\";\nimport { ResizeHandle } from \"#/components/ui/resize-handle\";\nimport { useResizableDrawerWidth } from \"#/hooks/use-resizable-drawer-width\";\nimport { cn } from \"#/utils/utils\";\nimport { ConversationOverviewDrawerContent } from \"./conversation-overview-drawer-content\";\nimport { useConversationOverviewDrawerOptional } from \"./conversation-overview-drawer-context\";\nimport {\n  CONVERSATION_OVERVIEW_DRAWER_DEFAULT_WIDTH_PX,\n  CONVERSATION_OVERVIEW_DRAWER_MAX_WIDTH_PX,\n  CONVERSATION_OVERVIEW_DRAWER_MIN_WIDTH_PX,\n  CONVERSATION_OVERVIEW_DRAWER_RESIZE_HANDLE_TEST_ID,\n  CONVERSATION_OVERVIEW_DRAWER_TEST_ID,\n  CONVERSATION_OVERVIEW_DRAWER_WIDTH_STORAGE_KEY,\n} from \"./conversation-overview-drawer.constants\";\n\nfunction getOverviewDrawerClass(isOpen: boolean) {\n  return isOpen\n    ? \"translate-x-0 opacity-100\"\n    : \"w-0 translate-x-full opacity-0\";\n}\n\ninterface ConversationOverviewDrawerProps {\n  isMobile: boolean;\n  resizeContainerRef?: RefObject<HTMLElement | null>;\n}\n\nexport function ConversationOverviewDrawer({\n  isMobile,\n  resizeContainerRef,\n}: ConversationOverviewDrawerProps) {\n  const overviewDrawer = useConversationOverviewDrawerOptional();\n  const isOpen = Boolean(overviewDrawer?.section);\n\n  const { drawerWidth, isDragging, handleMouseDown } = useResizableDrawerWidth({\n    containerRef: resizeContainerRef ?? { current: null },\n    defaultWidth: CONVERSATION_OVERVIEW_DRAWER_DEFAULT_WIDTH_PX,\n    minWidth: CONVERSATION_OVERVIEW_DRAWER_MIN_WIDTH_PX,\n    maxWidth: CONVERSATION_OVERVIEW_DRAWER_MAX_WIDTH_PX,\n    storageKey: CONVERSATION_OVERVIEW_DRAWER_WIDTH_STORAGE_KEY,\n    enabled: isOpen && !isMobile && resizeContainerRef != null,\n  });\n\n  if (!overviewDrawer) {\n    return null;\n  }\n\n  if (isMobile) {\n    if (!isOpen) {\n      return null;\n    }\n\n    return (\n      <ConversationOverviewDrawerContent className=\"max-h-[min(50vh,420px)] w-full border-t border-border\" />\n    );\n  }\n\n  return (\n    <>\n      {isOpen && resizeContainerRef ? (\n        <div data-testid={CONVERSATION_OVERVIEW_DRAWER_RESIZE_HANDLE_TEST_ID}>\n          <ResizeHandle onMouseDown={handleMouseDown} isDragging={isDragging} />\n        </div>\n      ) : null}\n      <div\n        data-testid={CONVERSATION_OVERVIEW_DRAWER_TEST_ID}\n        aria-hidden={!isOpen}\n        className={cn(\n          \"shrink-0 overflow-hidden ease-in-out\",\n          isDragging\n            ? \"transition-[transform,opacity] duration-300\"\n            : \"transition-all duration-300\",\n          getOverviewDrawerClass(isOpen),\n        )}\n        style={{\n          width: isOpen ? `${drawerWidth}px` : \"0px\",\n          transitionProperty: isDragging\n            ? \"transform, opacity\"\n            : \"width, transform, opacity\",\n        }}\n      >\n        <div\n          className=\"flex h-full flex-col overflow-hidden border-l border-border bg-base-secondary\"\n          style={{ width: `${drawerWidth}px` }}\n        >\n          {isOpen ? (\n            <ConversationOverviewDrawerContent className=\"h-full\" />\n          ) : null}\n        </div>\n      </div>\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/conversation-overview-panel.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[45%]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 51,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 51,
          "endColumn": 84
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { useRef, useState } from \"react\";\nimport { Laptop } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useConversationOverviewStats } from \"#/hooks/use-conversation-overview-stats\";\nimport { useConversationId } from \"#/hooks/use-conversation-id\";\nimport { useConversationPrimaryRepository } from \"#/hooks/use-conversation-primary-repository\";\nimport { useConversationLocalStorageState } from \"#/utils/conversation-local-storage\";\nimport { useCloseOnEscape } from \"#/hooks/use-close-on-escape\";\nimport { ConversationOverviewDiffsRow } from \"./conversation-overview-diffs-row\";\nimport { ConversationOverviewContextMenu } from \"./conversation-overview-context-menu\";\nimport { ConversationOverviewGitSection } from \"./conversation-overview-git-section\";\nimport { EllipsisButton } from \"#/components/features/conversation-panel/ellipsis-button\";\nimport { Divider } from \"#/ui/divider\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  CONVERSATION_OVERVIEW_GIT_PART,\n  CONVERSATION_OVERVIEW_SECTION,\n  CONVERSATION_OVERVIEW_SECTION_GROUPS,\n  isOverviewGitPartPinned,\n  isOverviewSectionPinned,\n  type ConversationOverviewSection,\n} from \"./conversation-overview-sections\";\n\nconst PANEL_CLASSNAME = cn(\n  \"w-full max-w-60 rounded-xl border border-border\",\n  \"bg-surface pb-1\",\n);\n\nconst ROW_CLASSNAME = cn(\n  \"flex items-center gap-2 rounded-md px-2 py-1.5\",\n  \"transition-colors hover:bg-contrast/5\",\n);\n\nconst ROW_ICON_CLASSNAME = \"size-4 shrink-0 text-muted\";\n\ninterface OverviewRowProps {\n  icon: React.ReactNode;\n  label: string;\n  value: React.ReactNode;\n  testId: string;\n}\n\nfunction OverviewRow({ icon, label, value, testId }: OverviewRowProps) {\n  return (\n    <li data-testid={testId} className={ROW_CLASSNAME}>\n      {icon}\n      <span className=\"min-w-0 flex-1 truncate text-sm text-foreground\">\n        {label}\n      </span>\n      <span className=\"max-w-[45%] shrink-0 truncate text-right text-sm text-muted\">\n        {value}\n      </span>\n    </li>\n  );\n}\n\nfunction OverviewSectionDivider() {\n  // Match dropdown/context-menu separators: `px-1` host + `inset=\"menu\"`\n  // Divider (12px slot, line bleeds into the horizontal padding).\n  return (\n    <div className=\"px-1\">\n      <Divider inset=\"menu\" />\n    </div>\n  );\n}\n\ntype OverviewPanelBlock =\n  | {\n      kind: \"sections\";\n      key: string;\n      sections: ConversationOverviewSection[];\n    }\n  | { kind: \"git\"; key: \"git\" };\n\nexport function ConversationOverviewPanel() {\n  const { t } = useTranslation(\"openhands\");\n  const stats = useConversationOverviewStats();\n  const { conversationId } = useConversationId();\n  const { state } = useConversationLocalStorageState(conversationId);\n  const { isConnected: isGitConnected } = useConversationPrimaryRepository();\n  const [isMenuOpen, setIsMenuOpen] = useState(false);\n  const menuAnchorRef = useRef<HTMLButtonElement>(null);\n  useCloseOnEscape(isMenuOpen, () => setIsMenuOpen(false), menuAnchorRef);\n\n  const isPinned = (section: ConversationOverviewSection) =>\n    isOverviewSectionPinned(section, state.unpinnedOverviewSections ?? []);\n\n  const showChanges = isOverviewGitPartPinned(\n    CONVERSATION_OVERVIEW_GIT_PART.changes,\n    state.unpinnedOverviewGitParts ?? [],\n  );\n\n  const showGitBlock =\n    isPinned(CONVERSATION_OVERVIEW_SECTION.git) &&\n    (showChanges || isGitConnected);\n\n  const workspaceSections = CONVERSATION_OVERVIEW_SECTION_GROUPS.flatMap(\n    (group) => group.sections.filter((section) => isPinned(section)),\n  );\n\n  // Git content first (untitled); Workspace sits below it.\n  const panelBlocks: OverviewPanelBlock[] = [];\n  if (showGitBlock) {\n    panelBlocks.push({ kind: \"git\", key: \"git\" });\n  }\n  if (workspaceSections.length > 0) {\n    panelBlocks.push({\n      kind: \"sections\",\n      key: workspaceSections.join(\"-\"),\n      sections: workspaceSections,\n    });\n  }\n\n  const renderSection = (section: ConversationOverviewSection) => {\n    switch (section) {\n      case CONVERSATION_OVERVIEW_SECTION.workspace:\n        return (\n          <OverviewRow\n            key={section}\n            testId=\"conversation-overview-workspace\"\n            icon={<Laptop className={ROW_ICON_CLASSNAME} />}\n            label={t(I18nKey.CONVERSATION$OVERVIEW_WORKSPACE)}\n            value={stats.workspaceName ?? t(I18nKey.CONVERSATION$OVERVIEW_NONE)}\n          />\n        );\n      case CONVERSATION_OVERVIEW_SECTION.git:\n        // Rendered as its own panel block (not a group row).\n        return null;\n      default: {\n        const _exhaustive: never = section;\n        return _exhaustive;\n      }\n    }\n  };\n\n  return (\n    <aside\n      data-testid=\"conversation-overview-panel\"\n      aria-label={t(I18nKey.CONVERSATION$OVERVIEW)}\n      className={PANEL_CLASSNAME}\n    >\n      <div className=\"flex items-center justify-between px-4 pb-0.5 pt-2.5\">\n        <span className=\"text-xs font-medium text-muted\">\n          {t(I18nKey.CONVERSATION$OVERVIEW)}\n        </span>\n        <div className=\"relative shrink-0\">\n          <EllipsisButton\n            ref={menuAnchorRef}\n            testId=\"conversation-overview-ellipsis\"\n            onClick={() => setIsMenuOpen((open) => !open)}\n            ariaLabel={t(I18nKey.COMMON$MORE_OPTIONS)}\n          />\n          <ConversationOverviewContextMenu\n            isOpen={isMenuOpen}\n            onClose={() => setIsMenuOpen(false)}\n            ignoreOutsideClickRef={menuAnchorRef}\n            anchorRef={menuAnchorRef}\n          />\n        </div>\n      </div>\n\n      {panelBlocks.map((block, blockIndex) => (\n        <React.Fragment key={block.key}>\n          {blockIndex > 0 ? <OverviewSectionDivider /> : null}\n          {block.kind === \"git\" ? (\n            <div data-testid=\"conversation-overview-git-block\">\n              {showChanges ? (\n                <ul className=\"px-2\">\n                  <ConversationOverviewDiffsRow />\n                </ul>\n              ) : null}\n              {isGitConnected ? <ConversationOverviewGitSection /> : null}\n            </div>\n          ) : (\n            <ul className=\"px-2\">\n              {block.sections.map((section) => renderSection(section))}\n            </ul>\n          )}\n        </React.Fragment>\n      ))}\n    </aside>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/conversation-overview-toggle.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets top. Style through classes; use CSS custom properties for dynamic values.",
          "line": 125,
          "column": 20,
          "nodeType": "MemberExpression",
          "messageId": "inlineStyle",
          "endLine": 125,
          "endColumn": 36
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets left. Style through classes; use CSS custom properties for dynamic values.",
          "line": 126,
          "column": 21,
          "nodeType": "MemberExpression",
          "messageId": "inlineStyle",
          "endLine": 126,
          "endColumn": 38
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 127,
          "column": 22,
          "nodeType": "Identifier",
          "messageId": "inlineStyle",
          "endLine": 127,
          "endColumn": 58
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { Info } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport {\n  openConversationOverviewPanelPeek,\n  scheduleCloseConversationOverviewPanelPeek,\n  useSyncConversationOverviewPanelPeek,\n} from \"#/hooks/use-conversation-overview-panel-peek\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { mobileTopBarIconButtonClassName } from \"#/utils/mobile-top-bar-icon-button-classes\";\nimport { ChatActionTooltip } from \"../chat/chat-action-tooltip\";\nimport { useIsArchivedConversation } from \"#/hooks/use-is-archived-conversation\";\nimport { useOptionalConversationId } from \"#/hooks/use-conversation-id\";\nimport { setConversationState } from \"#/utils/conversation-local-storage\";\nimport { ConversationOverviewPanel } from \"./conversation-overview-panel\";\nimport { CONVERSATION_OVERVIEW_PANEL_WIDTH_PX } from \"./conversation-overview-panel.constants\";\n\ninterface ConversationOverviewToggleProps {\n  className?: string;\n}\n\n/**\n * Info toggle for the session-only overview panel beside the chat thread.\n * Opening overview closes the files/diffs drawer; the panel also auto-hides\n * when that drawer opens from elsewhere. While the drawer is open, hovering\n * this control peeks the overview as a portaled overlay.\n */\nexport function ConversationOverviewToggle({\n  className,\n}: ConversationOverviewToggleProps) {\n  const { t } = useTranslation(\"openhands\");\n  const isArchivedConversation = useIsArchivedConversation();\n  const { conversationId } = useOptionalConversationId();\n  const triggerRef = useRef<HTMLDivElement>(null);\n  const [peekPosition, setPeekPosition] = useState<{\n    top: number;\n    left: number;\n  } | null>(null);\n  const {\n    isOverviewPanelShown,\n    isOverviewPanelPeeked,\n    isRightPanelShown,\n    setIsOverviewPanelShown,\n    setHasRightPanelToggled,\n    setIsRightPanelShown,\n  } = useConversationStore();\n\n  useSyncConversationOverviewPanelPeek();\n\n  useEffect(() => {\n    if (isRightPanelShown && isOverviewPanelShown) {\n      setIsOverviewPanelShown(false);\n    }\n  }, [isOverviewPanelShown, isRightPanelShown, setIsOverviewPanelShown]);\n\n  const canPeekOnHover =\n    !isArchivedConversation && isRightPanelShown && !isOverviewPanelShown;\n  const showPeek = canPeekOnHover && isOverviewPanelPeeked;\n\n  useLayoutEffect(() => {\n    if (!showPeek) {\n      setPeekPosition(null);\n      return undefined;\n    }\n\n    const updatePosition = () => {\n      const rect = triggerRef.current?.getBoundingClientRect();\n      if (!rect) {\n        return;\n      }\n\n      setPeekPosition({\n        top: rect.bottom + 4,\n        left: Math.max(8, rect.right - CONVERSATION_OVERVIEW_PANEL_WIDTH_PX),\n      });\n    };\n\n    updatePosition();\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [showPeek]);\n\n  const handleToggle = () => {\n    if (isArchivedConversation) {\n      return;\n    }\n\n    // Reveal overview by closing the files/diffs drawer first; otherwise the\n    // mutual-exclusion effect above would immediately hide overview again.\n    if (isRightPanelShown) {\n      setHasRightPanelToggled(false);\n      setIsRightPanelShown(false);\n      if (conversationId) {\n        setConversationState(conversationId, { rightPanelShown: false });\n      }\n      setIsOverviewPanelShown(true);\n      return;\n    }\n\n    setIsOverviewPanelShown(!isOverviewPanelShown);\n  };\n\n  const isOverviewVisible = isOverviewPanelShown || showPeek;\n\n  const tooltipText = isArchivedConversation\n    ? t(I18nKey.CONVERSATION$UNAVAILABLE_FOR_ARCHIVES)\n    : isOverviewPanelShown\n      ? t(I18nKey.CONVERSATION$HIDE_OVERVIEW)\n      : t(I18nKey.CONVERSATION$SHOW_OVERVIEW);\n\n  const peek =\n    showPeek && peekPosition\n      ? ReactDOM.createPortal(\n          <div\n            data-testid=\"conversation-overview-peek\"\n            className=\"fixed z-50\"\n            style={{\n              top: peekPosition.top,\n              left: peekPosition.left,\n              width: CONVERSATION_OVERVIEW_PANEL_WIDTH_PX,\n            }}\n            onMouseEnter={openConversationOverviewPanelPeek}\n            onMouseLeave={scheduleCloseConversationOverviewPanelPeek}\n          >\n            <div className=\"shadow-lg\">\n              <ConversationOverviewPanel />\n            </div>\n          </div>,\n          document.body,\n        )\n      : null;\n\n  return (\n    <>\n      <div\n        ref={triggerRef}\n        className=\"relative inline-flex items-center self-center\"\n        onMouseEnter={() => {\n          if (canPeekOnHover) {\n            openConversationOverviewPanelPeek();\n          }\n        }}\n        onMouseLeave={() => {\n          if (canPeekOnHover || isOverviewPanelPeeked) {\n            scheduleCloseConversationOverviewPanelPeek();\n          }\n        }}\n      >\n        <ChatActionTooltip tooltip={tooltipText} ariaLabel={tooltipText}>\n          <button\n            type=\"button\"\n            onClick={handleToggle}\n            disabled={isArchivedConversation}\n            className={cn(\n              // Match RightPanelToggle hit target (p-1 + size-5 = 28px) while\n              // keeping the smaller Info glyph.\n              mobileTopBarIconButtonClassName,\n              \"size-7\",\n              isOverviewVisible && \"bg-contrast/10 text-foreground\",\n              isArchivedConversation &&\n                \"cursor-not-allowed opacity-50 hover:bg-transparent hover:text-muted\",\n              className,\n            )}\n            aria-label={tooltipText}\n            aria-pressed={isOverviewPanelShown}\n            aria-disabled={isArchivedConversation}\n            data-testid=\"conversation-overview-toggle\"\n          >\n            <Info className=\"h-4 w-4 shrink-0\" size={16} aria-hidden />\n          </button>\n        </ChatActionTooltip>\n      </div>\n      {peek}\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/conversation-tabs/conversation-planner-build-bar.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 55,
          "column": 36,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 55,
          "endColumn": 96
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "i18next/no-literal-string",
          "severity": 2,
          "message": "disallow literal string: <Typography.Text className=\"text-[11px] font-normal leading-5 text-contrast-foreground\">\n          {/* eslint-disable-next-line i18next/no-literal-string */}\n          {t(I18nKey.COMMON$BUILD)} \u2318\u21a9\n        </Typography.Text>",
          "line": 57,
          "column": 36,
          "nodeType": "JSXText",
          "endLine": 58,
          "endColumn": 9,
          "suppressions": [
            {
              "kind": "directive",
              "justification": ""
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { cn } from \"#/utils/utils\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport { useSelectConversationTab } from \"#/hooks/use-select-conversation-tab\";\nimport { useHandleBuildPlanClick } from \"#/hooks/use-handle-build-plan-click\";\nimport { useAgentState, usePlanningAgentState } from \"#/hooks/use-agent-state\";\nimport { AgentState } from \"#/types/agent-state\";\nimport { Typography } from \"#/ui/typography\";\n\n/**\n * The Planner tab's Build row, shown below the drawer tab row while the\n * Planner tab is open. Rendered by the desktop drawer header and, at phone\n * width, below the panel page's fixed-height top bar.\n */\nexport function ConversationPlannerBuildBar({\n  className,\n}: {\n  className?: string;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const planContent = useConversationStore((state) => state.planContent);\n  const { isTabActive } = useSelectConversationTab();\n  const { handleBuildPlanClick } = useHandleBuildPlanClick();\n  const { curAgentState } = useAgentState();\n  const { isPlanningAgentRunning } = usePlanningAgentState();\n\n  if (!isTabActive(\"planner\")) return null;\n\n  const isAgentRunning =\n    curAgentState === AgentState.RUNNING ||\n    curAgentState === AgentState.LOADING ||\n    isPlanningAgentRunning;\n  const isBuildDisabled = isAgentRunning || !planContent;\n\n  return (\n    <div\n      className={cn(\n        \"flex h-10 min-h-10 shrink-0 items-center border-t border-border pl-2.5 pr-1\",\n        className,\n      )}\n    >\n      <button\n        type=\"button\"\n        onClick={handleBuildPlanClick}\n        disabled={isBuildDisabled}\n        className={cn(\n          \"flex h-5 min-w-17 items-center justify-center rounded bg-contrast px-2 transition-opacity\",\n          isBuildDisabled\n            ? \"cursor-not-allowed opacity-50\"\n            : \"cursor-pointer hover:opacity-90\",\n        )}\n        data-testid=\"planner-tab-build-button\"\n      >\n        <Typography.Text className=\"text-[11px] font-normal leading-5 text-contrast-foreground\">\n          {/* eslint-disable-next-line i18next/no-literal-string */}\n          {t(I18nKey.COMMON$BUILD)} \u2318\u21a9\n        </Typography.Text>\n      </button>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/conversation-tabs/conversation-tabs-context-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[9999]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 188,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 188,
          "endColumn": 37
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 274,
          "column": 19,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 274,
          "endColumn": 30
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { useLayoutEffect, useState } from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { useCloseOnEscape } from \"#/hooks/use-close-on-escape\";\nimport { clampLeftToViewport } from \"#/hooks/use-popover-fixed-placement\";\nimport { useConversationId } from \"#/hooks/use-conversation-id\";\nimport { useConversationLocalStorageState } from \"#/utils/conversation-local-storage\";\nimport {\n  useConversationStore,\n  type ConversationTab,\n} from \"#/stores/conversation-store\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { Gauge, Globe, ListTodo, SquareChevronRight } from \"lucide-react\";\nimport { LuFileDiff } from \"react-icons/lu\";\nimport DocumentIcon from \"#/icons/document.svg?react\";\nimport PillIcon from \"#/icons/pill.svg?react\";\nimport PillFillIcon from \"#/icons/pill-fill.svg?react\";\nimport DoubleCheckIcon from \"#/icons/double-check.svg?react\";\nimport { useTaskList } from \"#/hooks/use-task-list\";\nimport { useSelectConversationTab } from \"#/hooks/use-select-conversation-tab\";\nimport { useIsArchivedConversation } from \"#/hooks/use-is-archived-conversation\";\nimport { ArchivedDisabledTooltip } from \"../../context-menu/archived-disabled-tooltip\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  dropdownInstantColorClassName,\n  dropdownMenuRowIconWrapperClassName,\n} from \"#/utils/dropdown-classes\";\n\n/** Space between the trigger and the portaled menu. */\nconst MENU_GAP_PX = 8;\n\ninterface ConversationTabsContextMenuProps {\n  isOpen: boolean;\n  onClose: () => void;\n  ignoreOutsideClickRef?: React.RefObject<HTMLElement | null>;\n  /** Portal anchor so the menu is not clipped by drawer overflow. */\n  anchorRef?: React.RefObject<HTMLElement | null>;\n}\n\nexport function ConversationTabsContextMenu({\n  isOpen,\n  onClose,\n  ignoreOutsideClickRef,\n  anchorRef,\n}: ConversationTabsContextMenuProps) {\n  const ref = useClickOutsideElement<HTMLUListElement>(\n    onClose,\n    ignoreOutsideClickRef,\n  );\n  useCloseOnEscape(isOpen, onClose, anchorRef);\n  const [portalStyle, setPortalStyle] = useState<React.CSSProperties>();\n\n  useLayoutEffect(() => {\n    if (!isOpen || !anchorRef?.current) {\n      setPortalStyle(undefined);\n      return undefined;\n    }\n\n    const updatePosition = () => {\n      const rect = anchorRef.current?.getBoundingClientRect();\n      if (!rect) return;\n\n      // The menu's own box is only measurable once it has painted; the first\n      // pass falls back to anchoring on the trigger, and the frame below\n      // re-runs with real dimensions.\n      const menuRect = ref.current?.getBoundingClientRect();\n\n      const overflowsBelow =\n        rect.bottom + MENU_GAP_PX + (menuRect?.height ?? 0) >\n        window.innerHeight;\n\n      setPortalStyle({\n        position: \"fixed\",\n        zIndex: 9999,\n        // Flip above the trigger rather than clipping at the viewport bottom.\n        ...(overflowsBelow\n          ? { bottom: window.innerHeight - rect.top + MENU_GAP_PX }\n          : { top: rect.bottom + MENU_GAP_PX }),\n        // An embedded canvas can sit hard against the viewport's right edge,\n        // where left-aligning on the trigger clips the labels and pin controls.\n        left: clampLeftToViewport(rect.left, menuRect?.width ?? 0),\n      });\n    };\n\n    updatePosition();\n    const frame = window.requestAnimationFrame(updatePosition);\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.cancelAnimationFrame(frame);\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [isOpen, anchorRef, ref]);\n  const { t } = useTranslation(\"openhands\");\n  const { conversationId } = useConversationId();\n  const {\n    state,\n    setUnpinnedTabs,\n    setSelectedTab: setPersistedSelectedTab,\n  } = useConversationLocalStorageState(conversationId);\n  const { selectedTab, isRightPanelShown, setSelectedTab } =\n    useConversationStore();\n\n  const { navigateToTab } = useSelectConversationTab();\n\n  const { hasTaskList } = useTaskList();\n  const isArchivedConversation = useIsArchivedConversation();\n\n  const tabConfig = [\n    {\n      tab: \"planner\",\n      icon: ListTodo,\n      i18nKey: I18nKey.COMMON$PLANNER,\n    },\n    { tab: \"files\", icon: DocumentIcon, i18nKey: I18nKey.COMMON$FILES },\n    {\n      tab: \"commits\",\n      icon: LuFileDiff,\n      i18nKey: I18nKey.DIFF_VIEWER$COMMITS,\n    },\n    {\n      tab: \"terminal\",\n      icon: SquareChevronRight,\n      i18nKey: I18nKey.COMMON$TERMINAL,\n    },\n    { tab: \"browser\", icon: Globe, i18nKey: I18nKey.COMMON$BROWSER },\n    { tab: \"usage\", icon: Gauge, i18nKey: I18nKey.COMMON$USAGE },\n  ];\n\n  if (hasTaskList) {\n    tabConfig.unshift({\n      tab: \"tasklist\",\n      icon: DoubleCheckIcon,\n      i18nKey: I18nKey.COMMON$TASK_LIST,\n    });\n  }\n\n  const handleOpenTab = (tab: string) => {\n    if (isArchivedConversation) {\n      return;\n    }\n    navigateToTab(tab as ConversationTab);\n    onClose();\n  };\n\n  const handlePinToggle = (tab: string, e: React.MouseEvent) => {\n    if (isArchivedConversation) {\n      e.preventDefault();\n      e.stopPropagation();\n      return;\n    }\n    e.preventDefault();\n    e.stopPropagation();\n    if (state.unpinnedTabs.includes(tab)) {\n      setUnpinnedTabs(state.unpinnedTabs.filter((item) => item !== tab));\n    } else {\n      const newUnpinnedTabs = [...state.unpinnedTabs, tab];\n      setUnpinnedTabs(newUnpinnedTabs);\n\n      if (selectedTab === tab && isRightPanelShown) {\n        const nextPinnedTab = tabConfig.find(\n          ({ tab: tabKey }) =>\n            tabKey !== tab && !newUnpinnedTabs.includes(tabKey),\n        );\n\n        if (nextPinnedTab) {\n          setSelectedTab(nextPinnedTab.tab as ConversationTab);\n          setPersistedSelectedTab(nextPinnedTab.tab as ConversationTab);\n        }\n      }\n    }\n  };\n\n  if (!isOpen) return null;\n\n  const isPortaled = Boolean(anchorRef?.current && portalStyle);\n\n  const menu = (\n    <ContextMenu\n      ref={ref}\n      theme={isPortaled ? \"popover\" : \"default\"}\n      position={isPortaled ? \"none\" : \"bottom\"}\n      alignment={isPortaled ? \"none\" : \"left\"}\n      spacing={isPortaled ? \"none\" : \"default\"}\n      className={cn(\"z-[9999] w-fit\", isPortaled ? \"mt-0\" : \"mt-2\")}\n    >\n      {tabConfig.map(({ tab, icon: Icon, i18nKey }) => {\n        const pinned = !state.unpinnedTabs.includes(tab);\n        return (\n          <li key={tab} className=\"list-none\">\n            <ArchivedDisabledTooltip isDisabled={isArchivedConversation}>\n              <div\n                className={cn(\n                  \"group flex h-7.5 w-full min-w-0 items-stretch rounded\",\n                  !isArchivedConversation && \"hover:bg-interactive-hover\",\n                  isArchivedConversation && \"opacity-50\",\n                )}\n              >\n                <button\n                  type=\"button\"\n                  data-testid={`conversation-tabs-menu-open-${tab}`}\n                  disabled={isArchivedConversation}\n                  className={cn(\n                    \"flex min-w-0 flex-1 items-center gap-2 rounded-l p-2 text-start text-contrast\",\n                    dropdownInstantColorClassName,\n                    isArchivedConversation\n                      ? \"cursor-not-allowed\"\n                      : \"cursor-pointer\",\n                  )}\n                  onClick={() => handleOpenTab(tab)}\n                >\n                  <span\n                    className={dropdownMenuRowIconWrapperClassName}\n                    aria-hidden\n                  >\n                    <Icon className=\"h-4 w-4\" />\n                  </span>\n                  <span className=\"text-sm\">{t(i18nKey)}</span>\n                </button>\n                <button\n                  type=\"button\"\n                  data-testid={`conversation-tabs-menu-pin-${tab}`}\n                  disabled={isArchivedConversation}\n                  className={cn(\n                    \"flex shrink-0 items-center justify-center rounded-r px-2 text-contrast\",\n                    dropdownInstantColorClassName,\n                    isArchivedConversation\n                      ? \"cursor-not-allowed\"\n                      : \"cursor-pointer hover:bg-contrast/10\",\n                  )}\n                  aria-pressed={pinned}\n                  aria-label={\n                    pinned\n                      ? t(I18nKey.CONVERSATION$UNPIN_TAB)\n                      : t(I18nKey.CONVERSATION$PIN_TAB)\n                  }\n                  onClick={(e) => handlePinToggle(tab, e)}\n                >\n                  {pinned ? (\n                    <span\n                      className={cn(\n                        \"-mr-1.25 ml-auto\",\n                        dropdownMenuRowIconWrapperClassName,\n                      )}\n                      aria-hidden\n                    >\n                      <PillFillIcon className=\"h-7 w-7\" />\n                    </span>\n                  ) : (\n                    <span\n                      className={cn(\n                        \"ml-auto\",\n                        dropdownMenuRowIconWrapperClassName,\n                      )}\n                      aria-hidden\n                    >\n                      <PillIcon className=\"h-4.5 w-4.5\" />\n                    </span>\n                  )}\n                </button>\n              </div>\n            </ArchivedDisabledTooltip>\n          </li>\n        );\n      })}\n    </ContextMenu>\n  );\n\n  if (isPortaled && portalStyle && typeof document !== \"undefined\") {\n    return ReactDOM.createPortal(\n      <div style={portalStyle}>{menu}</div>,\n      document.body,\n    );\n  }\n\n  return menu;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/conversation-tabs/conversation-tabs.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"left-[-10000px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 321,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 321,
          "endColumn": 111
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 452,
          "column": 40,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 452,
          "endColumn": 100
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "i18next/no-literal-string",
          "severity": 2,
          "message": "disallow literal string: <Typography.Text className=\"text-[11px] font-normal leading-5 text-contrast-foreground\">\n              {/* eslint-disable-next-line i18next/no-literal-string */}\n              {t(I18nKey.COMMON$BUILD)} \u2318\u21a9\n            </Typography.Text>",
          "line": 454,
          "column": 40,
          "nodeType": "JSXText",
          "endLine": 455,
          "endColumn": 13,
          "suppressions": [
            {
              "kind": "directive",
              "justification": ""
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent,\n} from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { LayoutGroup } from \"framer-motion\";\nimport { Gauge, Globe, ListTodo, SquareChevronRight } from \"lucide-react\";\nimport { LuFileDiff } from \"react-icons/lu\";\nimport DocumentIcon from \"#/icons/document.svg?react\";\nimport DoubleCheckIcon from \"#/icons/double-check.svg?react\";\nimport { EllipsisButton } from \"#/components/features/conversation-panel/ellipsis-button\";\nimport { cn } from \"#/utils/utils\";\nimport { useConversationLocalStorageState } from \"#/utils/conversation-local-storage\";\nimport { CONVERSATION_TAB_LABEL_KEYS } from \"./conversation-tab-ids\";\nimport { ConversationTabNav } from \"./conversation-tab-nav\";\nimport { DrawerVSCodeLink } from \"./drawer-vscode-link\";\nimport { ChatActionTooltip } from \"../../chat/chat-action-tooltip\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport { ConversationTabsContextMenu } from \"./conversation-tabs-context-menu\";\nimport { useConversationId } from \"#/hooks/use-conversation-id\";\nimport { useSelectConversationTab } from \"#/hooks/use-select-conversation-tab\";\nimport { useTaskList } from \"#/hooks/use-task-list\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useHandleBuildPlanClick } from \"#/hooks/use-handle-build-plan-click\";\nimport { useAgentState, usePlanningAgentState } from \"#/hooks/use-agent-state\";\nimport { AgentState } from \"#/types/agent-state\";\nimport { Typography } from \"#/ui/typography\";\nimport { mobileTopBarIconClassName } from \"#/utils/mobile-top-bar-icon-button-classes\";\n\nexport function ConversationTabs({\n  variant = \"default\",\n  isPanelResizing = false,\n}: {\n  variant?: \"default\" | \"compact\";\n  /** True while the desktop drawer gripper is being dragged. */\n  isPanelResizing?: boolean;\n}) {\n  const { conversationId } = useConversationId();\n  const { setSelectedTab, planContent } = useConversationStore();\n\n  const [isMenuOpen, setIsMenuOpen] = useState(false);\n\n  const { state: persistedState } =\n    useConversationLocalStorageState(conversationId);\n\n  const { hasTaskList } = useTaskList();\n  const { backend } = useActiveBackend();\n\n  const { handleBuildPlanClick } = useHandleBuildPlanClick();\n  const { curAgentState } = useAgentState();\n  const { isPlanningAgentRunning } = usePlanningAgentState();\n\n  const {\n    selectTab,\n    isTabActive,\n    onTabChange,\n    selectedTab,\n    isRightPanelShown,\n  } = useSelectConversationTab();\n\n  // Restore the most-recently-used tab from localStorage so users don't\n  // lose their tab selection across reloads.\n  //\n  // Note: we deliberately do NOT mirror `rightPanelShown` from\n  // localStorage. The drawer's open/closed state is session-only \u2014 see\n  // the comment in `useConversationStore` and the schema note in\n  // `conversation-local-storage.ts` for the rationale.\n  useEffect(() => {\n    setSelectedTab(persistedState.selectedTab);\n  }, [setSelectedTab, persistedState.selectedTab]);\n\n  useEffect(() => {\n    const handlePanelVisibilityChange = () => {\n      if (isRightPanelShown) {\n        // If no tab is selected, default to files tab\n        if (!selectedTab) {\n          onTabChange(\"files\");\n        }\n      }\n    };\n\n    handlePanelVisibilityChange();\n  }, [isRightPanelShown, selectedTab, onTabChange]);\n\n  const { t, i18n } = useTranslation(\"openhands\");\n\n  // `files` is intentionally the leftmost tab \u2014 it's the primary entry\n  // point for inspecting agent output (workspace files + git diff).\n  const tabs = [\n    {\n      tabValue: \"files\",\n      isActive: isTabActive(\"files\"),\n      icon: DocumentIcon,\n      onClick: () => selectTab(\"files\"),\n      tooltipContent: t(CONVERSATION_TAB_LABEL_KEYS.files),\n      tooltipAriaLabel: t(CONVERSATION_TAB_LABEL_KEYS.files),\n      label: t(CONVERSATION_TAB_LABEL_KEYS.files),\n    },\n    {\n      tabValue: \"commits\",\n      isActive: isTabActive(\"commits\"),\n      icon: LuFileDiff,\n      onClick: () => selectTab(\"commits\"),\n      tooltipContent: t(CONVERSATION_TAB_LABEL_KEYS.commits),\n      tooltipAriaLabel: t(CONVERSATION_TAB_LABEL_KEYS.commits),\n      label: t(CONVERSATION_TAB_LABEL_KEYS.commits),\n    },\n    {\n      tabValue: \"planner\",\n      isActive: isTabActive(\"planner\"),\n      icon: ListTodo,\n      onClick: () => selectTab(\"planner\"),\n      tooltipContent: t(CONVERSATION_TAB_LABEL_KEYS.planner),\n      tooltipAriaLabel: t(CONVERSATION_TAB_LABEL_KEYS.planner),\n      label: t(CONVERSATION_TAB_LABEL_KEYS.planner),\n    },\n    {\n      tabValue: \"terminal\",\n      isActive: isTabActive(\"terminal\"),\n      icon: SquareChevronRight,\n      onClick: () => selectTab(\"terminal\"),\n      tooltipContent: t(CONVERSATION_TAB_LABEL_KEYS.terminal),\n      tooltipAriaLabel: t(CONVERSATION_TAB_LABEL_KEYS.terminal),\n      label: t(CONVERSATION_TAB_LABEL_KEYS.terminal),\n      className: \"pl-2\",\n    },\n    {\n      tabValue: \"browser\",\n      isActive: isTabActive(\"browser\"),\n      icon: Globe,\n      onClick: () => selectTab(\"browser\"),\n      tooltipContent: t(CONVERSATION_TAB_LABEL_KEYS.browser),\n      tooltipAriaLabel: t(CONVERSATION_TAB_LABEL_KEYS.browser),\n      label: t(CONVERSATION_TAB_LABEL_KEYS.browser),\n    },\n    {\n      tabValue: \"usage\",\n      isActive: isTabActive(\"usage\"),\n      icon: Gauge,\n      onClick: () => selectTab(\"usage\"),\n      tooltipContent: t(CONVERSATION_TAB_LABEL_KEYS.usage),\n      tooltipAriaLabel: t(CONVERSATION_TAB_LABEL_KEYS.usage),\n      label: t(CONVERSATION_TAB_LABEL_KEYS.usage),\n    },\n  ];\n\n  if (hasTaskList) {\n    // Insert after the file-related tabs.\n    tabs.splice(2, 0, {\n      tabValue: \"tasklist\",\n      isActive: isTabActive(\"tasklist\"),\n      icon: DoubleCheckIcon,\n      onClick: () => selectTab(\"tasklist\"),\n      tooltipContent: t(CONVERSATION_TAB_LABEL_KEYS.tasklist),\n      tooltipAriaLabel: t(CONVERSATION_TAB_LABEL_KEYS.tasklist),\n      label: t(CONVERSATION_TAB_LABEL_KEYS.tasklist),\n    });\n  }\n\n  // Pinned tabs always show in the bar. Unpinned tabs stay hidden unless the\n  // user has that tab selected \u2014 then it appears while active so the bar\n  // matches the open panel.\n  const visibleTabs = tabs.filter((tab) => {\n    if (!persistedState.unpinnedTabs.includes(tab.tabValue)) return true;\n    return selectedTab === tab.tabValue;\n  });\n\n  const unpinnedSignature = persistedState.unpinnedTabs.join(\",\");\n\n  const isAgentRunning =\n    curAgentState === AgentState.RUNNING ||\n    curAgentState === AgentState.LOADING ||\n    isPlanningAgentRunning;\n  const isBuildDisabled = isAgentRunning || !planContent;\n\n  const tabsRowInnerRef = useRef<HTMLDivElement>(null);\n  const measureRowRef = useRef<HTMLDivElement>(null);\n  const menuRef = useRef<HTMLDivElement>(null);\n  const vscodeButtonRef = useRef<HTMLDivElement>(null);\n  const anchorRef = useRef<HTMLButtonElement>(null);\n  const [inlineTabCount, setInlineTabCount] = useState(visibleTabs.length);\n\n  useLayoutEffect(() => {\n    const rowInner = tabsRowInnerRef.current;\n    const measureRow = measureRowRef.current;\n    const menuEl = menuRef.current;\n    const vscodeEl = vscodeButtonRef.current;\n    if (!rowInner || !measureRow || !menuEl || !vscodeEl) return undefined;\n\n    const measure = () => {\n      const measureButtons = measureRow.querySelectorAll<HTMLButtonElement>(\n        '[data-tab-measure=\"true\"]',\n      );\n      const tabCount = measureButtons.length;\n\n      const rowWidth = rowInner.getBoundingClientRect().width;\n      if (rowWidth === 0) {\n        setInlineTabCount(tabCount);\n        return;\n      }\n\n      const widths = Array.from(measureButtons).map(\n        (button) => button.getBoundingClientRect().width,\n      );\n\n      if (widths.length !== tabCount || tabCount === 0) {\n        setInlineTabCount(Math.max(0, tabCount));\n        return;\n      }\n\n      const menuWidth = menuEl.getBoundingClientRect().width;\n      const vscodeWidth = vscodeEl.getBoundingClientRect().width;\n      const gapCss =\n        getComputedStyle(rowInner).columnGap || getComputedStyle(rowInner).gap;\n      const gapPx = parseFloat(gapCss) || 6;\n\n      let nextCount = 0;\n      for (let k = tabCount; k >= 0; k -= 1) {\n        let total = menuWidth + vscodeWidth;\n        for (let i = 0; i < k; i += 1) {\n          total += widths[i] ?? 0;\n        }\n        if (k > 0) {\n          total += k * gapPx;\n        }\n        if (total <= rowWidth + 0.5) {\n          nextCount = k;\n          break;\n        }\n      }\n\n      setInlineTabCount((prev) => (prev === nextCount ? prev : nextCount));\n    };\n\n    measure();\n    if (typeof ResizeObserver === \"undefined\") return undefined;\n    const ro = new ResizeObserver(measure);\n    ro.observe(rowInner);\n    // The editor button's presence is resolved asynchronously (the hook probes\n    // /api/vscode/status), and it sits inside an `ml-auto shrink-0` wrapper, so\n    // it appearing or disappearing does not change `rowInner`'s own box and\n    // would not otherwise re-measure. Its width is folded into the fit\n    // calculation above, so a stale value permanently costs an inline tab.\n    ro.observe(vscodeEl);\n    return () => ro.disconnect();\n  }, [\n    unpinnedSignature,\n    visibleTabs.length,\n    hasTaskList,\n    backend.kind,\n    selectedTab,\n    isRightPanelShown,\n    i18n.language,\n  ]);\n\n  const safeInlineTabCount = Math.min(inlineTabCount, visibleTabs.length);\n  const inlineTabs = visibleTabs.slice(0, safeInlineTabCount);\n\n  // Roving tabindex: the strip is a single tab stop. It sits on the selected\n  // tab \u2014 drawer open or not, since the strip stays in the accessibility tree\n  // while the drawer is collapsed \u2014 until the user arrows elsewhere.\n  const selectedTabIndex = inlineTabs.findIndex(\n    (tab) => tab.tabValue === selectedTab,\n  );\n  const [rovingTabIndex, setRovingTabIndex] = useState(0);\n  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);\n\n  useEffect(() => {\n    if (selectedTabIndex >= 0) setRovingTabIndex(selectedTabIndex);\n  }, [selectedTabIndex]);\n\n  const tabStopIndex = Math.min(\n    rovingTabIndex,\n    Math.max(inlineTabs.length - 1, 0),\n  );\n\n  // Arrow keys move focus only; Enter/Space still activate, which keeps the\n  // drawer from thrashing through tabs as the user scans the strip.\n  const handleTabKeyDown = (\n    event: KeyboardEvent<HTMLButtonElement>,\n    index: number,\n  ) => {\n    const lastIndex = inlineTabs.length - 1;\n    if (lastIndex < 0) return;\n\n    let nextIndex: number;\n    if (event.key === \"ArrowRight\") {\n      nextIndex = index === lastIndex ? 0 : index + 1;\n    } else if (event.key === \"ArrowLeft\") {\n      nextIndex = index === 0 ? lastIndex : index - 1;\n    } else if (event.key === \"Home\") {\n      nextIndex = 0;\n    } else if (event.key === \"End\") {\n      nextIndex = lastIndex;\n    } else {\n      return;\n    }\n\n    event.preventDefault();\n    setRovingTabIndex(nextIndex);\n    tabRefs.current[nextIndex]?.focus();\n  };\n\n  return (\n    <>\n      <div\n        className={cn(\n          \"relative w-full min-w-0\",\n          variant === \"compact\"\n            ? \"flex h-full min-h-0 items-center py-0 pl-0 pr-1\"\n            : \"min-h-10 p-1\",\n        )}\n      >\n        <div\n          ref={measureRowRef}\n          aria-hidden\n          className=\"pointer-events-none absolute top-0 left-[-10000px] flex flex-nowrap items-center gap-1.5\"\n        >\n          {visibleTabs.map(\n            (\n              {\n                tabValue,\n                icon,\n                isActive,\n                tooltipContent,\n                tooltipAriaLabel,\n                label,\n                className: tabClassName,\n              },\n              index,\n            ) => (\n              <ChatActionTooltip\n                key={`measure-${tabValue}-${index}`}\n                tooltip={tooltipContent}\n                ariaLabel={tooltipAriaLabel}\n              >\n                <ConversationTabNav\n                  tabValue={tabValue}\n                  icon={icon}\n                  onClick={() => {}}\n                  isActive={isActive}\n                  label={label}\n                  className={cn(tabClassName, \"shrink-0\")}\n                  measureOnly\n                />\n              </ChatActionTooltip>\n            ),\n          )}\n        </div>\n        <div\n          ref={tabsRowInnerRef}\n          className=\"flex w-full min-w-0 flex-nowrap items-center justify-start\"\n        >\n          <div className=\"flex min-w-0 flex-1 items-center justify-start overflow-hidden\">\n            <div className=\"flex w-fit max-w-full min-w-0 items-center gap-1.5\">\n              <LayoutGroup id=\"conversation-drawer-tabs\">\n                <div\n                  // A narrow drawer can push every tab into the overflow menu,\n                  // and a tablist owning no tabs is not a tablist.\n                  role={inlineTabs.length > 0 ? \"tablist\" : undefined}\n                  aria-label={t(I18nKey.CONVERSATION$TABS_LABEL)}\n                  className=\"flex w-fit max-w-full min-w-0 flex-nowrap items-center gap-1.5 overflow-x-hidden\"\n                >\n                  {inlineTabs.map(\n                    (\n                      {\n                        tabValue,\n                        icon,\n                        onClick,\n                        isActive,\n                        tooltipContent,\n                        tooltipAriaLabel,\n                        label,\n                        className: tabClassName,\n                      },\n                      index,\n                    ) => (\n                      <ChatActionTooltip\n                        key={`${tabValue}-${index}`}\n                        tooltip={tooltipContent}\n                        ariaLabel={tooltipAriaLabel}\n                      >\n                        <ConversationTabNav\n                          tabValue={tabValue}\n                          icon={icon}\n                          onClick={onClick}\n                          isActive={isActive}\n                          isSelected={selectedTab === tabValue}\n                          label={label}\n                          className={cn(tabClassName, \"shrink-0\")}\n                          suppressLayoutAnimation={isPanelResizing}\n                          tabIndex={index === tabStopIndex ? 0 : -1}\n                          onKeyDown={(event) => handleTabKeyDown(event, index)}\n                          buttonRef={(node) => {\n                            tabRefs.current[index] = node;\n                          }}\n                        />\n                      </ChatActionTooltip>\n                    ),\n                  )}\n                </div>\n              </LayoutGroup>\n              <div ref={menuRef} className=\"relative shrink-0\">\n                <EllipsisButton\n                  ref={anchorRef}\n                  onClick={() => setIsMenuOpen(!isMenuOpen)}\n                  ariaLabel={t(I18nKey.CONVERSATION$CUSTOMIZE_TABS)}\n                  iconClassName={\n                    variant === \"compact\"\n                      ? mobileTopBarIconClassName\n                      : undefined\n                  }\n                />\n                <ConversationTabsContextMenu\n                  isOpen={isMenuOpen}\n                  onClose={() => setIsMenuOpen(false)}\n                  ignoreOutsideClickRef={anchorRef}\n                  anchorRef={anchorRef}\n                />\n              </div>\n            </div>\n          </div>\n          {/* The ref'd wrapper must stay mounted \u2014 the overflow measurement\n              effect above bails if it's missing. */}\n          <div ref={vscodeButtonRef} className=\"ml-auto shrink-0 pr-1\">\n            <DrawerVSCodeLink />\n          </div>\n        </div>\n      </div>\n      {isTabActive(\"planner\") && variant !== \"compact\" && (\n        <div\n          className={cn(\n            \"flex h-10 min-h-10 shrink-0 items-center border-t border-border pl-2.5 pr-1\",\n          )}\n        >\n          <button\n            type=\"button\"\n            onClick={handleBuildPlanClick}\n            disabled={isBuildDisabled}\n            className={cn(\n              \"flex h-5 min-w-17 items-center justify-center rounded bg-contrast px-2 transition-opacity\",\n              isBuildDisabled\n                ? \"cursor-not-allowed opacity-50\"\n                : \"cursor-pointer hover:opacity-90\",\n            )}\n            data-testid=\"planner-tab-build-button\"\n          >\n            <Typography.Text className=\"text-[11px] font-normal leading-5 text-contrast-foreground\">\n              {/* eslint-disable-next-line i18next/no-literal-string */}\n              {t(I18nKey.COMMON$BUILD)} \u2318\u21a9\n            </Typography.Text>\n          </button>\n        </div>\n      )}\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/conversation-tabs/drawer-vscode-link.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[7px]\" hardcodes an off-token value. Nearest on the scale: rounded-md (6px), rounded-lg (8px).",
          "line": 45,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 45,
          "endColumn": 114
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { ExternalLink } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport VSCodeIcon from \"#/icons/vscode.svg?react\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useAgentState } from \"#/hooks/use-agent-state\";\nimport { useUnifiedVSCodeUrl } from \"#/hooks/query/use-unified-vscode-url\";\nimport { RUNTIME_STARTING_STATES } from \"#/types/agent-state\";\nimport { cn } from \"#/utils/utils\";\n\nexport function DrawerVSCodeLink() {\n  const { t } = useTranslation(\"openhands\");\n  const { curAgentState } = useAgentState();\n  const { data, refetch, isLoading, isUnavailable } = useUnifiedVSCodeUrl();\n  const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState);\n\n  const handleClick = async () => {\n    let vscodeUrl = data?.url;\n\n    if (!vscodeUrl) {\n      const result = await refetch();\n      vscodeUrl = result.data?.url ?? null;\n    }\n\n    if (vscodeUrl) {\n      window.open(vscodeUrl, \"_blank\", \"noopener,noreferrer\");\n    }\n  };\n\n  // Backends that report no editor to open \u2014 `enable_vscode: false`, a\n  // configured editor whose process is not running, or no URL reported \u2014 get\n  // no button rather than one that does nothing when clicked.\n  if (isUnavailable) {\n    return null;\n  }\n\n  return (\n    <button\n      type=\"button\"\n      onClick={handleClick}\n      disabled={isLoading || isRuntimeStarting}\n      aria-label={t(I18nKey.VSCODE$OPEN)}\n      title={t(I18nKey.VSCODE$OPEN)}\n      data-testid=\"drawer-vscode-link\"\n      className={cn(\n        \"inline-flex items-center gap-1.5 rounded-[7px] border border-border bg-base-secondary px-2 py-1 text-xs\",\n        \"text-muted transition-colors hover:enabled:bg-surface-raised hover:enabled:text-contrast\",\n        \"cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed\",\n      )}\n    >\n      <VSCodeIcon className=\"h-3.75 w-3.75 shrink-0\" aria-hidden />\n      <span>{t(I18nKey.FILES$VSCODE)}</span>\n      <ExternalLink\n        className=\"h-3.5 w-3.5 shrink-0\"\n        aria-hidden\n        strokeWidth={2}\n      />\n    </button>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/conversation/usage-panel/context-meter.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 90,
          "column": 27,
          "nodeType": "TemplateLiteral",
          "messageId": "inlineStyle",
          "endLine": 90,
          "endColumn": 48
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { getContextWindowUsagePercentage } from \"#/utils/format-token-count\";\n\n/** Context fill percentage above which the meter warns and the compact CTA becomes prominent. */\nexport const CONTEXT_FILL_WARNING_PERCENT = 70;\n/** Context fill percentage above which the meter signals danger. */\nexport const CONTEXT_FILL_DANGER_PERCENT = 90;\n\nexport type ContextFillTone = \"neutral\" | \"warning\" | \"danger\";\n\nexport function getContextFillTone(usagePercentage: number): ContextFillTone {\n  if (usagePercentage > CONTEXT_FILL_DANGER_PERCENT) {\n    return \"danger\";\n  }\n  if (usagePercentage > CONTEXT_FILL_WARNING_PERCENT) {\n    return \"warning\";\n  }\n  return \"neutral\";\n}\n\ninterface ContextMeterProps {\n  /** Tokens currently held in the agent's context (`per_turn_token`). */\n  perTurnToken: number;\n  /** Model context window size in tokens. */\n  contextWindow: number;\n}\n\n/**\n * Context-fill progress bar with theme-aware warning and danger states. Shows\n * the raw token numbers underneath.\n */\nexport function ContextMeter({\n  perTurnToken,\n  contextWindow,\n}: ContextMeterProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  // Some models (e.g. via OpenRouter) report no context window size; show the\n  // raw token count without a misleading \"x / 0\" or \"0.0% used\" readout.\n  const isWindowUnknown = contextWindow <= 0;\n\n  // Capped at 100 by getContextWindowUsagePercentage, so the bar width and\n  // the label never read above a full window.\n  const usagePercentage = getContextWindowUsagePercentage(\n    perTurnToken,\n    contextWindow,\n  );\n  const tone = getContextFillTone(usagePercentage);\n  const isWarning = tone === \"warning\";\n  const isDanger = tone === \"danger\";\n  const roundedPercentage = Math.round(usagePercentage);\n  const remainingPercentage = Math.max(0, 100 - roundedPercentage);\n  const usagePercentLabel = isWindowUnknown\n    ? t(I18nKey.CONVERSATION$CONTEXT_WINDOW_UNKNOWN)\n    : `${roundedPercentage}% ${t(I18nKey.CONVERSATION$USED)} (${remainingPercentage}% ${t(I18nKey.CONVERSATION$LEFT)})`;\n\n  return (\n    <div data-testid=\"context-meter\" className=\"flex flex-col gap-2\">\n      <div className=\"flex items-center justify-between gap-2\">\n        <span className=\"font-semibold\">\n          {t(I18nKey.CONVERSATION$CONTEXT_WINDOW)}\n        </span>\n        <span\n          className={cn(\n            \"shrink-0 text-xs\",\n            isDanger\n              ? \"text-feedback-error\"\n              : isWarning\n                ? \"text-feedback-warning\"\n                : \"text-muted\",\n          )}\n        >\n          {usagePercentLabel}\n        </span>\n      </div>\n      <div className=\"relative h-1.5 w-full rounded-full bg-tertiary\">\n        <div\n          data-testid=\"context-meter-bar\"\n          className={cn(\n            \"absolute inset-y-0 left-0 rounded-full transition-all duration-300\",\n            isDanger\n              ? \"bg-status-error\"\n              : isWarning\n                ? \"bg-feedback-warning\"\n                : \"bg-foreground\",\n          )}\n          // runtime usage-percentage width\n          style={{ width: `${usagePercentage}%` }}\n        />\n      </div>\n      <div className=\"flex justify-end\">\n        <span className=\"text-xs text-muted\">\n          {isWindowUnknown\n            ? perTurnToken.toLocaleString()\n            : `${perTurnToken.toLocaleString()} / ${contextWindow.toLocaleString()}`}\n        </span>\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/diff-viewer/commit-row.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[7ch]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 48,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 48,
          "endColumn": 89
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { GitCommit } from \"#/api/open-hands.types\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { formatTimeDelta } from \"#/utils/format-time-delta\";\nimport { ChevronDown, ChevronRight } from \"lucide-react\";\nimport { useCommitChanges } from \"#/hooks/query/use-commit-changes\";\nimport { AccordionPanel } from \"./accordion-panel\";\nimport { DiffChangeList } from \"./diff-change-list\";\nimport { LoadingSpinner } from \"./loading-spinner\";\n\nexport interface CommitRowProps {\n  commit: GitCommit;\n  /** Only shown when the listed commits have more than one author. */\n  showAuthor: boolean;\n  isExpanded: boolean;\n  onToggle: () => void;\n}\n\n/**\n * One collapsible commit: header row (short SHA, subject, relative time),\n * expanding into the files that commit changed as a single-open accordion.\n */\nexport function CommitRow({\n  commit,\n  showAuthor,\n  isExpanded,\n  onToggle,\n}: CommitRowProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  // Only fetch a commit's file list once the row is expanded.\n  const {\n    data: changes,\n    isLoading,\n    isSuccess,\n  } = useCommitChanges(commit.sha, { enabled: isExpanded });\n\n  return (\n    <div data-testid=\"commit-row\" className=\"w-full flex flex-col\">\n      <button\n        type=\"button\"\n        onClick={onToggle}\n        aria-expanded={isExpanded}\n        data-testid=\"commit-row-toggle\"\n        className=\"w-full flex h-10 items-center gap-2 px-3 border-b border-border text-sm text-content text-left hover:cursor-pointer\"\n      >\n        <code className=\"w-[7ch] flex-shrink-0 text-center font-mono text-xs text-muted\">\n          {commit.shortSha}\n        </code>\n        <strong className=\"flex-1 truncate font-medium\">\n          {commit.subject}\n        </strong>\n        {showAuthor && (\n          <span className=\"text-xs text-muted truncate max-w-32 flex-shrink-0\">\n            {commit.author}\n          </span>\n        )}\n        <span className=\"text-xs text-muted flex-shrink-0\">\n          {`${formatTimeDelta(commit.timestamp)} ${t(I18nKey.CONVERSATION$AGO)}`}\n        </span>\n        {isExpanded ? (\n          <ChevronDown className=\"w-4 h-4 shrink-0 text-muted\" aria-hidden />\n        ) : (\n          <ChevronRight className=\"w-4 h-4 shrink-0 text-muted\" aria-hidden />\n        )}\n      </button>\n\n      <AccordionPanel\n        open={isExpanded}\n        testId=\"commit-row-content\"\n        className=\"w-full flex flex-col pl-6\"\n      >\n        {isLoading && (\n          <div className=\"p-3\">\n            <LoadingSpinner className=\"w-4 h-4\" />\n          </div>\n        )}\n        {isSuccess && changes && changes.length > 0 && (\n          <DiffChangeList changes={changes} commit={commit.sha} />\n        )}\n      </AccordionPanel>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/diff-viewer/editor-container.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"h-[var(--editor-height)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 19,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 19,
          "endColumn": 81
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { cn } from \"#/utils/utils\";\n\ninterface EditorContainerProps {\n  height: number;\n  children: React.ReactNode;\n  className?: string;\n}\n\nexport function EditorContainer({\n  height,\n  children,\n  className,\n}: EditorContainerProps) {\n  return (\n    <div\n      data-testid=\"editor-container\"\n      className={cn(\n        \"w-full border-b border-border overflow-hidden h-[var(--editor-height)]\",\n        className,\n      )}\n      // CSS custom property plumbed through for h-[var(--editor-height)] above\n      style={{ \"--editor-height\": `${height}px` } as React.CSSProperties}\n    >\n      {children}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/diff-viewer/uncommitted-changes-row.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[7ch]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 38,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 38,
          "endColumn": 89
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { ChevronDown, ChevronRight } from \"lucide-react\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { AccordionPanel } from \"./accordion-panel\";\nimport { DiffChangeList, type DiffChangeListItem } from \"./diff-change-list\";\n\nconst EMPTY_COMMIT_SHA_PLACEHOLDER = \"-\";\n/** Base key for i18next pluralization (`_one` / `_other` suffixes). */\nconst UNCOMMITTED_FILE_COUNT_I18N_KEY = \"DIFF_VIEWER$UNCOMMITTED_FILE_COUNT\";\n\nexport interface UncommittedChangesRowProps {\n  changes: DiffChangeListItem[];\n  isExpanded: boolean;\n  onToggle: () => void;\n}\n\n/**\n * Top accordion row in the Commits pane for working-tree changes that\n * are not yet associated with a commit. Same single-open accordion\n * contract as CommitRow.\n */\nexport function UncommittedChangesRow({\n  changes,\n  isExpanded,\n  onToggle,\n}: UncommittedChangesRowProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <div data-testid=\"uncommitted-changes-row\" className=\"w-full flex flex-col\">\n      <button\n        type=\"button\"\n        onClick={onToggle}\n        aria-expanded={isExpanded}\n        data-testid=\"uncommitted-changes-row-toggle\"\n        className=\"w-full flex h-10 items-center gap-2 px-3 border-b border-border text-sm text-content text-left hover:cursor-pointer\"\n      >\n        <code className=\"w-[7ch] flex-shrink-0 text-center font-mono text-xs text-muted\">\n          {EMPTY_COMMIT_SHA_PLACEHOLDER}\n        </code>\n        <strong className=\"flex-1 truncate font-medium\">\n          {t(I18nKey.DIFF_VIEWER$UNCOMMITTED)}\n        </strong>\n        {changes.length > 0 ? (\n          <span\n            data-testid=\"uncommitted-changes-count\"\n            className=\"text-xs text-muted tabular-nums flex-shrink-0\"\n          >\n            {t(UNCOMMITTED_FILE_COUNT_I18N_KEY, { count: changes.length })}\n          </span>\n        ) : null}\n        {isExpanded ? (\n          <ChevronDown className=\"w-4 h-4 shrink-0 text-muted\" aria-hidden />\n        ) : (\n          <ChevronRight className=\"w-4 h-4 shrink-0 text-muted\" aria-hidden />\n        )}\n      </button>\n\n      <AccordionPanel\n        open={isExpanded}\n        testId=\"uncommitted-changes-row-content\"\n        className=\"w-full flex flex-col pl-6\"\n      >\n        {changes.length > 0 ? <DiffChangeList changes={changes} /> : null}\n      </AccordionPanel>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/files-tab/file-content-viewer.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--oh-scroll-fade-from:var(--oh-surface)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 198,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 198,
          "endColumn": 141
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--tw-prose-body:var(--oh-foreground)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 203,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 203,
          "endColumn": 470
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--tw-prose-bold:var(--oh-foreground)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 203,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 203,
          "endColumn": 470
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--tw-prose-headings:var(--oh-foreground)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 203,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 203,
          "endColumn": 470
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--tw-prose-lead:var(--oh-text-secondary)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 203,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 203,
          "endColumn": 470
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--tw-prose-counters:var(--oh-text-secondary)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 203,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 203,
          "endColumn": 470
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--tw-prose-quotes:var(--oh-foreground)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 203,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 203,
          "endColumn": 470
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--tw-prose-quote-borders:var(--oh-border-subtle)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 203,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 203,
          "endColumn": 470
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--tw-prose-bullets:var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 203,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 203,
          "endColumn": 470
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--tw-prose-hr:var(--oh-border-subtle)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 203,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 203,
          "endColumn": 470
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--tw-prose-captions:var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 203,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 203,
          "endColumn": 470
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[--tw-prose-kbd:var(--oh-foreground)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 203,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 203,
          "endColumn": 470
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 12,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\n\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useColorTheme } from \"#/hooks/use-color-theme\";\nimport { useWorkspaceFileContent } from \"#/hooks/query/use-workspace-file-content\";\nimport {\n  useWorkspaceMutationCounter,\n  withWorkspaceCacheBuster,\n} from \"#/stores/use-workspace-mutation-counter\";\nimport { MarkdownRenderer } from \"#/components/features/markdown/markdown-renderer\";\nimport { isMarkdownFilePath } from \"#/utils/is-markdown-file-path\";\nimport { COLOR_THEMES } from \"#/themes/color-themes\";\nimport { cn } from \"#/utils/utils\";\nimport { HighlightedSourceView } from \"./highlighted-source-view\";\nimport type { ViewMode } from \"./view-mode\";\n\ninterface FileContentViewerProps {\n  path: string;\n  viewMode: ViewMode;\n}\n\nconst HTML_LIKE_EXTS = new Set([\"html\", \"htm\", \"svg\"]);\n\n// Office/document formats we can't preview inline. The label doubles as the\n// allow-list (a present entry => Office doc) and feeds a clear, format-named\n// \"no preview\" message instead of the generic binary fallback.\nconst OFFICE_DOCUMENT_LABELS: Record<string, string> = {\n  pptx: \"PowerPoint\",\n  ppt: \"PowerPoint\",\n  docx: \"Word\",\n  doc: \"Word\",\n  xlsx: \"Excel\",\n  xls: \"Excel\",\n};\n\nfunction getExtension(path: string): string {\n  const idx = path.lastIndexOf(\".\");\n  return idx === -1 ? \"\" : path.slice(idx + 1).toLowerCase();\n}\n\n/**\n * Fallback shown when a file's bytes aren't previewable. Office documents\n * (.pptx / .docx / .xlsx \u2026) get a clear, format-named message; every other\n * binary keeps the generic \"binary file\" string so the pane is never blank.\n */\nfunction UnpreviewableFallback({ path }: { path: string }) {\n  const { t } = useTranslation(\"openhands\");\n  const documentLabel = OFFICE_DOCUMENT_LABELS[getExtension(path)];\n  return (\n    <div\n      className=\"flex h-full w-full items-center justify-center text-sm text-muted\"\n      data-testid={\n        documentLabel\n          ? \"file-content-viewer-unsupported-document\"\n          : \"file-content-viewer-binary-fallback\"\n      }\n    >\n      {documentLabel\n        ? t(I18nKey.FILES$UNSUPPORTED_DOCUMENT, { type: documentLabel })\n        : t(I18nKey.FILES$BINARY_FALLBACK)}\n    </div>\n  );\n}\n\n/**\n * Renders the contents of a single workspace file. In `rich` mode we point\n * an iframe / <img> straight at the agent server's static workspace\n * fileserver for HTML / SVG / images / PDFs, so relative asset references\n * load naturally. In `plain` mode we always show the raw bytes as text (or\n * a fallback message for binaries).\n */\nexport function FileContentViewer({ path, viewMode }: FileContentViewerProps) {\n  const { t } = useTranslation(\"openhands\");\n  const colorTheme = useColorTheme();\n  const isLightTheme = COLOR_THEMES[colorTheme].appearance === \"light\";\n  const query = useWorkspaceFileContent(path);\n  // Subscribe to the workspace mutation counter so the iframe / <img> src\n  // changes after every agent file-editor edit and every Files tab Refresh,\n  // forcing a fresh fetch of the displayed file even when the *path* hasn't\n  // moved. Assets it references (e.g. a `style.css` used by `index.html`)\n  // keep their own URLs, so they are not cache-busted by this.\n  const mutationCounter = useWorkspaceMutationCounter((state) => state.count);\n\n  if (query.isLoading) {\n    return (\n      <div className=\"flex h-full w-full items-center justify-center text-sm text-muted\">\n        {t(I18nKey.FILES$LOADING_FILES)}\n      </div>\n    );\n  }\n\n  if (query.isError || !query.data) {\n    // Show a load-error message rather than the binary-fallback string \u2014\n    // these are completely different failure modes (couldn't even fetch\n    // the file vs. fetched fine but the bytes aren't previewable) and\n    // mixing them up hides real backend failures behind a misleading\n    // \"Binary file\" label. Prefer the underlying error's own message when\n    // we have one; fall back to the generic translated string otherwise.\n    return (\n      <div\n        className=\"flex h-full w-full items-center justify-center text-sm text-muted\"\n        data-testid=\"file-content-viewer-error\"\n      >\n        {(query.error as Error | undefined)?.message ??\n          t(I18nKey.FILES$LOAD_ERROR)}\n      </div>\n    );\n  }\n\n  const { kind, text, staticUrl, mimeType } = query.data;\n  const bustedStaticUrl = withWorkspaceCacheBuster(staticUrl, mutationCounter);\n\n  // ----- Plain mode: raw source bytes, syntax-highlighted when we can\n  // recognize the grammar (falls through to a `<pre>` otherwise). This\n  // includes \"plain\" view of markdown / HTML, where the point is to see\n  // the markup behind the rich preview.\n  if (viewMode === \"plain\") {\n    if (kind === \"text\" && text !== null) {\n      return (\n        <HighlightedSourceView\n          path={path}\n          text={text}\n          mimeType={mimeType ?? undefined}\n        />\n      );\n    }\n    return <UnpreviewableFallback path={path} />;\n  }\n\n  // ----- Rich mode: render HTML, markdown, images, PDFs from staticUrl. ----\n  if (kind === \"image\") {\n    return (\n      <div\n        className=\"flex h-full w-full items-center justify-center bg-surface p-4\"\n        data-testid=\"file-content-viewer-image\"\n      >\n        <img\n          src={bustedStaticUrl}\n          alt={path}\n          className=\"max-h-full max-w-full object-contain\"\n        />\n      </div>\n    );\n  }\n\n  if (kind === \"pdf\") {\n    // Deliberately NOT sandboxed: Chromium refuses to instantiate its\n    // built-in PDF viewer inside any sandboxed frame (the `sandbox`\n    // attribute disables plugins unconditionally \u2014 no token re-enables\n    // them), rendering \"This page has been blocked by Chrome\" instead of\n    // the document. Unsandboxed is safe here: the fileserver declares\n    // `Content-Type: application/pdf`, which browsers never sniff into\n    // HTML, so the frame can only host the PDF viewer \u2014 and PDF-embedded\n    // JS (AcroForm, OpenAction\u2026) runs in the viewer's own sandboxed\n    // engine with no access to this page's DOM.\n    return (\n      <iframe\n        title={path}\n        src={bustedStaticUrl}\n        data-testid=\"file-content-viewer-iframe\"\n        className=\"h-full w-full bg-white\"\n      />\n    );\n  }\n\n  if (kind === \"binary\") {\n    return <UnpreviewableFallback path={path} />;\n  }\n\n  // Text-like content.\n  if (mimeType === \"text/html\" || HTML_LIKE_EXTS.has(getExtension(path))) {\n    // Sandbox the preview iframe: `allow-same-origin` keeps the frame on\n    // the workspace fileserver's origin so relative `<link href=\"\u2026\">`,\n    // `<img src=\"\u2026\">`, etc. continue to resolve, while the absence of\n    // `allow-scripts` means any `<script>` (or `onerror=\u2026`, inline event\n    // handler, \u2026) inside the previewed file is inert. This is exactly\n    // the safe-preview posture we want \u2014 users can look at their HTML\n    // without it executing in the canvas's context.\n    return (\n      <iframe\n        title={path}\n        src={bustedStaticUrl}\n        sandbox=\"allow-same-origin\"\n        data-testid=\"file-content-viewer-iframe\"\n        className=\"h-full w-full bg-white\"\n      />\n    );\n  }\n\n  if (kind === \"text\" && isMarkdownFilePath(path)) {\n    // Match the right-pane chrome color so the rich-rendered markdown\n    // blends with the surrounding files tab instead of painting a stark\n    // white card. `--oh-scroll-fade-from` keeps wide-table edge fades on\n    // the same surface (otherwise they default to `--oh-color-base`).\n    return (\n      <div\n        data-testid=\"file-content-viewer-markdown\"\n        className=\"h-full w-full overflow-auto bg-surface text-foreground custom-scrollbar-always [--oh-scroll-fade-from:var(--oh-surface)]\"\n      >\n        <div\n          className={cn(\n            \"prose prose-sm max-w-none p-6\",\n            \"[--tw-prose-body:var(--oh-foreground)] [--tw-prose-bold:var(--oh-foreground)] [--tw-prose-headings:var(--oh-foreground)] [--tw-prose-lead:var(--oh-text-secondary)] [--tw-prose-counters:var(--oh-text-secondary)] [--tw-prose-quotes:var(--oh-foreground)] [--tw-prose-quote-borders:var(--oh-border-subtle)] [--tw-prose-bullets:var(--oh-muted)] [--tw-prose-hr:var(--oh-border-subtle)] [--tw-prose-captions:var(--oh-muted)] [--tw-prose-kbd:var(--oh-foreground)]\",\n            !isLightTheme && \"prose-invert\",\n          )}\n        >\n          <MarkdownRenderer\n            content={text ?? \"\"}\n            includeStandard\n            includeHeadings\n          />\n        </div>\n      </div>\n    );\n  }\n\n  // Rich mode for actual source code (.ts, .py, .yaml, .css, \u2026): there\n  // is no other \"rich\" rendering to fall back to, so highlighted source\n  // IS the rich view. Identical to the plain-mode treatment \u2014 keeping\n  // both branches reuse `HighlightedSourceView` means the toggle has the\n  // same visual identity for source files in both modes (which is the\n  // honest answer: source IS rendered code).\n  if (kind === \"text\" && text !== null) {\n    return (\n      <HighlightedSourceView\n        path={path}\n        text={text}\n        mimeType={mimeType ?? undefined}\n      />\n    );\n  }\n\n  // Truly unknown / empty payload \u2014 show a fallback so the pane is never\n  // blank.\n  return <UnpreviewableFallback path={path} />;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/files-tab/file-quick-row.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[scrollbar-width:none]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 23,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 23,
          "endColumn": 83
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[-ms-overflow-style:none]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 23,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 23,
          "endColumn": 83
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { ListTree } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport CloseIcon from \"#/icons/u-close.svg?react\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\n\ninterface FileQuickRowProps {\n  /** Open file tabs only (paths the user or agent has opened). */\n  openPaths: string[];\n  selectedPath: string | null;\n  onSelectFile: (path: string) => void;\n  onCloseFile: (path: string) => void;\n  /** Whether the left-hand file tree is currently visible. */\n  isTreeVisible: boolean;\n  /** Toggle the visibility of the left-hand file tree. */\n  onToggleTree: () => void;\n  /** Trailing actions (e.g. refresh), pinned to the right. */\n  actions?: ReactNode;\n}\n\nconst HIDDEN_SCROLLBAR_CLASSNAME =\n  \"[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\";\n\n/**\n * Horizontal strip of open-file tabs. A path only appears after the user or\n * agent opens it. Overflow scrolls horizontally without a visible scrollbar.\n */\nexport function FileQuickRow({\n  openPaths,\n  selectedPath,\n  onSelectFile,\n  onCloseFile,\n  isTreeVisible,\n  onToggleTree,\n  actions,\n}: FileQuickRowProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <div\n      className=\"flex h-8.5 shrink-0 items-stretch gap-1.5 overflow-y-hidden border-b border-border px-2\"\n      data-testid=\"file-quick-row\"\n    >\n      <button\n        type=\"button\"\n        onClick={onToggleTree}\n        data-testid=\"file-quick-row-tree-toggle\"\n        aria-pressed={isTreeVisible}\n        aria-label={t(\n          isTreeVisible\n            ? I18nKey.FILES$HIDE_FILE_TREE\n            : I18nKey.FILES$SHOW_FILE_TREE,\n        )}\n        title={t(\n          isTreeVisible\n            ? I18nKey.FILES$HIDE_FILE_TREE\n            : I18nKey.FILES$SHOW_FILE_TREE,\n        )}\n        className={cn(\n          \"shrink-0 self-center inline-flex items-center justify-center w-6 h-6 rounded-md cursor-pointer\",\n          \"text-text-tertiary hover:bg-tertiary\",\n          isTreeVisible && \"bg-surface-raised\",\n        )}\n      >\n        <ListTree className=\"w-3 h-3\" aria-hidden strokeWidth={2} />\n      </button>\n\n      {openPaths.length > 0 ? (\n        <div\n          role=\"tablist\"\n          aria-label={t(I18nKey.COMMON$FILES)}\n          className={cn(\n            \"flex min-h-0 min-w-0 flex-1 flex-nowrap items-stretch overflow-x-auto overflow-y-hidden overscroll-y-none\",\n            HIDDEN_SCROLLBAR_CLASSNAME,\n          )}\n        >\n          {openPaths.map((path, index) => {\n            const isSelected = selectedPath === path;\n            const fileName = path.split(\"/\").pop() || path;\n            return (\n              <div\n                key={path}\n                className={cn(\n                  \"group/file-tab relative flex shrink-0 items-stretch\",\n                  // Vertical edges on every tab (left on the first, right on all).\n                  \"border-r border-r-border\",\n                  index === 0 && \"border-l border-l-border\",\n                  \"border-b-2 -mb-px transition-colors\",\n                  isSelected\n                    ? \"border-b-contrast text-contrast\"\n                    : \"border-b-transparent text-muted hover:text-contrast hover:border-b-contrast/25\",\n                )}\n              >\n                <button\n                  type=\"button\"\n                  role=\"tab\"\n                  aria-selected={isSelected}\n                  onClick={() => onSelectFile(path)}\n                  title={path}\n                  data-testid={`file-quick-row-item-${path}`}\n                  className=\"flex min-w-0 max-w-40 items-center pl-2.5 pr-1 text-xs cursor-pointer text-inherit\"\n                >\n                  <span className=\"truncate\">{fileName}</span>\n                </button>\n                <button\n                  type=\"button\"\n                  data-testid={`file-quick-row-close-${path}`}\n                  aria-label={t(I18nKey.FILES$CLOSE_TAB, { path })}\n                  title={t(I18nKey.FILES$CLOSE_TAB, { path })}\n                  className={cn(\n                    \"inline-flex items-center justify-center size-5 self-center mr-1 rounded-sm shrink-0 cursor-pointer\",\n                    \"text-inherit hover:bg-contrast/10\",\n                    // Always visible on small / touch-first viewports; hover to\n                    // reveal on fine pointers (desktop).\n                    \"opacity-100 transition-opacity\",\n                    \"md:opacity-0 md:group-hover/file-tab:opacity-100 md:group-focus-within/file-tab:opacity-100\",\n                  )}\n                  onClick={() => onCloseFile(path)}\n                >\n                  <CloseIcon width={10} height={10} aria-hidden />\n                </button>\n              </div>\n            );\n          })}\n        </div>\n      ) : (\n        <div className=\"flex-1 min-w-0\" />\n      )}\n\n      {actions ? (\n        <div className=\"ml-auto shrink-0 self-center flex items-center gap-1\">\n          {actions}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/files-tab/highlighted-source-view.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 53,
          "column": 16,
          "nodeType": "CallExpression",
          "messageId": "dynamicStyle",
          "endLine": 53,
          "endColumn": 53
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { SyntaxHighlighter } from \"#/components/features/markdown/syntax-highlighter\";\nimport { useColorTheme } from \"#/hooks/use-color-theme\";\nimport { getSyntaxHighlighterTheme } from \"#/themes/syntax-highlighter-themes\";\nimport { getPrismLanguageForFile } from \"#/utils/file-language\";\n\ninterface HighlightedSourceViewProps {\n  path: string;\n  text: string;\n  mimeType?: string;\n}\n\n/**\n * Renders the raw bytes of a workspace text file with Prism syntax\n * highlighting. Used both in:\n *   - Rich mode for actual source files (.ts, .py, .yaml, \u2026) \u2014 there is\n *     no \"rich\" rendering of source code, so highlighted source IS the\n *     rich view.\n *   - Plain mode for source code AND for the source form of markdown /\n *     HTML files (so users can inspect the markup behind a rich preview).\n *\n * When we don't have a Prism grammar for the file we fall through to a\n * plain `<pre>` so the bytes still show. The wrapper styling matches the\n * right-pane background so the highlighted block reads as part of the\n * surrounding chrome instead of a floating card.\n */\nexport function HighlightedSourceView({\n  path,\n  text,\n  mimeType,\n}: HighlightedSourceViewProps) {\n  const colorTheme = useColorTheme();\n  const language = getPrismLanguageForFile(path, mimeType);\n\n  if (!language) {\n    return (\n      <pre\n        data-testid=\"file-content-viewer-plain\"\n        className=\"h-full w-full overflow-auto whitespace-pre-wrap break-words bg-surface p-4 text-xs leading-5 text-contrast custom-scrollbar-always\"\n      >\n        {text}\n      </pre>\n    );\n  }\n\n  return (\n    <div\n      data-testid=\"file-content-viewer-highlighted\"\n      data-language={language}\n      className=\"h-full w-full overflow-auto bg-surface custom-scrollbar-always\"\n    >\n      <SyntaxHighlighter\n        language={language}\n        style={getSyntaxHighlighterTheme(colorTheme)}\n        showLineNumbers\n        wrapLongLines={false}\n        // Override the theme's hard-coded background so the highlighter\n        // blends with the right-pane chrome instead of painting a slab\n        // of a slightly-different dark color.\n        customStyle={{\n          margin: 0,\n          padding: \"1rem\",\n          background: \"transparent\",\n          fontSize: \"0.75rem\",\n          lineHeight: \"1.25rem\",\n          minHeight: \"100%\",\n        }}\n        codeTagProps={{\n          style: { background: \"transparent\", fontFamily: \"inherit\" },\n        }}\n        lineNumberStyle={{\n          color: \"var(--oh-border)\",\n          minWidth: \"2.5em\",\n          paddingRight: \"1em\",\n          userSelect: \"none\",\n        }}\n      >\n        {text}\n      </SyntaxHighlighter>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/files-tab/workspace-file-discovery-settings.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[90vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 63,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 63,
          "endColumn": 55
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useWorkspaceFileDiscovery } from \"#/hooks/query/use-workspace-file-discovery\";\nimport {\n  DEFAULT_FILE_DISCOVERY,\n  isValidFileLimit,\n  type WorkspaceFileDiscovery,\n} from \"#/utils/workspace-file-discovery\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  ModalBody,\n} from \"#/components/shared/modals/modal-body\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { cn } from \"#/utils/utils\";\n\nfunction DiscoveryForm({\n  options,\n  save,\n  isSaving,\n  onClose,\n}: {\n  options: WorkspaceFileDiscovery;\n  save: (options: WorkspaceFileDiscovery) => Promise<unknown>;\n  isSaving: boolean;\n  onClose: () => void;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  // An unsaved form draft is discarded on cancel or workspace navigation.\n  const [patterns, setPatterns] = useState(options.excludedPatterns.join(\"\\n\"));\n  const [limit, setLimit] = useState(String(options.maxFiles));\n  const [includeSymlinks, setIncludeSymlinks] = useState(\n    options.includeSymlinks,\n  );\n  const [saveFailed, setSaveFailed] = useState(false);\n  const maxFiles = Number(limit);\n  const valid =\n    limit.trim() !== \"\" &&\n    isValidFileLimit(maxFiles) &&\n    !patterns.includes(\"\\0\");\n  const submit = async (value: WorkspaceFileDiscovery) => {\n    setSaveFailed(false);\n    try {\n      await save(value);\n      onClose();\n    } catch {\n      setSaveFailed(true);\n    }\n  };\n\n  return (\n    <ModalBackdrop\n      onClose={onClose}\n      aria-label={t(I18nKey.FILES$DISCOVERY_SETTINGS)}\n      closeOnEscape={!isSaving}\n      closeOnBackdropClick={!isSaving}\n    >\n      <ModalBody\n        testID=\"file-discovery-settings-modal\"\n        width=\"md\"\n        className={cn(\n          \"items-stretch max-h-[90vh] overflow-y-auto\",\n          MODAL_MAX_WIDTH_VIEWPORT,\n        )}\n      >\n        <h2 className=\"text-lg font-semibold\">\n          {t(I18nKey.FILES$DISCOVERY_SETTINGS)}\n        </h2>\n        <p className=\"text-sm text-muted\">{t(I18nKey.FILES$DISCOVERY_SCOPE)}</p>\n        <form\n          className=\"flex flex-col gap-4\"\n          onSubmit={(event) => {\n            event.preventDefault();\n            if (valid && !isSaving)\n              void submit({\n                maxFiles,\n                includeSymlinks,\n                excludedPatterns: patterns\n                  .split(/\\r?\\n/)\n                  .map((pattern) => pattern.trim())\n                  .filter(Boolean),\n              });\n          }}\n        >\n          <label className=\"flex flex-col gap-2 text-sm\">\n            {t(I18nKey.FILES$DISCOVERY_EXCLUSIONS)}\n            <textarea\n              value={patterns}\n              onChange={(event) => setPatterns(event.target.value)}\n              rows={5}\n              disabled={isSaving}\n              className=\"rounded-md border border-border bg-canvas-base px-3 py-2 font-mono\"\n            />\n            <span className=\"text-xs text-muted\">\n              {t(I18nKey.FILES$DISCOVERY_PATTERN_HELP)}\n            </span>\n          </label>\n          <label className=\"flex flex-col gap-2 text-sm\">\n            {t(I18nKey.FILES$DISCOVERY_LIMIT)}\n            <input\n              type=\"number\"\n              min={0}\n              step={1}\n              max={Number.MAX_SAFE_INTEGER - 1}\n              required\n              value={limit}\n              disabled={isSaving}\n              onChange={(event) => setLimit(event.target.value)}\n              className=\"rounded-md border border-border bg-canvas-base px-3 py-2\"\n            />\n          </label>\n          <label className=\"flex items-center gap-2 text-sm\">\n            <input\n              type=\"checkbox\"\n              checked={includeSymlinks}\n              disabled={isSaving}\n              onChange={(event) => setIncludeSymlinks(event.target.checked)}\n            />\n            {t(I18nKey.FILES$DISCOVERY_SYMLINKS)}\n          </label>\n          {saveFailed && (\n            <p role=\"alert\" className=\"text-sm text-danger\">\n              {t(I18nKey.FILES$DISCOVERY_SAVE_ERROR)}\n            </p>\n          )}\n          <div className=\"flex flex-wrap justify-end gap-2\">\n            <BrandButton\n              type=\"button\"\n              variant=\"secondary\"\n              isDisabled={isSaving}\n              onClick={() => {\n                setPatterns(DEFAULT_FILE_DISCOVERY.excludedPatterns.join(\"\\n\"));\n                setLimit(String(DEFAULT_FILE_DISCOVERY.maxFiles));\n                setIncludeSymlinks(DEFAULT_FILE_DISCOVERY.includeSymlinks);\n              }}\n            >\n              {t(I18nKey.FILES$DISCOVERY_RESET)}\n            </BrandButton>\n            <BrandButton\n              type=\"button\"\n              variant=\"secondary\"\n              isDisabled={isSaving}\n              onClick={onClose}\n            >\n              {t(I18nKey.BUTTON$CANCEL)}\n            </BrandButton>\n            <BrandButton\n              type=\"submit\"\n              variant=\"primary\"\n              isDisabled={!valid || isSaving}\n            >\n              {t(isSaving ? I18nKey.SETTINGS$SAVING : I18nKey.BUTTON$SAVE)}\n            </BrandButton>\n          </div>\n        </form>\n      </ModalBody>\n    </ModalBackdrop>\n  );\n}\n\nfunction DiscoveryControl({\n  discovery,\n}: {\n  discovery: ReturnType<typeof useWorkspaceFileDiscovery>;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const [isOpen, setIsOpen] = useState(false);\n  if (!discovery.canConfigure) return null;\n  return (\n    <>\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen(true)}\n        className=\"px-2 py-1 text-xs text-muted hover:text-contrast\"\n        data-testid=\"file-discovery-settings\"\n      >\n        {t(I18nKey.FILES$DISCOVERY_SETTINGS)}\n      </button>\n      {isOpen && (\n        <DiscoveryForm\n          options={discovery.options}\n          save={discovery.save}\n          isSaving={discovery.isSaving}\n          onClose={() => setIsOpen(false)}\n        />\n      )}\n    </>\n  );\n}\n\n// @spec WFD-002 \u2014 Workspace-scoped server persistence\nexport function WorkspaceFileDiscoverySettings({\n  workingDir,\n}: {\n  workingDir?: string;\n}) {\n  const discovery = useWorkspaceFileDiscovery(workingDir);\n  return <DiscoveryControl key={discovery.scopeKey} discovery={discovery} />;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/home/featured-automations/automation-run-activity-sparkline.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[1px]\" hardcodes an off-token value. Nearest on the scale: rounded-xs (2px), rounded-sm (4px).",
          "line": 69,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 69,
          "endColumn": 47
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[1px]\" hardcodes an off-token value. Nearest on the scale: rounded-xs (2px), rounded-sm (4px).",
          "line": 133,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 133,
          "endColumn": 55
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"group-hover/spark-bar:scale-x-[1.4]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 136,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 136,
          "endColumn": 87
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"group-hover/spark-bar:scale-y-[1.15]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 136,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 136,
          "endColumn": 87
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"group-focus-visible/spark-bar:scale-x-[1.4]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 137,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 137,
          "endColumn": 103
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"group-focus-visible/spark-bar:scale-y-[1.15]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 137,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 137,
          "endColumn": 103
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets height. Style through classes; use CSS custom properties for dynamic values.",
          "line": 140,
          "column": 28,
          "nodeType": "CallExpression",
          "messageId": "inlineStyle",
          "endLine": 140,
          "endColumn": 72
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 7,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { Tooltip } from \"@heroui/react\";\nimport { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { NavigationLink } from \"#/components/shared/navigation-link\";\nimport { AUTOMATION_RUN_ACTIVITY_LIMIT } from \"#/hooks/query/use-latest-automation-runs\";\nimport { isInFlightAutomationRun } from \"#/hooks/use-home-automation-actions\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type { AutomationRun } from \"#/types/automation\";\nimport { getAutomationRunDisplay } from \"#/utils/automation-run-display\";\nimport { formatRelativeTime } from \"#/utils/format-relative-time\";\nimport { cn } from \"#/utils/utils\";\nimport { getLastRunTimestamp } from \"./automation-run-health\";\nimport {\n  AUTOMATION_RUN_ACTIVITY_TRACK_PX,\n  barColorClassForStatus,\n  durationMsToSparklineBarHeightPx,\n  formatDurationForTitle,\n  getAutomationRunDurationMs,\n  getAutomationRunStatusLabelKey,\n} from \"./automation-run-activity-metrics\";\n\ninterface AutomationRunActivitySparklineProps {\n  automationId: string;\n  /** Newest-first run list (same order as the runs API). */\n  runs: readonly AutomationRun[];\n  testId?: string;\n}\n\nfunction TooltipRow({\n  label,\n  children,\n}: {\n  label: string;\n  children: ReactNode;\n}) {\n  return (\n    <div className=\"flex items-start gap-2 text-xs\">\n      <span className=\"w-16 shrink-0 text-muted\">{label}</span>\n      <span className=\"min-w-0 flex-1 break-words text-contrast\">\n        {children}\n      </span>\n    </div>\n  );\n}\n\nfunction RunActivityBarTooltip({\n  run,\n  nowMs,\n}: {\n  run: AutomationRun;\n  nowMs: number;\n}) {\n  const { t, i18n } = useTranslation(\"openhands\");\n  const timestamp = getLastRunTimestamp(run);\n  const when = timestamp\n    ? formatRelativeTime(timestamp, i18n.language, t)\n    : null;\n  const durationMs = getAutomationRunDurationMs(run, nowMs);\n  const durationLabel = formatDurationForTitle(durationMs);\n  const display = getAutomationRunDisplay(run);\n  const statusLabel = t(getAutomationRunStatusLabelKey(display.badgeStatus));\n\n  return (\n    <div className=\"flex w-55 flex-col gap-2 p-3\">\n      <div className=\"flex items-center gap-2\">\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"h-3 w-1.5 shrink-0 rounded-[1px]\",\n            barColorClassForStatus(display.badgeStatus),\n          )}\n        />\n        <span className=\"text-sm font-medium text-contrast\">{statusLabel}</span>\n      </div>\n\n      <div className=\"flex flex-col gap-1\">\n        {when ? (\n          <TooltipRow label={t(I18nKey.AUTOMATIONS$DETAIL$LAST_RUN)}>\n            {when}\n          </TooltipRow>\n        ) : null}\n        {durationLabel ? (\n          <TooltipRow label={t(I18nKey.FEATURED_AUTOMATIONS$RUN_DURATION)}>\n            {durationLabel}\n          </TooltipRow>\n        ) : null}\n      </div>\n\n      {display.summary ? (\n        <p className=\"line-clamp-3 text-xs text-text-secondary\">\n          {display.summary}\n        </p>\n      ) : null}\n    </div>\n  );\n}\n\nfunction RunActivityBar({\n  automationId,\n  run,\n  nowMs,\n}: {\n  automationId: string;\n  run: AutomationRun;\n  nowMs: number;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const durationMs = getAutomationRunDurationMs(run, nowMs);\n  const display = getAutomationRunDisplay(run);\n  const statusLabel = t(getAutomationRunStatusLabelKey(display.badgeStatus));\n  const disableAnimation = import.meta.env.MODE === \"test\";\n  const href = `/automations/${encodeURIComponent(automationId)}?run=${encodeURIComponent(run.id)}`;\n\n  return (\n    <Tooltip\n      content={<RunActivityBarTooltip run={run} nowMs={nowMs} />}\n      placement=\"top\"\n      closeDelay={80}\n      delay={200}\n      disableAnimation={disableAnimation}\n      className=\"rounded-xl border border-border bg-base-secondary p-0 text-contrast shadow-xl\"\n    >\n      {/* Wider hit target than the 4px bar so cancelled/skipped greys are easy to inspect. */}\n      <NavigationLink\n        to={href}\n        aria-label={statusLabel}\n        className=\"group/spark-bar inline-flex h-full cursor-pointer items-end px-0.5 focus:outline-none focus-visible:ring-1 focus-visible:ring-focus\"\n        onMouseDown={(event) => event.stopPropagation()}\n        onClick={(event) => event.stopPropagation()}\n      >\n        <span\n          className={cn(\n            \"w-1 shrink-0 origin-bottom rounded-[1px]\",\n            \"transition-transform duration-100 ease-out motion-reduce:transition-none\",\n            // Grow the hovered/focused bar so it reads clearly among neighbors.\n            \"group-hover/spark-bar:scale-x-[1.4] group-hover/spark-bar:scale-y-[1.15]\",\n            \"group-focus-visible/spark-bar:scale-x-[1.4] group-focus-visible/spark-bar:scale-y-[1.15]\",\n            barColorClassForStatus(display.badgeStatus),\n          )}\n          style={{ height: durationMsToSparklineBarHeightPx(durationMs) }}\n        />\n      </NavigationLink>\n    </Tooltip>\n  );\n}\n\n/**\n * Colored-bar sparkline of recent automation runs (oldest \u2192 newest left to\n * right). Color encodes status; height encodes run duration (log-scaled).\n * Bars link to the automation detail page focused on that run.\n */\nexport function AutomationRunActivitySparkline({\n  automationId,\n  runs,\n  testId,\n}: AutomationRunActivitySparklineProps) {\n  const { t } = useTranslation(\"openhands\");\n  const hasInFlight = runs.some((run) => isInFlightAutomationRun(run));\n  const [nowMs, setNowMs] = useState(() => Date.now());\n\n  useEffect(() => {\n    if (!hasInFlight) return undefined;\n    // Tick while a run is in flight so bar height tracks elapsed time between\n    // the slower runs-list polls.\n    const id = window.setInterval(() => setNowMs(Date.now()), 1000);\n    return () => window.clearInterval(id);\n  }, [hasInFlight]);\n\n  if (runs.length === 0) return null;\n\n  // API is newest-first; reverse so the sparkline reads left\u2192right as time.\n  const bars = [...runs].reverse().slice(-AUTOMATION_RUN_ACTIVITY_LIMIT);\n\n  return (\n    <span\n      data-testid={testId}\n      aria-label={t(I18nKey.FEATURED_AUTOMATIONS$RUN_ACTIVITY_LABEL, {\n        count: bars.length,\n      })}\n      className=\"inline-flex h-(--automation-run-activity-track-height) shrink-0 items-end\"\n      style={\n        {\n          \"--automation-run-activity-track-height\": `${AUTOMATION_RUN_ACTIVITY_TRACK_PX}px`,\n        } as CSSProperties\n      }\n      onMouseDown={(event) => event.preventDefault()}\n    >\n      {bars.map((run, index) => (\n        <RunActivityBar\n          key={`${run.id}-${index}`}\n          automationId={automationId}\n          run={run}\n          nowMs={nowMs}\n        />\n      ))}\n    </span>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/home/featured-automations/home-automation-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 149,
          "column": 22,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 149,
          "endColumn": 35
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import {\n  useEffect,\n  useLayoutEffect,\n  useReducer,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { KebabMenuItemContent } from \"#/components/features/automations/kebab-menu-item-content\";\nimport { ContextMenuListItem } from \"#/components/features/context-menu/context-menu-list-item\";\nimport { EllipsisButton } from \"#/components/features/conversation-panel/ellipsis-button\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { MenuSeparator } from \"#/components/features/conversation-panel/menu-separator\";\nimport { ContextMenu } from \"#/ui/context-menu\";\nimport type { HomeAutomationMenuEntry } from \"./build-home-automation-menu-items\";\n\ninterface HomeAutomationMenuProps {\n  testId: string;\n  panelTestId: string;\n  ariaLabel: string;\n  items: HomeAutomationMenuEntry[];\n  triggerClassName?: string;\n}\n\n/**\n * Shared home kebab: portal menu, Escape/arrow keys, focus restore to trigger.\n */\nexport function HomeAutomationMenu({\n  testId,\n  panelTestId,\n  ariaLabel,\n  items,\n  triggerClassName,\n}: HomeAutomationMenuProps) {\n  const [menuOpen, setMenuOpen] = useState(false);\n  const [activeIndex, setActiveIndex] = useState(0);\n  const anchorRef = useRef<HTMLButtonElement>(null);\n  const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n  const [, bumpPosition] = useReducer((i: number) => i + 1, 0);\n\n  const closeMenu = () => {\n    setMenuOpen(false);\n    // Restore focus so keyboard users land back on the ellipsis.\n    queueMicrotask(() => anchorRef.current?.focus());\n  };\n\n  const menuRef = useClickOutsideElement<HTMLUListElement>(\n    closeMenu,\n    anchorRef,\n  );\n\n  const actionableIndexes = items\n    .map((entry, index) =>\n      entry.kind === \"item\" && !entry.disabled ? index : -1,\n    )\n    .filter((index) => index >= 0);\n\n  useLayoutEffect(() => {\n    if (!menuOpen) return undefined;\n    bumpPosition();\n    setActiveIndex(actionableIndexes[0] ?? 0);\n    let frame = 0;\n    const update = () => {\n      if (frame) return;\n      frame = window.requestAnimationFrame(() => {\n        frame = 0;\n        bumpPosition();\n      });\n    };\n    window.addEventListener(\"resize\", update);\n    window.addEventListener(\"scroll\", update, true);\n    return () => {\n      window.removeEventListener(\"resize\", update);\n      window.removeEventListener(\"scroll\", update, true);\n      if (frame) window.cancelAnimationFrame(frame);\n    };\n    // actionableIndexes is derived from items; re-run when the menu opens.\n  }, [menuOpen]);\n\n  useEffect(() => {\n    if (!menuOpen) return;\n    itemRefs.current[activeIndex]?.focus();\n  }, [activeIndex, menuOpen]);\n\n  const floatingStyle = (() => {\n    if (!menuOpen || !anchorRef.current) return undefined;\n    const rect = anchorRef.current.getBoundingClientRect();\n    const gutter = 8;\n    const vw = window.innerWidth;\n    const vh = window.innerHeight;\n    // The panel's real height is only measurable from the second render\n    // pass onward (the open effect forces one via bumpPosition); the first\n    // paint falls back to an items-derived estimate (~36px per row, ~9px\n    // per separator, plus panel padding).\n    const estimatedHeight = items.reduce(\n      (total, entry) => total + (entry.kind === \"separator\" ? 9 : 36),\n      10,\n    );\n    const menuHeight =\n      menuRef.current?.getBoundingClientRect().height ?? estimatedHeight;\n    const overflowsBelow = rect.bottom + 4 + menuHeight + gutter > vh;\n    return {\n      position: \"fixed\" as const,\n      // When the menu would clip at the viewport bottom, flip it above the\n      // trigger. Bottom-anchoring (rather than an estimated `top`) keeps\n      // the panel hugging the trigger whatever its actual height.\n      ...(overflowsBelow\n        ? { bottom: vh - rect.top + 4 }\n        : { top: rect.bottom + 4 }),\n      right: Math.max(gutter, vw - rect.right),\n      zIndex: 100_000,\n    };\n  })();\n\n  const portalTarget = typeof document !== \"undefined\" ? document.body : null;\n\n  const moveActive = (direction: 1 | -1) => {\n    if (actionableIndexes.length === 0) return;\n    const currentPos = actionableIndexes.indexOf(activeIndex);\n    const nextPos =\n      (currentPos + direction + actionableIndexes.length) %\n      actionableIndexes.length;\n    setActiveIndex(actionableIndexes[nextPos] ?? actionableIndexes[0]);\n  };\n\n  return (\n    <div className=\"relative shrink-0\">\n      <EllipsisButton\n        ref={anchorRef}\n        testId={testId}\n        ariaLabel={ariaLabel}\n        className={triggerClassName}\n        onClick={(event) => {\n          event.preventDefault();\n          event.stopPropagation();\n          setMenuOpen((open) => !open);\n        }}\n      />\n\n      {menuOpen && floatingStyle && portalTarget\n        ? createPortal(\n            <ContextMenu\n              ref={menuRef}\n              testId={panelTestId}\n              theme=\"popover\"\n              position=\"none\"\n              alignment=\"none\"\n              spacing=\"none\"\n              style={floatingStyle}\n              className=\"min-w-40\"\n              onKeyDown={(event) => {\n                if (event.key === \"Escape\") {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  closeMenu();\n                  return;\n                }\n                if (event.key === \"ArrowDown\") {\n                  event.preventDefault();\n                  moveActive(1);\n                  return;\n                }\n                if (event.key === \"ArrowUp\") {\n                  event.preventDefault();\n                  moveActive(-1);\n                }\n              }}\n            >\n              {items.map((entry, index) => {\n                if (entry.kind === \"separator\") {\n                  return <MenuSeparator key={entry.key} />;\n                }\n\n                return (\n                  <li key={entry.key}>\n                    <ContextMenuListItem\n                      ref={(node) => {\n                        itemRefs.current[index] = node;\n                      }}\n                      testId={entry.testId}\n                      isDisabled={entry.disabled}\n                      className=\"group\"\n                      onClick={(event) => {\n                        event.preventDefault();\n                        event.stopPropagation();\n                        if (entry.disabled) return;\n                        entry.onClick();\n                        closeMenu();\n                      }}\n                    >\n                      <KebabMenuItemContent\n                        icon={entry.icon}\n                        label={entry.label}\n                      />\n                    </ContextMenuListItem>\n                  </li>\n                );\n              })}\n            </ContextMenu>,\n            portalTarget,\n          )\n        : null}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/home/featured-automations/pinned-automation-card.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"shadow-[inset_0_2px_0_0_var(--oh-focus)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 158,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 158,
          "endColumn": 53
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"shadow-[inset_0_-2px_0_0_var(--oh-focus)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 161,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 161,
          "endColumn": 54
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { Tooltip } from \"@heroui/react\";\nimport { ExternalLink } from \"lucide-react\";\nimport { useMemo, useRef } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { buildAutomationMetadataPills } from \"#/components/features/automations/build-automation-pills\";\nimport {\n  RunPhase,\n  shouldShowRunPhase,\n} from \"#/components/features/automations/detail/run-phase\";\nimport { RunStatusBadge } from \"#/components/features/automations/detail/run-status-badge\";\nimport { AutomationRunStats } from \"#/components/features/automations/automation-run-insights\";\nimport { toRunSummaryState } from \"#/components/features/automations/to-latest-run-state\";\nimport { TurnOffConfirmationModal } from \"#/components/features/automations/turn-off-confirmation-modal\";\nimport { getDashboardSpec } from \"#/manifests/automation-interface\";\nimport { SkillCardPillRow } from \"#/components/features/skills/skill-card-pill-row\";\nimport { NavigationLink } from \"#/components/shared/navigation-link\";\nimport type { LatestAutomationRunState } from \"#/hooks/query/use-latest-automation-runs\";\nimport { useHomeAutomationActions } from \"#/hooks/use-home-automation-actions\";\nimport { getDemoConversationTitle } from \"#/fixtures/home-automations-demo\";\nimport { useUserConversation } from \"#/hooks/query/use-user-conversation\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { AutomationRunStatus, type Automation } from \"#/types/automation\";\nimport { getAutomationRunDisplay } from \"#/utils/automation-run-display\";\nimport { formatRelativeTime } from \"#/utils/format-relative-time\";\nimport { cn } from \"#/utils/utils\";\nimport { automationCardStatusStripClassName } from \"#/components/features/automations/automation-view-mode\";\nimport {\n  extensionModuleCardInteractiveClassName,\n  extensionModuleCardSurfaceClassName,\n} from \"#/utils/extension-module-card-classes\";\nimport { AutomationRunActivitySparkline } from \"./automation-run-activity-sparkline\";\nimport { buildPinnedAutomationMenuItems } from \"./build-pinned-automation-menu-items\";\nimport { HomeAutomationMenu } from \"./home-automation-menu\";\nimport {\n  getLastRunTimestamp,\n  shortenAutomationRunSummary,\n  shouldShowAutomationRunSummaryHovercard,\n} from \"./automation-run-health\";\n\ninterface PinnedAutomationCardProps {\n  automation: Automation;\n  runState: LatestAutomationRunState;\n  onUnpin: (automationId: string) => void;\n  onDragStart: (automationId: string) => void;\n  onDragOver: (automationId: string, position: \"before\" | \"after\") => void;\n  onDrop: (automationId: string) => void;\n  onDragEnd: () => void;\n  isDropTarget: boolean;\n  dropPosition: \"before\" | \"after\" | null;\n  isDragging: boolean;\n}\n\n/**\n * Home pinned card. Shares the Automations dashboard tile chrome (surface,\n * header, pills, status strip) and keeps pin-only extras: drag-to-reorder,\n * conversation title, and unpin.\n */\nexport function PinnedAutomationCard({\n  automation,\n  runState,\n  onUnpin,\n  onDragStart,\n  onDragOver,\n  onDrop,\n  onDragEnd,\n  isDropTarget,\n  dropPosition,\n  isDragging,\n}: PinnedAutomationCardProps) {\n  const { t, i18n } = useTranslation(\"openhands\");\n  const { latestRun, recentRuns, isLoading, isError } = runState;\n  const actions = useHomeAutomationActions(automation, latestRun);\n  const conversationId = latestRun?.conversation_id ?? null;\n  const demoConversationTitle = conversationId\n    ? getDemoConversationTitle(conversationId)\n    : undefined;\n  const { data: conversation } = useUserConversation(\n    demoConversationTitle !== undefined ? null : conversationId,\n  );\n  const conversationTitle =\n    demoConversationTitle !== undefined\n      ? demoConversationTitle\n      : (conversation?.title ?? null);\n\n  const timestamp = latestRun ? getLastRunTimestamp(latestRun) : null;\n  const isTerminal =\n    latestRun?.status === AutomationRunStatus.COMPLETED ||\n    latestRun?.status === AutomationRunStatus.FAILED;\n  const scheduleLabel =\n    automation.trigger.schedule_human || automation.trigger.type;\n  const pills = useMemo(\n    () => buildAutomationMetadataPills(automation, scheduleLabel),\n    [automation, scheduleLabel],\n  );\n  const showPhase = shouldShowRunPhase(latestRun?.status);\n  const display = latestRun ? getAutomationRunDisplay(latestRun) : null;\n  const summary = display?.summary ?? null;\n  const shortSummary = summary ? shortenAutomationRunSummary(summary) : null;\n  const showSummaryHovercard =\n    summary != null &&\n    shortSummary != null &&\n    shouldShowAutomationRunSummaryHovercard(summary, shortSummary);\n  const disableAnimation = import.meta.env.MODE === \"test\";\n  const cardRef = useRef<HTMLElement>(null);\n  const insights = getDashboardSpec()?.insights;\n\n  const menuItems = buildPinnedAutomationMenuItems({\n    automation,\n    t,\n    canManage: actions.canManage,\n    isRunPending: actions.isRunPending,\n    isCancelPending: actions.isCancelPending,\n    canCancel: actions.canCancel,\n    onRunNow: actions.runNow,\n    onCancelRun: actions.cancelRun,\n    onView: actions.viewDetails,\n    onEdit: actions.openEdit,\n    onTurnOff: actions.requestTurnOff,\n    onUnpin: () => onUnpin(automation.id),\n  });\n\n  return (\n    <article\n      ref={cardRef}\n      role=\"listitem\"\n      data-testid={`pinned-automation-card-${automation.id}`}\n      onDragOver={(event) => {\n        event.preventDefault();\n        const { dataTransfer } = event;\n        dataTransfer.dropEffect = \"move\";\n        const rect = event.currentTarget.getBoundingClientRect();\n        // 2-col grid: prefer horizontal edge when the pointer is clearly\n        // left/right of center; otherwise use vertical before/after.\n        const xRatio = (event.clientX - rect.left) / rect.width;\n        const yRatio = (event.clientY - rect.top) / rect.height;\n        const useHorizontal = xRatio < 0.3 || xRatio > 0.7;\n        const position = useHorizontal\n          ? xRatio < 0.5\n            ? \"before\"\n            : \"after\"\n          : yRatio < 0.5\n            ? \"before\"\n            : \"after\";\n        onDragOver(automation.id, position);\n      }}\n      onDrop={(event) => {\n        event.preventDefault();\n        onDrop(automation.id);\n      }}\n      onDragEnd={onDragEnd}\n      className={cn(\n        \"group relative flex min-w-0 flex-col overflow-hidden p-4 text-left\",\n        extensionModuleCardSurfaceClassName,\n        extensionModuleCardInteractiveClassName,\n        isDragging && \"opacity-50\",\n        isDropTarget &&\n          dropPosition === \"before\" &&\n          \"shadow-[inset_0_2px_0_0_var(--oh-focus)]\",\n        isDropTarget &&\n          dropPosition === \"after\" &&\n          \"shadow-[inset_0_-2px_0_0_var(--oh-focus)]\",\n      )}\n    >\n      {/*\n        Top padding + title row is the drag surface. Title is not an <a> so\n        browsers don't steal the gesture; click still opens details. The\n        menu is opted out via data-no-drag.\n      */}\n      <header className=\"flex flex-col gap-1.5\">\n        <div\n          draggable\n          data-testid={`pinned-automation-drag-${automation.id}`}\n          aria-label={t(I18nKey.FEATURED_AUTOMATIONS$REORDER_HANDLE, {\n            name: automation.name,\n          })}\n          className=\"-mx-4 -mt-4 flex cursor-grab items-center justify-between gap-3 px-4 pt-4 active:cursor-grabbing\"\n          onDragStart={(event) => {\n            const target = event.target as HTMLElement | null;\n            if (target?.closest(\"[data-no-drag]\")) {\n              event.preventDefault();\n              return;\n            }\n            const { dataTransfer } = event;\n            dataTransfer.effectAllowed = \"move\";\n            dataTransfer.setData(\"text/plain\", automation.id);\n            if (cardRef.current) {\n              dataTransfer.setDragImage(cardRef.current, 24, 24);\n            }\n            onDragStart(automation.id);\n          }}\n          onDragEnd={onDragEnd}\n        >\n          <span\n            role=\"link\"\n            tabIndex={0}\n            title={t(I18nKey.FEATURED_AUTOMATIONS$VIEW_DETAILS)}\n            className=\"h-8 min-w-0 flex-1 cursor-pointer truncate text-sm font-semibold leading-8 text-foreground hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus\"\n            onClick={() => actions.viewDetails()}\n            onKeyDown={(event) => {\n              if (event.key === \"Enter\" || event.key === \" \") {\n                event.preventDefault();\n                actions.viewDetails();\n              }\n            }}\n          >\n            {automation.name}\n          </span>\n\n          <div data-no-drag className=\"flex h-8 shrink-0 items-center\">\n            <HomeAutomationMenu\n              testId={`pinned-automation-menu-${automation.id}`}\n              panelTestId={`pinned-automation-menu-panel-${automation.id}`}\n              ariaLabel={t(I18nKey.FEATURED_AUTOMATIONS$ROW_MENU_LABEL, {\n                name: automation.name,\n              })}\n              triggerClassName=\"opacity-70 group-hover:opacity-100\"\n              items={menuItems}\n            />\n          </div>\n        </div>\n        {automation.prompt ? (\n          <p className=\"line-clamp-2 text-xs leading-relaxed text-text-secondary\">\n            {automation.prompt}\n          </p>\n        ) : null}\n      </header>\n\n      {pills.length > 0 || recentRuns.length > 0 ? (\n        <div\n          className={cn(\n            \"mt-3 flex items-center gap-3\",\n            pills.length > 0 ? \"justify-between\" : \"justify-end\",\n          )}\n        >\n          {pills.length > 0 ? (\n            <div className=\"min-w-0 flex-1 overflow-hidden\">\n              <SkillCardPillRow\n                pills={pills}\n                testId={`pinned-automation-pills-${automation.id}`}\n              />\n            </div>\n          ) : null}\n          {recentRuns.length > 0 ? (\n            <AutomationRunActivitySparkline\n              automationId={automation.id}\n              runs={recentRuns}\n              testId={`pinned-automation-activity-${automation.id}`}\n            />\n          ) : null}\n        </div>\n      ) : null}\n\n      <div className={automationCardStatusStripClassName}>\n        <div className=\"flex min-w-0 flex-1 items-center gap-2 overflow-hidden\">\n          {isLoading ? (\n            <div\n              className=\"h-3.5 w-3/4 animate-pulse rounded bg-surface-raised motion-reduce:animate-none\"\n              aria-hidden=\"true\"\n            />\n          ) : null}\n\n          {!isLoading && isError ? (\n            <p className=\"truncate text-text-secondary\">\n              {t(I18nKey.FEATURED_AUTOMATIONS$STATUS_UNAVAILABLE)}\n            </p>\n          ) : null}\n\n          {!isLoading && !isError && !latestRun ? (\n            <p className=\"truncate text-text-secondary\">\n              {t(I18nKey.AUTOMATIONS$DETAIL$NO_RUNS)}\n            </p>\n          ) : null}\n\n          {latestRun ? (\n            <>\n              <RunStatusBadge\n                status={display?.badgeStatus ?? latestRun.status}\n                iconOnly\n                showLabel={\n                  latestRun.status === AutomationRunStatus.PENDING ||\n                  // Running with no conversation title yet \u2192 same treatment as Pending.\n                  (latestRun.status === AutomationRunStatus.RUNNING &&\n                    !conversationTitle)\n                }\n              />\n\n              {showPhase ? (\n                <RunPhase currentPhase={latestRun.current_phase} />\n              ) : null}\n\n              {shortSummary ? (\n                showSummaryHovercard && summary ? (\n                  <Tooltip\n                    content={\n                      <p className=\"max-w-xs whitespace-pre-wrap break-words p-2 text-xs\">\n                        {summary}\n                      </p>\n                    }\n                    placement=\"top\"\n                    closeDelay={100}\n                    disableAnimation={disableAnimation}\n                    className=\"rounded-xl border border-border bg-base-secondary p-0 text-contrast shadow-xl\"\n                  >\n                    <span className=\"min-w-0 flex-1 cursor-default truncate text-text-secondary\">\n                      {shortSummary}\n                    </span>\n                  </Tooltip>\n                ) : (\n                  <p className=\"min-w-0 flex-1 truncate text-text-secondary\">\n                    {shortSummary}\n                  </p>\n                )\n              ) : null}\n\n              {conversationId && conversationTitle ? (\n                <NavigationLink\n                  to={`/conversations/${conversationId}`}\n                  aria-label={conversationTitle}\n                  title={conversationTitle}\n                  className=\"group/conversation inline-flex min-w-0 items-center gap-1 text-foreground hover:text-text-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus\"\n                >\n                  <span className=\"truncate\">{conversationTitle}</span>\n                  <ExternalLink\n                    className=\"size-3 shrink-0 opacity-0 transition-opacity group-hover/conversation:opacity-100 group-focus-visible/conversation:opacity-100\"\n                    aria-hidden=\"true\"\n                  />\n                </NavigationLink>\n              ) : null}\n\n              {!conversationId && isTerminal ? (\n                <p className=\"min-w-0 truncate text-text-secondary\">\n                  {t(I18nKey.AUTOMATIONS$DETAIL$NO_CONVERSATION)}\n                </p>\n              ) : null}\n            </>\n          ) : null}\n        </div>\n\n        {timestamp ? (\n          <span className=\"shrink-0 text-text-secondary\">\n            {formatRelativeTime(timestamp, i18n.language, t)}\n          </span>\n        ) : null}\n      </div>\n\n      {insights ? (\n        <div className=\"mt-3\">\n          <AutomationRunStats\n            state={toRunSummaryState(runState)}\n            copy={insights.stats}\n          />\n        </div>\n      ) : null}\n\n      <TurnOffConfirmationModal\n        automationName={automation.name}\n        isOpen={actions.turnOffConfirmOpen}\n        onConfirm={actions.confirmTurnOff}\n        onCancel={actions.cancelTurnOff}\n      />\n    </article>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/home/featured-automations/running-automations-list.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"grid-cols-[auto_minmax(0,1fr)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 117,
          "column": 26,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 117,
          "endColumn": 86
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { Tooltip } from \"@heroui/react\";\nimport { Plus, Zap } from \"lucide-react\";\nimport { useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { RunStatusBadge } from \"#/components/features/automations/detail/run-status-badge\";\nimport { TurnOffConfirmationModal } from \"#/components/features/automations/turn-off-confirmation-modal\";\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\nimport { NavigationLink } from \"#/components/shared/navigation-link\";\nimport type { LatestAutomationRunState } from \"#/hooks/query/use-latest-automation-runs\";\nimport {\n  HOME_AUTOMATIONS_PREVIEW_LIMIT,\n  MAX_HOME_AUTOMATION_CHIPS,\n  UNKNOWN_RUN_STATE,\n  useHomeAutomations,\n} from \"#/hooks/query/use-home-automations\";\nimport { useHomeAutomationActions } from \"#/hooks/use-home-automation-actions\";\nimport { useHomePinnedAutomations } from \"#/hooks/use-home-pinned-automations\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport ClockIcon from \"#/icons/clock.svg?react\";\nimport type { Automation } from \"#/types/automation\";\nimport { extensionModuleCardPillClassName } from \"#/utils/extension-module-card-classes\";\nimport { cn } from \"#/utils/utils\";\nimport { AutomationRunActivitySparkline } from \"./automation-run-activity-sparkline\";\nimport { AutomationHealthIndicator } from \"./automation-health-indicator\";\nimport { buildHomeAutomationMenuItems } from \"./build-home-automation-menu-items\";\nimport { HomeAutomationMenu } from \"./home-automation-menu\";\nimport {\n  deriveRunHealth,\n  formatTriggerSourceLabel,\n  getTriggerEventLabel,\n  getTriggerScheduleLabel,\n  getTriggerSource,\n} from \"./automation-run-health\";\nimport {\n  automationActivityListClassName,\n  automationActivityRowClassName,\n} from \"#/components/features/automations/automation-view-mode\";\nimport {\n  buildHomeAutomationActivityItems,\n  hrefForActivityItem,\n  type HomeAutomationActivityItem,\n} from \"./home-automation-activity\";\nimport {\n  getRunStatusLabelKey,\n  HomeAutomationRunTooltip,\n} from \"./home-automation-run-tooltip\";\n\ninterface RunningAutomationRowProps {\n  item: HomeAutomationActivityItem;\n  automation: Automation;\n  runState: LatestAutomationRunState;\n}\n\nfunction RunningAutomationRow({\n  item,\n  automation,\n  runState,\n}: RunningAutomationRowProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { isPinned, togglePin } = useHomePinnedAutomations();\n  const actions = useHomeAutomationActions(automation, runState.latestRun);\n  const pinned = isPinned(item.id);\n  const isEventTrigger = automation.trigger.type === \"event\";\n  const TriggerIcon = isEventTrigger ? Zap : ClockIcon;\n  const triggerEventLabel = getTriggerEventLabel(automation);\n  const triggerScheduleLabel = getTriggerScheduleLabel(automation);\n  const triggerSource = getTriggerSource(automation);\n  const hasTriggerMeta = Boolean(\n    triggerEventLabel || triggerScheduleLabel || triggerSource,\n  );\n  const hasMeta =\n    hasTriggerMeta || Boolean(item.whenLabel) || Boolean(item.status);\n  const health = deriveRunHealth(runState);\n  const statusLabelKey = getRunStatusLabelKey(runState);\n  const disableAnimation = import.meta.env.MODE === \"test\";\n\n  const menuItems = buildHomeAutomationMenuItems({\n    automation,\n    t,\n    canManage: actions.canManage,\n    isRunPending: actions.isRunPending,\n    isCancelPending: actions.isCancelPending,\n    canCancel: actions.canCancel,\n    testIdPrefix: \"running-automation\",\n    pinTestId: `running-automation-pin-${item.id}`,\n    isPinned: pinned,\n    onRunNow: actions.runNow,\n    onCancelRun: actions.cancelRun,\n    onView: actions.viewDetails,\n    onEdit: actions.openEdit,\n    onTurnOff: actions.requestTurnOff,\n    onTogglePin: () => togglePin(item.id),\n  });\n\n  return (\n    <li\n      data-testid={`running-automation-row-${item.id}`}\n      className={automationActivityRowClassName}\n    >\n      <Tooltip\n        content={\n          <HomeAutomationRunTooltip\n            automation={automation}\n            runState={runState}\n          />\n        }\n        placement=\"top-start\"\n        closeDelay={100}\n        disableAnimation={disableAnimation}\n        className=\"rounded-xl border border-border bg-base-secondary p-0 text-contrast shadow-xl\"\n      >\n        <NavigationLink\n          to={hrefForActivityItem(item)}\n          aria-label={`${item.name} ${t(statusLabelKey)}`}\n          className=\"flex min-w-0 flex-1 items-center justify-between gap-3 px-3 py-2.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus\"\n        >\n          <div className=\"grid min-w-0 flex-1 grid-cols-[auto_minmax(0,1fr)] gap-x-2\">\n            <span className=\"inline-flex h-5 w-2.5 shrink-0 items-center justify-center\">\n              <AutomationHealthIndicator health={health} />\n            </span>\n            <span className=\"truncate text-sm font-medium leading-5 text-foreground\">\n              {item.name}\n            </span>\n            {hasMeta ? (\n              <span className=\"col-start-2 mt-0.5 flex min-w-0 items-center gap-1.5 text-xs leading-4 text-text-secondary\">\n                <TriggerIcon className=\"size-3 shrink-0\" aria-hidden=\"true\" />\n                {triggerEventLabel ? (\n                  <span className=\"truncate\">{triggerEventLabel}</span>\n                ) : null}\n                {triggerScheduleLabel ? (\n                  <span className=\"truncate\">{triggerScheduleLabel}</span>\n                ) : null}\n                {triggerSource ? (\n                  <span\n                    className={cn(\n                      extensionModuleCardPillClassName,\n                      \"shrink-0 px-1.5 py-0 text-text-secondary\",\n                    )}\n                  >\n                    {formatTriggerSourceLabel(triggerSource)}\n                  </span>\n                ) : null}\n                {item.whenLabel ? (\n                  <>\n                    {hasTriggerMeta ? (\n                      <span className=\"shrink-0\" aria-hidden=\"true\">\n                        \u00b7\n                      </span>\n                    ) : null}\n                    <span className=\"truncate\">{item.whenLabel}</span>\n                  </>\n                ) : null}\n                {item.status ? (\n                  <>\n                    {hasTriggerMeta || item.whenLabel ? (\n                      <span className=\"shrink-0\" aria-hidden=\"true\">\n                        \u00b7\n                      </span>\n                    ) : null}\n                    <RunStatusBadge status={item.status} compact />\n                  </>\n                ) : null}\n              </span>\n            ) : null}\n            <span className=\"sr-only\">{t(statusLabelKey)}</span>\n          </div>\n        </NavigationLink>\n      </Tooltip>\n\n      <div className=\"flex shrink-0 items-center gap-1.5 pr-1.5\">\n        <AutomationRunActivitySparkline\n          automationId={automation.id}\n          runs={runState.recentRuns}\n          testId={`running-automation-activity-${item.id}`}\n        />\n        <HomeAutomationMenu\n          testId={`running-automation-menu-${item.id}`}\n          panelTestId={`running-automation-menu-panel-${item.id}`}\n          ariaLabel={t(I18nKey.FEATURED_AUTOMATIONS$ROW_MENU_LABEL, {\n            name: item.name,\n          })}\n          triggerClassName=\"opacity-70 group-hover:opacity-100\"\n          items={menuItems}\n        />\n      </div>\n\n      <TurnOffConfirmationModal\n        automationName={automation.name}\n        isOpen={actions.turnOffConfirmOpen}\n        onConfirm={actions.confirmTurnOff}\n        onCancel={actions.cancelTurnOff}\n      />\n    </li>\n  );\n}\n\n/**\n * Recent automation activity under the home composer. Driven by live\n * enabled automations + latest-run queries; self-gates when the automation\n * service is unavailable or there are no enabled automations.\n */\nexport function RunningAutomationsList() {\n  const { t, i18n } = useTranslation(\"openhands\");\n  const [isExpanded, setIsExpanded] = useState(false);\n  const {\n    isBackendHealthy,\n    isHealthLoading,\n    isError,\n    isAutomationsLoading,\n    enabledAutomations,\n    runStates,\n  } = useHomeAutomations();\n\n  const language = i18n?.language ?? \"en\";\n  const items = useMemo(\n    () =>\n      buildHomeAutomationActivityItems(\n        enabledAutomations,\n        runStates,\n        language,\n        t,\n        UNKNOWN_RUN_STATE,\n      ),\n    [enabledAutomations, runStates, language, t],\n  );\n\n  const automationById = useMemo(\n    () =>\n      new Map(\n        enabledAutomations.map((automation) => [automation.id, automation]),\n      ),\n    [enabledAutomations],\n  );\n\n  const visibleLimit = isExpanded\n    ? MAX_HOME_AUTOMATION_CHIPS\n    : HOME_AUTOMATIONS_PREVIEW_LIMIT;\n  const visibleItems = items.slice(0, visibleLimit);\n  const canViewMore =\n    !isExpanded && items.length > HOME_AUTOMATIONS_PREVIEW_LIMIT;\n  const canViewAll = isExpanded && items.length >= MAX_HOME_AUTOMATION_CHIPS;\n\n  if (isHealthLoading || !isBackendHealthy || isError || isAutomationsLoading) {\n    return null;\n  }\n\n  const isEmpty = items.length === 0;\n\n  return (\n    <section\n      aria-labelledby=\"running-automations-heading\"\n      data-testid=\"running-automations-list\"\n      className=\"w-full\"\n    >\n      <div className=\"mb-2 flex items-center justify-between gap-2\">\n        <h2\n          id=\"running-automations-heading\"\n          className=\"text-sm font-medium text-foreground\"\n        >\n          {t(I18nKey.FEATURED_AUTOMATIONS$SECTION_TITLE)}\n        </h2>\n        <StyledTooltip\n          content={t(I18nKey.FEATURED_AUTOMATIONS$MANAGE)}\n          placement=\"bottom\"\n        >\n          <NavigationLink\n            to=\"/automations\"\n            data-testid=\"home-automations-manage\"\n            aria-label={t(I18nKey.FEATURED_AUTOMATIONS$MANAGE)}\n            className=\"inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:bg-interactive-hover hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-focus\"\n          >\n            <Plus className=\"size-3\" aria-hidden=\"true\" />\n            {t(I18nKey.BUTTON$ADD)}\n          </NavigationLink>\n        </StyledTooltip>\n      </div>\n\n      {isEmpty ? (\n        <p\n          data-testid=\"running-automations-empty-hint\"\n          className=\"rounded-xl border border-border-subtle bg-surface px-4 py-3 text-xs text-text-secondary\"\n        >\n          {t(I18nKey.FEATURED_AUTOMATIONS$EMPTY_HINT)}\n        </p>\n      ) : (\n        <ul\n          aria-label={t(I18nKey.FEATURED_AUTOMATIONS$RECENT_GROUP_LABEL)}\n          className={automationActivityListClassName}\n        >\n          {visibleItems.map((item) => {\n            const automation = automationById.get(item.id);\n            if (!automation) return null;\n            return (\n              <RunningAutomationRow\n                key={item.id}\n                item={item}\n                automation={automation}\n                runState={runStates.get(item.id) ?? UNKNOWN_RUN_STATE}\n              />\n            );\n          })}\n        </ul>\n      )}\n\n      {canViewMore ? (\n        <div className=\"mt-2 flex justify-start\">\n          <button\n            type=\"button\"\n            data-testid=\"home-automations-view-more\"\n            onClick={() => setIsExpanded(true)}\n            className=\"rounded-md px-1.5 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:bg-interactive-hover hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-focus\"\n          >\n            {t(I18nKey.COMMON$VIEW_MORE)}\n          </button>\n        </div>\n      ) : null}\n\n      {canViewAll ? (\n        <div className=\"mt-2 flex justify-start\">\n          <NavigationLink\n            to=\"/automations\"\n            data-testid=\"home-automations-view-all\"\n            className=\"rounded-md px-1.5 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:bg-interactive-hover hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-focus\"\n          >\n            {t(I18nKey.FEATURED_AUTOMATIONS$VIEW_ALL)}\n          </NavigationLink>\n        </div>\n      ) : null}\n    </section>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/home/home-chat-launcher.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"pt-[max(4rem,28vh)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 306,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 306,
          "endColumn": 78
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-text-secondary)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 334,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 334,
          "endColumn": 79
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useConversationWorkspace } from \"#/hooks/query/use-conversation-workspace\";\nimport { useEffect, useRef, useState } from \"react\";\nimport toast from \"react-hot-toast\";\nimport { useTranslation } from \"react-i18next\";\nimport { CustomChatInput } from \"#/components/features/chat/custom-chat-input\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useCreateConversation } from \"#/hooks/mutation/use-create-conversation\";\nimport { useLocalWorkspaces } from \"#/hooks/query/use-local-workspaces\";\nimport { useLlmProfiles } from \"#/hooks/query/use-llm-profiles\";\nimport { useModelInterceptor } from \"#/hooks/chat/use-model-interceptor\";\nimport { useLlmConfigured } from \"#/hooks/use-llm-configured\";\nimport { HOME_PROMPT_DRAFT_KEY } from \"#/hooks/chat/use-draft-persistence\";\nimport { useChatAttachmentUpload } from \"#/hooks/chat/use-chat-attachment-upload\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport { useModelStore } from \"#/stores/model-store\";\nimport type { WorkspaceMode } from \"#/api/conversation-metadata-store\";\nimport { setPendingTaskAttachments } from \"#/stores/pending-task-attachments-store\";\nimport { enqueueHomeTaskPendingMessage } from \"#/utils/enqueue-home-task-pending-message\";\nimport { sendMessageWithAttachments } from \"#/utils/send-message-with-attachments\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport { useIsCreatingConversation } from \"#/hooks/use-is-creating-conversation\";\nimport { Branch, GitRepository } from \"#/types/git\";\nimport { Provider } from \"#/types/settings\";\nimport { LocalWorkspace } from \"#/types/workspace\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  displayErrorToast,\n  TOAST_OPTIONS,\n} from \"#/utils/custom-toast-handlers\";\nimport { getApiErrorMessage } from \"#/utils/api-error-message\";\nimport { getWorkspacesUnsupportedMessage } from \"#/utils/workspaces-compatibility\";\nimport {\n  readStoredLocalWorkspaceMode,\n  writeStoredLocalWorkspaceMode,\n} from \"#/utils/workspace-mode\";\nimport type { PluginSpec } from \"#/api/conversation-service/agent-server-conversation-service.types\";\nimport { markAutomationSetupHandoff } from \"#/api/automation-setup-handoff-store\";\nimport { PluginPickerModal } from \"#/components/features/plugins/plugin-picker-modal\";\nimport { PluginPickerTrigger } from \"#/components/features/plugins/plugin-picker-trigger\";\nimport { RecommendedAutomationsLauncher } from \"#/components/features/automations/recommended-automations-launcher\";\nimport { useHomeRecommendedAutomationsDismissed } from \"./use-home-recommended-automations-dismissed\";\nimport { writeHomeRecommendedAutomationsDismissed } from \"./home-recommended-automations-storage\";\nimport { PinnedAutomationsDashboard } from \"./featured-automations/pinned-automations-dashboard\";\nimport { RunningAutomationsList } from \"./featured-automations/running-automations-list\";\nimport { HomeHeaderTitle } from \"./home-header/home-header-title\";\nimport {\n  HomeLauncherModeToggle,\n  type HomeLauncherMode,\n} from \"./home-launcher-mode-toggle\";\nimport { OpenLauncherButton } from \"./open-launcher-button\";\nimport { OpenWorkspaceDialog } from \"./open-workspace-dialog\";\nimport { OpenRepositoryDialog } from \"./open-repository-dialog\";\nimport { HomeGitControlBarPreview } from \"./home-git-control-bar-preview\";\n\nexport function HomeChatLauncher() {\n  const { t } = useTranslation(\"openhands\");\n  const { backend } = useActiveBackend();\n  const { navigate } = useNavigation();\n  const isLocal = backend.kind === \"local\";\n\n  const [isDialogOpen, setIsDialogOpen] = useState(false);\n  const [pendingWorkspace, setPendingWorkspace] =\n    useState<LocalWorkspace | null>(null);\n  const [pendingRepository, setPendingRepository] =\n    useState<GitRepository | null>(null);\n  const [pendingBranch, setPendingBranch] = useState<Branch | null>(null);\n  const [pendingProvider, setPendingProvider] = useState<Provider | null>(null);\n  const [workspaceMode, setWorkspaceModeState] = useState<WorkspaceMode>(() =>\n    readStoredLocalWorkspaceMode(),\n  );\n  const isRecommendedAutomationsDismissed =\n    useHomeRecommendedAutomationsDismissed();\n  const [selectedPlugins, setSelectedPlugins] = useState<PluginSpec[]>([]);\n  const [isPluginPickerOpen, setIsPluginPickerOpen] = useState(false);\n  const [launcherMode, setLauncherMode] = useState<HomeLauncherMode>(\"code\");\n  const displayedLauncherMode = launcherMode;\n  const isAutomateMode = launcherMode === \"automate\";\n  const handleLauncherModeChange = setLauncherMode;\n  const isMountedRef = useRef(true);\n\n  useEffect(() => {\n    isMountedRef.current = true;\n    return () => {\n      isMountedRef.current = false;\n    };\n  }, []);\n\n  // The launcher reports a failed create itself (see handleSubmit), so the\n  // global mutation error toast is turned off to keep it to one toast.\n  const { mutateAsync: createConversation, isPending } = useCreateConversation({\n    disableToast: true,\n  });\n  const isCreatingElsewhere = useIsCreatingConversation();\n  const isCreating = isPending || isCreatingElsewhere;\n  const { isConfigured: isLlmConfigured, isLoading: isLlmConfigLoading } =\n    useLlmConfigured();\n  // Block sending entirely when there's no usable LLM; the banner above the\n  // launcher (rendered by the home route) explains it and offers setup.\n  const llmBlocked = !isLlmConfigLoading && !isLlmConfigured;\n  const {\n    images,\n    files,\n    imagesMarkedUploadAsFile,\n    clearAllFiles,\n    restoreMessageToInputIfEmpty,\n  } = useConversationStore();\n  const { handleUpload } = useChatAttachmentUpload();\n  const homeLlmProfile = useModelStore((s) => s.homeLlmProfile);\n  const { data: llmProfiles } = useLlmProfiles();\n  const { error: workspacesError } = useLocalWorkspaces({ enabled: isLocal });\n  const { isolated, unsupportedMessage: runtimeWorkspaceMessage } =\n    useConversationWorkspace();\n  const workspacesUnsupportedMessage =\n    runtimeWorkspaceMessage ??\n    (isLocal ? getWorkspacesUnsupportedMessage(workspacesError, t) : null);\n\n  const setWorkspaceMode = (mode: WorkspaceMode) => {\n    setWorkspaceModeState(mode);\n    if (isLocal) writeStoredLocalWorkspaceMode(mode);\n  };\n\n  const hasSelection = isLocal\n    ? !!pendingWorkspace\n    : !!pendingRepository && !!pendingBranch;\n\n  const handleSubmit = (message: string) => {\n    const trimmed = message.trim();\n    const hasAttachments = images.length > 0 || files.length > 0;\n    if ((!trimmed && !hasAttachments) || isCreating) return;\n\n    // Safety net: the input is disabled when there's no usable LLM, but never\n    // create a conversation that can't run (it would fail with a cryptic\n    // API-key error on the first turn).\n    if (llmBlocked) return;\n\n    const attachmentSnapshot = {\n      images: [...images],\n      files: [...files],\n    };\n\n    // Workspace/repo are optional \u2014 match the \"Start from scratch\" flow which\n    // creates a conversation with no working dir and no repo. Build the\n    // payload from whatever is selected.\n    // When attachments are present the first user message is sent afterward\n    // via sendMessageWithAttachments / flushPendingTaskAttachments. Passing\n    // query here would create a duplicate text-only initial_message.\n    let variables: Parameters<typeof createConversation>[0] = {\n      query: hasAttachments ? undefined : trimmed || undefined,\n      automationSetup: isAutomateMode,\n      entryPoint: \"home_chat_launcher\",\n    };\n    // An isolated backend owns its workspace, so a host selection left over\n    // from a non-isolated session must not be forwarded: the server rejects it\n    // (`HOME$ISOLATED_WORKSPACE_NOTICE`) and the user sees an error toast for a\n    // selection they may not have noticed. Creation proceeds isolated instead;\n    // the launcher still offers an explicit \"clear\" affordance for the UI.\n    if (isLocal && pendingWorkspace && !isolated) {\n      variables = {\n        ...variables,\n        workingDir: pendingWorkspace.path,\n        workspaceMode,\n      };\n    } else if (!isLocal && pendingRepository && pendingBranch) {\n      variables = {\n        ...variables,\n        repository: {\n          name: pendingRepository.full_name,\n          gitProvider: pendingRepository.git_provider,\n          branch: pendingBranch.name,\n        },\n      };\n    }\n\n    // Explicitly-attached plugins are additive on top of any ambient set and\n    // are resolved from git at run time. Omitted entirely when none selected so\n    // nothing attaches unless the user picked it.\n    if (!isAutomateMode && selectedPlugins.length > 0) {\n      variables = { ...variables, plugins: selectedPlugins };\n    }\n\n    // A cloud member's home LLM pick (see useChatInputLlmProfileState), unless\n    // that profile has since gone.\n    if (\n      homeLlmProfile &&\n      llmProfiles?.profiles.some((p) => p.name === homeLlmProfile)\n    ) {\n      variables = { ...variables, llmProfile: homeLlmProfile };\n    }\n\n    const openAutomationSetupMode = (conversationId: string) => {\n      if (!isAutomateMode) return;\n      markAutomationSetupHandoff(conversationId);\n    };\n\n    // Loading toast gives the user a clear signal that the request is in\n    // flight; dismissed precisely once the mutation resolves.\n    const toastId = toast.loading(\n      t(I18nKey.HOME$CREATING_CONVERSATION),\n      TOAST_OPTIONS,\n    );\n\n    void (async () => {\n      let isCreated = false;\n      try {\n        const data = await createConversation(variables);\n        isCreated = true;\n        toast.dismiss(toastId);\n        try {\n          sessionStorage.removeItem(HOME_PROMPT_DRAFT_KEY);\n        } catch {\n          // sessionStorage not available\n        }\n        const targetConversationId = data.conversation_id;\n        const isTaskConversation = targetConversationId.startsWith(\"task-\");\n\n        if (hasAttachments) {\n          // Cloud sandboxes provision asynchronously; uploads and the first\n          // message must target the runtime URL, not the bundled local server.\n          const shouldDeferAttachments = !isLocal || isTaskConversation;\n\n          if (shouldDeferAttachments) {\n            const taskId =\n              data.task_id ??\n              (isTaskConversation\n                ? targetConversationId.slice(\"task-\".length)\n                : null);\n\n            if (!taskId) {\n              displayErrorToast(null);\n              return;\n            }\n\n            setPendingTaskAttachments(taskId, {\n              content: trimmed,\n              images: attachmentSnapshot.images,\n              files: attachmentSnapshot.files,\n              imagesMarkedUploadAsFile: [...imagesMarkedUploadAsFile],\n            });\n            clearAllFiles();\n            await enqueueHomeTaskPendingMessage({\n              conversationId: targetConversationId,\n              text: trimmed,\n              images: attachmentSnapshot.images,\n              imagesMarkedUploadAsFile,\n            });\n            openAutomationSetupMode(targetConversationId);\n            navigate(`/conversations/${targetConversationId}`);\n            return;\n          } else {\n            try {\n              await sendMessageWithAttachments({\n                conversationId: targetConversationId,\n                content: trimmed,\n                images: attachmentSnapshot.images,\n                files: attachmentSnapshot.files,\n                imagesMarkedUploadAsFile,\n                t,\n              });\n              clearAllFiles();\n            } catch (error) {\n              displayErrorToast(error instanceof Error ? error.message : null);\n              return;\n            }\n          }\n        }\n\n        if (isTaskConversation && trimmed) {\n          await enqueueHomeTaskPendingMessage({\n            conversationId: targetConversationId,\n            text: trimmed,\n            images: [],\n            imagesMarkedUploadAsFile: [],\n          });\n        }\n\n        openAutomationSetupMode(targetConversationId);\n        navigate(`/conversations/${targetConversationId}`);\n      } catch (error) {\n        toast.dismiss(toastId);\n        // Prefer the server's own message; without one, keep the launcher's\n        // existing wording (the error's message).\n        const fallback = error instanceof Error ? error.message : \"\";\n        displayErrorToast(getApiErrorMessage(error, fallback) || null);\n        // The composer cleared itself on submit; hand the prompt back so the\n        // user can retry without retyping it. Only when the create itself\n        // failed and this composer is still on screen: the request is consumed\n        // by whichever composer is mounted, so otherwise the prompt would\n        // replay into a conversation.\n        if (!isCreated && isMountedRef.current) {\n          restoreMessageToInputIfEmpty(message);\n        }\n      }\n    })();\n  };\n\n  // Without this wrapper a `/model NAME` typed here would become the first\n  // user message of the new conversation. The interceptor activates the\n  // profile globally (null conversationId path) so the next conversation\n  // launches with it.\n  const handleSubmitWithModelGuard = useModelInterceptor(null, handleSubmit);\n\n  return (\n    <div\n      data-testid=\"home-chat-launcher\"\n      className=\"flex w-full flex-col items-center pt-[max(4rem,28vh)] pb-10\"\n    >\n      <div className=\"flex w-full max-w-200 flex-col gap-4 md:px-4\">\n        <div className=\"flex w-full justify-center\">\n          <HomeHeaderTitle />\n        </div>\n\n        <div className=\"flex w-full justify-center\">\n          <HomeLauncherModeToggle\n            mode={displayedLauncherMode}\n            onChange={handleLauncherModeChange}\n          />\n        </div>\n\n        <div className=\"w-full\">\n          <CustomChatInput\n            onSubmit={handleSubmitWithModelGuard}\n            onFilesPaste={handleUpload}\n            disabled={isCreating || llmBlocked}\n            placeholder={\n              isAutomateMode\n                ? t(I18nKey.HOME$AUTOMATE_PROMPT_PLACEHOLDER)\n                : t(I18nKey.SUGGESTIONS$WHAT_TO_BUILD)\n            }\n          />\n        </div>\n\n        {isolated && (\n          <p role=\"status\" className=\"text-xs text-[var(--oh-text-secondary)]\">\n            {pendingWorkspace\n              ? runtimeWorkspaceMessage\n              : t(I18nKey.HOME$ISOLATED_WORKSPACE_NEW)}\n            {pendingWorkspace && (\n              <button\n                type=\"button\"\n                className=\"ml-2 underline\"\n                onClick={() => setPendingWorkspace(null)}\n              >\n                {t(I18nKey.HOME$CLEAR_HOST_WORKSPACE)}\n              </button>\n            )}\n          </p>\n        )}\n        <div\n          data-testid=\"home-composer-actions\"\n          aria-disabled={isAutomateMode}\n          className={cn(\n            \"flex items-center justify-start gap-2\",\n            isAutomateMode && \"pointer-events-none opacity-40\",\n          )}\n        >\n          {hasSelection ? (\n            <HomeGitControlBarPreview\n              workspace={pendingWorkspace}\n              repository={pendingRepository}\n              branch={pendingBranch}\n              provider={pendingProvider}\n              workspaceMode={workspaceMode}\n              backendKind={backend.kind}\n              onRepoClick={() => setIsDialogOpen(true)}\n              onWorkspaceModeChange={setWorkspaceMode}\n            />\n          ) : (\n            <OpenLauncherButton\n              kind={isLocal ? \"local\" : \"cloud\"}\n              onClick={() => setIsDialogOpen(true)}\n              disabled={\n                isCreating ||\n                isAutomateMode ||\n                Boolean(workspacesUnsupportedMessage)\n              }\n              disabledTooltip={workspacesUnsupportedMessage}\n            />\n          )}\n          <PluginPickerTrigger\n            count={selectedPlugins.length}\n            onClick={() => setIsPluginPickerOpen(true)}\n            disabled={isCreating || isAutomateMode}\n          />\n        </div>\n\n        <div className=\"mt-8 flex w-full flex-col gap-8\">\n          {/* @spec HL-002 \u2014 Only Home's rail is dismissible; dismissal unmounts it, inset included */}\n          {!isRecommendedAutomationsDismissed && (\n            <RecommendedAutomationsLauncher\n              variant=\"rail\"\n              dismissible\n              onDismiss={() => writeHomeRecommendedAutomationsDismissed(true)}\n            />\n          )}\n          <PinnedAutomationsDashboard />\n          <RunningAutomationsList />\n        </div>\n      </div>\n\n      {isLocal ? (\n        <OpenWorkspaceDialog\n          isOpen={isDialogOpen}\n          onClose={() => setIsDialogOpen(false)}\n          onConfirm={(workspace) => {\n            setPendingWorkspace(workspace);\n            setPendingRepository(null);\n            setPendingBranch(null);\n            setPendingProvider(null);\n          }}\n        />\n      ) : (\n        <OpenRepositoryDialog\n          isOpen={isDialogOpen}\n          onClose={() => setIsDialogOpen(false)}\n          onConfirm={({ repository, branch, provider }) => {\n            setPendingRepository(repository);\n            setPendingBranch(branch);\n            setPendingProvider(provider ?? repository.git_provider);\n            setPendingWorkspace(null);\n            setWorkspaceModeState(\"local_repo\");\n          }}\n        />\n      )}\n\n      {isPluginPickerOpen && (\n        <PluginPickerModal\n          selected={selectedPlugins}\n          onChange={setSelectedPlugins}\n          onClose={() => setIsPluginPickerOpen(false)}\n        />\n      )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/home/home-header/home-header-title.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-text-tertiary)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 15,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 15,
          "endColumn": 63
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { Typography } from \"#/ui/typography\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\nexport function HomeHeaderTitle() {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <div className=\"flex w-full flex-col items-center gap-2 py-2\">\n      <Typography.H1 className=\"w-full text-center leading-normal\">\n        {t(I18nKey.HOME$WHAT_TO_WORK_ON)}\n      </Typography.H1>\n      <p\n        data-testid=\"home-header-subtitle\"\n        className=\"text-center text-[var(--oh-text-tertiary)]\"\n      >\n        {t(I18nKey.HOME$ENGINEERING_TASKS_SUBHEADER)}\n      </p>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/home/home-launcher-mode-toggle.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-surface-raised)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 141,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 141,
          "endColumn": 114
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 159,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 159,
          "endColumn": 67
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-interactive-hover)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 162,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 162,
          "endColumn": 55
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-interactive-hover)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 168,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 168,
          "endColumn": 95
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useEffect, useRef, type ReactNode } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { LayoutGroup, motion, useReducedMotion } from \"framer-motion\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\n\nexport type HomeLauncherMode = \"code\" | \"automate\";\n\ninterface HomeLauncherModeToggleProps {\n  mode: HomeLauncherMode;\n  onChange: (mode: HomeLauncherMode) => void;\n}\n\nconst PILL_TRANSITION = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 34,\n  mass: 0.55,\n} as const;\n\nfunction CodeBracketsIcon({ active }: { active: boolean }) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className=\"size-4\"\n      aria-hidden\n    >\n      <motion.g\n        animate={active ? { x: [0, -1.5, 0] } : { x: 0 }}\n        transition={\n          active ? { duration: 0.38, ease: \"easeInOut\" } : { duration: 0.15 }\n        }\n      >\n        <path d=\"m8 6-6 6 6 6\" />\n      </motion.g>\n      <motion.g\n        animate={active ? { x: [0, 1.5, 0] } : { x: 0 }}\n        transition={\n          active ? { duration: 0.38, ease: \"easeInOut\" } : { duration: 0.15 }\n        }\n      >\n        <path d=\"m16 18 6-6-6-6\" />\n      </motion.g>\n    </svg>\n  );\n}\n\nfunction AutomateClockIcon({ active }: { active: boolean }) {\n  const handRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const node = handRef.current;\n    if (!node || !active || import.meta.env.MODE === \"test\") {\n      return undefined;\n    }\n\n    const animation = node.animate(\n      [{ transform: \"rotate(0deg)\" }, { transform: \"rotate(360deg)\" }],\n      {\n        duration: 700,\n        easing: \"cubic-bezier(0.2, 0.7, 0.2, 1)\",\n        fill: \"forwards\",\n      },\n    );\n\n    return () => {\n      animation.cancel();\n    };\n  }, [active]);\n\n  return (\n    <span className=\"relative inline-flex size-4\" aria-hidden>\n      <svg\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        className=\"absolute inset-0 size-4\"\n      >\n        <circle cx=\"12\" cy=\"12\" r=\"9\" />\n        <path d=\"M12 12V8\" />\n      </svg>\n      <span ref={handRef} className=\"absolute inset-0 size-4 origin-center\">\n        <svg\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          className=\"size-4\"\n        >\n          <path d=\"M12 12L16.5 15\" />\n        </svg>\n      </span>\n    </span>\n  );\n}\n\nexport function HomeLauncherModeToggle({\n  mode,\n  onChange,\n}: HomeLauncherModeToggleProps) {\n  const { t } = useTranslation(\"openhands\");\n  const reduceMotion = useReducedMotion();\n  const disableAnimation = reduceMotion || import.meta.env.MODE === \"test\";\n\n  const options: Array<{\n    value: HomeLauncherMode;\n    label: string;\n    icon: (active: boolean) => ReactNode;\n  }> = [\n    {\n      value: \"code\",\n      label: t(I18nKey.COMMON$CODE),\n      icon: (active) => (\n        <CodeBracketsIcon active={active && !disableAnimation} />\n      ),\n    },\n    {\n      value: \"automate\",\n      label: t(I18nKey.AUTOMATE$SECTION_TITLE),\n      icon: (active) => <AutomateClockIcon active={active} />,\n    },\n  ];\n\n  return (\n    <div className=\"flex w-full justify-center\">\n      <LayoutGroup id=\"home-launch-mode\">\n        <div\n          role=\"radiogroup\"\n          aria-label={t(I18nKey.HOME$MODE_TOGGLE_LABEL)}\n          data-testid=\"home-launcher-mode-toggle\"\n          className=\"inline-flex items-center rounded-full bg-[var(--oh-surface-raised)] p-1 text-sm font-medium\"\n        >\n          {options.map((option) => {\n            const isActive = option.value === mode;\n            return (\n              <button\n                key={option.value}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={isActive}\n                data-testid={`home-launcher-mode-${option.value}`}\n                onClick={() => onChange(option.value)}\n                className={cn(\n                  \"relative inline-flex items-center gap-2 rounded-full px-5 py-2\",\n                  \"cursor-pointer transition-colors\",\n                  // @spec HL-001 \u2014 Labels on the hover pill use the theme's high-emphasis ink\n                  isActive\n                    ? \"text-contrast\"\n                    : \"text-[var(--oh-muted)] hover:text-contrast\",\n                  disableAnimation &&\n                    isActive &&\n                    \"bg-[var(--oh-interactive-hover)]\",\n                )}\n              >\n                {!disableAnimation && isActive ? (\n                  <motion.span\n                    layoutId=\"home-launch-mode-pill\"\n                    className=\"absolute inset-0 rounded-full bg-[var(--oh-interactive-hover)]\"\n                    transition={PILL_TRANSITION}\n                  />\n                ) : null}\n                <span className=\"relative z-10 inline-flex size-4 shrink-0 items-center justify-center\">\n                  {option.icon(isActive)}\n                </span>\n                <span className=\"relative z-10\">{option.label}</span>\n              </button>\n            );\n          })}\n        </div>\n      </LayoutGroup>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/home/shared/clear-button.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#fff]\" hardcodes a color. Nearest theme tokens: text-primary, text-content-2. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 27,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 27,
          "endColumn": 26,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-primary"
              },
              "fix": {
                "range": [
                  563,
                  580
                ],
                "text": "\"p-1 text-primary\""
              },
              "desc": "Replace with \"text-primary\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-content-2"
              },
              "fix": {
                "range": [
                  563,
                  580
                ],
                "text": "\"p-1 text-content-2\""
              },
              "desc": "Replace with \"text-content-2\"."
            }
          ]
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { cn } from \"#/utils/utils\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\ninterface ClearButtonProps {\n  disabled: boolean;\n  onClear: () => void;\n  testId?: string;\n}\n\nexport function ClearButton({\n  disabled,\n  onClear,\n  testId = \"dropdown-clear\",\n}: ClearButtonProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <button\n      onClick={(e) => {\n        e.stopPropagation();\n        onClear();\n      }}\n      disabled={disabled}\n      className={cn(\n        \"p-1 text-[#fff]\",\n        \"cursor-pointer disabled:cursor-not-allowed disabled:opacity-60\",\n      )}\n      type=\"button\"\n      aria-label={t(I18nKey.COMMON$CLEAR_SELECTION)}\n      data-testid={testId}\n    >\n      <svg\n        className=\"w-4 h-4\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        viewBox=\"0 0 24 24\"\n      >\n        <path\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={2}\n          d=\"M6 18L18 6M6 6l12 12\"\n        />\n      </svg>\n    </button>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/home/shared/generic-dropdown-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[9999]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 92,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 92,
          "endColumn": 45
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[calc(15rem-3rem)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 103,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 103,
          "endColumn": 45
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "@typescript-eslint/no-explicit-any",
          "severity": 2,
          "message": "Unexpected any. Specify a different type.",
          "line": 17,
          "column": 8,
          "nodeType": "TSAnyKeyword",
          "messageId": "unexpectedAny",
          "endLine": 17,
          "endColumn": 11,
          "suggestions": [
            {
              "messageId": "suggestUnknown",
              "fix": {
                "range": [
                  485,
                  488
                ],
                "text": "unknown"
              },
              "desc": "Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."
            },
            {
              "messageId": "suggestNever",
              "fix": {
                "range": [
                  485,
                  488
                ],
                "text": "never"
              },
              "desc": "Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."
            }
          ],
          "suppressions": [
            {
              "kind": "directive",
              "justification": ""
            }
          ]
        },
        {
          "ruleId": "@typescript-eslint/no-explicit-any",
          "severity": 2,
          "message": "Unexpected any. Specify a different type.",
          "line": 20,
          "column": 8,
          "nodeType": "TSAnyKeyword",
          "messageId": "unexpectedAny",
          "endLine": 20,
          "endColumn": 11,
          "suggestions": [
            {
              "messageId": "suggestUnknown",
              "fix": {
                "range": [
                  640,
                  643
                ],
                "text": "unknown"
              },
              "desc": "Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."
            },
            {
              "messageId": "suggestNever",
              "fix": {
                "range": [
                  640,
                  643
                ],
                "text": "never"
              },
              "desc": "Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."
            }
          ],
          "suppressions": [
            {
              "kind": "directive",
              "justification": ""
            }
          ]
        },
        {
          "ruleId": "@typescript-eslint/no-explicit-any",
          "severity": 2,
          "message": "Unexpected any. Specify a different type.",
          "line": 30,
          "column": 10,
          "nodeType": "TSAnyKeyword",
          "messageId": "unexpectedAny",
          "endLine": 30,
          "endColumn": 13,
          "suggestions": [
            {
              "messageId": "suggestUnknown",
              "fix": {
                "range": [
                  1024,
                  1027
                ],
                "text": "unknown"
              },
              "desc": "Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."
            },
            {
              "messageId": "suggestNever",
              "fix": {
                "range": [
                  1024,
                  1027
                ],
                "text": "never"
              },
              "desc": "Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."
            }
          ],
          "suppressions": [
            {
              "kind": "directive",
              "justification": ""
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport {\n  UseComboboxGetMenuPropsOptions,\n  UseComboboxGetItemPropsOptions,\n} from \"downshift\";\nimport { cn } from \"#/utils/utils\";\nimport { dropdownMenuListClassName } from \"#/utils/dropdown-classes\";\n\nexport interface GenericDropdownMenuProps<T> {\n  isOpen: boolean;\n  filteredItems: T[];\n  inputValue: string;\n  highlightedIndex: number;\n  selectedItem: T | null;\n  getMenuProps: <Options>(\n    options?: UseComboboxGetMenuPropsOptions & Options,\n  ) => any; // eslint-disable-line @typescript-eslint/no-explicit-any\n  getItemProps: <Options>(\n    options: UseComboboxGetItemPropsOptions<T> & Options,\n  ) => any; // eslint-disable-line @typescript-eslint/no-explicit-any\n  onScroll?: (event: React.UIEvent<HTMLUListElement>) => void;\n  menuRef?: React.RefObject<HTMLUListElement | null>;\n  renderItem: (\n    item: T,\n    index: number,\n    highlightedIndex: number,\n    selectedItem: T | null,\n    getItemProps: <Options>(\n      options: UseComboboxGetItemPropsOptions<T> & Options,\n    ) => any, // eslint-disable-line @typescript-eslint/no-explicit-any\n  ) => React.ReactNode;\n  /**\n   * Optional presentational node rendered immediately BEFORE an item (e.g. a\n   * group header). It is a sibling of the item, not part of downshift's `items`\n   * array, so it consumes no item index \u2014 mirroring the `numberOfRecentItems`\n   * divider. The consumer owns when a prefix appears (e.g. at group boundaries).\n   */\n  renderItemPrefix?: (item: T, index: number) => React.ReactNode;\n  renderEmptyState: (inputValue: string) => React.ReactNode;\n  stickyTopItem?: React.ReactNode;\n  stickyFooterItem?: React.ReactNode;\n  testId?: string;\n  numberOfRecentItems?: number;\n  itemKey: (item: T) => string | number;\n}\n\nexport function GenericDropdownMenu<T>({\n  isOpen,\n  filteredItems,\n  inputValue,\n  highlightedIndex,\n  selectedItem,\n  getMenuProps,\n  getItemProps,\n  onScroll,\n  menuRef,\n  renderItem,\n  renderItemPrefix,\n  renderEmptyState,\n  stickyTopItem,\n  stickyFooterItem,\n  testId,\n  numberOfRecentItems = 0,\n  itemKey,\n}: GenericDropdownMenuProps<T>) {\n  // The sticky footer renders outside the list, so it must not suppress the\n  // empty state: with no matching items the list shows the consumer's empty\n  // state (instead of a lone sticky header) and the footer stays below it.\n  const showEmptyState = filteredItems.length === 0;\n\n  // Always render the menu container (even when closed) so getMenuProps is always called\n  // This prevents the downshift warning about forgetting to call getMenuProps\n  if (!isOpen) {\n    return (\n      <div className=\"relative\">\n        <ul\n          {...getMenuProps({\n            ref: menuRef,\n            className: \"hidden\",\n            \"data-testid\": testId,\n          })}\n        />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"relative\">\n      <div\n        className={cn(\n          \"absolute z-10 w-full bg-tertiary border border-border-input rounded-lg shadow-none\",\n          \"focus:outline-none mt-1 z-[9999]\",\n          stickyTopItem || stickyFooterItem ? \"max-h-60\" : \"max-h-60\",\n        )}\n      >\n        <ul\n          {...getMenuProps({\n            ref: menuRef,\n            className: cn(\n              \"w-full overflow-auto p-1 custom-scrollbar-always\",\n              dropdownMenuListClassName,\n              stickyTopItem || stickyFooterItem\n                ? \"max-h-[calc(15rem-3rem)]\"\n                : \"max-h-60\", // Reserve space for sticky items\n            ),\n            onScroll,\n            \"data-testid\": testId,\n          })}\n        >\n          {showEmptyState ? (\n            renderEmptyState(inputValue)\n          ) : (\n            <>\n              {stickyTopItem}\n              {filteredItems.map((item, index) => {\n                const key = itemKey(item);\n                return (\n                  <React.Fragment key={key}>\n                    {renderItemPrefix?.(item, index)}\n                    {renderItem(\n                      item,\n                      index,\n                      highlightedIndex,\n                      selectedItem,\n                      getItemProps,\n                    )}\n                    {numberOfRecentItems > 0 &&\n                      index === numberOfRecentItems - 1 && (\n                        <li\n                          role=\"presentation\"\n                          aria-hidden=\"true\"\n                          className=\"border-b border-border-input bg-tertiary pb-1 mb-1 h-px\"\n                        />\n                      )}\n                  </React.Fragment>\n                );\n              })}\n            </>\n          )}\n        </ul>\n        {stickyFooterItem && (\n          <div className=\"border-t border-border-input bg-tertiary p-1 rounded-b-lg\">\n            {stickyFooterItem}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/home/workspace-dropdown/folder-browser-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"grid-cols-[minmax(0,1fr)_4rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 30,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 30,
          "endColumn": 84
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"md:grid-cols-[1fr_120px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 30,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 30,
          "endColumn": 84
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 60,
          "column": 22,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 60,
          "endColumn": 103
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { useEffect, useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\nimport { BaseModalTitle } from \"#/components/shared/modals/confirmation-modals/base-modal\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  modalWidthClassName,\n} from \"#/components/shared/modals/modal-body\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { LocalWorkspace, LocalWorkspaceParent } from \"#/types/workspace\";\nimport {\n  type HomeDirectoryResponse,\n  useHomeDirectory,\n  useSearchSubdirs,\n} from \"#/hooks/query/use-search-subdirs\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { cn } from \"#/utils/utils\";\nimport { modalTitleSmClassName } from \"#/utils/modal-classes\";\nimport FolderIcon from \"#/icons/folder.svg?react\";\nimport ChevronLeft from \"#/icons/chevron-left-small.svg?react\";\n\nconst PROJECTS_PATH = \"/projects\";\n\n// Shared by the column header and every entry row so the columns line up.\n// Below md the Kind track is narrow and the name track may shrink to its\n// share (the name truncates), so folder names stay visible at phone width.\nconst FOLDER_BROWSER_COLUMNS_CLASS_NAME =\n  \"grid grid-cols-[minmax(0,1fr)_4rem] gap-x-2 md:grid-cols-[1fr_120px] md:gap-x-0\";\n\ninterface FolderBrowserModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  onAdd: (items: LocalWorkspace[]) => void;\n  onAddParent?: (items: LocalWorkspaceParent[]) => void;\n}\n\ninterface SidebarEntry {\n  label: string;\n  path: string;\n}\n\ninterface SidebarSectionProps {\n  label: string;\n  entries: SidebarEntry[];\n  currentPath: string | null;\n  onPick: (path: string) => void;\n}\n\nfunction SidebarSection({\n  label,\n  entries,\n  currentPath,\n  onPick,\n}: SidebarSectionProps) {\n  if (entries.length === 0) return null;\n  return (\n    <div className=\"px-2 pb-3\">\n      <div className=\"px-2 pb-1 text-[11px] uppercase tracking-wide text-muted font-semibold truncate\">\n        {label}\n      </div>\n      <ul>\n        {entries.map((entry) => {\n          const isActive = currentPath === entry.path;\n          return (\n            <li key={entry.path}>\n              <button\n                type=\"button\"\n                onClick={() => onPick(entry.path)}\n                data-testid={`folder-browser-sidebar-${entry.label.toLowerCase()}`}\n                className={cn(\n                  \"flex items-center gap-2 w-full px-2 py-1 rounded text-sm cursor-pointer\",\n                  isActive\n                    ? \"bg-tertiary text-contrast\"\n                    : \"text-text-tertiary hover:bg-surface-raised\",\n                )}\n              >\n                <FolderIcon width={14} height={14} className=\"shrink-0\" />\n                <span className=\"truncate\">{entry.label}</span>\n              </button>\n            </li>\n          );\n        })}\n      </ul>\n    </div>\n  );\n}\n\nfunction getParentPath(path: string): string | null {\n  const trimmed = trimTrailingSeparators(path);\n  if (!trimmed || trimmed === \"/\" || isWindowsDriveRoot(trimmed)) return null;\n\n  const idx = Math.max(trimmed.lastIndexOf(\"/\"), trimmed.lastIndexOf(\"\\\\\"));\n  if (idx < 0) return null;\n  if (idx === 0) return \"/\";\n\n  const parent = trimmed.slice(0, idx);\n  if (/^[A-Za-z]:$/.test(parent)) {\n    return `${parent}${trimmed[idx]}`;\n  }\n\n  return parent;\n}\n\nfunction isWindowsDriveRoot(path: string): boolean {\n  return /^[A-Za-z]:[\\\\/]?$/.test(path);\n}\n\nfunction trimTrailingSeparators(path: string): string {\n  const trimmed = path.replace(/[\\\\/]+$/, \"\");\n  if (/^[A-Za-z]:$/.test(trimmed)) {\n    const separator = path.includes(\"/\") && !path.includes(\"\\\\\") ? \"/\" : \"\\\\\";\n    return `${trimmed}${separator}`;\n  }\n  return trimmed;\n}\n\nfunction shouldDefaultToProjectsPath(\n  homeData: HomeDirectoryResponse | undefined,\n): boolean {\n  return homeData?.home === \"/home/openhands\";\n}\n\nexport function FolderBrowserModal({\n  isOpen,\n  onClose,\n  onAdd,\n  onAddParent,\n}: FolderBrowserModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [currentPath, setCurrentPath] = useState<string | null>(null);\n  const active = useActiveBackend();\n\n  const { data: homeData } = useHomeDirectory();\n\n  // Initialize / reset to home each time the modal is opened\n  useEffect(() => {\n    if (isOpen && homeData?.home && currentPath === null) {\n      setCurrentPath(\n        shouldDefaultToProjectsPath(homeData) ? PROJECTS_PATH : homeData.home,\n      );\n    }\n    if (!isOpen) {\n      setCurrentPath(null);\n    }\n  }, [isOpen, homeData?.home, currentPath]);\n\n  // A backend switch invalidates the previous path \u2014 clear it so the\n  // open/close effect can re-seed from the new backend's homeData.home.\n  useEffect(() => {\n    setCurrentPath(null);\n  }, [active.backend.id, active.orgId]);\n\n  const {\n    data: listing,\n    isLoading,\n    isError,\n    error,\n  } = useSearchSubdirs(isOpen ? currentPath : null);\n\n  const favorites: SidebarEntry[] = useMemo(() => {\n    if (!homeData?.home) return [];\n    const trimmed = trimTrailingSeparators(homeData.home) || homeData.home;\n    const backendFavorites = [\n      { label: \"Home\", path: trimmed },\n      ...(homeData.favorites ?? []),\n    ];\n    if (\n      shouldDefaultToProjectsPath(homeData) &&\n      !backendFavorites.some((entry) => entry.path === PROJECTS_PATH)\n    ) {\n      backendFavorites.push({\n        label: PROJECTS_PATH,\n        path: PROJECTS_PATH,\n      });\n    }\n\n    return backendFavorites;\n  }, [homeData]);\n\n  const locations: SidebarEntry[] = homeData?.locations ?? [];\n\n  if (!isOpen) return null;\n\n  const subdirs = listing?.items ?? [];\n  const parent = currentPath ? getParentPath(currentPath) : null;\n\n  // Signal that we're inside a container environment without the host\n  // home mounted: the agent server reports `/home/openhands` as home and\n  // returns no favorites (the only contents are hidden credential dirs).\n  // In that case there's nothing useful for the user to browse, so we\n  // surface a hint instead of the generic empty state.\n  const showHostHomeHint =\n    homeData?.home === \"/home/openhands\" &&\n    (homeData?.favorites?.length ?? 0) === 0 &&\n    currentPath === homeData?.home &&\n    !isLoading &&\n    !isError &&\n    subdirs.length === 0;\n\n  const getBasename = (path: string): string => {\n    const trimmed = trimTrailingSeparators(path);\n    if (!trimmed) return \"/\";\n    if (trimmed === \"/\" || isWindowsDriveRoot(trimmed)) return trimmed;\n    const idx = Math.max(trimmed.lastIndexOf(\"/\"), trimmed.lastIndexOf(\"\\\\\"));\n    return idx >= 0 ? trimmed.slice(idx + 1) || trimmed : trimmed;\n  };\n\n  const handleAddDirectory = () => {\n    if (!currentPath) return;\n    const item: LocalWorkspace = {\n      id: currentPath,\n      name: getBasename(currentPath),\n      path: currentPath,\n    };\n    onAdd([item]);\n    onClose();\n  };\n\n  const handleAddAllSubdirectories = () => {\n    if (!currentPath || !onAddParent) return;\n    onAddParent([\n      {\n        id: currentPath,\n        name: getBasename(currentPath),\n        path: currentPath,\n      },\n    ]);\n    onClose();\n  };\n\n  return (\n    <ModalBackdrop\n      onClose={onClose}\n      aria-label={t(I18nKey.HOME$ADD_WORKSPACES_TITLE)}\n    >\n      <div\n        data-testid=\"folder-browser-modal\"\n        className={cn(\n          \"flex flex-col bg-surface border border-border-input rounded-xl\",\n          modalWidthClassName(\"xl\"),\n          MODAL_MAX_WIDTH_VIEWPORT,\n          \"h-120\",\n        )}\n      >\n        {/* Title bar */}\n        <div className=\"flex items-center justify-between px-5 py-3 border-b border-border-input\">\n          <BaseModalTitle\n            className={modalTitleSmClassName}\n            title={t(I18nKey.HOME$ADD_WORKSPACES_TITLE)}\n          />\n        </div>\n\n        {/* Body: sidebar + main */}\n        <div className=\"flex flex-1 min-h-0\">\n          {/* Sidebar */}\n          <aside\n            data-testid=\"folder-browser-sidebar\"\n            className=\"w-28 md:w-45 shrink-0 border-r border-border-input bg-surface py-3 overflow-y-auto\"\n          >\n            <SidebarSection\n              label={t(I18nKey.HOME$FAVORITES)}\n              entries={favorites}\n              currentPath={currentPath}\n              onPick={setCurrentPath}\n            />\n            <SidebarSection\n              label={t(I18nKey.HOME$LOCATIONS)}\n              entries={locations}\n              currentPath={currentPath}\n              onPick={setCurrentPath}\n            />\n          </aside>\n\n          {/* Main */}\n          <div className=\"flex-1 flex flex-col min-w-0\">\n            {/* Nav row */}\n            <div className=\"flex items-center gap-2 px-4 py-2 border-b border-border-input\">\n              <button\n                type=\"button\"\n                data-testid=\"folder-browser-up\"\n                onClick={() => parent && setCurrentPath(parent)}\n                disabled={!parent}\n                aria-label={t(I18nKey.COMMON$UP)}\n                className=\"p-1 rounded hover:bg-interactive-hover text-contrast disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer\"\n              >\n                <ChevronLeft width={16} height={16} />\n              </button>\n              <span\n                className=\"text-xs text-muted truncate\"\n                data-testid=\"folder-browser-current-path\"\n              >\n                {currentPath ?? \"\"}\n              </span>\n            </div>\n\n            {/* Column headers */}\n            <div\n              data-testid=\"folder-browser-column-headers\"\n              className={cn(\n                FOLDER_BROWSER_COLUMNS_CLASS_NAME,\n                \"px-4 py-1 border-b border-border-input text-xs text-text-secondary font-semibold\",\n              )}\n            >\n              <span>{t(I18nKey.HOME$NAME)}</span>\n              <span>{t(I18nKey.HOME$KIND)}</span>\n            </div>\n\n            {/* List */}\n            <ul\n              className=\"flex-1 overflow-auto custom-scrollbar-always\"\n              data-testid=\"folder-browser-list\"\n            >\n              {isLoading && (\n                <li className=\"px-4 py-2 text-sm text-text-secondary\">\n                  {t(I18nKey.HOME$LOADING)}\n                </li>\n              )}\n              {isError && (\n                <li\n                  className=\"px-4 py-2 text-sm text-feedback-error\"\n                  data-testid=\"folder-browser-error\"\n                >\n                  {(error as Error | undefined)?.message ??\n                    t(I18nKey.COMMON$FAILED_TO_LOAD)}\n                </li>\n              )}\n              {!isLoading && !isError && subdirs.length === 0 && (\n                <li\n                  className=\"px-4 py-2 text-sm text-text-secondary\"\n                  data-testid={\n                    showHostHomeHint\n                      ? \"folder-browser-host-home-hint\"\n                      : \"folder-browser-empty\"\n                  }\n                >\n                  {showHostHomeHint\n                    ? t(I18nKey.HOME$HOST_HOME_NOT_MOUNTED_HINT)\n                    : t(I18nKey.HOME$NO_WORKSPACES)}\n                </li>\n              )}\n              {subdirs.map((entry) => (\n                <li key={entry.path}>\n                  <button\n                    type=\"button\"\n                    onClick={() => setCurrentPath(entry.path)}\n                    className={cn(\n                      FOLDER_BROWSER_COLUMNS_CLASS_NAME,\n                      \"items-center w-full text-left px-4 py-1.5 text-sm text-contrast hover:bg-interactive-hover cursor-pointer\",\n                    )}\n                    data-testid={`folder-browser-entry-${entry.name}`}\n                  >\n                    <span className=\"flex items-center gap-2 min-w-0\">\n                      <FolderIcon width={16} height={16} className=\"shrink-0\" />\n                      <span className=\"truncate\">{entry.name}</span>\n                    </span>\n                    <span className=\"text-text-secondary text-xs\">\n                      {t(I18nKey.HOME$FOLDER)}\n                    </span>\n                  </button>\n                </li>\n              ))}\n            </ul>\n          </div>\n        </div>\n\n        {/* Footer */}\n        <div className=\"flex items-center justify-end gap-2 px-5 py-3 border-t border-border-input\">\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={onClose}\n            testId=\"folder-browser-cancel\"\n          >\n            {t(I18nKey.HOME$CANCEL)}\n          </BrandButton>\n          {onAddParent && (\n            <BrandButton\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={handleAddAllSubdirectories}\n              isDisabled={!currentPath || isLoading}\n              testId=\"folder-browser-add-all-subdirs\"\n            >\n              {t(I18nKey.HOME$ADD_ALL_SUBDIRECTORIES)}\n            </BrandButton>\n          )}\n          <BrandButton\n            type=\"button\"\n            variant=\"primary\"\n            onClick={handleAddDirectory}\n            isDisabled={!currentPath || isLoading}\n            testId=\"folder-browser-use\"\n          >\n            {t(I18nKey.HOME$ADD_THIS_DIRECTORY)}\n          </BrandButton>\n        </div>\n      </div>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/home/workspace-dropdown/manage-workspaces-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[70vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 82,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 82,
          "endColumn": 27
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 144,
          "column": 32,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 144,
          "endColumn": 109
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\nimport { BaseModalTitle } from \"#/components/shared/modals/confirmation-modals/base-modal\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  modalWidthClassName,\n} from \"#/components/shared/modals/modal-body\";\nimport { ConfirmationModal } from \"#/components/shared/modals/confirmation-modal\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { LocalWorkspace, LocalWorkspaceParent } from \"#/types/workspace\";\nimport { cn } from \"#/utils/utils\";\nimport { modalTitleSmClassName } from \"#/utils/modal-classes\";\nimport FolderIcon from \"#/icons/folder.svg?react\";\nimport CloseIcon from \"#/icons/close.svg?react\";\n\ninterface ManageWorkspacesModalProps {\n  isOpen: boolean;\n  workspaces: LocalWorkspace[];\n  workspaceParents?: LocalWorkspaceParent[];\n  onClose: () => void;\n  onRemove: (path: string) => void;\n  onRemoveParent?: (path: string) => void;\n}\n\ntype PendingRemoval =\n  | { type: \"workspace\"; path: string; text: string }\n  | { type: \"parent\"; path: string; text: string };\n\nexport function ManageWorkspacesModal({\n  isOpen,\n  workspaces,\n  workspaceParents = [],\n  onClose,\n  onRemove,\n  onRemoveParent,\n}: ManageWorkspacesModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [pendingRemoval, setPendingRemoval] =\n    React.useState<PendingRemoval | null>(null);\n\n  if (!isOpen) return null;\n\n  // Workspaces from a parent are read-only here; users remove the parent.\n  const staticWorkspaces = workspaces.filter((w) => !w.parentPath);\n  const dynamicWorkspacesByParent = new Map<string, LocalWorkspace[]>();\n  workspaces.forEach((w) => {\n    if (!w.parentPath) return;\n    const list = dynamicWorkspacesByParent.get(w.parentPath) ?? [];\n    list.push(w);\n    dynamicWorkspacesByParent.set(w.parentPath, list);\n  });\n\n  const hasContent = staticWorkspaces.length > 0 || workspaceParents.length > 0;\n\n  const handleConfirmRemoval = () => {\n    if (!pendingRemoval) return;\n\n    if (pendingRemoval.type === \"workspace\") {\n      onRemove(pendingRemoval.path);\n    } else {\n      onRemoveParent?.(pendingRemoval.path);\n    }\n\n    setPendingRemoval(null);\n  };\n\n  return (\n    <>\n      <ModalBackdrop\n        onClose={onClose}\n        aria-label={t(I18nKey.HOME$MANAGE_WORKSPACES)}\n      >\n        <div\n          data-testid=\"manage-workspaces-modal\"\n          className={cn(\n            \"flex flex-col bg-surface border border-border-input rounded-xl\",\n            modalWidthClassName(\"lg\"),\n            MODAL_MAX_WIDTH_VIEWPORT,\n            \"max-h-[70vh]\",\n          )}\n        >\n          <div className=\"flex items-center justify-between px-5 py-3 border-b border-border-input\">\n            <BaseModalTitle\n              className={modalTitleSmClassName}\n              title={t(I18nKey.HOME$MANAGE_WORKSPACES)}\n            />\n          </div>\n\n          <div\n            className=\"flex-1 overflow-auto custom-scrollbar-always\"\n            data-testid=\"manage-workspaces-list\"\n          >\n            {!hasContent && (\n              <p className=\"px-5 py-6 text-sm text-text-secondary text-center\">\n                {t(I18nKey.HOME$MANAGE_WORKSPACES_EMPTY)}\n              </p>\n            )}\n\n            {staticWorkspaces.length > 0 && (\n              <ul>\n                {staticWorkspaces.map((workspace) => (\n                  <li\n                    key={workspace.id}\n                    className=\"flex items-center gap-3 px-5 py-2 border-b border-border-subtle last:border-b-0\"\n                    data-testid={`manage-workspaces-row-${workspace.name}`}\n                  >\n                    <FolderIcon width={16} height={16} className=\"shrink-0\" />\n                    <div className=\"flex flex-col min-w-0 flex-1\">\n                      <span className=\"text-sm text-contrast truncate\">\n                        {workspace.name}\n                      </span>\n                      <span className=\"text-xs text-muted truncate\">\n                        {workspace.path}\n                      </span>\n                    </div>\n                    <button\n                      type=\"button\"\n                      onClick={() =>\n                        setPendingRemoval({\n                          type: \"workspace\",\n                          path: workspace.path,\n                          text: t(I18nKey.HOME$REMOVE_WORKSPACE_CONFIRMATION, {\n                            name: workspace.name,\n                          }),\n                        })\n                      }\n                      aria-label={t(I18nKey.HOME$REMOVE_WORKSPACE)}\n                      data-testid={`manage-workspaces-remove-${workspace.name}`}\n                      className=\"flex items-center gap-1 px-2 py-1 rounded text-xs text-text-tertiary hover:bg-interactive-hover hover:text-contrast cursor-pointer\"\n                    >\n                      <CloseIcon width={12} height={12} />\n                      <span>{t(I18nKey.HOME$REMOVE_WORKSPACE)}</span>\n                    </button>\n                  </li>\n                ))}\n              </ul>\n            )}\n\n            {workspaceParents.length > 0 && (\n              <div data-testid=\"manage-workspaces-parents-section\">\n                <div className=\"px-5 pt-3 pb-1 text-[11px] uppercase tracking-wide text-muted font-semibold\">\n                  {t(I18nKey.HOME$WORKSPACE_PARENTS)}\n                </div>\n                <ul>\n                  {workspaceParents.map((parent) => {\n                    const children =\n                      dynamicWorkspacesByParent.get(parent.path) ?? [];\n                    return (\n                      <li\n                        key={parent.id}\n                        className=\"border-b border-border-subtle last:border-b-0\"\n                        data-testid={`manage-workspaces-parent-row-${parent.name}`}\n                      >\n                        <div className=\"flex items-center gap-3 px-5 py-2\">\n                          <FolderIcon\n                            width={16}\n                            height={16}\n                            className=\"shrink-0\"\n                          />\n                          <div className=\"flex flex-col min-w-0 flex-1\">\n                            <span className=\"text-sm text-contrast truncate\">\n                              {parent.name}\n                            </span>\n                            <span className=\"text-xs text-muted truncate\">\n                              {parent.path}\n                            </span>\n                          </div>\n                          {onRemoveParent && (\n                            <button\n                              type=\"button\"\n                              onClick={() =>\n                                setPendingRemoval({\n                                  type: \"parent\",\n                                  path: parent.path,\n                                  text: t(\n                                    I18nKey.HOME$REMOVE_WORKSPACE_PARENT_CONFIRMATION,\n                                    {\n                                      name: parent.name,\n                                      count: children.length,\n                                    },\n                                  ),\n                                })\n                              }\n                              aria-label={t(\n                                I18nKey.HOME$REMOVE_WORKSPACE_PARENT,\n                              )}\n                              data-testid={`manage-workspaces-remove-parent-${parent.name}`}\n                              className=\"flex items-center gap-1 px-2 py-1 rounded text-xs text-text-tertiary hover:bg-interactive-hover hover:text-contrast cursor-pointer\"\n                            >\n                              <CloseIcon width={12} height={12} />\n                              <span>\n                                {t(I18nKey.HOME$REMOVE_WORKSPACE_PARENT)}\n                              </span>\n                            </button>\n                          )}\n                        </div>\n                        {children.length > 0 && (\n                          <ul className=\"pb-2\">\n                            {children.map((child) => (\n                              <li\n                                key={child.id}\n                                className=\"flex items-center gap-3 px-5 pl-10 py-1 text-xs text-text-secondary\"\n                                data-testid={`manage-workspaces-child-${child.name}`}\n                              >\n                                <FolderIcon\n                                  width={12}\n                                  height={12}\n                                  className=\"shrink-0 opacity-70\"\n                                />\n                                <span className=\"truncate\">{child.name}</span>\n                              </li>\n                            ))}\n                          </ul>\n                        )}\n                      </li>\n                    );\n                  })}\n                </ul>\n              </div>\n            )}\n          </div>\n\n          <div className=\"flex justify-end gap-2 px-5 py-3 border-t border-border-input\">\n            <BrandButton\n              type=\"button\"\n              variant=\"primary\"\n              onClick={onClose}\n              testId=\"manage-workspaces-done\"\n            >\n              {t(I18nKey.HOME$DONE)}\n            </BrandButton>\n          </div>\n        </div>\n      </ModalBackdrop>\n\n      {pendingRemoval && (\n        <ConfirmationModal\n          text={pendingRemoval.text}\n          onConfirm={handleConfirmRemoval}\n          onCancel={() => setPendingRemoval(null)}\n        />\n      )}\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/images/image-lightbox.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[90vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 47,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 47,
          "endColumn": 74
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[90vw]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 47,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 47,
          "endColumn": 74
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\ninterface ImageLightboxProps {\n  src: string;\n  onClose: () => void;\n}\n\n/**\n * Full-size overlay for an image attachment. The thumbnail is a CSS downscale\n * of the same source, so no refetch is needed.\n */\nexport function ImageLightbox({ src, onClose }: ImageLightboxProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  // HeroUI consumes Escape on a document capture listener, so it never reaches\n  // ModalBackdrop's window listener. Capture it here instead.\n  React.useEffect(() => {\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      event.stopPropagation();\n      onClose();\n    };\n\n    document.addEventListener(\"keydown\", handleEscape, true);\n    return () => document.removeEventListener(\"keydown\", handleEscape, true);\n  }, [onClose]);\n\n  return (\n    <ModalBackdrop\n      onClose={onClose}\n      closeOnEscape={false}\n      aria-label={t(I18nKey.IMAGE$FULL_SIZE_PREVIEW)}\n    >\n      <div data-testid=\"image-lightbox\" className=\"relative\">\n        <ModalCloseButton\n          onClose={onClose}\n          testId=\"image-lightbox-close\"\n          className=\"bg-black/60 text-white hover:bg-black/80\"\n        />\n        <img\n          src={src}\n          alt={t(I18nKey.IMAGE$FULL_SIZE_PREVIEW)}\n          className=\"max-h-[90vh] max-w-[90vw] rounded-lg object-contain\"\n        />\n      </div>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/launch/plugin-launch-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[80vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 143,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 143,
          "endColumn": 108
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  modalWidthClassName,\n} from \"#/components/shared/modals/modal-body\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { PluginSpec } from \"#/api/conversation-service/agent-server-conversation-service.types\";\nimport { Typography } from \"#/ui/typography\";\nimport { cn } from \"#/utils/utils\";\nimport { hasPluginParameters } from \"#/utils/plugin-parameters\";\nimport { PluginLaunchPluginSection } from \"./plugin-launch-plugin-section\";\n\ninterface PluginLaunchModalProps {\n  plugins: PluginSpec[];\n  parametersSupported: boolean;\n  message?: string;\n  isLoading?: boolean;\n  onStartConversation: (plugins: PluginSpec[], initialMessage?: string) => void;\n  onClose: () => void;\n}\n\ninterface ExpandedState {\n  [key: number]: boolean;\n}\n\nexport function PluginLaunchModal({\n  plugins,\n  parametersSupported,\n  message,\n  isLoading = false,\n  onStartConversation,\n  onClose,\n}: PluginLaunchModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [pluginConfigs, setPluginConfigs] =\n    React.useState<PluginSpec[]>(plugins);\n  const [expandedSections, setExpandedSections] = React.useState<ExpandedState>(\n    () => {\n      // Initially expand plugins that have parameters\n      const initial: ExpandedState = {};\n      plugins.forEach((plugin, index) => {\n        if (plugin.parameters && Object.keys(plugin.parameters).length > 0) {\n          initial[index] = true;\n        }\n      });\n      return initial;\n    },\n  );\n  const [trustConfirmed, setTrustConfirmed] = React.useState(false);\n\n  const pluginsWithParams = pluginConfigs.filter(\n    (p) => p.parameters && Object.keys(p.parameters).length > 0,\n  );\n  const pluginsWithoutParams = pluginConfigs.filter(\n    (p) => !p.parameters || Object.keys(p.parameters).length === 0,\n  );\n\n  const toggleSection = (index: number) => {\n    setExpandedSections((prev) => ({\n      ...prev,\n      [index]: !prev[index],\n    }));\n  };\n\n  const updateParameter = (\n    pluginIndex: number,\n    paramKey: string,\n    value: unknown,\n  ) => {\n    setPluginConfigs((prev) => {\n      const updated = [...prev];\n      const plugin = { ...updated[pluginIndex] };\n      plugin.parameters = {\n        ...plugin.parameters,\n        [paramKey]: value,\n      };\n      updated[pluginIndex] = plugin;\n      return updated;\n    });\n  };\n\n  const getPluginDisplayName = (plugin: PluginSpec): string => {\n    const { source, repo_path: repoPath } = plugin;\n\n    // If repo_path is specified, show the plugin name from the path\n    if (repoPath) {\n      const pathParts = repoPath.split(\"/\");\n      const pluginName = pathParts[pathParts.length - 1];\n      return pluginName;\n    }\n\n    // Otherwise show the repo name\n    if (source.startsWith(\"github:\")) {\n      return source.replace(\"github:\", \"\");\n    }\n    if (source.includes(\"/\")) {\n      const parts = source.split(\"/\");\n      return parts[parts.length - 1].replace(\".git\", \"\");\n    }\n    return source;\n  };\n\n  const getPluginSourceInfo = (plugin: PluginSpec): string => {\n    const { source } = plugin;\n    if (source.startsWith(\"github:\")) {\n      return source.replace(\"github:\", \"\");\n    }\n    if (source.includes(\"github.com/\")) {\n      return source.split(\"github.com/\")[1]?.replace(\".git\", \"\") || source;\n    }\n    return source;\n  };\n\n  const getUniqueSources = (): string[] => {\n    const sources = pluginConfigs.map((plugin) => getPluginSourceInfo(plugin));\n    return [...new Set(sources)];\n  };\n\n  const unsupportedParameters =\n    !parametersSupported && hasPluginParameters(pluginConfigs);\n\n  const handleStartConversation = () => {\n    if (unsupportedParameters) return;\n    onStartConversation(pluginConfigs, message);\n  };\n\n  // The generic title already includes the verb (\"Launch Plugin\"), so only a\n  // single plugin's display name is prefixed with \"Launch\".\n  const modalTitle =\n    pluginConfigs.length === 1\n      ? `${t(I18nKey.LAUNCH$MODAL_TITLE)} ${getPluginDisplayName(pluginConfigs[0])}`\n      : t(I18nKey.LAUNCH$MODAL_TITLE_GENERIC);\n\n  return (\n    <ModalBackdrop onClose={onClose}>\n      <div\n        data-testid=\"plugin-launch-modal\"\n        className={cn(\n          \"relative bg-base-secondary p-6 rounded-xl flex flex-col gap-4 border border-border max-h-[80vh]\",\n          modalWidthClassName(\"md\"),\n          MODAL_MAX_WIDTH_VIEWPORT,\n        )}\n      >\n        <ModalCloseButton onClose={onClose} testId=\"close-button\" />\n        <Typography.H2 className=\"pr-6\">{modalTitle}</Typography.H2>\n\n        {message && <p className=\"text-sm text-contrast\">{message}</p>}\n\n        <div className=\"flex-1 overflow-y-auto custom-scrollbar\">\n          {pluginsWithParams.length > 0 && (\n            <div className=\"space-y-3\">\n              {pluginConfigs.map((plugin, index) => (\n                <PluginLaunchPluginSection\n                  key={`plugin-${index}`}\n                  plugin={plugin}\n                  originalIndex={index}\n                  isExpanded={!!expandedSections[index]}\n                  onToggle={() => toggleSection(index)}\n                  getPluginDisplayName={getPluginDisplayName}\n                  onParameterChange={updateParameter}\n                />\n              ))}\n            </div>\n          )}\n\n          {pluginsWithoutParams.length > 0 && (\n            <div className={cn(pluginsWithParams.length > 0 && \"mt-4\")}>\n              <Typography.H3 className=\"mb-2 text-contrast\">\n                {pluginsWithParams.length > 0\n                  ? t(I18nKey.LAUNCH$ADDITIONAL_PLUGINS)\n                  : t(I18nKey.LAUNCH$PLUGINS)}\n              </Typography.H3>\n              <div className=\"space-y-2\">\n                {pluginsWithoutParams.map((plugin, index) => (\n                  <div\n                    key={`simple-plugin-${index}`}\n                    className=\"rounded-md bg-tertiary px-3 py-2 text-sm\"\n                  >\n                    <div className=\"font-medium\">\n                      {getPluginDisplayName(plugin)}\n                    </div>\n                    <div className=\"text-xs text-contrast mt-1\">\n                      {getPluginSourceInfo(plugin)}\n                      {plugin.repo_path && (\n                        <span className=\"ml-1\">/ {plugin.repo_path}</span>\n                      )}\n                      {plugin.ref && (\n                        <span className=\"ml-2\">@ {plugin.ref}</span>\n                      )}\n                    </div>\n                  </div>\n                ))}\n              </div>\n            </div>\n          )}\n        </div>\n\n        {unsupportedParameters && (\n          <p\n            role=\"alert\"\n            data-testid=\"plugin-parameters-unsupported\"\n            className=\"text-sm text-feedback-error\"\n          >\n            {t(I18nKey.LAUNCH$PARAMETERS_UNSUPPORTED_LOCAL)}\n          </p>\n        )}\n\n        <div className=\"pt-4 border-t border-border-subtle\">\n          <div className=\"flex items-start gap-3 mb-4\">\n            <input\n              id=\"trust-checkbox\"\n              data-testid=\"trust-checkbox\"\n              type=\"checkbox\"\n              checked={trustConfirmed}\n              onChange={(e) => setTrustConfirmed(e.target.checked)}\n              className=\"mt-1 h-4 w-4 flex-shrink-0\"\n            />\n            <label htmlFor=\"trust-checkbox\" className=\"text-sm text-contrast\">\n              {t(I18nKey.LAUNCH$TRUST_PLUGIN_CHECKBOX, {\n                sources: getUniqueSources().join(\", \"),\n                interpolation: { escapeValue: false },\n              })}\n            </label>\n          </div>\n          <div className=\"flex w-full justify-end mt-8\">\n            <BrandButton\n              testId=\"start-conversation-button\"\n              type=\"button\"\n              variant=\"primary\"\n              onClick={handleStartConversation}\n              isDisabled={isLoading || !trustConfirmed || unsupportedParameters}\n              className=\"px-4\"\n            >\n              {isLoading\n                ? t(I18nKey.LAUNCH$STARTING)\n                : t(I18nKey.LAUNCH$START_CONVERSATION)}\n            </BrandButton>\n          </div>\n        </div>\n      </div>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/manifest/manifest-setup-dialog.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[85vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 464,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 464,
          "endColumn": 124
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[92vw]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 464,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 464,
          "endColumn": 124
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { AutomationAgentProfileSelector } from \"#/components/features/automations/agent-profile-selector\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useNavigate } from \"react-router\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { SettingsDropdownInput } from \"#/components/features/settings/settings-dropdown-input\";\nimport { LoadingSpinner } from \"#/components/shared/loading-spinner\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { modalTitleLgClassName } from \"#/utils/modal-classes\";\nimport { getApiErrorBody } from \"#/utils/api-error-message\";\nimport { useTracking } from \"#/hooks/use-tracking\";\nimport { notifySuperAdminSetupStep } from \"#/components/features/setup-guide/super-admin-setup-step-event\";\nimport { useSetupCapabilities } from \"#/hooks/query/use-manifest-capabilities\";\nimport { useSetupPrerequisites } from \"#/hooks/query/use-manifest-prerequisites\";\nimport { useNativeGitIntegrations } from \"#/hooks/query/use-native-git-integrations\";\nimport { useLlmProfiles } from \"#/hooks/query/use-llm-profiles\";\nimport { useSetupPreflight } from \"#/hooks/use-manifest-preflight\";\nimport { useSetupAction } from \"#/manifests/manifest-actions\";\nimport {\n  supportedActionKinds,\n  supportedTriggerKinds,\n} from \"#/manifests/manifest-capabilities\";\nimport {\n  buildCreatePayload,\n  deriveErrorMap,\n  missingCreateEndpoints,\n  supportsAgentProfile,\n} from \"#/manifests/automation-setup\";\nimport {\n  actionKinds,\n  collectFields,\n  getFieldOptions,\n  getInitialFormValues,\n  initialActionKind,\n  initialTriggerKind,\n  resolveFieldOverrides,\n  triggerKinds,\n  validateFormValues,\n  type SetupFieldError,\n  type SetupFieldErrors,\n} from \"#/manifests/manifest-local-validation\";\nimport {\n  mapServiceErrors,\n  normalizeServiceErrors,\n  type MappedManifestErrors,\n} from \"#/manifests/manifest-error-map\";\nimport { automationDetailPath } from \"#/manifests/automation-interface\";\nimport { findAutomationCommand } from \"#/utils/automation-catalog\";\nimport type { GitRepository } from \"#/types/git\";\nimport type { Provider } from \"#/types/settings\";\nimport type {\n  SetupEntry,\n  SetupFormValue,\n  SetupFormValues,\n  SetupMode,\n  SetupRequestBody,\n} from \"#/manifests/types\";\nimport { SetupFormField } from \"./manifest-form-field\";\nimport { SetupPrerequisitesStep } from \"./manifest-prerequisites-step\";\nimport { SetupReviewStep } from \"./manifest-review-step\";\n\ntype SetupStep = \"prerequisites\" | \"form\" | \"review\";\n\n/** How long a field rests after a blur before the draft is sent for checking. */\nconst PREFLIGHT_DEBOUNCE_MS = 400;\n\nconst NO_SERVICE_ERRORS: MappedManifestErrors = {\n  fieldErrors: {},\n  formErrors: [],\n};\n\n/**\n * Fields naming the secret a bundle reads only when the user has not connected\n * the provider under Settings > Integrations; once connected, the bundle uses\n * that account's token. On an OpenHands Enterprise instance (`self_hosted`)\n * the field therefore has nothing to ask and is not shown while the provider\n * is connected. It keeps its default, so the config still names a secret.\n * OpenHands Cloud and local backends show it as declared.\n */\nconst CONNECTED_PROVIDER_SECRET_FIELDS: Record<string, Provider> = {\n  githubTokenSecret: \"github\",\n};\n\nfunction hasAnyError(errors: MappedManifestErrors): boolean {\n  return (\n    errors.formErrors.length > 0 || Object.keys(errors.fieldErrors).length > 0\n  );\n}\n\n/** Where a completed setup lands: the new automation, or the conversation. */\nfunction getDestination(response: Record<string, unknown>): string | null {\n  if (typeof response.conversation_id === \"string\") {\n    return `/conversations/${response.conversation_id}`;\n  }\n  if (typeof response.id === \"string\") return automationDetailPath(response.id);\n  return null;\n}\n\nexport interface SetupDialogProps {\n  entry: SetupEntry;\n  onClose: () => void;\n}\n\n/**\n * The setup host: capabilities check, prerequisites, form, review, and the\n * action the manifest's mode selects, rendered as a dialog.\n *\n * The host runs the stages and owns everything the same for every entry; the\n * manifest supplies only what varies. Any string the user reads here is either\n * manifest-authored or host chrome.\n */\nexport function SetupDialog({ entry, onClose }: SetupDialogProps) {\n  const { t } = useTranslation(\"openhands\");\n  const navigate = useNavigate();\n\n  const capabilities = useSetupCapabilities(entry);\n  const prerequisites = useSetupPrerequisites(entry);\n  const runPreflight = useSetupPreflight(entry);\n  const runAction = useSetupAction();\n  const { getNativeIntegration, deploymentMode } = useNativeGitIntegrations();\n  const {\n    trackAutomationSetupOpened,\n    trackAutomationSetupValidated,\n    trackAutomationSetupCreated,\n    trackAutomationSetupFailed,\n  } = useTracking();\n\n  const [step, setStep] = useState<SetupStep>(\"prerequisites\");\n  const allTriggerOptions = useMemo(() => triggerKinds(entry.setup), [entry]);\n  const triggerOptions = useMemo(() => {\n    if (allTriggerOptions.length === 0) return [];\n    return capabilities.capabilities\n      ? supportedTriggerKinds(entry, capabilities.capabilities)\n      : allTriggerOptions;\n  }, [allTriggerOptions, capabilities.capabilities, entry]);\n  const allActionOptions = useMemo(() => actionKinds(entry.setup), [entry]);\n  const [selectedTrigger, setSelectedTrigger] = useState<string | null>(() =>\n    initialTriggerKind(entry.setup),\n  );\n  const [selectedAction, setSelectedAction] = useState<string | null>(() =>\n    initialActionKind(entry.setup),\n  );\n  const [values, setValues] = useState<SetupFormValues>(() =>\n    getInitialFormValues(\n      entry.setup,\n      initialTriggerKind(entry.setup),\n      initialActionKind(entry.setup),\n    ),\n  );\n  const [repositories, setRepositories] = useState<\n    Record<string, GitRepository | null>\n  >({});\n  const [localErrors, setLocalErrors] = useState<SetupFieldErrors>({});\n  const [serviceErrors, setServiceErrors] =\n    useState<MappedManifestErrors>(NO_SERVICE_ERRORS);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  // Blur-triggered preflight reads the values as they are when the field is\n  // left, which can be the same tick as the change that caused it.\n  const valuesRef = useRef(values);\n  const blurTimerRef = useRef<number | null>(null);\n  useEffect(\n    () => () => {\n      if (blurTimerRef.current) window.clearTimeout(blurTimerRef.current);\n    },\n    [],\n  );\n\n  const actionOptions = useMemo(() => {\n    if (allActionOptions.length === 0) return [];\n    const supported = capabilities.capabilities\n      ? supportedActionKinds(entry, capabilities.capabilities)\n      : allActionOptions;\n    return supported.filter(\n      (kind) => missingCreateEndpoints(entry, kind).length === 0,\n    );\n  }, [allActionOptions, capabilities.capabilities, entry]);\n\n  const fields = useMemo(\n    () => collectFields(entry.setup, selectedTrigger, selectedAction),\n    [entry, selectedTrigger, selectedAction],\n  );\n  const hidesConnectedProviderSecrets = deploymentMode === \"self_hosted\";\n  const visibleFields = Object.fromEntries(\n    Object.entries(fields).filter(([name, field]) => {\n      if (values.agent_profile_id && field.type === \"llm-profile\") return false;\n      if (!hidesConnectedProviderSecrets) return true;\n      if (!Object.hasOwn(CONNECTED_PROVIDER_SECRET_FIELDS, name)) return true;\n      return (\n        getNativeIntegration(CONNECTED_PROVIDER_SECRET_FIELDS[name])\n          ?.isConnected !== true\n      );\n    }),\n  );\n  const canSelectAgentProfile =\n    capabilities.capabilities?.features.includes(\"agentProfiles\") === true &&\n    supportsAgentProfile(entry, selectedAction);\n  const hasLlmProfileField = useMemo(\n    () => Object.values(fields).some((field) => field.type === \"llm-profile\"),\n    [fields],\n  );\n  const { data: profilesData, isLoading: isLoadingProfiles } = useLlmProfiles({\n    enabled: hasLlmProfileField,\n  });\n  const llmProfileOptions = useMemo(\n    () =>\n      (profilesData?.profiles ?? []).map((profile) => ({\n        value: profile.name,\n        label: profile.name,\n      })),\n    [profilesData?.profiles],\n  );\n  const overrides = useMemo(() => {\n    const base = resolveFieldOverrides(\n      entry.setup,\n      capabilities.capabilities,\n      selectedTrigger,\n      selectedAction,\n    );\n    if (!hasLlmProfileField || isLoadingProfiles) return base;\n\n    return Object.entries(fields).reduce(\n      (next, [fieldName, field]) =>\n        field.type === \"llm-profile\"\n          ? { ...next, [fieldName]: { options: llmProfileOptions } }\n          : next,\n      base,\n    );\n  }, [\n    entry.setup,\n    capabilities.capabilities,\n    selectedTrigger,\n    selectedAction,\n    hasLlmProfileField,\n    isLoadingProfiles,\n    fields,\n    llmProfileOptions,\n  ]);\n  const payload = useMemo(\n    () =>\n      buildCreatePayload(\n        entry,\n        values,\n        undefined,\n        selectedTrigger,\n        selectedAction,\n      ),\n    [entry, values, selectedTrigger, selectedAction],\n  );\n  const errorMap = useMemo(\n    () => deriveErrorMap(entry, selectedTrigger, selectedAction),\n    [entry, selectedTrigger, selectedAction],\n  );\n\n  const emittedOpenRef = useRef(false);\n  useEffect(() => {\n    if (emittedOpenRef.current) return;\n    emittedOpenRef.current = true;\n    trackAutomationSetupOpened({ automationId: entry.id });\n  }, [entry.id, trackAutomationSetupOpened]);\n\n  // An entry the published interface cannot create is refused here rather than\n  // at the moment of creating: a bundle needs two endpoints a manifest from\n  // before bundles does not declare, and no answer the user gives supplies\n  // them. Named alongside the deployment's own unmet requirements, because\n  // \"which one\" is the only thing that makes either diagnosable.\n  const missingEndpoints = useMemo(\n    () => missingCreateEndpoints(entry, selectedAction),\n    [entry, selectedAction],\n  );\n  const isUnsupported =\n    capabilities.supported === false || missingEndpoints.length > 0;\n  const unmet = [...capabilities.unmet, ...missingEndpoints];\n  const showPrerequisites =\n    prerequisites.blockingIntegrations.length > 0 ||\n    prerequisites.warningIntegrations.length > 0;\n  // Prerequisites resolve asynchronously; deriving the step keeps an entry with\n  // nothing to check from flashing an empty first screen.\n  const currentStep: SetupStep =\n    step === \"prerequisites\" && !showPrerequisites ? \"form\" : step;\n\n  const setTriggerValue = (kind: string) => {\n    setSelectedTrigger(kind);\n    const defaults = getInitialFormValues(entry.setup, kind, selectedAction);\n    valuesRef.current = { ...defaults, ...valuesRef.current };\n    setValues(valuesRef.current);\n    setLocalErrors({});\n    setServiceErrors(NO_SERVICE_ERRORS);\n  };\n\n  const setActionValue = (kind: string) => {\n    setSelectedAction(kind);\n    const defaults = getInitialFormValues(entry.setup, selectedTrigger, kind);\n    valuesRef.current = {\n      ...defaults,\n      ...valuesRef.current,\n      ...(supportsAgentProfile(entry, kind) ? {} : { agent_profile_id: \"\" }),\n    };\n    setValues(valuesRef.current);\n    setLocalErrors({});\n    setServiceErrors(NO_SERVICE_ERRORS);\n  };\n\n  useEffect(() => {\n    if (allTriggerOptions.length === 0 || capabilities.isLoading) return;\n    if (selectedTrigger && triggerOptions.includes(selectedTrigger)) return;\n    if (triggerOptions.length > 0) setTriggerValue(triggerOptions[0]);\n  }, [\n    triggerOptions,\n    allTriggerOptions.length,\n    capabilities.isLoading,\n    selectedTrigger,\n  ]);\n\n  useEffect(() => {\n    if (allActionOptions.length === 0 || capabilities.isLoading) return;\n    if (\n      selectedAction &&\n      actionOptions.includes(selectedAction as (typeof actionOptions)[number])\n    ) {\n      return;\n    }\n    if (actionOptions.length > 0) setActionValue(actionOptions[0]);\n  }, [\n    actionOptions,\n    allActionOptions.length,\n    capabilities.isLoading,\n    selectedAction,\n  ]);\n\n  const setFieldValue = (name: string, value: SetupFormValue) => {\n    valuesRef.current = { ...valuesRef.current, [name]: value };\n    setValues(valuesRef.current);\n    setLocalErrors(({ [name]: _removed, ...rest }) => rest);\n    setServiceErrors((current) => {\n      if (!(name in current.fieldErrors)) return current;\n      const { [name]: _cleared, ...fieldErrors } = current.fieldErrors;\n      return { ...current, fieldErrors };\n    });\n  };\n\n  const handleFieldBlur = () => {\n    if (blurTimerRef.current) window.clearTimeout(blurTimerRef.current);\n    blurTimerRef.current = window.setTimeout(() => {\n      void runPreflight(\n        valuesRef.current,\n        selectedTrigger,\n        selectedAction,\n      ).then((result) => {\n        if (result) setServiceErrors(result);\n      });\n    }, PREFLIGHT_DEBOUNCE_MS);\n  };\n\n  const handleContinue = async () => {\n    if (currentStep === \"prerequisites\") {\n      setStep(\"form\");\n      return;\n    }\n\n    const failures = validateFormValues(\n      entry.setup,\n      values,\n      overrides,\n      selectedTrigger,\n      selectedAction,\n    );\n    if (Object.keys(failures).length > 0) {\n      setLocalErrors(failures);\n      return;\n    }\n    setLocalErrors({});\n\n    const result = await runPreflight(values, selectedTrigger, selectedAction);\n    if (result && hasAnyError(result)) {\n      setServiceErrors(result);\n      return;\n    }\n\n    setServiceErrors(NO_SERVICE_ERRORS);\n    trackAutomationSetupValidated({ automationId: entry.id });\n    setStep(\"review\");\n  };\n\n  const submitAction = async (\n    actionPayload: SetupRequestBody | null,\n    setupMode: SetupMode,\n  ) => {\n    setIsSubmitting(true);\n    try {\n      const { response } = await runAction(\n        entry,\n        values,\n        actionPayload,\n        selectedTrigger,\n        selectedAction,\n      );\n      trackAutomationSetupCreated({\n        automationId: entry.id,\n        setupMode,\n      });\n      // An assisted setup only starts the conversation that creates it.\n      if (setupMode === \"direct\") notifySuperAdminSetupStep(\"first-automation\");\n      const destination = getDestination(response);\n      if (destination) navigate(destination, { replace: true });\n      else onClose();\n    } catch (error) {\n      trackAutomationSetupFailed({\n        automationId: entry.id,\n        setupMode,\n      });\n      const mapped = mapServiceErrors(\n        normalizeServiceErrors(getApiErrorBody(error), actionPayload),\n        errorMap,\n      );\n      setServiceErrors(\n        hasAnyError(mapped)\n          ? mapped\n          : {\n              fieldErrors: {},\n              formErrors: [t(I18nKey.SETUP$SUBMIT_FAILED)],\n            },\n      );\n      setStep(\"form\");\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  const handleConfirm = () => submitAction(payload, entry.setup.mode);\n\n  // A direct entry whose deployment cannot run the direct path degrades to the\n  // assisted outcome: the skill command and the entry's fallback message seed\n  // a conversation that finishes setup instead.\n  const handleFallbackConversation = () => submitAction(null, \"assisted\");\n  const canFallBackToConversation =\n    entry.setup.mode === \"direct\" &&\n    (findAutomationCommand(entry) !== null ||\n      entry.setup.message !== undefined);\n\n  const resolveFieldError = (name: string): string | undefined => {\n    const local = localErrors[name];\n    return local ? formatFieldError(local, t) : serviceErrors.fieldErrors[name];\n  };\n\n  const isLoading = capabilities.isLoading || prerequisites.isLoading;\n  const isLoadingLlmProfileOptions = hasLlmProfileField && isLoadingProfiles;\n\n  const title = (() => {\n    if (isUnsupported) return t(I18nKey.SETUP$UNAVAILABLE_TITLE);\n    if (currentStep === \"prerequisites\")\n      return t(I18nKey.SETUP$PREREQUISITES_TITLE);\n    if (currentStep === \"review\") return t(I18nKey.SETUP$REVIEW_TITLE);\n    return entry.name;\n  })();\n\n  return (\n    <ModalBackdrop onClose={onClose} aria-label={entry.name}>\n      <div\n        data-testid=\"setup-dialog\"\n        className=\"relative flex max-h-[85vh] w-[92vw] max-w-lg flex-col rounded-xl border border-border bg-base-secondary\"\n      >\n        <ModalCloseButton\n          onClose={onClose}\n          testId=\"setup-dialog-close\"\n          disabled={isSubmitting}\n        />\n        <header className=\"flex-shrink-0 px-6 pb-4 pt-6\">\n          <h2 className={cn(\"pr-6\", modalTitleLgClassName)}>{title}</h2>\n        </header>\n\n        <div className=\"min-h-0 flex-1 overflow-y-auto px-6\">\n          {isLoading && (\n            <div className=\"flex justify-center py-6\">\n              <LoadingSpinner size=\"small\" />\n            </div>\n          )}\n\n          {!isLoading && isUnsupported && (\n            <div className=\"flex flex-col gap-2\">\n              <p className=\"text-sm text-muted\">\n                {t(I18nKey.SETUP$UNSUPPORTED_MESSAGE)}\n              </p>\n              {/* The unmet requirements are the names both sides of the\n                  contract already use, so they are printed rather than\n                  translated. Without them the block is undiagnosable: the\n                  deployment answered, and the host would be discarding the\n                  one thing it learned. */}\n              {unmet.length > 0 && (\n                <p\n                  data-testid=\"setup-unmet-requirements\"\n                  className=\"text-sm text-muted\"\n                >\n                  {unmet.join(\", \")}\n                </p>\n              )}\n            </div>\n          )}\n\n          {!isLoading && !isUnsupported && currentStep === \"prerequisites\" && (\n            <SetupPrerequisitesStep prerequisites={prerequisites} />\n          )}\n\n          {!isLoading && !isUnsupported && currentStep === \"form\" && (\n            <div className=\"flex flex-col gap-5\">\n              <p className=\"text-sm text-muted\">{entry.description}</p>\n              {entry.setup.form.note && (\n                <p className=\"text-sm text-muted\">{entry.setup.form.note}</p>\n              )}\n              {allActionOptions.length > 1 && actionOptions.length > 1 && (\n                <div className=\"flex w-full flex-col gap-2.5\">\n                  <SettingsDropdownInput\n                    testId=\"setup-action-kind\"\n                    name=\"actionKind\"\n                    label={t(I18nKey.SETUP$ACTION_LABEL)}\n                    items={actionOptions.map((kind) => {\n                      const action = entry.setup.actions?.[kind];\n                      return {\n                        key: kind,\n                        label: action?.label ?? kind,\n                      };\n                    })}\n                    selectedKey={selectedAction ?? undefined}\n                    isDisabled={isSubmitting}\n                    required\n                    onSelectionChange={(key) => {\n                      if (key !== null) setActionValue(String(key));\n                    }}\n                  />\n                  {selectedAction &&\n                    entry.setup.actions?.[\n                      selectedAction as keyof typeof entry.setup.actions\n                    ] && (\n                      <p className=\"text-xs text-muted\">\n                        {\n                          entry.setup.actions[\n                            selectedAction as keyof typeof entry.setup.actions\n                          ]?.help\n                        }\n                      </p>\n                    )}\n                </div>\n              )}\n              {triggerOptions.length > 1 && (\n                <div className=\"flex w-full flex-col gap-2.5\">\n                  <SettingsDropdownInput\n                    testId=\"setup-trigger-kind\"\n                    name=\"triggerKind\"\n                    label={t(I18nKey.SETUP$TRIGGER_LABEL)}\n                    items={triggerOptions.map((kind) => ({\n                      key: kind,\n                      label: kind === \"cron\" ? \"Scheduled\" : \"Event\",\n                    }))}\n                    selectedKey={selectedTrigger ?? undefined}\n                    isDisabled={isSubmitting}\n                    required\n                    onSelectionChange={(key) => {\n                      if (key !== null) setTriggerValue(String(key));\n                    }}\n                  />\n                  <p className=\"text-xs text-muted\">\n                    {t(I18nKey.SETUP$TRIGGER_HELP)}\n                  </p>\n                </div>\n              )}\n              {canSelectAgentProfile && (\n                <AutomationAgentProfileSelector\n                  // The form stores \"no profile\" as \"\", but the selector only\n                  // reads null/undefined as Default; \"\" would render it blank.\n                  value={\n                    typeof values.agent_profile_id === \"string\" &&\n                    values.agent_profile_id !== \"\"\n                      ? values.agent_profile_id\n                      : null\n                  }\n                  onChange={(value) =>\n                    setFieldValue(\"agent_profile_id\", value ?? \"\")\n                  }\n                />\n              )}\n              {Object.entries(visibleFields).map(([name, field]) => (\n                <SetupFormField\n                  key={name}\n                  name={name}\n                  field={field}\n                  value={values[name] ?? \"\"}\n                  error={resolveFieldError(name)}\n                  options={getFieldOptions(name, field, overrides)}\n                  repository={repositories[name] ?? null}\n                  disabled={isSubmitting}\n                  isOptionsLoading={\n                    field.type === \"llm-profile\" && isLoadingLlmProfileOptions\n                  }\n                  onChange={(value) => setFieldValue(name, value)}\n                  onRepositoryChange={(repository) =>\n                    setRepositories((current) => ({\n                      ...current,\n                      [name]: repository,\n                    }))\n                  }\n                  onBlur={handleFieldBlur}\n                />\n              ))}\n            </div>\n          )}\n\n          {!isLoading && !isUnsupported && currentStep === \"review\" && (\n            <SetupReviewStep\n              setup={entry.setup}\n              fields={visibleFields}\n              values={values}\n              selectedAction={selectedAction}\n            />\n          )}\n\n          {serviceErrors.formErrors.map((message) => (\n            <p\n              key={message}\n              role=\"alert\"\n              data-testid=\"setup-form-error\"\n              className=\"pt-4 text-sm text-feedback-error\"\n            >\n              {message}\n            </p>\n          ))}\n        </div>\n\n        <footer className=\"flex flex-shrink-0 justify-end gap-2 px-6 pb-6 pt-4\">\n          {currentStep === \"review\" && (\n            <BrandButton\n              testId=\"setup-back-button\"\n              type=\"button\"\n              variant=\"secondary\"\n              isDisabled={isSubmitting}\n              onClick={() => setStep(\"form\")}\n            >\n              {t(I18nKey.BUTTON$BACK)}\n            </BrandButton>\n          )}\n\n          {isUnsupported ? (\n            <>\n              <BrandButton\n                type=\"button\"\n                variant=\"secondary\"\n                isDisabled={isSubmitting}\n                onClick={onClose}\n              >\n                {t(I18nKey.BUTTON$CLOSE)}\n              </BrandButton>\n              {canFallBackToConversation && (\n                <BrandButton\n                  testId=\"setup-fallback-conversation\"\n                  type=\"button\"\n                  variant=\"primary\"\n                  isDisabled={isSubmitting}\n                  onClick={handleFallbackConversation}\n                >\n                  {t(I18nKey.SETUP$FALLBACK_CONVERSATION)}\n                </BrandButton>\n              )}\n            </>\n          ) : (\n            <BrandButton\n              testId=\"setup-continue-button\"\n              type=\"button\"\n              variant=\"primary\"\n              isDisabled={\n                isLoading ||\n                isLoadingLlmProfileOptions ||\n                isSubmitting ||\n                (currentStep === \"prerequisites\" && prerequisites.isBlocked)\n              }\n              onClick={\n                currentStep === \"review\" ? handleConfirm : handleContinue\n              }\n            >\n              {currentStep === \"review\"\n                ? t(I18nKey.SETUP$CONFIRM)\n                : t(I18nKey.BUTTON$CONTINUE)}\n            </BrandButton>\n          )}\n        </footer>\n      </div>\n    </ModalBackdrop>\n  );\n}\n\nfunction formatFieldError(\n  error: SetupFieldError,\n  t: (key: I18nKey, options?: Record<string, unknown>) => string,\n): string {\n  switch (error.code) {\n    case \"required\":\n      return t(I18nKey.SETUP$VALIDATION_REQUIRED);\n    case \"minLength\":\n      return t(I18nKey.SETUP$VALIDATION_MIN_LENGTH, { length: error.length });\n    case \"maxLength\":\n      return t(I18nKey.SETUP$VALIDATION_MAX_LENGTH, { length: error.length });\n    case \"min\":\n      return `Must be at least ${error.value}.`;\n    case \"max\":\n      return `Must be at most ${error.value}.`;\n    case \"invalidOption\":\n      return t(I18nKey.SETUP$VALIDATION_INVALID_OPTION);\n    case \"unsafeExpressionLiteral\":\n      return t(I18nKey.SETUP$VALIDATION_UNSAFE_VALUE);\n    default: {\n      // A code added to `SetupFieldError` without a message here is a compile\n      // error rather than a field that silently reads \"this field is required\".\n      const exhaustive: never = error;\n      return exhaustive;\n    }\n  }\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/markdown/code.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"px-[0.4em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 37,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 37,
          "endColumn": 107
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"py-[0.2em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 37,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 37,
          "endColumn": 107
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"p-[1em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 47,
          "column": 24,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 47,
          "endColumn": 118
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 58,
          "column": 16,
          "nodeType": "CallExpression",
          "messageId": "dynamicStyle",
          "endLine": 58,
          "endColumn": 53
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { ExtraProps } from \"react-markdown\";\nimport { CopyableContentWrapper } from \"#/components/shared/buttons/copyable-content-wrapper\";\nimport { useColorTheme } from \"#/hooks/use-color-theme\";\nimport { getSyntaxHighlighterTheme } from \"#/themes/syntax-highlighter-themes\";\nimport { cn } from \"#/utils/utils\";\nimport { SyntaxHighlighter } from \"./syntax-highlighter\";\n\n// See https://github.com/remarkjs/react-markdown?tab=readme-ov-file#use-custom-components-syntax-highlight\n\n/**\n * Component to render code blocks in markdown.\n *\n * Named `Code` rather than lowercase like its sibling markdown components\n * because it calls a hook (`useColorTheme`), and `react-hooks/rules-of-hooks`\n * only treats capitalized functions as components. It is exported as `code`\n * so the `components` map in markdown-renderer reads like the others.\n */\nfunction Code({\n  children,\n  className,\n}: React.ClassAttributes<HTMLElement> &\n  React.HTMLAttributes<HTMLElement> &\n  ExtraProps) {\n  const colorTheme = useColorTheme();\n  const match = /language-(\\w+)/.exec(className || \"\"); // get the language\n  const codeString = String(children).replace(/\\n$/, \"\");\n\n  if (!match) {\n    const isMultiline = String(children).includes(\"\\n\");\n\n    if (!isMultiline) {\n      return (\n        <code\n          className={cn(\n            className,\n            \"bg-surface-raised text-foreground border border-surface-raised rounded px-[0.4em] py-[0.2em]\",\n          )}\n        >\n          {children}\n        </code>\n      );\n    }\n\n    return (\n      <CopyableContentWrapper text={codeString}>\n        <pre className=\"bg-surface-raised text-foreground border border-surface-raised rounded p-[1em] overflow-auto\">\n          <code className={className}>{codeString}</code>\n        </pre>\n      </CopyableContentWrapper>\n    );\n  }\n\n  return (\n    <CopyableContentWrapper text={codeString}>\n      <SyntaxHighlighter\n        className=\"rounded-lg\"\n        style={getSyntaxHighlighterTheme(colorTheme)}\n        language={match?.[1]}\n        PreTag=\"div\"\n      >\n        {codeString}\n      </SyntaxHighlighter>\n    </CopyableContentWrapper>\n  );\n}\n\nexport { Code as code };\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/markdown/headings.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"-tracking-[0.01em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 24,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 24,
          "endColumn": 106
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { ExtraProps } from \"react-markdown\";\n\n// Custom component to render <h1> in markdown\nexport function h1({\n  children,\n}: React.ClassAttributes<HTMLHeadingElement> &\n  React.HTMLAttributes<HTMLHeadingElement> &\n  ExtraProps) {\n  return (\n    <h1 className=\"text-xl text-contrast font-medium leading-7 mb-3 mt-4 first:mt-0\">\n      {children}\n    </h1>\n  );\n}\n\n// Custom component to render <h2> in markdown\nexport function h2({\n  children,\n}: React.ClassAttributes<HTMLHeadingElement> &\n  React.HTMLAttributes<HTMLHeadingElement> &\n  ExtraProps) {\n  return (\n    <h2 className=\"text-lg font-medium leading-6 -tracking-[0.01em] text-contrast mb-2.5 mt-4 first:mt-0\">\n      {children}\n    </h2>\n  );\n}\n\n// Custom component to render <h3> in markdown\nexport function h3({\n  children,\n}: React.ClassAttributes<HTMLHeadingElement> &\n  React.HTMLAttributes<HTMLHeadingElement> &\n  ExtraProps) {\n  return (\n    <h3 className=\"text-base font-medium text-contrast mb-2 mt-3 first:mt-0\">\n      {children}\n    </h3>\n  );\n}\n\n// Custom component to render <h4> in markdown\nexport function h4({\n  children,\n}: React.ClassAttributes<HTMLHeadingElement> &\n  React.HTMLAttributes<HTMLHeadingElement> &\n  ExtraProps) {\n  return (\n    <h4 className=\"text-sm font-medium text-contrast mb-1.5 mt-3 first:mt-0\">\n      {children}\n    </h4>\n  );\n}\n\n// Custom component to render <h5> in markdown\nexport function h5({\n  children,\n}: React.ClassAttributes<HTMLHeadingElement> &\n  React.HTMLAttributes<HTMLHeadingElement> &\n  ExtraProps) {\n  return (\n    <h5 className=\"text-sm font-normal text-text-tertiary mb-1.5 mt-2.5 first:mt-0\">\n      {children}\n    </h5>\n  );\n}\n\n// Custom component to render <h6> in markdown\nexport function h6({\n  children,\n}: React.ClassAttributes<HTMLHeadingElement> &\n  React.HTMLAttributes<HTMLHeadingElement> &\n  ExtraProps) {\n  return (\n    <h6 className=\"text-sm font-normal text-text-tertiary mb-1.5 mt-2.5 first:mt-0\">\n      {children}\n    </h6>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/markdown/markdown-table-scroll.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"from-[var(--oh-scroll-fade-from,var(--oh-color-base))]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 72,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 72,
          "endColumn": 99
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"from-[var(--oh-scroll-fade-from,var(--oh-color-base))]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 84,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 84,
          "endColumn": 99
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  readScrollFadeState,\n  type ScrollFadeState,\n} from \"#/utils/scroll-fade-state\";\n\nexport { readScrollFadeState };\n\nconst FADE_WIDTH_CLASS = \"w-10\";\n\ninterface MarkdownTableScrollProps {\n  children: React.ReactNode;\n}\n\nexport function MarkdownTableScroll({ children }: MarkdownTableScrollProps) {\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const [fadeState, setFadeState] = React.useState<ScrollFadeState>({\n    left: false,\n    right: false,\n  });\n\n  const updateFadeState = React.useCallback(() => {\n    const element = scrollRef.current;\n    if (!element) {\n      return;\n    }\n    setFadeState(readScrollFadeState(element));\n  }, []);\n\n  React.useLayoutEffect(() => {\n    updateFadeState();\n\n    const element = scrollRef.current;\n    if (!element) {\n      return undefined;\n    }\n\n    const resizeObserver = new ResizeObserver(updateFadeState);\n    resizeObserver.observe(element);\n\n    const table = element.firstElementChild;\n    if (table) {\n      resizeObserver.observe(table);\n    }\n\n    return () => resizeObserver.disconnect();\n  }, [updateFadeState, children]);\n\n  return (\n    <div className=\"relative max-w-full\">\n      <div\n        ref={scrollRef}\n        data-testid=\"markdown-table-scroll\"\n        onScroll={updateFadeState}\n        className=\"max-w-full overflow-x-auto custom-scrollbar-always\"\n      >\n        {children}\n      </div>\n      {/* The fades blend into whatever surface the table sits on, which differs\n          between the chat stream and cards like the markdown artifact preview.\n          Set `--oh-scroll-fade-from` on any ancestor to match another surface.\n          The value is inlined at both edges because Tailwind only extracts\n          classes from literal strings, not interpolated constants. */}\n      <div\n        aria-hidden\n        data-testid=\"markdown-table-scroll-fade-left\"\n        data-visible={fadeState.left ? \"true\" : \"false\"}\n        className={cn(\n          \"pointer-events-none absolute inset-y-0 left-0 z-10\",\n          FADE_WIDTH_CLASS,\n          \"bg-gradient-to-r from-[var(--oh-scroll-fade-from,var(--oh-color-base))] to-transparent\",\n          \"transition-opacity duration-300 ease-out motion-reduce:transition-none\",\n          fadeState.left ? \"opacity-100\" : \"opacity-0\",\n        )}\n      />\n      <div\n        aria-hidden\n        data-testid=\"markdown-table-scroll-fade-right\"\n        data-visible={fadeState.right ? \"true\" : \"false\"}\n        className={cn(\n          \"pointer-events-none absolute inset-y-0 right-0 z-10\",\n          FADE_WIDTH_CLASS,\n          \"bg-gradient-to-l from-[var(--oh-scroll-fade-from,var(--oh-color-base))] to-transparent\",\n          \"transition-opacity duration-300 ease-out motion-reduce:transition-none\",\n          fadeState.right ? \"opacity-100\" : \"opacity-0\",\n        )}\n      />\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/mcp-logo-badge.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"shadow-[inset_0_1px_0_color-mix(in_srgb,var(--oh-contrast)_18%,transparent)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 54,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 54,
          "endColumn": 113
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets backgroundColor. Style through classes; use CSS custom properties for dynamic values.",
          "line": 59,
          "column": 26,
          "nodeType": "LogicalExpression",
          "messageId": "inlineStyle",
          "endLine": 59,
          "endColumn": 69
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets color. Style through classes; use CSS custom properties for dynamic values.",
          "line": 61,
          "column": 11,
          "nodeType": "LogicalExpression",
          "messageId": "inlineStyle",
          "endLine": 62,
          "endColumn": 61
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"p-[22%]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 71,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 71,
          "endColumn": 59
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import type { ComponentType, ReactNode, SVGProps } from \"react\";\nimport { Bot } from \"lucide-react\";\nimport type { IntegrationCatalogEntry } from \"@openhands/extensions/integrations\";\nimport SlackIcon from \"#/icons/slack.svg?react\";\nimport { cn } from \"#/utils/utils\";\n\ntype McpLogoEntry = Pick<\n  IntegrationCatalogEntry,\n  \"id\" | \"name\" | \"iconBg\" | \"iconColor\" | \"logoUrl\"\n>;\n\nexport type { McpLogoEntry };\n\ninterface McpLogoBadgeProps {\n  entry?: McpLogoEntry | null;\n  size?: \"xs\" | \"sm\" | \"base\" | \"md\";\n  className?: string;\n  fallback?: ReactNode;\n  testId?: string;\n}\n\nconst sizeClassNames = {\n  xs: \"h-4 w-4 rounded [&>svg]:h-2.5 [&>svg]:w-2.5\",\n  sm: \"h-5 w-5 rounded-md [&>svg]:h-3 [&>svg]:w-3\",\n  base: \"h-7 w-7 rounded-lg [&>svg]:h-3.5 [&>svg]:w-3.5\",\n  md: \"h-10 w-10 rounded-lg [&>svg]:h-5 [&>svg]:w-5\",\n};\n\n// Catalog entries whose remote logoUrl is unreliable (e.g. Slack's was removed\n// from cdn.simpleicons.org and now 404s) render a bundled mark instead, keyed\n// by IntegrationCatalogEntry.id.\nconst LOCAL_LOGO_ICONS: Record<\n  string,\n  ComponentType<SVGProps<SVGSVGElement>>\n> = {\n  slack: SlackIcon,\n};\n\nexport function McpLogoBadge({\n  entry,\n  size = \"md\",\n  className,\n  fallback,\n  testId,\n}: McpLogoBadgeProps) {\n  const LocalLogoIcon = entry ? LOCAL_LOGO_ICONS[entry.id] : undefined;\n  return (\n    <span\n      aria-hidden=\"true\"\n      title={entry?.name}\n      data-testid={testId}\n      className={cn(\n        \"inline-flex shrink-0 items-center justify-center overflow-hidden\",\n        \"border border-contrast/10 shadow-[inset_0_1px_0_color-mix(in_srgb,var(--oh-contrast)_18%,transparent)]\",\n        sizeClassNames[size],\n        className,\n      )}\n      style={{\n        backgroundColor: entry?.iconBg ?? \"var(--oh-color-tertiary)\",\n        color:\n          entry?.iconColor ??\n          (entry?.iconBg ? \"#FFFFFF\" : \"var(--oh-contrast)\"),\n      }}\n    >\n      {LocalLogoIcon ? (\n        <LocalLogoIcon />\n      ) : entry?.logoUrl ? (\n        <img\n          src={entry.logoUrl}\n          alt={`${entry.name} logo`}\n          className=\"h-full w-full object-contain p-[22%]\"\n          onError={(e) => {\n            const image = e.currentTarget;\n            image.style.display = \"none\";\n          }}\n        />\n      ) : (\n        (fallback ?? <Bot className=\"h-5 w-5\" strokeWidth={2.25} />)\n      )}\n    </span>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/mcp-page/custom-server-editor.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[90vw]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 242,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 242,
          "endColumn": 150
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[90vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 242,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 242,
          "endColumn": 150
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { AxiosError } from \"axios\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { ConfirmationModal } from \"#/components/shared/modals/confirmation-modal\";\nimport {\n  MCPServerForm,\n  type TestMessage,\n} from \"#/components/features/settings/mcp-settings/mcp-server-form\";\nimport { seedMcpServerHealth } from \"#/api/mcp-health/probe-mcp-server-health\";\nimport { useAddMcpServer } from \"#/hooks/mutation/use-add-mcp-server\";\nimport { useUpdateMcpServer } from \"#/hooks/mutation/use-update-mcp-server\";\nimport { useDeleteMcpServer } from \"#/hooks/mutation/use-delete-mcp-server\";\nimport { useTestMcpServer } from \"#/hooks/mutation/use-test-mcp-server\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { ExtendedMCPTestResponse, MCPServerConfig } from \"#/types/mcp-server\";\nimport {\n  displayErrorToast,\n  displaySuccessToast,\n} from \"#/utils/custom-toast-handlers\";\nimport { makeMcpTestErrorMessage } from \"#/utils/mcp-test-error-message\";\nimport { retrieveAxiosErrorMessage } from \"#/utils/retrieve-axios-error-message\";\nimport { cn } from \"#/utils/utils\";\nimport { modalTitleLgClassName } from \"#/utils/modal-classes\";\nimport McpService from \"#/api/mcp-service/mcp-service.api\";\nimport { MCP_RENAME_CREDENTIAL_ERROR } from \"#/utils/mcp-config\";\n\ninterface CustomServerEditorProps {\n  server: MCPServerConfig;\n  existingServers: MCPServerConfig[];\n  onClose: () => void;\n}\n\n/**\n * Modal wrapper around `MCPServerForm` so users can hand-author\n * arbitrary stdio / SSE / SHTTP entries without reaching for raw JSON.\n * An empty `server.id` means \"Add new\".\n */\nexport function CustomServerEditor({\n  server,\n  existingServers,\n  onClose,\n}: CustomServerEditorProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { mutate: addMcpServer, isPending: isAdding } = useAddMcpServer();\n  const { mutate: updateMcpServer, isPending: isUpdating } =\n    useUpdateMcpServer();\n  const { mutate: deleteMcpServer, isPending: isDeleting } =\n    useDeleteMcpServer();\n  const {\n    mutate: testServer,\n    isPending: isTesting,\n    data: testResult,\n    variables: testedServer,\n    reset: resetTest,\n  } = useTestMcpServer();\n  const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);\n  const [oauthTestResult, setOauthTestResult] =\n    React.useState<ExtendedMCPTestResponse | null>(null);\n  const [isOauthTesting, setIsOauthTesting] = React.useState(false);\n\n  // stdio servers cannot be probed from a cloud backend (they spawn inside the\n  // sandbox): `McpService.testServer` short-circuits them with a synthetic\n  // success so the save still completes. Hide the manual \"Test connection\"\n  // button and the resulting message for them so cloud users aren't shown a\n  // misleading \"0 tools\" result. Remote servers are probed via the app server.\n  const { backend } = useActiveBackend();\n  const isCloudBackend = backend.kind === \"cloud\";\n  const isSyntheticTestResult =\n    isCloudBackend && !oauthTestResult && testedServer?.type === \"stdio\";\n\n  const isEditing = !!server.id;\n  const isPending = isAdding || isUpdating || isDeleting;\n  const isDismissBlocked =\n    isPending || isTesting || isOauthTesting || showDeleteConfirm;\n\n  const testMessage: TestMessage | null = React.useMemo(() => {\n    const result = oauthTestResult ?? testResult;\n    if (!result) return null;\n    if (result.ok) {\n      return {\n        ok: true,\n        text: t(I18nKey.MCP$TEST_SUCCESS, { count: result.tools.length }),\n      };\n    }\n    // OAuth results come only from remote servers; `testedServer` may be\n    // left over from an earlier non-OAuth test.\n    const resultServerType = oauthTestResult ? undefined : testedServer?.type;\n    return {\n      ok: false,\n      text: makeMcpTestErrorMessage(\n        t,\n        result.error_kind,\n        result.error,\n        resultServerType,\n      ),\n    };\n  }, [oauthTestResult, testResult, testedServer, t]);\n\n  // A save always follows a fresh successful probe of the exact config being\n  // saved, so publish that result to the card's health entry. For stdio\n  // servers on cloud backends the probe is synthetic \u2014 never present it as a\n  // health verdict.\n  const seedSavedServerHealth = (\n    serverToSave: MCPServerConfig,\n    result: ExtendedMCPTestResponse,\n  ) => {\n    if (isCloudBackend && serverToSave.type === \"stdio\") return;\n    // When editing, the server's own entry must be overwritten, so only the\n    // OTHER servers guard against a same-key collision.\n    const otherServers = isEditing\n      ? existingServers.filter((existing) => existing.id !== server.id)\n      : existingServers;\n    seedMcpServerHealth(serverToSave, result, otherServers);\n  };\n\n  // Shared error handler so both add and update surface backend errors\n  // as a toast instead of failing silently \u2014 previously these calls\n  // had no `onError` and the modal closed even on a 4xx/5xx, leaving\n  // the user to discover the failure on the next page load.\n  const handleError = (err: unknown) => {\n    if (err instanceof Error && err.message === MCP_RENAME_CREDENTIAL_ERROR) {\n      displayErrorToast(err.message);\n      return;\n    }\n    const message = retrieveAxiosErrorMessage(err as AxiosError);\n    displayErrorToast(message || t(I18nKey.ERROR$GENERIC));\n  };\n\n  const handleSubmit = (payload: MCPServerConfig) => {\n    resetTest();\n    setOauthTestResult(null);\n    if (payload.auth?.strategy === \"oauth2\") {\n      setIsOauthTesting(true);\n      void McpService.authorizeOAuth(payload)\n        .then((result) => {\n          setOauthTestResult(result);\n          if (!result.ok) return;\n          const serverToSave = result.oauth_state\n            ? {\n                ...payload,\n                auth: { ...payload.auth!, state: result.oauth_state },\n              }\n            : payload;\n          const onSaved = () => {\n            seedSavedServerHealth(serverToSave, result);\n            onClose();\n          };\n          if (isEditing) {\n            updateMcpServer(\n              { serverId: server.id, server: serverToSave },\n              { onSuccess: onSaved, onError: handleError },\n            );\n          } else {\n            addMcpServer(serverToSave, {\n              onSuccess: onSaved,\n              onError: handleError,\n            });\n          }\n        })\n        .catch(handleError)\n        .finally(() => setIsOauthTesting(false));\n      return;\n    }\n    testServer(payload, {\n      onSuccess: (result) => {\n        if (!result.ok) {\n          // Test failed \u2014 modal stays open, error shown via testMessage.\n          return;\n        }\n        const serverToSave =\n          result.oauth_state && payload.auth?.strategy === \"oauth2\"\n            ? {\n                ...payload,\n                auth: { ...payload.auth, state: result.oauth_state },\n              }\n            : payload;\n        const onSaved = () => {\n          seedSavedServerHealth(serverToSave, result);\n          onClose();\n        };\n        if (isEditing) {\n          updateMcpServer(\n            { serverId: server.id, server: serverToSave },\n            { onSuccess: onSaved, onError: handleError },\n          );\n        } else {\n          addMcpServer(serverToSave, {\n            onSuccess: onSaved,\n            onError: handleError,\n          });\n        }\n      },\n    });\n  };\n\n  const handleTestClick = (payload: MCPServerConfig) => {\n    setOauthTestResult(null);\n    if (payload.auth?.strategy === \"oauth2\") {\n      setIsOauthTesting(true);\n      void McpService.authorizeOAuth(payload)\n        .then(setOauthTestResult)\n        .catch(handleError)\n        .finally(() => setIsOauthTesting(false));\n      return;\n    }\n    testServer(payload);\n  };\n\n  const handleConfirmDelete = () => {\n    deleteMcpServer(server, {\n      onSuccess: () => {\n        displaySuccessToast(t(I18nKey.MCP$REMOVE_SUCCESS));\n        setShowDeleteConfirm(false);\n        onClose();\n      },\n      onError: (err) => {\n        handleError(err);\n        setShowDeleteConfirm(false);\n      },\n    });\n  };\n\n  return (\n    <>\n      <ModalBackdrop\n        // Block backdrop-click / Escape from dismissing the modal while\n        // a mutation is in flight \u2014 closing mid-request would orphan\n        // the request and leave the user with no error feedback.\n        onClose={isDismissBlocked ? undefined : onClose}\n        closeOnEscape={!isDismissBlocked}\n        aria-label={\n          isEditing\n            ? t(I18nKey.MCP$EDIT_CUSTOM_TITLE)\n            : t(I18nKey.MCP$ADD_CUSTOM_TITLE)\n        }\n      >\n        <div\n          data-testid=\"mcp-custom-editor\"\n          className=\"relative bg-base-secondary p-6 rounded-xl border border-border w-130 max-w-[90vw] max-h-[90vh] overflow-y-auto custom-scrollbar\"\n        >\n          <ModalCloseButton\n            onClose={onClose}\n            testId=\"mcp-custom-editor-close\"\n            disabled={isDismissBlocked}\n          />\n          <h2 className={cn(\"mb-4 pr-6\", modalTitleLgClassName)}>\n            {isEditing\n              ? t(I18nKey.MCP$EDIT_CUSTOM_TITLE)\n              : t(I18nKey.MCP$ADD_CUSTOM_TITLE)}\n          </h2>\n          <MCPServerForm\n            mode={isEditing ? \"edit\" : \"add\"}\n            server={isEditing ? server : undefined}\n            existingServers={existingServers}\n            onSubmit={handleSubmit}\n            onCancel={onClose}\n            onDelete={isEditing ? () => setShowDeleteConfirm(true) : undefined}\n            isActionDisabled={isPending}\n            onTest={handleTestClick}\n            isTestPending={isTesting || isOauthTesting}\n            testMessage={isSyntheticTestResult ? null : testMessage}\n            isStdioTestUnavailable={isCloudBackend}\n          />\n        </div>\n      </ModalBackdrop>\n\n      {showDeleteConfirm ? (\n        <ConfirmationModal\n          text={t(I18nKey.SETTINGS$MCP_CONFIRM_DELETE)}\n          onCancel={() => setShowDeleteConfirm(false)}\n          onConfirm={handleConfirmDelete}\n          isConfirming={isDeleting}\n        />\n      ) : null}\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/mcp-page/install-server-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[90vw]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 752,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 752,
          "endColumn": 168
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[85vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 752,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 752,
          "endColumn": 168
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { AxiosError } from \"axios\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { SettingsInput } from \"#/components/features/settings/settings-input\";\nimport { SaveAsSecretToggle } from \"#/components/features/mcp-page/save-as-secret-toggle\";\nimport { NativeIntegrationPanel } from \"#/components/features/mcp-page/native-integration-panel\";\nimport { TabButton } from \"#/components/features/conversation-panel/system-message-modal/tab-button\";\nimport { BrandBadge } from \"#/components/shared/badge\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type {\n  IntegrationCatalogEntry as MarketplaceEntry,\n  MarketplaceField,\n} from \"@openhands/extensions/integrations\";\nimport { McpLogoBadge } from \"#/components/features/mcp-logo-badge\";\nimport { MCPServerConfig } from \"#/types/mcp-server\";\nimport type { MCPAuthCredential } from \"#/types/mcp-auth\";\nimport { seedMcpServerHealth } from \"#/api/mcp-health/probe-mcp-server-health\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useAddMcpServer } from \"#/hooks/mutation/use-add-mcp-server\";\nimport {\n  isNativeGitCandidate,\n  useNativeGitIntegrations,\n} from \"#/hooks/query/use-native-git-integrations\";\nimport { useTestMcpServer } from \"#/hooks/mutation/use-test-mcp-server\";\nimport { displaySuccessToast } from \"#/utils/custom-toast-handlers\";\nimport {\n  getMcpOAuthAuthenticationConfig,\n  getInstallableMcpConnectionOptions,\n  type McpMarketplaceConnectionOption,\n} from \"#/utils/mcp-marketplace-utils\";\nimport { retrieveAxiosErrorMessage } from \"#/utils/retrieve-axios-error-message\";\nimport { useSaveFieldsAsSecrets } from \"#/hooks/mutation/use-save-fields-as-secrets\";\nimport { makeMcpTestErrorMessage } from \"#/utils/mcp-test-error-message\";\nimport { modalTitleLgClassName } from \"#/utils/modal-classes\";\nimport McpService from \"#/api/mcp-service/mcp-service.api\";\nimport { toMcpServerName } from \"#/utils/mcp-server-name\";\n\n/**\n * Renders a helperText string as React nodes, converting any `[text](url)`\n * markdown links into real `<a>` elements. Plain text segments are left as-is.\n * Only `http:` and `https:` URLs are rendered as links; anything else falls\n * back to `#` to guard against `javascript:` / `data:` XSS vectors.\n */\nfunction renderHelperText(text: string): React.ReactNode {\n  const linkPattern = /\\[([^\\]]+)\\]\\(([^)]+)\\)/g;\n  const parts: React.ReactNode[] = [];\n  let lastIndex = 0;\n  for (const match of text.matchAll(linkPattern)) {\n    if (match.index > lastIndex) {\n      parts.push(text.slice(lastIndex, match.index));\n    }\n    parts.push(\n      <a\n        key={match.index}\n        href={/^https?:\\/\\//i.test(match[2]) ? match[2] : \"#\"}\n        target=\"_blank\"\n        rel=\"noreferrer\"\n        className=\"underline hover:text-contrast transition-colors\"\n      >\n        {match[1]}\n      </a>,\n    );\n    lastIndex = match.index + match[0].length;\n  }\n  if (lastIndex < text.length) {\n    parts.push(text.slice(lastIndex));\n  }\n  return parts;\n}\n\ninterface InstallServerModalProps {\n  entry: MarketplaceEntry;\n  /** Servers installed before this modal opened \u2014 guards health seeding. */\n  existingServers: MCPServerConfig[];\n  onClose: () => void;\n  onSuccess?: (entry: MarketplaceEntry) => void;\n  /**\n   * When true, an entry that has an MCP option opens on its MCP form without\n   * offering the instance's native integration. Defaults to false.\n   */\n  mcpOnly?: boolean;\n}\n\ninterface FieldState {\n  values: Record<string, string>;\n  errors: Record<string, string | null>;\n  savedAsSecret: Record<string, boolean>;\n}\n\nfunction optionNeedsCredentialField(\n  option: McpMarketplaceConnectionOption | undefined,\n): boolean {\n  if (option?.transport.kind !== \"shttp\" && option?.transport.kind !== \"sse\") {\n    return false;\n  }\n  return [\"api_key\", \"bearer\"].includes(option.auth.strategy);\n}\n\nfunction isOAuthOption(\n  option: McpMarketplaceConnectionOption | undefined,\n): boolean {\n  return !!option && option.auth.strategy === \"oauth2\";\n}\n\nfunction isCredentialOptional(option: McpMarketplaceConnectionOption): boolean {\n  if (option.transport.kind === \"stdio\") {\n    return option.auth.apiKeyOptional ?? false;\n  }\n  return option.auth.apiKeyOptional ?? option.transport.apiKeyOptional ?? false;\n}\n\nfunction getRemoteHeaderFields(\n  option: McpMarketplaceConnectionOption | undefined,\n): MarketplaceField[] {\n  if (option?.transport.kind !== \"shttp\" && option?.transport.kind !== \"sse\") {\n    return [];\n  }\n  return option.transport.headerFields ?? [];\n}\n\nfunction getOptionLabelKey(option: McpMarketplaceConnectionOption): I18nKey {\n  if (option.auth.strategy === \"oauth2\") return I18nKey.MCP$OPTION_OAUTH;\n  if ([\"api_key\", \"bearer\"].includes(option.auth.strategy)) {\n    return I18nKey.MCP$OPTION_API_KEY;\n  }\n  return option.transport.kind === \"stdio\"\n    ? I18nKey.MCP$OPTION_LOCAL\n    : I18nKey.MCP$OPTION_REMOTE;\n}\n\nfunction makeInitialState(\n  option: McpMarketplaceConnectionOption | undefined,\n): FieldState {\n  const values: Record<string, string> = {};\n  const savedAsSecret: Record<string, boolean> = {};\n  const template = option?.transport;\n  if (template?.kind === \"stdio\") {\n    for (const field of template.envFields ?? []) {\n      values[field.key] = \"\";\n      // Pre-check password fields; non-password fields default to off.\n      savedAsSecret[field.key] = field.type === \"password\";\n    }\n    for (const field of template.argFields ?? []) {\n      values[field.key] = \"\";\n    }\n  } else if (template?.kind === \"shttp\" || template?.kind === \"sse\") {\n    values.url = template.url;\n    for (const field of getRemoteHeaderFields(option)) {\n      values[field.key] = \"\";\n      savedAsSecret[field.key] = field.type === \"password\";\n    }\n    if (optionNeedsCredentialField(option)) {\n      values.api_key = \"\";\n      if (option?.auth.credentialSecretName) {\n        savedAsSecret.api_key =\n          option.auth.saveCredentialAsSecretByDefault ?? false;\n      }\n    }\n  }\n  return { values, errors: {}, savedAsSecret };\n}\n\n// The marketplace install modal is intentionally add-only: clicking\n// a catalog tile always appends a new server (the user might want\n// two Slack workspaces, two Postgres connections, etc.) even when\n// one of the same template kind is already installed. Editing an\n// existing server is reached via the installed-server-card's edit\n// button, which opens `CustomServerEditor` instead.\nexport function InstallServerModal({\n  entry,\n  existingServers,\n  onClose,\n  onSuccess,\n  mcpOnly = false,\n}: InstallServerModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { mutate: addMcpServer, isPending: isAdding } = useAddMcpServer();\n  const { mutate: testMcpServer, isPending: isTesting } = useTestMcpServer();\n  const saveFieldsAsSecrets = useSaveFieldsAsSecrets();\n  const { backend } = useActiveBackend();\n  const isCloudBackend = backend.kind === \"cloud\";\n\n  // The first installable option is the entry's preferred one; entries with\n  // several let the user pick another before filling in its fields.\n  const options = React.useMemo(\n    () => getInstallableMcpConnectionOptions(entry),\n    [entry],\n  );\n  const [selectedOptionIndex, setSelectedOptionIndex] = React.useState(0);\n  const option = options[selectedOptionIndex];\n\n  const [state, setState] = React.useState<FieldState>(() =>\n    makeInitialState(option),\n  );\n  // Always holds the latest state so async callbacks (onSuccess) never read\n  // stale closure values, even under React concurrent-mode scheduling.\n  const stateRef = React.useRef(state);\n  stateRef.current = state;\n\n  const [globalError, setGlobalError] = React.useState<string | null>(null);\n  const [isFinalizingInstall, setIsFinalizingInstall] = React.useState(false);\n  const [isAuthorizingOAuth, setIsAuthorizingOAuth] = React.useState(false);\n  const template = option?.transport;\n  // stdio servers on cloud backends get a synthetic test success (they only\n  // run inside the sandbox); never seed card health from it.\n  const isSyntheticTest = isCloudBackend && template?.kind === \"stdio\";\n\n  // A git entry the cloud instance can also connect natively offers that as\n  // the recommended option next to the MCP server; an entry with no MCP\n  // option (Bitbucket) offers only the native one. A caller that asked for the\n  // MCP server alone skips the native option whenever the entry has an MCP one.\n  const { getNativeIntegration, isLoading: isNativeIntegrationsLoading } =\n    useNativeGitIntegrations();\n  const skipNative = mcpOnly && !!option;\n  const native = skipNative ? null : getNativeIntegration(entry.id);\n  // Until the instance's providers are known, a git entry cannot say whether\n  // it leads with the native option; showing the MCP form meanwhile would swap\n  // it out from under the user once the answer arrives.\n  const isResolvingNative =\n    !skipNative &&\n    isNativeIntegrationsLoading &&\n    isNativeGitCandidate(entry.id);\n  const [selectedConnection, setSelectedConnection] = React.useState<\n    \"native\" | \"mcp\"\n  >(\"native\");\n  const showConnectionTabs = !!native && !!option;\n  const isNativeSelected =\n    !!native && (!option || selectedConnection === \"native\");\n\n  const isPending =\n    isTesting || isAuthorizingOAuth || isAdding || isFinalizingInstall;\n\n  const setValue = (key: string, value: string) => {\n    setState((prev) => ({\n      ...prev,\n      values: { ...prev.values, [key]: value },\n      errors: { ...prev.errors, [key]: null },\n    }));\n    setGlobalError(null);\n  };\n\n  const selectOption = (index: number) => {\n    setSelectedOptionIndex(index);\n    setState(makeInitialState(options[index]));\n    setGlobalError(null);\n  };\n\n  const toggleSecret = (key: string, value: boolean) => {\n    setState((prev) => ({\n      ...prev,\n      savedAsSecret: { ...prev.savedAsSecret, [key]: value },\n    }));\n  };\n\n  const saveHostedCredentialAsSecret = (): Promise<void> => {\n    const secretName = option?.auth.credentialSecretName;\n    const apiKey = stateRef.current.values.api_key?.trim();\n    if (!secretName || !apiKey || !stateRef.current.savedAsSecret.api_key) {\n      return Promise.resolve();\n    }\n\n    const field: MarketplaceField = {\n      key: secretName,\n      label: option.auth.credentialLabel ?? secretName,\n      type: \"password\",\n      required: true,\n    };\n    return saveFieldsAsSecrets(\n      [field],\n      { [secretName]: apiKey },\n      { [secretName]: true },\n    );\n  };\n\n  const saveSelectedSecrets = (): Promise<void> => {\n    if (template?.kind === \"stdio\") {\n      return saveFieldsAsSecrets(\n        template.envFields ?? [],\n        stateRef.current.values,\n        stateRef.current.savedAsSecret,\n      );\n    }\n    if (template?.kind === \"shttp\" || template?.kind === \"sse\") {\n      return Promise.all([\n        saveHostedCredentialAsSecret(),\n        saveFieldsAsSecrets(\n          getRemoteHeaderFields(option),\n          stateRef.current.values,\n          stateRef.current.savedAsSecret,\n        ),\n      ]).then(() => undefined);\n    }\n    return Promise.resolve();\n  };\n\n  const submitServer = (payload: MCPServerConfig) => {\n    if (payload.auth?.strategy === \"oauth2\") {\n      setIsAuthorizingOAuth(true);\n      void McpService.authorizeOAuth(payload)\n        .then((result) => {\n          if (!result.ok) {\n            setGlobalError(\n              makeMcpTestErrorMessage(\n                t,\n                result.error_kind,\n                result.error,\n                payload.type,\n              ),\n            );\n            return;\n          }\n          const serverToSave = result.oauth_state\n            ? {\n                ...payload,\n                auth: { ...payload.auth!, state: result.oauth_state },\n              }\n            : payload;\n          addMcpServer(serverToSave, {\n            onSuccess: () => {\n              if (!isSyntheticTest) {\n                seedMcpServerHealth(serverToSave, result, existingServers);\n              }\n              displaySuccessToast(t(I18nKey.MCP$INSTALL_SUCCESS));\n              setIsFinalizingInstall(true);\n              void (async () => {\n                try {\n                  await saveSelectedSecrets();\n                } finally {\n                  onSuccess?.(entry);\n                  onClose();\n                }\n              })();\n            },\n            onError: (err: unknown) => {\n              const message = retrieveAxiosErrorMessage(err as AxiosError);\n              setGlobalError(message || t(I18nKey.ERROR$GENERIC));\n            },\n          });\n        })\n        .catch((err: unknown) => {\n          const message = retrieveAxiosErrorMessage(err as AxiosError);\n          setGlobalError(message || t(I18nKey.ERROR$GENERIC));\n        })\n        .finally(() => setIsAuthorizingOAuth(false));\n      return;\n    }\n    testMcpServer(payload, {\n      onSuccess: (result) => {\n        if (!result.ok) {\n          setGlobalError(\n            makeMcpTestErrorMessage(\n              t,\n              result.error_kind,\n              result.error,\n              payload.type,\n            ),\n          );\n          // Modal stays open \u2014 do NOT call onClose.\n          return;\n        }\n        const serverToSave =\n          result.oauth_state && payload.auth?.strategy === \"oauth2\"\n            ? {\n                ...payload,\n                auth: { ...payload.auth, state: result.oauth_state },\n              }\n            : payload;\n        addMcpServer(serverToSave, {\n          onSuccess: () => {\n            if (!isSyntheticTest) {\n              seedMcpServerHealth(serverToSave, result, existingServers);\n            }\n            displaySuccessToast(t(I18nKey.MCP$INSTALL_SUCCESS));\n            setIsFinalizingInstall(true);\n            void (async () => {\n              try {\n                await saveSelectedSecrets();\n              } finally {\n                onSuccess?.(entry);\n                onClose();\n              }\n            })();\n          },\n          onError: (err: unknown) => {\n            const message = retrieveAxiosErrorMessage(err as AxiosError);\n            setGlobalError(message || t(I18nKey.ERROR$GENERIC));\n          },\n        });\n      },\n      onError: (err: unknown) => {\n        const message = retrieveAxiosErrorMessage(err as AxiosError);\n        setGlobalError(message || t(I18nKey.ERROR$GENERIC));\n      },\n    });\n  };\n\n  // ------------------------------------------------------------------\n  // Per-template submit handlers. Each is small and self-contained:\n  // validate user input, build the payload, then hand off to\n  // submitServer.\n  // ------------------------------------------------------------------\n  const handleHttpServerSubmit = () => {\n    // TS narrows this branch to shttp|sse; the equality guard is a\n    // runtime/defensive belt to make the helper safe in isolation.\n    if (template?.kind !== \"shttp\" && template?.kind !== \"sse\") {\n      return;\n    }\n    if (!option) return;\n    const apiKey = state.values.api_key?.trim() ?? \"\";\n    const url = template.urlEditable\n      ? (state.values.url?.trim() ?? \"\")\n      : template.url;\n    const oauthMode = isOAuthOption(option);\n    const needsCredential = optionNeedsCredentialField(option);\n    const headerFields = getRemoteHeaderFields(option);\n    const headerErrors: Record<string, string | null> = {};\n    if (!url) {\n      headerErrors.url = t(I18nKey.SETTINGS$MCP_ERROR_URL_REQUIRED);\n    } else {\n      try {\n        const parsedUrl = new URL(url);\n        if (![\"http:\", \"https:\"].includes(parsedUrl.protocol)) {\n          headerErrors.url = t(I18nKey.SETTINGS$MCP_ERROR_URL_INVALID_PROTOCOL);\n        }\n      } catch {\n        headerErrors.url = t(I18nKey.SETTINGS$MCP_ERROR_URL_INVALID);\n      }\n    }\n    for (const field of headerFields) {\n      if (field.required && !(state.values[field.key] ?? \"\").trim()) {\n        headerErrors[field.key] = t(I18nKey.MCP$ERROR_FIELD_REQUIRED);\n      }\n    }\n    if (\n      !oauthMode &&\n      needsCredential &&\n      !isCredentialOptional(option) &&\n      !apiKey\n    ) {\n      headerErrors.api_key = t(I18nKey.MCP$ERROR_FIELD_REQUIRED);\n    }\n    if (Object.values(headerErrors).some(Boolean)) {\n      setState((prev) => ({ ...prev, errors: headerErrors }));\n      return;\n    }\n    const oauthAuthentication = oauthMode\n      ? getMcpOAuthAuthenticationConfig(option)\n      : undefined;\n    const fieldHeaders = Object.fromEntries(\n      headerFields\n        .map((field) => [field.key, state.values[field.key]?.trim() ?? \"\"])\n        .filter(([, value]) => value),\n    );\n    const hasFieldHeaders = Object.keys(fieldHeaders).length > 0;\n    let auth: MCPAuthCredential | undefined;\n    if (oauthMode) {\n      auth = {\n        strategy: \"oauth2\",\n        ...(oauthAuthentication && { authentication: oauthAuthentication }),\n      };\n    } else if (needsCredential && apiKey) {\n      auth =\n        option.auth.strategy === \"api_key\"\n          ? {\n              strategy: \"api_key\",\n              value: apiKey,\n              ...(option.auth.apiKeyHeaderName && {\n                header_name: option.auth.apiKeyHeaderName,\n              }),\n            }\n          : { strategy: \"bearer\", value: apiKey };\n    } else if (hasFieldHeaders) {\n      auth = { strategy: \"header\", headers: fieldHeaders };\n    }\n    const payload: MCPServerConfig = {\n      id: \"\",\n      type: template.kind,\n      // Name remote servers after the catalog slug (e.g. \"github\") so they\n      // get a referenceable, LLM-tool-safe mcp_config key instead of the\n      // auto-generated \"sse\"/\"shttp\" fallback. Stdio installs already carry\n      // serverName from the catalog.\n      name: toMcpServerName(entry.id),\n      url,\n      ...(auth && { auth }),\n      ...(hasFieldHeaders &&\n        auth?.strategy !== \"header\" && { headers: fieldHeaders }),\n    };\n    submitServer(payload);\n  };\n\n  const handleStdioSubmit = () => {\n    if (template?.kind !== \"stdio\") return;\n    const stdio = template;\n    const errors: Record<string, string | null> = {};\n\n    for (const field of stdio.envFields ?? []) {\n      if (field.required && !(state.values[field.key] ?? \"\").trim()) {\n        errors[field.key] = t(I18nKey.MCP$ERROR_FIELD_REQUIRED);\n      }\n    }\n    for (const field of stdio.argFields ?? []) {\n      if (field.required && !(state.values[field.key] ?? \"\").trim()) {\n        errors[field.key] = t(I18nKey.MCP$ERROR_FIELD_REQUIRED);\n      }\n    }\n    if (Object.values(errors).some(Boolean)) {\n      setState((prev) => ({ ...prev, errors }));\n      return;\n    }\n\n    const env: Record<string, string> = {};\n    for (const field of stdio.envFields ?? []) {\n      const v = state.values[field.key]?.trim();\n      if (v) env[field.key] = v;\n    }\n    const extraArgs: string[] = [];\n    for (const field of stdio.argFields ?? []) {\n      const v = state.values[field.key]?.trim();\n      if (v) {\n        // Filesystem-style multi-token input: split on whitespace.\n        for (const token of v.split(/\\s+/)) {\n          if (token) extraArgs.push(token);\n        }\n      }\n    }\n\n    const payload: MCPServerConfig = {\n      id: \"\",\n      type: \"stdio\",\n      name: stdio.serverName,\n      command: stdio.command,\n      args: [...stdio.args, ...extraArgs],\n      ...(Object.keys(env).length > 0 && { env }),\n    };\n    submitServer(payload);\n  };\n\n  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    setGlobalError(null);\n    if (template?.kind === \"shttp\" || template?.kind === \"sse\") {\n      return handleHttpServerSubmit();\n    }\n    if (template?.kind === \"stdio\") {\n      return handleStdioSubmit();\n    }\n    // @spec MCP-006 \u2014 Library installs never dead-end\n    return setGlobalError(\n      t(I18nKey.MCP$INSTALL_UNAVAILABLE, { name: entry.name }),\n    );\n  };\n\n  // @spec MCP-004 \u2014 Form feedback follows readable theme text roles\n  const renderFields = () => {\n    if (template?.kind === \"shttp\" || template?.kind === \"sse\") {\n      const oauthMode = isOAuthOption(option);\n      const shouldRenderCredential = optionNeedsCredentialField(option);\n      const apiKeyOptional = option ? isCredentialOptional(option) : false;\n      const credentialSecretName = option?.auth.credentialSecretName;\n      const headerFields = getRemoteHeaderFields(option);\n      return (\n        <>\n          <SettingsInput\n            testId=\"mcp-install-field-url\"\n            name=\"url\"\n            type=\"url\"\n            label={t(I18nKey.SETTINGS$MCP_URL)}\n            value={state.values.url ?? template.url}\n            onChange={(value) => setValue(\"url\", value)}\n            isDisabled={!template.urlEditable}\n            className=\"w-full\"\n          />\n          {state.errors.url && (\n            <p className=\"text-xs text-feedback-error\">{state.errors.url}</p>\n          )}\n          {headerFields.map((field) => (\n            <div key={field.key} className=\"flex flex-col gap-1\">\n              <SettingsInput\n                testId={`mcp-install-field-${field.key}`}\n                name={field.key}\n                type={field.type === \"password\" ? \"password\" : \"text\"}\n                label={field.label}\n                value={state.values[field.key] ?? \"\"}\n                onChange={(v) => setValue(field.key, v)}\n                placeholder={field.placeholder}\n                required={field.required}\n                showOptionalTag={!field.required}\n                className=\"w-full\"\n              />\n              {field.helperText && (\n                <p className=\"text-xs text-tertiary-alt\">\n                  {renderHelperText(field.helperText)}\n                </p>\n              )}\n              {state.errors[field.key] && (\n                <p className=\"text-xs text-feedback-error\">\n                  {state.errors[field.key]}\n                </p>\n              )}\n              {field.key in state.savedAsSecret && (\n                <SaveAsSecretToggle\n                  fieldKey={field.key}\n                  checked={state.savedAsSecret[field.key] ?? false}\n                  onToggle={(v) => toggleSecret(field.key, v)}\n                />\n              )}\n            </div>\n          ))}\n          {oauthMode ? (\n            <div\n              data-testid=\"mcp-install-oauth-info\"\n              className=\"flex flex-col gap-2 p-3 rounded-lg border border-border bg-tertiary\"\n            >\n              <p className=\"text-sm text-text-secondary\">\n                {t(I18nKey.MCP$OAUTH_CONNECT_INFO)}\n              </p>\n              <p className=\"text-xs text-tertiary-alt\">\n                {t(I18nKey.MCP$OAUTH_CONNECT_HINT)}\n              </p>\n            </div>\n          ) : shouldRenderCredential ? (\n            <div className=\"flex flex-col gap-1\">\n              <SettingsInput\n                testId=\"mcp-install-field-api_key\"\n                name=\"api_key\"\n                type=\"password\"\n                label={\n                  option?.auth.credentialLabel ??\n                  t(I18nKey.SETTINGS$MCP_API_KEY)\n                }\n                value={state.values.api_key ?? \"\"}\n                onChange={(v) => setValue(\"api_key\", v)}\n                placeholder={\n                  option?.auth.credentialPlaceholder ??\n                  t(I18nKey.SETTINGS$MCP_API_KEY_PLACEHOLDER)\n                }\n                showOptionalTag={apiKeyOptional}\n                required={!apiKeyOptional}\n                className=\"w-full\"\n              />\n              {option?.auth.credentialHelp && (\n                <p className=\"text-xs text-tertiary-alt\">\n                  {renderHelperText(option.auth.credentialHelp)}\n                </p>\n              )}\n              {state.errors.api_key && (\n                <p className=\"text-xs text-feedback-error\">\n                  {state.errors.api_key}\n                </p>\n              )}\n              {credentialSecretName && (\n                <SaveAsSecretToggle\n                  fieldKey={credentialSecretName}\n                  checked={state.savedAsSecret.api_key ?? false}\n                  onToggle={(v) => toggleSecret(\"api_key\", v)}\n                />\n              )}\n            </div>\n          ) : null}\n        </>\n      );\n    }\n\n    if (template?.kind !== \"stdio\") return null;\n    const stdio = template;\n    return (\n      <>\n        <SettingsInput\n          testId=\"mcp-install-field-command-readonly\"\n          name=\"command-readonly\"\n          type=\"text\"\n          label={t(I18nKey.MCP$COMMAND_LABEL)}\n          value={`${stdio.command} ${stdio.args.join(\" \")}`.trim()}\n          onChange={() => {}}\n          isDisabled\n          className=\"w-full\"\n        />\n        {(stdio.envFields ?? []).map((field) => (\n          <div key={field.key} className=\"flex flex-col gap-1\">\n            <SettingsInput\n              testId={`mcp-install-field-${field.key}`}\n              name={field.key}\n              type={field.type === \"password\" ? \"password\" : \"text\"}\n              label={field.label}\n              value={state.values[field.key] ?? \"\"}\n              onChange={(v) => setValue(field.key, v)}\n              placeholder={field.placeholder}\n              required={field.required}\n              showOptionalTag={!field.required}\n              className=\"w-full\"\n            />\n            {field.helperText && (\n              <p className=\"text-xs text-tertiary-alt\">\n                {renderHelperText(field.helperText)}\n              </p>\n            )}\n            {state.errors[field.key] && (\n              <p className=\"text-xs text-feedback-error\">\n                {state.errors[field.key]}\n              </p>\n            )}\n            {field.key in state.savedAsSecret && (\n              <SaveAsSecretToggle\n                fieldKey={field.key}\n                checked={state.savedAsSecret[field.key]}\n                onToggle={(v) => toggleSecret(field.key, v)}\n              />\n            )}\n          </div>\n        ))}\n        {/* argFields are CLI arguments, not credentials \u2014 they don't need\n            a \"save as secret\" toggle and are excluded from savedAsSecret. */}\n        {(stdio.argFields ?? []).map((field) => (\n          <div key={field.key} className=\"flex flex-col gap-1\">\n            <SettingsInput\n              testId={`mcp-install-field-${field.key}`}\n              name={field.key}\n              type={field.type === \"password\" ? \"password\" : \"text\"}\n              label={field.label}\n              value={state.values[field.key] ?? \"\"}\n              onChange={(v) => setValue(field.key, v)}\n              placeholder={field.placeholder}\n              required={field.required}\n              showOptionalTag={!field.required}\n              className=\"w-full\"\n            />\n            {field.helperText && (\n              <p className=\"text-xs text-tertiary-alt\">\n                {renderHelperText(field.helperText)}\n              </p>\n            )}\n            {state.errors[field.key] && (\n              <p className=\"text-xs text-feedback-error\">\n                {state.errors[field.key]}\n              </p>\n            )}\n          </div>\n        ))}\n      </>\n    );\n  };\n\n  return (\n    <ModalBackdrop onClose={onClose} aria-label={entry.name}>\n      <form\n        data-testid=\"mcp-install-modal\"\n        data-marketplace-id={entry.id}\n        onSubmit={handleSubmit}\n        className=\"relative bg-base-secondary p-6 rounded-xl flex flex-col gap-4 border border-border w-130 max-w-[90vw] max-h-[85vh] overflow-y-auto custom-scrollbar\"\n      >\n        <ModalCloseButton\n          onClose={onClose}\n          testId=\"mcp-install-modal-close\"\n          disabled={isPending}\n        />\n        <div className=\"flex items-start gap-3 pr-6\">\n          <McpLogoBadge entry={entry} />\n          <div className=\"flex flex-col flex-1\">\n            <h2 className={modalTitleLgClassName}>{entry.name}</h2>\n            <p className=\"text-xs text-tertiary-light\">{entry.description}</p>\n          </div>\n        </div>\n\n        {isResolvingNative && (\n          <p\n            data-testid=\"mcp-install-resolving\"\n            className=\"text-xs text-tertiary-light\"\n          >\n            {t(I18nKey.HOME$LOADING)}\n          </p>\n        )}\n\n        {showConnectionTabs && (\n          <div role=\"tablist\" className=\"flex border-b border-border\">\n            <TabButton\n              isActive={selectedConnection === \"native\"}\n              onClick={() => setSelectedConnection(\"native\")}\n              disabled={isPending}\n              className=\"flex items-center gap-2\"\n            >\n              <span data-testid=\"mcp-install-tab-native\">\n                {t(I18nKey.MCP$NATIVE_TAB, { name: entry.name })}\n              </span>\n              <BrandBadge className=\"shrink-0 whitespace-nowrap px-2.5 py-1 text-xs\">\n                {t(I18nKey.SETTINGS$SKILLS_RECOMMENDED)}\n              </BrandBadge>\n            </TabButton>\n            <TabButton\n              isActive={selectedConnection === \"mcp\"}\n              onClick={() => setSelectedConnection(\"mcp\")}\n            >\n              <span data-testid=\"mcp-install-tab-mcp\">\n                {t(I18nKey.MCP$MCP_TAB, { name: entry.name })}\n              </span>\n            </TabButton>\n          </div>\n        )}\n\n        {!isResolvingNative &&\n          (isNativeSelected && native ? (\n            <NativeIntegrationPanel\n              entry={entry}\n              native={native}\n              onConnected={() => {\n                onSuccess?.(entry);\n                onClose();\n              }}\n            />\n          ) : (\n            <>\n              {showConnectionTabs && (\n                <div className=\"flex flex-col gap-1\">\n                  <h3 className=\"text-sm font-semibold text-contrast\">\n                    {t(I18nKey.MCP$MCP_TAB, { name: entry.name })}\n                  </h3>\n                  <p className=\"text-xs text-tertiary-light\">\n                    {t(I18nKey.MCP$MCP_DESCRIPTION, { name: entry.name })}\n                  </p>\n                </div>\n              )}\n\n              {/* The entry's hint and documentation describe its MCP server, so\n                they belong to the MCP option rather than above both. */}\n              {entry.installHint && (\n                <p className=\"text-xs text-tertiary-light\">\n                  {entry.installHint}\n                </p>\n              )}\n\n              {entry.docsUrl && (\n                <a\n                  href={entry.docsUrl}\n                  target=\"_blank\"\n                  rel=\"noreferrer\"\n                  className=\"text-xs text-muted hover:text-contrast hover:underline self-start transition-colors\"\n                >\n                  {t(I18nKey.MCP$VIEW_DOCS)}\n                </a>\n              )}\n\n              {options.length > 1 && (\n                <div\n                  role=\"tablist\"\n                  aria-label={t(I18nKey.MCP$CONNECTION_METHOD)}\n                  data-testid=\"mcp-install-options\"\n                  className=\"flex flex-wrap border-b border-border\"\n                >\n                  {options.map((candidate, index) => (\n                    <TabButton\n                      key={candidate.id}\n                      isActive={index === selectedOptionIndex}\n                      onClick={() => selectOption(index)}\n                      disabled={isPending}\n                    >\n                      <span data-testid={`mcp-install-option-${candidate.id}`}>\n                        {t(getOptionLabelKey(candidate))}\n                      </span>\n                    </TabButton>\n                  ))}\n                </div>\n              )}\n\n              <div className=\"flex flex-col gap-3\">{renderFields()}</div>\n\n              {/* @spec MCP-006 \u2014 Library installs never dead-end */}\n              {!option && (\n                <div\n                  data-testid=\"mcp-install-unavailable\"\n                  className=\"flex flex-col gap-2 p-3 rounded-lg border border-border bg-tertiary\"\n                >\n                  <p className=\"text-sm text-text-secondary\">\n                    {t(I18nKey.MCP$INSTALL_UNAVAILABLE, { name: entry.name })}\n                  </p>\n                </div>\n              )}\n\n              {/* @spec MCP-004 \u2014 Form feedback follows readable theme text roles */}\n              {globalError && (\n                <p\n                  data-testid=\"mcp-install-modal-error\"\n                  className=\"text-sm text-feedback-error whitespace-pre-wrap\"\n                >\n                  {globalError}\n                </p>\n              )}\n\n              <div className=\"flex justify-end gap-2 mt-2\">\n                <BrandButton\n                  type=\"button\"\n                  variant=\"secondary\"\n                  onClick={onClose}\n                  testId=\"mcp-install-cancel\"\n                  isDisabled={isPending}\n                >\n                  {t(I18nKey.BUTTON$CANCEL)}\n                </BrandButton>\n                <BrandButton\n                  type=\"submit\"\n                  variant=\"primary\"\n                  isDisabled={isPending || !option}\n                  testId=\"mcp-install-submit\"\n                >\n                  {isTesting || isAuthorizingOAuth\n                    ? t(I18nKey.MCP$VERIFYING)\n                    : isAdding || isFinalizingInstall\n                      ? t(I18nKey.SETTINGS$SAVING)\n                      : t(I18nKey.MCP$INSTALL_BUTTON)}\n                </BrandButton>\n              </div>\n            </>\n          ))}\n      </form>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/mcp-page/mcp-logo-stack-badge.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"shadow-[inset_0_1px_0_color-mix(in_srgb,var(--oh-contrast)_18%,transparent)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 8,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 8,
          "endColumn": 183
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import {\n  McpLogoBadge,\n  type McpLogoEntry,\n} from \"#/components/features/mcp-logo-badge\";\nimport { cn } from \"#/utils/utils\";\n\nconst STACK_CONTAINER_CLASS_NAME =\n  \"inline-flex h-10 w-10 shrink-0 overflow-hidden rounded-lg border border-contrast/10 bg-surface-raised shadow-[inset_0_1px_0_color-mix(in_srgb,var(--oh-contrast)_18%,transparent)]\";\n\ninterface McpLogoStackBadgeProps {\n  entries: McpLogoEntry[];\n  className?: string;\n  testId?: string;\n}\n\nfunction toLogoEntries(\n  entries: McpLogoStackBadgeProps[\"entries\"],\n): McpLogoEntry[] {\n  return entries.slice(0, 4);\n}\n\nexport function McpLogoStackBadge({\n  entries,\n  className,\n  testId,\n}: McpLogoStackBadgeProps) {\n  const visibleEntries = toLogoEntries(entries);\n\n  if (visibleEntries.length === 0) {\n    return (\n      <McpLogoBadge\n        entry={null}\n        size=\"md\"\n        className={className}\n        testId={testId}\n      />\n    );\n  }\n\n  if (visibleEntries.length === 1) {\n    return (\n      <McpLogoBadge\n        entry={visibleEntries[0]}\n        size=\"md\"\n        className={className}\n        testId={testId}\n      />\n    );\n  }\n\n  if (visibleEntries.length === 2) {\n    return (\n      <span\n        aria-hidden=\"true\"\n        data-testid={testId}\n        data-layout=\"overlap\"\n        className={cn(\n          STACK_CONTAINER_CLASS_NAME,\n          \"items-center justify-center\",\n          className,\n        )}\n      >\n        <span className=\"flex items-center justify-center -space-x-2\">\n          {visibleEntries.map((entry) => (\n            <McpLogoBadge\n              key={entry.id}\n              entry={entry}\n              size=\"sm\"\n              className=\"ring-2 ring-surface-raised\"\n            />\n          ))}\n        </span>\n      </span>\n    );\n  }\n\n  const quadrantSlots: Array<McpLogoEntry | null> = [\n    visibleEntries[0] ?? null,\n    visibleEntries[1] ?? null,\n    visibleEntries[2] ?? null,\n    visibleEntries[3] ?? null,\n  ];\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-testid={testId}\n      data-layout=\"quadrants\"\n      className={cn(\n        STACK_CONTAINER_CLASS_NAME,\n        \"grid grid-cols-2 grid-rows-2 gap-0.5 p-1\",\n        className,\n      )}\n    >\n      {quadrantSlots.map((entry, index) =>\n        entry ? (\n          <span key={entry.id} className=\"flex items-center justify-center\">\n            <McpLogoBadge entry={entry} size=\"xs\" />\n          </span>\n        ) : (\n          <span key={`empty-${index}`} aria-hidden=\"true\" />\n        ),\n      )}\n    </span>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/mcp-page/save-as-secret-toggle.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 48,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 48,
          "endColumn": 112
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[9px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 62,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 62,
          "endColumn": 156
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { cn } from \"#/utils/utils\";\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { ToggleSwitchVisual } from \"#/ui/toggle-switch\";\n\ninterface SaveAsSecretToggleProps {\n  fieldKey: string;\n  checked: boolean;\n  onToggle: (value: boolean) => void;\n}\n\nexport function SaveAsSecretToggle({\n  fieldKey,\n  checked,\n  onToggle,\n}: SaveAsSecretToggleProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  // @spec MCP-005 \u2014 Credential selection stays readable in every theme\n  return (\n    <label\n      data-testid={`mcp-install-save-secret-${fieldKey}`}\n      className={cn(\n        \"flex flex-wrap items-center gap-2 px-3 py-2 mt-0.5 rounded-lg border cursor-pointer transition-colors\",\n        \"has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-focus\",\n        checked\n          ? \"border-contrast/25 bg-contrast/5\"\n          : \"border-border bg-transparent hover:bg-contrast/[0.03]\",\n      )}\n    >\n      {/* sr-only keeps the real checkbox in the accessibility tree so AT\n          users can toggle it without seeing the custom visual track. */}\n      <input\n        className=\"sr-only\"\n        id={`mcp-save-secret-checkbox-${fieldKey}`}\n        type=\"checkbox\"\n        checked={checked}\n        onChange={(e) => onToggle(e.target.checked)}\n      />\n      <ToggleSwitchVisual enabled={checked} />\n      <span className=\"text-sm text-contrast\">\n        {t(I18nKey.MCP$ALSO_SAVE_AS_SECRET)}\n      </span>\n      <span className=\"ml-auto flex min-w-0 max-w-full items-center gap-2\">\n        <code\n          className={cn(\n            \"min-w-0 break-all text-[11px] text-contrast font-mono tracking-tight border rounded px-1.5 py-0.5\",\n            checked ? \"border-contrast/25 bg-contrast/4\" : \"border-border\",\n          )}\n        >\n          {fieldKey}\n        </code>\n        <StyledTooltip\n          content={t(I18nKey.MCP$SAVE_AS_SECRET_TOOLTIP)}\n          placement=\"top\"\n        >\n          {/* The help button must stay keyboard-reachable without toggling. */}\n          <button\n            type=\"button\"\n            aria-label={t(I18nKey.MCP$SAVE_AS_SECRET_TOOLTIP)}\n            className=\"flex items-center justify-center size-3.75 shrink-0 rounded-full border border-muted text-contrast text-[9px] font-bold cursor-help\"\n            onClick={(e) => e.preventDefault()}\n          >\n            ?\n          </button>\n        </StyledTooltip>\n      </span>\n    </label>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/onboarding/onboarding-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets transform. Style through classes; use CSS custom properties for dynamic values.",
          "line": 92,
          "column": 27,
          "nodeType": "TemplateLiteral",
          "messageId": "inlineStyle",
          "endLine": 92,
          "endColumn": 54
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[90vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 319,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 319,
          "endColumn": 27
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { isNoBackend } from \"#/api/backend-registry/active-store\";\nimport { getLockedCloudHost, isSameCloudHost } from \"#/api/agent-server-config\";\nimport {\n  readSidebarOnboardingChecklistDismissed,\n  writeSidebarOnboardingChecklistDismissed,\n} from \"#/components/features/sidebar/sidebar-onboarding-checklist-storage\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  modalWidthClassName,\n} from \"#/components/shared/modals/modal-body\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { useActiveBackendContext } from \"#/contexts/active-backend-context\";\nimport { useBackendsHealth } from \"#/hooks/query/use-backends-health\";\nimport { useSettings } from \"#/hooks/query/use-settings\";\nimport { useTracking } from \"#/hooks/use-tracking\";\nimport { OnboardingProgressBar } from \"./onboarding-progress-bar\";\nimport {\n  ChooseAgentStep,\n  type OnboardingAgentId,\n} from \"./steps/choose-agent-step\";\nimport { CheckBackendStep } from \"./steps/check-backend-step\";\nimport { SetupLlmStep } from \"./steps/setup-llm-step\";\nimport { SetupAcpSecretsStep } from \"./steps/setup-acp-secrets-step\";\nimport { SayHelloStep } from \"./steps/say-hello-step\";\n\n/**\n * Logical onboarding phases.\n *\n * State is tracked as a phase (not a numeric step) so that the slide\n * indices renumbering when ``skipBackendStep`` flips \u2014 e.g. a returning\n * Cloud user finishes the backend slide and the carousel collapses from\n * 4 slides to 3 \u2014 cannot accidentally drag the user from \"agent\" onto\n * the slide that used to live at the agent index (\"setup\"). See the\n * regression test `onboarding-modal` covering this transition.\n */\ntype OnboardingPhase = \"backend\" | \"agent\" | \"setup\" | \"hello\";\n\nconst PHASE_ORDER_WITH_BACKEND: readonly OnboardingPhase[] = [\n  \"backend\",\n  \"agent\",\n  \"setup\",\n  \"hello\",\n];\nconst PHASE_ORDER_WITHOUT_BACKEND: readonly OnboardingPhase[] = [\n  \"agent\",\n  \"setup\",\n  \"hello\",\n];\n\n/**\n * sessionStorage flag marking that the one-time \"onboarding started\" analytics\n * event has already been captured for this browser session. It survives\n * rerenders, remounts and the lazy/Suspense flip so the event fires at most\n * once per onboarding session.\n */\nconst ONBOARDING_STARTED_TRACKED_KEY = \"openhands-onboarding-started\";\n\ninterface SlideProps {\n  /** Index of this slide in the step sequence. */\n  index: number;\n  /** Index of the currently visible step. */\n  currentStep: number;\n  children: React.ReactNode;\n}\n\n/**\n * One step panel inside the slide rail.\n *\n * Only the active slide is in normal flow \u2014 it drives the surrounding\n * container's height. Inactive slides are absolutely positioned so\n * they don't add their height to the modal box (which previously made\n * the modal \"overhang\" with empty space sized to the tallest step).\n *\n * Each slide is translated horizontally by `(index - currentStep) *\n * 100%` so the active step sits at offset 0, with prior steps off to\n * the left and upcoming steps off to the right. Changes to\n * `currentStep` smoothly animate the transform.\n */\nfunction Slide({ index, currentStep, children }: SlideProps) {\n  const isActive = index === currentStep;\n  const offsetPct = (index - currentStep) * 100;\n  return (\n    <div\n      data-testid={`onboarding-slide-${index}`}\n      data-active={isActive}\n      aria-hidden={!isActive}\n      // slide offset computed from step index at runtime\n      style={{ transform: `translateX(${offsetPct}%)` }}\n      className={cn(\n        \"w-full transition-transform duration-300 ease-out\",\n        // Inactive slides are taken out of flow so the rail's height\n        // tracks just the active step; they stay overlaid via inset-0\n        // so they slide in/out of view across the same horizontal box.\n        !isActive && \"pointer-events-none absolute inset-0\",\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\ninterface OnboardingModalProps {\n  /** Called when the user dismisses the modal (skip / X / launch). */\n  onClose: () => void;\n  /** Optional slide index for dev preview (`?previewOnboardingStep=`). */\n  initialStep?: number;\n  /** When true, skip/close does not persist onboarding completion. */\n  isPreview?: boolean;\n}\n\n/**\n * Top-level onboarding modal for first-time users.\n *\n * The flow starts with backend setup only when the active backend is missing\n * or cannot be reached. If an already configured backend is healthy, the user\n * starts directly on agent selection:\n *   0. Check/add backend (only when needed)\n *   1. Choose agent\n *   2. Set up LLM\n *   3. Say hello (creates a fresh conversation, then closes)\n *\n * Internally we track the user's *phase* \u2014 \"backend\" | \"agent\" | \"setup\" |\n * \"hello\" \u2014 rather than a numeric step, because the slide indices renumber\n * when the backend slide is skipped and we must never accidentally drop the\n * user onto whatever slide *used to* live at their numeric position.\n *\n * Each visible step lives in its own slide and the rail is translated\n * horizontally by step index, so transitioning between steps animates the new\n * step in from the right.\n */\nexport function OnboardingModal({\n  onClose,\n  initialStep = 0,\n  isPreview = false,\n}: OnboardingModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { data: settings } = useSettings();\n  const analyticsEnabled = settings?.user_consents_to_analytics === true;\n  const {\n    trackOnboardingStarted,\n    trackOnboardingStepViewed,\n    trackOnboardingCompleted,\n    trackOnboardingSkipped,\n  } = useTracking();\n  const { active } = useActiveBackendContext();\n  const { backend } = active;\n  const noBackendSelected = isNoBackend(backend);\n  const healthByBackendId = useBackendsHealth(\n    noBackendSelected ? [] : [backend],\n  );\n  // In locked-to-Cloud mode the backend slide may only be skipped when the\n  // active backend IS the configured locked Cloud host. A reachable stale\n  // Local backend (or a Cloud backend on a different host) must keep\n  // `CheckBackendStep` visible so the user can log into Cloud and replace\n  // the stale backend \u2014 otherwise they would continue as Local despite\n  // `VITE_LOCK_TO_CLOUD`. Outside locked mode the existing behavior\n  // (skip once the active backend is healthy) is unchanged.\n  const lockedCloudHost = getLockedCloudHost();\n  const isActiveLockedCloudBackend =\n    lockedCloudHost !== null &&\n    backend.kind === \"cloud\" &&\n    isSameCloudHost(backend.host, lockedCloudHost);\n  const skipBackendStep =\n    !noBackendSelected &&\n    healthByBackendId[backend.id]?.isConnected === true &&\n    (lockedCloudHost === null || isActiveLockedCloudBackend);\n\n  const slideOrder = skipBackendStep\n    ? PHASE_ORDER_WITHOUT_BACKEND\n    : PHASE_ORDER_WITH_BACKEND;\n\n  const [phase, setPhase] = React.useState<OnboardingPhase>(\n    () =>\n      PHASE_ORDER_WITH_BACKEND[\n        Math.min(Math.max(initialStep, 0), PHASE_ORDER_WITH_BACKEND.length - 1)\n      ],\n  );\n  const [selectedAgentId, setSelectedAgentId] =\n    React.useState<OnboardingAgentId>(\"openhands\");\n  const [skipGettingStartedChecklist, setSkipGettingStartedChecklist] =\n    React.useState(() => readSidebarOnboardingChecklistDismissed());\n\n  // When the backend slide drops out of the flow (skipBackendStep flips\n  // true), a user still parked on \"backend\" must be moved forward to the\n  // first remaining phase. Doing this with the *phase* rather than a numeric\n  // step keeps every other phase pinned to itself, so the user never lands\n  // on the slide that used to live at the next index (\"setup\").\n  React.useEffect(() => {\n    if (!slideOrder.includes(phase)) setPhase(slideOrder[0]);\n  }, [phase, slideOrder]);\n\n  const totalSteps = slideOrder.length;\n  const currentPhase = slideOrder.includes(phase) ? phase : slideOrder[0];\n  const currentStep = slideOrder.indexOf(currentPhase);\n\n  const handleSkipGettingStartedChange = (\n    event: React.ChangeEvent<HTMLInputElement>,\n  ) => {\n    const skip = event.target.checked;\n    setSkipGettingStartedChecklist(skip);\n    writeSidebarOnboardingChecklistDismissed(skip);\n  };\n\n  // Backend connectivity is \"settled\" once we know whether the active backend\n  // is reachable (or no backend is selected). Until then `skipBackendStep` may\n  // still flip true and renumber the slides, briefly showing the backend slide\n  // for an already-healthy backend. Gate Step Viewed on this so we never emit a\n  // phantom \"backend\" view that immediately auto-skips to \"agent\".\n  const backendHealthSettled =\n    noBackendSelected ||\n    healthByBackendId[backend.id]?.isConnected !== undefined;\n\n  const isOpenHands = selectedAgentId === \"openhands\";\n  const hideSkip = currentStep === 0 && getLockedCloudHost() !== null;\n  const goNext = React.useCallback(() => {\n    setPhase((prev) => {\n      const order = slideOrder;\n      const idx = order.indexOf(prev);\n      // If `prev` isn't in the current order (e.g. \"backend\" after the\n      // backend slide just collapsed), start from the first slot.\n      const safeIdx = idx === -1 ? 0 : idx;\n      return order[Math.min(safeIdx + 1, order.length - 1)];\n    });\n  }, [slideOrder]);\n  const goBack = React.useCallback(() => {\n    setPhase((prev) => {\n      const order = slideOrder;\n      const idx = order.indexOf(prev);\n      const safeIdx = idx === -1 ? 0 : idx;\n      return order[Math.max(safeIdx - 1, 0)];\n    });\n  }, [slideOrder]);\n\n  // --- Onboarding analytics -------------------------------------------------\n  // Every event is routed through `useTracking`, which drops captures unless\n  // the user has consented; the design-preview harness (`isPreview`) emits\n  // nothing.\n\n  // Onboarding Started \u2014 fire at most once per browser session. The\n  // sessionStorage flag survives rerenders, remounts and the lazy/Suspense\n  // flip; the consent gate means we only arm the guard once a capture can\n  // actually happen, so a user who consents partway through onboarding still\n  // gets the event instead of having it latched away during the no-consent\n  // window.\n  const startedTrackedRef = React.useRef(false);\n  React.useEffect(() => {\n    if (isPreview || !analyticsEnabled || startedTrackedRef.current) return;\n    if (window.sessionStorage.getItem(ONBOARDING_STARTED_TRACKED_KEY)) return;\n    startedTrackedRef.current = true;\n    window.sessionStorage.setItem(ONBOARDING_STARTED_TRACKED_KEY, \"1\");\n    trackOnboardingStarted();\n  }, [isPreview, analyticsEnabled]);\n\n  // Onboarding Step Viewed \u2014 fire once per phase entry. The phase string is the\n  // stable identity (slide indices renumber), so dedupe on it: this absorbs\n  // StrictMode's double-invoke and the index renumber, while re-entering a step\n  // via Back counts as a genuine new view.\n  const lastViewedPhaseRef = React.useRef<OnboardingPhase | null>(null);\n  React.useEffect(() => {\n    if (isPreview || !analyticsEnabled || !backendHealthSettled) return;\n    if (lastViewedPhaseRef.current === currentPhase) return;\n    lastViewedPhaseRef.current = currentPhase;\n    trackOnboardingStepViewed({\n      step: currentPhase,\n      stepIndex: currentStep,\n      totalSteps,\n      agent: selectedAgentId,\n    });\n  }, [\n    isPreview,\n    analyticsEnabled,\n    backendHealthSettled,\n    currentPhase,\n    currentStep,\n    totalSteps,\n    selectedAgentId,\n  ]);\n\n  // Onboarding Completed \u2014 `onLaunched` runs only once the user has committed\n  // to a launch: a created conversation for the hello message or a recommended\n  // automation, or the setup form for an automation that ships one. Wrapping it\n  // captures completion exactly once before the modal closes.\n  const handleCompleted = () => {\n    trackOnboardingCompleted({ agent: selectedAgentId });\n    onClose();\n  };\n\n  // Onboarding Skipped/Dismissed \u2014 the user exited before completing, via the\n  // skip button (non-final steps) or the final-step Close button. `currentPhase`\n  // records where they left, which also distinguishes the two affordances.\n  const handleSkipOrDismiss = () => {\n    trackOnboardingSkipped({\n      step: currentPhase,\n      stepIndex: currentStep,\n      totalSteps,\n      agent: selectedAgentId,\n    });\n    onClose();\n  };\n\n  return (\n    // No `onClose`: the flow must only be dismissed via explicit actions\n    // (the skip button or launching), never by an errant backdrop click or\n    // Escape press \u2014 see https://github.com/OpenHands/agent-canvas/issues/1085.\n    <ModalBackdrop aria-label={t(I18nKey.ONBOARDING$TITLE)}>\n      <div className=\"relative flex flex-col items-center gap-4\">\n        <section\n          data-testid=\"onboarding-modal\"\n          data-current-step={currentStep}\n          data-preview={isPreview ? \"true\" : undefined}\n          className={cn(\n            \"flex flex-col gap-6 overflow-hidden rounded-2xl border border-contrast/10 bg-base-secondary shadow-2xl\",\n            modalWidthClassName(\"lg\"),\n            MODAL_MAX_WIDTH_VIEWPORT,\n            \"max-h-[90vh]\",\n          )}\n        >\n          <header className=\"flex flex-col gap-3 px-7 pt-7 shrink-0\">\n            <OnboardingProgressBar\n              currentStep={currentStep}\n              totalSteps={totalSteps}\n            />\n          </header>\n\n          <div\n            data-testid=\"onboarding-scroll-area\"\n            className=\"min-h-0 overflow-y-auto custom-scrollbar-always px-7 pb-7\"\n          >\n            <div\n              data-testid=\"onboarding-slide-rail\"\n              data-current-step={currentStep}\n              className=\"relative overflow-clip\"\n            >\n              {skipBackendStep ? null : (\n                <Slide\n                  index={slideOrder.indexOf(\"backend\")}\n                  currentStep={currentStep}\n                >\n                  <CheckBackendStep onNext={goNext} onClose={onClose} />\n                </Slide>\n              )}\n              <Slide\n                index={slideOrder.indexOf(\"agent\")}\n                currentStep={currentStep}\n              >\n                <ChooseAgentStep\n                  selectedAgentId={selectedAgentId}\n                  onSelect={setSelectedAgentId}\n                  onBack={skipBackendStep ? undefined : goBack}\n                  onNext={goNext}\n                />\n              </Slide>\n              <Slide\n                index={slideOrder.indexOf(\"setup\")}\n                currentStep={currentStep}\n              >\n                {isOpenHands ? (\n                  <SetupLlmStep onBack={goBack} onNext={goNext} />\n                ) : (\n                  <SetupAcpSecretsStep\n                    providerKey={selectedAgentId}\n                    isActive={currentPhase === \"setup\"}\n                    onBack={goBack}\n                    onNext={goNext}\n                  />\n                )}\n              </Slide>\n              <Slide\n                index={slideOrder.indexOf(\"hello\")}\n                currentStep={currentStep}\n              >\n                <SayHelloStep\n                  onBack={goBack}\n                  onClose={handleSkipOrDismiss}\n                  onLaunched={handleCompleted}\n                />\n              </Slide>\n            </div>\n          </div>\n        </section>\n\n        {currentStep < totalSteps - 1 && !hideSkip ? (\n          <button\n            type=\"button\"\n            data-testid=\"onboarding-skip\"\n            onClick={handleSkipOrDismiss}\n            className=\"rounded-md px-3 py-2 text-sm text-muted transition-colors hover:bg-contrast/5 hover:text-contrast cursor-pointer\"\n          >\n            {t(I18nKey.ONBOARDING$SKIP)}\n          </button>\n        ) : null}\n\n        {currentPhase === \"hello\" ? (\n          <label className=\"flex cursor-pointer items-center justify-center gap-2 text-sm text-muted\">\n            <input\n              data-testid=\"onboarding-skip-getting-started-checklist\"\n              type=\"checkbox\"\n              checked={skipGettingStartedChecklist}\n              onChange={handleSkipGettingStartedChange}\n              className=\"size-4 cursor-pointer\"\n            />\n            {t(I18nKey.ONBOARDING$SKIP_GETTING_STARTED_CHECKLIST)}\n          </label>\n        ) : null}\n      </div>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/onboarding/steps/say-hello-step.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[calc(90vh-7rem)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 82,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 82,
          "endColumn": 56
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[15px]\" hardcodes an off-token value. Nearest on the scale: rounded-2xl (16px), rounded-xl (12px).",
          "line": 96,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 96,
          "endColumn": 151
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 134,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 134,
          "endColumn": 58
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[16px]\" hardcodes an off-token value. Use \"text-base\" instead (same value, on the scale).",
          "line": 116,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 116,
          "endColumn": 203,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "text-base"
              },
              "fix": {
                "range": [
                  4362,
                  4540
                ],
                "text": "\"min-h-5 w-full flex-1 bg-transparent text-base font-normal leading-5 text-contrast outline-none placeholder:text-text-tertiary disabled:cursor-not-allowed disabled:opacity-50\""
              },
              "desc": "Replace with \"text-base\" (same value, on the scale)."
            }
          ],
          "suppressions": [
            {
              "kind": "directive",
              "justification": ""
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ChatSendButton } from \"#/components/features/chat/chat-send-button\";\nimport { RecommendedAutomationsLauncher } from \"#/components/features/automations/recommended-automations-launcher\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport { useCreateConversation } from \"#/hooks/mutation/use-create-conversation\";\nimport { useIsCreatingConversation } from \"#/hooks/use-is-creating-conversation\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\ninterface SayHelloStepProps {\n  onBack: () => void;\n  /** Dismisses onboarding without launching a conversation. */\n  onClose: () => void;\n  /** Called once the conversation has been created \u2014 used by the parent\n   * modal to mark the onboarding as complete before unmounting. */\n  onLaunched: () => void;\n}\n\n/**\n * Step 3: a simple text input pre-filled with \"hello OpenHands!\" that\n * launches a brand-new conversation with no workspace and navigates\n * to it. Completing this step finishes the onboarding flow.\n */\nexport function SayHelloStep({\n  onBack,\n  onClose,\n  onLaunched,\n}: SayHelloStepProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { navigate } = useNavigation();\n  const defaultMessage = t(I18nKey.ONBOARDING$HELLO_DEFAULT_MESSAGE);\n  const [message, setMessage] = React.useState(defaultMessage);\n\n  const {\n    mutate: createConversation,\n    isPending,\n    isSuccess,\n  } = useCreateConversation();\n  const isCreatingElsewhere = useIsCreatingConversation();\n  const [launchStatus, setLaunchStatus] = React.useState<\n    \"idle\" | \"pending\" | \"error\"\n  >(\"idle\");\n  const isPendingLaunch =\n    launchStatus === \"pending\" || isPending || isCreatingElsewhere;\n  const isLaunching = isPendingLaunch || isSuccess;\n  const launchInFlightRef = React.useRef(false);\n\n  const canSubmit =\n    message.trim().length > 0 && !isLaunching && !launchInFlightRef.current;\n\n  const launchConversation = () => {\n    if (!canSubmit || launchInFlightRef.current) return;\n    launchInFlightRef.current = true;\n    setLaunchStatus(\"pending\");\n\n    // Explicitly omit `repository` and `workingDir` so the\n    // conversation starts with no workspace, per the spec.\n    createConversation(\n      { query: message.trim(), entryPoint: \"onboarding_say_hello\" },\n      {\n        onSuccess: (data) => {\n          navigate(`/conversations/${data.conversation_id}`);\n          onLaunched();\n        },\n        onError: () => {\n          launchInFlightRef.current = false;\n          setLaunchStatus(\"error\");\n        },\n      },\n    );\n  };\n\n  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    launchConversation();\n  };\n\n  return (\n    <div\n      data-testid=\"onboarding-step-say-hello\"\n      className=\"flex max-h-[calc(90vh-7rem)] flex-col\"\n    >\n      <header className=\"flex shrink-0 flex-col gap-2\">\n        <h2 className=\"text-2xl font-medium text-contrast\">\n          {t(I18nKey.ONBOARDING$HELLO_TITLE)}\n        </h2>\n        <p className=\"text-sm text-muted\">\n          {t(I18nKey.ONBOARDING$HELLO_SUBTITLE)}\n        </p>\n      </header>\n\n      <form\n        onSubmit={handleSubmit}\n        data-testid=\"onboarding-hello-input-form\"\n        className=\"mt-6 box-border flex w-full shrink-0 flex-col items-start justify-center rounded-[15px] border border-border bg-surface-raised p-4\"\n      >\n        <div className=\"relative w-full\">\n          <div className=\"box-border flex w-full shrink-0 flex-row items-end justify-between gap-2 p-0 pb-4.5\">\n            <input\n              data-testid=\"onboarding-hello-input\"\n              aria-label={t(I18nKey.ONBOARDING$HELLO_TITLE)}\n              type=\"text\"\n              value={message}\n              onChange={(event) => setMessage(event.target.value)}\n              onKeyDown={(event) => {\n                if (event.key === \"Enter\" && !event.shiftKey) {\n                  event.preventDefault();\n                  launchConversation();\n                }\n              }}\n              placeholder={defaultMessage}\n              disabled={isLaunching}\n              // Keep a fixed 16px input size; text-base scales with the host root font size.\n              // eslint-disable-next-line shadcn/no-arbitrary-values\n              className=\"min-h-5 w-full flex-1 bg-transparent text-[16px] font-normal leading-5 text-contrast outline-none placeholder:text-text-tertiary disabled:cursor-not-allowed disabled:opacity-50\"\n            />\n          </div>\n        </div>\n        <div className=\"flex w-full min-w-0 items-center justify-end gap-2\">\n          <ChatSendButton\n            buttonClassName=\"\"\n            handleSubmit={launchConversation}\n            disabled={!canSubmit}\n            isPending={isPendingLaunch}\n          />\n        </div>\n      </form>\n\n      {isPendingLaunch ? (\n        <p\n          role=\"status\"\n          aria-live=\"polite\"\n          className=\"mt-3 text-sm text-[var(--oh-muted)]\"\n        >\n          {t(I18nKey.ONBOARDING$HELLO_LAUNCHING)}\n        </p>\n      ) : null}\n      {launchStatus === \"error\" ? (\n        <div\n          role=\"alert\"\n          className=\"mt-3 flex items-center gap-2 text-sm text-danger\"\n        >\n          <span>{t(I18nKey.ERROR$GENERIC)}</span>\n          <button\n            type=\"button\"\n            className=\"underline underline-offset-2\"\n            onClick={launchConversation}\n          >\n            {t(I18nKey.LAUNCH$TRY_AGAIN)}\n          </button>\n        </div>\n      ) : null}\n\n      <div\n        data-testid=\"onboarding-hello-or-separator\"\n        className=\"mt-6 flex w-full items-center gap-3\"\n      >\n        <div className=\"h-px flex-1 bg-border\" />\n        <span className=\"text-xs uppercase text-muted\">\n          {t(I18nKey.LANDING$OR)}\n        </span>\n        <div className=\"h-px flex-1 bg-border\" />\n      </div>\n\n      <div\n        data-testid=\"onboarding-recommended-automations\"\n        className=\"flex min-h-0 flex-1 flex-col\"\n      >\n        <RecommendedAutomationsLauncher\n          onLaunched={onLaunched}\n          scrollableGrid\n        />\n      </div>\n\n      <div className=\"flex shrink-0 items-center justify-between gap-2 bg-base-secondary pt-7 pb-7\">\n        <BrandButton\n          testId=\"onboarding-hello-back\"\n          type=\"button\"\n          variant=\"secondary\"\n          onClick={onBack}\n          isDisabled={isLaunching}\n        >\n          {t(I18nKey.ONBOARDING$BACK)}\n        </BrandButton>\n        <BrandButton\n          testId=\"onboarding-hello-close\"\n          type=\"button\"\n          variant=\"secondary\"\n          onClick={onClose}\n          isDisabled={isLaunching}\n        >\n          {t(I18nKey.BUTTON$CLOSE)}\n        </BrandButton>\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/onboarding/steps/setup-llm-step.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[calc(90vh-7rem)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 344,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 344,
          "endColumn": 62
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport { useTranslation } from \"react-i18next\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { LlmSettingsScreen } from \"#/routes/llm-settings\";\nimport type { SdkSectionSaveControl } from \"#/components/features/settings/sdk-settings/sdk-section-page\";\nimport {\n  buildProfileLlmConfig,\n  profileConfigToFormValues,\n} from \"#/components/features/settings/llm-profiles/llm-profile-form\";\nimport { isNoBackend } from \"#/api/backend-registry/active-store\";\nimport AgentProfilesService, {\n  WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME,\n} from \"#/api/agent-profiles-service/agent-profiles-service.api\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useSaveLlmProfile } from \"#/hooks/mutation/use-save-llm-profile\";\nimport { useActivateLlmProfile } from \"#/hooks/mutation/use-activate-llm-profile\";\nimport { useApplyOnboardingAgentProfile } from \"#/hooks/mutation/use-apply-onboarding-agent-profile\";\nimport {\n  useDefaultModel,\n  useDefaultModelReady,\n} from \"#/hooks/query/use-free-models\";\nimport { LLM_PROFILES_QUERY_KEYS } from \"#/hooks/query/use-llm-profiles\";\nimport { useOpenAISubscriptionModels } from \"#/hooks/query/use-llm-subscription-models\";\nimport { useSettings } from \"#/hooks/query/use-settings\";\nimport { useAgentSettingsSchema } from \"#/hooks/query/use-agent-settings-schema\";\nimport { useBackendsHealth } from \"#/hooks/query/use-backends-health\";\nimport { LlmSettingsInputsSkeleton } from \"#/components/features/settings/llm-settings/llm-settings-inputs-skeleton\";\nimport { deriveProfileNameFromModel } from \"#/utils/derive-profile-name\";\nimport ProfilesService, {\n  type SaveProfileRequest,\n} from \"#/api/profiles-service/profiles-service.api\";\nimport { displayErrorToast } from \"#/utils/custom-toast-handlers\";\nimport type { SettingsFormValues } from \"#/utils/sdk-settings-schema\";\nimport {\n  LLM_AUTH_TYPE_KEY,\n  LLM_AUTH_TYPE_SUBSCRIPTION,\n  resolveLlmAuthType,\n} from \"#/constants/llm-subscription\";\n\ninterface SetupLlmStepProps {\n  onBack: () => void;\n  onNext: () => void;\n}\n\n/**\n * Fallback when the backend has not exposed a DB-selected OpenHands default.\n * A fresh profile starts with this model, and Next saves it even when the user\n * leaves it as is. An existing profile keeps its own model.\n */\nexport const ONBOARDING_DEFAULT_LLM_MODEL = \"openai/gpt-5.6-sol\";\n\ninterface ProfileSeed {\n  /** The existing LLM profile the form shows, or `null` for a fresh profile. */\n  profileName: string | null;\n  /** That profile's config (secrets encrypted), or `{}` for a fresh profile. */\n  baseConfig: Record<string, unknown>;\n  /** The form values that show that profile. */\n  initialValues: SettingsFormValues;\n}\n\n/**\n * The LLM profile onboarding edits: the active LLM profile, else the one the\n * `default` agent profile points at, else none (a fresh profile). Listing the\n * agent profiles first also runs the Agent Server's one-time backfill, which\n * turns an older install's raw LLM settings into an LLM profile named\n * `default` and points the `default` agent profile at it, without activating\n * it. The active profile comes first because a home launch runs it. The agent\n * profiles are best-effort, as in `useApplyOnboardingAgentProfile`: an older\n * backend without them still gets its active profile.\n */\nasync function loadOnboardingProfile(): Promise<{\n  profileName: string | null;\n  config: Record<string, unknown>;\n}> {\n  const agentProfiles = await AgentProfilesService.listProfiles().catch(\n    () => null,\n  );\n  const llmProfiles = await ProfilesService.listProfiles();\n  const existing = new Set(llmProfiles.profiles.map((profile) => profile.name));\n  const defaultAgentRef = agentProfiles?.profiles.find(\n    (profile) => profile.name === WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME,\n  )?.llm_profile_ref;\n  const profileName =\n    [llmProfiles.active_profile, defaultAgentRef].find(\n      (name): name is string => !!name && existing.has(name),\n    ) ?? null;\n  if (!profileName) return { profileName: null, config: {} };\n  const detail = await ProfilesService.getProfile(profileName, \"encrypted\");\n  return {\n    profileName,\n    config: (detail.config ?? {}) as Record<string, unknown>,\n  };\n}\n\n/**\n * The profile the local LLM step edits (see `loadOnboardingProfile`). Raw LLM\n * settings are never the source. The seed is taken once per backend, because\n * the embedded form reads its initial values only when it mounts. Onboarding\n * mounts this step before a public-mode user adds the backend, and often while\n * a local Agent Server is still starting, so there is no seed until a backend\n * exists and answers. A failed read is an error to retry, not a fresh profile:\n * a fresh form would drop an endpoint the Basic view hides.\n */\nfunction useOnboardingProfileSeed(isLocalBackend: boolean): {\n  seed: ProfileSeed | null;\n  isError: boolean;\n  retry: () => void;\n} {\n  const { backend, orgId } = useActiveBackend();\n  const hasBackend = isLocalBackend && !isNoBackend(backend);\n  const healthByBackendId = useBackendsHealth(hasBackend ? [backend] : []);\n  const enabled =\n    hasBackend && healthByBackendId[backend.id]?.isConnected === true;\n  const [seedState, setSeedState] = React.useState<{\n    backendId: string;\n    seed: ProfileSeed;\n  } | null>(null);\n  const seed = seedState?.backendId === backend.id ? seedState.seed : null;\n  const profile = useQuery({\n    queryKey: [\n      ...LLM_PROFILES_QUERY_KEYS.all,\n      \"onboarding-seed\",\n      backend.id,\n      orgId,\n    ],\n    queryFn: loadOnboardingProfile,\n    // Read once: the seed never changes, and saving or activating the new\n    // profile would otherwise refetch the secrets for nothing.\n    enabled: enabled && !seed,\n    // Keep the encrypted secrets out of the cache once the step unmounts.\n    gcTime: 0,\n    retry: 1,\n    meta: { disableToast: true },\n  });\n  const { data: settings } = useSettings();\n  const { data: schema, error: schemaError } = useAgentSettingsSchema(\n    settings?.agent_settings_schema,\n  );\n  const isSchemaSettled = !!schema || !!schemaError;\n\n  React.useEffect(() => {\n    if (!enabled || seed || !profile.data || !isSchemaSettled) return;\n    setSeedState({\n      backendId: backend.id,\n      seed: {\n        profileName: profile.data.profileName,\n        baseConfig: profile.data.config,\n        initialValues: profileConfigToFormValues(schema, profile.data.config),\n      },\n    });\n  }, [enabled, seed, backend.id, profile.data, isSchemaSettled, schema]);\n\n  const { refetch } = profile;\n  const retry = React.useCallback(() => {\n    void refetch();\n  }, [refetch]);\n\n  return {\n    seed,\n    isError: enabled && !seed && profile.isError && !profile.isFetching,\n    retry,\n  };\n}\n\n/**\n * Step 2: embed the LLM settings form. The screen runs in `embedded`\n * mode (so it doesn't render its own sticky Save bar) and with\n * `hideSaveButton` set, surfacing its save state via\n * `onSaveControlChange`. We then render a single Next button at the\n * modal footer level matching the other onboarding steps.\n *\n * On a local backend the step edits an LLM profile, like the profile editor\n * in Settings: the form shows the profile `loadOnboardingProfile` picks, or a\n * fresh one with the default model. If the user changes nothing, Next keeps\n * that profile; otherwise it saves the form as a profile named after the\n * model. Either way it activates the profile and points the `default` agent\n * profile at it. Activation applies the profile to `agent_settings.llm` on the\n * server, so the step never reads or writes raw LLM settings.\n *\n * On Cloud, Next saves the form to the settings and `onSaveSuccess` advances.\n * The agent-profile \u2194 LLM wiring is resolved server-side from those settings,\n * and there is no client-writable cloud agent-profile ref to repoint here. If\n * the form is untouched, Next advances without a save call.\n *\n * Note: returning Cloud users who already have an LLM configured are\n * intercepted upstream by `OnboardingHost`, so they never reach this\n * step. Users who do reach it are first-time installs (Cloud or Local)\n * who want the OpenHands default pre-filled.\n */\nexport function SetupLlmStep({ onBack, onNext }: SetupLlmStepProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { backend } = useActiveBackend();\n  const isLocalBackend = backend.kind === \"local\";\n  const saveProfile = useSaveLlmProfile();\n  const activateProfile = useActivateLlmProfile();\n  const applyAgentProfile = useApplyOnboardingAgentProfile();\n  const dbDefaultLlmModel = useDefaultModel();\n  const isDefaultModelReady = useDefaultModelReady();\n  const defaultLlmModel = dbDefaultLlmModel ?? ONBOARDING_DEFAULT_LLM_MODEL;\n  const {\n    seed: profileSeed,\n    isError: isProfileSeedError,\n    retry: retryProfileSeed,\n  } = useOnboardingProfileSeed(isLocalBackend);\n  const [saveControl, setSaveControl] =\n    React.useState<SdkSectionSaveControl | null>(null);\n  const [isFinalizing, setIsFinalizing] = React.useState(false);\n  const isSubscriptionAuth =\n    resolveLlmAuthType(saveControl?.values[LLM_AUTH_TYPE_KEY]) ===\n    LLM_AUTH_TYPE_SUBSCRIPTION;\n  // Shares the form's query, so this reads the list its picker shows.\n  const { data: subscriptionModels } = useOpenAISubscriptionModels({\n    enabled: isLocalBackend && isSubscriptionAuth,\n  });\n\n  // An existing profile keeps its own model; only a fresh one gets the\n  // onboarding default, so the form never pairs the default model with\n  // another profile's key and endpoint.\n  const initialValueOverrides = React.useMemo(() => {\n    if (profileSeed?.profileName) return profileSeed.initialValues;\n    return {\n      ...(profileSeed?.initialValues ?? {}),\n      \"llm.model\": defaultLlmModel,\n    };\n  }, [profileSeed, defaultLlmModel]);\n\n  const activateForOnboarding = React.useCallback(\n    async (profileName: string) => {\n      await activateProfile.mutateAsync(profileName);\n      // Conversations launch from the active AGENT profile, so point it at the\n      // LLM the user just set up or kept (this also clears the \"LLM not set\n      // up\" banner). Otherwise it keeps its seeded llm_profile_ref, which may\n      // have no key.\n      await applyAgentProfile({\n        agent_kind: \"openhands\",\n        llm_profile_ref: profileName,\n      });\n      onNext();\n    },\n    [activateProfile, applyAgentProfile, onNext],\n  );\n\n  const persistProfile = React.useCallback(async () => {\n    if (!saveControl || !profileSeed) return;\n\n    // Unchanged existing profile: keep it as it is rather than saving a copy\n    // under the name derived from its model.\n    if (profileSeed.profileName && !saveControl.isDirty) {\n      setIsFinalizing(true);\n      try {\n        await activateForOnboarding(profileSeed.profileName);\n      } catch {\n        displayErrorToast(t(I18nKey.ERROR$GENERIC));\n      } finally {\n        setIsFinalizing(false);\n      }\n      return;\n    }\n\n    let dirtyLlm: Record<string, unknown>;\n    try {\n      dirtyLlm = {\n        ...((saveControl.getDirtyPayload().llm ?? {}) as Record<\n          string,\n          unknown\n        >),\n      };\n    } catch (error) {\n      displayErrorToast(\n        error instanceof Error ? error.message : t(I18nKey.ERROR$GENERIC),\n      );\n      return;\n    }\n    // The form shows its model even when the user keeps it (a fresh profile's\n    // is the onboarding default), so the profile gets that model whether or\n    // not the field was edited.\n    if (!Object.prototype.hasOwnProperty.call(dirtyLlm, \"model\")) {\n      dirtyLlm.model = String(saveControl.values[\"llm.model\"] ?? \"\");\n    }\n    if (isSubscriptionAuth && !subscriptionModels?.length) {\n      displayErrorToast(t(I18nKey.SETTINGS$SUBSCRIPTION_MODELS_NOT_LOADED));\n      return;\n    }\n    const { llmConfig } = buildProfileLlmConfig({\n      baseConfig: profileSeed.baseConfig,\n      dirtyLlm,\n      values: saveControl.values,\n      view: saveControl.view,\n      supportsConnections: true,\n      isCloud: false,\n      subscriptionModels,\n    });\n    const model = typeof llmConfig.model === \"string\" ? llmConfig.model : \"\";\n    if (!model) {\n      displayErrorToast(t(I18nKey.SETTINGS$MODEL_REQUIRED));\n      return;\n    }\n    const profileName = deriveProfileNameFromModel(model);\n\n    setIsFinalizing(true);\n    try {\n      await saveProfile.mutateAsync({\n        name: profileName,\n        request: {\n          llm: llmConfig as SaveProfileRequest[\"llm\"],\n          include_secrets: true,\n        },\n      });\n      await activateForOnboarding(profileName);\n    } catch {\n      // Nothing advanced, so Next retries the whole save.\n      displayErrorToast(t(I18nKey.ERROR$GENERIC));\n    } finally {\n      setIsFinalizing(false);\n    }\n  }, [\n    saveControl,\n    profileSeed,\n    isSubscriptionAuth,\n    subscriptionModels,\n    saveProfile,\n    activateForOnboarding,\n    t,\n  ]);\n\n  const handleNext = () => {\n    if (isLocalBackend) {\n      void persistProfile();\n      return;\n    }\n    if (saveControl?.isDirty) {\n      // `onSaveSuccess` advances once the settings save resolves.\n      saveControl.save();\n      return;\n    }\n    onNext();\n  };\n\n  return (\n    <div\n      data-testid=\"onboarding-step-setup-llm\"\n      className=\"flex flex-col gap-6 max-h-[calc(90vh-7rem)]\"\n    >\n      <header className=\"flex flex-col gap-2\">\n        <h2 className=\"text-2xl font-medium text-contrast\">\n          {t(I18nKey.ONBOARDING$LLM_TITLE)}\n        </h2>\n        <p className=\"text-sm text-muted\">\n          {t(\n            profileSeed?.profileName\n              ? I18nKey.ONBOARDING$LLM_SUBTITLE_CURRENT_PROFILE\n              : I18nKey.ONBOARDING$LLM_SUBTITLE,\n          )}\n        </p>\n      </header>\n\n      <div\n        data-testid=\"onboarding-llm-settings\"\n        className=\"flex min-h-0 flex-1 flex-col overflow-y-auto custom-scrollbar-always\"\n      >\n        {isProfileSeedError ? (\n          <div\n            data-testid=\"onboarding-llm-load-error\"\n            className=\"flex flex-col items-start gap-3\"\n          >\n            <p className=\"text-sm text-muted\">\n              {t(I18nKey.ERROR$FAILED_TO_LOAD_PROFILE_TRY_AGAIN)}\n            </p>\n            <BrandButton\n              testId=\"onboarding-llm-load-retry\"\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={retryProfileSeed}\n            >\n              {t(I18nKey.AUTOMATIONS$ERROR_RETRY)}\n            </BrandButton>\n          </div>\n        ) : isDefaultModelReady && (!isLocalBackend || profileSeed) ? (\n          <LlmSettingsScreen\n            embedded\n            hideSaveButton\n            suppressSuccessToast\n            initialValueOverrides={initialValueOverrides}\n            // Local edits a profile, so like the profile editor only real\n            // changes are dirty. Cloud saves the model override to settings,\n            // so the override starts dirty there.\n            markInitialOverridesDirty={!isLocalBackend}\n            onSaveSuccess={isLocalBackend ? undefined : onNext}\n            onSaveControlChange={setSaveControl}\n          />\n        ) : (\n          <LlmSettingsInputsSkeleton />\n        )}\n      </div>\n\n      <div className=\"sticky bottom-0 flex items-center justify-between gap-2 bg-base-secondary pt-4 pb-7\">\n        <BrandButton\n          testId=\"onboarding-llm-back\"\n          type=\"button\"\n          variant=\"secondary\"\n          onClick={onBack}\n        >\n          {t(I18nKey.ONBOARDING$BACK)}\n        </BrandButton>\n        <BrandButton\n          testId=\"onboarding-llm-next\"\n          type=\"button\"\n          variant=\"primary\"\n          isDisabled={\n            !isDefaultModelReady ||\n            (isLocalBackend && (!profileSeed || !saveControl)) ||\n            (saveControl?.isSaving ?? false) ||\n            isFinalizing\n          }\n          onClick={handleNext}\n        >\n          {t(I18nKey.ONBOARDING$NEXT)}\n        </BrandButton>\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/plugins/add-plugin-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[90vw]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 55,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 55,
          "endColumn": 125
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[85vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 55,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 55,
          "endColumn": 125
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { SettingsInput } from \"#/components/features/settings/settings-input\";\nimport { useInstallPlugin } from \"#/hooks/mutation/use-install-plugin\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { modalTitleLgClassName } from \"#/utils/modal-classes\";\n\ninterface AddPluginModalProps {\n  onClose: () => void;\n  onInstalled?: () => void;\n}\n\nexport function AddPluginModal({ onClose, onInstalled }: AddPluginModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const installPlugin = useInstallPlugin();\n\n  const [source, setSource] = React.useState(\"\");\n  const [ref, setRef] = React.useState(\"\");\n  const [repoPath, setRepoPath] = React.useState(\"\");\n\n  const trimmedSource = source.trim();\n  const canSubmit = trimmedSource.length > 0 && !installPlugin.isPending;\n\n  const handleSubmit = (event: React.FormEvent) => {\n    event.preventDefault();\n    if (!canSubmit) return;\n\n    installPlugin.mutate(\n      {\n        source: trimmedSource,\n        ref: ref.trim() || null,\n        repo_path: repoPath.trim() || null,\n      },\n      {\n        onSuccess: () => {\n          onInstalled?.();\n          onClose();\n        },\n      },\n    );\n  };\n\n  return (\n    <ModalBackdrop\n      onClose={onClose}\n      aria-label={t(I18nKey.SETTINGS$PLUGINS_ADD_MODAL_TITLE)}\n    >\n      <form\n        onSubmit={handleSubmit}\n        data-testid=\"add-plugin-modal\"\n        className=\"relative flex w-130 max-w-[90vw] max-h-[85vh] flex-col rounded-xl border border-border bg-base-secondary\"\n      >\n        <ModalCloseButton onClose={onClose} testId=\"add-plugin-modal-close\" />\n\n        <header className=\"flex-shrink-0 px-6 pb-4 pt-6\">\n          <h2 className={cn(\"pr-6\", modalTitleLgClassName)}>\n            {t(I18nKey.SETTINGS$PLUGINS_ADD_MODAL_TITLE)}\n          </h2>\n          <p className=\"mt-4 text-sm text-tertiary-light\">\n            {t(I18nKey.SETTINGS$PLUGINS_ADD_MODAL_INTRO)}\n          </p>\n        </header>\n\n        <div className=\"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 custom-scrollbar\">\n          <SettingsInput\n            testId=\"add-plugin-source-input\"\n            label={t(I18nKey.SETTINGS$PLUGINS_SOURCE_LABEL)}\n            type=\"text\"\n            value={source}\n            onChange={setSource}\n            placeholder={t(I18nKey.SETTINGS$PLUGINS_SOURCE_PLACEHOLDER)}\n            showRequiredTag\n          />\n          <SettingsInput\n            testId=\"add-plugin-ref-input\"\n            label={t(I18nKey.SETTINGS$PLUGINS_REF_LABEL)}\n            type=\"text\"\n            value={ref}\n            onChange={setRef}\n            showOptionalTag\n          />\n          <SettingsInput\n            testId=\"add-plugin-repo-path-input\"\n            label={t(I18nKey.SETTINGS$PLUGINS_REPO_PATH_LABEL)}\n            type=\"text\"\n            value={repoPath}\n            onChange={setRepoPath}\n            showOptionalTag\n          />\n        </div>\n\n        <footer className=\"flex flex-shrink-0 justify-end gap-2 px-6 pb-6 pt-4\">\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={onClose}\n            testId=\"add-plugin-modal-dismiss\"\n          >\n            {t(I18nKey.BUTTON$CLOSE)}\n          </BrandButton>\n          <BrandButton\n            type=\"submit\"\n            variant=\"primary\"\n            testId=\"add-plugin-submit\"\n            isDisabled={!canSubmit}\n          >\n            {t(\n              installPlugin.isPending\n                ? I18nKey.SETTINGS$PLUGINS_INSTALLING\n                : I18nKey.SETTINGS$PLUGINS_INSTALL,\n            )}\n          </BrandButton>\n        </footer>\n      </form>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/plugins/plugin-detail-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[90vw]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 45,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 45,
          "endColumn": 125
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[85vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 45,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 45,
          "endColumn": 125
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { SettingsSwitch } from \"#/components/features/settings/settings-switch\";\nimport { SkillIconBadge } from \"#/components/features/skills/skill-icon-badge\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { modalTitleLgClassName } from \"#/utils/modal-classes\";\nimport { extensionModuleCardPillClassName } from \"#/utils/extension-module-card-classes\";\nimport type { PluginViewModel } from \"./build-plugins-view-model\";\nimport { PluginFilesSection } from \"./plugin-files-section\";\n\ninterface PluginDetailModalProps {\n  plugin: PluginViewModel;\n  isBusy?: boolean;\n  isDisabled?: boolean;\n  onToggle: (enabled: boolean) => void;\n  onInstall: () => void;\n  onUninstall: () => void;\n  onRefresh: () => void;\n  onClose: () => void;\n  onStartConversation?: () => void;\n}\n\nexport function PluginDetailModal({\n  plugin,\n  isBusy = false,\n  isDisabled = false,\n  onToggle,\n  onInstall,\n  onUninstall,\n  onRefresh,\n  onClose,\n  onStartConversation,\n}: PluginDetailModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const actionsDisabled = isDisabled || isBusy;\n\n  return (\n    <ModalBackdrop onClose={onClose} aria-label={plugin.name}>\n      <div\n        data-testid=\"plugin-detail-modal\"\n        data-plugin-name={plugin.name}\n        className=\"relative flex w-160 max-w-[90vw] max-h-[85vh] flex-col rounded-xl border border-border bg-base-secondary\"\n      >\n        <ModalCloseButton\n          onClose={onClose}\n          testId=\"plugin-detail-modal-close\"\n        />\n\n        <header className=\"flex-shrink-0 px-6 pb-4 pt-6\">\n          <h2 className={cn(\"pr-6\", modalTitleLgClassName)}>{plugin.name}</h2>\n          {plugin.source ? (\n            <p className=\"mt-1 break-all text-xs text-tertiary-alt\">\n              {plugin.source}\n            </p>\n          ) : null}\n        </header>\n\n        <div className=\"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 custom-scrollbar\">\n          {plugin.description ? (\n            <p className=\"text-sm leading-relaxed text-tertiary-light\">\n              {plugin.description}\n            </p>\n          ) : null}\n\n          <div className=\"flex flex-wrap gap-2\">\n            {plugin.isLocal ? (\n              <span className={extensionModuleCardPillClassName}>\n                {t(I18nKey.SETTINGS$PLUGINS_FILTER_LOCAL)}\n              </span>\n            ) : null}\n            {plugin.version ? (\n              <span className={extensionModuleCardPillClassName}>\n                {t(I18nKey.SETTINGS$SKILLS_VERSION, {\n                  version: plugin.version,\n                })}\n              </span>\n            ) : null}\n            {plugin.ref ? (\n              <span className={extensionModuleCardPillClassName}>\n                {t(I18nKey.SETTINGS$PLUGINS_REF_LABEL)} {plugin.ref}\n              </span>\n            ) : null}\n            {plugin.repoPath ? (\n              <span className={extensionModuleCardPillClassName}>\n                {t(I18nKey.SETTINGS$PLUGINS_REPO_PATH_LABEL)} {plugin.repoPath}\n              </span>\n            ) : null}\n          </div>\n\n          {plugin.installed ? (\n            <SettingsSwitch\n              testId={`plugin-modal-toggle-${plugin.name}`}\n              isToggled={plugin.enabled}\n              onToggle={onToggle}\n              isDisabled={actionsDisabled}\n              togglePosition=\"right\"\n            >\n              {t(\n                plugin.enabled\n                  ? I18nKey.SETTINGS$SKILLS_ENABLED\n                  : I18nKey.SETTINGS$SKILLS_DISABLED,\n              )}\n            </SettingsSwitch>\n          ) : null}\n\n          {plugin.skills?.length ? (\n            <section className=\"flex min-w-0 flex-col gap-2\">\n              <h3 className=\"text-sm font-medium text-contrast\">\n                {t(I18nKey.SETTINGS$PLUGINS_SKILLS_IN_BUNDLE)}\n              </h3>\n              <ul className=\"flex min-w-0 flex-col gap-2\">\n                {plugin.skills.map((skill) => (\n                  <li\n                    key={skill.name}\n                    data-testid={`plugin-bundled-skill-${skill.name}`}\n                    className=\"flex items-start gap-3 rounded-lg border border-border bg-contrast/[0.04] px-3 py-2.5\"\n                  >\n                    <SkillIconBadge skillName={skill.name} />\n                    <div className=\"min-w-0 flex-1\">\n                      <p className=\"truncate text-sm font-semibold text-contrast\">\n                        {skill.name}\n                      </p>\n                      {skill.description ? (\n                        <p className=\"mt-0.5 break-words text-xs leading-relaxed text-tertiary-light\">\n                          {skill.description}\n                        </p>\n                      ) : null}\n                    </div>\n                  </li>\n                ))}\n              </ul>\n            </section>\n          ) : null}\n\n          {plugin.path && plugin.files?.length ? (\n            <PluginFilesSection basePath={plugin.path} files={plugin.files} />\n          ) : null}\n        </div>\n\n        <footer className=\"flex flex-shrink-0 flex-wrap justify-end gap-2 px-6 pb-6 pt-4\">\n          {plugin.installed ? (\n            <>\n              <BrandButton\n                type=\"button\"\n                variant=\"secondary\"\n                testId={`plugin-detail-refresh-${plugin.name}`}\n                isDisabled={actionsDisabled}\n                onClick={onRefresh}\n              >\n                {t(I18nKey.SETTINGS$PLUGINS_REFRESH)}\n              </BrandButton>\n              <BrandButton\n                type=\"button\"\n                variant=\"secondary\"\n                testId={`plugin-detail-uninstall-${plugin.name}`}\n                isDisabled={actionsDisabled}\n                onClick={onUninstall}\n              >\n                {t(I18nKey.SETTINGS$PLUGINS_UNINSTALL)}\n              </BrandButton>\n            </>\n          ) : plugin.isLocal ? null : (\n            <BrandButton\n              type=\"button\"\n              variant=\"secondary\"\n              testId={`plugin-detail-install-${plugin.name}`}\n              isDisabled={actionsDisabled}\n              onClick={onInstall}\n            >\n              {t(\n                isBusy\n                  ? I18nKey.SETTINGS$PLUGINS_INSTALLING\n                  : I18nKey.SETTINGS$PLUGINS_INSTALL,\n              )}\n            </BrandButton>\n          )}\n          {plugin.source && onStartConversation ? (\n            <BrandButton\n              type=\"button\"\n              variant=\"secondary\"\n              testId={`plugin-detail-start-conversation-${plugin.name}`}\n              onClick={onStartConversation}\n            >\n              {t(I18nKey.COMMON$START_CONVERSATION)}\n            </BrandButton>\n          ) : null}\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            testId=\"plugin-detail-modal-dismiss\"\n            onClick={onClose}\n          >\n            {t(I18nKey.BUTTON$CLOSE)}\n          </BrandButton>\n        </footer>\n      </div>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/plugins/plugin-picker-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[80vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 34,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 34,
          "endColumn": 108
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport type { PluginSpec } from \"#/api/conversation-service/agent-server-conversation-service.types\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  modalWidthClassName,\n} from \"#/components/shared/modals/modal-body\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { Typography } from \"#/ui/typography\";\nimport { cn } from \"#/utils/utils\";\nimport { PluginPicker } from \"./plugin-picker\";\n\ninterface PluginPickerModalProps {\n  selected: PluginSpec[];\n  onChange: (next: PluginSpec[]) => void;\n  onClose: () => void;\n}\n\n/** Modal shell around the reusable {@link PluginPicker} for the launcher flow. */\nexport function PluginPickerModal({\n  selected,\n  onChange,\n  onClose,\n}: PluginPickerModalProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <ModalBackdrop onClose={onClose}>\n      <div\n        data-testid=\"plugin-picker-modal\"\n        className={cn(\n          \"relative flex max-h-[80vh] flex-col gap-4 rounded-xl border border-border bg-base-secondary p-6\",\n          modalWidthClassName(\"lg\"),\n          MODAL_MAX_WIDTH_VIEWPORT,\n        )}\n      >\n        <ModalCloseButton onClose={onClose} testId=\"plugin-picker-close\" />\n        <div className=\"pr-6\">\n          <Typography.H2>{t(I18nKey.PLUGINS$PICKER_TITLE)}</Typography.H2>\n          <Typography.Text className=\"mt-1 block text-sm text-tertiary-light\">\n            {t(I18nKey.PLUGINS$PICKER_SUBTITLE)}\n          </Typography.Text>\n        </div>\n\n        <div className=\"flex-1 overflow-y-auto custom-scrollbar\">\n          <PluginPicker selected={selected} onChange={onChange} />\n        </div>\n\n        <div className=\"flex items-center justify-between border-t border-border-subtle pt-4\">\n          <Typography.Text className=\"text-sm text-tertiary-light\">\n            {t(I18nKey.PLUGINS$PICKER_SELECTED_COUNT, {\n              count: selected.length,\n            })}\n          </Typography.Text>\n          <BrandButton\n            testId=\"plugin-picker-done\"\n            type=\"button\"\n            variant=\"primary\"\n            onClick={onClose}\n            className=\"px-4\"\n          >\n            {t(I18nKey.PLUGINS$PICKER_DONE)}\n          </BrandButton>\n        </div>\n      </div>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/plugins/plugin-picker-trigger.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 51,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 51,
          "endColumn": 168
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport PuzzleIcon from \"#/icons/u-puzzle-piece.svg?react\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  formControlBorderClassName,\n  formControlSurfaceClassName,\n  formControlTransitionClassName,\n} from \"#/utils/form-control-classes\";\n\ninterface PluginPickerTriggerProps {\n  /** Number of currently-attached plugins, shown as a badge when > 0. */\n  count: number;\n  onClick: () => void;\n  disabled?: boolean;\n}\n\n/** Pill button that opens the plugin picker; mirrors `OpenLauncherButton`. */\nexport function PluginPickerTrigger({\n  count,\n  onClick,\n  disabled = false,\n}: PluginPickerTriggerProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <button\n      type=\"button\"\n      data-testid=\"open-plugin-picker\"\n      onClick={onClick}\n      disabled={disabled}\n      className={cn(\n        \"flex flex-row items-center gap-2 rounded-full px-2.5 py-1 text-contrast\",\n        formControlBorderClassName,\n        formControlSurfaceClassName,\n        formControlTransitionClassName,\n        disabled\n          ? \"cursor-not-allowed opacity-50\"\n          : \"cursor-pointer hover:bg-surface-raised\",\n      )}\n    >\n      <span className=\"flex h-4 w-4 shrink-0 items-center justify-center\">\n        <PuzzleIcon width={16} height={16} className=\"shrink-0\" aria-hidden />\n      </span>\n      <span className=\"text-sm font-normal leading-5\">\n        {t(I18nKey.PLUGINS$PICKER_TRIGGER)}\n      </span>\n      {count > 0 ? (\n        <span\n          data-testid=\"plugin-picker-count\"\n          className=\"inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-contrast px-1 text-[11px] font-semibold leading-none text-contrast-foreground\"\n        >\n          {count}\n        </span>\n      ) : null}\n    </button>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/acp-credentials-section.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#717888]\" hardcodes a color. Nearest theme tokens: text-cool-grey-600, text-cool-grey-500. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 49,
          "column": 36,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 49,
          "endColumn": 60,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-600"
              },
              "fix": {
                "range": [
                  1977,
                  2001
                ],
                "text": "\"text-xs text-cool-grey-600\""
              },
              "desc": "Replace with \"text-cool-grey-600\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-500"
              },
              "fix": {
                "range": [
                  1977,
                  2001
                ],
                "text": "\"text-xs text-cool-grey-500\""
              },
              "desc": "Replace with \"text-cool-grey-500\"."
            }
          ]
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { AcpConflictWarnings } from \"#/components/features/settings/acp-conflict-warnings\";\nimport { AcpAuthStatusBanner } from \"#/components/features/settings/acp-auth-status-banner\";\nimport { AcpSecretField } from \"#/components/features/settings/acp-secret-field\";\nimport { Typography } from \"#/ui/typography\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useAcpAuthStatus } from \"#/hooks/query/use-acp-auth-status\";\nimport { getAcpProviderDisplayName } from \"#/constants/acp-providers\";\nimport type { AcpCredentialForm } from \"#/hooks/use-acp-credential-form\";\n\n/**\n * Settings \u2192 Agent credentials section for a built-in ACP provider: renders the\n * same fields the onboarding step collects (and the same \"already signed in\"\n * auth banner), so credentials can be added or rotated after onboarding. The\n * form state and the save are owned by the parent (Settings \u2192 Agent) so the\n * page has a single Save button for both agent settings and credentials.\n * Renders nothing for providers without credential fields.\n */\nexport function AcpCredentialsSection({\n  form,\n  providerKey,\n  loginRejected = false,\n}: {\n  form: AcpCredentialForm;\n  providerKey: string;\n  /** The agent itself refused its login, whatever the local check found. */\n  loginRejected?: boolean;\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const {\n    fields,\n    values,\n    setValue,\n    secretExists,\n    conflicts,\n    credentialsConfigured,\n  } = form;\n  const { status: authStatus, isChecking } = useAcpAuthStatus(providerKey);\n  const providerName = getAcpProviderDisplayName(providerKey) ?? providerKey;\n\n  if (fields.length === 0) return null;\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <div className=\"flex flex-col gap-1\">\n        <Typography.Text className=\"text-sm font-medium text-contrast\">\n          {t(I18nKey.SETTINGS$ACP_CREDENTIALS_TITLE)}\n        </Typography.Text>\n        <Typography.Text className=\"text-xs text-[#717888]\">\n          {t(I18nKey.SETTINGS$ACP_CREDENTIALS_DESCRIPTION)}\n        </Typography.Text>\n      </div>\n\n      <AcpAuthStatusBanner\n        status={loginRejected ? \"unauthenticated\" : authStatus}\n        isChecking={isChecking}\n        credentialsConfigured={credentialsConfigured}\n        providerName={providerName}\n        testIdPrefix=\"settings-acp-auth\"\n      />\n\n      <div className=\"flex flex-col gap-5\">\n        {fields.map((field) => (\n          <AcpSecretField\n            key={field.name}\n            field={field}\n            value={values[field.name] ?? \"\"}\n            onChange={(value) => setValue(field.name, value)}\n            alreadySet={secretExists(field.name)}\n            testId={`settings-acp-secret-${field.name}`}\n            showOptionalTag\n          />\n        ))}\n      </div>\n\n      <AcpConflictWarnings conflicts={conflicts} />\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/agent-canvas-update-card.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[80vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 170,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 170,
          "endColumn": 95
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[#3B82F6]/30\" hardcodes a color and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css.",
          "line": 214,
          "column": 30,
          "nodeType": "Literal",
          "messageId": "arbitraryColorFar",
          "endLine": 214,
          "endColumn": 138
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[#1E3A5F]\" hardcodes a color. Nearest theme tokens: bg-border-subtle, bg-cool-grey-900. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 214,
          "column": 30,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 214,
          "endColumn": 138,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-border-subtle"
              },
              "fix": {
                "range": [
                  6881,
                  6989
                ],
                "text": "\"flex items-start gap-2 rounded-lg border border-[#3B82F6]/30 bg-border-subtle px-3 py-2 text-xs text-[#3B82F6]\""
              },
              "desc": "Replace with \"bg-border-subtle\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-cool-grey-900"
              },
              "fix": {
                "range": [
                  6881,
                  6989
                ],
                "text": "\"flex items-start gap-2 rounded-lg border border-[#3B82F6]/30 bg-cool-grey-900 px-3 py-2 text-xs text-[#3B82F6]\""
              },
              "desc": "Replace with \"bg-cool-grey-900\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#3B82F6]\" hardcodes a color and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css.",
          "line": 214,
          "column": 30,
          "nodeType": "Literal",
          "messageId": "arbitraryColorFar",
          "endLine": 214,
          "endColumn": 138
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"hover:text-[var(--oh-text)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 225,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 225,
          "endColumn": 137
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"hover:text-[var(--oh-text)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 242,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 242,
          "endColumn": 137
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 325,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 325,
          "endColumn": 97
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[#1E3A5F]\" hardcodes a color. Nearest theme tokens: bg-border-subtle, bg-cool-grey-900. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 327,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 327,
          "endColumn": 105,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-border-subtle"
              },
              "fix": {
                "range": [
                  11715,
                  11797
                ],
                "text": "\"rounded-full border border-transparent bg-border-subtle px-1.5 py-0.5 text-[#3B82F6]\""
              },
              "desc": "Replace with \"bg-border-subtle\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-cool-grey-900"
              },
              "fix": {
                "range": [
                  11715,
                  11797
                ],
                "text": "\"rounded-full border border-transparent bg-cool-grey-900 px-1.5 py-0.5 text-[#3B82F6]\""
              },
              "desc": "Replace with \"bg-cool-grey-900\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#3B82F6]\" hardcodes a color and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css.",
          "line": 327,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryColorFar",
          "endLine": 327,
          "endColumn": 105
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 9,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  ArrowUpCircle,\n  Check,\n  CircleCheck,\n  Copy,\n  ExternalLink,\n  RefreshCw,\n} from \"lucide-react\";\nimport {\n  AGENT_CANVAS_RELEASE_NOTES_URL,\n  AGENT_CANVAS_UPDATE_COMMANDS,\n} from \"#/api/agent-canvas-updates\";\nimport { compareAgentServerVersions } from \"#/api/agent-server-compatibility\";\nimport { getLockedCloudHost } from \"#/api/agent-server-config\";\nimport { AGENT_CANVAS_CLIENT_VERSION } from \"#/api/client-source\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { BaseModalTitle } from \"#/components/shared/modals/confirmation-modals/base-modal\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  ModalBody,\n} from \"#/components/shared/modals/modal-body\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { useLatestAgentCanvasVersion } from \"#/hooks/query/use-latest-agent-canvas-version\";\nimport DockerIcon from \"#/icons/docker.svg?react\";\nimport NpmIcon from \"#/icons/npm.svg?react\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\n\nconst COPY_FEEDBACK_MS = 2000;\n\ntype UpdateCommandTab = \"npm\" | \"docker\";\n\nconst UPDATE_COMMAND_TABS: UpdateCommandTab[] = [\"npm\", \"docker\"];\n\nfunction getUpdateCommandTabLabelKey(tab: UpdateCommandTab): I18nKey {\n  return tab === \"npm\"\n    ? I18nKey.SETTINGS$VERSION_NPM_RECOMMENDED\n    : I18nKey.SETTINGS$VERSION_DOCKER;\n}\n\nfunction UpdateCommandTabIcon({ tab }: { tab: UpdateCommandTab }) {\n  if (tab === \"npm\") {\n    return <NpmIcon className=\"size-4 shrink-0\" aria-hidden />;\n  }\n  return <DockerIcon className=\"size-5 shrink-0\" aria-hidden />;\n}\n\nfunction UpdateCommandTabs() {\n  const { t } = useTranslation(\"openhands\");\n  const [selectedTab, setSelectedTab] = useState<UpdateCommandTab>(\"npm\");\n  const [copied, setCopied] = useState(false);\n  const copiedTimeoutRef = useRef<number | null>(null);\n  const command =\n    selectedTab === \"npm\"\n      ? AGENT_CANVAS_UPDATE_COMMANDS.npm\n      : AGENT_CANVAS_UPDATE_COMMANDS.docker;\n\n  useEffect(\n    () => () => {\n      if (copiedTimeoutRef.current !== null) {\n        window.clearTimeout(copiedTimeoutRef.current);\n      }\n    },\n    [],\n  );\n\n  useEffect(() => {\n    setCopied(false);\n    if (copiedTimeoutRef.current !== null) {\n      window.clearTimeout(copiedTimeoutRef.current);\n      copiedTimeoutRef.current = null;\n    }\n  }, [selectedTab]);\n\n  const copyCommand = useCallback(() => {\n    void navigator.clipboard?.writeText(command);\n    setCopied(true);\n    if (copiedTimeoutRef.current !== null) {\n      window.clearTimeout(copiedTimeoutRef.current);\n    }\n    copiedTimeoutRef.current = window.setTimeout(() => {\n      setCopied(false);\n      copiedTimeoutRef.current = null;\n    }, COPY_FEEDBACK_MS);\n  }, [command]);\n\n  return (\n    <div className=\"w-full\">\n      <div className=\"flex justify-center\">\n        {UPDATE_COMMAND_TABS.map((tab) => (\n          <button\n            key={tab}\n            type=\"button\"\n            onClick={() => setSelectedTab(tab)}\n            className={cn(\n              \"inline-flex items-center gap-1.5 px-3 pb-2 text-sm font-medium\",\n              selectedTab === tab\n                ? \"border-b border-contrast text-contrast\"\n                : \"text-muted hover:text-contrast\",\n            )}\n          >\n            <UpdateCommandTabIcon tab={tab} />\n            {t(getUpdateCommandTabLabelKey(tab))}\n          </button>\n        ))}\n      </div>\n      <div className=\"flex items-center gap-3 rounded-lg border border-border bg-surface-deep px-4 py-3\">\n        <code\n          data-testid={\n            selectedTab === \"npm\"\n              ? \"agent-canvas-update-command-npm\"\n              : \"agent-canvas-update-command-docker\"\n          }\n          className=\"min-w-0 flex-1 overflow-visible whitespace-nowrap font-mono text-sm text-contrast\"\n        >\n          {command}\n        </code>\n        <button\n          type=\"button\"\n          data-testid=\"copy-to-clipboard\"\n          onClick={copyCommand}\n          aria-label={t(\n            copied\n              ? I18nKey.BUTTON$COPIED\n              : I18nKey.SETTINGS$VERSION_COPY_COMMAND,\n          )}\n          disabled={copied}\n          className=\"shrink-0 text-muted hover:text-contrast disabled:hover:text-muted\"\n        >\n          {copied ? (\n            <Check className=\"size-4 text-status-success\" aria-hidden />\n          ) : (\n            <Copy className=\"size-4\" aria-hidden />\n          )}\n        </button>\n      </div>\n    </div>\n  );\n}\n\ninterface AgentCanvasUpdateModalProps {\n  onClose: () => void;\n  latestVersion: string | undefined;\n  isPending: boolean;\n  isFetching: boolean;\n  updateAvailable: boolean;\n  upToDate: boolean;\n  onCheckForUpdates: () => void;\n}\n\nfunction AgentCanvasUpdateModal({\n  onClose,\n  latestVersion,\n  isPending,\n  isFetching,\n  updateAvailable,\n  upToDate,\n  onCheckForUpdates,\n}: AgentCanvasUpdateModalProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <ModalBackdrop onClose={onClose}>\n      <ModalBody\n        width=\"md\"\n        className={cn(\n          \"relative flex max-h-[80vh] flex-col items-start overflow-auto border border-border\",\n          MODAL_MAX_WIDTH_VIEWPORT,\n        )}\n        testID=\"agent-canvas-update-modal\"\n      >\n        <ModalCloseButton\n          onClose={onClose}\n          testId=\"close-agent-canvas-update-modal\"\n        />\n        <div className=\"flex w-full flex-col gap-2 pr-10\">\n          <BaseModalTitle title={t(I18nKey.SETTINGS$APP_UPDATE_CARD_TITLE)} />\n          <div className=\"flex items-center gap-1.5 text-xs\">\n            <span className=\"text-text-dim\">\n              {t(I18nKey.SETTINGS$APP_UPDATE_VERSION_LABEL)}\n            </span>\n            <span className=\"text-contrast\">{AGENT_CANVAS_CLIENT_VERSION}</span>\n          </div>\n        </div>\n\n        <div className=\"flex w-full flex-col gap-3\">\n          <BrandButton\n            testId=\"agent-canvas-update-check-button\"\n            type=\"button\"\n            variant=\"secondary\"\n            className=\"w-full\"\n            isDisabled={isFetching}\n            aria-busy={isFetching}\n            onClick={onCheckForUpdates}\n            startContent={\n              <RefreshCw\n                className={cn(\"size-4\", isFetching && \"animate-spin\")}\n                aria-hidden\n              />\n            }\n          >\n            {t(I18nKey.SETTINGS$APP_UPDATE_CHECK_BUTTON)}\n          </BrandButton>\n\n          <div data-testid=\"agent-canvas-update-status\" className=\"w-full\">\n            {isPending || isFetching ? (\n              <span className=\"text-xs text-muted\">\n                {t(I18nKey.SETTINGS$APP_UPDATE_CHECKING)}\n              </span>\n            ) : updateAvailable ? (\n              <div className=\"flex items-start gap-2 rounded-lg border border-[#3B82F6]/30 bg-[#1E3A5F] px-3 py-2 text-xs text-[#3B82F6]\">\n                <ArrowUpCircle className=\"size-4 shrink-0\" aria-hidden />\n                <span>\n                  {t(I18nKey.SETTINGS$APP_UPDATE_AVAILABLE_MESSAGE, {\n                    version: latestVersion,\n                  })}{\" \"}\n                  <a\n                    data-testid=\"agent-canvas-update-release-notes\"\n                    href={AGENT_CANVAS_RELEASE_NOTES_URL}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"inline-flex items-center gap-1.5 whitespace-nowrap font-medium text-contrast hover:text-[var(--oh-text)]\"\n                  >\n                    {t(I18nKey.SETTINGS$VERSION_RELEASE_NOTES)}\n                    <ExternalLink className=\"size-3.5 shrink-0\" aria-hidden />\n                  </a>\n                </span>\n              </div>\n            ) : upToDate ? (\n              <div className=\"flex items-start gap-2 rounded-lg border border-status-success/30 bg-status-success/10 px-3 py-2 text-xs text-status-success\">\n                <CircleCheck className=\"size-4 shrink-0\" aria-hidden />\n                <span>\n                  {t(I18nKey.SETTINGS$APP_UPDATE_LATEST_MESSAGE)}{\" \"}\n                  <a\n                    data-testid=\"agent-canvas-update-release-notes\"\n                    href={AGENT_CANVAS_RELEASE_NOTES_URL}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"inline-flex items-center gap-1.5 whitespace-nowrap font-medium text-contrast hover:text-[var(--oh-text)]\"\n                  >\n                    {t(I18nKey.SETTINGS$VERSION_RELEASE_NOTES)}\n                    <ExternalLink className=\"size-3.5 shrink-0\" aria-hidden />\n                  </a>\n                </span>\n              </div>\n            ) : (\n              <span className=\"text-xs text-muted\">\n                {t(I18nKey.SETTINGS$APP_UPDATE_CHECK_FAILED)}\n              </span>\n            )}\n          </div>\n\n          {updateAvailable ? (\n            <div className=\"flex w-full flex-col border-t border-border pt-3\">\n              <span className=\"text-sm font-medium text-contrast\">\n                {t(I18nKey.SETTINGS$APP_UPDATE_HOW_TO_UPDATE)}\n              </span>\n              <span className=\"mt-1 mb-4 text-sm text-text-dim\">\n                {t(I18nKey.SETTINGS$APP_UPDATE_RUN_COMMANDS)}\n              </span>\n              <UpdateCommandTabs />\n            </div>\n          ) : null}\n        </div>\n      </ModalBody>\n    </ModalBackdrop>\n  );\n}\n\ninterface AgentCanvasUpdateCardProps {\n  /** When true, omit the card unless an update is available (main sidebar). */\n  hideWhenUpToDate?: boolean;\n}\n\n/**\n * Information-only version/update card for settings and the main sidebar.\n * Opens update details in a modal. Never blocks or toasts; check failures\n * degrade to a quiet inline message. Hidden in locked-to-Cloud deployments \u2014\n * the hosted canvas has no npm/docker install to update.\n */\nexport function AgentCanvasUpdateCard({\n  hideWhenUpToDate = false,\n}: AgentCanvasUpdateCardProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [isModalOpen, setIsModalOpen] = useState(false);\n  const isLockedToCloud = getLockedCloudHost() !== null;\n  const latestVersionQuery = useLatestAgentCanvasVersion({\n    enabled: !isLockedToCloud,\n  });\n\n  if (isLockedToCloud) return null;\n\n  const latestVersion = latestVersionQuery.data;\n  const comparison =\n    latestVersion !== undefined\n      ? compareAgentServerVersions(latestVersion, AGENT_CANVAS_CLIENT_VERSION)\n      : null;\n  const updateAvailable = comparison === 1;\n  // 0 or -1: running the npm latest or newer (local dev build).\n  const upToDate = comparison !== null && comparison <= 0;\n\n  if (hideWhenUpToDate && !updateAvailable) return null;\n\n  return (\n    <>\n      <section data-testid=\"agent-canvas-update-card\">\n        <button\n          type=\"button\"\n          onClick={() => setIsModalOpen(true)}\n          aria-haspopup=\"dialog\"\n          data-testid=\"agent-canvas-update-toggle\"\n          className=\"flex w-full cursor-pointer flex-col gap-1 rounded-md border border-border bg-base-secondary px-3 py-2 text-left hover:bg-surface-raised\"\n        >\n          <span className=\"flex w-full items-center gap-2\">\n            <span className=\"flex-1 truncate text-sm font-semibold leading-5 text-contrast\">\n              {t(I18nKey.SETTINGS$APP_UPDATE_CARD_TITLE)}\n            </span>\n            {comparison !== null && (\n              <span\n                data-testid=\"agent-canvas-update-badge\"\n                className={cn(\n                  \"inline-flex shrink-0 items-center gap-1 text-[10px] font-medium leading-none\",\n                  updateAvailable\n                    ? \"rounded-full border border-transparent bg-[#1E3A5F] px-1.5 py-0.5 text-[#3B82F6]\"\n                    : \"text-success\",\n                )}\n              >\n                {updateAvailable ? (\n                  <ArrowUpCircle className=\"size-3 shrink-0\" aria-hidden />\n                ) : (\n                  <CircleCheck className=\"size-3 shrink-0\" aria-hidden />\n                )}\n                {t(\n                  updateAvailable\n                    ? I18nKey.SETTINGS$APP_UPDATE_BADGE_UPDATE_AVAILABLE\n                    : I18nKey.SETTINGS$APP_UPDATE_BADGE_UP_TO_DATE,\n                )}\n              </span>\n            )}\n          </span>\n\n          <span className=\"flex items-center gap-1.5 text-xs leading-5 text-muted\">\n            <span>{t(I18nKey.SETTINGS$APP_UPDATE_VERSION_LABEL)}</span>\n            <span className=\"text-contrast\">{AGENT_CANVAS_CLIENT_VERSION}</span>\n          </span>\n        </button>\n      </section>\n\n      {isModalOpen && (\n        <AgentCanvasUpdateModal\n          onClose={() => setIsModalOpen(false)}\n          latestVersion={latestVersion}\n          isPending={latestVersionQuery.isPending}\n          isFetching={latestVersionQuery.isFetching}\n          updateAvailable={updateAvailable}\n          upToDate={upToDate}\n          onCheckForUpdates={() => latestVersionQuery.refetch()}\n        />\n      )}\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/agent-canvas-version-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[90vw]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 101,
          "column": 26,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 101,
          "endColumn": 139
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#3B82F6]\" hardcodes a color and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css.",
          "line": 110,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryColorFar",
          "endLine": 110,
          "endColumn": 57
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[#1E3A5F]\" hardcodes a color. Nearest theme tokens: bg-border-subtle, bg-cool-grey-900. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 127,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 127,
          "endColumn": 105,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-border-subtle"
              },
              "fix": {
                "range": [
                  3820,
                  3896
                ],
                "text": "\"rounded-full bg-border-subtle px-2 py-0.5 text-xs font-semibold text-[#3B82F6]\""
              },
              "desc": "Replace with \"bg-border-subtle\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-cool-grey-900"
              },
              "fix": {
                "range": [
                  3820,
                  3896
                ],
                "text": "\"rounded-full bg-cool-grey-900 px-2 py-0.5 text-xs font-semibold text-[#3B82F6]\""
              },
              "desc": "Replace with \"bg-cool-grey-900\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#3B82F6]\" hardcodes a color and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css.",
          "line": 127,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryColorFar",
          "endLine": 127,
          "endColumn": 105
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-text)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 135,
          "column": 26,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 135,
          "endColumn": 57
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"hover:text-[var(--oh-text)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 144,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 144,
          "endColumn": 133
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-text)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 152,
          "column": 28,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 152,
          "endColumn": 59
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"hover:text-[var(--oh-text)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 164,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 164,
          "endColumn": 119
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"hover:text-[var(--oh-text)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 229,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 229,
          "endColumn": 160
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 9,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  ArrowUpCircle,\n  Check,\n  CheckCircle2,\n  Copy,\n  ExternalLink,\n  RefreshCw,\n} from \"lucide-react\";\nimport {\n  AGENT_CANVAS_RELEASE_NOTES_URL,\n  AGENT_CANVAS_UPDATE_COMMANDS,\n} from \"#/api/agent-canvas-updates\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport DockerIcon from \"#/icons/docker.svg?react\";\nimport NpmIcon from \"#/icons/npm.svg?react\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\n\nconst COPY_FEEDBACK_MS = 2000;\n\ntype UpdateCommandTab = \"npm\" | \"docker\";\n\nconst UPDATE_COMMAND_TABS: UpdateCommandTab[] = [\"npm\", \"docker\"];\n\ninterface AgentCanvasVersionModalProps {\n  installedVersion: string;\n  latestVersion: string | null;\n  updateAvailable: boolean;\n  isChecking: boolean;\n  onCheckForUpdates: () => void;\n  onClose: () => void;\n}\n\nfunction getUpdateCommandTabLabelKey(tab: UpdateCommandTab): I18nKey {\n  return tab === \"npm\"\n    ? I18nKey.SETTINGS$VERSION_NPM_RECOMMENDED\n    : I18nKey.SETTINGS$VERSION_DOCKER;\n}\n\nfunction UpdateCommandTabIcon({ tab }: { tab: UpdateCommandTab }) {\n  if (tab === \"npm\") {\n    return <NpmIcon className=\"size-4 shrink-0\" aria-hidden />;\n  }\n  return <DockerIcon className=\"size-5 shrink-0\" aria-hidden />;\n}\n\nexport function AgentCanvasVersionModal({\n  installedVersion,\n  latestVersion,\n  updateAvailable,\n  isChecking,\n  onCheckForUpdates,\n  onClose,\n}: AgentCanvasVersionModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [selectedTab, setSelectedTab] = React.useState<UpdateCommandTab>(\"npm\");\n  const [copied, setCopied] = React.useState(false);\n  const copiedTimeoutRef = React.useRef<number | null>(null);\n  const command =\n    selectedTab === \"npm\"\n      ? AGENT_CANVAS_UPDATE_COMMANDS.npm\n      : AGENT_CANVAS_UPDATE_COMMANDS.docker;\n\n  React.useEffect(\n    () => () => {\n      if (copiedTimeoutRef.current !== null) {\n        window.clearTimeout(copiedTimeoutRef.current);\n      }\n    },\n    [],\n  );\n\n  React.useEffect(() => {\n    setCopied(false);\n    if (copiedTimeoutRef.current !== null) {\n      window.clearTimeout(copiedTimeoutRef.current);\n      copiedTimeoutRef.current = null;\n    }\n  }, [selectedTab]);\n\n  const copyCommand = React.useCallback(() => {\n    void navigator.clipboard?.writeText(command);\n    setCopied(true);\n    if (copiedTimeoutRef.current !== null) {\n      window.clearTimeout(copiedTimeoutRef.current);\n    }\n    copiedTimeoutRef.current = window.setTimeout(() => {\n      setCopied(false);\n      copiedTimeoutRef.current = null;\n    }, COPY_FEEDBACK_MS);\n  }, [command]);\n\n  return (\n    <ModalBackdrop\n      onClose={onClose}\n      aria-label={t(I18nKey.SETTINGS$VERSION_MODAL_ARIA_LABEL)}\n    >\n      <section className=\"relative flex w-130 max-w-[90vw] flex-col gap-5 rounded-xl border border-border bg-base-secondary p-6 shadow-xl\">\n        <ModalCloseButton\n          onClose={onClose}\n          testId=\"agent-canvas-version-modal-close\"\n        />\n\n        <header className=\"flex items-center gap-3 pr-8\">\n          {updateAvailable ? (\n            <ArrowUpCircle\n              className=\"size-7 shrink-0 text-[#3B82F6]\"\n              aria-hidden\n            />\n          ) : (\n            <CheckCircle2\n              className=\"size-7 shrink-0 text-status-success\"\n              aria-hidden\n            />\n          )}\n          <h2 className=\"text-base font-semibold leading-6 text-contrast\">\n            {t(\n              updateAvailable\n                ? I18nKey.SETTINGS$VERSION_UPDATE_AVAILABLE\n                : I18nKey.SETTINGS$VERSION_UP_TO_DATE_TITLE,\n            )}\n          </h2>\n          {updateAvailable && latestVersion ? (\n            <span className=\"rounded-full bg-[#1E3A5F] px-2 py-0.5 text-xs font-semibold text-[#3B82F6]\">\n              {latestVersion}\n            </span>\n          ) : null}\n        </header>\n\n        <div className=\"flex flex-col gap-1\">\n          {updateAvailable && latestVersion ? (\n            <p className=\"text-sm text-[var(--oh-text)]\">\n              {t(I18nKey.SETTINGS$VERSION_UPDATE_MESSAGE, {\n                installed: installedVersion,\n                latest: latestVersion,\n              })}{\" \"}\n              <a\n                href={AGENT_CANVAS_RELEASE_NOTES_URL}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"inline-flex items-center gap-1.5 whitespace-nowrap font-medium text-contrast hover:text-[var(--oh-text)]\"\n              >\n                {t(I18nKey.SETTINGS$VERSION_RELEASE_NOTES)}\n                <ExternalLink className=\"size-4 shrink-0\" aria-hidden />\n              </a>\n            </p>\n          ) : (\n            <>\n              <p className=\"text-sm text-[var(--oh-text)]\">\n                {t(I18nKey.SETTINGS$VERSION_INSTALLED, {\n                  version: installedVersion,\n                })}\n              </p>\n              {latestVersion ? (\n                <p className=\"whitespace-nowrap text-sm text-status-success\">\n                  {t(I18nKey.SETTINGS$VERSION_LATEST_MESSAGE)}{\" \"}\n                  <a\n                    href={AGENT_CANVAS_RELEASE_NOTES_URL}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"inline-flex items-center gap-1.5 font-medium text-contrast hover:text-[var(--oh-text)]\"\n                  >\n                    {t(I18nKey.SETTINGS$VERSION_RELEASE_NOTES)}\n                    <ExternalLink className=\"size-4 shrink-0\" aria-hidden />\n                  </a>\n                </p>\n              ) : (\n                <p className=\"text-sm text-muted\">\n                  {t(I18nKey.SETTINGS$VERSION_CHECK_UNAVAILABLE)}\n                </p>\n              )}\n            </>\n          )}\n        </div>\n\n        {updateAvailable ? (\n          <div>\n            <div className=\"flex justify-center\">\n              {UPDATE_COMMAND_TABS.map((tab) => (\n                <button\n                  key={tab}\n                  type=\"button\"\n                  onClick={() => setSelectedTab(tab)}\n                  className={cn(\n                    \"inline-flex items-center gap-1.5 px-3 pb-2 text-sm font-medium\",\n                    selectedTab === tab\n                      ? \"border-b border-contrast text-contrast\"\n                      : \"text-muted hover:text-contrast\",\n                  )}\n                >\n                  <UpdateCommandTabIcon tab={tab} />\n                  {t(getUpdateCommandTabLabelKey(tab))}\n                </button>\n              ))}\n            </div>\n            <div className=\"flex items-center gap-3 rounded-lg border border-border bg-surface-deep px-4 py-3\">\n              <code className=\"min-w-0 flex-1 overflow-visible whitespace-nowrap font-mono text-sm text-contrast\">\n                {command}\n              </code>\n              <button\n                type=\"button\"\n                onClick={copyCommand}\n                aria-label={t(\n                  copied\n                    ? I18nKey.BUTTON$COPIED\n                    : I18nKey.SETTINGS$VERSION_COPY_COMMAND,\n                )}\n                disabled={copied}\n                className=\"shrink-0 text-muted hover:text-contrast disabled:hover:text-muted\"\n              >\n                {copied ? (\n                  <Check className=\"size-4 text-status-success\" aria-hidden />\n                ) : (\n                  <Copy className=\"size-4\" aria-hidden />\n                )}\n              </button>\n            </div>\n          </div>\n        ) : null}\n\n        {!updateAvailable ? (\n          <div className=\"flex flex-wrap gap-x-8 gap-y-3\">\n            <button\n              type=\"button\"\n              onClick={onCheckForUpdates}\n              className=\"inline-flex items-center gap-2 text-sm font-medium text-contrast hover:text-[var(--oh-text)] disabled:cursor-wait disabled:text-muted\"\n              disabled={isChecking}\n            >\n              {t(I18nKey.SETTINGS$VERSION_CHECK_FOR_UPDATES)}\n              <RefreshCw\n                className={cn(\"size-4 shrink-0\", isChecking && \"animate-spin\")}\n                aria-hidden\n              />\n            </button>\n          </div>\n        ) : null}\n      </section>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/agent-canvas-version-tile.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#3B82F6]\" hardcodes a color and no declared theme color is close to it. Use one of: accent, base-secondary, basic, border, border-input, border-subtle, canvas-base, content, content-2, contrast, cool-grey-100, cool-grey-200 (+49 more), or declare --color-<name> in src/styles.css.",
          "line": 77,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryColorFar",
          "endLine": 77,
          "endColumn": 55
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ArrowUpCircle, CheckCircle2 } from \"lucide-react\";\nimport { compareAgentServerVersions } from \"#/api/agent-server-compatibility\";\nimport { getLockedCloudHost } from \"#/api/agent-server-config\";\nimport { AGENT_CANVAS_CLIENT_VERSION } from \"#/api/client-source\";\nimport { useLatestAgentCanvasVersion } from \"#/hooks/query/use-latest-agent-canvas-version\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { AgentCanvasVersionModal } from \"./agent-canvas-version-modal\";\n\ninterface AgentCanvasVersionTileProps {\n  className?: string;\n  /** When true, the tile is omitted unless an update is available. */\n  hideWhenUpToDate?: boolean;\n}\n\n/**\n * Main-sidebar tile that opens the update-specific version modal.\n * Hidden when the install is already up to date; settings uses\n * AgentCanvasUpdateCard instead.\n */\nexport function AgentCanvasVersionTile({\n  className,\n  hideWhenUpToDate = false,\n}: AgentCanvasVersionTileProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [isModalOpen, setIsModalOpen] = React.useState(false);\n  const isLockedToCloud = getLockedCloudHost() !== null;\n  const latestVersionQuery = useLatestAgentCanvasVersion({\n    enabled: !isLockedToCloud,\n  });\n\n  if (isLockedToCloud) return null;\n\n  const installedVersion = AGENT_CANVAS_CLIENT_VERSION;\n  const latestVersion = latestVersionQuery.data ?? null;\n  const comparison =\n    latestVersion !== null\n      ? compareAgentServerVersions(latestVersion, installedVersion)\n      : null;\n  const updateAvailable = comparison === 1;\n\n  if (hideWhenUpToDate && !updateAvailable) return null;\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        data-testid=\"agent-canvas-version-tile\"\n        onClick={() => setIsModalOpen(true)}\n        aria-label={t(I18nKey.SETTINGS$VERSION_TILE_ARIA_LABEL)}\n        className={cn(\n          \"flex w-full items-center gap-3 rounded-md border border-border bg-base-secondary px-3 py-2 text-left hover:bg-surface-raised\",\n          className,\n        )}\n      >\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"block truncate text-sm font-semibold leading-5 text-contrast\">\n            {updateAvailable\n              ? t(I18nKey.SETTINGS$VERSION_TILE_NEW_VERSION)\n              : t(I18nKey.SETTINGS$VERSION_PRODUCT_NAME)}\n          </span>\n          <span className=\"flex min-w-0 items-center gap-1.5 truncate text-xs leading-5\">\n            <span className=\"text-muted\">\n              {t(I18nKey.SETTINGS$APP_UPDATE_VERSION_LABEL)}\n            </span>\n            <span className=\"truncate text-contrast\">\n              {updateAvailable && latestVersion\n                ? latestVersion\n                : installedVersion}\n            </span>\n          </span>\n        </span>\n        {updateAvailable ? (\n          <ArrowUpCircle\n            className=\"size-5 shrink-0 text-[#3B82F6]\"\n            aria-hidden\n          />\n        ) : (\n          <CheckCircle2\n            className=\"size-5 shrink-0 text-status-success\"\n            aria-hidden\n          />\n        )}\n      </button>\n\n      {isModalOpen ? (\n        <AgentCanvasVersionModal\n          installedVersion={installedVersion}\n          latestVersion={latestVersion}\n          updateAvailable={updateAvailable}\n          isChecking={latestVersionQuery.isFetching}\n          onCheckForUpdates={() => {\n            void latestVersionQuery.refetch();\n          }}\n          onClose={() => setIsModalOpen(false)}\n        />\n      ) : null}\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/agent-profiles/agent-profile-actions-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 196,
          "column": 19,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 196,
          "endColumn": 30
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useCallback,\n  useState,\n} from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport { cn } from \"#/utils/utils\";\nimport { dropdownMenuListClassName } from \"#/utils/dropdown-classes\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport EditIcon from \"#/icons/u-edit.svg?react\";\nimport CheckCircleIcon from \"#/icons/u-check-circle.svg?react\";\nimport DeleteIcon from \"#/icons/u-delete.svg?react\";\nimport { MenuItem } from \"#/components/features/settings/llm-profiles/profile-actions-menu-item\";\n\n// A natively disabled button ignores focus(), so keyboard navigation has to\n// skip it rather than stop on it.\nconst getEnabledItems = (items: (HTMLButtonElement | null)[]) =>\n  items.filter((item): item is HTMLButtonElement =>\n    Boolean(item && !item.disabled),\n  );\n\ninterface AgentProfileActionsMenuProps {\n  onEdit: () => void;\n  onSetActive: () => void;\n  onDelete: () => void;\n  /** Disables Delete and explains why. */\n  deleteDisabledReason?: string;\n  isActive: boolean;\n  isActivating: boolean;\n  onClose: () => void;\n  /**\n   * Element the menu anchors against. When provided, the menu renders into a\n   * body portal with fixed positioning so it isn't clipped by scroll\n   * containers (matches the LLM-profiles menu behavior).\n   */\n  anchorRef?: React.RefObject<HTMLElement | null>;\n}\n\nexport function AgentProfileActionsMenu({\n  onEdit,\n  onSetActive,\n  onDelete,\n  deleteDisabledReason,\n  isActive,\n  isActivating,\n  onClose,\n  anchorRef,\n}: AgentProfileActionsMenuProps) {\n  const { t } = useTranslation(\"openhands\");\n  const menuRef = useRef<HTMLDivElement>(null);\n  const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([]);\n\n  const anchorElement = anchorRef?.current ?? null;\n  const [portalStyle, setPortalStyle] = useState<React.CSSProperties>();\n\n  useLayoutEffect(() => {\n    if (!anchorElement) return undefined;\n\n    const updatePosition = () => {\n      const rect = anchorElement.getBoundingClientRect();\n      if (!rect) return;\n      const gap = 8;\n      setPortalStyle({\n        position: \"fixed\",\n        zIndex: 9999,\n        top: rect.bottom + gap,\n        right: window.innerWidth - rect.right,\n        width: \"max-content\",\n      });\n    };\n\n    updatePosition();\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [anchorElement]);\n\n  // The anchored menu renders nothing until its position is measured, so wait\n  // for the items to exist before focusing the first enabled one.\n  const isMenuRendered = !anchorElement || portalStyle !== undefined;\n  useEffect(() => {\n    if (isMenuRendered) getEnabledItems(menuItemsRef.current)[0]?.focus();\n  }, [isMenuRendered]);\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      const target = event.target as Node;\n      if (menuRef.current?.contains(target)) return;\n      if (anchorElement?.contains(target)) return;\n      onClose();\n    };\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        if (menuRef.current?.contains(document.activeElement)) {\n          anchorElement?.focus();\n        }\n        onClose();\n      }\n    };\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    document.addEventListener(\"keydown\", handleEscape);\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n      document.removeEventListener(\"keydown\", handleEscape);\n    };\n  }, [anchorElement, onClose]);\n\n  const handleAction = (action: () => void) => {\n    action();\n    onClose();\n  };\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent, currentIndex: number) => {\n      if (e.key === \"Tab\") {\n        // The anchored menu is portaled to the end of <body>; move focus back\n        // to its trigger so Tab continues from the row, not the page end.\n        anchorElement?.focus();\n        onClose();\n        return;\n      }\n      if (e.key !== \"ArrowDown\" && e.key !== \"ArrowUp\") return;\n      e.preventDefault();\n      const enabledItems = getEnabledItems(menuItemsRef.current);\n      const position = enabledItems.findIndex(\n        (item) => item === menuItemsRef.current[currentIndex],\n      );\n      const step = e.key === \"ArrowDown\" ? 1 : -1;\n      const target =\n        (position + step + enabledItems.length) % enabledItems.length;\n      enabledItems[target]?.focus();\n    },\n    [anchorElement, onClose],\n  );\n\n  const setActiveDisabled = isActive || isActivating;\n  const isPortaled = Boolean(anchorElement);\n\n  const menu = (\n    <div\n      ref={menuRef}\n      className={cn(\n        \"absolute right-0 top-full z-10 mt-2 w-40 rounded-md border border-border-subtle bg-tertiary px-1 py-1 shadow-lg\",\n        dropdownMenuListClassName,\n        isPortaled &&\n          \"!static !top-auto !bottom-auto !left-auto !right-auto !mt-0\",\n      )}\n      role=\"menu\"\n      aria-orientation=\"vertical\"\n      data-testid=\"agent-profile-actions-menu\"\n    >\n      <MenuItem\n        index={0}\n        icon={<EditIcon width={16} height={16} />}\n        label={t(I18nKey.SETTINGS$PROFILE_EDIT)}\n        onClick={() => handleAction(onEdit)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        testId=\"agent-profile-edit\"\n      />\n      <MenuItem\n        index={1}\n        icon={<CheckCircleIcon width={16} height={16} />}\n        label={t(I18nKey.SETTINGS$PROFILE_SET_ACTIVE)}\n        onClick={() => handleAction(onSetActive)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        disabled={setActiveDisabled}\n        testId=\"agent-profile-set-active\"\n      />\n      <MenuItem\n        index={2}\n        icon={<DeleteIcon width={16} height={16} />}\n        label={t(I18nKey.BUTTON$DELETE)}\n        onClick={() => handleAction(onDelete)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        disabled={Boolean(deleteDisabledReason)}\n        title={deleteDisabledReason}\n        testId=\"agent-profile-delete\"\n      />\n    </div>\n  );\n\n  if (isPortaled) {\n    if (typeof document === \"undefined\" || !portalStyle) {\n      return null;\n    }\n    return ReactDOM.createPortal(\n      <div style={portalStyle}>{menu}</div>,\n      document.body,\n    );\n  }\n\n  return menu;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/backend-synced-settings-badge.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 40,
          "column": 34,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 40,
          "endColumn": 89
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { Typography } from \"#/ui/typography\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useAllCloudOrganizations } from \"#/hooks/query/use-cloud-organizations\";\nimport { useCloudCurrentUserId } from \"#/hooks/query/use-cloud-current-user-id\";\n\nfunction useActiveBackendDisplayName(): string {\n  const { t } = useTranslation();\n  const active = useActiveBackend();\n  const cloudOrgs = useAllCloudOrganizations();\n  const userIds = useCloudCurrentUserId();\n\n  if (active.backend.kind !== \"cloud\" || !active.orgId) {\n    return active.backend.name;\n  }\n\n  const entry = cloudOrgs[active.backend.id];\n  const org = entry?.orgs.find((o) => o.id === active.orgId);\n  if (!org) return active.backend.name;\n\n  const userId = userIds[active.backend.id]?.userId ?? null;\n  const isPersonal = !!userId && org.id === userId;\n  const orgLabel = isPersonal\n    ? t(I18nKey.BACKEND$PERSONAL_WORKSPACE)\n    : org.name;\n  return `${active.backend.name} \u2013 ${orgLabel}`;\n}\n\nexport function BackendSyncedSettingsBadge() {\n  const { t } = useTranslation();\n  const active = useActiveBackend();\n  const name = useActiveBackendDisplayName();\n\n  return (\n    <div\n      data-testid=\"backend-synced-settings-badge\"\n      className=\"flex items-center\"\n    >\n      <Typography.Text className=\"text-[11px] font-medium text-text-secondary leading-5\">\n        {t(I18nKey.SETTINGS$BACKEND_SYNCED_BADGE, {\n          name,\n          host: active.backend.host,\n          interpolation: { escapeValue: false },\n        })}\n      </Typography.Text>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/brand-button.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-accent-foreground)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 60,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 60,
          "endColumn": 146
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { forwardRef } from \"react\";\nimport { cn } from \"#/utils/utils\";\nimport { formControlButtonClassName } from \"#/utils/form-control-classes\";\n\ninterface BrandButtonProps {\n  testId?: string;\n  name?: string;\n  variant: \"primary\" | \"secondary\" | \"tertiary\" | \"danger\" | \"ghost-danger\";\n  type: React.ButtonHTMLAttributes<HTMLButtonElement>[\"type\"];\n  isDisabled?: boolean;\n  className?: string;\n  onClick?: (event?: React.MouseEvent<HTMLButtonElement>) => void;\n  startContent?: React.ReactNode;\n  /** Accessible label for icon-only buttons */\n  ariaLabel?: string;\n  /** Indicates busy/loading state for screen readers */\n  \"aria-busy\"?: boolean;\n  \"aria-haspopup\"?: React.AriaAttributes[\"aria-haspopup\"];\n  \"aria-expanded\"?: boolean;\n}\n\nexport const BrandButton = forwardRef<\n  HTMLButtonElement,\n  React.PropsWithChildren<BrandButtonProps>\n>(function BrandButton(\n  {\n    testId,\n    name,\n    children,\n    variant,\n    type,\n    isDisabled,\n    className,\n    onClick,\n    startContent,\n    ariaLabel,\n    \"aria-busy\": ariaBusy,\n    \"aria-haspopup\": ariaHasPopup,\n    \"aria-expanded\": ariaExpanded,\n  },\n  ref,\n) {\n  return (\n    <button\n      ref={ref}\n      name={name}\n      data-testid={testId}\n      disabled={isDisabled}\n      // The type is already passed as a prop to the button component\n\n      type={type}\n      onClick={onClick}\n      aria-label={ariaLabel}\n      aria-busy={ariaBusy}\n      aria-haspopup={ariaHasPopup}\n      aria-expanded={ariaExpanded}\n      className={cn(\n        formControlButtonClassName,\n        variant === \"primary\" &&\n          \"bg-primary text-[var(--oh-accent-foreground)] hover:opacity-80 disabled:bg-interactive-hover disabled:text-muted disabled:opacity-100\",\n        variant === \"secondary\" &&\n          \"border border-border bg-base-secondary text-contrast hover:bg-surface-raised\",\n        variant === \"tertiary\" &&\n          \"bg-interactive-hover text-contrast hover:opacity-80\",\n        variant === \"danger\" &&\n          \"bg-feedback-error text-contrast-foreground hover:bg-feedback-error/90\",\n        variant === \"ghost-danger\" &&\n          \"h-auto min-h-0 bg-transparent px-0 text-feedback-error underline hover:no-underline font-normal\",\n        startContent && \"flex items-center justify-center gap-2\",\n        className,\n      )}\n    >\n      {startContent}\n      {children}\n    </button>\n  );\n});\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/llm-profiles/add-models-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 393,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 393,
          "endColumn": 126
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-background)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 393,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 393,
          "endColumn": 126
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 421,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 421,
          "endColumn": 128
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[var(--oh-background)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 421,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 421,
          "endColumn": 128
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 439,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 439,
          "endColumn": 87
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 448,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 448,
          "endColumn": 83
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 463,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 463,
          "endColumn": 55
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 7,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { LoadingSpinner } from \"#/components/shared/loading-spinner\";\nimport { ApiKeyModalBase } from \"#/components/features/settings/api-key-modal-base\";\nimport { useProviderModels } from \"#/hooks/query/use-provider-models\";\nimport { useSearchProviders } from \"#/hooks/query/use-search-providers\";\nimport { useSaveLlmProfile } from \"#/hooks/mutation/use-save-llm-profile\";\nimport type { SaveProfileRequest } from \"#/api/profiles-service/profiles-service.api\";\nimport type { ProviderConnection } from \"#/api/provider-connections-service/provider-connections-service.api\";\nimport { mapProvider } from \"#/utils/map-provider\";\nimport { deriveProfileNameFromModel } from \"#/utils/derive-profile-name\";\nimport {\n  displayErrorToast,\n  displaySuccessToast,\n} from \"#/utils/custom-toast-handlers\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { isSdkHttpStatusError } from \"#/api/agent-server-compatibility\";\n/** Pull the server's own explanation out of an error, when it sent one. */\nfunction getServerDetail(error: unknown): string | null {\n  const detail = (error as { response?: { detail?: unknown } })?.response\n    ?.detail;\n  return typeof detail === \"string\" && detail.trim() ? detail : null;\n}\n\ninterface AddModelsModalProps {\n  isOpen: boolean;\n  existingNames: string[];\n  /**\n   * Provider connections the user can bulk-add from. When the selected\n   * provider has a matching connection, the modal binds it (created profiles\n   * inherit the connection's credential). When it does not \u2014 or the user\n   * chooses \"No connection\" \u2014 profiles are created keyless, preserving the\n   * pre-connection bulk-add behavior.\n   */\n  connections: ProviderConnection[];\n  /**\n   * A connection id to preselect when the modal opens (launched from a\n   * connection row's \"\u2026\"). Omit/leave null for the chooser entry point\n   * (the top \"Add from provider connections\" button), where the user picks a\n   * provider. In preselect mode the provider combobox opens on that\n   * connection's provider, the connection is bound, and its models load\n   * already selected.\n   */\n  initialConnectionId?: string | null;\n  onClose: () => void;\n}\n\ntype RowStatus = \"idle\" | \"saving\" | \"saved\" | \"failed\";\n\ninterface ModelRow {\n  /** Full model id, e.g. \"openhands/deepseek-v4-flash\". */\n  model: string;\n  /** Derived profile name (not user-editable). */\n  name: string;\n  selected: boolean;\n  status: RowStatus;\n}\n\n/** Sentinel for the \"no connection\" (keyless) option in the connection box. */\nconst KEYLESS_CONNECTION_VALUE = \"\";\n\n/**\n * Bulk-add models as LLM profiles. One modal serves two entry points: the top\n * \"Add from provider connections\" button (chooser mode \u2014 pick a provider) and\n * a connection row's \"\u2026\" (preselect mode \u2014 opens on that connection's\n * provider with the connection bound and models already selected).\n *\n * The selected provider drives the model list. When a matching provider\n * connection exists it is bound by default, so created profiles inherit the\n * connection's shared credential via `provider_connection_id` (mirroring the\n * link flow in `llm-settings-local-view.tsx`) and no key needs to be added\n * afterward. When no connection matches \u2014 or the user chooses \"No connection\"\n * \u2014 `provider_connection_id` is null and profiles are created keyless, the\n * same behavior bulk-add had before provider connections existed.\n */\nexport function AddModelsModal({\n  isOpen,\n  existingNames,\n  connections,\n  initialConnectionId = null,\n  onClose,\n}: AddModelsModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [selectedProvider, setSelectedProvider] = useState<string | null>(null);\n  const [selectedConnectionId, setSelectedConnectionId] = useState<\n    string | null\n  >(null);\n  const [rows, setRows] = useState<ModelRow[]>([]);\n  const [submitting, setSubmitting] = useState(false);\n\n  const { data: providers } = useSearchProviders();\n  const models = useProviderModels(selectedProvider);\n  const saveProfile = useSaveLlmProfile();\n\n  // Connections whose provider matches the selected provider. These are the\n  // only ones the connection box offers: a connection is only usable as a\n  // credential source for its own provider's models.\n  const matchingConnections = useMemo(\n    () =>\n      selectedProvider\n        ? connections.filter((c) => c.provider === selectedProvider)\n        : [],\n    [connections, selectedProvider],\n  );\n\n  // The bound connection id drives the submit payload. Null means keyless.\n  const connectionId = useMemo(() => {\n    if (!selectedConnectionId) return null;\n    return matchingConnections.some((c) => c.id === selectedConnectionId)\n      ? selectedConnectionId\n      : null;\n  }, [matchingConnections, selectedConnectionId]);\n\n  // Rows follow the model list, and only the model list. Already-added models\n  // are hidden at render rather than rebuilding the rows, because a rebuild\n  // discards the selections and per-row statuses the user has accumulated. A\n  // row already on screen keeps its state when the list refreshes.\n  // `autoSelectRef` is set when the modal opens from a connection row (\"\u2026\"):\n  // that entry point is an explicit intent to bulk-add from that connection,\n  // so the loaded rows are selected for the user instead of forcing per-row\n  // clicking. It fires once per open (on the first model list that arrives)\n  // and is cleared, so a later connection change in the combobox does not\n  // re-select rows the user has already curated.\n  const autoSelectRef = useRef(false);\n  useEffect(() => {\n    const items = models.data ?? [];\n    const selectNew = autoSelectRef.current;\n    setRows((prev) => {\n      const prior = new Map(prev.map((row) => [row.model, row]));\n      // Dedupe by canonical model id: a provider's catalog can list the same\n      // model twice (e.g. once in the verified set and once in the raw list\n      // when the two disagree on prefixing). Without dedup each copy becomes\n      // its own row with the same derived name, and the two flag each other as\n      // conflicts \u2014 surfacing bogus \"Name already exists\" on a fresh account.\n      const seen = new Set<string>();\n      const built: ModelRow[] = [];\n      for (const m of items) {\n        const full =\n          m.provider && !m.name.startsWith(`${m.provider}/`)\n            ? `${m.provider}/${m.name}`\n            : m.name;\n        if (seen.has(full)) continue;\n        seen.add(full);\n        const carried = prior.get(full);\n        built.push(\n          carried\n            ? carried\n            : {\n                model: full,\n                name: deriveProfileNameFromModel(full),\n                // Chooser mode leaves rows unselected: the server caps how\n                // many profiles an account may hold, so defaulting to \"all\"\n                // invites a submission that is mostly refusals. Preselect mode\n                // (autoSelectRef) selects them \u2014 see the comment above.\n                selected: selectNew,\n                status: \"idle\" as RowStatus,\n              },\n        );\n      }\n      return built;\n    });\n    if (items.length > 0) autoSelectRef.current = false;\n  }, [models.data]);\n\n  // The manager keeps this component mounted and drives it with `isOpen`, so\n  // without an explicit reset a reopened modal still shows the last session's\n  // provider, connection, selections, and Saved/Failed marks. On open, seed\n  // the provider/connection from `initialConnectionId` (preselect mode) or\n  // leave both unset (chooser mode). On close, clear everything.\n  useEffect(() => {\n    if (isOpen) {\n      const initial =\n        initialConnectionId &&\n        connections.find((c) => c.id === initialConnectionId);\n      if (initial) {\n        setSelectedProvider(initial.provider);\n        setSelectedConnectionId(initial.id);\n        autoSelectRef.current = true;\n      } else {\n        setSelectedProvider(null);\n        setSelectedConnectionId(null);\n      }\n    } else {\n      // Clear the provider/connection too, not just the rows. The modal stays\n      // mounted between openings, and `useProviderModels` serves a cached\n      // `models.data` for the same provider \u2014 so if `selectedProvider` is left\n      // set, reopening in preselect mode reseeds the same provider string, the\n      // cached list reference is unchanged, and the `[models.data]` effect\n      // never re-runs to rebuild `rows`. Resetting to null makes that effect\n      // see undefined -> cached-list on reopen, so the model list reloads.\n      setSelectedProvider(null);\n      setSelectedConnectionId(null);\n      setRows([]);\n      setSubmitting(false);\n      autoSelectRef.current = false;\n    }\n  }, [isOpen, initialConnectionId, connections]);\n\n  const existing = useMemo(() => new Set(existingNames), [existingNames]);\n\n  if (!isOpen) return null;\n\n  // Already-added models are hidden, not disabled: a row whose name matches an\n  // existing profile can't be re-created, so showing it (flagged or greyed)\n  // just clutters the list. A saved row is exempt \u2014 it was added from this\n  // very session, so it stays visible with its \"Saved\" mark.\n  const visibleRows = rows.filter(\n    (row) => row.status === \"saved\" || !existing.has(row.name),\n  );\n\n  // Saved rows stay visible (so the user sees the \"Saved\" mark) but are not\n  // selectable: a saved row is finished work, so it must not count toward the\n  // footer's \"Add N\", drive select-all, or be re-POSTed on a retry. Without\n  // this exclusion a partial submit leaves the saved rows selected, and a\n  // second Submit re-creates them \u2014 the create now 409s (the name exists) and\n  // is reported as a fresh failure for work that already succeeded.\n  const selectable = visibleRows.filter((row) => row.status !== \"saved\");\n  const selectedRows = selectable.filter((row) => row.selected);\n\n  const setRow = (model: string, patch: Partial<ModelRow>) =>\n    setRows((prev) =>\n      prev.map((row) => (row.model === model ? { ...row, ...patch } : row)),\n    );\n\n  const allSelected =\n    selectable.length > 0 && selectedRows.length === selectable.length;\n\n  const toggleAll = () => {\n    const next = !allSelected;\n    const reachable = new Set(selectable.map((row) => row.model));\n    setRows((prev) =>\n      prev.map((row) =>\n        reachable.has(row.model) ? { ...row, selected: next } : row,\n      ),\n    );\n  };\n\n  // When the user picks a provider, bind a matching connection by default so\n  // created profiles are usable out of the box (this modal's reason for\n  // existing). If there is no matching connection, leave it keyless. The user\n  // can still switch the connection box to \"No connection\" to opt out.\n  const handleProviderChange = (value: string) => {\n    setSelectedProvider(value || null);\n    const first = connections.find((c) => c.provider === value);\n    setSelectedConnectionId(first?.id ?? null);\n  };\n\n  const handleSubmit = async () => {\n    setSubmitting(true);\n    const targets = selectedRows;\n    for (const row of targets) setRow(row.model, { status: \"saving\" });\n\n    // Sequential, not a parallel fan-out: the server enforces a profile limit,\n    // and firing every create at once turns one refusal into a wall of\n    // identical failures.\n    //\n    // A 409 is ambiguous. It means the profile ceiling, which every remaining\n    // create would hit too \u2014 or a name taken since this list loaded, which\n    // says nothing about the rows behind it. Stopping on the first one halts\n    // a run that would have succeeded; ignoring it spends the whole selection\n    // against a wall. So one 409 fails its own row and the run continues, and\n    // a second confirms the wall at a cost of exactly one extra request.\n    let added = 0;\n    let failed = 0;\n    let conflicts = 0;\n    let blocked = false;\n    let blockedReason: string | null = null;\n\n    for (const [i, row] of targets.entries()) {\n      try {\n        await saveProfile.mutateAsync({\n          name: row.name,\n          request: {\n            // A bound connection sources the credential, so it replaces any\n            // inline api_key/base_url \u2014 mirrored on the normal save flow in\n            // `llm-settings-local-view.tsx`. `include_secrets: false` because\n            // no secret is being sent; the link is by id. When no connection\n            // is bound (`connectionId` is null) the profile is keyless, the\n            // same as bulk-add before provider connections existed.\n            llm: {\n              model: row.model,\n              provider_connection_id: connectionId,\n            } as SaveProfileRequest[\"llm\"],\n            include_secrets: false,\n          },\n        });\n        setRow(row.model, { status: \"saved\" });\n        added += 1;\n      } catch (error) {\n        // Surface the server's reason \u2014 a silent \"failed\" row is undebuggable.\n        console.error(`profile create failed for ${row.model}:`, error);\n        setRow(row.model, { status: \"failed\" });\n        failed += 1;\n        if (isSdkHttpStatusError(error, 409)) {\n          conflicts += 1;\n          if (conflicts >= 2) {\n            blocked = true;\n            // Read the reason off the refusal that actually stopped the run.\n            // Carrying an earlier 409's detail forward would report a raced\n            // duplicate name as the cause of a halt it had nothing to do with.\n            blockedReason = getServerDetail(error);\n            // Rows past this one were marked \"saving\" up front and are now\n            // never attempted; leave them idle rather than spinning forever.\n            for (const skipped of targets.slice(i + 1)) {\n              setRow(skipped.model, { status: \"idle\" });\n            }\n            break;\n          }\n        }\n      }\n    }\n\n    setSubmitting(false);\n    if (blocked) {\n      // Quote the server when it explained itself. When it did not, say so\n      // plainly: the partial-count wording below counts only the rows that\n      // were attempted, so it reads as a smaller failure than it was.\n      displayErrorToast(\n        blockedReason ??\n          t(I18nKey.SETTINGS$MODELS_ADD_BLOCKED, { added: String(added) }),\n      );\n    } else if (failed === 0) {\n      displaySuccessToast(\n        t(I18nKey.SETTINGS$MODELS_ADDED, { count: String(added) }),\n      );\n      onClose();\n    } else {\n      displayErrorToast(\n        t(I18nKey.SETTINGS$MODELS_ADDED_PARTIAL, {\n          added: String(added),\n          failed: String(failed),\n        }),\n      );\n    }\n  };\n\n  const handleClose = () => {\n    if (!submitting) onClose();\n  };\n\n  const isLoadingModels = selectedProvider !== null && models.isLoading;\n  // Only flag \"empty\" once a provider is chosen and its (non-loading) model\n  // list came back bare. In chooser mode with nothing picked yet, the\n  // provider box is the prompt \u2014 there's no list to call empty.\n  const showEmpty =\n    selectedProvider !== null && !isLoadingModels && visibleRows.length === 0;\n  const emptyMessage = I18nKey.SETTINGS$ADD_MODELS_EMPTY;\n\n  const footer = (\n    <>\n      <BrandButton\n        type=\"button\"\n        variant=\"tertiary\"\n        onClick={handleClose}\n        isDisabled={submitting}\n      >\n        {t(I18nKey.BUTTON$CANCEL)}\n      </BrandButton>\n      <BrandButton\n        testId=\"add-models-submit\"\n        type=\"button\"\n        variant=\"primary\"\n        onClick={handleSubmit}\n        isDisabled={submitting || selectedRows.length === 0}\n      >\n        {submitting ? (\n          <LoadingSpinner size=\"small\" />\n        ) : (\n          t(I18nKey.SETTINGS$ADD_N_PROFILES, {\n            count: String(selectedRows.length),\n          })\n        )}\n      </BrandButton>\n    </>\n  );\n\n  return (\n    <ApiKeyModalBase\n      isOpen\n      title={t(I18nKey.SETTINGS$ADD_MODELS_TITLE)}\n      footer={footer}\n      onClose={handleClose}\n    >\n      <div data-testid=\"add-models-modal\" className=\"flex flex-col gap-3\">\n        <label\n          className=\"flex flex-col gap-2 text-sm text-contrast\"\n          data-testid=\"add-models-provider-field\"\n        >\n          {t(I18nKey.SETTINGS$ADD_MODELS_PROVIDER_LABEL)}\n          <select\n            data-testid=\"add-models-provider\"\n            className=\"rounded-md border border-[var(--oh-border)] bg-[var(--oh-background)] px-3 py-2 text-sm text-contrast\"\n            value={selectedProvider ?? \"\"}\n            onChange={(e) => handleProviderChange(e.target.value)}\n            disabled={submitting}\n          >\n            <option value=\"\" disabled>\n              {t(I18nKey.SETTINGS$ADD_MODELS_PROVIDER_PLACEHOLDER)}\n            </option>\n            {providers?.map((p) => (\n              <option key={p.name} value={p.name}>\n                {mapProvider(p.name)}\n              </option>\n            ))}\n          </select>\n        </label>\n\n        {/* Only offer a connection binding when the selected provider has a\n            matching connection. With none, the run is keyless and there is\n            nothing to choose, so the box is omitted rather than showing a\n            lone \"No connection\" option. */}\n        {matchingConnections.length > 0 && (\n          <label\n            className=\"flex flex-col gap-2 text-sm text-contrast\"\n            data-testid=\"add-models-connection-field\"\n          >\n            {t(I18nKey.SETTINGS$ADD_MODELS_CONNECTION_LABEL)}\n            <select\n              data-testid=\"add-models-connection\"\n              className=\"rounded-md border border-[var(--oh-border)] bg-[var(--oh-background)] px-3 py-2 text-sm text-contrast\"\n              value={selectedConnectionId ?? KEYLESS_CONNECTION_VALUE}\n              onChange={(e) => setSelectedConnectionId(e.target.value || null)}\n              disabled={submitting}\n            >\n              <option value={KEYLESS_CONNECTION_VALUE}>\n                {t(I18nKey.SETTINGS$ADD_MODELS_NO_CONNECTION)}\n              </option>\n              {matchingConnections.map((c) => (\n                <option key={c.id} value={c.id}>\n                  {c.display_name}\n                </option>\n              ))}\n            </select>\n          </label>\n        )}\n\n        {selectedProvider && connectionId && (\n          <span className=\"min-w-0 max-w-full truncate text-xs text-[var(--oh-muted)]\">\n            {t(I18nKey.SETTINGS$ADD_MODELS_CONNECTION_BOUND, {\n              provider: selectedProvider,\n            })}\n          </span>\n        )}\n        {selectedProvider && !connectionId && (\n          <span\n            data-testid=\"add-models-keyless-note\"\n            className=\"min-w-0 max-w-full truncate text-xs text-[var(--oh-muted)]\"\n          >\n            {t(I18nKey.SETTINGS$ADD_MODELS_KEYLESS_NOTE)}\n          </span>\n        )}\n\n        {isLoadingModels && (\n          <div data-testid=\"add-models-loading\" className=\"py-4 text-center\">\n            <LoadingSpinner size=\"small\" />\n          </div>\n        )}\n\n        {showEmpty && (\n          <p\n            data-testid=\"add-models-empty\"\n            className=\"text-sm text-[var(--oh-muted)]\"\n          >\n            {t(emptyMessage)}\n          </p>\n        )}\n\n        {visibleRows.length > 0 && (\n          <>\n            <label className=\"flex items-center gap-2 text-sm text-contrast\">\n              <input\n                data-testid=\"add-models-select-all\"\n                type=\"checkbox\"\n                checked={allSelected}\n                onChange={toggleAll}\n                disabled={submitting || selectable.length === 0}\n              />\n              {t(I18nKey.SETTINGS$ADD_MODELS_SELECT_ALL)}\n            </label>\n            <ul className=\"flex max-h-64 flex-col gap-2 overflow-y-auto\">\n              {visibleRows.map((row) => {\n                const disabled = submitting || row.status === \"saved\";\n                // Show the model name without the provider prefix \u2014 the\n                // prefix is implied by the selected connection and just adds\n                // visual noise to every row.\n                const shortName = row.model.split(\"/\").pop() ?? row.model;\n                return (\n                  <li\n                    key={row.model}\n                    data-testid={`add-models-row-${row.model}`}\n                    className=\"flex items-center gap-2\"\n                  >\n                    <input\n                      data-testid={`add-models-check-${row.model}`}\n                      type=\"checkbox\"\n                      checked={row.selected}\n                      onChange={(e) =>\n                        setRow(row.model, { selected: e.target.checked })\n                      }\n                      disabled={disabled}\n                    />\n                    <span\n                      className=\"min-w-0 flex-1 truncate text-sm text-contrast\"\n                      title={row.model}\n                    >\n                      {shortName}\n                    </span>\n                    {row.status === \"saving\" && <LoadingSpinner size=\"small\" />}\n                    {row.status === \"saved\" && (\n                      <span className=\"text-xs text-feedback-success\">\n                        {t(I18nKey.SETTINGS$MODEL_ROW_SAVED)}\n                      </span>\n                    )}\n                    {row.status === \"failed\" && (\n                      <span className=\"text-xs text-feedback-error\">\n                        {t(I18nKey.SETTINGS$MODEL_ROW_FAILED)}\n                      </span>\n                    )}\n                  </li>\n                );\n              })}\n            </ul>\n          </>\n        )}\n      </div>\n    </ApiKeyModalBase>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/llm-profiles/profile-actions-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 221,
          "column": 19,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 221,
          "endColumn": 30
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useCallback,\n  useState,\n} from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport { TextCursor, Copy } from \"lucide-react\";\nimport { cn } from \"#/utils/utils\";\nimport { dropdownMenuListClassName } from \"#/utils/dropdown-classes\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport EditIcon from \"#/icons/u-edit.svg?react\";\nimport CheckCircleIcon from \"#/icons/u-check-circle.svg?react\";\nimport DeleteIcon from \"#/icons/u-delete.svg?react\";\nimport { MenuItem } from \"./profile-actions-menu-item\";\n\ninterface ProfileActionsMenuProps {\n  onEdit: () => void;\n  onRename: () => void;\n  onDuplicate: () => void;\n  onSetActive: () => void;\n  onDelete: () => void;\n  isActive: boolean;\n  isActivating: boolean;\n  onClose: () => void;\n  /**\n   * Element the menu should anchor against. When provided, the menu renders\n   * into a portal at the document body using fixed positioning so it cannot be\n   * clipped by ancestors with `overflow: auto/hidden` (e.g. the settings\n   * `<main>` scroll container).\n   */\n  anchorRef?: React.RefObject<HTMLElement | null>;\n}\n\n// A natively disabled button ignores focus(), so keyboard navigation has to\n// skip it rather than stop on it.\nconst getEnabledItems = (items: (HTMLButtonElement | null)[]) =>\n  items.filter((item): item is HTMLButtonElement =>\n    Boolean(item && !item.disabled),\n  );\n\nexport function ProfileActionsMenu({\n  onEdit,\n  onRename,\n  onDuplicate,\n  onSetActive,\n  onDelete,\n  isActive,\n  isActivating,\n  onClose,\n  anchorRef,\n}: ProfileActionsMenuProps) {\n  const { t } = useTranslation(\"openhands\");\n  const menuRef = useRef<HTMLDivElement>(null);\n  const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([]);\n\n  const anchorElement = anchorRef?.current ?? null;\n  const [portalStyle, setPortalStyle] = useState<React.CSSProperties>();\n\n  useLayoutEffect(() => {\n    if (!anchorElement) return undefined;\n\n    const updatePosition = () => {\n      const rect = anchorElement.getBoundingClientRect();\n      if (!rect) return;\n      const gap = 8;\n      setPortalStyle({\n        position: \"fixed\",\n        zIndex: 9999,\n        top: rect.bottom + gap,\n        right: window.innerWidth - rect.right,\n        width: \"max-content\",\n      });\n    };\n\n    updatePosition();\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [anchorElement]);\n\n  // The anchored menu renders nothing until its position is measured, so wait\n  // for the items to exist before focusing the first enabled one.\n  const isMenuRendered = !anchorElement || portalStyle !== undefined;\n  useEffect(() => {\n    if (isMenuRendered) getEnabledItems(menuItemsRef.current)[0]?.focus();\n  }, [isMenuRendered]);\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      const target = event.target as Node;\n      if (menuRef.current?.contains(target)) return;\n      if (anchorElement?.contains(target)) return;\n      onClose();\n    };\n\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        if (menuRef.current?.contains(document.activeElement)) {\n          anchorElement?.focus();\n        }\n        onClose();\n      }\n    };\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    document.addEventListener(\"keydown\", handleEscape);\n\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n      document.removeEventListener(\"keydown\", handleEscape);\n    };\n  }, [anchorElement, onClose]);\n\n  const handleAction = (action: () => void) => {\n    action();\n    onClose();\n  };\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent, currentIndex: number) => {\n      if (e.key === \"Tab\") {\n        // The anchored menu is portaled to the end of <body>; move focus back\n        // to its trigger so Tab continues from the row, not the page end.\n        anchorElement?.focus();\n        onClose();\n        return;\n      }\n      if (e.key !== \"ArrowDown\" && e.key !== \"ArrowUp\") return;\n      e.preventDefault();\n      const enabledItems = getEnabledItems(menuItemsRef.current);\n      const position = enabledItems.findIndex(\n        (item) => item === menuItemsRef.current[currentIndex],\n      );\n      const step = e.key === \"ArrowDown\" ? 1 : -1;\n      const target =\n        (position + step + enabledItems.length) % enabledItems.length;\n      enabledItems[target]?.focus();\n    },\n    [anchorElement, onClose],\n  );\n\n  const setActiveDisabled = isActive || isActivating;\n  const isPortaled = Boolean(anchorElement);\n\n  const menu = (\n    <div\n      ref={menuRef}\n      className={cn(\n        \"absolute right-0 top-full z-10 mt-2 w-40 rounded-md border border-border-subtle bg-tertiary px-1 py-1 shadow-lg\",\n        dropdownMenuListClassName,\n        isPortaled &&\n          \"!static !top-auto !bottom-auto !left-auto !right-auto !mt-0\",\n      )}\n      role=\"menu\"\n      aria-orientation=\"vertical\"\n      data-testid=\"profile-actions-menu\"\n    >\n      <MenuItem\n        index={0}\n        icon={<EditIcon width={16} height={16} />}\n        label={t(I18nKey.SETTINGS$PROFILE_EDIT)}\n        onClick={() => handleAction(onEdit)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        testId=\"profile-edit\"\n      />\n      <MenuItem\n        index={1}\n        icon={<TextCursor aria-hidden className=\"size-4\" strokeWidth={2} />}\n        label={t(I18nKey.BUTTON$RENAME)}\n        onClick={() => handleAction(onRename)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        testId=\"profile-rename\"\n      />\n      <MenuItem\n        index={2}\n        icon={<Copy aria-hidden className=\"size-4\" strokeWidth={2} />}\n        label={t(I18nKey.BUTTON$DUPLICATE)}\n        onClick={() => handleAction(onDuplicate)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        testId=\"profile-duplicate\"\n      />\n      <MenuItem\n        index={3}\n        icon={<CheckCircleIcon width={16} height={16} />}\n        label={t(I18nKey.SETTINGS$PROFILE_SET_DEFAULT)}\n        onClick={() => handleAction(onSetActive)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        disabled={setActiveDisabled}\n        testId=\"profile-set-active\"\n      />\n      {/* The active profile can be deleted: useEnsureActiveProfile then promotes\n          another remaining profile so a profile is always active in local mode. */}\n      <MenuItem\n        index={4}\n        icon={<DeleteIcon width={16} height={16} />}\n        label={t(I18nKey.BUTTON$DELETE)}\n        onClick={() => handleAction(onDelete)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        testId=\"profile-delete\"\n      />\n    </div>\n  );\n\n  if (isPortaled) {\n    if (typeof document === \"undefined\" || !portalStyle) {\n      return null;\n    }\n    return ReactDOM.createPortal(\n      // portal position computed from DOM bounding rect at runtime\n      <div style={portalStyle}>{menu}</div>,\n      document.body,\n    );\n  }\n\n  return menu;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/llm-profiles/provider-connection-actions-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 174,
          "column": 19,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 174,
          "endColumn": 30
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useCallback,\n  useState,\n} from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport { cn } from \"#/utils/utils\";\nimport { dropdownMenuListClassName } from \"#/utils/dropdown-classes\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport EditIcon from \"#/icons/u-edit.svg?react\";\nimport DeleteIcon from \"#/icons/u-delete.svg?react\";\nimport AddModelsIcon from \"#/icons/u-plus.svg?react\";\nimport { MenuItem } from \"./profile-actions-menu-item\";\n\ninterface ProviderConnectionActionsMenuProps {\n  onAddModels: () => void;\n  onEdit: () => void;\n  onDelete: () => void;\n  onClose: () => void;\n  /**\n   * Element the menu should anchor against. When provided, the menu renders\n   * into a portal at the document body using fixed positioning so it cannot be\n   * clipped by ancestors with `overflow: auto/hidden` (e.g. the settings\n   * `<main>` scroll container).\n   */\n  anchorRef?: React.RefObject<HTMLElement | null>;\n}\n\nexport function ProviderConnectionActionsMenu({\n  onAddModels,\n  onEdit,\n  onDelete,\n  onClose,\n  anchorRef,\n}: ProviderConnectionActionsMenuProps) {\n  const { t } = useTranslation(\"openhands\");\n  const menuRef = useRef<HTMLDivElement>(null);\n  const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([]);\n\n  const anchorElement = anchorRef?.current ?? null;\n  const [portalStyle, setPortalStyle] = useState<React.CSSProperties>();\n\n  useLayoutEffect(() => {\n    if (!anchorElement) return undefined;\n\n    const updatePosition = () => {\n      const rect = anchorElement.getBoundingClientRect();\n      if (!rect) return;\n      const gap = 8;\n      setPortalStyle({\n        position: \"fixed\",\n        zIndex: 9999,\n        top: rect.bottom + gap,\n        right: window.innerWidth - rect.right,\n        width: \"max-content\",\n      });\n    };\n\n    updatePosition();\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [anchorElement]);\n\n  useEffect(() => {\n    menuItemsRef.current[0]?.focus();\n  }, []);\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      const target = event.target as Node;\n      if (menuRef.current?.contains(target)) return;\n      if (anchorElement?.contains(target)) return;\n      onClose();\n    };\n\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        onClose();\n      }\n    };\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    document.addEventListener(\"keydown\", handleEscape);\n\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n      document.removeEventListener(\"keydown\", handleEscape);\n    };\n  }, [anchorElement, onClose]);\n\n  const handleAction = (action: () => void) => {\n    action();\n    onClose();\n  };\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent, currentIndex: number) => {\n      if (e.key === \"Tab\") {\n        onClose();\n        return;\n      }\n      const itemCount = menuItemsRef.current.filter(Boolean).length;\n      if (e.key === \"ArrowDown\") {\n        e.preventDefault();\n        const nextIndex = (currentIndex + 1) % itemCount;\n        menuItemsRef.current[nextIndex]?.focus();\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        const prevIndex = (currentIndex - 1 + itemCount) % itemCount;\n        menuItemsRef.current[prevIndex]?.focus();\n      }\n    },\n    [onClose],\n  );\n\n  const isPortaled = Boolean(anchorElement);\n\n  const menu = (\n    <div\n      ref={menuRef}\n      className={cn(\n        \"absolute right-0 top-full z-10 mt-2 w-40 rounded-md border border-border-subtle bg-tertiary px-1 py-1 shadow-lg\",\n        dropdownMenuListClassName,\n        isPortaled &&\n          \"!static !top-auto !bottom-auto !left-auto !right-auto !mt-0\",\n      )}\n      role=\"menu\"\n      aria-orientation=\"vertical\"\n      data-testid=\"provider-connection-actions-menu\"\n    >\n      <MenuItem\n        index={0}\n        icon={<AddModelsIcon width={16} height={16} />}\n        label={t(I18nKey.SETTINGS$PROVIDER_CONNECTION_BULK_ADD)}\n        onClick={() => handleAction(onAddModels)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        testId=\"provider-connection-add-models\"\n      />\n      <MenuItem\n        index={1}\n        icon={<EditIcon width={16} height={16} />}\n        label={t(I18nKey.BUTTON$EDIT)}\n        onClick={() => handleAction(onEdit)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        testId=\"provider-connection-edit\"\n      />\n      <MenuItem\n        index={2}\n        icon={<DeleteIcon width={16} height={16} />}\n        label={t(I18nKey.BUTTON$DELETE)}\n        onClick={() => handleAction(onDelete)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        testId=\"provider-connection-delete\"\n      />\n    </div>\n  );\n\n  if (isPortaled) {\n    if (typeof document === \"undefined\" || !portalStyle) {\n      return null;\n    }\n    return ReactDOM.createPortal(\n      // portal position computed from DOM bounding rect at runtime\n      <div style={portalStyle}>{menu}</div>,\n      document.body,\n    );\n  }\n\n  return menu;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/llm-settings/openai-subscription-auth-card.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"tracking-[0.08em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 207,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 207,
          "endColumn": 62
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ExternalLink } from \"lucide-react\";\nimport type { LLMSubscriptionDeviceChallenge } from \"#/api/llm-subscription-service\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { CopyToClipboardButton } from \"#/components/shared/buttons/copy-to-clipboard-button\";\nimport {\n  useLogoutOpenAISubscription,\n  usePollOpenAISubscriptionLogin,\n  useStartOpenAISubscriptionLogin,\n} from \"#/hooks/mutation/use-llm-subscription-auth\";\nimport { useOpenAISubscriptionStatus } from \"#/hooks/query/use-llm-subscription-status\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { Typography } from \"#/ui/typography\";\nimport {\n  displayErrorToast,\n  displaySuccessToast,\n} from \"#/utils/custom-toast-handlers\";\n\nconst DEFAULT_DEVICE_POLL_INTERVAL_SECONDS = 5;\nconst MIN_DEVICE_POLL_INTERVAL_SECONDS = 1;\n\ninterface OpenAISubscriptionAuthCardProps {\n  isDisabled?: boolean;\n}\n\nfunction openVerificationUrl(challenge: LLMSubscriptionDeviceChallenge) {\n  const url = challenge.verificationUriComplete ?? challenge.verificationUri;\n  window.open(url, \"_blank\", \"noopener,noreferrer\");\n}\n\nexport function OpenAISubscriptionAuthCard({\n  isDisabled = false,\n}: OpenAISubscriptionAuthCardProps) {\n  const { t } = useTranslation(\"openhands\");\n  const status = useOpenAISubscriptionStatus();\n  const startLogin = useStartOpenAISubscriptionLogin();\n  const pollLogin = usePollOpenAISubscriptionLogin();\n  const logout = useLogoutOpenAISubscription();\n  const [challenge, setChallenge] =\n    React.useState<LLMSubscriptionDeviceChallenge | null>(null);\n  const [copied, setCopied] = React.useState(false);\n  const [isPendingLogin, setIsPendingLogin] = React.useState(false);\n  const pollTimeoutRef = React.useRef<number | null>(null);\n\n  const clearPollTimeout = React.useCallback(() => {\n    if (pollTimeoutRef.current !== null) {\n      window.clearTimeout(pollTimeoutRef.current);\n      pollTimeoutRef.current = null;\n    }\n  }, []);\n\n  const handleCopyCode = () => {\n    if (!challenge) return;\n    navigator.clipboard.writeText(challenge.userCode);\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  };\n\n  const isBusy =\n    startLogin.isPending || pollLogin.isPending || logout.isPending;\n  const connected = Boolean(status.data?.connected);\n\n  const pollDeviceLogin = React.useCallback(\n    async (deviceCode: string) => {\n      try {\n        const nextStatus = await pollLogin.mutateAsync(deviceCode);\n        if (nextStatus.connected) {\n          setChallenge(null);\n          setIsPendingLogin(false);\n          displaySuccessToast(t(I18nKey.SETTINGS$SUBSCRIPTION_CONNECTED_TOAST));\n          return true;\n        }\n        setIsPendingLogin(true);\n        return false;\n      } catch {\n        displayErrorToast(t(I18nKey.SETTINGS$SUBSCRIPTION_CONNECT_ERROR));\n        return true;\n      }\n    },\n    [pollLogin, t],\n  );\n\n  React.useEffect(() => {\n    if (!challenge || connected || isDisabled) {\n      return undefined;\n    }\n\n    let cancelled = false;\n    const intervalSeconds = Math.max(\n      challenge.intervalSeconds ?? DEFAULT_DEVICE_POLL_INTERVAL_SECONDS,\n      MIN_DEVICE_POLL_INTERVAL_SECONDS,\n    );\n\n    const schedulePoll = () => {\n      clearPollTimeout();\n      pollTimeoutRef.current = window.setTimeout(async () => {\n        if (cancelled) {\n          return;\n        }\n\n        const shouldStop = await pollDeviceLogin(challenge.deviceCode);\n        if (!cancelled && !shouldStop) {\n          schedulePoll();\n        }\n      }, intervalSeconds * 1000);\n    };\n\n    schedulePoll();\n\n    return () => {\n      cancelled = true;\n      clearPollTimeout();\n    };\n  }, [challenge, clearPollTimeout, connected, isDisabled, pollDeviceLogin]);\n\n  const handleStartLogin = async () => {\n    try {\n      const nextChallenge = await startLogin.mutateAsync();\n      setChallenge(nextChallenge);\n      setIsPendingLogin(true);\n      openVerificationUrl(nextChallenge);\n    } catch {\n      displayErrorToast(t(I18nKey.SETTINGS$SUBSCRIPTION_CONNECT_ERROR));\n    }\n  };\n\n  const handlePollLogin = async () => {\n    if (!challenge) return;\n    clearPollTimeout();\n    void pollDeviceLogin(challenge.deviceCode);\n  };\n\n  const handleLogout = async () => {\n    try {\n      await logout.mutateAsync();\n      clearPollTimeout();\n      setChallenge(null);\n      setIsPendingLogin(false);\n      displaySuccessToast(t(I18nKey.SETTINGS$SUBSCRIPTION_DISCONNECTED_TOAST));\n    } catch {\n      displayErrorToast(t(I18nKey.ERROR$GENERIC));\n    }\n  };\n\n  const handleCancelLogin = () => {\n    clearPollTimeout();\n    setChallenge(null);\n    setIsPendingLogin(false);\n  };\n\n  return (\n    <section\n      data-testid=\"openai-subscription-auth-card\"\n      className=\"flex flex-col gap-4 rounded-xl border border-border bg-surface-raised p-4\"\n    >\n      <div className=\"flex flex-col gap-2\">\n        <Typography.H3>\n          {t(I18nKey.SETTINGS$SUBSCRIPTION_CARD_TITLE)}\n        </Typography.H3>\n        <Typography.Paragraph className=\"text-tertiary-alt text-sm leading-5\">\n          {t(I18nKey.SETTINGS$SUBSCRIPTION_CARD_DESCRIPTION)}\n        </Typography.Paragraph>\n      </div>\n\n      <div\n        className=\"flex flex-col gap-1 text-sm\"\n        data-testid=\"subscription-status\"\n      >\n        {status.isLoading ? (\n          <span className=\"text-tertiary-light\">\n            {t(I18nKey.SETTINGS$SUBSCRIPTION_STATUS_CHECKING)}\n          </span>\n        ) : status.isError ? (\n          <span className=\"text-danger\">\n            {t(I18nKey.SETTINGS$SUBSCRIPTION_STATUS_UNAVAILABLE)}\n          </span>\n        ) : connected ? (\n          <>\n            <span className=\"text-success\">\n              {t(I18nKey.SETTINGS$SUBSCRIPTION_STATUS_CONNECTED)}\n            </span>\n            {status.data?.accountEmail ? (\n              <span className=\"text-tertiary-light\">\n                {t(I18nKey.SETTINGS$SUBSCRIPTION_ACCOUNT, {\n                  account: status.data.accountEmail,\n                })}\n              </span>\n            ) : null}\n          </>\n        ) : (\n          <span className=\"text-warning\">\n            {t(I18nKey.SETTINGS$SUBSCRIPTION_STATUS_DISCONNECTED)}\n          </span>\n        )}\n      </div>\n\n      {challenge ? (\n        <div\n          data-testid=\"subscription-device-challenge\"\n          className=\"flex flex-col gap-2 rounded-lg border border-border-subtle p-3 text-sm\"\n        >\n          <span>{t(I18nKey.SETTINGS$SUBSCRIPTION_DEVICE_INSTRUCTIONS)}</span>\n          <div className=\"flex items-center gap-2 rounded-lg bg-surface-deep px-3 py-2 font-mono text-base font-semibold text-contrast\">\n            <span\n              data-testid=\"subscription-user-code\"\n              className=\"flex-1 select-all tracking-[0.08em]\"\n            >\n              {challenge.userCode}\n            </span>\n            <CopyToClipboardButton\n              isHidden={false}\n              isDisabled={false}\n              onClick={handleCopyCode}\n              mode={copied ? \"copied\" : \"copy\"}\n            />\n          </div>\n          <a\n            href={\n              challenge.verificationUriComplete ?? challenge.verificationUri\n            }\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            className=\"inline-flex items-center gap-1 text-sm text-accent underline\"\n          >\n            {t(I18nKey.SETTINGS$SUBSCRIPTION_OPEN_LOGIN)}\n            <ExternalLink size={14} aria-hidden />\n          </a>\n          {isPendingLogin ? (\n            <span className=\"text-warning\">\n              {t(I18nKey.SETTINGS$SUBSCRIPTION_PENDING_TOAST)}\n            </span>\n          ) : null}\n        </div>\n      ) : null}\n\n      <div className=\"flex flex-wrap gap-2\">\n        {connected ? (\n          <BrandButton\n            testId=\"subscription-disconnect\"\n            type=\"button\"\n            variant=\"tertiary\"\n            isDisabled={isDisabled || isBusy}\n            onClick={handleLogout}\n          >\n            {t(I18nKey.SETTINGS$SUBSCRIPTION_DISCONNECT)}\n          </BrandButton>\n        ) : (\n          <BrandButton\n            testId=\"subscription-connect\"\n            type=\"button\"\n            variant=\"primary\"\n            isDisabled={isDisabled || isBusy}\n            onClick={handleStartLogin}\n          >\n            {t(I18nKey.SETTINGS$SUBSCRIPTION_CONNECT)}\n          </BrandButton>\n        )}\n\n        {challenge ? (\n          <>\n            <BrandButton\n              testId=\"subscription-poll\"\n              type=\"button\"\n              variant=\"secondary\"\n              isDisabled={isDisabled || isBusy}\n              onClick={handlePollLogin}\n            >\n              {t(I18nKey.SETTINGS$SUBSCRIPTION_FINISH_SIGN_IN)}\n            </BrandButton>\n            <BrandButton\n              testId=\"subscription-cancel\"\n              type=\"button\"\n              variant=\"tertiary\"\n              isDisabled={isDisabled || isBusy}\n              onClick={handleCancelLogin}\n            >\n              {t(I18nKey.BUTTON$CANCEL)}\n            </BrandButton>\n          </>\n        ) : null}\n      </div>\n    </section>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 292,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 292,
          "endColumn": 51
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 327,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 327,
          "endColumn": 55
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 365,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 365,
          "endColumn": 55
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { HttpError } from \"@openhands/typescript-client\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { ApiKeyModalBase } from \"#/components/features/settings/api-key-modal-base\";\nimport { LoadingSpinner } from \"#/components/shared/loading-spinner\";\nimport { useMetaProfiles } from \"#/hooks/query/use-meta-profiles\";\nimport { useLlmProfiles } from \"#/hooks/query/use-llm-profiles\";\nimport { useProviderConnections } from \"#/hooks/query/use-provider-connections\";\nimport { useSaveMetaProfile } from \"#/hooks/mutation/use-save-meta-profile\";\nimport { useSaveLlmProfile } from \"#/hooks/mutation/use-save-llm-profile\";\nimport { useActivateMetaProfile } from \"#/hooks/mutation/use-activate-meta-profile\";\nimport { agentProfileSupportsRouter } from \"#/api/agent-profiles-service/profile-field-support\";\nimport { useSettings } from \"#/hooks/query/use-settings\";\nimport { useSaveSettings } from \"#/hooks/mutation/use-save-settings\";\nimport { SettingsSwitch } from \"#/components/features/settings/settings-switch\";\nimport MetaProfilesService, {\n  type MetaProfile,\n} from \"#/api/meta-profiles-service/meta-profiles-service.api\";\nimport type { SaveProfileRequest } from \"#/api/profiles-service/profiles-service.api\";\nimport type { ProviderConnection } from \"#/api/provider-connections-service/provider-connections-service.api\";\nimport {\n  displayErrorToast,\n  displaySuccessToast,\n} from \"#/utils/custom-toast-handlers\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { MetaProfileEditor } from \"./meta-profile-editor\";\nimport { MetaProfileRow } from \"./meta-profile-row\";\nimport { DeleteMetaProfileModal } from \"./delete-meta-profile-modal\";\nimport {\n  DEFAULT_ROUTER_PRO_META_PROFILE_DEFAULT,\n  DEFAULT_ROUTER_PRO_META_PROFILE_NAME,\n  DEFAULT_ROUTER_FLASH_META_PROFILE_DEFAULT,\n  DEFAULT_ROUTER_FLASH_META_PROFILE_NAME,\n} from \"./default-meta-profile\";\nimport {\n  buildRouterModel,\n  collectRequiredRouterModelNames,\n} from \"./router-profiles\";\n\ntype ViewMode = \"list\" | \"create\" | \"edit\";\ntype RouterTemplate = \"router-pro\" | \"router-flash\" | \"custom\";\n\ninterface EditingMetaProfile {\n  name: string;\n  config: MetaProfile;\n}\n\nconst CUSTOM_META_PROFILE_CONFIG: MetaProfile = {\n  classifier_model: \"\",\n  classes: [],\n  prompt_template: \"\",\n  model_table: \"\",\n};\n\nexport function MetaLlmSettingsView() {\n  const { t } = useTranslation(\"openhands\");\n  const { data, isLoading, error } = useMetaProfiles();\n  const { data: llmProfilesData } = useLlmProfiles();\n  const { data: providerConnections } = useProviderConnections();\n  const saveMetaProfile = useSaveMetaProfile();\n  const saveLlmProfile = useSaveLlmProfile();\n  const activateMetaProfile = useActivateMetaProfile();\n  const { data: settings } = useSettings();\n  const { mutate: saveSettings, mutateAsync: saveSettingsAsync } =\n    useSaveSettings();\n\n  const [view, setView] = useState<ViewMode>(\"list\");\n  const [editing, setEditing] = useState<EditingMetaProfile | null>(null);\n  const [createInitial, setCreateInitial] = useState<EditingMetaProfile | null>(\n    null,\n  );\n  // Whether the create editor should pre-select a provider connection to\n  // populate the router's LLM profiles (true for the built-in router templates,\n  // false for a blank custom profile).\n  const [createRouterProfilesByDefault, setCreateRouterProfilesByDefault] =\n    useState(true);\n  const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);\n  const [nameToDelete, setNameToDelete] = useState<string | null>(null);\n  const [isCreatingRouterProfiles, setIsCreatingRouterProfiles] =\n    useState(false);\n  const [createdProviderConnections, setCreatedProviderConnections] = useState<\n    ProviderConnection[]\n  >([]);\n\n  const metaProfiles = data?.meta_profiles ?? [];\n  // Locally each agent profile picks its router, so none is active here.\n  const routerPerProfile = agentProfileSupportsRouter();\n  const active = routerPerProfile ? null : (data?.active_meta_profile ?? null);\n  const availableProfiles = (llmProfilesData?.profiles ?? []).map(\n    (p) => p.name,\n  );\n  const connections = useMemo(() => {\n    const byId = new Map<string, ProviderConnection>();\n    for (const connection of providerConnections ?? []) {\n      byId.set(connection.id, connection);\n    }\n    for (const connection of createdProviderConnections) {\n      byId.set(connection.id, connection);\n    }\n    return [...byId.values()];\n  }, [providerConnections, createdProviderConnections]);\n  const existingNames = metaProfiles.map((p) => p.name);\n  // A 404 means the backend predates the /api/meta-profiles endpoints\n  // (software-agent-sdk #3744). Surface that explicitly instead of a generic\n  // error so the page isn't a dead end on older backends.\n  const isUnsupportedBackend =\n    error instanceof HttpError && error.status === 404;\n\n  const handleActivate = async (name: string) => {\n    try {\n      await activateMetaProfile.mutateAsync(name);\n      displaySuccessToast(t(I18nKey.SETTINGS$META_PROFILE_ACTIVATED, { name }));\n    } catch (activateError) {\n      const message =\n        activateError instanceof Error\n          ? activateError.message\n          : t(I18nKey.ERROR$GENERIC);\n      displayErrorToast(message);\n    }\n  };\n\n  const handleEdit = async (name: string) => {\n    try {\n      const detail = await MetaProfilesService.getMetaProfile(name);\n      setEditing({ name: detail.name, config: detail.config });\n      setView(\"edit\");\n    } catch (loadError) {\n      const message =\n        loadError instanceof Error\n          ? loadError.message\n          : t(I18nKey.ERROR$GENERIC);\n      displayErrorToast(message);\n    }\n  };\n\n  const handleChooseTemplate = (template: RouterTemplate) => {\n    if (template === \"router-pro\") {\n      setCreateInitial({\n        name: DEFAULT_ROUTER_PRO_META_PROFILE_NAME,\n        config: DEFAULT_ROUTER_PRO_META_PROFILE_DEFAULT,\n      });\n      setCreateRouterProfilesByDefault(true);\n    } else if (template === \"router-flash\") {\n      setCreateInitial({\n        name: DEFAULT_ROUTER_FLASH_META_PROFILE_NAME,\n        config: DEFAULT_ROUTER_FLASH_META_PROFILE_DEFAULT,\n      });\n      setCreateRouterProfilesByDefault(true);\n    } else {\n      setCreateInitial({ name: \"\", config: CUSTOM_META_PROFILE_CONFIG });\n      setCreateRouterProfilesByDefault(false);\n    }\n\n    setEditing(null);\n    setIsTemplateModalOpen(false);\n    setView(\"create\");\n  };\n\n  // Create an LLM profile for every model the router config needs that does not\n  // already exist, linking the chosen provider connection for credentials. The\n  // endpoint is derived by convention from the connection's provider\n  // (``<provider>/<name>``); the profile name stays the bare model name so the\n  // router can match the classifier's answer against it.\n  const createMissingRouterLlmProfiles = async (\n    config: MetaProfile,\n    providerConnectionId: string,\n  ) => {\n    const connection = connections.find((c) => c.id === providerConnectionId);\n    if (!connection) {\n      throw new Error(\"The selected provider connection no longer exists.\");\n    }\n\n    const existingProfileNames = new Set(\n      availableProfiles.map((profileName) => profileName.toLowerCase()),\n    );\n    const missingNames = collectRequiredRouterModelNames(config).filter(\n      (modelName) => !existingProfileNames.has(modelName.toLowerCase()),\n    );\n    if (missingNames.length === 0) return;\n\n    for (const modelName of missingNames) {\n      await saveLlmProfile.mutateAsync({\n        name: modelName,\n        request: {\n          llm: {\n            model: buildRouterModel(connection.provider, modelName),\n            usage_id: modelName,\n            provider_connection_id: connection.id,\n          } as SaveProfileRequest[\"llm\"],\n          include_secrets: true,\n        },\n      });\n    }\n  };\n\n  const handleProviderConnectionCreated = (connection: ProviderConnection) => {\n    setCreatedProviderConnections((existing) => [\n      connection,\n      ...existing.filter((item) => item.id !== connection.id),\n    ]);\n  };\n\n  const handleSave = async (\n    name: string,\n    config: MetaProfile,\n    providerConnectionId: string | null,\n  ) => {\n    const shouldActivateAfterCreate =\n      view === \"create\" && active === null && !routerPerProfile;\n    // Creating the first router (0 \u2192 1) is the moment \"Run on first message\"\n    // becomes useful, so flip it on by default \u2014 but only if the user hasn't\n    // already enabled it. Subsequent router creations leave the preference\n    // untouched.\n    const isFirstRouter = view === \"create\" && metaProfiles.length === 0;\n    let autoEnableFailed = false;\n    try {\n      if (view === \"create\" && providerConnectionId) {\n        setIsCreatingRouterProfiles(true);\n        await createMissingRouterLlmProfiles(config, providerConnectionId);\n      }\n      await saveMetaProfile.mutateAsync({ name, config });\n      if (isFirstRouter && !settings?.run_router_at_conversation_start) {\n        // Await the preference write so we observe its outcome: on success\n        // `useSaveSettings`'s onSuccess invalidates the settings cache and the\n        // switch flips on (matching the server); on failure we surface an\n        // error but keep the router creation intact, and the switch stays off\n        // to match the unchanged server value. This must not abort the primary\n        // create/activate flow, so it has its own try/catch. The success toast\n        // is suppressed on this path so the user isn't shown both a success\n        // and an error for one action.\n        try {\n          await saveSettingsAsync({ run_router_at_conversation_start: true });\n        } catch {\n          autoEnableFailed = true;\n          displayErrorToast(t(I18nKey.ERROR$GENERIC));\n        }\n      }\n      if (shouldActivateAfterCreate) {\n        await activateMetaProfile.mutateAsync(name);\n      }\n      if (!autoEnableFailed) {\n        displaySuccessToast(t(I18nKey.SETTINGS$META_PROFILE_SAVED, { name }));\n      }\n      setView(\"list\");\n      setEditing(null);\n      setCreateInitial(null);\n    } catch (saveError) {\n      const message =\n        saveError instanceof Error\n          ? saveError.message\n          : t(I18nKey.ERROR$GENERIC);\n      displayErrorToast(message);\n    } finally {\n      setIsCreatingRouterProfiles(false);\n    }\n  };\n\n  const handleCancel = () => {\n    setView(\"list\");\n    setEditing(null);\n    setCreateInitial(null);\n  };\n\n  const runRouterAtConversationStart =\n    !!settings?.run_router_at_conversation_start;\n  // The toggle only does something when a router is actually active: with no\n  // active meta-profile the `route_task_to_model` tool is not attached, so\n  // routing the first message would be a no-op. Disable it then, and render\n  // it off, so the switch reflects what the agent will actually do. We do NOT\n  // auto-clear the persisted preference here: ``useMetaProfiles`` has no\n  // ``initialData``/``placeholderData``, so ``active`` is ``null`` on every\n  // mount until the fetch resolves, and a load-time effect would fire\n  // ``saveSettings({ run_router_at_conversation_start: false })`` in that\n  // window \u2014 silently destroying a user's saved preference before the\n  // meta-profiles response can prove a router is active. Instead the launch\n  // paths gate on the active meta-profile at the single suffix-emission\n  // point (``buildRouterAtStartSystemSuffix``), so a stale ``true`` with no\n  // router can never emit the ``route_task_to_model`` instruction. Locally\n  // that gate is the launched profile's router, so any router enables it.\n  const canRunRouterAtStart = routerPerProfile\n    ? metaProfiles.length > 0\n    : active !== null;\n  const handleToggleRunAtConversationStart = (value: boolean) => {\n    saveSettings({ run_router_at_conversation_start: value });\n  };\n\n  if (isUnsupportedBackend) {\n    return (\n      <p\n        data-testid=\"meta-profile-unsupported\"\n        className=\"text-sm text-[var(--oh-muted)]\"\n      >\n        {t(I18nKey.SETTINGS$META_PROFILE_UNSUPPORTED)}\n      </p>\n    );\n  }\n\n  if (view === \"create\" || view === \"edit\") {\n    return (\n      <MetaProfileEditor\n        mode={view === \"edit\" ? \"edit\" : \"create\"}\n        initialName={view === \"edit\" ? editing?.name : createInitial?.name}\n        initialConfig={\n          view === \"edit\" ? editing?.config : createInitial?.config\n        }\n        providerConnections={connections}\n        selectRouterConnectionByDefault={\n          view === \"create\" ? createRouterProfilesByDefault : false\n        }\n        availableProfiles={availableProfiles}\n        existingNames={existingNames}\n        isSaving={saveMetaProfile.isPending || isCreatingRouterProfiles}\n        onSave={handleSave}\n        onCancel={handleCancel}\n        onProviderConnectionCreated={handleProviderConnectionCreated}\n      />\n    );\n  }\n\n  return (\n    <>\n      <div className=\"flex flex-col gap-4\">\n        {availableProfiles.length === 0 ? (\n          <p\n            data-testid=\"meta-profile-no-llm-profiles\"\n            className=\"text-sm text-[var(--oh-muted)]\"\n          >\n            {t(I18nKey.SETTINGS$META_PROFILE_NO_LLM_PROFILES)}\n          </p>\n        ) : null}\n\n        <div className=\"flex flex-wrap items-center justify-between gap-3\">\n          <h2 className=\"text-base font-medium text-contrast\">\n            {t(I18nKey.SETTINGS$META_PROFILES_AVAILABLE)}\n          </h2>\n          <BrandButton\n            testId=\"add-meta-profile\"\n            type=\"button\"\n            variant=\"secondary\"\n            className=\"ml-auto\"\n            onClick={() => {\n              setIsTemplateModalOpen(true);\n            }}\n          >\n            {t(I18nKey.SETTINGS$ADD_META_PROFILE)}\n          </BrandButton>\n        </div>\n\n        {isLoading ? (\n          <div className=\"flex justify-center py-6\">\n            <LoadingSpinner size=\"small\" />\n          </div>\n        ) : null}\n\n        {error ? (\n          <p className=\"text-sm text-feedback-error\">\n            {t(I18nKey.ERROR$GENERIC)}\n          </p>\n        ) : null}\n\n        {!isLoading && !error && metaProfiles.length === 0 ? (\n          <p\n            data-testid=\"meta-profile-empty\"\n            className=\"text-sm text-[var(--oh-muted)]\"\n          >\n            {t(I18nKey.SETTINGS$META_PROFILE_NO_PROFILES)}\n          </p>\n        ) : null}\n\n        {metaProfiles.length > 0 ? (\n          <div className=\"flex flex-col gap-2\" data-testid=\"meta-profile-list\">\n            {metaProfiles.map((info) => (\n              <MetaProfileRow\n                key={info.name}\n                info={info}\n                isActive={info.name === active}\n                onActivate={routerPerProfile ? undefined : handleActivate}\n                onEdit={handleEdit}\n                onDelete={setNameToDelete}\n                isActivating={activateMetaProfile.isPending}\n              />\n            ))}\n          </div>\n        ) : null}\n\n        <div\n          className=\"flex flex-col gap-1 border-t border-border pt-4\"\n          data-testid=\"meta-profile-run-at-conversation-start\"\n        >\n          <SettingsSwitch\n            testId=\"meta-profile-run-at-conversation-start-switch\"\n            isToggled={\n              canRunRouterAtStart ? runRouterAtConversationStart : false\n            }\n            isDisabled={!canRunRouterAtStart}\n            onToggle={handleToggleRunAtConversationStart}\n          >\n            {t(I18nKey.SETTINGS$META_PROFILE_RUN_AT_CONVERSATION_START)}\n          </SettingsSwitch>\n          <p className=\"text-xs leading-4 text-tertiary-light\">\n            {t(\n              routerPerProfile\n                ? I18nKey.SETTINGS$META_PROFILE_RUN_AT_CONVERSATION_START_HELP_PROFILE\n                : I18nKey.SETTINGS$META_PROFILE_RUN_AT_CONVERSATION_START_HELP,\n            )}\n          </p>\n        </div>\n      </div>\n\n      <DeleteMetaProfileModal\n        name={nameToDelete}\n        onClose={() => setNameToDelete(null)}\n      />\n      <ApiKeyModalBase\n        isOpen={isTemplateModalOpen}\n        title={t(I18nKey.SETTINGS$META_PROFILE_TEMPLATE_TITLE)}\n        width=\"md\"\n        onClose={() => setIsTemplateModalOpen(false)}\n        footer={\n          <BrandButton\n            testId=\"meta-profile-template-cancel\"\n            type=\"button\"\n            variant=\"tertiary\"\n            onClick={() => setIsTemplateModalOpen(false)}\n          >\n            {t(I18nKey.BUTTON$CANCEL)}\n          </BrandButton>\n        }\n      >\n        <div\n          data-testid=\"meta-profile-template-modal\"\n          className=\"flex flex-col gap-3\"\n        >\n          <BrandButton\n            testId=\"meta-profile-template-router-pro\"\n            type=\"button\"\n            variant=\"secondary\"\n            className=\"justify-start\"\n            onClick={() => handleChooseTemplate(\"router-pro\")}\n          >\n            {t(I18nKey.SETTINGS$META_PROFILE_TEMPLATE_ROUTER_PRO)}\n          </BrandButton>\n          <BrandButton\n            testId=\"meta-profile-template-router-flash\"\n            type=\"button\"\n            variant=\"secondary\"\n            className=\"justify-start\"\n            onClick={() => handleChooseTemplate(\"router-flash\")}\n          >\n            {t(I18nKey.SETTINGS$META_PROFILE_TEMPLATE_ROUTER_FLASH)}\n          </BrandButton>\n          <BrandButton\n            testId=\"meta-profile-template-custom\"\n            type=\"button\"\n            variant=\"secondary\"\n            className=\"justify-start\"\n            onClick={() => handleChooseTemplate(\"custom\")}\n          >\n            {t(I18nKey.SETTINGS$META_PROFILE_TEMPLATE_CUSTOM)}\n          </BrandButton>\n        </div>\n      </ApiKeyModalBase>\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/meta-llm-profiles/meta-profile-actions-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-foreground)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 51,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 51,
          "endColumn": 77
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"hover:bg-[var(--oh-interactive-hover)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 51,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 51,
          "endColumn": 77
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[160px]\" hardcodes an off-token value. Use \"w-40\" instead (same value, on the scale).",
          "line": 179,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 179,
          "endColumn": 139,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "w-40"
              },
              "fix": {
                "range": [
                  5063,
                  5193
                ],
                "text": "\"absolute right-0 top-full z-10 mt-2 w-40 rounded-md border border-[var(--oh-border-subtle)] bg-tertiary px-1 py-1 shadow-lg\""
              },
              "desc": "Replace with \"w-40\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border-subtle)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 179,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 179,
          "endColumn": 139
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 227,
          "column": 19,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 227,
          "endColumn": 30
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "no-param-reassign",
          "severity": 2,
          "message": "Assignment to property of function parameter 'menuItemsRef'.",
          "line": 43,
          "column": 9,
          "nodeType": "Identifier",
          "messageId": "assignmentToFunctionParamProp",
          "endLine": 43,
          "endColumn": 21,
          "suppressions": [
            {
              "kind": "directive",
              "justification": ""
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 5,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useCallback,\n  useState,\n} from \"react\";\nimport ReactDOM from \"react-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport { cn } from \"#/utils/utils\";\nimport { dropdownMenuListClassName } from \"#/utils/dropdown-classes\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { ConversationNameContextMenuIconText } from \"#/components/features/conversation/conversation-name-context-menu-icon-text\";\nimport EditIcon from \"#/icons/u-edit.svg?react\";\nimport CheckCircleIcon from \"#/icons/u-check-circle.svg?react\";\nimport DeleteIcon from \"#/icons/u-delete.svg?react\";\n\ninterface MenuItemProps {\n  index: number;\n  icon: React.ReactNode;\n  label: string;\n  onClick: () => void;\n  onKeyDown: (e: React.KeyboardEvent, index: number) => void;\n  menuItemsRef: React.MutableRefObject<(HTMLButtonElement | null)[]>;\n  disabled?: boolean;\n  testId: string;\n}\n\nfunction MenuItem({\n  index,\n  icon,\n  label,\n  onClick,\n  onKeyDown,\n  menuItemsRef,\n  disabled,\n  testId,\n}: MenuItemProps) {\n  return (\n    <button\n      ref={(el) => {\n        // eslint-disable-next-line no-param-reassign\n        menuItemsRef.current[index] = el;\n      }}\n      type=\"button\"\n      onClick={onClick}\n      onKeyDown={(e) => onKeyDown(e, index)}\n      disabled={disabled}\n      className={cn(\n        \"group w-full cursor-pointer rounded px-2 py-2 text-start text-nowrap text-sm font-normal\",\n        \"text-[var(--oh-foreground)] hover:bg-[var(--oh-interactive-hover)]\",\n        \"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent\",\n      )}\n      role=\"menuitem\"\n      data-testid={testId}\n    >\n      <ConversationNameContextMenuIconText icon={icon} text={label} />\n    </button>\n  );\n}\n\ninterface MetaProfileActionsMenuProps {\n  onEdit: () => void;\n  onSetActive?: () => void;\n  onDelete: () => void;\n  isActive: boolean;\n  isActivating: boolean;\n  onClose: () => void;\n  /**\n   * Element the menu should anchor against. When provided, the menu renders\n   * into a portal at the document body using fixed positioning so it cannot be\n   * clipped by ancestors with `overflow: auto/hidden` (e.g. the settings\n   * `<main>` scroll container).\n   */\n  anchorRef?: React.RefObject<HTMLElement | null>;\n}\n\nexport function MetaProfileActionsMenu({\n  onEdit,\n  onSetActive,\n  onDelete,\n  isActive,\n  isActivating,\n  onClose,\n  anchorRef,\n}: MetaProfileActionsMenuProps) {\n  const { t } = useTranslation(\"openhands\");\n  const menuRef = useRef<HTMLDivElement>(null);\n  const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([]);\n\n  const anchorElement = anchorRef?.current ?? null;\n  const [portalStyle, setPortalStyle] = useState<React.CSSProperties>();\n\n  useLayoutEffect(() => {\n    if (!anchorElement) return undefined;\n\n    const updatePosition = () => {\n      const rect = anchorElement.getBoundingClientRect();\n      if (!rect) return;\n      const gap = 8;\n      setPortalStyle({\n        position: \"fixed\",\n        zIndex: 9999,\n        top: rect.bottom + gap,\n        right: window.innerWidth - rect.right,\n        width: \"max-content\",\n      });\n    };\n\n    updatePosition();\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [anchorElement]);\n\n  // Focus first item when menu opens\n  useEffect(() => {\n    menuItemsRef.current[0]?.focus();\n  }, []);\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      const target = event.target as Node;\n      if (menuRef.current && !menuRef.current.contains(target)) {\n        onClose();\n      }\n    };\n\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        onClose();\n      }\n    };\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    document.addEventListener(\"keydown\", handleEscape);\n\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n      document.removeEventListener(\"keydown\", handleEscape);\n    };\n  }, [onClose]);\n\n  const handleAction = (action: () => void) => {\n    action();\n    onClose();\n  };\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent, currentIndex: number) => {\n      if (e.key === \"Tab\") {\n        onClose();\n        return;\n      }\n      const itemCount = menuItemsRef.current.filter(Boolean).length;\n      if (e.key === \"ArrowDown\") {\n        e.preventDefault();\n        const nextIndex = (currentIndex + 1) % itemCount;\n        menuItemsRef.current[nextIndex]?.focus();\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        const prevIndex = (currentIndex - 1 + itemCount) % itemCount;\n        menuItemsRef.current[prevIndex]?.focus();\n      }\n    },\n    [onClose],\n  );\n\n  const setActiveDisabled = isActive || isActivating;\n  const isPortaled = Boolean(anchorElement);\n\n  const menu = (\n    <div\n      ref={menuRef}\n      className={cn(\n        \"absolute right-0 top-full z-10 mt-2 w-[160px] rounded-md border border-[var(--oh-border-subtle)] bg-tertiary px-1 py-1 shadow-lg\",\n        dropdownMenuListClassName,\n        isPortaled &&\n          \"!static !top-auto !bottom-auto !left-auto !right-auto !mt-0\",\n      )}\n      role=\"menu\"\n      aria-orientation=\"vertical\"\n      data-testid=\"meta-profile-actions-menu\"\n    >\n      <MenuItem\n        index={0}\n        icon={<EditIcon width={16} height={16} />}\n        label={t(I18nKey.BUTTON$EDIT)}\n        onClick={() => handleAction(onEdit)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        testId=\"meta-profile-edit\"\n      />\n      {onSetActive ? (\n        <MenuItem\n          index={1}\n          icon={<CheckCircleIcon width={16} height={16} />}\n          label={t(I18nKey.SETTINGS$META_PROFILE_ACTIVATE)}\n          onClick={() => handleAction(onSetActive)}\n          onKeyDown={handleKeyDown}\n          menuItemsRef={menuItemsRef}\n          disabled={setActiveDisabled}\n          testId=\"meta-profile-set-active\"\n        />\n      ) : null}\n      <MenuItem\n        index={onSetActive ? 2 : 1}\n        icon={<DeleteIcon width={16} height={16} />}\n        label={t(I18nKey.BUTTON$DELETE)}\n        onClick={() => handleAction(onDelete)}\n        onKeyDown={handleKeyDown}\n        menuItemsRef={menuItemsRef}\n        testId=\"meta-profile-delete\"\n      />\n    </div>\n  );\n\n  if (isPortaled) {\n    if (typeof document === \"undefined\" || !portalStyle) {\n      return null;\n    }\n    return ReactDOM.createPortal(\n      // portal position computed from DOM bounding rect at runtime\n      <div style={portalStyle}>{menu}</div>,\n      document.body,\n    );\n  }\n\n  return menu;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/meta-llm-profiles/meta-profile-editor.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 243,
          "column": 22,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 243,
          "endColumn": 54
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 272,
          "column": 22,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 272,
          "endColumn": 54
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 306,
          "column": 22,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 306,
          "endColumn": 54
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 329,
          "column": 26,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 329,
          "endColumn": 58
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useMemo, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { SettingsDropdownInput } from \"#/components/features/settings/settings-dropdown-input\";\nimport { ProfileNameInput } from \"#/components/features/settings/llm-profiles/profile-name-input\";\nimport { ProviderConnectionModal } from \"#/components/features/settings/llm-profiles/provider-connection-modal\";\nimport { Typography } from \"#/ui/typography\";\nimport { isProfileNameValid } from \"#/utils/derive-profile-name\";\nimport { cn } from \"#/utils/utils\";\nimport { formControlMultilineFieldClassName } from \"#/utils/form-control-classes\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type { MetaProfile } from \"#/api/meta-profiles-service/meta-profiles-service.api\";\nimport type { ProviderConnection } from \"#/api/provider-connections-service/provider-connections-service.api\";\nimport {\n  DEFAULT_ROUTER_PRO_META_PROFILE_DEFAULT,\n  DEFAULT_ROUTER_PRO_META_PROFILE_NAME,\n} from \"./default-meta-profile\";\n\n// Dropdown key that means \"don't create any router profiles\" (i.e. all the\n// referenced LLM profiles already exist or the user will create them manually).\nconst NO_ROUTER_CONNECTION_KEY = \"\";\n\ninterface MetaProfileEditorProps {\n  mode: \"create\" | \"edit\";\n  initialName?: string;\n  initialConfig?: MetaProfile;\n  /**\n   * When true (create mode, built-in templates), the first provider connection\n   * is pre-selected so the router's LLM profiles are created on save.\n   */\n  selectRouterConnectionByDefault?: boolean;\n  /**\n   * Provider connections offered for populating the router's LLM profiles.\n   * Empty on cloud / older backends, where the picker is hidden.\n   */\n  providerConnections?: ProviderConnection[];\n  /** Names of saved LLM profiles, offered as dropdown options. */\n  availableProfiles: string[];\n  /**\n   * Names of existing meta-profiles. In create mode a name already present\n   * here is rejected, so \"Add\" cannot silently overwrite an existing profile\n   * (the backend save contract is create-or-overwrite).\n   */\n  existingNames?: string[];\n  isSaving: boolean;\n  onSave: (\n    name: string,\n    config: MetaProfile,\n    providerConnectionId: string | null,\n  ) => void;\n  onCancel: () => void;\n  onProviderConnectionCreated?: (connection: ProviderConnection) => void;\n}\n\nconst EMPTY_CONFIG: MetaProfile = {\n  classifier_model: \"\",\n  prompt_template: \"\",\n  model_table: \"\",\n};\n\nconst INSTANCE_TEXT_PLACEHOLDER = /{{\\s*instance_text\\s*}}/;\nconst INSTANCE_TEXT_PLACEHOLDER_TEXT = \"{{ instance_text }}\";\nconst MODEL_TABLE_PLACEHOLDER_TEXT = \"{{ model_table }}\";\nconst PROMPT_TEMPLATE_PLACEHOLDER = `Return JSON with the best model for this task.\n${MODEL_TABLE_PLACEHOLDER_TEXT}\n\nTask:\n${INSTANCE_TEXT_PLACEHOLDER_TEXT}`;\n\nconst normalizeConfig = (config?: MetaProfile): MetaProfile => ({\n  classifier_model: config?.classifier_model ?? \"\",\n  classes: [],\n  prompt_template: config?.prompt_template ?? \"\",\n  model_table: config?.model_table ?? \"\",\n});\n\nexport function MetaProfileEditor({\n  mode,\n  initialName,\n  initialConfig,\n  selectRouterConnectionByDefault,\n  providerConnections = [],\n  availableProfiles,\n  existingNames = [],\n  isSaving,\n  onSave,\n  onCancel,\n  onProviderConnectionCreated,\n}: MetaProfileEditorProps) {\n  const { t } = useTranslation(\"openhands\");\n  const isEdit = mode === \"edit\";\n  const startingConfig = normalizeConfig(\n    initialConfig ??\n      (isEdit ? EMPTY_CONFIG : DEFAULT_ROUTER_PRO_META_PROFILE_DEFAULT),\n  );\n  const [name, setName] = useState(\n    initialName ?? (isEdit ? \"\" : DEFAULT_ROUTER_PRO_META_PROFILE_NAME),\n  );\n  const [config, setConfig] = useState<MetaProfile>(() => startingConfig);\n  const [createdProviderConnections, setCreatedProviderConnections] = useState<\n    ProviderConnection[]\n  >([]);\n  const [isProviderConnectionModalOpen, setIsProviderConnectionModalOpen] =\n    useState(false);\n  const connectionOptions = useMemo(() => {\n    const byId = new Map<string, ProviderConnection>();\n    for (const connection of providerConnections)\n      byId.set(connection.id, connection);\n    for (const connection of createdProviderConnections) {\n      byId.set(connection.id, connection);\n    }\n    return [...byId.values()];\n  }, [providerConnections, createdProviderConnections]);\n  const showRouterConnectionPicker = !isEdit;\n  // `null` until the user picks a connection. \"Don't create profiles\" is the\n  // empty key, so the template default must not be stored in the same state\n  // or it would overwrite that choice.\n  const [chosenRouterConnectionId, setChosenRouterConnectionId] = useState<\n    string | null\n  >(null);\n  const routerConnectionId =\n    chosenRouterConnectionId ??\n    (selectRouterConnectionByDefault && connectionOptions.length > 0\n      ? connectionOptions[0].id\n      : NO_ROUTER_CONNECTION_KEY);\n\n  // The classifier dropdown is driven by saved LLM profile names, but a router\n  // template can name a classifier (e.g. ``minimax-m3``) before any profiles\n  // exist. Without the value in the list, the Autocomplete's\n  // ``defaultSelectedKey`` finds no match and renders an empty input \u2014 which\n  // ``allowsCustomValue`` then syncs back as ``\"\"``, dropping the classifier\n  // from the saved config (so it never gets created). Surface the chosen\n  // classifier as an item so it is genuinely preselected.\n  const profileItems = useMemo(() => {\n    const items = availableProfiles.map((p) => ({ key: p, label: p }));\n    const classifier = config.classifier_model.trim();\n    if (\n      classifier &&\n      !availableProfiles.some(\n        (p) => p.toLowerCase() === classifier.toLowerCase(),\n      )\n    ) {\n      items.push({ key: classifier, label: classifier });\n    }\n    return items;\n  }, [availableProfiles, config.classifier_model]);\n  const routerConnectionItems = useMemo(\n    () => [\n      {\n        key: NO_ROUTER_CONNECTION_KEY,\n        label: t(I18nKey.SETTINGS$META_PROFILE_ROUTER_CONNECTION_NONE),\n      },\n      ...connectionOptions.map((connection) => ({\n        key: connection.id,\n        label: `${connection.display_name} (${connection.provider})`,\n      })),\n    ],\n    [connectionOptions, t],\n  );\n\n  const nameValid = isProfileNameValid(name, { isRequired: true });\n  // In create mode, a name that already exists would overwrite that profile\n  // (the backend save is create-or-overwrite), so reject it here.\n  const isDuplicateName = !isEdit && existingNames.includes(name.trim());\n  const canSave =\n    nameValid &&\n    !isDuplicateName &&\n    config.classifier_model.trim().length > 0 &&\n    INSTANCE_TEXT_PLACEHOLDER.test(config.prompt_template ?? \"\");\n\n  const handleProviderConnectionSaved = (connection: ProviderConnection) => {\n    setCreatedProviderConnections((existing) => [\n      connection,\n      ...existing.filter((item) => item.id !== connection.id),\n    ]);\n    onProviderConnectionCreated?.(connection);\n    setChosenRouterConnectionId(connection.id);\n  };\n\n  const handleSave = () => {\n    if (!canSave || isSaving) return;\n    onSave(\n      name.trim(),\n      {\n        classifier_model: config.classifier_model.trim(),\n        classes: [],\n        prompt_template: (config.prompt_template ?? \"\").trim(),\n        model_table: config.model_table?.trim() || null,\n      },\n      showRouterConnectionPicker && routerConnectionId\n        ? routerConnectionId\n        : null,\n    );\n  };\n\n  return (\n    <div className=\"flex flex-col gap-6\" data-testid=\"meta-profile-editor\">\n      <Typography.H3>\n        {t(\n          isEdit\n            ? I18nKey.SETTINGS$EDIT_META_PROFILE\n            : I18nKey.SETTINGS$NEW_META_PROFILE,\n        )}\n      </Typography.H3>\n\n      <div className=\"flex flex-col gap-1\">\n        <ProfileNameInput\n          testId=\"meta-profile-name-input\"\n          value={name}\n          onChange={setName}\n          isDisabled={isEdit || isSaving}\n          isRequired\n        />\n        {isDuplicateName ? (\n          <p\n            data-testid=\"meta-profile-name-taken\"\n            className=\"text-xs text-feedback-error\"\n          >\n            {t(I18nKey.SETTINGS$META_PROFILE_NAME_TAKEN)}\n          </p>\n        ) : null}\n      </div>\n\n      <div className=\"flex flex-col gap-2\">\n        <SettingsDropdownInput\n          testId=\"meta-profile-classifier-input\"\n          name=\"classifier_model\"\n          label={t(I18nKey.SETTINGS$META_PROFILE_CLASSIFIER)}\n          items={profileItems}\n          selectedKey={config.classifier_model || undefined}\n          allowsCustomValue\n          isDisabled={isSaving}\n          onInputChange={(value) =>\n            setConfig((prev) => ({ ...prev, classifier_model: value }))\n          }\n          onSelectionChange={(key) =>\n            setConfig((prev) => ({\n              ...prev,\n              classifier_model: key ? String(key) : \"\",\n            }))\n          }\n        />\n        <p className=\"text-xs text-[var(--oh-muted)]\">\n          {t(I18nKey.SETTINGS$META_PROFILE_CLASSIFIER_HELP)}\n        </p>\n      </div>\n\n      <div className=\"flex flex-col gap-2\">\n        <label className=\"flex flex-col gap-2.5\">\n          <span className=\"text-sm\">\n            {t(I18nKey.SETTINGS$META_PROFILE_PROMPT_TEMPLATE)}\n          </span>\n          <textarea\n            data-testid=\"meta-profile-prompt-template\"\n            rows={8}\n            spellCheck={false}\n            value={config.prompt_template ?? \"\"}\n            placeholder={PROMPT_TEMPLATE_PLACEHOLDER}\n            onChange={(event) =>\n              setConfig((prev) => ({\n                ...prev,\n                prompt_template: event.target.value,\n              }))\n            }\n            disabled={isSaving}\n            className={cn(\n              formControlMultilineFieldClassName,\n              \"font-mono text-xs\",\n            )}\n          />\n        </label>\n        <p className=\"text-xs text-[var(--oh-muted)]\">\n          {t(I18nKey.SETTINGS$META_PROFILE_PROMPT_TEMPLATE_HELP, {\n            instance_text: INSTANCE_TEXT_PLACEHOLDER_TEXT,\n            model_table: MODEL_TABLE_PLACEHOLDER_TEXT,\n          })}\n        </p>\n      </div>\n\n      <div className=\"flex flex-col gap-2\">\n        <label className=\"flex flex-col gap-2.5\">\n          <span className=\"text-sm\">\n            {t(I18nKey.SETTINGS$META_PROFILE_MODEL_TABLE)}\n          </span>\n          <textarea\n            data-testid=\"meta-profile-model-table\"\n            rows={5}\n            spellCheck={false}\n            value={config.model_table ?? \"\"}\n            placeholder={t(\n              I18nKey.SETTINGS$META_PROFILE_MODEL_TABLE_PLACEHOLDER,\n            )}\n            onChange={(event) =>\n              setConfig((prev) => ({\n                ...prev,\n                model_table: event.target.value,\n              }))\n            }\n            disabled={isSaving}\n            className={cn(\n              formControlMultilineFieldClassName,\n              \"font-mono text-xs\",\n            )}\n          />\n        </label>\n        <p className=\"text-xs text-[var(--oh-muted)]\">\n          {t(I18nKey.SETTINGS$META_PROFILE_MODEL_TABLE_HELP, {\n            model_table: MODEL_TABLE_PLACEHOLDER_TEXT,\n          })}\n        </p>\n      </div>\n\n      {showRouterConnectionPicker ? (\n        <div className=\"flex flex-col gap-2\">\n          <SettingsDropdownInput\n            testId=\"meta-profile-router-connection\"\n            name=\"router_connection\"\n            label={t(I18nKey.SETTINGS$META_PROFILE_CREATE_ROUTER_PROFILES)}\n            items={routerConnectionItems}\n            selectedKey={routerConnectionId}\n            isDisabled={isSaving}\n            onSelectionChange={(key) =>\n              setChosenRouterConnectionId(\n                key ? String(key) : NO_ROUTER_CONNECTION_KEY,\n              )\n            }\n          />\n          <div className=\"flex flex-wrap items-center gap-3\">\n            <p className=\"text-xs text-[var(--oh-muted)]\">\n              {t(I18nKey.SETTINGS$META_PROFILE_CREATE_ROUTER_PROFILES_HELP)}\n            </p>\n            <BrandButton\n              testId=\"meta-profile-add-provider-connection\"\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={() => setIsProviderConnectionModalOpen(true)}\n              isDisabled={isSaving}\n            >\n              {t(I18nKey.SETTINGS$PROVIDER_CONNECTION_ADD)}\n            </BrandButton>\n          </div>\n        </div>\n      ) : null}\n\n      {isProviderConnectionModalOpen ? (\n        <ProviderConnectionModal\n          isCreate\n          onClose={() => setIsProviderConnectionModalOpen(false)}\n          onSaved={handleProviderConnectionSaved}\n        />\n      ) : null}\n\n      <div className=\"flex items-center gap-3\">\n        <BrandButton\n          testId=\"meta-profile-save\"\n          type=\"button\"\n          variant=\"primary\"\n          onClick={handleSave}\n          isDisabled={!canSave || isSaving}\n        >\n          {t(I18nKey.BUTTON$SAVE)}\n        </BrandButton>\n        <BrandButton\n          testId=\"meta-profile-cancel\"\n          type=\"button\"\n          variant=\"tertiary\"\n          onClick={onCancel}\n          isDisabled={isSaving}\n        >\n          {t(I18nKey.BUTTON$CANCEL)}\n        </BrandButton>\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/meta-llm-profiles/meta-profile-row.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 56,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 56,
          "endColumn": 81
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { MetaProfileActionsMenu } from \"./meta-profile-actions-menu\";\nimport { MetaProfileInfo } from \"#/api/meta-profiles-service/meta-profiles-service.api\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { EllipsisButton } from \"#/components/features/conversation-panel/ellipsis-button\";\nimport { BrandBadge } from \"#/components/shared/badge\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  settingsListIconActionButtonClassName,\n  settingsListRowClassName,\n} from \"#/utils/settings-list-classes\";\n\ninterface MetaProfileRowProps {\n  info: MetaProfileInfo;\n  isActive: boolean;\n  /** Omitted when routers aren't activated here. */\n  onActivate?: (name: string) => void;\n  onEdit: (name: string) => void;\n  onDelete: (name: string) => void;\n  isActivating: boolean;\n}\n\nexport function MetaProfileRow({\n  info,\n  isActive,\n  onActivate,\n  onEdit,\n  onDelete,\n  isActivating,\n}: MetaProfileRowProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [menuOpen, setMenuOpen] = useState(false);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n\n  const route = info.classifier_model ? String(info.classifier_model) : \"\";\n  const mode =\n    info.num_classes > 0\n      ? `${info.num_classes} ${t(I18nKey.SETTINGS$META_PROFILE_CLASSES)}`\n      : t(I18nKey.SETTINGS$META_PROFILE_DIRECT_PROMPT);\n  const summary = `${route}${route ? \" \u00b7 \" : \"\"}${mode}`;\n\n  return (\n    <div\n      data-testid={`meta-profile-row-${info.name}`}\n      className={cn(settingsListRowClassName, \"justify-between gap-3\")}\n    >\n      <div className=\"flex min-w-0 flex-1 items-center gap-3\">\n        <span\n          className=\"min-w-0 max-w-full truncate text-sm font-medium text-contrast\"\n          title={info.name}\n        >\n          {info.name}\n        </span>\n        <span\n          className=\"min-w-0 max-w-full truncate text-sm text-[var(--oh-muted)]\"\n          title={summary}\n        >\n          {summary}\n        </span>\n        {isActive && (\n          <BrandBadge\n            className=\"shrink-0 whitespace-nowrap px-2.5 py-1 text-xs\"\n            data-testid=\"meta-profile-active-badge\"\n          >\n            {t(I18nKey.SETTINGS$META_PROFILE_ACTIVE)}\n          </BrandBadge>\n        )}\n      </div>\n      <div className=\"relative shrink-0\">\n        <EllipsisButton\n          ref={triggerRef}\n          onClick={() => setMenuOpen((open) => !open)}\n          ariaLabel={t(I18nKey.SETTINGS$PROFILE_MENU)}\n          testId={`meta-profile-menu-trigger-${info.name}`}\n          className={settingsListIconActionButtonClassName}\n        />\n        {menuOpen && (\n          <MetaProfileActionsMenu\n            anchorRef={triggerRef}\n            onEdit={() => onEdit(info.name)}\n            onSetActive={onActivate ? () => onActivate(info.name) : undefined}\n            onDelete={() => onDelete(info.name)}\n            isActive={isActive}\n            isActivating={isActivating}\n            onClose={() => setMenuOpen(false)}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/settings-nav-header.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 16,
          "column": 34,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 16,
          "endColumn": 107
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { Typography } from \"#/ui/typography\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\n\ninterface SettingsNavHeaderProps {\n  text: I18nKey;\n  className?: string;\n}\n\nexport function SettingsNavHeader({ text, className }: SettingsNavHeaderProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <div className={cn(\"px-3.5\", className)}>\n      <Typography.Text className=\"text-[11px] font-medium text-text-dim uppercase tracking-wide leading-5\">\n        {t(text)}\n      </Typography.Text>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/settings-switch.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 61,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 61,
          "endColumn": 123
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"font-[500]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 61,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 61,
          "endColumn": 123
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { ToggleSwitchVisual } from \"#/ui/toggle-switch\";\n\ninterface SettingsSwitchProps {\n  testId?: string;\n  name?: string;\n  onToggle?: (value: boolean) => void;\n  defaultIsToggled?: boolean;\n  isToggled?: boolean;\n  isBeta?: boolean;\n  isDisabled?: boolean;\n  /** Whether the toggle sits before or after the label. Defaults to \"left\". */\n  togglePosition?: \"left\" | \"right\";\n}\n\nexport function SettingsSwitch({\n  children,\n  testId,\n  name,\n  onToggle,\n  defaultIsToggled,\n  isToggled: controlledIsToggled,\n  isBeta,\n  isDisabled,\n  togglePosition = \"left\",\n}: React.PropsWithChildren<SettingsSwitchProps>) {\n  const { t } = useTranslation(\"openhands\");\n  const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);\n\n  const handleToggle = (value: boolean) => {\n    if (isDisabled) return;\n    setIsToggled(value);\n    onToggle?.(value);\n  };\n\n  // @spec SSW-001 \u2014 Native checkbox access with visible keyboard focus.\n  const input = (\n    <input\n      className=\"sr-only\"\n      data-testid={testId}\n      name={name}\n      type=\"checkbox\"\n      onChange={(e) => handleToggle(e.target.checked)}\n      checked={controlledIsToggled ?? isToggled}\n      disabled={isDisabled}\n    />\n  );\n\n  const toggle = (\n    <ToggleSwitchVisual enabled={controlledIsToggled ?? isToggled} />\n  );\n\n  const label =\n    children || isBeta ? (\n      <div className=\"flex items-center gap-1\">\n        <span className=\"text-sm\">{children}</span>\n        {isBeta && (\n          <span className=\"text-[11px] leading-4 text-on-primary font-[500] tracking-tighter bg-primary px-1 rounded-full\">\n            {t(I18nKey.BADGE$BETA)}\n          </span>\n        )}\n      </div>\n    ) : null;\n\n  return (\n    <label\n      className={cn(\n        \"flex items-center gap-2 rounded-sm has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-focus\",\n        togglePosition === \"right\" ? \"w-full justify-between\" : \"w-fit\",\n        isDisabled ? \"cursor-not-allowed opacity-50\" : \"cursor-pointer\",\n      )}\n    >\n      {input}\n      {togglePosition === \"right\" ? (\n        <>\n          {label}\n          {toggle}\n        </>\n      ) : (\n        <>\n          {toggle}\n          {label}\n        </>\n      )}\n    </label>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/settings/upgrade-button.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[9px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 24,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 24,
          "endColumn": 166
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[100px]\" hardcodes an off-token value. Nearest on the scale: rounded-4xl (32px), rounded-3xl (24px).",
          "line": 24,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 24,
          "endColumn": 166
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\n\ninterface UpgradeButtonProps {\n  onClick?: () => void;\n  className?: string;\n  isDisabled?: boolean;\n}\n\nexport function UpgradeButton({\n  onClick,\n  className,\n  isDisabled,\n}: UpgradeButtonProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      disabled={isDisabled}\n      className={cn(\n        \"bg-interactive-selected text-contrast text-[9px] font-normal w-16 h-4 rounded-[100px] mix-blend-multiply hover:opacity-80 transition-opacity cursor-pointer\",\n        className,\n      )}\n    >\n      {t(I18nKey.SETTINGS$UPGRADE_BUTTON)}\n    </button>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/setup-guide/super-admin-setup-guide.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 337,
          "column": 33,
          "nodeType": "TemplateLiteral",
          "messageId": "inlineStyle",
          "endLine": 337,
          "endColumn": 50
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import {\n  type MouseEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ArrowUpRight, Check, ClipboardList, X } from \"lucide-react\";\nimport { getLockedCloudHost } from \"#/api/agent-server-config\";\nimport { ConfirmationModal } from \"#/components/shared/modals/confirmation-modal\";\nimport { NavigationLink } from \"#/components/shared/navigation-link\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { formControlButtonClassName } from \"#/utils/form-control-classes\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  SETUP_TOUR_PARAM,\n  SUPER_ADMIN_SETUP_GUIDE_PAGE_PATH,\n  SUPER_ADMIN_SETUP_STEPS,\n  type SuperAdminSetupStep,\n  type SuperAdminSetupStepId,\n} from \"./super-admin-setup-guide.constants\";\nimport { SUPER_ADMIN_SETUP_STEP_EVENT } from \"./super-admin-setup-step-event\";\nimport {\n  hasSetupGuideTour,\n  startSetupGuideTour,\n} from \"./tour/setup-guide-tour\";\nimport {\n  isGuidedTourActive,\n  subscribeGuidedTourActive,\n} from \"./tour/tour-engine\";\nimport {\n  getNextSetupStep,\n  useSuperAdminSetupGuide,\n} from \"./use-super-admin-setup-guide\";\n\nconst ICON_BUTTON_CLASS = cn(\n  \"inline-flex size-7 shrink-0 items-center justify-center rounded-md\",\n  \"text-muted hover:bg-surface hover:text-contrast\",\n);\n\nconst STEP_ROW_CLASS =\n  \"flex w-full min-w-0 items-center gap-2.5 rounded-md px-2.5 py-1.5 hover:bg-surface\";\n\n// The enterprise guide's primary button: light on dark. Canvas's `primary`\n// is its gold accent, so this uses `foreground` like the enterprise app.\nconst START_BUTTON_CLASS = cn(\n  formControlButtonClassName,\n  \"w-full bg-foreground font-medium text-on-primary hover:opacity-80\",\n);\n\nconst START_TEST_ID = \"super-admin-setup-guide-start\";\n\n/**\n * A page on the cloud host, on the guide's organization when given, and\n * naming the step whose tour the enterprise guide should start.\n */\nfunction cloudPageUrl(\n  cloudHost: string,\n  path: string,\n  orgId: string | null,\n  tourStepId?: SuperAdminSetupStepId,\n) {\n  const params = new URLSearchParams();\n  if (orgId) params.set(\"org\", orgId);\n  if (tourStepId) params.set(SETUP_TOUR_PARAM, tourStepId);\n  const query = params.toString();\n  return query ? `${cloudHost}${path}?${query}` : `${cloudHost}${path}`;\n}\n\n/**\n * Floating lower-right setup guide for the enterprise Super Admin, matching\n * the guide in the OpenHands Enterprise app. Steps that live in the\n * enterprise app link back to it; progress is read from the server.\n */\nexport default function SuperAdminSetupGuide() {\n  const { t } = useTranslation(\"openhands\");\n  const { currentPath, navigate } = useNavigation();\n  const { backend } = useActiveBackend();\n  const {\n    steps,\n    completedCount,\n    totalCount,\n    nextStep,\n    guideOrgId,\n    visible,\n    refetch,\n    dismiss,\n  } = useSuperAdminSetupGuide();\n  const [open, setOpen] = useState(true);\n  const [confirmDismiss, setConfirmDismiss] = useState(false);\n  const tourActive = useSyncExternalStore(\n    subscribeGuidedTourActive,\n    isGuidedTourActive,\n    isGuidedTourActive,\n  );\n\n  // The tour checks the current route after it navigates, between renders.\n  const pathRef = useRef(currentPath);\n  useEffect(() => {\n    pathRef.current = currentPath;\n  }, [currentPath]);\n  const startTour = useCallback(\n    (stepId: SuperAdminSetupStepId) =>\n      startSetupGuideTour(\n        stepId,\n        { navigate, getPath: () => pathRef.current },\n        t,\n      ),\n    [navigate, t],\n  );\n\n  // Progress is read from the server, so re-read it as the admin moves around.\n  useEffect(() => {\n    if (visible) {\n      refetch();\n    }\n  }, [currentPath, visible, refetch]);\n\n  const cloudHost = backend.host.replace(/\\/+$/, \"\");\n  // Locked-to-Cloud serves the canvas on the cloud host itself, so enterprise\n  // pages open in this tab; standalone / Electron keep a new tab.\n  const isLockedToCloud = getLockedCloudHost() !== null;\n\n  // When the guide's next step is done, open the step after it the way Start\n  // does. A step finished out of order, or not confirmed by the server, only\n  // refreshes the progress; nothing opens after the last required step.\n  const nextStepId = nextStep?.id ?? null;\n  useEffect(() => {\n    if (!visible) {\n      return undefined;\n    }\n    const onStep = async (event: Event) => {\n      const completedId = (event as CustomEvent<{ id?: string }>).detail?.id;\n      const { data } = await refetch();\n      const guideSteps = data?.guide_steps;\n      if (!guideSteps || !completedId || completedId !== nextStepId) {\n        return;\n      }\n      const after = getNextSetupStep(guideSteps);\n      if (!after || after.id === completedId) {\n        return;\n      }\n      const { destination } = after;\n      if (destination.kind === \"canvas\") {\n        if (hasSetupGuideTour(after.id)) {\n          await startTour(after.id);\n        } else {\n          navigate(destination.path);\n        }\n      } else if (isLockedToCloud) {\n        window.location.assign(\n          cloudPageUrl(\n            cloudHost,\n            destination.path,\n            destination.withOrg ? guideOrgId : null,\n            after.id,\n          ),\n        );\n      } else {\n        // A new tab opened after a save is blocked, so point at Start instead.\n        setOpen(true);\n      }\n    };\n    window.addEventListener(SUPER_ADMIN_SETUP_STEP_EVENT, onStep);\n    return () =>\n      window.removeEventListener(SUPER_ADMIN_SETUP_STEP_EVENT, onStep);\n  }, [\n    visible,\n    refetch,\n    nextStepId,\n    navigate,\n    startTour,\n    cloudHost,\n    guideOrgId,\n    isLockedToCloud,\n  ]);\n\n  // A page opened from the enterprise guide names the step whose tour to\n  // start. Enterprise steps have no tour here, so only Canvas steps start.\n  useEffect(() => {\n    if (!visible) {\n      return;\n    }\n    const params = new URLSearchParams(window.location.search);\n    const requested = params.get(SETUP_TOUR_PARAM);\n    if (!requested) {\n      return;\n    }\n    // Drop it first, so the tour starts once.\n    params.delete(SETUP_TOUR_PARAM);\n    const query = params.toString();\n    window.history.replaceState(\n      window.history.state,\n      \"\",\n      `${window.location.pathname}${query ? `?${query}` : \"\"}${window.location.hash}`,\n    );\n    const step = SUPER_ADMIN_SETUP_STEPS.find(({ id }) => id === requested);\n    if (step && hasSetupGuideTour(step.id)) {\n      setOpen(false);\n      startTour(step.id);\n    }\n  }, [visible, startTour]);\n\n  if (!visible || tourActive) {\n    return null;\n  }\n\n  const cloudLinkProps = {\n    target: isLockedToCloud ? undefined : \"_blank\",\n    rel: isLockedToCloud ? undefined : \"noopener noreferrer\",\n  };\n  const cloudUrl = (\n    path: string,\n    withOrg: boolean,\n    tourStepId?: SuperAdminSetupStepId,\n  ) => cloudPageUrl(cloudHost, path, withOrg ? guideOrgId : null, tourStepId);\n  // A plain click on a Canvas step that has a tour runs the tour, which opens\n  // the step's page itself; a modified click still follows the link.\n  const startStepTour = (\n    step: SuperAdminSetupStep,\n    event: MouseEvent<HTMLAnchorElement>,\n  ) => {\n    const plainClick =\n      event.button === 0 &&\n      !event.metaKey &&\n      !event.ctrlKey &&\n      !event.shiftKey &&\n      !event.altKey;\n    if (\n      plainClick &&\n      step.destination.kind === \"canvas\" &&\n      hasSetupGuideTour(step.id)\n    ) {\n      event.preventDefault();\n      setOpen(false);\n      startTour(step.id);\n    }\n  };\n  // A step row and Start open the same page for a step, with the step's tour\n  // for a Canvas step. Start also asks the enterprise guide for its tour.\n  const renderStepLink = (\n    step: SuperAdminSetupStep,\n    testId: string,\n    className: string,\n    children: ReactNode,\n    onClick?: (event: MouseEvent<HTMLAnchorElement>) => void,\n    startsTour = false,\n  ) =>\n    step.destination.kind === \"canvas\" ? (\n      <NavigationLink\n        to={step.destination.path}\n        data-testid={testId}\n        className={className}\n        onClick={onClick}\n      >\n        {children}\n      </NavigationLink>\n    ) : (\n      <a\n        href={cloudUrl(\n          step.destination.path,\n          step.destination.withOrg,\n          startsTour ? step.id : undefined,\n        )}\n        {...cloudLinkProps}\n        data-testid={testId}\n        className={className}\n        onClick={onClick}\n      >\n        {children}\n      </a>\n    );\n\n  const title = t(I18nKey.ONBOARDING$SETUP_GUIDE_TITLE);\n  const progressPct = Math.round((completedCount / totalCount) * 100);\n\n  return (\n    <div\n      className=\"fixed bottom-5 right-5 z-30 flex flex-col items-end gap-2\"\n      data-testid=\"super-admin-setup-guide\"\n    >\n      {open ? (\n        <div\n          data-testid=\"super-admin-setup-guide-panel\"\n          className={cn(\n            \"flex w-80 flex-col gap-3 rounded-xl border border-border\",\n            \"bg-surface-raised p-4 shadow-lg\",\n          )}\n        >\n          <div className=\"flex items-center justify-between gap-2\">\n            <p className=\"min-w-0 text-sm font-semibold text-content\">\n              {title}\n            </p>\n            <div className=\"flex shrink-0 items-center gap-0.5\">\n              <a\n                href={cloudUrl(SUPER_ADMIN_SETUP_GUIDE_PAGE_PATH, false)}\n                {...cloudLinkProps}\n                data-testid=\"super-admin-setup-guide-page\"\n                aria-label={title}\n                title={title}\n                className={ICON_BUTTON_CLASS}\n              >\n                <ArrowUpRight className=\"size-4\" strokeWidth={2} aria-hidden />\n              </a>\n              <button\n                type=\"button\"\n                aria-label={t(I18nKey.BUTTON$CLOSE)}\n                className={ICON_BUTTON_CLASS}\n                // Closing asks whether to turn the guide off for good;\n                // cancelling leaves it closed to the pill.\n                onClick={() => {\n                  setOpen(false);\n                  setConfirmDismiss(true);\n                }}\n              >\n                <X className=\"size-4\" strokeWidth={2} aria-hidden />\n              </button>\n            </div>\n          </div>\n\n          <div className=\"flex items-center gap-3\">\n            <div\n              className=\"h-1.5 flex-1 overflow-hidden rounded-full bg-border\"\n              role=\"progressbar\"\n              aria-valuemin={0}\n              aria-valuemax={100}\n              aria-valuenow={progressPct}\n              aria-label={title}\n            >\n              <div\n                className=\"h-full rounded-full bg-foreground transition-all duration-300\"\n                style={{ width: `${progressPct}%` }}\n              />\n            </div>\n            <span className=\"shrink-0 text-xs tabular-nums text-muted\">\n              {completedCount}/{totalCount}\n            </span>\n          </div>\n\n          <ul className=\"flex flex-col gap-0.5\">\n            {steps.map((step) => {\n              const label = (\n                <>\n                  <span\n                    aria-hidden\n                    className={cn(\n                      \"inline-flex size-4 shrink-0 items-center justify-center rounded-full border\",\n                      step.done\n                        ? \"border-foreground bg-foreground text-on-primary\"\n                        : \"border-border bg-transparent\",\n                    )}\n                  >\n                    {step.done ? (\n                      <Check className=\"size-2.5\" strokeWidth={3} />\n                    ) : null}\n                  </span>\n                  <span\n                    className={cn(\n                      \"min-w-0 flex-1 truncate text-xs\",\n                      step.done ? \"text-muted line-through\" : \"text-content\",\n                    )}\n                  >\n                    {t(step.labelKey)}\n                  </span>\n                </>\n              );\n              const testId = `super-admin-setup-guide-step-${step.id}`;\n              const rowClassName = cn(\n                STEP_ROW_CLASS,\n                nextStep?.id === step.id && \"bg-surface\",\n              );\n              return (\n                <li key={step.id}>\n                  {renderStepLink(step, testId, rowClassName, label, (event) =>\n                    startStepTour(step, event),\n                  )}\n                </li>\n              );\n            })}\n          </ul>\n\n          {nextStep ? (\n            <div className=\"flex flex-col gap-2\">\n              <p\n                className=\"text-xs text-muted\"\n                data-testid=\"super-admin-setup-guide-next\"\n              >\n                {t(I18nKey.ONBOARDING$SETUP_GUIDE_NEXT, {\n                  step: t(nextStep.labelKey),\n                })}\n              </p>\n              {/* Like the enterprise Start, close the panel before opening\n                  the step, so Start does something even when the admin is\n                  already on that step's page. */}\n              {renderStepLink(\n                nextStep,\n                START_TEST_ID,\n                START_BUTTON_CLASS,\n                t(I18nKey.ONBOARDING$SETUP_GUIDE_START),\n                (event) => {\n                  setOpen(false);\n                  startStepTour(nextStep, event);\n                },\n                true,\n              )}\n            </div>\n          ) : null}\n        </div>\n      ) : null}\n\n      <button\n        type=\"button\"\n        data-testid=\"super-admin-setup-guide-toggle\"\n        aria-label={title}\n        aria-expanded={open}\n        onClick={() => setOpen((current) => !current)}\n        className={cn(\n          \"flex items-center gap-2 rounded-full border border-border\",\n          \"bg-surface-raised px-3.5 py-2 text-sm font-medium text-content\",\n          \"shadow-sm hover:bg-surface\",\n        )}\n      >\n        <ClipboardList className=\"size-4\" strokeWidth={2} aria-hidden />\n        {title}\n      </button>\n\n      {confirmDismiss ? (\n        <ConfirmationModal\n          text={t(I18nKey.ONBOARDING$SETUP_GUIDE_DISMISS_CONFIRM)}\n          onConfirm={() => {\n            setConfirmDismiss(false);\n            dismiss();\n          }}\n          onCancel={() => setConfirmDismiss(false)}\n        />\n      ) : null}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/sidebar/mars-session-panel.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[calc(100%+0.625rem)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 285,
          "column": 22,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 285,
          "endColumn": 77
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[40vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 322,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 322,
          "endColumn": 51
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Check, ChevronsUpDown, Loader2, Plus, Settings2 } from \"lucide-react\";\n\nimport DigitalOceanLogo from \"#/assets/branding/digitalocean-logo.svg?react\";\nimport type { Backend } from \"#/api/backend-registry/types\";\nimport type { AppConversation } from \"#/api/conversation-service/agent-server-conversation-service.types\";\nimport {\n  isConnectableSession,\n  NEW_CHAT_PATH,\n  SESSION_STATUS_READY,\n  type MarsAgentConfig,\n  type MarsSession,\n} from \"#/api/mars/mars-tunnel-backend\";\nimport { agentLabel } from \"#/components/features/backends/managed-agents/managed-agents-labels\";\nimport { SessionStatusDot } from \"#/components/features/backends/managed-agents/session-status-dot\";\nimport {\n  getConnectStage,\n  useSessionMeta,\n} from \"#/components/features/backends/managed-agents/use-session-meta\";\nimport { ConversationStatusDot } from \"#/components/features/conversation-panel/conversation-status-dot\";\nimport { NEW_CONVERSATION_DROPDOWN_SURFACE } from \"#/components/features/conversation-panel/new-conversation-dropdown-styles\";\nimport { ErrorAlert } from \"#/components/shared/error-alert\";\nimport { NavigationLink } from \"#/components/shared/navigation-link\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport {\n  getUsableConnectionId,\n  useMarsAgents,\n  useMarsAuthState,\n  type MarsAgentsSnapshot,\n} from \"#/hooks/query/use-mars-agents\";\nimport { usePaginatedConversations } from \"#/hooks/query/use-paginated-conversations\";\nimport { useBackendScopedPath } from \"#/hooks/use-backend-scoped-path\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { useCloseOnEscape } from \"#/hooks/use-close-on-escape\";\nimport { useLaunchMarsSession } from \"#/hooks/use-launch-mars-session\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { Divider } from \"#/ui/divider\";\nimport {\n  dropdownMenuListClassName,\n  dropdownMenuRowClassName,\n} from \"#/utils/dropdown-classes\";\nimport { formatTimeDelta } from \"#/utils/format-time-delta\";\nimport { cn } from \"#/utils/utils\";\n\nconst ManageBackendsModal = React.lazy(() =>\n  import(\"#/components/features/backends/manage-backends-modal\").then((m) => ({\n    default: m.ManageBackendsModal,\n  })),\n);\n\n/** Listing sessions is a harness-api read; it never wakes a sandbox. */\nconst SESSIONS_REFETCH_MS = 30_000;\n\nconst ICON_BUTTON_CLASS =\n  \"inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50\";\n\ninterface SessionContext {\n  config: MarsAgentConfig | undefined;\n  sessions: MarsSession[];\n}\n\n/**\n * The agent the active session belongs to and that agent's sessions; for a\n * session launched outside any listed agent, the team's other sessions.\n */\nfunction resolveSessionContext(\n  data: MarsAgentsSnapshot | undefined,\n  backend: Backend,\n): SessionContext {\n  const groups = data?.groups ?? [];\n  const group =\n    groups.find((g) => g.config.id === backend.marsConfigId) ??\n    groups.find((g) =>\n      g.sessions.some((s) => s.session_id === backend.marsSessionId),\n    );\n  return {\n    config: group?.config,\n    sessions: group?.sessions ?? data?.otherSessions ?? [],\n  };\n}\n\n/**\n * The list can lag a just-created or just-connected session; the one the\n * backend points at must still show, or its conversations would vanish.\n */\nfunction withCurrentSession(\n  sessions: MarsSession[],\n  backend: Backend,\n): MarsSession[] {\n  const sessionId = backend.marsSessionId;\n  if (!sessionId || sessions.some((s) => s.session_id === sessionId)) {\n    return sessions;\n  }\n  // Connected implies its tunnel answered, so it reads as running.\n  return [{ session_id: sessionId, status: SESSION_STATUS_READY }, ...sessions];\n}\n\n/** The active session's conversations, nested under its row. */\nfunction SessionConversations({\n  conversations,\n}: {\n  conversations: AppConversation[];\n}) {\n  const { t } = useTranslation(\"openhands\");\n  const { conversationId } = useNavigation();\n  const backendScopedPath = useBackendScopedPath();\n\n  if (conversations.length === 0) return null;\n  return (\n    <ul\n      data-testid=\"mars-session-conversations\"\n      className=\"mt-0.5 flex flex-col gap-0.5\"\n    >\n      {conversations.map((conversation) => (\n        <li key={conversation.id}>\n          <NavigationLink\n            to={backendScopedPath(`/conversations/${conversation.id}`)}\n            data-testid={`mars-session-conversation-${conversation.id}`}\n            className={cn(\n              \"flex h-8 min-w-0 items-center gap-2 rounded-md pl-6 pr-2 text-sm text-white hover:bg-surface\",\n              conversation.id === conversationId && \"bg-surface\",\n            )}\n          >\n            <ConversationStatusDot\n              executionStatus={conversation.execution_status}\n              sandboxStatus={conversation.sandbox_status}\n              showTooltip={false}\n            />\n            <span className=\"min-w-0 flex-1 truncate\">\n              {conversation.title || t(I18nKey.CONVERSATION$UNTITLED)}\n            </span>\n            <time className=\"shrink-0 text-xs tabular-nums text-muted\">\n              {formatTimeDelta(\n                conversation.updated_at ?? conversation.created_at,\n              )}\n            </time>\n          </NavigationLink>\n        </li>\n      ))}\n    </ul>\n  );\n}\n\ntype Launcher = ReturnType<typeof useLaunchMarsSession>;\n\ninterface MarsSessionRowProps {\n  session: MarsSession;\n  isCurrent: boolean;\n  launcher: Launcher;\n  conversations: AppConversation[];\n  /** Go to the current session's latest conversation, or a new chat. */\n  onOpenCurrent: () => void;\n  onOpen: () => void;\n  onNewChat: () => void;\n}\n\n/** One session, read like a thread folder; the current one lists its chats. */\nfunction MarsSessionRow({\n  session,\n  isCurrent,\n  launcher,\n  conversations,\n  onOpenCurrent,\n  onOpen,\n  onNewChat,\n}: MarsSessionRowProps) {\n  const { t } = useTranslation(\"openhands\");\n  const stage = getConnectStage(launcher.connecting, session);\n  const { name, phase, phaseLabel, stageLabel, lastActivityAt } =\n    useSessionMeta(session, stage);\n\n  let trailing: React.ReactNode = null;\n  if (stage) {\n    trailing = (\n      <Loader2 className=\"size-3.5 shrink-0 animate-spin\" aria-hidden />\n    );\n  } else if (!isCurrent && lastActivityAt) {\n    trailing = (\n      <time className=\"shrink-0 text-xs tabular-nums\">\n        {formatTimeDelta(lastActivityAt)}\n      </time>\n    );\n  }\n\n  return (\n    <li data-testid={`mars-session-${session.session_id}`}>\n      <div\n        className={cn(\n          \"group flex h-8 w-full min-w-0 items-center gap-0.5 rounded-md pl-2 pr-1 text-sm hover:bg-surface-raised\",\n          isCurrent ? \"text-white\" : \"text-muted hover:text-white\",\n        )}\n      >\n        <button\n          type=\"button\"\n          disabled={\n            launcher.isBusy || (!isCurrent && !isConnectableSession(session))\n          }\n          aria-current={isCurrent ? \"true\" : undefined}\n          title={stageLabel ?? phaseLabel}\n          onClick={isCurrent ? onOpenCurrent : onOpen}\n          data-testid={`mars-session-open-${session.session_id}`}\n          className=\"flex min-h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md py-1 text-left text-inherit outline-none focus-visible:ring-1 focus-visible:ring-border disabled:cursor-not-allowed\"\n        >\n          <SessionStatusDot phase={phase} />\n          <span className=\"min-w-0 flex-1 truncate\">{name}</span>\n          {stageLabel ? <span className=\"sr-only\">{stageLabel}</span> : null}\n          {trailing}\n        </button>\n        {isCurrent ? (\n          <button\n            type=\"button\"\n            onClick={onNewChat}\n            aria-label={t(I18nKey.DO_AGENTS$NEW_CHAT_IN_SESSION, { name })}\n            title={t(I18nKey.DO_AGENTS$NEW_CHAT_IN_SESSION, { name })}\n            data-testid=\"mars-session-new-chat\"\n            className={ICON_BUTTON_CLASS}\n          >\n            <Plus className=\"size-3.5\" aria-hidden strokeWidth={2} />\n          </button>\n        ) : null}\n      </div>\n      {isCurrent ? (\n        <SessionConversations conversations={conversations} />\n      ) : null}\n    </li>\n  );\n}\n\ninterface MarsSessionPanelProps {\n  /** The active backend; must carry a `marsSessionId`. */\n  backend: Backend;\n}\n\n/**\n * Sidebar list for a DigitalOcean Managed Agents backend, in place of the\n * conversation list. Sessions of the active agent read like thread folders:\n * the one you are on expands to its conversations, any other opens with a\n * click, and the header launches a new session or switches agent.\n */\nexport function MarsSessionPanel({ backend }: MarsSessionPanelProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { navigate } = useNavigation();\n  const backendScopedPath = useBackendScopedPath();\n  const launcher = useLaunchMarsSession();\n\n  const [menuOpen, setMenuOpen] = React.useState(false);\n  const [manageOpen, setManageOpen] = React.useState(false);\n  const [error, setError] = React.useState<string | null>(null);\n  const closeMenu = () => setMenuOpen(false);\n  const menuRef = useClickOutsideElement<HTMLDivElement>(closeMenu);\n  const menuTriggerRef = React.useRef<HTMLButtonElement>(null);\n  useCloseOnEscape(menuOpen, closeMenu, menuTriggerRef);\n\n  const authQuery = useMarsAuthState();\n  const agentsQuery = useMarsAgents(getUsableConnectionId(authQuery.data), {\n    refetchIntervalMs: SESSIONS_REFETCH_MS,\n  });\n  const context = resolveSessionContext(agentsQuery.data, backend);\n  const { config } = context;\n  const sessions = withCurrentSession(context.sessions, backend);\n  const groups = agentsQuery.data?.groups ?? [];\n  const { data: conversationPages } = usePaginatedConversations();\n  const conversations =\n    conversationPages?.pages.flatMap((page) => page.items) ?? [];\n  const latestConversationId = conversations[0]?.id;\n\n  const run = async (action: () => Promise<unknown>) => {\n    setError(null);\n    try {\n      await action();\n    } catch (failure) {\n      setError(launcher.describeError(failure));\n    }\n  };\n\n  const isLaunching = config ? launcher.launchingConfigId === config.id : false;\n  const title = config ? agentLabel(config) : backend.name;\n\n  return (\n    <div\n      data-testid=\"mars-session-panel\"\n      className=\"flex h-full min-h-0 w-full flex-col\"\n    >\n      <div className=\"-ml-2.5 box-border w-[calc(100%+0.625rem)] max-w-none\">\n        <div className=\"flex min-w-0 flex-nowrap items-center gap-x-1 py-2 pl-2.5 pr-2.5\">\n          <div ref={menuRef} className=\"relative min-w-0 flex-1\">\n            <button\n              ref={menuTriggerRef}\n              type=\"button\"\n              onClick={() => setMenuOpen((open) => !open)}\n              aria-expanded={menuOpen}\n              aria-haspopup=\"menu\"\n              aria-label={t(I18nKey.DO_AGENTS$SWITCH_AGENT)}\n              data-testid=\"mars-session-panel-agent\"\n              className=\"flex h-7 max-w-full cursor-pointer items-center gap-2 rounded-md px-1.5 text-sm font-medium text-muted hover:bg-surface-raised hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border\"\n            >\n              <DigitalOceanLogo\n                width={14}\n                height={14}\n                className=\"shrink-0\"\n                aria-hidden\n              />\n              <span className=\"truncate\">{title}</span>\n              <ChevronsUpDown className=\"size-3.5 shrink-0\" aria-hidden />\n            </button>\n\n            {menuOpen ? (\n              <div\n                role=\"menu\"\n                data-testid=\"mars-session-panel-agent-menu\"\n                className={cn(\n                  NEW_CONVERSATION_DROPDOWN_SURFACE,\n                  \"absolute left-0 top-full z-20 mt-1 w-64\",\n                )}\n              >\n                <p className=\"px-2 pb-1 pt-1.5 text-xs text-text-tertiary\">\n                  {t(I18nKey.DO_AGENTS$AGENTS_HEADING)}\n                </p>\n                <div\n                  className={cn(\n                    \"max-h-[40vh] overflow-y-auto\",\n                    dropdownMenuListClassName,\n                  )}\n                >\n                  {groups.map((group) => {\n                    const isCurrent = group.config.id === config?.id;\n                    return (\n                      <button\n                        key={group.config.id}\n                        type=\"button\"\n                        role=\"menuitem\"\n                        disabled={launcher.isBusy}\n                        onClick={() => {\n                          setMenuOpen(false);\n                          if (!isCurrent) {\n                            void run(() => launcher.openAgent(group));\n                          }\n                        }}\n                        data-testid={`mars-session-panel-agent-${group.config.id}`}\n                        className={dropdownMenuRowClassName}\n                      >\n                        <span className=\"min-w-0 flex-1 truncate\">\n                          {agentLabel(group.config)}\n                        </span>\n                        {isCurrent ? (\n                          <Check\n                            className=\"size-3.5 shrink-0 text-primary\"\n                            aria-hidden\n                          />\n                        ) : null}\n                      </button>\n                    );\n                  })}\n                  <Divider inset=\"menu\" />\n                  <button\n                    type=\"button\"\n                    role=\"menuitem\"\n                    onClick={() => {\n                      setMenuOpen(false);\n                      setManageOpen(true);\n                    }}\n                    data-testid=\"mars-session-panel-manage\"\n                    className={dropdownMenuRowClassName}\n                  >\n                    <Settings2 className=\"size-4\" aria-hidden />\n                    {t(I18nKey.DO_AGENTS$MANAGE)}\n                  </button>\n                </div>\n              </div>\n            ) : null}\n          </div>\n\n          {config ? (\n            <button\n              type=\"button\"\n              onClick={() => void run(() => launcher.launch(config))}\n              disabled={launcher.isBusy}\n              aria-label={t(I18nKey.DO_AGENTS$NEW_SESSION)}\n              title={t(I18nKey.DO_AGENTS$NEW_SESSION)}\n              data-testid=\"mars-session-panel-new\"\n              className={ICON_BUTTON_CLASS}\n            >\n              {isLaunching ? (\n                <Loader2 className=\"size-3.5 animate-spin\" aria-hidden />\n              ) : (\n                <Plus className=\"size-4\" aria-hidden />\n              )}\n            </button>\n          ) : null}\n        </div>\n      </div>\n\n      <div className=\"flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden overscroll-contain custom-scrollbar-always\">\n        {isLaunching ? (\n          <p\n            data-testid=\"mars-session-panel-launching\"\n            className=\"flex h-8 items-center gap-2 pl-2 text-sm text-muted\"\n          >\n            <Loader2 className=\"size-3.5 animate-spin\" aria-hidden />\n            {t(I18nKey.BACKEND$DIGITALOCEAN_CREATING_SESSION)}\n          </p>\n        ) : null}\n\n        {error ? (\n          <ErrorAlert\n            testId=\"mars-session-panel-error\"\n            className=\"mx-1 my-1 p-2 text-xs text-pretty\"\n          >\n            {error}\n          </ErrorAlert>\n        ) : null}\n\n        {agentsQuery.data && sessions.length === 0 ? (\n          <p className=\"px-2 py-2 text-xs text-muted\">\n            {t(I18nKey.DO_AGENTS$NO_SESSIONS)}\n          </p>\n        ) : null}\n\n        <ul className=\"flex flex-col gap-0.5\">\n          {sessions.map((session) => (\n            <MarsSessionRow\n              key={session.session_id}\n              session={session}\n              isCurrent={session.session_id === backend.marsSessionId}\n              launcher={launcher}\n              conversations={conversations}\n              onOpenCurrent={() =>\n                navigate(\n                  backendScopedPath(\n                    latestConversationId\n                      ? `/conversations/${latestConversationId}`\n                      : NEW_CHAT_PATH,\n                  ),\n                )\n              }\n              onOpen={() => void run(() => launcher.open(session, config))}\n              onNewChat={() => navigate(backendScopedPath(NEW_CHAT_PATH))}\n            />\n          ))}\n        </ul>\n      </div>\n\n      {manageOpen ? (\n        <React.Suspense fallback={null}>\n          <ManageBackendsModal\n            initialView=\"managed-agents\"\n            onClose={() => setManageOpen(false)}\n          />\n        </React.Suspense>\n      ) : null}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/sidebar/sidebar-layout.ts",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[1]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 63,
          "column": 5,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 63,
          "endColumn": 91
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cn } from \"#/utils/utils\";\n\n/** Nav rows and side nav links \u2014 hover color/background snap instantly (no fade). */\nexport const navInteractiveTransitionClassName =\n  \"transition-none motion-reduce:transition-none\";\n\n/** Expanded sidebar icon column beside labels (matches 36px nav rows). */\nexport const SIDEBAR_ICON_SLOT_CLASS =\n  \"flex h-9 w-4.5 shrink-0 items-center justify-center\";\n\n/** Collapsed rail: 36px-tall hit target; width follows the row (full rail). */\nexport const SIDEBAR_COLLAPSED_ICON_SLOT_CLASS =\n  \"relative h-9 min-h-9 max-h-9 w-full shrink-0\";\n\nexport function sidebarHeaderRowClassName(collapsed: boolean): string {\n  return cn(\n    \"flex h-10 min-h-10 shrink-0 items-center w-full\",\n    collapsed ? \"px-0\" : \"gap-2 pl-2.5 pr-2.5\",\n  );\n}\n\nexport const SIDEBAR_ROW_INTERACTIVE_CLASS = {\n  active: \"bg-tertiary text-contrast font-normal\",\n  idle: \"text-muted hover:text-contrast hover:bg-surface-raised\",\n} as const;\n\nexport function sidebarNavListClassName(collapsed: boolean): string {\n  return cn(\n    \"flex flex-col gap-0.5 w-full shrink-0 items-stretch\",\n    !collapsed && \"pr-2.5\",\n  );\n}\n\nexport function sidebarNavRowClassName(options?: {\n  indent?: boolean;\n  collapsed?: boolean;\n}): string {\n  const { indent = false, collapsed = false } = options ?? {};\n  return cn(\n    \"flex h-9 min-h-9 min-w-0 items-center rounded-md\",\n    navInteractiveTransitionClassName,\n    \"text-sm leading-5 w-full\",\n    collapsed\n      ? \"group gap-0 px-0 overflow-visible bg-transparent hover:bg-transparent\"\n      : \"gap-2 px-2.5 overflow-hidden\",\n    indent && !collapsed && \"pl-7\",\n  );\n}\n\nexport function sidebarCollapsedIconBgClassName(active: boolean): string {\n  return cn(\n    \"pointer-events-none absolute inset-0 z-0 rounded-md\",\n    navInteractiveTransitionClassName,\n    active ? \"bg-tertiary\" : \"bg-transparent group-hover:bg-surface-raised\",\n  );\n}\n\n/** Matches expanded row `px-2.5` + 18px icon column alignment. */\nexport function sidebarCollapsedIconGlyphClassName(active: boolean): string {\n  return cn(\n    // Full width inside the 36px-tall slot; `pl-2.5` aligns with expanded `px-2.5` rows.\n    // Do not set a narrow `w-4.5` here \u2014 with horizontal padding it shrinks the glyph.\n    \"relative z-[1] flex h-full w-full items-center justify-start pl-2.5 [&_svg]:shrink-0\",\n    active\n      ? \"text-contrast font-normal\"\n      : \"text-muted group-hover:text-contrast\",\n  );\n}\n\nexport function sidebarNavLabelClassName(collapsed: boolean): string {\n  if (collapsed) {\n    return \"sr-only\";\n  }\n  return \"min-w-0 truncate\";\n}\n\nexport const SIDEBAR_ICON_BUTTON_CLASS = cn(\n  \"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md\",\n  navInteractiveTransitionClassName,\n  \"cursor-pointer\",\n);\n\n/** Logo + expand overlay when the desktop rail is collapsed. */\nexport const SIDEBAR_COLLAPSED_LOGO_WRAPPER_CLASS = cn(\n  \"relative hidden md:block shrink-0 overflow-visible\",\n  SIDEBAR_COLLAPSED_ICON_SLOT_CLASS,\n);\n\nexport const SIDEBAR_COLLAPSE_TOGGLE_OVERLAY_CLASS = cn(\n  \"absolute left-1/2 top-1/2 hidden h-7 w-7 -translate-x-1/2 -translate-y-1/2 md:inline-flex\",\n  \"items-center justify-center rounded-md\",\n  navInteractiveTransitionClassName,\n  \"cursor-pointer\",\n  \"text-muted hover:text-contrast hover:bg-surface-raised\",\n);\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/sidebar/sidebar-rail-body.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[1]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 367,
          "column": 31,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 367,
          "endColumn": 86
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bottom-[-4px]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 378,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 378,
          "endColumn": 78
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[calc(100%+0.625rem)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 404,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 404,
          "endColumn": 83
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  ChevronLeft,\n  ChevronRight,\n  Plus,\n  Server,\n  Settings,\n} from \"lucide-react\";\nimport { OpenHandsLogoButton } from \"#/components/shared/buttons/openhands-logo-button\";\nimport { NavigationLink } from \"#/components/shared/navigation-link\";\nimport { getLockedCloudHost } from \"#/api/agent-server-config\";\nimport {\n  automationListPath,\n  getInterfaceCopy,\n  hasAutomationInterface,\n} from \"#/manifests/automation-interface\";\nimport {\n  CUSTOMIZE_PATH,\n  usePinnedHomeRoute,\n} from \"#/hooks/use-pinned-home-route\";\nimport { SidebarCollapsedIconSlot } from \"./sidebar-collapsed-icon-slot\";\nimport { SidebarNavLink } from \"./sidebar-nav-link\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\nimport { BackendSelector } from \"#/components/features/backends/backend-selector\";\nimport { BackendStatusDot } from \"#/components/features/backends/backend-status-dot\";\nimport { CommandMenuTrigger } from \"#/components/features/command-menu/command-menu-trigger\";\nimport { AgentCanvasVersionTile } from \"#/components/features/settings/agent-canvas-version-tile\";\nimport { SidebarConversationList } from \"./sidebar-conversation-list\";\nimport { SidebarOnboardingChecklist } from \"./sidebar-onboarding-checklist\";\nimport AutomationsIcon from \"#/icons/automations.svg?react\";\nimport {\n  SIDEBAR_COLLAPSE_TOGGLE_OVERLAY_CLASS,\n  SIDEBAR_COLLAPSED_LOGO_WRAPPER_CLASS,\n  SIDEBAR_ICON_BUTTON_CLASS,\n  SIDEBAR_ICON_SLOT_CLASS,\n  sidebarHeaderRowClassName,\n  sidebarNavLabelClassName,\n  sidebarNavListClassName,\n  sidebarNavRowClassName,\n} from \"./sidebar-layout\";\nimport { useCanvasExtensionsRuntime } from \"#/components/features/canvas-extensions/canvas-extensions-runtime\";\nimport { CanvasExtensionIcon } from \"#/components/features/canvas-extensions/canvas-extension-icon\";\nimport type { Backend } from \"#/api/backend-registry/types\";\n\nconst ICON_SIZE = 18;\nconst SIDEBAR_LOGO_WIDTH = 34;\nconst SIDEBAR_LOGO_HEIGHT = Math.round((SIDEBAR_LOGO_WIDTH * 30) / 46);\n\nexport interface SidebarRailBodyProps {\n  collapsed: boolean;\n  showCollapseToggle: boolean;\n  showMobileCloseButton?: boolean;\n  onCloseMobile?: () => void;\n  collapseToggleLabel: string;\n  onCollapse: () => void;\n  onExpand: () => void;\n  showCollapsedExpandButton: boolean;\n  isExtensionsActive: boolean;\n  currentPath: string;\n  activeBackend: Backend;\n  activeOrgId: string | null;\n  activeBackendHealth: { isConnected: boolean | null } | undefined;\n  collapsedBackendPopoverOpen: boolean;\n  setCollapsedBackendPopoverOpen: (open: boolean) => void;\n  collapsedBackendPopoverRef: React.RefObject<HTMLDivElement | null>;\n  collapsedBackendCloseTimer: React.MutableRefObject<ReturnType<\n    typeof setTimeout\n  > | null>;\n  onOpenAddBackend: () => void;\n  onOpenManageBackends: () => void;\n}\n\nexport function SidebarRailBody({\n  collapsed,\n  showCollapseToggle,\n  showMobileCloseButton = false,\n  onCloseMobile,\n  collapseToggleLabel,\n  onCollapse,\n  onExpand,\n  showCollapsedExpandButton,\n  isExtensionsActive,\n  currentPath,\n  activeBackend,\n  activeOrgId,\n  activeBackendHealth,\n  collapsedBackendPopoverOpen,\n  setCollapsedBackendPopoverOpen,\n  collapsedBackendPopoverRef,\n  collapsedBackendCloseTimer,\n  onOpenAddBackend,\n  onOpenManageBackends,\n}: SidebarRailBodyProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { pages: canvasExtensionPages } = useCanvasExtensionsRuntime();\n  const backendCloseTimerRef = collapsedBackendCloseTimer;\n  const { isPinnedRoute, togglePinnedRoute } = usePinnedHomeRoute();\n\n  const buildPinAction = (path: string, testId: string) => {\n    const pinned = isPinnedRoute(path);\n    return {\n      pinned,\n      onToggle: () => togglePinnedRoute(path),\n      label: pinned\n        ? t(I18nKey.SIDEBAR$UNPIN_AS_HOME)\n        : t(I18nKey.SIDEBAR$PIN_AS_HOME),\n      testId,\n    };\n  };\n\n  const isCloudBackend = activeBackend.kind === \"cloud\";\n  // `org` is consumed by the cloud settings loader so the page opens on the\n  // org that is active here instead of the cloud's last-used org.\n  const cloudSettingsOrgQuery = activeOrgId\n    ? `?org=${encodeURIComponent(activeOrgId)}`\n    : \"\";\n  const cloudSettingsUrl = isCloudBackend\n    ? `${activeBackend.host.replace(/\\/+$/, \"\")}/settings${cloudSettingsOrgQuery}`\n    : null;\n  // Locked-to-Cloud (SaaS / self-hosted OHE) serves the canvas at /canvas on\n  // the cloud host itself, so cloud settings open in this tab and Back\n  // returns here (OHE-3242). Standalone / Electron keep the new tab.\n  const isLockedToCloud = getLockedCloudHost() !== null;\n\n  return (\n    <div className=\"flex min-h-0 flex-1 flex-col\">\n      <div className={sidebarHeaderRowClassName(collapsed)}>\n        <div\n          className={cn(\n            collapsed && showCollapseToggle\n              ? SIDEBAR_COLLAPSED_LOGO_WRAPPER_CLASS\n              : \"flex min-w-0 shrink-0 items-center\",\n          )}\n        >\n          <div\n            className={cn(\n              collapsed &&\n                showCollapseToggle &&\n                \"flex h-full w-full items-center justify-start pl-2.5 transition-opacity duration-150\",\n              collapsed && showCollapsedExpandButton && \"opacity-0\",\n            )}\n          >\n            <OpenHandsLogoButton\n              logoWidth={SIDEBAR_LOGO_WIDTH}\n              logoHeight={SIDEBAR_LOGO_HEIGHT}\n              logoClassName=\"max-w-none\"\n              className={cn(SIDEBAR_ICON_SLOT_CLASS, \"overflow-visible\")}\n            />\n          </div>\n          {collapsed && showCollapseToggle ? (\n            <button\n              type=\"button\"\n              data-testid=\"sidebar-collapse-toggle\"\n              aria-pressed={collapsed}\n              aria-label={collapseToggleLabel}\n              onClick={onExpand}\n              className={cn(\n                SIDEBAR_COLLAPSE_TOGGLE_OVERLAY_CLASS,\n                showCollapsedExpandButton\n                  ? \"opacity-100 pointer-events-auto\"\n                  : \"opacity-0 pointer-events-none\",\n              )}\n            >\n              <ChevronRight width={14} height={14} />\n            </button>\n          ) : null}\n        </div>\n        {!collapsed && showCollapseToggle ? (\n          <button\n            type=\"button\"\n            data-testid=\"sidebar-collapse-toggle\"\n            aria-pressed={collapsed}\n            aria-label={collapseToggleLabel}\n            onClick={onCollapse}\n            className={cn(\n              \"hidden md:inline-flex ml-auto\",\n              SIDEBAR_ICON_BUTTON_CLASS,\n              \"text-muted hover:text-contrast hover:bg-surface-raised\",\n            )}\n          >\n            <ChevronLeft width={14} height={14} />\n          </button>\n        ) : null}\n        {!collapsed && showMobileCloseButton ? (\n          <button\n            type=\"button\"\n            data-testid=\"sidebar-mobile-drawer-close\"\n            onClick={onCloseMobile}\n            aria-label={t(I18nKey.SIDEBAR$CLOSE_MENU)}\n            className={cn(\n              \"inline-flex ml-auto\",\n              SIDEBAR_ICON_BUTTON_CLASS,\n              \"text-muted hover:text-contrast hover:bg-surface-raised\",\n            )}\n          >\n            <ChevronLeft width={14} height={14} />\n          </button>\n        ) : null}\n      </div>\n\n      <nav className={sidebarNavListClassName(collapsed)}>\n        <CommandMenuTrigger collapsed={collapsed} />\n        <SidebarNavLink\n          to=\"/conversations\"\n          end\n          label={t(I18nKey.SIDEBAR$NEW_CHAT)}\n          testId=\"sidebar-conversations-link\"\n          collapsed={collapsed}\n          icon={<Plus width={ICON_SIZE} height={ICON_SIZE} />}\n        />\n        <SidebarNavLink\n          to={CUSTOMIZE_PATH}\n          label={t(I18nKey.NAV$CUSTOMIZE)}\n          testId=\"sidebar-skills-link\"\n          collapsed={collapsed}\n          forceActive={isExtensionsActive}\n          pinAction={buildPinAction(\n            CUSTOMIZE_PATH,\n            \"sidebar-pin-home-toggle-customize\",\n          )}\n          icon={\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width={ICON_SIZE}\n              height={ICON_SIZE}\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden=\"true\"\n            >\n              <path d=\"M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z\" />\n              <path d=\"m7 16.5-4.74-2.85\" />\n              <path d=\"m7 16.5 5-3\" />\n              <path d=\"M7 16.5v5.17\" />\n              <path d=\"M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z\" />\n              <path d=\"m17 16.5-5-3\" />\n              <path d=\"m17 16.5 4.74-2.85\" />\n              <path d=\"m17 16.5v5.17\" />\n              <path d=\"M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z\" />\n              <path d=\"M12 8 7.26 5.15\" />\n              <path d=\"m12 8 4.74-2.85\" />\n              <path d=\"M12 13.5V8\" />\n            </svg>\n          }\n        />\n        {/* The interface manifest owns this entry's label, so an absent\n            manifest leaves the rail without it rather than with host copy. */}\n        {hasAutomationInterface() && (\n          <SidebarNavLink\n            to={automationListPath()}\n            label={getInterfaceCopy().sidebarLabel}\n            testId=\"sidebar-automations-link\"\n            collapsed={collapsed}\n            icon={<AutomationsIcon width={ICON_SIZE} height={ICON_SIZE} />}\n            pinAction={buildPinAction(\n              automationListPath(),\n              \"sidebar-pin-home-toggle-automations\",\n            )}\n          />\n        )}\n        {canvasExtensionPages.map((page) => (\n          <SidebarNavLink\n            key={`${page.extension.name}:${page.contribution.id}`}\n            to={page.href}\n            label={page.contribution.nav_label || page.contribution.title}\n            testId={`sidebar-canvas-extension-${page.extension.name}-${page.contribution.id}`}\n            collapsed={collapsed}\n            icon={\n              <CanvasExtensionIcon\n                extension={page.extension}\n                size={ICON_SIZE}\n              />\n            }\n          />\n        ))}\n      </nav>\n\n      <SidebarConversationList collapsed={collapsed} />\n\n      {collapsed && showCollapseToggle ? (\n        <nav\n          className={cn(\n            sidebarNavListClassName(collapsed),\n            \"mt-auto pb-2 cursor-pointer\",\n          )}\n        >\n          <StyledTooltip\n            content={t(I18nKey.SIDEBAR$SETTINGS)}\n            placement=\"right\"\n          >\n            {isCloudBackend && cloudSettingsUrl ? (\n              <a\n                href={cloudSettingsUrl}\n                target={isLockedToCloud ? undefined : \"_blank\"}\n                rel={isLockedToCloud ? undefined : \"noopener noreferrer\"}\n                data-testid=\"collapsed-settings-link\"\n                aria-label={t(I18nKey.SIDEBAR$SETTINGS)}\n                className={sidebarNavRowClassName({ collapsed: true })}\n              >\n                <SidebarCollapsedIconSlot active={false}>\n                  <Settings width={ICON_SIZE} height={ICON_SIZE} />\n                </SidebarCollapsedIconSlot>\n                <span className={sidebarNavLabelClassName(true)}>\n                  {t(I18nKey.SIDEBAR$SETTINGS)}\n                </span>\n              </a>\n            ) : (\n              <NavigationLink\n                to=\"/settings\"\n                data-testid=\"collapsed-settings-link\"\n                aria-label={t(I18nKey.SIDEBAR$SETTINGS)}\n                className={sidebarNavRowClassName({ collapsed: true })}\n              >\n                <SidebarCollapsedIconSlot\n                  active={currentPath.startsWith(\"/settings\")}\n                >\n                  <Settings width={ICON_SIZE} height={ICON_SIZE} />\n                </SidebarCollapsedIconSlot>\n                <span className={sidebarNavLabelClassName(true)}>\n                  {t(I18nKey.SIDEBAR$SETTINGS)}\n                </span>\n              </NavigationLink>\n            )}\n          </StyledTooltip>\n          <div\n            className=\"relative\"\n            ref={collapsedBackendPopoverRef}\n            onMouseEnter={() => {\n              if (backendCloseTimerRef.current) {\n                clearTimeout(backendCloseTimerRef.current);\n                backendCloseTimerRef.current = null;\n              }\n              setCollapsedBackendPopoverOpen(true);\n            }}\n            onMouseLeave={() => {\n              backendCloseTimerRef.current = setTimeout(\n                () => setCollapsedBackendPopoverOpen(false),\n                150,\n              );\n            }}\n          >\n            <button\n              type=\"button\"\n              data-testid=\"collapsed-backend-selector-link\"\n              aria-label={t(I18nKey.BACKEND$MANAGE)}\n              aria-expanded={collapsedBackendPopoverOpen}\n              onMouseDown={(event) => {\n                event.preventDefault();\n                event.stopPropagation();\n              }}\n              onMouseUp={(event) => event.stopPropagation()}\n              className={cn(\n                sidebarNavRowClassName({ collapsed: true }),\n                \"relative\",\n              )}\n            >\n              <SidebarCollapsedIconSlot active={collapsedBackendPopoverOpen}>\n                <span className=\"relative inline-flex size-4.5 shrink-0 items-center justify-center\">\n                  <BackendStatusDot\n                    isConnected={activeBackendHealth?.isConnected ?? null}\n                    className=\"absolute -left-0.5 -top-0.5 z-[1] pointer-events-none\"\n                  />\n                  <Server width={ICON_SIZE} height={ICON_SIZE} />\n                </span>\n              </SidebarCollapsedIconSlot>\n              <span className={sidebarNavLabelClassName(true)}>\n                {t(I18nKey.BACKEND$MANAGE)}\n              </span>\n            </button>\n            {collapsedBackendPopoverOpen ? (\n              <div\n                className=\"absolute bottom-[-4px] left-full pl-2.5 z-40 w-68\"\n                onClick={(event) => event.stopPropagation()}\n              >\n                <BackendSelector\n                  sidebarCollapsed={collapsed}\n                  hideTrigger\n                  defaultOpen\n                  openUpward\n                  onSelectOption={() => setCollapsedBackendPopoverOpen(false)}\n                  onOpenAddBackend={onOpenAddBackend}\n                  onOpenManageBackends={onOpenManageBackends}\n                />\n              </div>\n            ) : null}\n          </div>\n        </nav>\n      ) : null}\n\n      {!collapsed ? (\n        <>\n          <div className=\"mb-2 shrink-0 pr-2.5\">\n            <SidebarOnboardingChecklist collapsed={collapsed} />\n          </div>\n          <div\n            className={cn(\n              \"flex flex-col items-stretch max-w-none box-border shrink-0 gap-2\",\n              \"-ml-2.5 w-[calc(100%+0.625rem)] border-t border-border pt-2 px-2.5\",\n            )}\n          >\n            <AgentCanvasVersionTile hideWhenUpToDate />\n            <BackendSelector sidebarCollapsed={collapsed} openUpward />\n          </div>\n        </>\n      ) : null}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/sidebar/sidebar.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"transition-[width,min-width]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 213,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 213,
          "endColumn": 105
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"w-[min(300px,85vw)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 247,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 247,
          "endColumn": 101
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "jsx-a11y/no-noninteractive-element-interactions",
          "severity": 2,
          "message": "Non-interactive elements should not be assigned mouse or keyboard event listeners.",
          "line": 200,
          "column": 7,
          "nodeType": "JSXOpeningElement",
          "endLine": 220,
          "endColumn": 8,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "the aside acts as a hit-area for the collapsed rail; nested controls handle their own keyboard interactions."
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { SidebarRailBody } from \"./sidebar-rail-body\";\nimport { getErrorStatus, useSettings } from \"#/hooks/query/use-settings\";\nimport { useConfig } from \"#/hooks/query/use-config\";\nimport { displayErrorToast } from \"#/utils/custom-toast-handlers\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport { useActiveBackendContext } from \"#/contexts/active-backend-context\";\nimport { cn } from \"#/utils/utils\";\nimport { useSidebarMobileNav } from \"./sidebar-mobile-nav-context\";\nimport { useSidebarStore } from \"#/stores/sidebar-store\";\nimport { useClickOutsideElement } from \"#/hooks/use-click-outside-element\";\nimport { useCloseOnEscape } from \"#/hooks/use-close-on-escape\";\nimport { useBackendsHealth } from \"#/hooks/query/use-backends-health\";\n// The LLM settings modal is only mounted when the settings query 404s and\n// LLM settings aren't hidden \u2014 keep it out of the sidebar's eager graph.\nconst SettingsModal = React.lazy(() =>\n  import(\"#/components/shared/modals/settings/settings-modal\").then((m) => ({\n    default: m.SettingsModal,\n  })),\n);\n\n// Add/Manage backend modals are lifted into the sidebar (instead of living\n// inside BackendSelector) so they survive the collapsed popover unmounting\n// when the user moves the cursor out of the popover toward the modal.\nconst AddBackendModal = React.lazy(() =>\n  import(\"#/components/features/backends/add-backend-modal\").then((m) => ({\n    default: m.AddBackendModal,\n  })),\n);\nconst ManageBackendsModal = React.lazy(() =>\n  import(\"#/components/features/backends/manage-backends-modal\").then((m) => ({\n    default: m.ManageBackendsModal,\n  })),\n);\n\nconst MOBILE_DRAWER_TRANSITION_MS = 250;\nconst MOBILE_DRAWER_TRANSITION_STYLE = {\n  \"--mobile-drawer-transition-duration\": `${MOBILE_DRAWER_TRANSITION_MS}ms`,\n} as React.CSSProperties;\n\nexport function Sidebar() {\n  const { t } = useTranslation(\"openhands\");\n  const { currentPath } = useNavigation();\n  const { data: config } = useConfig();\n  const {\n    data: settings,\n    error: settingsError,\n    isError: settingsIsError,\n    isFetching: isFetchingSettings,\n  } = useSettings();\n  const { backends, active } = useActiveBackendContext();\n  const healthByBackendId = useBackendsHealth(backends);\n  const activeBackendHealth = healthByBackendId[active.backend.id];\n  const collapsed = useSidebarStore((state) => state.collapsed);\n  const setCollapsed = useSidebarStore((state) => state.setCollapsed);\n  const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);\n  const [collapsedBackendPopoverOpen, setCollapsedBackendPopoverOpen] =\n    React.useState(false);\n  const collapsedBackendCloseTimer = React.useRef<ReturnType<\n    typeof setTimeout\n  > | null>(null);\n  // Lifted out of BackendSelector so opening these modals from the\n  // collapsed-sidebar popover doesn't lose state when the popover unmounts\n  // (cursor moving toward the modal triggers onMouseLeave -> close).\n  const [addBackendModalOpen, setAddBackendModalOpen] = React.useState(false);\n  const [manageBackendsModalOpen, setManageBackendsModalOpen] =\n    React.useState(false);\n  const [collapsedRailHovered, setCollapsedRailHovered] = React.useState(false);\n  const suppressCollapsedExpandRef = React.useRef(false);\n  const [, refreshCollapsedExpandGate] = React.useReducer((n) => n + 1, 0);\n  const { isOpen: isMobileNavOpen, close: closeMobileNav } =\n    useSidebarMobileNav();\n  const [mobileDrawerMounted, setMobileDrawerMounted] = React.useState(false);\n  const [mobileDrawerVisible, setMobileDrawerVisible] = React.useState(false);\n  const collapsedBackendPopoverRef = useClickOutsideElement<HTMLDivElement>(\n    () => setCollapsedBackendPopoverOpen(false),\n  );\n  const settingsErrorStatus = getErrorStatus(settingsError);\n\n  React.useEffect(() => {\n    closeMobileNav();\n  }, [currentPath, closeMobileNav]);\n\n  React.useEffect(() => {\n    if (isMobileNavOpen) {\n      setMobileDrawerMounted(true);\n      const frame = requestAnimationFrame(() => {\n        setMobileDrawerVisible(true);\n      });\n      return () => cancelAnimationFrame(frame);\n    }\n\n    setMobileDrawerVisible(false);\n    const timer = window.setTimeout(() => {\n      setMobileDrawerMounted(false);\n    }, MOBILE_DRAWER_TRANSITION_MS);\n    return () => window.clearTimeout(timer);\n  }, [isMobileNavOpen]);\n\n  // A menu or the command menu opened over the drawer takes Escape first.\n  useCloseOnEscape(isMobileNavOpen, closeMobileNav);\n\n  React.useEffect(() => {\n    if (currentPath === \"/settings\") {\n      setSettingsModalIsOpen(false);\n    } else if (\n      !isFetchingSettings &&\n      settingsIsError &&\n      settingsErrorStatus !== 404\n    ) {\n      // We don't show toast errors for settings in the global error handler\n      // because we have a special case for 404 errors\n      displayErrorToast(t(I18nKey.SETTINGS$FETCH_ERROR));\n    } else if (\n      settingsErrorStatus === 404 &&\n      !config?.feature_flags?.hide_llm_settings\n    ) {\n      setSettingsModalIsOpen(true);\n    }\n  }, [\n    currentPath,\n    isFetchingSettings,\n    settingsIsError,\n    settingsErrorStatus,\n    config?.feature_flags?.hide_llm_settings,\n    t,\n  ]);\n\n  const collapseToggleLabel = t(\n    collapsed ? I18nKey.SIDEBAR$EXPAND : I18nKey.SIDEBAR$COLLAPSE,\n  );\n  const handleCollapsedRailClick = React.useCallback(\n    (event: React.MouseEvent<HTMLElement>) => {\n      if (!collapsed) {\n        return;\n      }\n\n      const target = event.target;\n      if (!(target instanceof HTMLElement)) {\n        return;\n      }\n\n      // Keep existing behavior for explicit controls/links and only use\n      // this as a convenience hit-area for empty collapsed-rail space.\n      if (\n        target.closest(\n          \"a,button,input,textarea,select,[role='button'],[role='link']\",\n        )\n      ) {\n        return;\n      }\n\n      setCollapsed(false);\n    },\n    [collapsed, setCollapsed],\n  );\n  const handleCollapse = React.useCallback(() => {\n    setCollapsedRailHovered(false);\n    suppressCollapsedExpandRef.current = true;\n    refreshCollapsedExpandGate();\n    setCollapsed(true);\n    window.setTimeout(() => {\n      suppressCollapsedExpandRef.current = false;\n      refreshCollapsedExpandGate();\n    }, 250);\n  }, [setCollapsed]);\n  const showCollapsedExpandButton =\n    collapsed && collapsedRailHovered && !suppressCollapsedExpandRef.current;\n\n  const isExtensionsActive =\n    currentPath === \"/customize\" ||\n    currentPath.startsWith(\"/skills\") ||\n    currentPath === \"/plugins\" ||\n    currentPath === \"/apps\" ||\n    currentPath === \"/mcp\";\n\n  const railBodyProps = {\n    collapseToggleLabel,\n    onCollapse: handleCollapse,\n    onExpand: () => setCollapsed(false),\n    showCollapsedExpandButton,\n    isExtensionsActive,\n    currentPath,\n    activeBackend: active.backend,\n    activeOrgId: active.orgId,\n    activeBackendHealth,\n    collapsedBackendPopoverOpen,\n    setCollapsedBackendPopoverOpen,\n    collapsedBackendPopoverRef,\n    collapsedBackendCloseTimer,\n    onOpenAddBackend: () => setAddBackendModalOpen(true),\n    onOpenManageBackends: () => setManageBackendsModalOpen(true),\n  };\n\n  return (\n    <>\n      {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the aside acts as a hit-area for the collapsed rail; nested controls handle their own keyboard interactions. */}\n      <aside\n        aria-label={t(I18nKey.SIDEBAR$NAVIGATION_LABEL)}\n        data-collapsed={collapsed ? \"true\" : \"false\"}\n        onClick={handleCollapsedRailClick}\n        onMouseEnter={() => {\n          if (collapsed) {\n            setCollapsedRailHovered(true);\n          }\n        }}\n        onMouseLeave={() => {\n          setCollapsedRailHovered(false);\n        }}\n        className={cn(\n          \"max-md:hidden flex bg-canvas-base flex-col min-h-0 transition-[width,min-width] duration-200\",\n          \"md:border-r md:border-border md:h-full\",\n          collapsed\n            ? \"md:w-15 md:min-w-15 md:px-2.5\"\n            : \"md:w-75 md:min-w-75 pb-2 md:pl-2.5 md:pr-0\",\n          currentPath === \"/\" && \"md:pb-3\",\n        )}\n      >\n        <SidebarRailBody\n          collapsed={collapsed}\n          showCollapseToggle\n          {...railBodyProps}\n        />\n      </aside>\n\n      {mobileDrawerMounted ? (\n        <>\n          <div\n            className={cn(\n              \"fixed inset-0 z-40 bg-black/50 md:hidden\",\n              \"transition-opacity duration-(--mobile-drawer-transition-duration) ease-in-out motion-reduce:transition-none\",\n              mobileDrawerVisible\n                ? \"opacity-100\"\n                : \"pointer-events-none opacity-0\",\n            )}\n            style={MOBILE_DRAWER_TRANSITION_STYLE}\n            onClick={closeMobileNav}\n            aria-hidden={!mobileDrawerVisible}\n          />\n          <aside\n            aria-label={t(I18nKey.SIDEBAR$NAVIGATION_LABEL)}\n            data-testid=\"sidebar-mobile-drawer\"\n            aria-hidden={!mobileDrawerVisible}\n            className={cn(\n              \"fixed inset-y-0 left-0 z-50 flex min-h-0 w-[min(300px,85vw)] flex-col bg-canvas-base\",\n              \"border-r border-border pb-2 pl-2.5 pr-0 md:hidden\",\n              \"transition-transform duration-(--mobile-drawer-transition-duration) ease-in-out motion-reduce:transition-none\",\n              mobileDrawerVisible ? \"translate-x-0\" : \"-translate-x-full\",\n            )}\n            style={MOBILE_DRAWER_TRANSITION_STYLE}\n          >\n            <SidebarRailBody\n              collapsed={false}\n              showCollapseToggle={false}\n              showMobileCloseButton\n              onCloseMobile={closeMobileNav}\n              {...railBodyProps}\n            />\n          </aside>\n        </>\n      ) : null}\n\n      {settingsModalIsOpen && (\n        <React.Suspense fallback={null}>\n          <SettingsModal\n            settings={settings}\n            onClose={() => setSettingsModalIsOpen(false)}\n          />\n        </React.Suspense>\n      )}\n      {addBackendModalOpen && (\n        <React.Suspense fallback={null}>\n          <AddBackendModal onClose={() => setAddBackendModalOpen(false)} />\n        </React.Suspense>\n      )}\n      {manageBackendsModalOpen && (\n        <React.Suspense fallback={null}>\n          <ManageBackendsModal\n            onClose={() => setManageBackendsModalOpen(false)}\n          />\n        </React.Suspense>\n      )}\n    </>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/skills/add-skill-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 39,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 39,
          "endColumn": 93
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[90vw]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 124,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 124,
          "endColumn": 125
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[85vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 124,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 124,
          "endColumn": 125
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport {\n  ADD_SKILL_DOCS_URL,\n  ADD_SKILL_EXAMPLE_COMMAND,\n} from \"#/constants/skills-docs\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { modalTitleLgClassName } from \"#/utils/modal-classes\";\nimport CheckmarkIcon from \"#/icons/checkmark.svg?react\";\nimport CopyIcon from \"#/icons/copy.svg?react\";\n\ninterface AddSkillModalProps {\n  onClose: () => void;\n}\n\nconst ADD_SKILL_STEP_KEYS = [\n  I18nKey.SETTINGS$SKILLS_ADD_MODAL_STEP_1,\n  I18nKey.SETTINGS$SKILLS_ADD_MODAL_STEP_2,\n  I18nKey.SETTINGS$SKILLS_ADD_MODAL_STEP_3,\n  I18nKey.SETTINGS$SKILLS_ADD_MODAL_STEP_4,\n  I18nKey.SETTINGS$SKILLS_ADD_MODAL_STEP_5,\n] as const;\n\nconst ADD_SKILL_INLINE_CODE_COMPONENTS = {\n  cmd: <InlineCodeChip />,\n  path: <InlineCodeChip />,\n  env: <InlineCodeChip />,\n};\n\nfunction InlineCodeChip({ children }: { children?: React.ReactNode }) {\n  return (\n    <code\n      className={cn(\n        \"mx-0.5 inline-block rounded-sm border border-border-subtle\",\n        \"bg-surface-raised px-1.5 py-0.5 align-baseline font-mono text-[11px] text-contrast\",\n      )}\n    >\n      {children}\n    </code>\n  );\n}\n\nfunction AddSkillExampleBlock() {\n  const { t } = useTranslation(\"openhands\");\n  const [copied, setCopied] = React.useState(false);\n\n  const handleCopy = async () => {\n    await navigator.clipboard.writeText(ADD_SKILL_EXAMPLE_COMMAND);\n    setCopied(true);\n  };\n\n  React.useEffect(() => {\n    if (!copied) return undefined;\n    const timeout = setTimeout(() => setCopied(false), 2000);\n    return () => clearTimeout(timeout);\n  }, [copied]);\n\n  return (\n    <div className=\"relative\">\n      <pre\n        data-testid=\"add-skill-modal-example\"\n        className={cn(\n          \"overflow-x-auto rounded-sm border border-border-subtle\",\n          \"bg-surface-raised p-2 pr-10 text-xs text-contrast\",\n        )}\n      >\n        {ADD_SKILL_EXAMPLE_COMMAND}\n      </pre>\n      <button\n        type=\"button\"\n        data-testid=\"add-skill-modal-example-copy\"\n        aria-label={t(copied ? I18nKey.BUTTON$COPIED : I18nKey.BUTTON$COPY)}\n        disabled={copied}\n        onClick={handleCopy}\n        className={cn(\n          \"absolute right-2 top-2 cursor-pointer rounded-sm border border-border-subtle\",\n          \"bg-base-secondary p-1 text-tertiary-alt transition-colors\",\n          \"hover:bg-surface hover:text-contrast disabled:cursor-default [&_path]:fill-current\",\n        )}\n      >\n        {copied ? (\n          <CheckmarkIcon width={14} height={14} />\n        ) : (\n          <CopyIcon width={14} height={14} />\n        )}\n      </button>\n    </div>\n  );\n}\n\nfunction AddSkillTransParagraph({\n  i18nKey,\n}: {\n  i18nKey:\n    | typeof I18nKey.SETTINGS$SKILLS_ADD_MODAL_CHAT_BODY\n    | typeof I18nKey.SETTINGS$SKILLS_ADD_MODAL_STORAGE_BODY\n    | typeof I18nKey.SETTINGS$SKILLS_ADD_MODAL_PRIVATE_REPOS;\n}) {\n  return (\n    <p className=\"text-xs leading-relaxed text-tertiary-light\">\n      <Trans\n        ns=\"openhands\"\n        i18nKey={i18nKey}\n        components={ADD_SKILL_INLINE_CODE_COMPONENTS}\n      />\n    </p>\n  );\n}\n\nexport function AddSkillModal({ onClose }: AddSkillModalProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <ModalBackdrop\n      onClose={onClose}\n      aria-label={t(I18nKey.SETTINGS$SKILLS_ADD_MODAL_TITLE)}\n    >\n      <div\n        data-testid=\"add-skill-modal\"\n        className=\"relative flex w-130 max-w-[90vw] max-h-[85vh] flex-col rounded-xl border border-border bg-base-secondary\"\n      >\n        <ModalCloseButton onClose={onClose} testId=\"add-skill-modal-close\" />\n        <header className=\"flex-shrink-0 px-6 pb-4 pt-6\">\n          <h2 className={cn(\"pr-6\", modalTitleLgClassName)}>\n            {t(I18nKey.SETTINGS$SKILLS_ADD_MODAL_TITLE)}\n          </h2>\n          <p className=\"mt-4 text-sm text-tertiary-light\">\n            {t(I18nKey.SETTINGS$SKILLS_ADD_MODAL_INTRO)}\n          </p>\n        </header>\n\n        <div className=\"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 custom-scrollbar\">\n          <section className=\"flex flex-col gap-2\">\n            <h3 className=\"text-sm font-semibold text-foreground\">\n              {t(I18nKey.SETTINGS$SKILLS_ADD_MODAL_CHAT_TITLE)}\n            </h3>\n            <AddSkillTransParagraph\n              i18nKey={I18nKey.SETTINGS$SKILLS_ADD_MODAL_CHAT_BODY}\n            />\n            <p className=\"text-xs text-tertiary-light\">\n              {t(I18nKey.SETTINGS$SKILLS_ADD_MODAL_EXAMPLE_LABEL)}\n            </p>\n            <AddSkillExampleBlock />\n            <ol className=\"list-decimal space-y-1 pl-4 text-xs leading-relaxed text-tertiary-light\">\n              {ADD_SKILL_STEP_KEYS.map((key) => (\n                <li key={key}>\n                  {key === I18nKey.SETTINGS$SKILLS_ADD_MODAL_STEP_3 ? (\n                    <Trans\n                      ns=\"openhands\"\n                      i18nKey={key}\n                      components={ADD_SKILL_INLINE_CODE_COMPONENTS}\n                    />\n                  ) : (\n                    t(key)\n                  )}\n                </li>\n              ))}\n            </ol>\n          </section>\n\n          <section className=\"flex flex-col gap-1\">\n            <h3 className=\"text-sm font-semibold text-foreground\">\n              {t(I18nKey.SETTINGS$SKILLS_ADD_MODAL_URL_FORMATS_TITLE)}\n            </h3>\n            <p className=\"text-xs leading-relaxed text-tertiary-light\">\n              {t(I18nKey.SETTINGS$SKILLS_ADD_MODAL_URL_FORMATS)}\n            </p>\n          </section>\n\n          <section className=\"flex flex-col gap-1\">\n            <h3 className=\"text-sm font-semibold text-foreground\">\n              {t(I18nKey.SETTINGS$SKILLS_ADD_MODAL_STORAGE_TITLE)}\n            </h3>\n            <AddSkillTransParagraph\n              i18nKey={I18nKey.SETTINGS$SKILLS_ADD_MODAL_STORAGE_BODY}\n            />\n          </section>\n\n          <AddSkillTransParagraph\n            i18nKey={I18nKey.SETTINGS$SKILLS_ADD_MODAL_PRIVATE_REPOS}\n          />\n\n          <a\n            href={ADD_SKILL_DOCS_URL}\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            data-testid=\"add-skill-modal-docs-link\"\n            className=\"self-start text-xs text-muted transition-colors hover:text-contrast hover:underline\"\n          >\n            {t(I18nKey.SETTINGS$SKILLS_ADD_MODAL_VIEW_DOCS)}\n          </a>\n        </div>\n\n        <footer className=\"flex flex-shrink-0 justify-end gap-2 px-6 pb-6 pt-4\">\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={onClose}\n            testId=\"add-skill-modal-dismiss\"\n          >\n            {t(I18nKey.BUTTON$CLOSE)}\n          </BrandButton>\n        </footer>\n      </div>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/skills/build-skill-pills.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[rgba(248,113,113,0.35)]\" hardcodes a color. Nearest theme tokens: border-status-error, border-danger. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 142,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 142,
          "endColumn": 221,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-status-error"
              },
              "fix": {
                "range": [
                  3606,
                  3806
                ],
                "text": "\"inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full border border-status-error bg-[rgba(248,113,113,0.12)] px-2 py-0.5 text-[11px] font-medium leading-4 text-[#fca5a5]\""
              },
              "desc": "Replace with \"border-status-error\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-danger"
              },
              "fix": {
                "range": [
                  3606,
                  3806
                ],
                "text": "\"inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full border border-danger bg-[rgba(248,113,113,0.12)] px-2 py-0.5 text-[11px] font-medium leading-4 text-[#fca5a5]\""
              },
              "desc": "Replace with \"border-danger\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[rgba(248,113,113,0.12)]\" hardcodes a color. Nearest theme tokens: bg-status-error, bg-danger. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 142,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 142,
          "endColumn": 221,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-status-error"
              },
              "fix": {
                "range": [
                  3606,
                  3806
                ],
                "text": "\"inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full border border-[rgba(248,113,113,0.35)] bg-status-error px-2 py-0.5 text-[11px] font-medium leading-4 text-[#fca5a5]\""
              },
              "desc": "Replace with \"bg-status-error\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-danger"
              },
              "fix": {
                "range": [
                  3606,
                  3806
                ],
                "text": "\"inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full border border-[rgba(248,113,113,0.35)] bg-danger px-2 py-0.5 text-[11px] font-medium leading-4 text-[#fca5a5]\""
              },
              "desc": "Replace with \"bg-danger\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 142,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 142,
          "endColumn": 221
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#fca5a5]\" hardcodes a color. Nearest theme tokens: text-markdown-alert-caution. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 142,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 142,
          "endColumn": 221,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-markdown-alert-caution"
              },
              "fix": {
                "range": [
                  3606,
                  3806
                ],
                "text": "\"inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full border border-[rgba(248,113,113,0.35)] bg-[rgba(248,113,113,0.12)] px-2 py-0.5 text-[11px] font-medium leading-4 text-markdown-alert-caution\""
              },
              "desc": "Replace with \"text-markdown-alert-caution\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[#fca5a5]\" hardcodes a color. Nearest theme tokens: bg-markdown-alert-caution. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 144,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 144,
          "endColumn": 63,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-markdown-alert-caution"
              },
              "fix": {
                "range": [
                  3843,
                  3879
                ],
                "text": "\"size-1.5 rounded-full bg-markdown-alert-caution\""
              },
              "desc": "Replace with \"bg-markdown-alert-caution\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 180,
          "column": 29,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 180,
          "endColumn": 72
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 6,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport type { TFunction } from \"i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type { SkillInfo } from \"#/types/settings\";\nimport { cn } from \"#/utils/utils\";\nimport { SkillTypeBadge } from \"./skill-type-badge\";\nimport {\n  SKILL_CARD_PILL_CLASS,\n  type SkillCardPill,\n} from \"./skill-card-pill-row\";\nimport {\n  getSkillCategory,\n  SKILL_CATEGORY_LABEL_KEYS,\n  UNCATEGORIZED_SKILL_CATEGORY,\n} from \"#/utils/skill-category\";\nimport { isRecommendedSkill } from \"#/utils/skill-enablement\";\n\ntype SkillPillVariant = \"card\" | \"detail\";\n\ninterface BuildSkillPillsOptions {\n  variant?: SkillPillVariant;\n  testIdPrefix?: string;\n}\n\nfunction pillTestId(\n  prefix: string | undefined,\n  skillName: string,\n  suffix: string,\n) {\n  if (prefix) {\n    return `${prefix}-${skillName}-${suffix}`;\n  }\n  return undefined;\n}\n\nexport function buildSkillPills(\n  skill: SkillInfo,\n  translate: TFunction,\n  options: BuildSkillPillsOptions = {},\n): SkillCardPill[] {\n  const { variant = \"card\", testIdPrefix } = options;\n  const category = getSkillCategory(skill);\n  const pills: SkillCardPill[] = [\n    {\n      id: `type-${skill.type}`,\n      node: <SkillTypeBadge type={skill.type} />,\n    },\n  ];\n\n  if (isRecommendedSkill(skill.name)) {\n    pills.push({\n      id: \"recommended\",\n      node: (\n        <span\n          data-testid={\n            pillTestId(testIdPrefix, skill.name, \"recommended\") ??\n            `skill-recommended-${skill.name}`\n          }\n          className={SKILL_CARD_PILL_CLASS}\n        >\n          {translate(I18nKey.SETTINGS$SKILLS_RECOMMENDED)}\n        </span>\n      ),\n    });\n  }\n\n  // An \"Other\" pill says nothing, and every local skill would carry one:\n  // only the catalog assigns categories.\n  if (category !== UNCATEGORIZED_SKILL_CATEGORY) {\n    pills.push({\n      id: `category-${category}`,\n      node: (\n        <span\n          data-testid={\n            pillTestId(testIdPrefix, skill.name, \"category\") ??\n            `skill-category-${skill.name}`\n          }\n          className={SKILL_CARD_PILL_CLASS}\n        >\n          {translate(SKILL_CATEGORY_LABEL_KEYS[category])}\n        </span>\n      ),\n    });\n  }\n\n  if (skill.version) {\n    pills.push({\n      id: `version-${skill.version}`,\n      node: (\n        <span\n          data-testid={\n            pillTestId(testIdPrefix, skill.name, \"version\") ??\n            `skill-version-${skill.name}`\n          }\n          className={SKILL_CARD_PILL_CLASS}\n        >\n          {translate(I18nKey.SETTINGS$SKILLS_VERSION, {\n            version: skill.version,\n          })}\n        </span>\n      ),\n    });\n  }\n\n  if (variant === \"detail\" && skill.license) {\n    pills.push({\n      id: `license-${skill.license}`,\n      node: (\n        <span\n          data-testid={pillTestId(testIdPrefix, skill.name, \"license\")}\n          className={SKILL_CARD_PILL_CLASS}\n        >\n          {skill.license}\n        </span>\n      ),\n    });\n  }\n\n  if (variant === \"detail\" && skill.compatibility) {\n    pills.push({\n      id: `compatibility-${skill.compatibility}`,\n      node: (\n        <span\n          data-testid={pillTestId(testIdPrefix, skill.name, \"compatibility\")}\n          className={SKILL_CARD_PILL_CLASS}\n        >\n          {skill.compatibility}\n        </span>\n      ),\n    });\n  }\n\n  if (skill.disable_model_invocation) {\n    pills.push({\n      id: \"disable-model-invocation\",\n      node: (\n        <span\n          data-testid={\n            pillTestId(testIdPrefix, skill.name, \"disable-model-invocation\") ??\n            `skill-disable-model-invocation-${skill.name}`\n          }\n          className=\"inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full border border-[rgba(248,113,113,0.35)] bg-[rgba(248,113,113,0.12)] px-2 py-0.5 text-[11px] font-medium leading-4 text-[#fca5a5]\"\n        >\n          <span className=\"size-1.5 rounded-full bg-[#fca5a5]\" />\n          {translate(I18nKey.SETTINGS$SKILLS_DISABLE_MODEL_INVOCATION)}\n        </span>\n      ),\n    });\n  }\n\n  if (variant === \"detail\" && skill.allowed_tools) {\n    for (const tool of skill.allowed_tools) {\n      pills.push({\n        id: `allowed-tool-${tool}`,\n        node: (\n          <span\n            data-testid={pillTestId(testIdPrefix, skill.name, `tool-${tool}`)}\n            className={cn(SKILL_CARD_PILL_CLASS, \"font-mono\")}\n          >\n            {tool}\n          </span>\n        ),\n      });\n    }\n  }\n\n  if (variant === \"detail\" && skill.metadata) {\n    for (const [key, value] of Object.entries(skill.metadata)) {\n      pills.push({\n        id: `metadata-${key}`,\n        node: (\n          <span\n            data-testid={pillTestId(\n              testIdPrefix,\n              skill.name,\n              `metadata-${key}`,\n            )}\n            className={SKILL_CARD_PILL_CLASS}\n          >\n            <span className=\"font-mono text-[10px] text-tertiary-light\">\n              {key}:\n            </span>{\" \"}\n            {value}\n          </span>\n        ),\n      });\n    }\n  }\n\n  for (const trigger of skill.triggers ?? []) {\n    pills.push({\n      id: `trigger-${trigger}`,\n      node: (\n        <span\n          data-testid={pillTestId(\n            testIdPrefix,\n            skill.name,\n            `trigger-${trigger}`,\n          )}\n          className={SKILL_CARD_PILL_CLASS}\n        >\n          {trigger}\n        </span>\n      ),\n    });\n  }\n\n  return pills;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/skills/extensions-navigation.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 120,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 120,
          "endColumn": 150
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { Blocks, ExternalLink } from \"lucide-react\";\nimport { CompactSectionNavigation } from \"#/components/shared/compact-section-navigation\";\nimport { useNavigation } from \"#/context/navigation-context\";\nimport { NavigationLink } from \"#/components/shared/navigation-link\";\nimport { cn } from \"#/utils/utils\";\nimport SkillsIcon from \"#/icons/skills.svg?react\";\nimport ServerProcessIcon from \"#/icons/server-process.svg?react\";\nimport { BackendSyncedSettingsBadge } from \"#/components/features/settings/backend-synced-settings-badge\";\nimport {\n  SIDEBAR_ROW_INTERACTIVE_CLASS,\n  sidebarNavRowClassName,\n} from \"#/components/features/sidebar/sidebar-layout\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useActiveBackendContext } from \"#/contexts/active-backend-context\";\nimport { isNoBackend } from \"#/api/backend-registry/active-store\";\n\n/** Only the Skills item points to a cloud-hosted page today. */\nconst CLOUD_LINKED_EXTENSION_PATH = \"/skills\";\n/** Backend-installed artifacts are not available on Cloud backends yet. */\nconst CLOUD_HIDDEN_EXTENSION_PATHS = new Set([\"/plugins\", \"/apps\"]);\n\ninterface ExtensionNavItem {\n  to: string;\n  label: string;\n  icon: React.ReactElement;\n  end?: boolean;\n  comingSoon?: boolean;\n}\n\nexport const EXTENSIONS_NAV_ITEMS: ExtensionNavItem[] = [\n  {\n    to: \"/mcp\",\n    label: \"MCP Servers\",\n    icon: <ServerProcessIcon width={16} height={16} />,\n    end: true,\n  },\n  {\n    to: \"/skills\",\n    label: \"Skills\",\n    icon: <SkillsIcon width={16} height={16} aria-hidden=\"true\" />,\n    end: true,\n  },\n  {\n    to: \"/plugins\",\n    label: \"Plugins\",\n    icon: (\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        width={16}\n        height={16}\n        aria-hidden=\"true\"\n      >\n        <path d=\"M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z\" />\n        <path d=\"m3.3 7 8.7 5 8.7-5\" />\n        <path d=\"M12 22V12\" />\n      </svg>\n    ),\n    end: true,\n  },\n  {\n    to: \"/apps\",\n    label: \"Apps\",\n    icon: <Blocks width={16} height={16} aria-hidden=\"true\" />,\n    end: true,\n  },\n];\n\nexport function ExtensionsNavigation() {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <aside\n      data-testid=\"extensions-navbar-desktop\"\n      className=\"hidden lg:flex lg:w-65 lg:shrink-0 lg:flex-col lg:gap-2 lg:sticky lg:top-8 lg:self-start\"\n    >\n      <span className=\"px-2 text-sm font-normal text-contrast\">\n        {t(I18nKey.NAV$CUSTOMIZE)}\n      </span>\n      <ExtensionsNavigationLinks />\n      <div className=\"px-2 pt-3\">\n        <BackendSyncedSettingsBadge />\n      </div>\n    </aside>\n  );\n}\n\nfunction ExtensionsNavigationLinks() {\n  const { t } = useTranslation(\"openhands\");\n  const { active } = useActiveBackendContext();\n  const { backend } = active;\n  const isCloudBackend = !isNoBackend(backend) && backend.kind === \"cloud\";\n  return (\n    <div className=\"flex flex-col gap-0.5 pt-0.5\">\n      {EXTENSIONS_NAV_ITEMS.filter(\n        (item) =>\n          !(CLOUD_HIDDEN_EXTENSION_PATHS.has(item.to) && isCloudBackend),\n      ).map((item) => {\n        const isCloudSkillsLink =\n          item.to === CLOUD_LINKED_EXTENSION_PATH && isCloudBackend;\n        const baseRow = (\n          <span className=\"shrink-0 flex items-center justify-center\">\n            {item.icon}\n          </span>\n        );\n        const label = (\n          <span className=\"truncate\">\n            {isCloudSkillsLink\n              ? t(I18nKey.SIDEBAR$SKILLS_AND_PLUGINS_CLOUD_LINK)\n              : item.label}\n          </span>\n        );\n        const comingSoonBadge = item.comingSoon && (\n          <span className=\"ml-auto shrink-0 rounded-full border border-contrast/20 bg-contrast/5 px-1.5 py-0.5 text-[10px] font-medium text-text-dim\">\n            {t(I18nKey.NAV$COMING_SOON)}\n          </span>\n        );\n\n        if (isCloudSkillsLink) {\n          const cloudSkillsUrl = `${backend.host.replace(/\\/+$/, \"\")}/settings/skills`;\n          return (\n            <a\n              key={item.to}\n              data-testid={`sidebar-extensions-${item.to}`}\n              href={cloudSkillsUrl}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className={cn(\n                sidebarNavRowClassName(),\n                \"truncate\",\n                SIDEBAR_ROW_INTERACTIVE_CLASS.idle,\n              )}\n            >\n              {baseRow}\n              {label}\n              <ExternalLink\n                className=\"ml-auto size-4 shrink-0 text-muted\"\n                aria-hidden\n              />\n            </a>\n          );\n        }\n\n        return (\n          <NavigationLink\n            key={item.to}\n            to={item.to}\n            end={item.end}\n            data-testid={`sidebar-extensions-${item.to}`}\n            className={({ isActive }) =>\n              cn(\n                sidebarNavRowClassName(),\n                \"truncate\",\n                isActive\n                  ? SIDEBAR_ROW_INTERACTIVE_CLASS.active\n                  : SIDEBAR_ROW_INTERACTIVE_CLASS.idle,\n              )\n            }\n          >\n            {baseRow}\n            {label}\n            {comingSoonBadge}\n          </NavigationLink>\n        );\n      })}\n    </div>\n  );\n}\n\nexport function ExtensionsCompactNavigation() {\n  const { t } = useTranslation(\"openhands\");\n  const { currentPath } = useNavigation();\n  const currentItem = EXTENSIONS_NAV_ITEMS.find(\n    (item) => currentPath === item.to || currentPath.startsWith(`${item.to}/`),\n  );\n\n  return (\n    <CompactSectionNavigation\n      label={t(I18nKey.NAV$CUSTOMIZE)}\n      currentLabel={currentItem?.label}\n      testId=\"extensions-compact-navigation\"\n    >\n      <ExtensionsNavigationLinks />\n    </CompactSectionNavigation>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/skills/skill-card-pill-row.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[-1]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 203,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 203,
          "endColumn": 118
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[9999]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 255,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 255,
          "endColumn": 67
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[20rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 255,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 255,
          "endColumn": 67
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets position. Style through classes; use CSS custom properties for dynamic values.",
          "line": 260,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 260,
          "endColumn": 32
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets top. Style through classes; use CSS custom properties for dynamic values.",
          "line": 261,
          "column": 20,
          "nodeType": "MemberExpression",
          "messageId": "inlineStyle",
          "endLine": 261,
          "endColumn": 34
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets left. Style through classes; use CSS custom properties for dynamic values.",
          "line": 262,
          "column": 21,
          "nodeType": "MemberExpression",
          "messageId": "inlineStyle",
          "endLine": 262,
          "endColumn": 36
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "jsx-a11y/no-noninteractive-element-interactions",
          "severity": 2,
          "message": "Non-interactive elements should not be assigned mouse or keyboard event listeners.",
          "line": 250,
          "column": 11,
          "nodeType": "JSXOpeningElement",
          "endLine": 269,
          "endColumn": 12,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "dialog surface must swallow clicks"
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 6,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { extensionModuleCardPillClassName } from \"#/utils/extension-module-card-classes\";\n\n/** @deprecated Use {@link extensionModuleCardPillClassName} from `#/utils/extension-module-card-classes`. */\nexport const SKILL_CARD_PILL_CLASS = extensionModuleCardPillClassName;\n\nconst PILL_GAP_PX = 6;\nconst OVERFLOW_PILL_WIDTH_PX = 40;\nconst OVERFLOW_POPOVER_GUTTER_PX = 8;\nconst OVERFLOW_POPOVER_OFFSET_PX = 4;\n\nfunction placePopoverByTrigger(\n  trigger: DOMRect,\n  popoverWidth: number,\n): { top: number; left: number } {\n  const maxLeft = window.innerWidth - OVERFLOW_POPOVER_GUTTER_PX - popoverWidth;\n  let left = trigger.left;\n  if (left > maxLeft) {\n    left = trigger.right - popoverWidth;\n  }\n  left = Math.min(\n    Math.max(OVERFLOW_POPOVER_GUTTER_PX, left),\n    Math.max(OVERFLOW_POPOVER_GUTTER_PX, maxLeft),\n  );\n  return { top: trigger.bottom + OVERFLOW_POPOVER_OFFSET_PX, left };\n}\n\nexport interface SkillCardPill {\n  id: string;\n  node: React.ReactNode;\n}\n\nfunction computeVisiblePillCount(\n  widths: number[],\n  containerWidth: number,\n): number {\n  if (widths.length === 0 || containerWidth <= 0) return 0;\n\n  let used = 0;\n  for (let i = 0; i < widths.length; i += 1) {\n    const width = widths[i]!;\n    const gap = i > 0 ? PILL_GAP_PX : 0;\n    const remaining = widths.length - i - 1;\n    const reserve = remaining > 0 ? OVERFLOW_PILL_WIDTH_PX + PILL_GAP_PX : 0;\n    if (used + gap + width + reserve > containerWidth) {\n      return Math.max(1, i);\n    }\n    used += gap + width;\n  }\n  return widths.length;\n}\n\ninterface SkillCardPillRowProps {\n  pills: SkillCardPill[];\n  testId: string;\n}\n\nexport function SkillCardPillRow({ pills, testId }: SkillCardPillRowProps) {\n  const { t } = useTranslation(\"openhands\");\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const measureRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const popoverRef = React.useRef<HTMLDivElement>(null);\n  const [visibleCount, setVisibleCount] = React.useState(pills.length);\n  const [isOverflowOpen, setIsOverflowOpen] = React.useState(false);\n  const [popoverBox, setPopoverBox] = React.useState<{\n    top: number;\n    left: number;\n  } | null>(null);\n\n  const recomputeVisibleCount = React.useCallback(() => {\n    const container = containerRef.current;\n    const measure = measureRef.current;\n    if (!container || !measure) return;\n\n    const widths = Array.from(measure.children).map(\n      (child) => (child as HTMLElement).offsetWidth,\n    );\n    setVisibleCount(computeVisiblePillCount(widths, container.clientWidth));\n  }, []);\n\n  // Recommended cards rebuild the pills array every render; compare ids so an\n  // open +N popover is not slammed shut under the cursor.\n  const pillsKey = pills.map((pill) => pill.id).join(\"\\u001f\");\n  const lastPillsKeyRef = React.useRef<string | null>(null);\n\n  React.useLayoutEffect(() => {\n    if (lastPillsKeyRef.current === pillsKey) {\n      return;\n    }\n    lastPillsKeyRef.current = pillsKey;\n    setIsOverflowOpen(false);\n    recomputeVisibleCount();\n  }, [pillsKey, recomputeVisibleCount]);\n\n  React.useEffect(() => {\n    const container = containerRef.current;\n    if (!container || typeof ResizeObserver === \"undefined\") return undefined;\n\n    const observer = new ResizeObserver(() => recomputeVisibleCount());\n    observer.observe(container);\n    return () => observer.disconnect();\n  }, [recomputeVisibleCount]);\n\n  const measurePopover = React.useCallback(() => {\n    const trigger = triggerRef.current;\n    if (!trigger) {\n      return;\n    }\n    const next = placePopoverByTrigger(\n      trigger.getBoundingClientRect(),\n      popoverRef.current?.offsetWidth ?? trigger.offsetWidth,\n    );\n    setPopoverBox((prev) =>\n      prev?.top === next.top && prev?.left === next.left ? prev : next,\n    );\n  }, []);\n\n  React.useLayoutEffect(() => {\n    if (!isOverflowOpen) {\n      setPopoverBox(null);\n      return undefined;\n    }\n    measurePopover();\n    window.addEventListener(\"resize\", measurePopover);\n    window.addEventListener(\"scroll\", measurePopover, true);\n    return () => {\n      window.removeEventListener(\"resize\", measurePopover);\n      window.removeEventListener(\"scroll\", measurePopover, true);\n    };\n  }, [isOverflowOpen, measurePopover]);\n\n  React.useLayoutEffect(() => {\n    if (isOverflowOpen && popoverBox && popoverRef.current) {\n      measurePopover();\n    }\n  }, [isOverflowOpen, measurePopover, popoverBox]);\n\n  React.useEffect(() => {\n    if (!isOverflowOpen) {\n      return undefined;\n    }\n    const onPointerDown = (event: MouseEvent) => {\n      const target = event.target as Node;\n      if (triggerRef.current?.contains(target)) {\n        return;\n      }\n      if (popoverRef.current?.contains(target)) {\n        return;\n      }\n      setIsOverflowOpen(false);\n    };\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        // The modal also listens for Escape on window. Keep this key press\n        // scoped to the open popover so it does not close both at once.\n        event.stopPropagation();\n        setIsOverflowOpen(false);\n      }\n    };\n    // mousedown (not click) so the opening click cannot race-close the panel,\n    // and so wrapping card/link activation is easier to cancel on the trigger.\n    document.addEventListener(\"mousedown\", onPointerDown);\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      document.removeEventListener(\"mousedown\", onPointerDown);\n      document.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [isOverflowOpen]);\n\n  if (pills.length === 0) return null;\n\n  const hiddenCount = Math.max(0, pills.length - visibleCount);\n  const overflowPills = pills.slice(visibleCount);\n\n  const stopCardActivation = (event: React.SyntheticEvent) => {\n    event.stopPropagation();\n  };\n\n  const stopCardActivationForKeyboard = (\n    event: React.KeyboardEvent<HTMLButtonElement>,\n  ) => {\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.stopPropagation();\n    }\n  };\n\n  const activateOverflow = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n    setIsOverflowOpen((open) => !open);\n  };\n\n  return (\n    <div data-testid={`${testId}-wrap`} className=\"min-w-0 overflow-hidden\">\n      <div\n        ref={measureRef}\n        aria-hidden\n        className=\"pointer-events-none fixed top-0 -left-2500 z-[-1] flex flex-nowrap items-center gap-1.5 opacity-0\"\n      >\n        {pills.map((pill) => (\n          <span key={pill.id} className=\"inline-flex shrink-0\">\n            {pill.node}\n          </span>\n        ))}\n      </div>\n      <div\n        ref={containerRef}\n        data-testid={testId}\n        className=\"flex w-full min-w-0 max-w-full flex-nowrap items-center gap-1.5 overflow-hidden\"\n      >\n        {pills.slice(0, visibleCount).map((pill) => (\n          <span key={pill.id} className=\"inline-flex shrink-0\">\n            {pill.node}\n          </span>\n        ))}\n        {hiddenCount > 0 ? (\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            data-testid={`${testId}-overflow`}\n            aria-expanded={isOverflowOpen}\n            aria-haspopup=\"dialog\"\n            aria-label={t(I18nKey.SETTINGS$SKILLS_PILLS_OVERFLOW_ARIA, {\n              count: hiddenCount,\n            })}\n            onMouseDown={stopCardActivation}\n            onClick={activateOverflow}\n            onKeyDown={stopCardActivationForKeyboard}\n            className={cn(\n              extensionModuleCardPillClassName,\n              \"cursor-pointer font-medium text-tertiary-alt hover:text-contrast\",\n            )}\n          >\n            {t(I18nKey.SETTINGS$SKILLS_PILLS_MORE, { count: hiddenCount })}\n          </button>\n        ) : null}\n      </div>\n\n      {isOverflowOpen &&\n        popoverBox &&\n        typeof document !== \"undefined\" &&\n        createPortal(\n          // Stop card-level click activation when interacting with the list.\n          // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- dialog surface must swallow clicks\n          <div\n            ref={popoverRef}\n            role=\"dialog\"\n            data-testid={`${testId}-overflow-popover`}\n            className={cn(\n              \"z-[9999] flex w-max max-w-[20rem] flex-col gap-1.5\",\n              \"rounded-md border border-border-subtle\",\n              \"bg-surface p-2 shadow-lg\",\n            )}\n            style={{\n              position: \"fixed\",\n              top: popoverBox.top,\n              left: popoverBox.left,\n            }}\n            onMouseDown={stopCardActivation}\n            onClick={(event) => {\n              event.preventDefault();\n              event.stopPropagation();\n            }}\n          >\n            {overflowPills.map((pill) => (\n              <div\n                key={pill.id}\n                data-testid={`${testId}-overflow-item`}\n                className=\"min-w-0\"\n              >\n                {pill.node}\n              </div>\n            ))}\n          </div>,\n          document.body,\n        )}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/skills/skill-detail-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[90vw]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 105,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 105,
          "endColumn": 141
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[85vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 105,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 105,
          "endColumn": 141
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport { ModalCloseButton } from \"#/components/shared/modals/modal-close-button\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { SettingsSwitch } from \"#/components/features/settings/settings-switch\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type { SkillInfo } from \"#/types/settings\";\nimport { cn } from \"#/utils/utils\";\nimport { modalTitleLgClassName } from \"#/utils/modal-classes\";\nimport CopyIcon from \"#/icons/copy.svg?react\";\nimport CheckmarkIcon from \"#/icons/checkmark.svg?react\";\nimport MessageSquareShareIcon from \"#/icons/message-square-share.svg?react\";\nimport { SkillIconBadge } from \"./skill-icon-badge\";\nimport { getSkillCardDescription } from \"./get-skill-card-description\";\nimport { buildSkillPills } from \"./build-skill-pills\";\nimport { isCopyableSkillSource } from \"./is-copyable-skill-source\";\nimport { SkillCardPillRow } from \"./skill-card-pill-row\";\nimport { getSkillChatLaunchMessage } from \"./get-skill-chat-launch-message\";\nimport { useLaunchSkillInChat } from \"#/hooks/use-launch-skill-in-chat\";\n\ninterface SkillDetailModalProps {\n  skill: SkillInfo;\n  enabled: boolean;\n  onToggle: (enabled: boolean) => void;\n  onClose: () => void;\n  isToggleDisabled?: boolean;\n}\n\nfunction ReadonlyTextArea({\n  testId,\n  label,\n  value,\n}: {\n  testId: string;\n  label: string;\n  value: string;\n}) {\n  return (\n    <label className=\"flex min-w-0 w-full flex-col gap-2.5\">\n      <span className=\"text-sm\">{label}</span>\n      <textarea\n        data-testid={testId}\n        readOnly\n        value={value}\n        rows={Math.min(12, Math.max(4, value.split(\"\\n\").length))}\n        className={cn(\n          \"bg-surface-raised border border-border-subtle w-full min-w-0 rounded-sm p-2 text-sm\",\n          \"cursor-not-allowed resize-none custom-scrollbar\",\n        )}\n      />\n    </label>\n  );\n}\n\nexport function SkillDetailModal({\n  skill,\n  enabled,\n  onToggle,\n  onClose,\n  isToggleDisabled = false,\n}: SkillDetailModalProps) {\n  const { t } = useTranslation(\"openhands\");\n  const launchSkillInChat = useLaunchSkillInChat();\n  const [sourceCopied, setSourceCopied] = React.useState(false);\n  const chatLaunchMessage = React.useMemo(\n    () => getSkillChatLaunchMessage(skill),\n    [skill],\n  );\n\n  const description = getSkillCardDescription(skill);\n  const pills = React.useMemo(\n    () =>\n      buildSkillPills(skill, t, {\n        variant: \"detail\",\n        testIdPrefix: \"skill-modal-pill\",\n      }),\n    [skill, t],\n  );\n  const showCopySource = isCopyableSkillSource(skill.source);\n\n  const handleCopySource = async () => {\n    if (!skill.source) {\n      return;\n    }\n\n    await navigator.clipboard.writeText(skill.source);\n    setSourceCopied(true);\n  };\n\n  React.useEffect(() => {\n    if (!sourceCopied) {\n      return undefined;\n    }\n\n    const timeout = setTimeout(() => setSourceCopied(false), 2000);\n    return () => clearTimeout(timeout);\n  }, [sourceCopied]);\n\n  return (\n    <ModalBackdrop onClose={onClose} aria-label={skill.name}>\n      <div\n        data-testid=\"skill-detail-modal\"\n        data-skill-name={skill.name}\n        className=\"relative flex w-130 max-w-[90vw] max-h-[85vh] flex-col overflow-hidden rounded-xl border border-border bg-base-secondary\"\n      >\n        <ModalCloseButton onClose={onClose} testId=\"skill-detail-modal-close\" />\n        <header className=\"flex flex-shrink-0 items-start gap-3 pb-4 pl-6 pr-12 pt-6\">\n          <SkillIconBadge skillName={skill.name} />\n          <div className=\"min-w-0 flex-1\">\n            <h2\n              data-testid={`skill-modal-name-${skill.name}`}\n              className={modalTitleLgClassName}\n            >\n              {skill.name}\n            </h2>\n            {skill.source ? (\n              <div className=\"mt-0.5 flex min-w-0 items-center gap-1\">\n                <p\n                  data-testid={`skill-modal-source-${skill.name}`}\n                  className=\"min-w-0 flex-1 truncate text-xs text-tertiary-alt\"\n                  title={skill.source}\n                >\n                  {skill.source}\n                </p>\n                {showCopySource ? (\n                  <button\n                    type=\"button\"\n                    data-testid={`skill-modal-copy-source-${skill.name}`}\n                    aria-label={t(\n                      sourceCopied\n                        ? I18nKey.BUTTON$COPIED\n                        : I18nKey.SETTINGS$SKILLS_COPY_PATH,\n                    )}\n                    disabled={sourceCopied}\n                    onClick={handleCopySource}\n                    className=\"shrink-0 cursor-pointer border-0 bg-transparent p-0.5 text-tertiary-alt hover:text-contrast disabled:cursor-default [&_path]:fill-current\"\n                  >\n                    {sourceCopied ? (\n                      <CheckmarkIcon width={12} height={12} />\n                    ) : (\n                      <CopyIcon width={12} height={12} />\n                    )}\n                  </button>\n                ) : null}\n              </div>\n            ) : null}\n          </div>\n        </header>\n\n        <div\n          data-testid=\"skill-detail-modal-content\"\n          className=\"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain px-6 custom-scrollbar-always\"\n        >\n          <div\n            data-testid={`skill-modal-enable-row-${skill.name}`}\n            className=\"flex w-full items-center rounded-lg border border-border bg-contrast/[0.04] px-3 py-2.5\"\n          >\n            <SettingsSwitch\n              testId={`skill-modal-toggle-${skill.name}`}\n              isToggled={enabled}\n              onToggle={onToggle}\n              isDisabled={isToggleDisabled}\n              togglePosition=\"right\"\n            >\n              {t(\n                enabled\n                  ? I18nKey.SETTINGS$SKILLS_ENABLED\n                  : I18nKey.SETTINGS$SKILLS_DISABLED,\n              )}\n            </SettingsSwitch>\n          </div>\n\n          {description ? (\n            <p\n              data-testid={`skill-modal-description-${skill.name}`}\n              className=\"break-words whitespace-pre-line text-xs text-tertiary-light\"\n            >\n              {description}\n            </p>\n          ) : null}\n\n          {pills.length > 0 ? (\n            <SkillCardPillRow\n              pills={pills}\n              testId={`skill-modal-pills-${skill.name}`}\n            />\n          ) : null}\n\n          {skill.content ? (\n            <ReadonlyTextArea\n              testId={`skill-modal-field-content-${skill.name}`}\n              label={t(I18nKey.SETTINGS$SKILLS_CONTENT)}\n              value={skill.content}\n            />\n          ) : null}\n        </div>\n\n        <footer\n          data-testid=\"skill-detail-modal-actions\"\n          className=\"flex flex-shrink-0 justify-end gap-2 px-6 pb-6 pt-4\"\n        >\n          <BrandButton\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={onClose}\n            testId=\"skill-detail-close\"\n          >\n            {t(I18nKey.BUTTON$CLOSE)}\n          </BrandButton>\n          <BrandButton\n            type=\"button\"\n            variant=\"primary\"\n            isDisabled={!enabled}\n            onClick={() => launchSkillInChat(chatLaunchMessage, onClose)}\n            testId={`skill-detail-use-skill-${skill.name}`}\n            startContent={\n              <MessageSquareShareIcon className=\"size-4\" aria-hidden />\n            }\n          >\n            {t(I18nKey.SETTINGS$SKILLS_USE_SKILL_BUTTON)}\n          </BrandButton>\n        </footer>\n      </div>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/skills/skill-facet-rail.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 50,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 50,
          "endColumn": 100
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import type { SkillCategoryId } from \"@openhands/extensions/skills\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { cn } from \"#/utils/utils\";\nimport { SKILL_CATEGORY_ICONS } from \"#/utils/skill-category\";\nimport { SkillFacetRow } from \"./skill-facet-row\";\nimport type { SkillFacetGroup, SkillFacetGroupId } from \"./skill-filter\";\n\ninterface SkillFacetRailProps {\n  groups: SkillFacetGroup[];\n  onToggle: (groupId: SkillFacetGroupId, value: string) => void;\n  className?: string;\n}\n\n/** Only the category group carries an icon; the rest are plain rows. */\nfunction iconFor(\n  groupId: SkillFacetGroupId,\n  value: string,\n): LucideIcon | undefined {\n  if (groupId !== \"category\") return undefined;\n  return SKILL_CATEGORY_ICONS[value as SkillCategoryId];\n}\n\nexport function SkillFacetRail({\n  groups,\n  onToggle,\n  className,\n}: SkillFacetRailProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  if (groups.length === 0) return null;\n\n  return (\n    <div\n      data-testid=\"skill-facet-rail\"\n      className={cn(\"flex min-w-0 flex-col gap-5\", className)}\n    >\n      {groups.map((group) => {\n        const titleId = `skill-facet-group-${group.id}-title`;\n        return (\n          <div\n            key={group.id}\n            data-testid={`skill-facet-group-${group.id}`}\n            role=\"group\"\n            aria-labelledby={titleId}\n            className=\"flex flex-col gap-1.5\"\n          >\n            <span\n              id={titleId}\n              className=\"px-1 text-[11px] font-semibold uppercase tracking-wider text-tertiary-alt\"\n            >\n              {t(group.labelKey)}\n            </span>\n            {group.rows.map((row) => (\n              <SkillFacetRow\n                key={row.value}\n                testId={`skill-facet-${group.id}-${row.value}`}\n                labelKey={row.labelKey}\n                count={row.count}\n                checked={row.checked}\n                disabled={row.disabled}\n                icon={iconFor(group.id, row.value)}\n                onToggle={() => onToggle(group.id, row.value)}\n              />\n            ))}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/skills/skill-facet-row.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[3px]\" hardcodes an off-token value. Nearest on the scale: rounded-xs (2px), rounded-sm (4px).",
          "line": 49,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 49,
          "endColumn": 84
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { Check, type LucideIcon } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\n\ninterface SkillFacetRowProps {\n  labelKey: I18nKey;\n  count: number;\n  checked: boolean;\n  disabled: boolean;\n  icon?: LucideIcon;\n  testId: string;\n  onToggle: () => void;\n}\n\nexport function SkillFacetRow({\n  labelKey,\n  count,\n  checked,\n  disabled,\n  icon: Icon,\n  testId,\n  onToggle,\n}: SkillFacetRowProps) {\n  const { t } = useTranslation(\"openhands\");\n  const label = t(labelKey);\n\n  return (\n    <button\n      type=\"button\"\n      role=\"checkbox\"\n      aria-checked={checked}\n      data-testid={testId}\n      disabled={disabled}\n      onClick={onToggle}\n      className={cn(\n        \"flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-sm\",\n        disabled\n          ? \"cursor-default text-tertiary-alt/40\"\n          : // Same hover pair as the extensions nav links: a raised surface the\n            // label brightens against, rather than a light fill it sinks into.\n            \"cursor-pointer hover:bg-surface-raised hover:text-contrast\",\n        checked ? \"text-contrast\" : \"text-tertiary-light\",\n      )}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"flex size-3.5 shrink-0 items-center justify-center rounded-[3px] border\",\n          checked\n            ? \"border-contrast bg-contrast text-contrast-foreground\"\n            : \"border-border\",\n        )}\n      >\n        {checked ? <Check className=\"size-2.5\" strokeWidth={3} /> : null}\n      </span>\n      {Icon ? <Icon className=\"size-3.5 shrink-0\" aria-hidden /> : null}\n      {/* The rail cannot be wide enough for every locale, so a truncated label stays readable on hover. */}\n      <span title={label} className=\"min-w-0 flex-1 truncate\">\n        {label}\n      </span>\n      <span className=\"shrink-0 text-xs text-tertiary-alt\">{count}</span>\n    </button>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/skills/skill-filters-modal.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[60vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 43,
          "column": 24,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 43,
          "endColumn": 78
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport { ModalBackdrop } from \"#/components/shared/modals/modal-backdrop\";\nimport {\n  MODAL_MAX_WIDTH_VIEWPORT,\n  ModalBody,\n} from \"#/components/shared/modals/modal-body\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\nimport { SkillFacetRail } from \"./skill-facet-rail\";\nimport type { SkillFacetGroup, SkillFacetGroupId } from \"./skill-filter\";\n\ninterface SkillFiltersModalProps {\n  groups: SkillFacetGroup[];\n  activeCount: number;\n  onToggle: (groupId: SkillFacetGroupId, value: string) => void;\n  onClearAll: () => void;\n  onClose: () => void;\n}\n\nexport function SkillFiltersModal({\n  groups,\n  activeCount,\n  onToggle,\n  onClearAll,\n  onClose,\n}: SkillFiltersModalProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <ModalBackdrop\n      onClose={onClose}\n      aria-label={t(I18nKey.SETTINGS$SKILLS_FILTERS_MODAL_TITLE)}\n    >\n      <ModalBody\n        testID=\"skill-filters-modal\"\n        className={cn(\"items-stretch\", MODAL_MAX_WIDTH_VIEWPORT)}\n      >\n        <h3 className=\"text-lg font-semibold text-foreground\">\n          {t(I18nKey.SETTINGS$SKILLS_FILTERS_MODAL_TITLE)}\n        </h3>\n\n        <div className=\"max-h-[60vh] overflow-y-auto custom-scrollbar-always\">\n          <SkillFacetRail groups={groups} onToggle={onToggle} />\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2\">\n          {activeCount > 0 ? (\n            <BrandButton\n              type=\"button\"\n              variant=\"secondary\"\n              testId=\"skill-filters-modal-clear\"\n              onClick={onClearAll}\n            >\n              {t(I18nKey.SETTINGS$SKILLS_CLEAR_FILTERS)}\n            </BrandButton>\n          ) : null}\n          <BrandButton\n            type=\"button\"\n            variant=\"primary\"\n            testId=\"skill-filters-modal-done\"\n            onClick={onClose}\n          >\n            {t(I18nKey.BUTTON$CLOSE)}\n          </BrandButton>\n        </div>\n      </ModalBody>\n    </ModalBackdrop>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/skills/skill-icon-badge.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"shadow-[inset_0_1px_0_color-mix(in_srgb,var(--oh-contrast)_18%,transparent)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 18,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 18,
          "endColumn": 87
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import SkillsIcon from \"#/icons/skills.svg?react\";\nimport { cn } from \"#/utils/utils\";\n\ninterface SkillIconBadgeProps {\n  skillName: string;\n  className?: string;\n}\n\nexport function SkillIconBadge({ skillName, className }: SkillIconBadgeProps) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      title={skillName}\n      data-testid={`skill-icon-${skillName}`}\n      className={cn(\n        \"inline-flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden\",\n        \"rounded-lg border border-contrast/10 bg-surface-raised text-contrast\",\n        \"shadow-[inset_0_1px_0_color-mix(in_srgb,var(--oh-contrast)_18%,transparent)]\",\n        \"[&>svg]:h-5 [&>svg]:w-5\",\n        className,\n      )}\n    >\n      <SkillsIcon />\n    </span>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/skills/skill-type-badge.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 11,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 11,
          "endColumn": 188
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport type { SkillType } from \"#/types/settings\";\n\ninterface SkillTypeBadgeProps {\n  type: SkillType;\n}\n\n/** Theme-aware pill chrome for skill type badges only (not shared metadata pills). */\nconst SKILL_TYPE_BADGE_CLASS_NAME =\n  \"inline-flex shrink-0 items-center whitespace-nowrap rounded-full border border-text-secondary/35 bg-text-secondary/12 px-2 py-0.5 text-[11px] font-medium leading-4 text-tertiary-light\";\n\nconst TYPE_CONFIG: Record<SkillType, { labelKey: I18nKey }> = {\n  agentskills: {\n    labelKey: I18nKey.SETTINGS$SKILLS_TYPE_AGENTSKILLS,\n  },\n  knowledge: {\n    labelKey: I18nKey.SETTINGS$SKILLS_TYPE_KNOWLEDGE,\n  },\n  repo: {\n    labelKey: I18nKey.SETTINGS$SKILLS_TYPE_REPO,\n  },\n};\n\nexport function SkillTypeBadge({ type }: SkillTypeBadgeProps) {\n  const { t } = useTranslation(\"openhands\");\n  const config = TYPE_CONFIG[type];\n  return (\n    <span\n      data-testid={`skill-type-badge-${type}`}\n      className={SKILL_TYPE_BADGE_CLASS_NAME}\n    >\n      {t(config.labelKey)}\n    </span>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/skills/skills-toolbar.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[11px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 74,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 74,
          "endColumn": 109
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { ListFilter, Search, X } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\n\ninterface SkillsToolbarProps {\n  search: string;\n  onSearchChange: (value: string) => void;\n  activeFilterCount: number;\n  onOpenFilters: () => void;\n}\n\nexport function SkillsToolbar({\n  search,\n  onSearchChange,\n  activeFilterCount,\n  onOpenFilters,\n}: SkillsToolbarProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <div data-testid=\"skills-toolbar\" className=\"flex items-stretch gap-2\">\n      <div\n        className={cn(\n          \"relative flex flex-1 min-w-0 items-center\",\n          \"rounded-lg border border-border bg-base-secondary\",\n          \"focus-within:border-contrast/40 focus-within:ring-1 focus-within:ring-contrast/20\",\n          \"transition-colors\",\n        )}\n      >\n        <Search\n          className=\"ml-3 h-4 w-4 shrink-0 text-tertiary-alt\"\n          aria-hidden\n        />\n        <input\n          data-testid=\"skills-search-input\"\n          type=\"search\"\n          value={search}\n          onChange={(e) => onSearchChange(e.target.value)}\n          placeholder={t(I18nKey.SETTINGS$SKILLS_SEARCH_PLACEHOLDER)}\n          aria-label={t(I18nKey.SETTINGS$SKILLS_SEARCH_PLACEHOLDER)}\n          className={cn(\n            \"flex-1 min-w-0 bg-transparent border-0 outline-none\",\n            \"px-3 py-2 text-sm placeholder:text-tertiary-alt\",\n            \"[&::-webkit-search-cancel-button]:hidden\",\n          )}\n        />\n        {search ? (\n          <button\n            type=\"button\"\n            onClick={() => onSearchChange(\"\")}\n            aria-label={t(I18nKey.MCP$SEARCH_CLEAR)}\n            className=\"mr-2 p-1 rounded text-tertiary-alt hover:text-contrast cursor-pointer\"\n          >\n            <X className=\"h-4 w-4\" aria-hidden />\n          </button>\n        ) : null}\n      </div>\n\n      {/* The facet rail is desktop-only, so below md this is the only way in. */}\n      <button\n        type=\"button\"\n        data-testid=\"skills-filters-button\"\n        onClick={onOpenFilters}\n        className={cn(\n          \"flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm md:hidden\",\n          \"border border-border bg-base-secondary text-contrast\",\n          \"cursor-pointer hover:bg-interactive-hover\",\n        )}\n      >\n        <ListFilter className=\"size-4 shrink-0\" aria-hidden />\n        {t(I18nKey.SETTINGS$SKILLS_FILTERS_BUTTON)}\n        {activeFilterCount > 0 ? (\n          <span className=\"rounded-full bg-contrast px-1.5 text-[11px] font-medium text-contrast-foreground\">\n            {activeFilterCount}\n          </span>\n        ) : null}\n      </button>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/features/suggestions/suggestion-item.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[15px]\" hardcodes an off-token value. Nearest on the scale: rounded-2xl (16px), rounded-xl (12px).",
          "line": 37,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 37,
          "endColumn": 213
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[15px]\" hardcodes an off-token value. Nearest on the scale: text-sm (14px), text-base (16px).",
          "line": 43,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 43,
          "endColumn": 95
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport TachometerFastIcon from \"#/icons/tachometer-fast.svg?react\";\nimport PrStatusIcon from \"#/icons/pr-status.svg?react\";\nimport DocumentIcon from \"#/icons/document.svg?react\";\nimport WaterIcon from \"#/icons/u-water.svg?react\";\n\nexport type Suggestion = { label: I18nKey | string; value: string };\n\ninterface SuggestionItemProps {\n  suggestion: Suggestion;\n  onClick: (value: string) => void;\n}\n\nexport function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {\n  const { t } = useTranslation(\"openhands\");\n\n  const itemIcon = useMemo(() => {\n    switch (suggestion.label) {\n      case \"INCREASE_TEST_COVERAGE\":\n        return <TachometerFastIcon width={24} height={24} color=\"#fff\" />;\n      case \"AUTO_MERGE_PRS\":\n        return <PrStatusIcon width={19} height={20} color=\"#fff\" />;\n      case \"FIX_README\":\n        return <DocumentIcon width={24} height={24} color=\"#fff\" />;\n      case \"CLEAN_DEPENDENCIES\":\n        return <WaterIcon width={24} height={24} color=\"#fff\" />;\n      default:\n        return null;\n    }\n  }, [suggestion]);\n\n  return (\n    <button\n      type=\"button\"\n      className=\"list-none border border-border rounded-[15px] hover:bg-surface-raised hover:border-interactive-hover transition-colors flex-1 flex items-center justify-center cursor-pointer gap-2.5 h-13.75 px-4\"\n      onClick={() => onClick(suggestion.value)}\n    >\n      {itemIcon}\n      <span\n        data-testid=\"suggestion\"\n        className=\"text-[15px] font-normal leading-5 text-contrast text-center cursor-pointer\"\n      >\n        {t(suggestion.label)}\n      </span>\n    </button>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/shared/badge.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-accent-foreground)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 11,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 11,
          "endColumn": 122
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cn } from \"#/utils/utils\";\n\nexport function BrandBadge({\n  children,\n  className,\n  ...rest\n}: React.HTMLAttributes<HTMLSpanElement>) {\n  return (\n    <span\n      className={cn(\n        \"text-sm leading-4 text-[var(--oh-accent-foreground)] font-semibold tracking-tighter bg-primary p-1 rounded-full\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </span>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/shared/buttons/circle-plus-check-toggle.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[rgba(248,113,113,0.14)]\" hardcodes a color. Nearest theme tokens: bg-status-error, bg-danger. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 96,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 96,
          "endColumn": 104,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-status-error"
              },
              "fix": {
                "range": [
                  3009,
                  3096
                ],
                "text": "\"border-0 bg-status-error text-[#ef4444] hover:bg-[rgba(248,113,113,0.24)]\""
              },
              "desc": "Replace with \"bg-status-error\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-danger"
              },
              "fix": {
                "range": [
                  3009,
                  3096
                ],
                "text": "\"border-0 bg-danger text-[#ef4444] hover:bg-[rgba(248,113,113,0.24)]\""
              },
              "desc": "Replace with \"bg-danger\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#ef4444]\" hardcodes a color. Nearest theme tokens: text-danger, text-status-error. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 96,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 96,
          "endColumn": 104,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-danger"
              },
              "fix": {
                "range": [
                  3009,
                  3096
                ],
                "text": "\"border-0 bg-[rgba(248,113,113,0.14)] text-danger hover:bg-[rgba(248,113,113,0.24)]\""
              },
              "desc": "Replace with \"text-danger\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-status-error"
              },
              "fix": {
                "range": [
                  3009,
                  3096
                ],
                "text": "\"border-0 bg-[rgba(248,113,113,0.14)] text-status-error hover:bg-[rgba(248,113,113,0.24)]\""
              },
              "desc": "Replace with \"text-status-error\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"hover:bg-[rgba(248,113,113,0.24)]\" hardcodes a color. Nearest theme tokens: hover:bg-status-error, hover:bg-danger. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 96,
          "column": 17,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 96,
          "endColumn": 104,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "hover:bg-status-error"
              },
              "fix": {
                "range": [
                  3009,
                  3096
                ],
                "text": "\"border-0 bg-[rgba(248,113,113,0.14)] text-[#ef4444] hover:bg-status-error\""
              },
              "desc": "Replace with \"hover:bg-status-error\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "hover:bg-danger"
              },
              "fix": {
                "range": [
                  3009,
                  3096
                ],
                "text": "\"border-0 bg-[rgba(248,113,113,0.14)] text-[#ef4444] hover:bg-danger\""
              },
              "desc": "Replace with \"hover:bg-danger\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"stroke-[2.5]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 110,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 110,
          "endColumn": 39
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport PlusIcon from \"#/icons/u-plus.svg?react\";\nimport CheckmarkIcon from \"#/icons/checkmark.svg?react\";\nimport RemoveIcon from \"#/icons/x-mark.svg?react\";\nimport { StyledTooltip } from \"#/components/shared/buttons/styled-tooltip\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { cn } from \"#/utils/utils\";\n\ninterface CirclePlusCheckToggleProps {\n  testId?: string;\n  isSelected: boolean;\n  onToggle: (selected: boolean) => void;\n  isDisabled?: boolean;\n  className?: string;\n  enableLabelKey?: I18nKey;\n  disableLabelKey?: I18nKey;\n  enableTooltipKey?: I18nKey;\n  disableTooltipKey?: I18nKey;\n  removeTooltipKey?: I18nKey;\n}\n\nexport function CirclePlusBadge({\n  className,\n  testId,\n  tooltipKey = I18nKey.AUTOMATIONS$ADD_AUTOMATION,\n}: {\n  className?: string;\n  testId?: string;\n  tooltipKey?: I18nKey;\n}) {\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <StyledTooltip content={t(tooltipKey)} placement=\"top\">\n      <span\n        aria-hidden=\"true\"\n        data-testid={testId}\n        className={cn(\n          \"inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-surface-raised text-contrast transition-colors hover:bg-interactive-hover\",\n          className,\n        )}\n      >\n        <PlusIcon className=\"size-3\" />\n      </span>\n    </StyledTooltip>\n  );\n}\n\nexport function CirclePlusCheckToggle({\n  testId,\n  isSelected,\n  onToggle,\n  isDisabled = false,\n  className,\n  enableLabelKey = I18nKey.SETTINGS$SKILLS_ENABLE_SKILL,\n  disableLabelKey = I18nKey.SETTINGS$SKILLS_DISABLE_SKILL,\n  enableTooltipKey = I18nKey.COMMON$ENABLE,\n  disableTooltipKey,\n  removeTooltipKey = I18nKey.COMMON$REMOVE,\n}: CirclePlusCheckToggleProps) {\n  const { t } = useTranslation(\"openhands\");\n  const [isPointerOver, setIsPointerOver] = React.useState(false);\n\n  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n    event.stopPropagation();\n    if (isDisabled) {\n      return;\n    }\n    onToggle(!isSelected);\n    event.currentTarget.blur();\n  };\n\n  const showRemoveIcon = isSelected && isPointerOver;\n  const selectedTooltipKey = disableTooltipKey ?? removeTooltipKey;\n  const tooltipLabel = t(isSelected ? selectedTooltipKey : enableTooltipKey);\n  const ariaLabel = t(isSelected ? disableLabelKey : enableLabelKey);\n\n  return (\n    <StyledTooltip content={tooltipLabel} placement=\"top\">\n      <button\n        type=\"button\"\n        role=\"switch\"\n        aria-checked={isSelected}\n        data-testid={testId}\n        data-showing-remove={showRemoveIcon ? \"true\" : \"false\"}\n        disabled={isDisabled}\n        aria-label={ariaLabel}\n        onClick={handleClick}\n        onPointerEnter={() => setIsPointerOver(true)}\n        onPointerLeave={() => setIsPointerOver(false)}\n        className={cn(\n          \"inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full p-0 transition-colors\",\n          isSelected &&\n            (showRemoveIcon\n              ? \"border-0 bg-[rgba(248,113,113,0.14)] text-[#ef4444] hover:bg-[rgba(248,113,113,0.24)]\"\n              : \"border border-contrast bg-transparent text-contrast [&_path]:fill-current\"),\n          !isSelected &&\n            \"border-0 bg-surface-raised text-contrast hover:bg-interactive-hover\",\n          isDisabled && \"cursor-not-allowed opacity-50\",\n          className,\n        )}\n      >\n        {isSelected ? (\n          showRemoveIcon ? (\n            <RemoveIcon\n              aria-hidden\n              width={14}\n              height={14}\n              className=\"stroke-[2.5]\"\n            />\n          ) : (\n            <CheckmarkIcon aria-hidden width={14} height={14} />\n          )\n        ) : (\n          <PlusIcon aria-hidden className=\"size-3\" />\n        )}\n      </button>\n    </StyledTooltip>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/shared/buttons/styled-tooltip.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[9999]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 53,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 53,
          "endColumn": 72
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { Tooltip, TooltipProps } from \"@heroui/react\";\nimport React, { ReactNode } from \"react\";\nimport { cn } from \"#/utils/utils\";\n\nexport interface StyledTooltipProps {\n  children: ReactNode;\n  content: string | ReactNode;\n  tooltipClassName?: React.HTMLAttributes<HTMLDivElement>[\"className\"];\n  placement?: TooltipProps[\"placement\"];\n  showArrow?: boolean;\n  closeDelay?: number;\n  offset?: number;\n  shouldFlip?: boolean;\n  isOpen?: TooltipProps[\"isOpen\"];\n  isDisabled?: TooltipProps[\"isDisabled\"];\n}\n\nfunction getTooltipTriggerChild(children: ReactNode) {\n  if (React.Children.count(children) === 1 && React.isValidElement(children)) {\n    return children;\n  }\n  return <span className=\"inline-flex\">{children}</span>;\n}\n\nexport function StyledTooltip({\n  children,\n  content,\n  tooltipClassName,\n  placement = \"right\",\n  showArrow = false,\n  closeDelay = 100,\n  shouldFlip,\n  offset = 7,\n  isOpen,\n  isDisabled,\n}: StyledTooltipProps) {\n  const disableAnimation = import.meta.env.MODE === \"test\";\n\n  return (\n    <Tooltip\n      content={content}\n      closeDelay={closeDelay}\n      placement={placement}\n      offset={offset}\n      shouldFlip={shouldFlip}\n      isOpen={isOpen}\n      isDisabled={isDisabled}\n      className={cn(\"bg-contrast text-contrast-foreground\", tooltipClassName)}\n      showArrow={showArrow}\n      disableAnimation={disableAnimation}\n      classNames={{\n        content: cn(\n          \"z-[9999] rounded-md px-2 py-1 text-xs font-medium shadow-md\",\n          \"!bg-contrast !text-contrast-foreground\",\n          tooltipClassName,\n        ),\n      }}\n    >\n      {getTooltipTriggerChild(children)}\n    </Tooltip>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/shared/modals/modal-backdrop.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"z-[70]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 68,
          "column": 20,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 68,
          "endColumn": 28
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "jsx-a11y/no-noninteractive-element-interactions",
          "severity": 2,
          "message": "Non-interactive elements should not be assigned mouse or keyboard event listeners.",
          "line": 59,
          "column": 5,
          "nodeType": "JSXOpeningElement",
          "endLine": 70,
          "endColumn": 6,
          "suppressions": [
            {
              "kind": "directive",
              "justification": ""
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useEscapeBoundary } from \"#/hooks/use-close-on-escape\";\nimport { useModalFocus } from \"./use-modal-focus\";\n\ninterface ModalBackdropProps {\n  children: React.ReactNode;\n  onClose?: () => void;\n  /** When false, pressing Escape does not close the modal. Defaults to true. */\n  closeOnEscape?: boolean;\n  /** When false, clicking the backdrop does not close the modal. Defaults to true. */\n  closeOnBackdropClick?: boolean;\n  /** When true, renders above the default modal layer so it stacks over other\n   *  modals (used by the telemetry consent banner over the onboarding modal).\n   *  Defaults to false. */\n  elevated?: boolean;\n  \"aria-label\"?: string;\n}\n\nexport function ModalBackdrop({\n  children,\n  onClose,\n  closeOnEscape = true,\n  closeOnBackdropClick = true,\n  elevated = false,\n  \"aria-label\": ariaLabel,\n}: ModalBackdropProps) {\n  const focus = useModalFocus({ elevated });\n  // The dialog is on top: menus and drawers opened before it never take its\n  // Escape, whether or not it closes on Escape itself.\n  useEscapeBoundary(true);\n\n  React.useEffect(() => {\n    if (!closeOnEscape) return undefined;\n    const handleEscape = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") onClose?.();\n    };\n\n    window.addEventListener(\"keydown\", handleEscape);\n    return () => window.removeEventListener(\"keydown\", handleEscape);\n  }, [closeOnEscape, onClose]);\n\n  const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {\n    if (!closeOnBackdropClick) return;\n    if (e.target === e.currentTarget) onClose?.(); // only close if the click was on the backdrop\n  };\n\n  if (typeof document === \"undefined\") return null;\n\n  // Portal to document.body so the modal's `position: fixed` resolves\n  // against the viewport. Otherwise a transformed ancestor (e.g. the\n  // onboarding slide rail) would become the containing block and the\n  // modal would render trapped inside it instead of overlapping its\n  // parent modal.\n  return createPortal(\n    // Focus and key events from the content (including popovers it portals\n    // elsewhere) bubble here through the React tree; see useModalFocus.\n    // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n    <div\n      ref={focus.rootRef}\n      role=\"dialog\"\n      aria-modal=\"true\"\n      aria-label={ariaLabel}\n      tabIndex={-1}\n      onFocus={focus.onFocus}\n      onKeyDown={focus.onKeyDown}\n      className={`fixed inset-0 flex items-center justify-center outline-none ${\n        elevated ? \"z-[70]\" : \"z-60\"\n      }`}\n    >\n      <div\n        onClick={handleClick}\n        className=\"fixed inset-0 bg-black opacity-60\"\n      />\n      <div className=\"relative\">{children}</div>\n    </div>,\n    document.body,\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/shared/modals/settings/model-selector.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[10px]\" hardcodes an off-token value. Nearest on the scale: text-xs (12px), text-sm (14px).",
          "line": 31,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 31,
          "endColumn": 117
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets left. Style through classes; use CSS custom properties for dynamic values.",
          "line": 320,
          "column": 25,
          "nodeType": "TemplateLiteral",
          "messageId": "inlineStyle",
          "endLine": 320,
          "endColumn": 79
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import {\n  Autocomplete,\n  AutocompleteItem,\n  AutocompleteSection,\n} from \"@heroui/react\";\nimport React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { mapProvider } from \"#/utils/map-provider\";\nimport { extractModelAndProvider } from \"#/utils/extract-model-and-provider\";\nimport { cn } from \"#/utils/utils\";\nimport { formControlSettingsFieldClassName } from \"#/utils/form-control-classes\";\nimport { heroUiAutocompleteSelectorButtonClassName } from \"#/ui/combobox-caret\";\nimport { HelpLink } from \"#/ui/help-link\";\nimport { PRODUCT_URL } from \"#/utils/constants\";\nimport { useSearchProviders } from \"#/hooks/query/use-search-providers\";\nimport { useProviderModels } from \"#/hooks/query/use-provider-models\";\nimport { FREE_MODEL_BADGE_LABEL } from \"#/utils/format-model-name\";\nimport { FreeOpenHandsModelsNote } from \"#/components/shared/free-models-note\";\nimport type { LLMProvider } from \"#/api/config-service/config-service.types\";\n\n// Sorts by the label the picker shows, keeping the managed OpenHands provider\n// first as the backend does.\nconst compareProviderLabels = (a: LLMProvider, b: LLMProvider) =>\n  Number(b.name === \"openhands\") - Number(a.name === \"openhands\") ||\n  mapProvider(a.name).localeCompare(mapProvider(b.name), undefined, {\n    sensitivity: \"base\",\n  });\n\nconst freeModelBadgeClassName =\n  \"shrink-0 rounded-full border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] leading-none text-warning\";\n\ninterface ModelSelectorProps {\n  isDisabled?: boolean;\n  currentModel?: string;\n  onChange?: (provider: string | null, model: string | null) => void;\n  onDefaultValuesChanged?: (\n    provider: string | null,\n    model: string | null,\n  ) => void;\n  wrapperClassName?: string;\n  labelClassName?: string;\n}\n\nexport function ModelSelector({\n  isDisabled,\n  currentModel,\n  onChange,\n  onDefaultValuesChanged,\n  wrapperClassName,\n  labelClassName,\n}: ModelSelectorProps) {\n  const [, setLitellmId] = React.useState<string | null>(null);\n  const [selectedProvider, setSelectedProvider] = React.useState<string | null>(\n    null,\n  );\n  const [selectedModel, setSelectedModel] = React.useState<string | null>(null);\n\n  const { data: providers = [] } = useSearchProviders();\n  const {\n    data: providerModels = [],\n    isLoading: isLoadingModels,\n    error: modelsError,\n  } = useProviderModels(selectedProvider);\n\n  const verifiedProviders = React.useMemo(\n    () => providers.filter((p) => p.verified).sort(compareProviderLabels),\n    [providers],\n  );\n  const unverifiedProviders = React.useMemo(\n    () => providers.filter((p) => !p.verified).sort(compareProviderLabels),\n    [providers],\n  );\n\n  const verifiedModels = React.useMemo(\n    () => providerModels.filter((m) => m.verified),\n    [providerModels],\n  );\n  const unverifiedModels = React.useMemo(\n    () => providerModels.filter((m) => !m.verified),\n    [providerModels],\n  );\n\n  // DB-driven set of free model names for the selected provider. Mirrors the\n  // `verified` flag: the frontend no longer hardcodes which models are free.\n  const freeModelNames = React.useMemo(\n    () => providerModels.filter((m) => m.free).map((m) => m.name),\n    [providerModels],\n  );\n  const freeModelNameSet = React.useMemo(\n    () => new Set(freeModelNames),\n    [freeModelNames],\n  );\n\n  React.useEffect(() => {\n    if (currentModel) {\n      const { provider, model } = extractModelAndProvider(currentModel);\n\n      setLitellmId(currentModel);\n      setSelectedProvider(provider || null);\n      setSelectedModel(model);\n      onDefaultValuesChanged?.(provider || null, model);\n    }\n  }, [currentModel]);\n\n  const handleChangeProvider = (provider: string) => {\n    setSelectedProvider(provider);\n    setSelectedModel(null);\n    setLitellmId(`${provider}/`);\n    onChange?.(provider, null);\n  };\n\n  const handleChangeModel = (model: string) => {\n    let fullModel = `${selectedProvider}/${model}`;\n    if (selectedProvider === \"openai\") {\n      fullModel = model;\n    }\n    setLitellmId(fullModel);\n    setSelectedModel(model);\n    onChange?.(selectedProvider, model);\n  };\n\n  const clear = () => {\n    setSelectedProvider(null);\n    setLitellmId(null);\n  };\n\n  const isSelectedModelFree = Boolean(\n    selectedModel && freeModelNameSet.has(selectedModel),\n  );\n  const selectedModelMeasureRef = React.useRef<HTMLSpanElement>(null);\n  const [selectedModelTextWidth, setSelectedModelTextWidth] = React.useState(0);\n\n  React.useLayoutEffect(() => {\n    if (!isSelectedModelFree || !selectedModelMeasureRef.current) {\n      setSelectedModelTextWidth(0);\n      return undefined;\n    }\n\n    const measureSelectedModel = () => {\n      setSelectedModelTextWidth(\n        Math.ceil(\n          selectedModelMeasureRef.current?.getBoundingClientRect().width ?? 0,\n        ),\n      );\n    };\n    measureSelectedModel();\n\n    if (typeof ResizeObserver === \"undefined\") {\n      return undefined;\n    }\n\n    const observer = new ResizeObserver(measureSelectedModel);\n    observer.observe(selectedModelMeasureRef.current);\n    return () => observer.disconnect();\n  }, [isSelectedModelFree, selectedModel]);\n\n  const { t } = useTranslation(\"openhands\");\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col md:flex-row w-full min-w-0 justify-between gap-4 md:gap-11.5\",\n        wrapperClassName,\n      )}\n    >\n      <fieldset className=\"flex flex-col gap-2.5 w-full\">\n        <label className={cn(\"text-sm\", labelClassName)}>\n          {t(I18nKey.LLM$PROVIDER)}\n        </label>\n        <Autocomplete\n          data-testid=\"llm-provider-input\"\n          isRequired\n          isVirtualized={false}\n          name=\"llm-provider-input\"\n          isDisabled={isDisabled}\n          aria-label={t(I18nKey.LLM$PROVIDER)}\n          isClearable={false}\n          onSelectionChange={(e) => {\n            if (e?.toString()) handleChangeProvider(e.toString());\n          }}\n          onInputChange={(value) => !value && clear()}\n          // Select the current provider's label so typing replaces it and\n          // filters the list instead of appending to it.\n          onFocus={(e) => e.target.select()}\n          defaultSelectedKey={selectedProvider ?? undefined}\n          selectedKey={selectedProvider}\n          classNames={{\n            popoverContent: \"bg-content1 rounded-xl border border-border\",\n            selectorButton: heroUiAutocompleteSelectorButtonClassName,\n          }}\n          selectorButtonProps={{ disableRipple: true }}\n          inputProps={{\n            classNames: {\n              inputWrapper: formControlSettingsFieldClassName,\n            },\n          }}\n        >\n          <AutocompleteSection\n            title={t(I18nKey.MODEL_SELECTOR$VERIFIED)}\n            classNames={{ heading: \"text-muted\" }}\n          >\n            {verifiedProviders.map((provider) => (\n              <AutocompleteItem\n                data-testid={`provider-item-${provider.name}`}\n                key={provider.name}\n              >\n                {mapProvider(provider.name)}\n              </AutocompleteItem>\n            ))}\n          </AutocompleteSection>\n          {unverifiedProviders.length > 0 ? (\n            <AutocompleteSection\n              title={t(I18nKey.MODEL_SELECTOR$OTHERS)}\n              classNames={{ heading: \"text-muted\" }}\n            >\n              {unverifiedProviders.map((provider) => (\n                <AutocompleteItem key={provider.name}>\n                  {mapProvider(provider.name)}\n                </AutocompleteItem>\n              ))}\n            </AutocompleteSection>\n          ) : null}\n        </Autocomplete>\n      </fieldset>\n\n      {selectedProvider === \"openhands\" && (\n        <div className=\"flex flex-col gap-2\">\n          <HelpLink\n            testId=\"openhands-account-help\"\n            text={t(I18nKey.SETTINGS$NEED_OPENHANDS_ACCOUNT)}\n            linkText={t(I18nKey.SETTINGS$CLICK_HERE)}\n            href={PRODUCT_URL.PRODUCTION}\n            size=\"settings\"\n            linkColor=\"white\"\n          />\n        </div>\n      )}\n\n      <fieldset className=\"flex flex-col gap-2.5 w-full\">\n        <label className={cn(\"text-sm\", labelClassName)}>\n          {t(I18nKey.LLM$MODEL)}\n        </label>\n        <div className=\"relative\">\n          <Autocomplete\n            data-testid=\"llm-model-input\"\n            isRequired\n            isVirtualized={false}\n            isLoading={isLoadingModels}\n            name=\"llm-model-input\"\n            aria-label={t(I18nKey.LLM$MODEL)}\n            isClearable={false}\n            onSelectionChange={(e) => {\n              if (e?.toString()) handleChangeModel(e.toString());\n            }}\n            isDisabled={isDisabled || !selectedProvider}\n            selectedKey={selectedModel}\n            defaultSelectedKey={selectedModel ?? undefined}\n            classNames={{\n              popoverContent: \"bg-content1 rounded-xl border border-border\",\n              selectorButton: heroUiAutocompleteSelectorButtonClassName,\n            }}\n            selectorButtonProps={{ disableRipple: true }}\n            inputProps={{\n              classNames: {\n                inputWrapper: formControlSettingsFieldClassName,\n              },\n            }}\n          >\n            <AutocompleteSection\n              title={t(I18nKey.MODEL_SELECTOR$VERIFIED)}\n              classNames={{ heading: \"text-muted\" }}\n            >\n              {verifiedModels.map((model) => (\n                <AutocompleteItem key={model.name} textValue={model.name}>\n                  <span className=\"flex min-w-0 items-center gap-2\">\n                    <span className=\"truncate\">{model.name}</span>\n                    {model.free ? (\n                      <span className={freeModelBadgeClassName}>\n                        {FREE_MODEL_BADGE_LABEL}\n                      </span>\n                    ) : null}\n                  </span>\n                </AutocompleteItem>\n              ))}\n            </AutocompleteSection>\n            {unverifiedModels.length > 0 ? (\n              <AutocompleteSection\n                title={t(I18nKey.MODEL_SELECTOR$OTHERS)}\n                classNames={{ heading: \"text-muted\" }}\n              >\n                {unverifiedModels.map((model) => (\n                  <AutocompleteItem\n                    data-testid={`model-item-${model.name}`}\n                    key={model.name}\n                    textValue={model.name}\n                  >\n                    {model.name}\n                  </AutocompleteItem>\n                ))}\n              </AutocompleteSection>\n            ) : null}\n          </Autocomplete>\n          {isSelectedModelFree && selectedModel ? (\n            <>\n              <span\n                ref={selectedModelMeasureRef}\n                className=\"pointer-events-none absolute left-3 top-1/2 whitespace-pre text-sm opacity-0\"\n                aria-hidden\n              >\n                {selectedModel}\n              </span>\n              <span\n                data-testid=\"selected-free-model-badge\"\n                className={cn(\n                  freeModelBadgeClassName,\n                  \"pointer-events-none absolute top-1/2 z-10 -translate-y-1/2\",\n                )}\n                style={{\n                  left: `calc(0.75rem + ${selectedModelTextWidth}px + 0.5rem)`,\n                }}\n              >\n                {FREE_MODEL_BADGE_LABEL}\n              </span>\n            </>\n          ) : null}\n        </div>\n        {modelsError && (\n          <p data-testid=\"models-error\" className=\"text-danger text-xs\">\n            {t(I18nKey.CONFIGURATION$ERROR_FETCH_MODELS)}\n          </p>\n        )}\n        {selectedProvider === \"openhands\" && freeModelNames.length > 0 ? (\n          <FreeOpenHandsModelsNote\n            modelIds={freeModelNames.map(\n              (name) => `${selectedProvider}/${name}`,\n            )}\n          />\n        ) : null}\n      </fieldset>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/shared/risk-alert.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"bg-[#4A0709]\" hardcodes a color. Nearest theme tokens: bg-base-secondary. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 24,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 24,
          "endColumn": 137,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "bg-base-secondary"
              },
              "fix": {
                "range": [
                  596,
                  722
                ],
                "text": "\"flex items-center gap-3.5 bg-base-secondary border border-[#FF0006] text-feedback-error rounded-xl px-3.5 h-13 text-sm text-white\""
              },
              "desc": "Replace with \"bg-base-secondary\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[#FF0006]\" hardcodes a color. Nearest theme tokens: border-status-error, border-danger. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 24,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 24,
          "endColumn": 137,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-status-error"
              },
              "fix": {
                "range": [
                  596,
                  722
                ],
                "text": "\"flex items-center gap-3.5 bg-[#4A0709] border border-status-error text-feedback-error rounded-xl px-3.5 h-13 text-sm text-white\""
              },
              "desc": "Replace with \"border-status-error\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-danger"
              },
              "fix": {
                "range": [
                  596,
                  722
                ],
                "text": "\"flex items-center gap-3.5 bg-[#4A0709] border border-danger text-feedback-error rounded-xl px-3.5 h-13 text-sm text-white\""
              },
              "desc": "Replace with \"border-danger\"."
            }
          ]
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { ReactNode } from \"react\";\nimport { cn } from \"#/utils/utils\";\n\ninterface RiskAlertProps {\n  className?: string;\n  content: ReactNode;\n  icon?: ReactNode;\n  severity: \"high\" | \"medium\" | \"low\";\n  title: string;\n}\n\nexport function RiskAlert({\n  className,\n  content,\n  icon,\n  severity,\n  title,\n}: RiskAlertProps) {\n  // Currently, we are only supporting the high risk alert. If we use want to support other risk levels, we can add them here and use cva to create different variants of this component.\n  if (severity === \"high\") {\n    return (\n      <div\n        className={cn(\n          \"flex items-center gap-3.5 bg-[#4A0709] border border-[#FF0006] text-feedback-error rounded-xl px-3.5 h-13 text-sm text-white\",\n          className,\n        )}\n      >\n        {icon && <span className=\"\">{icon}</span>}\n        <span className=\"font-bold\">{title}</span>\n        <span className=\"font-normal\">{content}</span>\n      </div>\n    );\n  }\n\n  return null;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/components/shared/text-shimmer.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "A <style> element injects CSS outside the design system. Use classes, or declare the rule in your theme CSS.",
          "line": 72,
          "column": 7,
          "nodeType": "JSXOpeningElement",
          "messageId": "styleElement",
          "endLine": 76,
          "endColumn": 9
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Dynamic style object cannot be checked. Build it from CSS custom properties only.",
          "line": 79,
          "column": 16,
          "nodeType": "Identifier",
          "messageId": "dynamicStyle",
          "endLine": 79,
          "endColumn": 28
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { useId, useMemo } from \"react\";\nimport { useReducedMotion } from \"framer-motion\";\nimport { cn } from \"#/utils/utils\";\n\n/**\n * The gradient is an oversized repeating pattern; animating background-position\n * by exactly one period loops with no visible jump and no muted gap between\n * sweeps (the pattern is periodic, so a highlight is always near the text).\n */\nconst SHIMMER_BACKGROUND_SIZE = \"200%\";\n/** Gradient period as a percentage of the (oversized) background image. */\nconst SHIMMER_PERIOD = 8;\n/** Shifting background-position by one period == 2 * period (image is 2x wide). */\nconst SHIMMER_TRAVEL = SHIMMER_PERIOD * 2;\n\nexport type TextShimmerProps = {\n  children: string;\n  as?: React.ElementType;\n  className?: string;\n  duration?: number;\n  spread?: number;\n} & Omit<React.HTMLAttributes<HTMLElement>, \"children\" | \"className\">;\n\nfunction TextShimmerComponent({\n  children,\n  as: Component = \"p\",\n  className,\n  duration = 2,\n  spread = 2,\n  style,\n  ...rest\n}: TextShimmerProps) {\n  const reduceMotion = useReducedMotion();\n  const reactId = useId();\n  const animationName = `oh-text-shimmer-${reactId.replace(/:/g, \"\")}`;\n\n  // Wider spread => wider bright band within each repeating period.\n  const bandHalfWidth = useMemo(\n    () => Math.min(SHIMMER_PERIOD / 2 - 1, 1 + spread / 2),\n    [spread],\n  );\n\n  const shimmerStyle = useMemo(() => {\n    const center = SHIMMER_PERIOD / 2;\n    return {\n      ...style,\n      backgroundImage: `repeating-linear-gradient(90deg, var(--oh-muted) 0%, var(--oh-muted) ${center - bandHalfWidth}%, var(--oh-foreground) ${center}%, var(--oh-muted) ${center + bandHalfWidth}%, var(--oh-muted) ${SHIMMER_PERIOD}%)`,\n      backgroundSize: `${SHIMMER_BACKGROUND_SIZE} 100%`,\n      backgroundRepeat: \"no-repeat\",\n      WebkitBackgroundClip: \"text\",\n      backgroundClip: \"text\",\n      color: \"transparent\",\n      WebkitTextFillColor: \"transparent\",\n      animation: `${animationName} ${duration}s linear infinite`,\n    } as React.CSSProperties;\n  }, [animationName, bandHalfWidth, duration, style]);\n\n  if (reduceMotion) {\n    return (\n      <Component\n        className={cn(\"text-muted\", className)}\n        style={style}\n        {...rest}\n      >\n        {children}\n      </Component>\n    );\n  }\n\n  return (\n    <>\n      <style\n        dangerouslySetInnerHTML={{\n          __html: `@keyframes ${animationName}{from{background-position:${SHIMMER_TRAVEL}% center}to{background-position:0% center}}`,\n        }}\n      />\n      <Component\n        className={cn(\"relative inline-block\", className)}\n        style={shimmerStyle}\n        {...rest}\n      >\n        {children}\n      </Component>\n    </>\n  );\n}\n\nexport const TextShimmer = React.memo(TextShimmerComponent);\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/routes/agent-settings.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[#717888]\" hardcodes a color. Nearest theme tokens: border-cool-grey-600, border-cool-grey-500. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 1667,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 1667,
          "endColumn": 195,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-cool-grey-600"
              },
              "fix": {
                "range": [
                  61376,
                  61546
                ],
                "text": "\"bg-tertiary border border-cool-grey-600 rounded-sm p-2 text-sm font-mono text-contrast placeholder:text-[#717888] min-h-15 resize-y focus:outline-none focus:border-contrast\""
              },
              "desc": "Replace with \"border-cool-grey-600\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-cool-grey-500"
              },
              "fix": {
                "range": [
                  61376,
                  61546
                ],
                "text": "\"bg-tertiary border border-cool-grey-500 rounded-sm p-2 text-sm font-mono text-contrast placeholder:text-[#717888] min-h-15 resize-y focus:outline-none focus:border-contrast\""
              },
              "desc": "Replace with \"border-cool-grey-500\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"placeholder:text-[#717888]\" hardcodes a color. Nearest theme tokens: placeholder:text-cool-grey-600, placeholder:text-cool-grey-500. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 1667,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 1667,
          "endColumn": 195,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "placeholder:text-cool-grey-600"
              },
              "fix": {
                "range": [
                  61376,
                  61546
                ],
                "text": "\"bg-tertiary border border-[#717888] rounded-sm p-2 text-sm font-mono text-contrast placeholder:text-cool-grey-600 min-h-15 resize-y focus:outline-none focus:border-contrast\""
              },
              "desc": "Replace with \"placeholder:text-cool-grey-600\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "placeholder:text-cool-grey-500"
              },
              "fix": {
                "range": [
                  61376,
                  61546
                ],
                "text": "\"bg-tertiary border border-[#717888] rounded-sm p-2 text-sm font-mono text-contrast placeholder:text-cool-grey-500 min-h-15 resize-y focus:outline-none focus:border-contrast\""
              },
              "desc": "Replace with \"placeholder:text-cool-grey-500\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#717888]\" hardcodes a color. Nearest theme tokens: text-cool-grey-600, text-cool-grey-500. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 1692,
          "column": 40,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 1692,
          "endColumn": 64,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-600"
              },
              "fix": {
                "range": [
                  62980,
                  63004
                ],
                "text": "\"text-xs text-cool-grey-600\""
              },
              "desc": "Replace with \"text-cool-grey-600\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-500"
              },
              "fix": {
                "range": [
                  62980,
                  63004
                ],
                "text": "\"text-xs text-cool-grey-500\""
              },
              "desc": "Replace with \"text-cool-grey-500\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[#3D4046]\" hardcodes a color. Nearest theme tokens: border-border-subtle, border-cool-grey-900. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 1701,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 1701,
          "endColumn": 43,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-border-subtle"
              },
              "fix": {
                "range": [
                  63228,
                  63246
                ],
                "text": "\"border-border-subtle\""
              },
              "desc": "Replace with \"border-border-subtle\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-cool-grey-900"
              },
              "fix": {
                "range": [
                  63228,
                  63246
                ],
                "text": "\"border-cool-grey-900\""
              },
              "desc": "Replace with \"border-cool-grey-900\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[#3D4046]\" hardcodes a color. Nearest theme tokens: border-border-subtle, border-cool-grey-900. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 1709,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 1709,
          "endColumn": 43,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-border-subtle"
              },
              "fix": {
                "range": [
                  63510,
                  63528
                ],
                "text": "\"border-border-subtle\""
              },
              "desc": "Replace with \"border-border-subtle\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "border-cool-grey-900"
              },
              "fix": {
                "range": [
                  63510,
                  63528
                ],
                "text": "\"border-cool-grey-900\""
              },
              "desc": "Replace with \"border-cool-grey-900\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#717888]\" hardcodes a color. Nearest theme tokens: text-cool-grey-600, text-cool-grey-500. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 1785,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 1785,
          "endColumn": 49,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-600"
              },
              "fix": {
                "range": [
                  66284,
                  66308
                ],
                "text": "\"text-xs text-cool-grey-600\""
              },
              "desc": "Replace with \"text-cool-grey-600\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-500"
              },
              "fix": {
                "range": [
                  66284,
                  66308
                ],
                "text": "\"text-xs text-cool-grey-500\""
              },
              "desc": "Replace with \"text-cool-grey-500\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#717888]\" hardcodes a color. Nearest theme tokens: text-cool-grey-600, text-cool-grey-500. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 1796,
          "column": 27,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 1796,
          "endColumn": 51,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-600"
              },
              "fix": {
                "range": [
                  66719,
                  66743
                ],
                "text": "\"text-xs text-cool-grey-600\""
              },
              "desc": "Replace with \"text-cool-grey-600\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-500"
              },
              "fix": {
                "range": [
                  66719,
                  66743
                ],
                "text": "\"text-xs text-cool-grey-500\""
              },
              "desc": "Replace with \"text-cool-grey-500\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#717888]\" hardcodes a color. Nearest theme tokens: text-cool-grey-600, text-cool-grey-500. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 1806,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 1806,
          "endColumn": 49,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-600"
              },
              "fix": {
                "range": [
                  67103,
                  67127
                ],
                "text": "\"text-xs text-cool-grey-600\""
              },
              "desc": "Replace with \"text-cool-grey-600\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-500"
              },
              "fix": {
                "range": [
                  67103,
                  67127
                ],
                "text": "\"text-xs text-cool-grey-500\""
              },
              "desc": "Replace with \"text-cool-grey-500\"."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[#717888]\" hardcodes a color. Nearest theme tokens: text-cool-grey-600, text-cool-grey-500. Use one of those, or declare --color-<name> in src/styles.css.",
          "line": 1811,
          "column": 38,
          "nodeType": "Literal",
          "messageId": "arbitraryColorNear",
          "endLine": 1811,
          "endColumn": 62,
          "suggestions": [
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-600"
              },
              "fix": {
                "range": [
                  67302,
                  67326
                ],
                "text": "\"text-xs text-cool-grey-600\""
              },
              "desc": "Replace with \"text-cool-grey-600\"."
            },
            {
              "messageId": "useToken",
              "data": {
                "replacement": "text-cool-grey-500"
              },
              "fix": {
                "range": [
                  67302,
                  67326
                ],
                "text": "\"text-xs text-cool-grey-500\""
              },
              "desc": "Replace with \"text-cool-grey-500\"."
            }
          ]
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 9,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Navigate } from \"react-router\";\nimport { useTranslation } from \"react-i18next\";\nimport { useSettings } from \"#/hooks/query/use-settings\";\nimport { useAgentSettingsSchema } from \"#/hooks/query/use-agent-settings-schema\";\nimport { SettingsDropdownInput } from \"#/components/features/settings/settings-dropdown-input\";\nimport { SettingsInput } from \"#/components/features/settings/settings-input\";\nimport { SchemaField } from \"#/components/features/settings/sdk-settings/schema-field\";\nimport {\n  SdkSectionPage,\n  type SdkSectionSaveControl,\n} from \"#/components/features/settings/sdk-settings/sdk-section-page\";\nimport {\n  fromBehaviorFormValues,\n  GLOBAL_BEHAVIOR_FIELD_KEYS,\n  toBehaviorFormValues,\n  type ProfileBehaviorSection,\n} from \"#/components/features/settings/agent-profiles/profile-behavior-form\";\nimport { AcpCredentialsSection } from \"#/components/features/settings/acp-credentials-section\";\nimport { useAcpCredentialForm } from \"#/hooks/use-acp-credential-form\";\nimport { BrandButton } from \"#/components/features/settings/brand-button\";\nimport {\n  buildProfileToolsValue,\n  readProfileTools,\n  type ProfileToolSpec,\n} from \"#/constants/profile-tools\";\nimport { ProfileScopeList } from \"#/components/features/settings/agent-profiles/profile-scope-list\";\nimport { Typography } from \"#/ui/typography\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { formControlMultilineFieldClassName } from \"#/utils/form-control-classes\";\nimport { cn } from \"#/utils/utils\";\nimport {\n  Settings,\n  SettingsFieldSchema,\n  SettingsSchema,\n  SettingsValue,\n} from \"#/types/settings\";\nimport {\n  coerceFieldValue,\n  inferInitialView,\n  normalizeFieldValue,\n  type SettingsView,\n} from \"#/utils/sdk-settings-schema\";\nimport {\n  ACP_CUSTOM_PRESET_KEY,\n  getAcpPreferredDefaultModel,\n  getAcpProvider,\n  getAcpProvidersForBackend,\n  getAcpProviderSecrets,\n  labelForAcpModel,\n  type ACPModelOption,\n  type ACPProviderConfig,\n} from \"#/constants/acp-providers\";\nimport { useAcpModelDiscovery } from \"#/hooks/query/use-acp-model-discovery\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { parseCommand, formatCommand } from \"#/utils/acp-command\";\nimport { enabledMcpServerRefs } from \"#/hooks/mutation/sync-agent-profiles\";\nimport {\n  readProfileScope,\n  sameScopeSelection,\n  type ProfileScopeMode,\n} from \"#/constants/profile-scope\";\nimport { useToolCatalog } from \"#/hooks/query/use-tool-catalog\";\nimport { useSkillCatalog } from \"#/hooks/query/use-skill-catalog\";\nimport {\n  buildSkillEnablementFilter,\n  profileSkillEnablement,\n  publicSkillTest,\n  sameSkillSelection,\n  toggleSkill,\n  toProfileSkillFields,\n  type SkillEnablement,\n} from \"#/utils/skill-enablement\";\nimport { useMetaProfiles } from \"#/hooks/query/use-meta-profiles\";\nimport { flattenMcpConfig } from \"#/utils/mcp-installed-servers\";\nimport { parseMcpConfig } from \"#/utils/mcp-config\";\nimport {\n  agentProfileSupportsSecretRefs,\n  agentProfileSupportsInstructions,\n  agentProfileSupportsSkills,\n  agentProfileSupportsTools,\n  agentProfileSupportsRouter,\n} from \"#/api/agent-profiles-service/profile-field-support\";\nimport { useSearchSecrets } from \"#/hooks/query/use-get-secrets\";\n\nexport const handle = { hideTitle: true };\n\nconst PROFILE_BEHAVIOR_BLOCKS: ReadonlyArray<{\n  section: ProfileBehaviorSection;\n  titleKey: I18nKey;\n}> = [\n  { section: \"condenser\", titleKey: I18nKey.SETTINGS$NAV_CONDENSER },\n  { section: \"verification\", titleKey: I18nKey.SETTINGS$NAV_VERIFICATION },\n];\n\ntype AgentType = \"openhands\" | \"acp\";\n\ntype AgentSettingsSnapshot = {\n  agentType: AgentType;\n  commandText: string;\n  acpModel: string;\n  isCustomAcpModel: boolean;\n};\n\nconst TOOL_CONCURRENCY_FIELD_KEY = \"tool_concurrency_limit\";\n// Not valid meta-profile names, so they can't collide with one.\nconst ROUTER_OFF_KEY = \"__off__\";\nconst ROUTER_FIRST_KEY = \"__first__\";\nconst SKILL_SEARCH_MIN_ITEMS = 10;\nconst MCP_SERVER_REFS_KEY = \"mcp_server_refs\";\nconst SECRET_REFS_KEY = \"secret_refs\";\nconst TOOLS_KEY = \"tools\";\nconst PERSONA_KEY = \"persona\";\nconst SYSTEM_MESSAGE_SUFFIX_KEY = \"system_message_suffix\";\nconst SYSTEM_PROMPT_MAX_LENGTH = 65536;\nconst COMMAND_PLACEHOLDER_FALLBACK = \"npx -y <package-name>\";\nconst ACP_CUSTOM_MODEL_KEY = \"__custom_model__\";\nconst ACP_AGENT_DEFAULT_MODEL_KEY = \"__agent_default_model__\";\nconst EMPTY_AGENT_SETTINGS_SNAPSHOT: AgentSettingsSnapshot = {\n  agentType: \"openhands\",\n  commandText: \"\",\n  acpModel: \"\",\n  isCustomAcpModel: false,\n};\n\nfunction toStringArray(value: unknown): string[] {\n  return Array.isArray(value)\n    ? value.filter((v): v is string => typeof v === \"string\")\n    : [];\n}\n\nfunction detectPreset(\n  commandText: string,\n  providers: ACPProviderConfig[],\n): string {\n  const normalized = parseCommand(commandText).join(\" \");\n  for (const provider of providers) {\n    if (normalized === provider.default_command.join(\" \")) {\n      return provider.key;\n    }\n  }\n  return ACP_CUSTOM_PRESET_KEY;\n}\n\nexport type SystemPromptMode = \"standard\" | \"append\" | \"custom\";\n\n/** A stored persona wins over stored instructions; the editor models one choice. */\nexport function readSystemPromptSeed(\n  override: Record<string, SettingsValue> | null | undefined,\n): { mode: SystemPromptMode; text: string } {\n  const persona = override?.[PERSONA_KEY];\n  if (typeof persona === \"string\" && persona)\n    return { mode: \"custom\", text: persona };\n  const suffix = override?.[SYSTEM_MESSAGE_SUFFIX_KEY];\n  if (typeof suffix === \"string\" && suffix)\n    return { mode: \"append\", text: suffix };\n  return { mode: \"standard\", text: \"\" };\n}\n\n/** The agent's models, keeping a saved model selectable when it isn't listed. */\nfunction buildModelSuggestions(\n  models: readonly ACPModelOption[],\n  selectedModel: string,\n): ACPModelOption[] {\n  const model = selectedModel.trim();\n  if (!model || models.some(({ id }) => id === model)) return [...models];\n  return [...models, { id: model, label: model }];\n}\n\n/**\n * Variant-specific AgentProfile fields derived from the form state. The\n * OpenHands branch omits `llm_profile_ref` \u2014 the profile editor supplies it.\n */\nexport type AgentProfileFieldsDraft =\n  | {\n      agent_kind: \"openhands\";\n      mcp_server_refs: string[] | null;\n      tool_concurrency_limit?: number;\n      secret_refs?: string[] | null;\n      public_skills?: string[];\n      disabled_skills?: string[];\n      tools?: ProfileToolSpec[] | null;\n      enable_classify_and_switch_llm_tool?: boolean;\n      meta_profile_ref?: string | null;\n      persona?: string | null;\n      system_message_suffix?: string | null;\n      condenser?: Record<string, SettingsValue>;\n      verification?: Record<string, SettingsValue>;\n    }\n  | {\n      agent_kind: \"acp\";\n      mcp_server_refs: string[] | null;\n      secret_refs?: string[] | null;\n      acp_server: string;\n      acp_model: string | null;\n      acp_command: string | null;\n      acp_args: string[] | null;\n    };\n\n/** Live form state the pure {@link buildAgentProfileFields} builder reads. */\nexport interface AgentProfileFieldsInput {\n  isAcp: boolean;\n  /** Detected ACP preset: a provider key or the ``custom`` sentinel. */\n  selectedPreset: string;\n  /** True when the command exactly matches the selected provider's default. */\n  isDefaultProviderCommand: boolean;\n  commandTokens: string[];\n  acpModel: string;\n  toolConcurrencyField?: SettingsFieldSchema;\n  toolConcurrency: string | boolean;\n  mcpMode: ProfileScopeMode;\n  selectedMcpServers: string[];\n  secretsMode: ProfileScopeMode;\n  selectedSecrets: string[];\n  /** Whether the backend's *profile* model accepts `secret_refs`. */\n  secretRefsSupportedOnProfile: boolean;\n  /** The skill selection to store; undefined keeps the stored one. */\n  skillEnablement?: SkillEnablement;\n  toolsMode?: ProfileScopeMode;\n  selectedTools?: string[];\n  /** Stored params per tool, kept so the editor cannot drop what it ignores. */\n  toolParams?: Record<string, Record<string, SettingsValue>>;\n  /** Whether the tool catalog loaded; `tools` is only written then. */\n  toolCatalogLoaded?: boolean;\n  /** The Model Router to store: a meta-profile name, null for none, undefined to keep it. */\n  router?: string | null;\n  systemPromptMode: SystemPromptMode;\n  systemPromptText: string;\n  /** False when the section is hidden, so neither prompt field is written. */\n  systemPromptEditable: boolean;\n  /** Profile sections to store; an absent one keeps the stored value. */\n  condenser?: Record<string, SettingsValue>;\n  verification?: Record<string, SettingsValue>;\n}\n\n/**\n * Translate the live Agent-settings form state into the variant-specific\n * AgentProfile fields. Pure (no React), so it can be unit-tested directly.\n *\n * ACP: a built-in provider on its default command stores **no** explicit\n * command (``acp_command: null`` \u2014 the profile resolver falls back to the\n * provider default); a customized or ``custom`` command is stored verbatim as a\n * shell string. OpenHands: reuses the schema-driven ``tool_concurrency_limit``\n * coercion, which **throws** on invalid input (callers catch at save time). A\n * blank concurrency field always emits an explicit value (the schema default\n * when the coercion is empty) rather than omitting the key \u2014 the profile\n * editor's save is a whole-profile overwrite (``mergeAgentProfileSaveInput``\n * spreads the stored profile under these fields), so omitting the key would\n * let a stale stored value silently survive an edit meant to clear it back to\n * the default (#1571 review). The backend field itself is a non-nullable\n * ``int`` with ``ge=1``, so the default \u2014 not ``null`` \u2014 is the value that\n * actually clears.\n */\nexport function buildAgentProfileFields(\n  input: AgentProfileFieldsInput,\n): AgentProfileFieldsDraft {\n  const {\n    isAcp,\n    selectedPreset,\n    isDefaultProviderCommand,\n    commandTokens,\n    acpModel,\n    toolConcurrencyField,\n    toolConcurrency,\n    mcpMode,\n    selectedMcpServers,\n    secretsMode,\n    selectedSecrets,\n    secretRefsSupportedOnProfile,\n    skillEnablement,\n    toolsMode = \"standard\",\n    selectedTools = [],\n    toolParams = {},\n    toolCatalogLoaded = false,\n    router,\n    systemPromptMode,\n    systemPromptText,\n    systemPromptEditable,\n    condenser,\n    verification,\n  } = input;\n  // Both are base-model fields, so they ride both variants. `mcp_server_refs`\n  // needs no version gate \u2014 it has existed since agent profiles shipped, below\n  // the supported floor \u2014 while `secret_refs` does.\n  const mcpRefs = {\n    mcp_server_refs: mcpMode === \"custom\" ? selectedMcpServers : null,\n  };\n  const secretRefs = secretRefsSupportedOnProfile\n    ? { secret_refs: secretsMode === \"custom\" ? selectedSecrets : null }\n    : {};\n  if (isAcp) {\n    const isBuiltinDefault =\n      isDefaultProviderCommand && selectedPreset !== ACP_CUSTOM_PRESET_KEY;\n    return {\n      agent_kind: \"acp\",\n      ...mcpRefs,\n      ...secretRefs,\n      acp_server: selectedPreset,\n      acp_model: acpModel.trim() || null,\n      acp_command: isBuiltinDefault\n        ? null\n        : formatCommand(commandTokens) || null,\n      acp_args: null,\n    };\n  }\n  const fields: Extract<AgentProfileFieldsDraft, { agent_kind: \"openhands\" }> =\n    {\n      agent_kind: \"openhands\",\n      ...mcpRefs,\n      ...secretRefs,\n    };\n  if (skillEnablement)\n    Object.assign(fields, toProfileSkillFields(skillEnablement));\n  if (systemPromptEditable) {\n    const text = systemPromptText.trim() ? systemPromptText : null;\n    fields.system_message_suffix = systemPromptMode === \"append\" ? text : null;\n    fields.persona = systemPromptMode === \"custom\" ? text : null;\n  }\n  if (condenser) fields.condenser = condenser;\n  if (verification) fields.verification = verification;\n  if (toolCatalogLoaded) {\n    fields.tools = buildProfileToolsValue({\n      mode: toolsMode,\n      selected: selectedTools,\n      params: toolParams,\n    });\n  }\n  if (router !== undefined) {\n    fields.enable_classify_and_switch_llm_tool = router !== null;\n    fields.meta_profile_ref = router;\n  }\n  if (toolConcurrencyField) {\n    // Reuse the schema-driven coercion/validation; throws on bad input.\n    const coerced = coerceFieldValue(toolConcurrencyField, toolConcurrency);\n    // A blank field coerces to `null`. Always emit an explicit value \u2014 never\n    // omit the key \u2014 so a deliberate clear on an edit-save actually resets the\n    // stored profile to the schema default, instead of the whole-profile merge\n    // silently carrying the old value forward.\n    const fallback =\n      typeof toolConcurrencyField.default === \"number\"\n        ? toolConcurrencyField.default\n        : 1;\n    fields.tool_concurrency_limit =\n      coerced != null ? Number(coerced) : fallback;\n  }\n  return fields;\n}\n\n/**\n * Handle the form exposes to the Agent-profile editor so it can read the\n * current state and persist it as an AgentProfile.\n */\nexport interface AgentSettingsSaveControl {\n  agentType: AgentType;\n  /** False when the current form can't be saved (e.g. an empty ACP command). */\n  isValid: boolean;\n  /** True when the form differs from the hydrated snapshot. */\n  isDirty: boolean;\n  /**\n   * Build the variant-specific AgentProfile fields from the live form state.\n   * Throws a user-facing Error on invalid input (e.g. a bad concurrency value).\n   */\n  buildAgentProfileFields: () => AgentProfileFieldsDraft;\n  /** Shared ACP credential form (writes to global secrets). */\n  credentials: {\n    isDirty: boolean;\n    save: (opts?: { silent?: boolean }) => Promise<boolean>;\n    reset: () => void;\n  };\n}\n\ninterface AgentSettingsScreenProps {\n  /** `agent_settings`-shaped profile fields the form opens on. */\n  agentSettingsOverride?: Record<string, SettingsValue> | null;\n  onSaveControlChange: (control: AgentSettingsSaveControl) => void;\n  /** `default` shares its MCP servers and skills with the global pages and keeps every secret. */\n  isDefaultProfile?: boolean;\n  /** Shown under the agent type for an OpenHands agent. */\n  llmPicker?: React.ReactNode;\n}\n\nexport function AgentSettingsScreen({\n  agentSettingsOverride = null,\n  onSaveControlChange,\n  isDefaultProfile = false,\n  llmPicker = null,\n}: AgentSettingsScreenProps) {\n  const { t } = useTranslation(\"openhands\");\n  const { data: settings, isLoading } = useSettings();\n  const { data: schema } = useAgentSettingsSchema(\n    settings?.agent_settings_schema,\n  );\n  const backendKind = useActiveBackend().backend.kind;\n  const acpProviders = getAcpProvidersForBackend(backendKind);\n\n  const fields = React.useMemo(\n    () => schema?.sections.flatMap((section) => section.fields),\n    [schema],\n  );\n\n  // --- Parallel tool calls (OpenHands path) ---\n  // Surfaced only when the backend schema exposes the field, so older\n  // agent-servers that predate ``tool_concurrency_limit`` hide it cleanly.\n  const toolConcurrencyField = fields?.find(\n    (field) => field.key === TOOL_CONCURRENCY_FIELD_KEY,\n  );\n  const initialToolConcurrency = React.useMemo(() => {\n    if (!toolConcurrencyField) return \"\";\n    const raw = agentSettingsOverride?.[TOOL_CONCURRENCY_FIELD_KEY];\n    return normalizeFieldValue(toolConcurrencyField, raw);\n  }, [toolConcurrencyField, agentSettingsOverride]);\n  const [toolConcurrency, setToolConcurrency] = useState<string | boolean>(\n    initialToolConcurrency,\n  );\n\n  // --- Tools (OpenHands only) ---\n  const initialTools = React.useMemo(\n    () => readProfileTools(agentSettingsOverride?.[TOOLS_KEY]),\n    [agentSettingsOverride],\n  );\n  const [toolsMode, setToolsMode] = useState<ProfileScopeMode>(\n    initialTools.mode,\n  );\n  const [selectedTools, setSelectedTools] = useState<string[] | null>(\n    initialTools.mode === \"custom\" ? initialTools.selected : null,\n  );\n  const toolsSupported = agentProfileSupportsTools();\n  const {\n    data: toolCatalog,\n    isError: toolCatalogFailed,\n    refetch: refetchToolCatalog,\n  } = useToolCatalog({ enabled: toolsSupported });\n  // Only a loaded catalog may write `tools`.\n  const toolCatalogLoaded = toolCatalog !== undefined;\n  const standardToolNames = React.useMemo(\n    () =>\n      toolCatalog\n        ?.filter(({ in_default_set: inDefault, usable }) => inDefault && usable)\n        .map(({ name }) => name),\n    [toolCatalog],\n  );\n  /** The server's blurb for a tool, by name. */\n  const toolDescriptions = React.useMemo(\n    () =>\n      new Map(\n        (toolCatalog ?? []).map(({ name, description }) => [name, description]),\n      ),\n    [toolCatalog],\n  );\n  /** Pickable tools this runtime can run, plus anything the profile stores. */\n  const toolPickerCatalog = React.useMemo(() => {\n    const items: { name: string; description?: string }[] = (toolCatalog ?? [])\n      .filter(({ user_selectable: selectable, usable }) => selectable && usable)\n      .map(({ name, description }) => ({ name, description }));\n    // Stored names outside the catalog stay selectable so they are kept.\n    initialTools.selected.forEach((name) => {\n      if (!items.some((item) => item.name === name))\n        items.push({ name, description: toolDescriptions.get(name) });\n    });\n    return items;\n  }, [toolCatalog, toolDescriptions, initialTools]);\n  const orderedSelectedTools = React.useMemo(\n    () =>\n      toolPickerCatalog\n        .map(({ name }) => name)\n        .filter((name) => selectedTools?.includes(name)),\n    [toolPickerCatalog, selectedTools],\n  );\n\n  // --- System prompt (OpenHands path) ---\n  const instructionsSupported = agentProfileSupportsInstructions();\n  const initialSystemPrompt = React.useMemo(\n    () => readSystemPromptSeed(agentSettingsOverride),\n    [agentSettingsOverride],\n  );\n  const [systemPromptMode, setSystemPromptMode] = useState<SystemPromptMode>(\n    initialSystemPrompt.mode,\n  );\n  const [systemPromptText, setSystemPromptText] = useState(\n    initialSystemPrompt.text,\n  );\n\n  // --- MCP servers (both variants; a base-model field) ---\n  const initialMcpRefs = React.useMemo(\n    () => readProfileScope(agentSettingsOverride?.[MCP_SERVER_REFS_KEY]),\n    [agentSettingsOverride],\n  );\n  const [mcpMode, setMcpMode] = useState<ProfileScopeMode>(initialMcpRefs.mode);\n  const [selectedMcpServers, setSelectedMcpServers] = useState<string[]>(\n    initialMcpRefs.selected,\n  );\n  const configuredMcpNames = React.useMemo(\n    () =>\n      flattenMcpConfig(\n        settings?.mcp_config ??\n          parseMcpConfig(settings?.agent_settings?.mcp_config),\n      )\n        // `name` is optional on the shared type; flattenMcpConfig always sets\n        // it from the config key, so narrow rather than assert.\n        .filter(\n          (server): server is typeof server & { name: string } =>\n            typeof server.name === \"string\",\n        )\n        .map((server) => ({ name: server.name, description: server.type })),\n    [settings],\n  );\n  const mcpCatalog = React.useMemo(() => {\n    const configured = configuredMcpNames;\n    // A stored ref whose server is gone rides along so the user can clear it:\n    // a dangling ref fails the launch (422 locally; on cloud the never-brick\n    // handler swallows it and silently falls back to unscoped settings).\n    const known = new Set(configured.map(({ name }) => name));\n    return [\n      ...configured,\n      ...initialMcpRefs.selected\n        .filter((name) => !known.has(name))\n        .map((name) => ({ name, description: null as string | null })),\n    ];\n  }, [configuredMcpNames, initialMcpRefs]);\n  const orderedSelectedMcpServers = React.useMemo(\n    () =>\n      mcpCatalog\n        .map(({ name }) => name)\n        .filter((name) => selectedMcpServers.includes(name)),\n    [mcpCatalog, selectedMcpServers],\n  );\n  // --- Skills (OpenHands; the selection a profile launch loads) ---\n  const skillsSupported = agentProfileSupportsSkills();\n  const {\n    data: skillCatalog,\n    isError: skillCatalogFailed,\n    refetch: refetchSkillCatalog,\n  } = useSkillCatalog({ enabled: skillsSupported });\n  const isPublicSkill = React.useMemo(\n    () => publicSkillTest(skillCatalog),\n    [skillCatalog],\n  );\n  const [skillQuery, setSkillQuery] = useState(\"\");\n  const visibleSkills = React.useMemo(() => {\n    const query = skillQuery.trim().toLowerCase();\n    if (!skillCatalog || !query) return skillCatalog;\n    return skillCatalog.filter((skill) =>\n      [skill.name, skill.description ?? \"\", ...(skill.triggers ?? [])].some(\n        (text) => text.toLowerCase().includes(query),\n      ),\n    );\n  }, [skillCatalog, skillQuery]);\n  const storedPublicSkills = agentSettingsOverride?.public_skills;\n  const hasStoredSkillList = Array.isArray(storedPublicSkills);\n  const initialSkills = React.useMemo<SkillEnablement | undefined>(() => {\n    const disabled = agentSettingsOverride?.disabled_skills;\n    const stored = {\n      public_skills: hasStoredSkillList\n        ? (storedPublicSkills as string[])\n        : null,\n      disabled_skills: Array.isArray(disabled) ? (disabled as string[]) : [],\n    };\n    if (hasStoredSkillList) return profileSkillEnablement(stored);\n    // No stored list means every catalog skill, which only the catalog can name.\n    if (!skillCatalog) return undefined;\n    return profileSkillEnablement(\n      stored,\n      skillCatalog\n        .filter((skill) => skill.origin === \"public\")\n        .map((skill) => skill.name),\n    );\n  }, [\n    agentSettingsOverride,\n    hasStoredSkillList,\n    storedPublicSkills,\n    skillCatalog,\n  ]);\n  const [editedSkills, setEditedSkills] = useState<SkillEnablement | null>(\n    null,\n  );\n  const currentSkills = editedSkills ?? initialSkills;\n  const enabledSkillNames = React.useMemo(() => {\n    if (!skillCatalog || !currentSkills) return [];\n    const isEnabled = buildSkillEnablementFilter(currentSkills, isPublicSkill);\n    return skillCatalog\n      .map((skill) => skill.name)\n      .filter((name) => isEnabled(name));\n  }, [skillCatalog, currentSkills, isPublicSkill]);\n  const toggleProfileSkill = useCallback(\n    (name: string, enabled: boolean) =>\n      setEditedSkills((previous) => {\n        const base = previous ?? initialSkills;\n        return base\n          ? toggleSkill(base, name, enabled, isPublicSkill)\n          : previous;\n      }),\n    [initialSkills, isPublicSkill],\n  );\n  // A seeded or stored list is written back; an unset one only once edited.\n  const skillsToSave = skillsSupported\n    ? (editedSkills ?? (hasStoredSkillList ? initialSkills : undefined))\n    : undefined;\n  const skillsDirty =\n    editedSkills !== null &&\n    initialSkills !== undefined &&\n    !sameSkillSelection(editedSkills, initialSkills);\n\n  // --- Secret scope (both variants; a base-model field) ---\n  const secretRefsSupportedOnProfile = agentProfileSupportsSecretRefs();\n  const { data: savedSecrets } = useSearchSecrets({\n    enabled: secretRefsSupportedOnProfile,\n  });\n  const initialSecretRefs = React.useMemo(\n    () => readProfileScope(agentSettingsOverride?.[SECRET_REFS_KEY]),\n    [agentSettingsOverride],\n  );\n  const [secretsMode, setSecretsMode] = useState<ProfileScopeMode>(\n    initialSecretRefs.mode,\n  );\n  const [selectedSecrets, setSelectedSecrets] = useState<string[]>(\n    initialSecretRefs.selected,\n  );\n  // Scoping is strict server-side \u2014 nothing is added back \u2014 so an ACP profile\n  // that omits its provider credential simply fails to authenticate. Keep the\n  // names selected by default rather than re-adding them behind the user's\n  // back: a visible checkbox they can still clear, not a hidden rule.\n  const addProviderSecrets = useCallback(\n    (providerKey: string) =>\n      setSelectedSecrets((prev) => {\n        const names = getAcpProviderSecrets(providerKey).map(\n          ({ name }) => name,\n        );\n        const missing = names.filter((name) => !prev.includes(name));\n        return missing.length ? [...prev, ...missing] : prev;\n      }),\n    [],\n  );\n\n  // A ref to a server that no longer exists 422s the launch, so surface it\n  // while the user can still fix it.\n  const danglingMcpRefs = React.useMemo(\n    () =>\n      mcpMode === \"custom\"\n        ? orderedSelectedMcpServers.filter(\n            (name) =>\n              !configuredMcpNames.some((server) => server.name === name),\n          )\n        : [],\n    [mcpMode, orderedSelectedMcpServers, configuredMcpNames],\n  );\n\n  // --- ACP path ---\n  const [agentType, setAgentType] = useState<AgentType>(\"openhands\");\n  const [commandText, setCommandText] = useState(\"\");\n  const [acpModel, setAcpModel] = useState(\"\");\n  const [isCustomAcpModel, setIsCustomAcpModel] = useState(false);\n\n  // ACP credentials live alongside the agent spec, so the form owns the\n  // credential form and hands it to the editor's Save. Called unconditionally\n  // (no-ops to empty fields for a non-ACP / custom command) to keep hook order\n  // stable across the ``isLoading`` early-return below; ``detectPreset`` is a\n  // cheap pure lookup.\n  const acpPresetForCreds =\n    agentType === \"acp\" ? detectPreset(commandText, acpProviders) : null;\n  const acpCredentialForm = useAcpCredentialForm(\n    acpPresetForCreds && acpPresetForCreds !== ACP_CUSTOM_PRESET_KEY\n      ? acpPresetForCreds\n      : null,\n  );\n\n  // Available provider credentials must remain visible when deselected, even\n  // before their values are saved. Stored references also survive deletion.\n  const secretCatalog = React.useMemo(() => {\n    const saved = (savedSecrets ?? []).map((secret) => ({\n      name: secret.name,\n      description: secret.description ?? null,\n    }));\n    const known = new Set(saved.map((secret) => secret.name));\n    return [\n      ...saved,\n      ...[\n        ...new Set([\n          ...initialSecretRefs.selected,\n          ...acpCredentialForm.fields.map(({ name }) => name),\n        ]),\n      ]\n        .filter((name) => !known.has(name))\n        .map((name) => ({ name, description: null as string | null })),\n    ];\n  }, [savedSecrets, initialSecretRefs, acpCredentialForm.fields]);\n  const orderedSelectedSecrets = React.useMemo(\n    () =>\n      secretCatalog\n        .map(({ name }) => name)\n        .filter((name) => selectedSecrets.includes(name)),\n    [secretCatalog, selectedSecrets],\n  );\n\n  const [loadedSnapshot, setLoadedSnapshot] = useState<AgentSettingsSnapshot>(\n    EMPTY_AGENT_SETTINGS_SNAPSHOT,\n  );\n\n  useEffect(() => {\n    const kind = agentSettingsOverride?.agent_kind;\n\n    if (kind === \"acp\") {\n      setAgentType(\"acp\");\n\n      const rawAcpServer = agentSettingsOverride?.acp_server;\n      const acpServer =\n        typeof rawAcpServer === \"string\" ? rawAcpServer : undefined;\n      const provider = getAcpProvider(acpServer);\n      const storedCommand = toStringArray(agentSettingsOverride?.acp_command);\n      const effectiveBaseCommand =\n        storedCommand.length > 0\n          ? storedCommand\n          : (provider?.default_command ?? []);\n      const tokens = [\n        ...effectiveBaseCommand,\n        ...toStringArray(agentSettingsOverride?.acp_args),\n      ];\n      const renderedCommandText =\n        tokens.length > 0 ? formatCommand(tokens) : \"\";\n      setCommandText(renderedCommandText);\n\n      const savedModel = agentSettingsOverride?.acp_model;\n      const normalizedSavedModel =\n        typeof savedModel === \"string\" ? savedModel.trim() : \"\";\n      const nextAcpModel =\n        normalizedSavedModel || getAcpPreferredDefaultModel(acpServer) || \"\";\n      const nextIsCustomAcpModel = !!normalizedSavedModel && !provider;\n      setAcpModel(nextAcpModel);\n      setIsCustomAcpModel(nextIsCustomAcpModel);\n      setLoadedSnapshot({\n        agentType: \"acp\",\n        commandText: renderedCommandText,\n        acpModel: nextAcpModel,\n        isCustomAcpModel: nextIsCustomAcpModel,\n      });\n    } else {\n      setAgentType(\"openhands\");\n      setCommandText(\"\");\n      setAcpModel(\"\");\n      setIsCustomAcpModel(false);\n      setLoadedSnapshot(EMPTY_AGENT_SETTINGS_SNAPSHOT);\n    }\n  }, [agentSettingsOverride]);\n\n  // Sync the parallel-tool-calls input when settings reload\n  useEffect(() => {\n    setToolConcurrency(initialToolConcurrency);\n  }, [initialToolConcurrency]);\n\n  useEffect(() => {\n    setSystemPromptMode(initialSystemPrompt.mode);\n    setSystemPromptText(initialSystemPrompt.text);\n  }, [initialSystemPrompt]);\n\n  // Sync the MCP scope when settings reload\n  useEffect(() => {\n    setMcpMode(initialMcpRefs.mode);\n    setSelectedMcpServers(initialMcpRefs.selected);\n  }, [initialMcpRefs]);\n\n  // Seed a custom selection from the standard set once the catalog arrives,\n  // without replacing edits made while it was pending.\n  useEffect(() => {\n    if (toolsMode !== \"custom\" || standardToolNames === undefined) return;\n    setSelectedTools(\n      (previous) =>\n        previous ??\n        standardToolNames.filter((name) =>\n          toolPickerCatalog.some((entry) => entry.name === name),\n        ),\n    );\n  }, [toolsMode, standardToolNames, toolPickerCatalog]);\n\n  // Sync the tool selection when settings reload\n  useEffect(() => {\n    setToolsMode(initialTools.mode);\n    setSelectedTools(\n      initialTools.mode === \"custom\" ? initialTools.selected : null,\n    );\n  }, [initialTools]);\n\n  // Sync the secret scope when settings reload\n  useEffect(() => {\n    setSecretsMode(initialSecretRefs.mode);\n    setSelectedSecrets(initialSecretRefs.selected);\n  }, [initialSecretRefs]);\n\n  // --- Condenser and verification (OpenHands; stored on the profile) ---\n  const [behaviorControls, setBehaviorControls] = useState<\n    Partial<Record<ProfileBehaviorSection, SdkSectionSaveControl>>\n  >({});\n  const behaviorControlHandlers = React.useMemo(\n    () =>\n      Object.fromEntries(\n        PROFILE_BEHAVIOR_BLOCKS.map(({ section }) => [\n          section,\n          (control: SdkSectionSaveControl) =>\n            setBehaviorControls((previous) => ({\n              ...previous,\n              [section]: control,\n            })),\n        ]),\n      ) as Record<\n        ProfileBehaviorSection,\n        (control: SdkSectionSaveControl) => void\n      >,\n    [],\n  );\n  // A new profile has no stored sections and starts from the global values.\n  const storedBehavior = React.useMemo(() => {\n    const asRecord = (value: unknown) =>\n      value && typeof value === \"object\" && !Array.isArray(value)\n        ? (value as Record<string, unknown>)\n        : null;\n    return {\n      condenser: asRecord(agentSettingsOverride?.condenser),\n      verification: asRecord(agentSettingsOverride?.verification),\n    };\n  }, [agentSettingsOverride]);\n  const behaviorInitialValues = React.useMemo(\n    () => ({\n      condenser: storedBehavior.condenser\n        ? toBehaviorFormValues(schema, \"condenser\", storedBehavior.condenser)\n        : undefined,\n      verification: storedBehavior.verification\n        ? toBehaviorFormValues(\n            schema,\n            \"verification\",\n            storedBehavior.verification,\n          )\n        : undefined,\n    }),\n    [schema, storedBehavior],\n  );\n  // Open each section on the view its stored values need, not the global one.\n  const behaviorInitialView = React.useMemo(\n    () =>\n      Object.fromEntries(\n        PROFILE_BEHAVIOR_BLOCKS.map(({ section }) => {\n          const stored = storedBehavior[section];\n          return [\n            section,\n            stored\n              ? (current: Settings, filteredSchema: SettingsSchema) =>\n                  inferInitialView(\n                    {\n                      ...current,\n                      agent_settings: {\n                        ...current.agent_settings,\n                        [section]: stored as SettingsValue,\n                      },\n                    },\n                    filteredSchema,\n                  )\n              : undefined,\n          ];\n        }),\n      ) as Record<\n        ProfileBehaviorSection,\n        | ((current: Settings, filteredSchema: SettingsSchema) => SettingsView)\n        | undefined\n      >,\n    [storedBehavior],\n  );\n  const behaviorDirty = Boolean(\n    behaviorControls.condenser?.isDirty ||\n    behaviorControls.verification?.isDirty,\n  );\n  const behaviorToSave = (section: ProfileBehaviorSection) => {\n    const control = behaviorControls[section];\n    if (!control) return undefined;\n    if (!control.isDirty && storedBehavior[section]) return undefined;\n    return fromBehaviorFormValues(schema, section, control.values);\n  };\n  // --- Model Router (OpenHands; stored on the profile) ---\n  const routerSupported = agentProfileSupportsRouter();\n  const { data: metaProfilesData } = useMetaProfiles({\n    enabled: routerSupported,\n  });\n  // On without a name means the server picks the alphabetically first router.\n  let initialRouter: string | null = null;\n  if (agentSettingsOverride?.enable_classify_and_switch_llm_tool === true) {\n    initialRouter =\n      typeof agentSettingsOverride.meta_profile_ref === \"string\"\n        ? agentSettingsOverride.meta_profile_ref\n        : ROUTER_FIRST_KEY;\n  }\n  const [router, setRouter] = useState<string | null>(initialRouter);\n  useEffect(() => {\n    setRouter(initialRouter);\n  }, [initialRouter]);\n  const routerNames = (metaProfilesData?.meta_profiles ?? []).map(\n    ({ name }) => name,\n  );\n  // A router that no longer exists fails the launch, so keep it visible.\n  const routerDangling =\n    router !== null &&\n    router !== ROUTER_FIRST_KEY &&\n    !!metaProfilesData &&\n    !routerNames.includes(router);\n\n  // --- Save control ---\n  // Ref-backed so the exposed builder/credential fns read the freshest state at\n  // call time without re-emitting the control on every keystroke (mirrors\n  // ``sdk-section-page``). The body is (re)assigned during render below.\n  const buildFieldsRef = useRef<() => AgentProfileFieldsDraft>(() => ({\n    agent_kind: \"openhands\",\n    mcp_server_refs: null,\n  }));\n  const stableBuildFields = useCallback(() => buildFieldsRef.current(), []);\n  const credFormRef = useRef(acpCredentialForm);\n  credFormRef.current = acpCredentialForm;\n  const stableCredSave = useCallback(\n    (opts?: { silent?: boolean }) => credFormRef.current.save(opts),\n    [],\n  );\n  const stableCredReset = useCallback(() => credFormRef.current.reset(), []);\n\n  // Validity/kind/dirty are computed here (before the loading early-return) so\n  // the emit effect can depend on them; the full ACP derivation lives after it.\n  const acpCommandEmpty =\n    agentType === \"acp\" && parseCommand(commandText).length === 0;\n  // `mcp_server_refs` lives on the profile base, so it is dirty-tracked for both\n  // variants rather than inside the kind-specific branch below.\n  const mcpScopeDirty =\n    mcpMode !== initialMcpRefs.mode ||\n    !sameScopeSelection(orderedSelectedMcpServers, initialMcpRefs.selected);\n  // `secret_refs` is a base-model field too, so it is tracked for both variants.\n  const secretScopeDirty =\n    secretsMode !== initialSecretRefs.mode ||\n    !sameScopeSelection(orderedSelectedSecrets, initialSecretRefs.selected);\n  const routerDirty = routerSupported && router !== initialRouter;\n  const toolSelectionDirty =\n    toolsMode !== initialTools.mode ||\n    (toolsMode === \"custom\" &&\n      !sameScopeSelection(orderedSelectedTools, initialTools.selected));\n  const systemPromptDirty =\n    instructionsSupported &&\n    (systemPromptMode !== initialSystemPrompt.mode ||\n      (systemPromptMode !== \"standard\" &&\n        systemPromptText !== initialSystemPrompt.text));\n  const systemPromptMissing =\n    agentType === \"openhands\" &&\n    instructionsSupported &&\n    systemPromptMode !== \"standard\" &&\n    !systemPromptText.trim();\n  const settingsDirty =\n    agentType !== loadedSnapshot.agentType ||\n    mcpScopeDirty ||\n    secretScopeDirty ||\n    toolSelectionDirty ||\n    (agentType === \"acp\"\n      ? commandText !== loadedSnapshot.commandText ||\n        acpModel !== loadedSnapshot.acpModel ||\n        isCustomAcpModel !== loadedSnapshot.isCustomAcpModel\n      : toolConcurrency !== initialToolConcurrency ||\n        systemPromptDirty ||\n        behaviorDirty ||\n        routerDirty);\n  const credentialsDirty = acpCredentialForm.isDirty;\n  const isAnyDirty =\n    settingsDirty ||\n    credentialsDirty ||\n    (agentType === \"openhands\" && skillsDirty);\n  const customToolsUninitialized =\n    toolCatalogLoaded &&\n    agentType === \"openhands\" &&\n    toolsMode === \"custom\" &&\n    selectedTools === null;\n  const isFormValid =\n    !acpCommandEmpty && !systemPromptMissing && !customToolsUninitialized;\n  useEffect(() => {\n    onSaveControlChange({\n      agentType,\n      isValid: isFormValid,\n      isDirty: isAnyDirty,\n      buildAgentProfileFields: stableBuildFields,\n      credentials: {\n        isDirty: credentialsDirty,\n        save: stableCredSave,\n        reset: stableCredReset,\n      },\n    });\n  }, [\n    onSaveControlChange,\n    agentType,\n    isFormValid,\n    isAnyDirty,\n    credentialsDirty,\n    stableBuildFields,\n    stableCredSave,\n    stableCredReset,\n  ]);\n\n  const isAcp = agentType === \"acp\";\n  // An OpenHands `default` shares its MCP servers, secrets and skills with the global pages.\n  const sharesGlobalPages = isDefaultProfile && !isAcp;\n  // Switching `default`'s agent type moves its MCP servers onto or off the\n  // MCP page's switches.\n  const sharedRef = React.useRef(sharesGlobalPages);\n  React.useEffect(() => {\n    if (sharedRef.current === sharesGlobalPages) return;\n    sharedRef.current = sharesGlobalPages;\n    const scope = sharesGlobalPages\n      ? readProfileScope(enabledMcpServerRefs(settings))\n      : initialMcpRefs;\n    setMcpMode(scope.mode);\n    setSelectedMcpServers(scope.selected);\n  }, [sharesGlobalPages, settings, initialMcpRefs]);\n  const commandTokens = parseCommand(commandText);\n  const selectedPreset = detectPreset(commandText, acpProviders);\n  const selectedProvider = getAcpProvider(selectedPreset);\n  const isDefaultProviderCommand =\n    !!selectedProvider &&\n    commandTokens.join(\" \") === selectedProvider.default_command.join(\" \");\n  const liveModels = useAcpModelDiscovery(\n    isAcp && isDefaultProviderCommand ? selectedPreset : null,\n    {\n      secretRefs:\n        secretRefsSupportedOnProfile && secretsMode === \"custom\"\n          ? selectedSecrets\n          : null,\n    },\n  );\n\n  if (isLoading) return null;\n\n  const modelSuggestions = buildModelSuggestions(\n    liveModels.models,\n    isCustomAcpModel ? \"\" : acpModel,\n  );\n  const agentDefaultModelLabel = labelForAcpModel(\n    liveModels.defaultModelId,\n    liveModels.models,\n  );\n  // Without a preferred model the agent picks its own, so name that choice.\n  const offersAgentDefault =\n    !!selectedProvider && !getAcpPreferredDefaultModel(selectedPreset);\n  const showModelSelector = offersAgentDefault || modelSuggestions.length > 0;\n  const modelListHint = (() => {\n    if (!selectedProvider || liveModels.isDiscovering) return null;\n    if (liveModels.discovery?.error?.code === \"ACPAuthRequired\") return null;\n    if (liveModels.source === \"remembered\") {\n      return {\n        testId: \"agent-model-list-remembered\",\n        key: I18nKey.SETTINGS$AGENT_MODEL_LIST_REMEMBERED,\n      };\n    }\n    if (liveModels.source === \"none\") {\n      return {\n        testId: \"agent-model-list-after-first-conversation\",\n        key: I18nKey.SETTINGS$AGENT_MODEL_LIST_AFTER_FIRST_CONVERSATION,\n      };\n    }\n    return null;\n  })();\n  const selectedModelIsSuggestion = modelSuggestions.some(\n    ({ id }) => id === acpModel.trim(),\n  );\n  let selectedModelKey = acpModel;\n  if (offersAgentDefault && !isCustomAcpModel && !acpModel.trim()) {\n    selectedModelKey = ACP_AGENT_DEFAULT_MODEL_KEY;\n  } else if (isCustomAcpModel || !selectedModelIsSuggestion) {\n    selectedModelKey = ACP_CUSTOM_MODEL_KEY;\n  }\n  const commandPlaceholder =\n    formatCommand(acpProviders[0]?.default_command ?? []) ||\n    COMMAND_PLACEHOLDER_FALLBACK;\n\n  // Assign the control's field builder from the live render state.\n  // The mapping itself lives in the pure `buildAgentProfileFields` (unit-\n  // tested); this closure just snapshots the current state. Throws only when\n  // called (at save time), never during render.\n  buildFieldsRef.current = (): AgentProfileFieldsDraft =>\n    buildAgentProfileFields({\n      isAcp,\n      selectedPreset,\n      isDefaultProviderCommand,\n      commandTokens,\n      acpModel,\n      toolConcurrencyField,\n      toolConcurrency,\n      mcpMode,\n      selectedMcpServers: orderedSelectedMcpServers,\n      secretsMode,\n      selectedSecrets: orderedSelectedSecrets,\n      secretRefsSupportedOnProfile,\n      skillEnablement: skillsToSave,\n      toolsMode,\n      selectedTools: orderedSelectedTools,\n      toolParams: initialTools.params,\n      toolCatalogLoaded,\n      router: routerDirty ? router : undefined,\n      systemPromptMode,\n      systemPromptText,\n      systemPromptEditable: instructionsSupported,\n      condenser: behaviorToSave(\"condenser\"),\n      verification: behaviorToSave(\"verification\"),\n    });\n\n  const isSaving = acpCredentialForm.isSaving;\n\n  return (\n    <div\n      data-testid=\"agent-settings-screen\"\n      className=\"flex flex-col gap-6 pb-8 max-w-2xl\"\n    >\n      <SettingsDropdownInput\n        testId=\"agent-type-selector\"\n        name=\"agent-type\"\n        label={t(I18nKey.SETTINGS$NAV_AGENT)}\n        items={[\n          {\n            key: \"openhands\",\n            label: t(I18nKey.SETTINGS$AGENT_TYPE_OPENHANDS),\n          },\n          { key: \"acp\", label: t(I18nKey.SETTINGS$AGENT_TYPE_ACP) },\n        ]}\n        selectedKey={agentType}\n        onSelectionChange={(key) => {\n          if (!key) return;\n          const newType = key as AgentType;\n          setAgentType(newType);\n          if (newType === \"acp\" && !commandText) {\n            const preferred = acpProviders[0];\n            if (preferred) {\n              setCommandText(formatCommand(preferred.default_command));\n              setAcpModel(getAcpPreferredDefaultModel(preferred.key) ?? \"\");\n              setIsCustomAcpModel(false);\n              if (secretsMode === \"custom\") addProviderSecrets(preferred.key);\n            }\n          } else if (\n            newType === \"acp\" &&\n            agentType !== \"acp\" &&\n            secretsMode === \"custom\"\n          ) {\n            addProviderSecrets(selectedPreset);\n          } else if (newType === \"openhands\") {\n            setIsCustomAcpModel(false);\n          }\n        }}\n      />\n\n      {isAcp ? null : llmPicker}\n\n      {!isAcp && instructionsSupported ? (\n        <div className=\"flex flex-col gap-2.5\">\n          <Typography.Text className=\"text-sm\">\n            {t(I18nKey.SETTINGS$AGENT_PROFILE_SYSTEM_PROMPT)}\n          </Typography.Text>\n          <SettingsDropdownInput\n            testId=\"agent-settings-system-prompt-mode\"\n            name=\"agent-system-prompt-mode\"\n            label=\"\"\n            items={[\n              {\n                key: \"standard\",\n                label: t(I18nKey.SETTINGS$AGENT_PROFILE_SYSTEM_PROMPT_STANDARD),\n              },\n              {\n                key: \"append\",\n                label: t(I18nKey.SETTINGS$AGENT_PROFILE_SYSTEM_PROMPT_APPEND),\n              },\n              {\n                key: \"custom\",\n                label: t(I18nKey.SETTINGS$AGENT_PROFILE_SYSTEM_PROMPT_CUSTOM),\n              },\n            ]}\n            selectedKey={systemPromptMode}\n            isDisabled={isSaving}\n            onSelectionChange={(key) => {\n              if (key) setSystemPromptMode(key as SystemPromptMode);\n            }}\n          />\n          {systemPromptMode !== \"standard\" ? (\n            <textarea\n              data-testid=\"agent-settings-system-prompt\"\n              aria-label={t(I18nKey.SETTINGS$AGENT_PROFILE_SYSTEM_PROMPT)}\n              className={cn(\n                formControlMultilineFieldClassName,\n                \"min-h-48 font-mono placeholder:italic\",\n                \"disabled:bg-surface-raised disabled:border-border-subtle\",\n              )}\n              value={systemPromptText}\n              maxLength={SYSTEM_PROMPT_MAX_LENGTH}\n              placeholder={t(\n                systemPromptMode === \"custom\"\n                  ? I18nKey.SETTINGS$AGENT_PROFILE_SYSTEM_PROMPT_PLACEHOLDER\n                  : I18nKey.SETTINGS$AGENT_PROFILE_SYSTEM_PROMPT_APPEND_PLACEHOLDER,\n              )}\n              disabled={isSaving}\n              onChange={(e) => setSystemPromptText(e.target.value)}\n            />\n          ) : null}\n          {systemPromptMissing ? (\n            <Typography.Text\n              testId=\"agent-settings-system-prompt-required\"\n              className=\"text-xs text-danger\"\n            >\n              {t(I18nKey.SETTINGS$AGENT_PROFILE_SYSTEM_PROMPT_REQUIRED)}\n            </Typography.Text>\n          ) : null}\n          <Typography.Text\n            testId=\"agent-settings-system-prompt-hint\"\n            className=\"text-xs text-tertiary-alt\"\n          >\n            {t(\n              {\n                standard:\n                  I18nKey.SETTINGS$AGENT_PROFILE_SYSTEM_PROMPT_STANDARD_HINT,\n                append:\n                  I18nKey.SETTINGS$AGENT_PROFILE_SYSTEM_PROMPT_APPEND_HINT,\n                custom:\n                  I18nKey.SETTINGS$AGENT_PROFILE_SYSTEM_PROMPT_CUSTOM_HINT,\n              }[systemPromptMode],\n            )}\n          </Typography.Text>\n        </div>\n      ) : null}\n\n      {!isAcp && routerSupported ? (\n        <div\n          className=\"flex flex-col gap-2.5\"\n          data-testid=\"agent-settings-router\"\n        >\n          <Typography.Text className=\"text-sm\">\n            {t(I18nKey.SETTINGS$NAV_META_LLM)}\n          </Typography.Text>\n          <SettingsDropdownInput\n            testId=\"agent-settings-router-select\"\n            name=\"agent-router\"\n            label=\"\"\n            items={[\n              {\n                key: ROUTER_OFF_KEY,\n                label: t(I18nKey.SETTINGS$AGENT_PROFILE_ROUTER_OFF),\n              },\n              ...(initialRouter === ROUTER_FIRST_KEY\n                ? [\n                    {\n                      key: ROUTER_FIRST_KEY,\n                      label: t(I18nKey.SETTINGS$AGENT_PROFILE_ROUTER_FIRST),\n                    },\n                  ]\n                : []),\n              ...[\n                ...routerNames,\n                ...(routerDangling && router ? [router] : []),\n              ].map((name) => ({ key: name, label: name })),\n            ]}\n            selectedKey={router ?? ROUTER_OFF_KEY}\n            isDisabled={isSaving}\n            onSelectionChange={(key) => {\n              if (key === null || key === undefined) return;\n              setRouter(key === ROUTER_OFF_KEY ? null : String(key));\n            }}\n          />\n          {routerDangling ? (\n            <Typography.Text\n              testId=\"agent-settings-router-dangling\"\n              className=\"text-xs text-danger\"\n            >\n              {t(I18nKey.SETTINGS$AGENT_PROFILE_ROUTER_DANGLING, {\n                name: router,\n              })}\n            </Typography.Text>\n          ) : null}\n          <Typography.Text className=\"text-xs text-tertiary-alt\">\n            {t(I18nKey.SETTINGS$AGENT_PROFILE_ROUTER_HINT)}\n          </Typography.Text>\n        </div>\n      ) : null}\n      {!isAcp && toolConcurrencyField ? (\n        <SchemaField\n          field={toolConcurrencyField}\n          value={toolConcurrency}\n          isDisabled={isSaving}\n          onChange={setToolConcurrency}\n        />\n      ) : null}\n      {!isAcp && toolsSupported ? (\n        <div className=\"flex flex-col gap-2.5\">\n          <Typography.Text className=\"text-sm\">\n            {t(I18nKey.SETTINGS$AGENT_PROFILE_TOOLS)}\n          </Typography.Text>\n          <SettingsDropdownInput\n            testId=\"agent-settings-tools-mode\"\n            name=\"agent-tools-mode\"\n            label=\"\"\n            items={[\n              {\n                key: \"standard\",\n                label: t(I18nKey.SETTINGS$AGENT_PROFILE_TOOLS_STANDARD),\n              },\n              {\n                key: \"custom\",\n                label: t(I18nKey.SETTINGS$AGENT_PROFILE_TOOLS_CHOOSE),\n              },\n            ]}\n            selectedKey={toolsMode}\n            isDisabled={isSaving || standardToolNames === undefined}\n            onSelectionChange={(key) => {\n              if (!key) return;\n              setToolsMode(key as ProfileScopeMode);\n            }}\n          />\n          {toolCatalogFailed && standardToolNames === undefined ? (\n            <div\n              className=\"flex items-center gap-2\"\n              data-testid=\"agent-settings-tools-load-failed\"\n            >\n              <Typography.Text className=\"text-xs text-feedback-error\">\n                {t(I18nKey.SETTINGS$AGENT_PROFILE_TOOLS_LOAD_FAILED)}{\" \"}\n                {t(I18nKey.SETTINGS$AGENT_PROFILE_TOOLS_KEPT_ON_SAVE)}\n              </Typography.Text>\n              <BrandButton\n                testId=\"agent-settings-tools-retry\"\n                type=\"button\"\n                variant=\"secondary\"\n                onClick={() => refetchToolCatalog()}\n              >\n                {t(I18nKey.SETTINGS$AGENT_PROFILE_TOOLS_RETRY)}\n              </BrandButton>\n            </div>\n          ) : toolsMode === \"standard\" ? (\n            <ProfileScopeList\n              testId=\"agent-settings-tool\"\n              items={(standardToolNames ?? []).map((name) => ({\n                name,\n                description: toolDescriptions.get(name),\n              }))}\n              selected={standardToolNames ?? []}\n              isDisabled\n              onToggle={() => {}}\n            />\n          ) : (\n            <ProfileScopeList\n              testId=\"agent-settings-tool\"\n              items={toolPickerCatalog}\n              selected={orderedSelectedTools}\n              isDisabled={isSaving || !toolCatalogLoaded}\n              onToggle={(name, checked) =>\n                setSelectedTools((prev) =>\n                  checked\n                    ? [...(prev ?? []), name]\n                    : (prev ?? []).filter((entry) => entry !== name),\n                )\n              }\n            />\n          )}\n          <Typography.Text className=\"text-xs text-tertiary-alt\">\n            {t(\n              toolsMode === \"custom\"\n                ? I18nKey.SETTINGS$AGENT_PROFILE_TOOLS_CHOOSE_HINT\n                : I18nKey.SETTINGS$AGENT_PROFILE_TOOLS_STANDARD_HINT,\n            )}\n          </Typography.Text>\n        </div>\n      ) : null}\n\n      <div className=\"flex flex-col gap-2.5\">\n        <Typography.Text className=\"text-sm\">\n          {t(I18nKey.SETTINGS$AGENT_PROFILE_MCP)}\n        </Typography.Text>\n        <SettingsDropdownInput\n          testId=\"agent-settings-mcp-mode\"\n          name=\"agent-mcp-mode\"\n          label=\"\"\n          items={[\n            {\n              key: \"standard\",\n              label: t(I18nKey.SETTINGS$AGENT_PROFILE_MCP_ALL),\n            },\n            {\n              key: \"custom\",\n              label: t(I18nKey.SETTINGS$AGENT_PROFILE_MCP_CHOOSE),\n            },\n          ]}\n          selectedKey={mcpMode}\n          isDisabled={isSaving}\n          onSelectionChange={(key) => {\n            if (!key) return;\n            const mode = key as ProfileScopeMode;\n            setMcpMode(mode);\n            // Seed a first switch to custom from the default \u2014 every\n            // configured server \u2014 so turning the control on narrows from what\n            // the agent had rather than cutting it off from all of them.\n            if (mode === \"custom\" && selectedMcpServers.length === 0) {\n              setSelectedMcpServers(configuredMcpNames.map(({ name }) => name));\n            }\n          }}\n        />\n        {mcpCatalog.length > 0 ? (\n          <ProfileScopeList\n            testId=\"agent-settings-mcp\"\n            items={mcpCatalog}\n            selected={\n              mcpMode === \"custom\"\n                ? orderedSelectedMcpServers\n                : mcpCatalog.map(({ name }) => name)\n            }\n            isDisabled={isSaving || mcpMode === \"standard\"}\n            onToggle={(name, checked) =>\n              setSelectedMcpServers((prev) =>\n                checked\n                  ? [...prev, name]\n                  : prev.filter((entry) => entry !== name),\n              )\n            }\n          />\n        ) : (\n          <Typography.Text className=\"text-xs text-tertiary-alt\">\n            {t(I18nKey.SETTINGS$AGENT_PROFILE_MCP_NONE)}\n          </Typography.Text>\n        )}\n        {danglingMcpRefs.length > 0 ? (\n          <Typography.Text\n            testId=\"agent-settings-mcp-dangling\"\n            className=\"text-xs text-danger\"\n          >\n            {t(I18nKey.SETTINGS$AGENT_PROFILE_MCP_DANGLING, {\n              names: danglingMcpRefs.join(\", \"),\n            })}\n          </Typography.Text>\n        ) : null}\n        <Typography.Text\n          testId={\n            sharesGlobalPages ? \"agent-settings-mcp-default-note\" : undefined\n          }\n          className=\"text-xs text-tertiary-alt\"\n        >\n          {t(\n            sharesGlobalPages\n              ? I18nKey.SETTINGS$AGENT_PROFILE_DEFAULT_MCP_NOTE\n              : mcpMode === \"custom\"\n                ? I18nKey.SETTINGS$AGENT_PROFILE_MCP_CHOOSE_HINT\n                : I18nKey.SETTINGS$AGENT_PROFILE_MCP_ALL_HINT,\n          )}\n        </Typography.Text>\n      </div>\n\n      {!isAcp\n        ? PROFILE_BEHAVIOR_BLOCKS.map(({ section, titleKey }) => (\n            <div\n              key={section}\n              className=\"flex flex-col gap-2.5\"\n              data-testid={`agent-settings-${section}`}\n            >\n              <Typography.Text className=\"text-sm\">\n                {t(titleKey)}\n              </Typography.Text>\n              <SdkSectionPage\n                settingsSources={[\n                  {\n                    settingsSource: \"agent_settings\",\n                    sectionKeys: [section],\n                    excludeKeys: GLOBAL_BEHAVIOR_FIELD_KEYS,\n                  },\n                ]}\n                embedded\n                hideSaveButton\n                markInitialOverridesDirty={false}\n                initialValueOverrides={behaviorInitialValues[section]}\n                getInitialView={behaviorInitialView[section]}\n                onSaveControlChange={behaviorControlHandlers[section]}\n                testId={`agent-settings-${section}-fields`}\n              />\n            </div>\n          ))\n        : null}\n\n      {secretRefsSupportedOnProfile && sharesGlobalPages ? (\n        <div className=\"flex flex-col gap-2.5\">\n          <Typography.Text className=\"text-sm\">\n            {t(I18nKey.SETTINGS$AGENT_PROFILE_SECRETS)}\n          </Typography.Text>\n          <Typography.Text\n            testId=\"agent-settings-secrets-default-note\"\n            className=\"text-xs text-tertiary-alt\"\n          >\n            {t(I18nKey.SETTINGS$AGENT_PROFILE_DEFAULT_SECRETS_NOTE)}\n          </Typography.Text>\n        </div>\n      ) : null}\n\n      {secretRefsSupportedOnProfile && !sharesGlobalPages ? (\n        <div className=\"flex flex-col gap-2.5\">\n          <Typography.Text className=\"text-sm\">\n            {t(I18nKey.SETTINGS$AGENT_PROFILE_SECRETS)}\n          </Typography.Text>\n          <SettingsDropdownInput\n            testId=\"agent-settings-secrets-mode\"\n            name=\"agent-secrets-mode\"\n            label=\"\"\n            items={[\n              {\n                key: \"standard\",\n                label: t(I18nKey.SETTINGS$AGENT_PROFILE_SECRETS_ALL),\n              },\n              {\n                key: \"custom\",\n                label: t(I18nKey.SETTINGS$AGENT_PROFILE_SECRETS_CHOOSE),\n              },\n            ]}\n            selectedKey={secretsMode}\n            isDisabled={isSaving}\n            onSelectionChange={(key) => {\n              if (!key) return;\n              const mode = key as ProfileScopeMode;\n              setSecretsMode(mode);\n              if (mode === \"custom\" && isAcp)\n                addProviderSecrets(selectedPreset);\n            }}\n          />\n          {secretCatalog.length > 0 ? (\n            <ProfileScopeList\n              testId=\"agent-settings-secret\"\n              items={secretCatalog}\n              selected={\n                secretsMode === \"custom\"\n                  ? orderedSelectedSecrets\n                  : secretCatalog.map(({ name }) => name)\n              }\n              isDisabled={isSaving || secretsMode === \"standard\"}\n              onToggle={(name, checked) =>\n                setSelectedSecrets((prev) =>\n                  checked\n                    ? [...prev, name]\n                    : prev.filter((entry) => entry !== name),\n                )\n              }\n            />\n          ) : (\n            <Typography.Text className=\"text-xs text-tertiary-alt\">\n              {t(I18nKey.SETTINGS$AGENT_PROFILE_SECRETS_NONE)}\n            </Typography.Text>\n          )}\n          <Typography.Text className=\"text-xs text-tertiary-alt\">\n            {t(\n              secretsMode === \"custom\"\n                ? I18nKey.SETTINGS$AGENT_PROFILE_SECRETS_CHOOSE_HINT\n                : I18nKey.SETTINGS$AGENT_PROFILE_SECRETS_ALL_HINT,\n            )}\n          </Typography.Text>\n        </div>\n      ) : null}\n\n      {agentType === \"openhands\" && skillsSupported ? (\n        <div\n          className=\"flex flex-col gap-2.5\"\n          data-testid=\"agent-settings-skills-section\"\n        >\n          <Typography.Text className=\"text-sm\">\n            {t(I18nKey.SETTINGS$AGENT_PROFILE_SKILLS)}\n          </Typography.Text>\n          {skillCatalog && skillCatalog.length > SKILL_SEARCH_MIN_ITEMS ? (\n            <SettingsInput\n              testId=\"agent-settings-skills-search\"\n              label=\"\"\n              type=\"search\"\n              className=\"w-full\"\n              value={skillQuery}\n              placeholder={t(I18nKey.SETTINGS$SKILLS_SEARCH_PLACEHOLDER)}\n              onChange={setSkillQuery}\n            />\n          ) : null}\n          {visibleSkills && currentSkills ? (\n            <div className=\"max-h-80 overflow-y-auto pr-1\">\n              <ProfileScopeList\n                testId=\"agent-settings-skills\"\n                items={visibleSkills.map((skill) => ({\n                  name: skill.name,\n                  description: t(\n                    skill.origin === \"public\"\n                      ? I18nKey.SETTINGS$SKILL_ORIGIN_PUBLIC\n                      : I18nKey.SETTINGS$SKILL_ORIGIN_USER,\n                  ),\n                }))}\n                selected={enabledSkillNames}\n                isDisabled={isSaving}\n                onToggle={toggleProfileSkill}\n              />\n            </div>\n          ) : null}\n          {skillCatalogFailed ? (\n            <div\n              className=\"flex items-center gap-2\"\n              data-testid=\"agent-settings-skills-load-failed\"\n            >\n              <Typography.Text className=\"text-xs text-feedback-error\">\n                {t(I18nKey.SETTINGS$AGENT_PROFILE_SKILLS_LOAD_FAILED)}\n              </Typography.Text>\n              <BrandButton\n                testId=\"agent-settings-skills-retry\"\n                type=\"button\"\n                variant=\"secondary\"\n                onClick={() => refetchSkillCatalog()}\n              >\n                {t(I18nKey.SETTINGS$AGENT_PROFILE_TOOLS_RETRY)}\n              </BrandButton>\n            </div>\n          ) : null}\n          <Typography.Text\n            testId={\n              sharesGlobalPages\n                ? \"agent-settings-skills-default-note\"\n                : undefined\n            }\n            className=\"text-xs text-tertiary-alt\"\n          >\n            {t(\n              sharesGlobalPages\n                ? I18nKey.SETTINGS$AGENT_PROFILE_DEFAULT_SKILLS_NOTE\n                : I18nKey.SETTINGS$AGENT_PROFILE_SKILLS_HINT,\n            )}\n          </Typography.Text>\n        </div>\n      ) : null}\n\n      {isAcp && (\n        <>\n          <SettingsDropdownInput\n            testId=\"agent-preset-selector\"\n            name=\"agent-preset\"\n            label={t(I18nKey.SETTINGS$AGENT_PRESET)}\n            items={[\n              ...acpProviders.map((provider) => ({\n                key: provider.key,\n                label: provider.display_name,\n              })),\n              {\n                key: ACP_CUSTOM_PRESET_KEY,\n                label: t(I18nKey.SETTINGS$AGENT_PRESET_CUSTOM),\n              },\n            ]}\n            selectedKey={selectedPreset}\n            onSelectionChange={(key) => {\n              if (!key) return;\n              const preset = String(key);\n              const provider = getAcpProvider(preset);\n              if (provider) {\n                setCommandText(formatCommand(provider.default_command));\n                setAcpModel(getAcpPreferredDefaultModel(preset) ?? \"\");\n                setIsCustomAcpModel(false);\n                if (secretsMode === \"custom\" && preset !== selectedPreset) {\n                  addProviderSecrets(preset);\n                }\n              } else if (preset === ACP_CUSTOM_PRESET_KEY) {\n                // Clear command + model: the previous provider's default\n                // command would otherwise make detectPreset(commandText)\n                // re-match it on the next render and snap the dropdown back\n                // off \"Custom\". Clearing model also prevents leaking e.g.\n                // ``claude-opus-4-7`` into ``acp_model`` for an unrelated\n                // wrapper.\n                setCommandText(\"\");\n                setAcpModel(\"\");\n                setIsCustomAcpModel(true);\n              }\n            }}\n          />\n\n          <div className=\"flex flex-col gap-2.5\">\n            <Typography.Text className=\"text-sm\">\n              {t(I18nKey.SETTINGS$AGENT_COMMAND)}\n            </Typography.Text>\n            <textarea\n              data-testid=\"agent-command-input\"\n              className=\"bg-tertiary border border-[#717888] rounded-sm p-2 text-sm font-mono text-contrast placeholder:text-[#717888] min-h-15 resize-y focus:outline-none focus:border-contrast\"\n              value={commandText}\n              placeholder={commandPlaceholder}\n              onChange={(e) => {\n                const nextCommandText = e.target.value;\n                // Keep the model selector in sync with the command being\n                // typed. Editing the command into a *different* provider \u2014 or\n                // into a custom command \u2014 must drop the previous provider's\n                // model, or Save would silently persist e.g.\n                // ``claude-opus-4-7`` against a Codex / custom wrapper. The\n                // preset dropdown already does this; the textarea is the other\n                // way a user changes provider, so it needs the same\n                // reconciliation. Gated on the *detected preset* actually\n                // changing, so it never clobbers a model the user is editing\n                // within the same provider.\n                const prevPreset = detectPreset(commandText, acpProviders);\n                const nextPreset = detectPreset(nextCommandText, acpProviders);\n                if (nextPreset !== prevPreset) {\n                  setAcpModel(getAcpPreferredDefaultModel(nextPreset) ?? \"\");\n                  setIsCustomAcpModel(false);\n                  if (secretsMode === \"custom\") addProviderSecrets(nextPreset);\n                }\n                setCommandText(nextCommandText);\n              }}\n            />\n            <Typography.Text className=\"text-xs text-[#717888]\">\n              {t(I18nKey.SETTINGS$AGENT_COMMAND_HINT)}\n            </Typography.Text>\n          </div>\n        </>\n      )}\n\n      {isAcp && selectedPreset !== ACP_CUSTOM_PRESET_KEY && (\n        <>\n          <hr className=\"border-[#3D4046]\" />\n          <AcpCredentialsSection\n            form={acpCredentialForm}\n            providerKey={selectedPreset}\n            loginRejected={\n              liveModels.discovery?.error?.code === \"ACPAuthRequired\"\n            }\n          />\n          <hr className=\"border-[#3D4046]\" />\n        </>\n      )}\n\n      {isAcp && (\n        <div className=\"flex flex-col gap-1.5\">\n          {showModelSelector && (\n            <SettingsDropdownInput\n              testId=\"agent-model-selector\"\n              name=\"agent-model\"\n              label={t(I18nKey.SETTINGS$AGENT_MODEL)}\n              items={[\n                ...(offersAgentDefault\n                  ? [\n                      {\n                        key: ACP_AGENT_DEFAULT_MODEL_KEY,\n                        label: agentDefaultModelLabel\n                          ? t(I18nKey.SETTINGS$AGENT_MODEL_AGENT_DEFAULT_IS, {\n                              model: agentDefaultModelLabel,\n                            })\n                          : t(I18nKey.SETTINGS$AGENT_MODEL_AGENT_DEFAULT),\n                      },\n                    ]\n                  : []),\n                ...modelSuggestions.map((model) => ({\n                  key: model.id,\n                  label:\n                    !offersAgentDefault &&\n                    model.id === liveModels.defaultModelId\n                      ? t(I18nKey.SETTINGS$AGENT_MODEL_IS_AGENT_DEFAULT, {\n                          model: model.label,\n                        })\n                      : model.label,\n                })),\n                {\n                  key: ACP_CUSTOM_MODEL_KEY,\n                  label: t(I18nKey.SETTINGS$AGENT_PRESET_CUSTOM),\n                },\n              ]}\n              selectedKey={selectedModelKey}\n              onSelectionChange={(key) => {\n                if (!key) return;\n                const modelKey = String(key);\n                if (modelKey === ACP_CUSTOM_MODEL_KEY) {\n                  setIsCustomAcpModel(true);\n                  setAcpModel(\"\");\n                } else if (modelKey === ACP_AGENT_DEFAULT_MODEL_KEY) {\n                  setIsCustomAcpModel(false);\n                  setAcpModel(\"\");\n                } else {\n                  setIsCustomAcpModel(false);\n                  setAcpModel(modelKey);\n                }\n              }}\n            />\n          )}\n          {selectedModelKey === ACP_CUSTOM_MODEL_KEY && (\n            <SettingsInput\n              testId=\"agent-model-input\"\n              label={\n                showModelSelector\n                  ? t(I18nKey.SETTINGS$AGENT_CUSTOM_MODEL)\n                  : t(I18nKey.SETTINGS$AGENT_MODEL)\n              }\n              type=\"text\"\n              className=\"w-full\"\n              value={acpModel}\n              showOptionalTag\n              onChange={(value) => {\n                setAcpModel(value);\n              }}\n            />\n          )}\n          {liveModels.isDiscovering && selectedProvider && (\n            <Typography.Text\n              testId=\"agent-model-discovering\"\n              className=\"text-xs text-[#717888]\"\n            >\n              {t(I18nKey.SETTINGS$AGENT_MODEL_DISCOVERING, {\n                agent: selectedProvider.display_name,\n              })}\n            </Typography.Text>\n          )}\n          {liveModels.discovery?.error?.code === \"ACPAuthRequired\" &&\n            selectedProvider && (\n              <Typography.Text\n                testId=\"agent-model-discovery-needs-auth\"\n                className=\"text-xs text-[#717888]\"\n              >\n                {t(I18nKey.SETTINGS$AGENT_MODEL_DISCOVERY_NEEDS_AUTH, {\n                  agent: selectedProvider.display_name,\n                })}\n              </Typography.Text>\n            )}\n          {modelListHint && selectedProvider && (\n            <Typography.Text\n              testId={modelListHint.testId}\n              className=\"text-xs text-[#717888]\"\n            >\n              {t(modelListHint.key, { agent: selectedProvider.display_name })}\n            </Typography.Text>\n          )}\n          <Typography.Text className=\"text-xs text-[#717888]\">\n            {t(I18nKey.SETTINGS$AGENT_MODEL_HINT)}\n          </Typography.Text>\n        </div>\n      )}\n    </div>\n  );\n}\n\n/**\n * Legacy `/settings/agent` route. Settings \u2192 Agent is now the Agent Profile\n * library (`/settings/agents`), whose editor reuses the named\n * `AgentSettingsScreen` export below; this global-agent-form route is retired\n * and redirects there so old links/bookmarks keep working.\n *\n * Note: This is a route file; only the router should import the default export.\n * React Router's Vite plugin wraps a route's default export with\n * `withComponentProps`, which invokes it with route props and drops any props\n * passed by a parent \u2014 so the Agent-profile editor MUST import the named\n * `AgentSettingsScreen` export instead, or `agentSettingsOverride` /\n * `onSaveControlChange` never arrive. Mirrors `LlmSettingsRoute`.\n */\nexport default function AgentSettingsRoute() {\n  return <Navigate to=\"/settings/agents\" replace />;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/routes/automation-setup-new.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"border-[var(--oh-border)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 109,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 109,
          "endColumn": 138
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 133,
          "column": 23,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 133,
          "endColumn": 95
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[scrollbar-gutter:stable]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 138,
          "column": 24,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 138,
          "endColumn": 184
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[800px]\" hardcodes an off-token value. Use \"max-w-200\" instead (same value, on the scale).",
          "line": 139,
          "column": 26,
          "nodeType": "Literal",
          "messageId": "arbitraryValueWithScale",
          "endLine": 139,
          "endColumn": 80,
          "suggestions": [
            {
              "messageId": "useScale",
              "data": {
                "replacement": "max-w-200"
              },
              "fix": {
                "range": [
                  5406,
                  5460
                ],
                "text": "\"relative mx-auto h-full w-full min-w-0 max-w-200\""
              },
              "desc": "Replace with \"max-w-200\" (same value, on the scale)."
            }
          ]
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[15px]\" hardcodes an off-token value. Nearest on the scale: rounded-2xl (16px), rounded-xl (12px).",
          "line": 143,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 143,
          "endColumn": 144
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"shadow-[0_12px_40px_rgba(0,0,0,0.55)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 143,
          "column": 25,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 143,
          "endColumn": 144
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 6,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useEffect, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useTranslation } from \"react-i18next\";\nimport { useSearchParams } from \"react-router\";\nimport AutomationService from \"#/api/automation-service/automation-service.api\";\nimport {\n  PENDING_AUTOMATION_SETUP_ID,\n  getAutomationFormSession,\n  initializeAutomationFormSession,\n  subscribeAutomationFormSession,\n} from \"#/api/automation-form-session\";\nimport type { AutomationSetupDraft } from \"#/api/automation-setup-types\";\nimport { setupDraftFromServerDraft } from \"#/components/features/automations/setup/automation-setup-draft-service\";\nimport { AutomationSetupPanel } from \"#/components/features/automations/setup/automation-setup-panel\";\nimport { InteractiveChatBox } from \"#/components/features/chat/interactive-chat-box\";\nimport { ComposerDockedProvider } from \"#/context/composer-docked-context\";\nimport { useStartAutomationSetup } from \"#/hooks/use-start-automation-setup\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { buildAutomationDraftTags } from \"#/utils/automation-draft-tags\";\nimport { setupDraftFromAutomation } from \"#/utils/automation-edit-draft\";\nimport { displayErrorToast } from \"#/utils/custom-toast-handlers\";\n\nconst BLANK_DRAFT: AutomationSetupDraft = { prompt: \"\", kind: \"prompt\" };\n\n/**\n * New automation form before any conversation exists.\n *\n * The agent drawer stays hidden. Sending the docked prompt is what creates\n * the conversation and reveals the agent.\n */\nexport default function AutomationSetupNew() {\n  const { t } = useTranslation(\"openhands\");\n  const [searchParams] = useSearchParams();\n  const automationId = searchParams.get(\"automationId\")?.trim() ?? \"\";\n  const draftId = searchParams.get(\"draftId\")?.trim() ?? \"\";\n  const { startConversationFromPrompt, isPending } = useStartAutomationSetup();\n  const [toolbarElement, setToolbarElement] = useState<HTMLDivElement | null>(\n    null,\n  );\n  const [composerTarget, setComposerTarget] = useState<HTMLDivElement | null>(\n    null,\n  );\n  const [draft, setDraft] = useState<AutomationSetupDraft | null>(() => {\n    const existing = getAutomationFormSession(PENDING_AUTOMATION_SETUP_ID);\n    return existing ?? (automationId || draftId ? null : BLANK_DRAFT);\n  });\n\n  useEffect(\n    () =>\n      subscribeAutomationFormSession(PENDING_AUTOMATION_SETUP_ID, (next) => {\n        setDraft(next ?? (automationId || draftId ? null : BLANK_DRAFT));\n      }),\n    [automationId, draftId],\n  );\n\n  useEffect(() => {\n    const existing = getAutomationFormSession(PENDING_AUTOMATION_SETUP_ID);\n    if (existing) {\n      setDraft(existing);\n      return undefined;\n    }\n\n    let cancelled = false;\n    const loadDraft = async () => {\n      try {\n        if (automationId) {\n          const automation =\n            await AutomationService.getAutomation(automationId);\n          const next = setupDraftFromAutomation(automation);\n          initializeAutomationFormSession(PENDING_AUTOMATION_SETUP_ID, next);\n          if (!cancelled) setDraft(next);\n          return;\n        }\n        if (draftId) {\n          const savedDraft = await AutomationService.getServerDraft(draftId);\n          const next = setupDraftFromServerDraft(savedDraft);\n          initializeAutomationFormSession(PENDING_AUTOMATION_SETUP_ID, next);\n          if (!cancelled) setDraft(next);\n          return;\n        }\n        if (!cancelled) setDraft(BLANK_DRAFT);\n      } catch (error) {\n        if (!cancelled) {\n          displayErrorToast(error instanceof Error ? error.message : null);\n          setDraft(BLANK_DRAFT);\n        }\n      }\n    };\n\n    void loadDraft();\n    return () => {\n      cancelled = true;\n    };\n  }, [automationId, draftId]);\n\n  const title = draft?.form?.name?.trim() || t(I18nKey.AUTOMATION_SETUP$TITLE);\n  const conversationTags = draft?.serverDraftId\n    ? buildAutomationDraftTags(\n        null,\n        draft.serverDraftId,\n        draft.materializedAutomationId,\n      )\n    : null;\n\n  return (\n    <div className=\"flex h-full min-h-0 flex-col overflow-hidden bg-canvas-base\">\n      <header\n        data-testid=\"automation-setup-topbar\"\n        className=\"flex h-10 min-h-10 shrink-0 items-center justify-between gap-2 border-b border-[var(--oh-border)] bg-canvas-base px-3\"\n      >\n        <h2 className=\"min-w-0 truncate text-sm font-medium text-content\">\n          {title}\n        </h2>\n        <div\n          ref={setToolbarElement}\n          data-testid=\"automation-setup-toolbar\"\n          className=\"flex min-w-0 shrink-0 items-center gap-1.5\"\n        />\n      </header>\n      <div className=\"relative flex min-h-0 flex-1 flex-col overflow-hidden\">\n        {draft ? (\n          <AutomationSetupPanel\n            draft={draft}\n            conversationId={PENDING_AUTOMATION_SETUP_ID}\n            conversationTags={conversationTags}\n            toolbarPortal={toolbarElement}\n            showInlineHeader={false}\n            reserveComposerSpace\n          />\n        ) : (\n          <div\n            role=\"status\"\n            className=\"flex flex-1 items-center justify-center text-sm text-[var(--oh-muted)]\"\n          >\n            {t(I18nKey.AUTOMATION_SETUP$LOADING_DRAFT)}\n          </div>\n        )}\n        <div className=\"custom-scrollbar-always pointer-events-none absolute inset-0 z-20 overflow-y-scroll px-5 [scrollbar-gutter:stable] [&::-webkit-scrollbar-thumb]:bg-transparent\">\n          <div className=\"relative mx-auto h-full w-full min-w-0 max-w-[800px]\">\n            <div\n              ref={setComposerTarget}\n              data-testid=\"automation-setup-docked-composer\"\n              className=\"pointer-events-auto absolute inset-x-0 bottom-5 overflow-visible rounded-[15px] shadow-[0_12px_40px_rgba(0,0,0,0.55)]\"\n            />\n          </div>\n        </div>\n        {composerTarget\n          ? createPortal(\n              <ComposerDockedProvider enabled minimal>\n                {/* No conversation route is mounted here, so a persisting\n                    composer would read and write the Home draft. The prompt\n                    belongs to this setup only and is not kept as a draft. */}\n                <InteractiveChatBox\n                  onSubmit={(content) => startConversationFromPrompt(content)}\n                  disabled={isPending || !draft}\n                  persistDraft={false}\n                  showGitControlBar={false}\n                  placeholder={t(\n                    I18nKey.AUTOMATION_SETUP$DOCKED_COMPOSER_PLACEHOLDER,\n                  )}\n                />\n              </ComposerDockedProvider>,\n              composerTarget,\n            )\n          : null}\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/routes/device-verify.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"tracking-[0.3em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 155,
          "column": 28,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 155,
          "endColumn": 90
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { useState } from \"react\";\nimport { useSearchParams } from \"react-router\";\nimport { useTranslation } from \"react-i18next\";\nimport { useIsAuthed } from \"#/hooks/query/use-is-authed\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { H1 } from \"#/ui/typography\";\nimport { cn } from \"#/utils/utils\";\n\nexport default function DeviceVerify() {\n  const { t } = useTranslation(\"openhands\");\n  const [searchParams] = useSearchParams();\n  const { data: isAuthed, isLoading: isAuthLoading } = useIsAuthed();\n  const [verificationResult, setVerificationResult] = useState<{\n    success: boolean;\n    messageKey: I18nKey;\n  } | null>(null);\n  const [isProcessing, setIsProcessing] = useState(false);\n\n  const userCode = searchParams.get(\"user_code\");\n\n  const processDeviceVerification = async (code: string) => {\n    try {\n      setIsProcessing(true);\n\n      const response = await fetch(\"/oauth/device/verify-authenticated\", {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/x-www-form-urlencoded\",\n        },\n        body: `user_code=${encodeURIComponent(code)}`,\n        credentials: \"include\",\n      });\n\n      if (response.ok) {\n        setVerificationResult({\n          success: true,\n          messageKey: I18nKey.DEVICE$SUCCESS_MESSAGE,\n        });\n      } else {\n        setVerificationResult({\n          success: false,\n          messageKey: I18nKey.DEVICE$ERROR_FAILED,\n        });\n      }\n    } catch {\n      setVerificationResult({\n        success: false,\n        messageKey: I18nKey.DEVICE$ERROR_OCCURRED,\n      });\n    } finally {\n      setIsProcessing(false);\n    }\n  };\n\n  const handleManualSubmit = (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    const formData = new FormData(event.currentTarget);\n    const code = formData.get(\"user_code\") as string;\n    if (code && isAuthed) {\n      processDeviceVerification(code);\n    }\n  };\n\n  if (verificationResult) {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-background\">\n        <div className=\"max-w-md w-full mx-auto p-6 bg-surface-raised rounded-lg shadow-lg\">\n          <div className=\"text-center\">\n            <div\n              className={cn(\n                \"mb-4\",\n                verificationResult.success\n                  ? \"text-feedback-success\"\n                  : \"text-feedback-error\",\n              )}\n            >\n              {verificationResult.success ? (\n                <svg\n                  className=\"w-12 h-12 mx-auto\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  viewBox=\"0 0 24 24\"\n                >\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2}\n                    d=\"M5 13l4 4L19 7\"\n                  />\n                </svg>\n              ) : (\n                <svg\n                  className=\"w-12 h-12 mx-auto\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  viewBox=\"0 0 24 24\"\n                >\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2}\n                    d=\"M6 18L18 6M6 6l12 12\"\n                  />\n                </svg>\n              )}\n            </div>\n            <h2 className=\"text-xl font-medium mb-2\">\n              {verificationResult.success\n                ? t(I18nKey.DEVICE$SUCCESS_TITLE)\n                : t(I18nKey.DEVICE$ERROR_TITLE)}\n            </h2>\n            <p className=\"text-muted mb-4\">\n              {t(verificationResult.messageKey)}\n            </p>\n            {!verificationResult.success && (\n              <button\n                type=\"button\"\n                onClick={() => window.location.reload()}\n                className=\"px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90\"\n              >\n                {t(I18nKey.DEVICE$TRY_AGAIN)}\n              </button>\n            )}\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  if (isProcessing) {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-background\">\n        <div className=\"max-w-md w-full mx-auto p-6 bg-surface-raised rounded-lg shadow-lg\">\n          <div className=\"text-center\">\n            <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-contrast mx-auto mb-4\" />\n            <p className=\"text-muted\">{t(I18nKey.DEVICE$PROCESSING)}</p>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  if (isAuthed && userCode) {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-background p-4\">\n        <div className=\"flex flex-col items-center gap-6 w-full max-w-md\">\n          <div className=\"flex-1 min-w-0 max-w-md w-full mx-auto p-6 bg-surface-raised rounded-2xl shadow-lg border border-border-subtle\">\n            <H1 className=\"text-2xl mb-4 text-center\">\n              {t(I18nKey.DEVICE$AUTHORIZATION_REQUEST)}\n            </H1>\n            <div className=\"mb-6 p-4 bg-canvas-base rounded-lg border border-border-subtle\">\n              <p className=\"text-xs text-text-subtle mb-2 text-center uppercase tracking-wider\">\n                {t(I18nKey.DEVICE$CODE_LABEL)}\n              </p>\n              <p className=\"text-xl font-mono font-semibold text-center tracking-[0.3em]\">\n                {userCode}\n              </p>\n            </div>\n            <div className=\"mb-6 p-4 bg-warning/5 border-l-2 border-feedback-warning rounded-r-lg\">\n              <p className=\"text-sm font-medium text-feedback-warning mb-1\">\n                {t(I18nKey.DEVICE$SECURITY_NOTICE)}\n              </p>\n              <p className=\"text-sm text-contrast\">\n                {t(I18nKey.DEVICE$SECURITY_WARNING)}\n              </p>\n            </div>\n            <p className=\"text-muted mb-6 text-center\">\n              {t(I18nKey.DEVICE$CONFIRM_PROMPT)}\n            </p>\n            <div className=\"flex gap-3\">\n              <button\n                type=\"button\"\n                onClick={() => window.close()}\n                className=\"flex-1 px-4 py-2 border border-border rounded-md hover:bg-muted text-text-tertiary\"\n              >\n                {t(I18nKey.DEVICE$CANCEL)}\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => processDeviceVerification(userCode)}\n                className=\"flex-1 px-4 py-2 bg-contrast text-contrast-foreground rounded-md hover:bg-contrast/90\"\n              >\n                {t(I18nKey.DEVICE$AUTHORIZE)}\n              </button>\n            </div>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  if (isAuthed && !userCode) {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-background\">\n        <div className=\"max-w-md w-full mx-auto p-6 bg-surface-raised rounded-lg shadow-lg\">\n          <H1 className=\"text-2xl mb-4 text-center\">\n            {t(I18nKey.DEVICE$AUTHORIZATION_TITLE)}\n          </H1>\n          <p className=\"text-muted mb-6 text-center\">\n            {t(I18nKey.DEVICE$ENTER_CODE_PROMPT)}\n          </p>\n          <form onSubmit={handleManualSubmit}>\n            <div className=\"mb-4\">\n              <label\n                htmlFor=\"user_code\"\n                className=\"block text-sm font-medium mb-2\"\n              >\n                {t(I18nKey.DEVICE$CODE_INPUT_LABEL)}\n              </label>\n              <input\n                type=\"text\"\n                id=\"user_code\"\n                name=\"user_code\"\n                required\n                className=\"w-full px-3 py-2 border border-border-input rounded-md focus:outline-none focus:ring-2 focus:ring-focus\"\n                placeholder={t(I18nKey.DEVICE$CODE_PLACEHOLDER)}\n              />\n            </div>\n            <button\n              type=\"submit\"\n              className=\"w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90\"\n            >\n              {t(I18nKey.DEVICE$CONTINUE)}\n            </button>\n          </form>\n        </div>\n      </div>\n    );\n  }\n\n  if (isAuthLoading) {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-background\">\n        <div className=\"text-center\">\n          <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-contrast mx-auto mb-4\" />\n          <p className=\"text-muted\">{t(I18nKey.DEVICE$PROCESSING)}</p>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-background\">\n      <div className=\"max-w-md w-full mx-auto p-6 bg-surface-raised rounded-lg shadow-lg text-center\">\n        <H1 className=\"text-2xl mb-4\">{t(I18nKey.DEVICE$AUTH_REQUIRED)}</H1>\n        <p className=\"text-muted\">{t(I18nKey.DEVICE$SIGN_IN_PROMPT)}</p>\n      </div>\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/routes/files-tab.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[7px]\" hardcodes an off-token value. Nearest on the scale: rounded-md (6px), rounded-lg (8px).",
          "line": 163,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 163,
          "endColumn": 176
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 210,
          "column": 35,
          "nodeType": "TemplateLiteral",
          "messageId": "inlineStyle",
          "endLine": 210,
          "endColumn": 51
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[7px]\" hardcodes an off-token value. Nearest on the scale: rounded-md (6px), rounded-lg (8px).",
          "line": 250,
          "column": 35,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 250,
          "endColumn": 158
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 3,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useQueryClient } from \"@tanstack/react-query\";\n\nimport { NoFileSelectedMessage } from \"#/components/features/files-tab/no-file-selected-message\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useFilesTabStore } from \"#/stores/files-tab-store\";\nimport { useWorkspaceFiles } from \"#/hooks/query/use-workspace-files\";\nimport { useWorkspaceFileContent } from \"#/hooks/query/use-workspace-file-content\";\nimport { useAutoRefreshFilesOnEdit } from \"#/hooks/use-auto-refresh-files-on-edit\";\nimport { useOptionalConversationId } from \"#/hooks/use-conversation-id\";\nimport { useResizableDrawerWidth } from \"#/hooks/use-resizable-drawer-width\";\nimport {\n  getConversationState,\n  useConversationLocalStorageState,\n} from \"#/utils/conversation-local-storage\";\nimport {\n  useWorkspaceMutationCounter,\n  withWorkspaceCacheBuster,\n} from \"#/stores/use-workspace-mutation-counter\";\nimport { FileQuickRow } from \"#/components/features/files-tab/file-quick-row\";\nimport { FileTreeView } from \"#/components/features/files-tab/file-tree-view\";\nimport { FileContentViewer } from \"#/components/features/files-tab/file-content-viewer\";\nimport { SegmentedToggle } from \"#/components/features/files-tab/segmented-toggle\";\nimport { WorkspacePath } from \"#/components/features/files-tab/workspace-path\";\nimport { WorkspaceFileDiscoverySettings } from \"#/components/features/files-tab/workspace-file-discovery-settings\";\nimport type { ViewMode } from \"#/components/features/files-tab/view-mode\";\nimport {\n  FILES_TAB_TREE_DEFAULT_WIDTH_PX,\n  FILES_TAB_TREE_MAX_WIDTH_PX,\n  FILES_TAB_TREE_MIN_WIDTH_PX,\n  FILES_TAB_TREE_RESIZE_HANDLE_TEST_ID,\n  FILES_TAB_TREE_WIDTH_STORAGE_KEY,\n} from \"#/components/features/files-tab/files-tab-tree.constants\";\nimport { ResizeHandle } from \"#/components/ui/resize-handle\";\nimport RefreshIcon from \"#/icons/u-refresh.svg?react\";\nimport LinkExternalIcon from \"#/icons/link-external.svg?react\";\nimport { useActiveConversation } from \"#/hooks/query/use-active-conversation\";\n\n/**\n * Workspace file browser. Diff/Commits live in the sibling Commits\n * conversation tab \u2014 this surface is only the file tree / content viewer.\n */\nfunction FilesTab() {\n  const { t } = useTranslation(\"openhands\");\n\n  // Keep the list / content caches fresh as the agent writes files.\n  useAutoRefreshFilesOnEdit();\n\n  const { data: activeConversation } = useActiveConversation();\n  const workspacePath = activeConversation?.workspace?.working_dir;\n\n  const { conversationId } = useOptionalConversationId();\n  const {\n    state: persistedState,\n    setFilesTabContentViewMode,\n    setFilesTabTreeVisible,\n  } = useConversationLocalStorageState(conversationId ?? \"\");\n  const contentViewMode = persistedState.filesTabContentViewMode;\n  const isTreeVisible = persistedState.filesTabTreeVisible ?? true;\n  const toggleTreeVisible = useCallback(() => {\n    setFilesTabTreeVisible?.(!isTreeVisible);\n  }, [isTreeVisible, setFilesTabTreeVisible]);\n\n  const treeLayoutRef = useRef<HTMLDivElement>(null);\n  const {\n    drawerWidth: treeWidth,\n    isDragging: isTreeResizing,\n    handleMouseDown: handleTreeResizeMouseDown,\n  } = useResizableDrawerWidth({\n    containerRef: treeLayoutRef,\n    defaultWidth: FILES_TAB_TREE_DEFAULT_WIDTH_PX,\n    minWidth: FILES_TAB_TREE_MIN_WIDTH_PX,\n    maxWidth: FILES_TAB_TREE_MAX_WIDTH_PX,\n    storageKey: FILES_TAB_TREE_WIDTH_STORAGE_KEY,\n    enabled: isTreeVisible,\n    edge: \"left\",\n  });\n\n  const filesQuery = useWorkspaceFiles();\n  const paths = useMemo(() => filesQuery.data ?? [], [filesQuery.data]);\n\n  const storedSelectedPath = useFilesTabStore((s) => s.selectedPath);\n  const selectedConversationId = useFilesTabStore(\n    (s) => s.selectedConversationId,\n  );\n  const openPaths = useFilesTabStore((s) => s.openPaths);\n  const setSelectedPath = useFilesTabStore((s) => s.setSelectedPath);\n  const closeOpenPath = useFilesTabStore((s) => s.closeOpenPath);\n  const hydrateForConversation = useFilesTabStore(\n    (s) => s.hydrateForConversation,\n  );\n\n  // A selection is scoped to the conversation it was made in. Ignore a path\n  // that belongs to a different conversation so we never try to open a file\n  // that only exists in the previous conversation's workspace (issue #1350).\n  const selectedPath =\n    selectedConversationId === conversationId ? storedSelectedPath : null;\n  const conversationOpenPaths =\n    selectedConversationId === conversationId ? openPaths : [];\n\n  // Tag every selection with the active conversation so it can't leak into\n  // the next one. Opening a path also appends it to the tab strip.\n  const handleSelectFile = useCallback(\n    (path: string) => setSelectedPath(path, conversationId),\n    [conversationId, setSelectedPath],\n  );\n\n  // Pre-fetch the selected file's content here too so the toolbar's\n  // \"open in new window\" link can reach for its `staticUrl`. react-query\n  // dedupes against `FileContentViewer`'s identical call, so this costs\n  // nothing extra.\n  const selectedFileContent = useWorkspaceFileContent(selectedPath);\n  const mutationCounter = useWorkspaceMutationCounter((state) => state.count);\n  const bumpWorkspaceMutationCounter = useWorkspaceMutationCounter(\n    (state) => state.bump,\n  );\n  const selectedFileStaticUrl = withWorkspaceCacheBuster(\n    selectedFileContent.data?.staticUrl ?? null,\n    mutationCounter,\n  );\n\n  // Restore open tabs / selection from conversation-scoped localStorage on\n  // mount and when switching conversations (survives a full page refresh).\n  // Read localStorage directly \u2014 the React hook mirror can briefly hold the\n  // previous conversation's state until its sync effect runs.\n  useEffect(() => {\n    if (!conversationId) return;\n    if (selectedConversationId === conversationId) return;\n    const persisted = getConversationState(conversationId);\n    hydrateForConversation(\n      conversationId,\n      persisted.filesTabOpenPaths ?? [],\n      persisted.filesTabSelectedPath ?? null,\n    );\n  }, [conversationId, selectedConversationId, hydrateForConversation]);\n\n  const queryClient = useQueryClient();\n  const [isRefreshing, setIsRefreshing] = useState(false);\n  const refreshFiles = async () => {\n    setIsRefreshing(true);\n    // Bumping the counter re-keys every `workspace-file-content` query, so\n    // the open file refetches, and gives the rich preview's <iframe>/<img> a\n    // new `?v=` URL: the only way to make them skip a cached response.\n    bumpWorkspaceMutationCounter();\n    try {\n      await queryClient.invalidateQueries({ queryKey: [\"workspace-files\"] });\n    } finally {\n      setIsRefreshing(false);\n    }\n  };\n\n  const quickRowActions = (\n    <>\n      <WorkspaceFileDiscoverySettings workingDir={workspacePath?.trim()} />\n      <button\n        type=\"button\"\n        onClick={refreshFiles}\n        disabled={isRefreshing}\n        aria-label={t(I18nKey.FILES$REFRESH)}\n        title={t(I18nKey.FILES$REFRESH)}\n        data-testid=\"files-tab-refresh\"\n        className=\"flex items-center justify-center w-6.5 py-1 rounded-[7px] hover:enabled:bg-interactive-hover cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed\"\n      >\n        <RefreshIcon\n          width={12.75}\n          height={15}\n          color=\"#ffffff\"\n          className={isRefreshing ? \"animate-spin\" : \"\"}\n        />\n      </button>\n    </>\n  );\n\n  return (\n    <main\n      className=\"h-full w-full flex flex-col items-stretch\"\n      data-testid=\"files-tab\"\n    >\n      <WorkspacePath path={workspacePath} />\n      {filesQuery.isTruncated && (\n        <p\n          role=\"status\"\n          className=\"border-b border-border px-3 py-2 text-xs text-muted\"\n        >\n          {t(I18nKey.FILES$DISCOVERY_TRUNCATED, { count: paths.length })}\n        </p>\n      )}\n      {filesQuery.isLoading ? (\n        <div className=\"flex flex-1 items-center justify-center text-sm text-muted\">\n          {t(I18nKey.FILES$LOADING_FILES)}\n        </div>\n      ) : (\n        <>\n          <FileQuickRow\n            openPaths={conversationOpenPaths}\n            selectedPath={selectedPath}\n            onSelectFile={handleSelectFile}\n            onCloseFile={closeOpenPath}\n            isTreeVisible={isTreeVisible}\n            onToggleTree={toggleTreeVisible}\n            actions={quickRowActions}\n          />\n          <div ref={treeLayoutRef} className=\"flex h-full min-h-0 flex-1\">\n            {isTreeVisible && (\n              <>\n                <aside\n                  className=\"shrink-0 border-r border-border overflow-y-auto custom-scrollbar-always\"\n                  data-testid=\"files-tab-tree\"\n                  style={{ width: `${treeWidth}px` }}\n                >\n                  <FileTreeView\n                    paths={paths}\n                    selectedPath={selectedPath}\n                    onSelectFile={handleSelectFile}\n                  />\n                </aside>\n                <ResizeHandle\n                  testId={FILES_TAB_TREE_RESIZE_HANDLE_TEST_ID}\n                  onMouseDown={handleTreeResizeMouseDown}\n                  isDragging={isTreeResizing}\n                />\n              </>\n            )}\n            <section\n              className=\"flex h-full min-h-0 min-w-0 flex-1 flex-col\"\n              data-testid=\"files-tab-content\"\n            >\n              {selectedPath ? (\n                <>\n                  <div className=\"flex items-center gap-3 px-3 py-1.5 border-b border-border\">\n                    <SegmentedToggle<ViewMode>\n                      ariaLabel={t(I18nKey.FILES$RICH)}\n                      testId=\"files-tab-content-mode-toggle\"\n                      value={contentViewMode}\n                      options={[\n                        { value: \"rich\", label: t(I18nKey.FILES$RICH) },\n                        { value: \"plain\", label: t(I18nKey.FILES$PLAIN) },\n                      ]}\n                      onChange={setFilesTabContentViewMode}\n                    />\n                    {selectedFileStaticUrl ? (\n                      <a\n                        href={selectedFileStaticUrl}\n                        target=\"_blank\"\n                        rel=\"noopener noreferrer\"\n                        aria-label={t(I18nKey.FILES$OPEN_IN_NEW_WINDOW)}\n                        title={t(I18nKey.FILES$OPEN_IN_NEW_WINDOW)}\n                        data-testid=\"files-tab-open-in-new-window\"\n                        className=\"ml-auto flex items-center justify-center w-6.5 py-1 rounded-[7px] hover:bg-interactive-hover cursor-pointer text-contrast\"\n                      >\n                        <LinkExternalIcon width={14} height={14} />\n                      </a>\n                    ) : null}\n                  </div>\n                  <FileContentViewer\n                    path={selectedPath}\n                    viewMode={contentViewMode}\n                  />\n                </>\n              ) : (\n                <NoFileSelectedMessage />\n              )}\n            </section>\n          </div>\n        </>\n      )}\n    </main>\n  );\n}\n\nexport default FilesTab;\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/routes/meta-llm-settings.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[var(--oh-muted)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 24,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 24,
          "endColumn": 51
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { useTranslation } from \"react-i18next\";\nimport { MetaLlmSettingsView } from \"#/components/features/settings/meta-llm-profiles\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { I18nKey } from \"#/i18n/declaration\";\n\n/**\n * Settings route for managing *meta-profiles* \u2014 declarative model-routing\n * configurations consumed by the agent's ``route_task_to_model`` tool.\n *\n * Local backends store meta-profiles on the agent-server. Organization-bound\n * cloud backends store them in the SaaS control plane and pass the active\n * configuration inline to ephemeral runtimes.\n *\n * Note: This is a route file, only the router should import the default export.\n */\nexport default function MetaLlmSettingsRoute() {\n  const { t } = useTranslation(\"openhands\");\n  const { backend, orgId } = useActiveBackend();\n\n  if (backend.kind === \"cloud\" && !orgId) {\n    return (\n      <p\n        data-testid=\"meta-profile-cloud-unsupported\"\n        className=\"text-sm text-[var(--oh-muted)]\"\n      >\n        {t(I18nKey.SETTINGS$META_PROFILE_CLOUD_UNSUPPORTED)}\n      </p>\n    );\n  }\n\n  return <MetaLlmSettingsView />;\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/ui/dropdown/dropdown-menu.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-w-[min(26rem,calc(100vw-2rem))]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 43,
          "column": 24,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 43,
          "endColumn": 61
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React from \"react\";\nimport { Divider } from \"#/ui/divider\";\nimport { cn } from \"#/utils/utils\";\nimport { DropdownOption } from \"./types\";\nimport {\n  dropdownMenuListClassName,\n  dropdownMenuRowClassName,\n  dropdownMenuRowIconWrapperClassName,\n} from \"#/utils/dropdown-classes\";\n\ninterface DropdownMenuProps {\n  isOpen: boolean;\n  filteredOptions: DropdownOption[];\n  selectedItem: DropdownOption | null;\n  emptyMessage: string;\n  getMenuProps: (props?: object) => object;\n  getItemProps: (props: {\n    item: DropdownOption;\n    index: number;\n    className?: string;\n  }) => object;\n  footer?: React.ReactNode;\n  openUpward?: boolean;\n  fitContent?: boolean;\n}\n\nexport function DropdownMenu({\n  isOpen,\n  filteredOptions,\n  selectedItem,\n  emptyMessage,\n  getMenuProps,\n  getItemProps,\n  footer,\n  openUpward = false,\n  fitContent = false,\n}: DropdownMenuProps) {\n  return (\n    <div\n      className={cn(\n        \"absolute z-50 overflow-hidden text-contrast min-w-full w-max\",\n        // Let long labels widen the menu past a narrow trigger, up to a cap.\n        !fitContent && \"max-w-[min(26rem,calc(100vw-2rem))]\",\n        openUpward ? \"bottom-full mb-1\" : \"mt-1\",\n        \"bg-tertiary rounded-md context-menu-box-shadow p-1\",\n        \"max-h-60 overflow-auto\",\n        !isOpen && \"hidden\",\n      )}\n    >\n      <ul\n        {...getMenuProps({ className: cn(\"p-0\", dropdownMenuListClassName) })}\n      >\n        {isOpen && filteredOptions.length === 0 && (\n          <li className=\"px-2 py-2 text-sm text-muted italic\">\n            {emptyMessage}\n          </li>\n        )}\n        {isOpen &&\n          filteredOptions.map((option, index) => (\n            <li\n              key={option.value}\n              {...getItemProps({\n                item: option,\n                index,\n                className: cn(\n                  dropdownMenuRowClassName,\n                  \"focus:outline-none\",\n                  selectedItem?.value === option.value &&\n                    \"bg-interactive-selected text-contrast\",\n                ),\n              })}\n            >\n              {option.prefix ? (\n                <span className={dropdownMenuRowIconWrapperClassName}>\n                  {option.prefix}\n                </span>\n              ) : null}\n              <span className=\"min-w-0 truncate\" title={option.label}>\n                {option.label}\n              </span>\n            </li>\n          ))}\n      </ul>\n      {isOpen && footer ? (\n        <>\n          <Divider inset=\"menu\" />\n          <div className=\"p-0\">{footer}</div>\n        </>\n      ) : null}\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/ui/dropdown/dropdown.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets position. Style through classes; use CSS custom properties for dynamic values.",
          "line": 15,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 15,
          "endColumn": 23
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets position. Style through classes; use CSS custom properties for dynamic values.",
          "line": 15,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 15,
          "endColumn": 23
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 16,
          "column": 10,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 16,
          "endColumn": 11
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets width. Style through classes; use CSS custom properties for dynamic values.",
          "line": 16,
          "column": 10,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 16,
          "endColumn": 11
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets height. Style through classes; use CSS custom properties for dynamic values.",
          "line": 17,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 17,
          "endColumn": 12
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets height. Style through classes; use CSS custom properties for dynamic values.",
          "line": 17,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 17,
          "endColumn": 12
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets padding. Style through classes; use CSS custom properties for dynamic values.",
          "line": 18,
          "column": 12,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 18,
          "endColumn": 13
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets padding. Style through classes; use CSS custom properties for dynamic values.",
          "line": 18,
          "column": 12,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 18,
          "endColumn": 13
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets margin. Style through classes; use CSS custom properties for dynamic values.",
          "line": 19,
          "column": 11,
          "nodeType": "UnaryExpression",
          "messageId": "inlineStyle",
          "endLine": 19,
          "endColumn": 13
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets margin. Style through classes; use CSS custom properties for dynamic values.",
          "line": 19,
          "column": 11,
          "nodeType": "UnaryExpression",
          "messageId": "inlineStyle",
          "endLine": 19,
          "endColumn": 13
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets overflow. Style through classes; use CSS custom properties for dynamic values.",
          "line": 20,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 20,
          "endColumn": 21
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets overflow. Style through classes; use CSS custom properties for dynamic values.",
          "line": 20,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 20,
          "endColumn": 21
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets clip. Style through classes; use CSS custom properties for dynamic values.",
          "line": 21,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 21,
          "endColumn": 27
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets clip. Style through classes; use CSS custom properties for dynamic values.",
          "line": 21,
          "column": 9,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 21,
          "endColumn": 27
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets whiteSpace. Style through classes; use CSS custom properties for dynamic values.",
          "line": 22,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 22,
          "endColumn": 23
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets whiteSpace. Style through classes; use CSS custom properties for dynamic values.",
          "line": 22,
          "column": 15,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 22,
          "endColumn": 23
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets border. Style through classes; use CSS custom properties for dynamic values.",
          "line": 23,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 23,
          "endColumn": 12
        },
        {
          "ruleId": "shadcn/no-inline-styles",
          "severity": 1,
          "message": "Inline style sets border. Style through classes; use CSS custom properties for dynamic values.",
          "line": 23,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "inlineStyle",
          "endLine": 23,
          "endColumn": 12
        }
      ],
      "suppressedMessages": [
        {
          "ruleId": "i18next/no-literal-string",
          "severity": 2,
          "message": "disallow literal string: placeholder ?? \"Filter options\"",
          "line": 226,
          "column": 44,
          "nodeType": "Literal",
          "endLine": 226,
          "endColumn": 60,
          "suppressions": [
            {
              "kind": "directive",
              "justification": "i18n-free UI primitive; callers supply translated labels"
            }
          ]
        }
      ],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 18,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { useState } from \"react\";\nimport { useCombobox } from \"downshift\";\nimport { cn } from \"#/utils/utils\";\nimport { DropdownOption } from \"./types\";\nimport { dropdownTriggerShellClassName } from \"#/utils/dropdown-classes\";\nimport { LoadingSpinner } from \"./loading-spinner\";\nimport { ClearButton } from \"./clear-button\";\nimport { ToggleButton } from \"./toggle-button\";\nimport { DropdownMenu } from \"./dropdown-menu\";\nimport { DropdownInput } from \"./dropdown-input\";\n\n// Equivalent to Tailwind's `sr-only`, inlined so we don't depend on the\n// utility class being preserved by the host project's CSS pipeline.\nconst visuallyHiddenStyle: React.CSSProperties = {\n  position: \"absolute\",\n  width: 1,\n  height: 1,\n  padding: 0,\n  margin: -1,\n  overflow: \"hidden\",\n  clip: \"rect(0, 0, 0, 0)\",\n  whiteSpace: \"nowrap\",\n  border: 0,\n};\n\ninterface DropdownProps {\n  options: DropdownOption[];\n  emptyMessage?: string;\n  clearable?: boolean;\n  loading?: boolean;\n  disabled?: boolean;\n  placeholder?: string;\n  defaultValue?: DropdownOption;\n  onChange?: (item: DropdownOption | null) => void;\n  testId?: string;\n  className?: string;\n  footer?: React.ReactNode;\n  openUpward?: boolean;\n  hideTrigger?: boolean;\n  defaultOpen?: boolean;\n  /** Open the dropdown menu on hover instead of requiring a click. */\n  openOnHover?: boolean;\n  /** When false, the combobox placeholder uses normal (non-italic) type. */\n  italicPlaceholder?: boolean;\n  /** Size the trigger to its label instead of stretching to the container width. */\n  fitContent?: boolean;\n}\n\nexport function Dropdown({\n  options,\n  emptyMessage = \"No options\",\n  clearable = false,\n  loading = false,\n  disabled = false,\n  placeholder,\n  defaultValue,\n  onChange,\n  testId,\n  className,\n  footer,\n  openUpward = false,\n  hideTrigger = false,\n  defaultOpen = false,\n  openOnHover = false,\n  italicPlaceholder = true,\n  fitContent = false,\n}: DropdownProps) {\n  const closeTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(\n    null,\n  );\n  const [inputValue, setInputValue] = useState(defaultValue?.label ?? \"\");\n  const [searchTerm, setSearchTerm] = useState(\"\");\n\n  React.useEffect(\n    () => () => {\n      if (closeTimerRef.current) {\n        clearTimeout(closeTimerRef.current);\n        closeTimerRef.current = null;\n      }\n    },\n    [],\n  );\n\n  const filteredOptions = options.filter((option) =>\n    option.label.toLowerCase().includes(searchTerm.toLowerCase()),\n  );\n\n  const {\n    isOpen,\n    selectedItem,\n    selectItem,\n    openMenu,\n    closeMenu,\n    getToggleButtonProps,\n    getMenuProps,\n    getItemProps,\n    getInputProps,\n  } = useCombobox({\n    items: filteredOptions,\n    itemToString: (item) => item?.label ?? \"\",\n    inputValue,\n    stateReducer: (state, actionAndChanges) =>\n      actionAndChanges.type === useCombobox.stateChangeTypes.InputClick &&\n      state.isOpen\n        ? { ...actionAndChanges.changes, isOpen: true }\n        : actionAndChanges.changes,\n    initialIsOpen: defaultOpen,\n    onInputValueChange: ({ inputValue: newValue }) => {\n      setInputValue(newValue ?? \"\");\n      setSearchTerm(newValue ?? \"\");\n    },\n    defaultSelectedItem: defaultValue,\n    onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {\n      onChange?.(newSelectedItem ?? null);\n    },\n    onIsOpenChange: ({\n      isOpen: newIsOpen,\n      selectedItem: currentSelectedItem,\n    }) => {\n      if (newIsOpen) {\n        // Clear the input on open so the user sees an empty search box\n        // (with the placeholder reminding them of the current value)\n        // and the full options list. Otherwise the active label would\n        // appear both in the trigger AND as the highlighted menu row.\n        setInputValue(\"\");\n        setSearchTerm(\"\");\n      } else {\n        setInputValue(currentSelectedItem?.label ?? \"\");\n        setSearchTerm(\"\");\n      }\n    },\n  });\n\n  const isDisabled = loading || disabled;\n\n  // `selectedItem` is downshift's internal state, frozen to whatever\n  // initialized it. Resolve the currently selected option against the\n  // live `options` array so per-option fields like `prefix` (e.g. a\n  // status indicator that re-renders on a timer) update on the trigger\n  // without remounting the dropdown.\n  const liveSelectedOption = selectedItem\n    ? (options.find((o) => o.value === selectedItem.value) ?? selectedItem)\n    : null;\n\n  // Wrap getInputProps to inject a direct onChange handler that preserves\n  // cursor position. Downshift's default onInputValueChange resets cursor\n  // to end of input on every keystroke; reading from e.target.value keeps\n  // the browser's native cursor position intact.\n  const getInputPropsWithCursorFix = (props?: object) =>\n    getInputProps({\n      ...props,\n      onChange: (e: React.ChangeEvent<HTMLInputElement>) => {\n        setInputValue(e.target.value);\n        setSearchTerm(e.target.value);\n      },\n    });\n\n  return (\n    <div\n      className={cn(\"relative\", fitContent ? \"inline-block w-auto\" : \"w-full\")}\n      data-testid={testId}\n      onMouseEnter={\n        openOnHover\n          ? () => {\n              if (closeTimerRef.current) {\n                clearTimeout(closeTimerRef.current);\n                closeTimerRef.current = null;\n              }\n              openMenu();\n            }\n          : undefined\n      }\n      onMouseLeave={\n        openOnHover\n          ? () => {\n              if (closeTimerRef.current) {\n                clearTimeout(closeTimerRef.current);\n              }\n              closeTimerRef.current = setTimeout(() => closeMenu(), 150);\n            }\n          : undefined\n      }\n    >\n      {!hideTrigger ? (\n        <div\n          className={cn(\n            dropdownTriggerShellClassName,\n            fitContent ? \"w-auto\" : \"w-full\",\n            isDisabled && \"cursor-not-allowed opacity-60\",\n            className,\n          )}\n        >\n          {liveSelectedOption?.prefix ? (\n            <span className=\"flex items-center shrink-0\">\n              {liveSelectedOption.prefix}\n            </span>\n          ) : null}\n          <DropdownInput\n            placeholder={placeholder}\n            title={liveSelectedOption?.label}\n            isDisabled={isDisabled}\n            getInputProps={getInputPropsWithCursorFix}\n            italicPlaceholder={italicPlaceholder}\n            fitContent={fitContent}\n          />\n          {loading && <LoadingSpinner />}\n          {clearable && selectedItem && (\n            <ClearButton onClear={() => selectItem(null)} />\n          )}\n          <ToggleButton\n            isOpen={isOpen}\n            isDisabled={isDisabled}\n            getToggleButtonProps={getToggleButtonProps}\n          />\n        </div>\n      ) : (\n        // downshift's useCombobox always expects getInputProps() (and the\n        // toggle button) to be wired up. When the trigger is hidden (e.g.\n        // collapsed-sidebar popover) we still need to mount a real input\n        // so it stops warning every render. Keep it visually hidden but\n        // present in the DOM for accessibility.\n        <>\n          <input\n            {...getInputPropsWithCursorFix({\n              // eslint-disable-next-line i18next/no-literal-string -- i18n-free UI primitive; callers supply translated labels\n              \"aria-label\": placeholder ?? \"Filter options\",\n              tabIndex: -1,\n            })}\n            style={visuallyHiddenStyle}\n          />\n          <button\n            type=\"button\"\n            {...getToggleButtonProps({ tabIndex: -1 })}\n            style={visuallyHiddenStyle}\n            aria-hidden\n          />\n        </>\n      )}\n      <DropdownMenu\n        isOpen={isOpen}\n        filteredOptions={filteredOptions}\n        selectedItem={selectedItem}\n        emptyMessage={emptyMessage}\n        getMenuProps={getMenuProps}\n        getItemProps={getItemProps}\n        footer={footer}\n        openUpward={openUpward}\n        fitContent={fitContent}\n      />\n    </div>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/ui/pre.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[60vh]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 42,
          "column": 14,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 42,
          "endColumn": 28
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"#/utils/utils\";\n\nconst preVariants = cva(\"whitespace-pre-wrap\", {\n  variants: {\n    size: {\n      default: \"text-sm\",\n      small: \"text-xs\",\n    },\n    font: {\n      default: \"\",\n      mono: \"font-mono\",\n    },\n    lineHeight: {\n      default: \"\",\n      relaxed: \"leading-relaxed\",\n    },\n    background: {\n      default: \"\",\n      dark: \"bg-canvas-base\",\n    },\n    textColor: {\n      default: \"\",\n      light: \"text-text-tertiary\",\n    },\n    padding: {\n      default: \"\",\n      medium: \"p-3\",\n      large: \"px-5\",\n    },\n    borderRadius: {\n      default: \"\",\n      medium: \"rounded-md\",\n    },\n    shadow: {\n      default: \"\",\n      inner: \"shadow-inner\",\n    },\n    maxHeight: {\n      default: \"\",\n      small: \"max-h-100\",\n      large: \"max-h-[60vh]\",\n    },\n    overflow: {\n      default: \"\",\n      auto: \"overflow-auto\",\n    },\n  },\n  defaultVariants: {\n    size: \"default\",\n    font: \"default\",\n    lineHeight: \"default\",\n    background: \"default\",\n    textColor: \"default\",\n    padding: \"default\",\n    borderRadius: \"default\",\n    shadow: \"default\",\n    maxHeight: \"default\",\n    overflow: \"default\",\n  },\n});\n\ninterface PreProps extends VariantProps<typeof preVariants> {\n  className?: string;\n  testId?: string;\n  children: React.ReactNode;\n}\n\nexport function Pre({\n  size,\n  font,\n  lineHeight,\n  background,\n  textColor,\n  padding,\n  borderRadius,\n  shadow,\n  maxHeight,\n  overflow,\n  className,\n  testId,\n  children,\n}: PreProps) {\n  return (\n    <pre\n      data-testid={testId}\n      className={cn(\n        preVariants({\n          size,\n          font,\n          lineHeight,\n          background,\n          textColor,\n          padding,\n          borderRadius,\n          shadow,\n          maxHeight,\n          overflow,\n        }),\n        className,\n      )}\n    >\n      {children}\n    </pre>\n  );\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/ui/typography.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"text-[32px]\" hardcodes an off-token value. Nearest on the scale: text-3xl (30px), text-4xl (36px).",
          "line": 7,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 7,
          "endColumn": 60
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"-tracking-[0.02em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 8,
          "column": 11,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 8,
          "endColumn": 75
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 2,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"#/utils/utils\";\n\nconst typographyVariants = cva(\"\", {\n  variants: {\n    variant: {\n      h1: \"text-[32px] text-contrast font-medium leading-5\",\n      h2: \"text-xl font-medium leading-6 -tracking-[0.02em] text-contrast\",\n      h3: \"text-sm font-medium text-text-tertiary\",\n      span: \"text-sm font-normal text-contrast leading-5.5\",\n      p: \"text-sm font-normal text-contrast leading-5.5\",\n      codeBlock:\n        \"font-mono text-sm leading-relaxed text-text-tertiary whitespace-pre-wrap\",\n    },\n  },\n  defaultVariants: {\n    variant: \"h1\",\n  },\n});\n\ninterface TypographyProps extends VariantProps<typeof typographyVariants> {\n  className?: string;\n  testId?: string;\n  children: React.ReactNode;\n}\n\nexport function Typography({\n  variant,\n  className,\n  testId,\n  children,\n}: TypographyProps) {\n  const Tag = variant as keyof React.JSX.IntrinsicElements;\n\n  return (\n    <Tag\n      data-testid={testId}\n      className={cn(typographyVariants({ variant }), className)}\n    >\n      {children}\n    </Tag>\n  );\n}\n\n// Export individual heading components for convenience\nexport function H1({\n  className,\n  testId,\n  children,\n}: Omit<TypographyProps, \"variant\">) {\n  return (\n    <Typography variant=\"h1\" className={className} testId={testId}>\n      {children}\n    </Typography>\n  );\n}\n\nexport function H2({\n  className,\n  testId,\n  children,\n}: Omit<TypographyProps, \"variant\">) {\n  return (\n    <Typography variant=\"h2\" className={className} testId={testId}>\n      {children}\n    </Typography>\n  );\n}\n\nexport function H3({\n  className,\n  testId,\n  children,\n}: Omit<TypographyProps, \"variant\">) {\n  return (\n    <Typography variant=\"h3\" className={className} testId={testId}>\n      {children}\n    </Typography>\n  );\n}\n\nexport function Text({\n  className,\n  testId,\n  children,\n}: Omit<TypographyProps, \"variant\">) {\n  return (\n    <Typography variant=\"span\" className={className} testId={testId}>\n      {children}\n    </Typography>\n  );\n}\n\nexport function CodeBlock({\n  className,\n  testId,\n  children,\n}: Omit<TypographyProps, \"variant\">) {\n  return (\n    <Typography variant=\"codeBlock\" className={className} testId={testId}>\n      {children}\n    </Typography>\n  );\n}\n\nexport function Paragraph({\n  className,\n  testId,\n  children,\n}: Omit<TypographyProps, \"variant\">) {\n  return (\n    <Typography variant=\"p\" className={className} testId={testId}>\n      {children}\n    </Typography>\n  );\n}\n\n// Attach components to Typography for the expected API\nTypography.H1 = H1;\nTypography.H2 = H2;\nTypography.H3 = H3;\nTypography.Text = Text;\nTypography.CodeBlock = CodeBlock;\nTypography.Paragraph = Paragraph;\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/utils/custom-toast-handlers.tsx",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[word-break:break-word]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 81,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 81,
          "endColumn": 102
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[overflow-wrap:anywhere]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 81,
          "column": 19,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 81,
          "endColumn": 102
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[word-break:break-word]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 130,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 130,
          "endColumn": 71
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[overflow-wrap:anywhere]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 130,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 130,
          "endColumn": 71
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[word-break:break-word]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 144,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 144,
          "endColumn": 71
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[overflow-wrap:anywhere]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 144,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 144,
          "endColumn": 71
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[word-break:break-word]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 165,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 165,
          "endColumn": 71
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"[overflow-wrap:anywhere]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 165,
          "column": 21,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 165,
          "endColumn": 71
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 8,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import React, { CSSProperties, ReactNode } from \"react\";\nimport { CircleX } from \"lucide-react\";\nimport toast, { ToastOptions } from \"react-hot-toast\";\nimport { calculateToastDuration } from \"./toast-duration\";\nimport { cn } from \"#/utils/utils\";\nimport i18n from \"#/i18n\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport {\n  isBackendRequestTimeoutMessage,\n  isCorsOrNetworkErrorMessage,\n} from \"./user-facing-error\";\nimport { buildAgentCanvasPath } from \"#/utils/base-path\";\nimport {\n  getApiOrConnectionErrorMessage,\n  hasApiErrorResponse,\n} from \"./api-error-message\";\n\n// react-hot-toast accepts only CSSProperties via the style option \u2014 cannot use className\n// @spec TN-001 \u2014 Every toast uses the theme's high-emphasis ink on its surface\nconst TOAST_STYLE: CSSProperties = {\n  background: \"var(--oh-color-tertiary)\",\n  border: \"1px solid var(--oh-border-input)\",\n  color: \"var(--oh-contrast)\",\n  borderRadius: \"var(--oh-radius)\",\n  maxWidth: \"400px\",\n  wordBreak: \"break-word\",\n  overflowWrap: \"anywhere\",\n  whiteSpace: \"pre-wrap\",\n};\n\nexport const TOAST_OPTIONS: ToastOptions = {\n  position: \"top-right\",\n  style: TOAST_STYLE,\n};\n\n/** Icon + message row; center icon for single-line text, top-align when wrapped. */\nexport function ErrorToastContent({ message }: { message: ReactNode }) {\n  const contentRef = React.useRef<HTMLSpanElement>(null);\n  const [isMultiLine, setIsMultiLine] = React.useState(false);\n\n  React.useLayoutEffect(() => {\n    const content = contentRef.current;\n    if (!content) {\n      return undefined;\n    }\n\n    const updateIsMultiLine = () => {\n      const lineHeight = Number.parseFloat(\n        getComputedStyle(content).lineHeight,\n      );\n      if (!Number.isFinite(lineHeight) || lineHeight <= 0) {\n        setIsMultiLine(false);\n        return;\n      }\n\n      setIsMultiLine(content.getBoundingClientRect().height > lineHeight * 1.5);\n    };\n\n    updateIsMultiLine();\n\n    const observer = new ResizeObserver(updateIsMultiLine);\n    observer.observe(content);\n\n    return () => observer.disconnect();\n  }, [message]);\n\n  return (\n    <div\n      className={cn(\n        \"flex min-w-0 gap-2\",\n        isMultiLine ? \"items-start\" : \"items-center\",\n      )}\n    >\n      <CircleX\n        aria-hidden\n        className=\"h-4 w-4 shrink-0 text-status-error\"\n        strokeWidth={2}\n      />\n      <span\n        ref={contentRef}\n        className=\"min-w-0 flex-1 text-sm leading-5 [word-break:break-word] [overflow-wrap:anywhere]\"\n      >\n        {message}\n      </span>\n    </div>\n  );\n}\n\nexport const ERROR_TOAST_OPTIONS: ToastOptions = {\n  ...TOAST_OPTIONS,\n  icon: null,\n};\n\nfunction showErrorToast(message: string) {\n  const duration = calculateToastDuration(message, 4000);\n  toast(<ErrorToastContent message={message} />, {\n    ...ERROR_TOAST_OPTIONS,\n    duration,\n  });\n}\n\nexport const displayErrorToast = (error: string | null | undefined) => {\n  let errorMessage = error || i18n.t(I18nKey.STATUS$ERROR);\n  if (isCorsOrNetworkErrorMessage(errorMessage)) {\n    errorMessage = i18n.t(I18nKey.ERROR$CORS_OR_NETWORK);\n  } else if (isBackendRequestTimeoutMessage(errorMessage)) {\n    errorMessage = i18n.t(I18nKey.ERROR$BACKEND_REQUEST_TIMEOUT);\n  }\n  showErrorToast(errorMessage);\n};\n\n/**\n * Toast a failed API call. When the server answered, its own reason (see\n * `getApiErrorMessage`) is shown as sent, even if it happens to mention\n * \"Failed to fetch\"; only a failure with no response (network, CORS, timeout)\n * gets the shared \"Disconnected\" wording.\n */\nexport const displayApiErrorToast = (error: unknown, fallback: string) => {\n  const message = getApiOrConnectionErrorMessage(error, fallback);\n  if (hasApiErrorResponse(error)) {\n    showErrorToast(message);\n  } else {\n    displayErrorToast(message);\n  }\n};\n\nexport const displaySuccessToast = (message: string) => {\n  const duration = calculateToastDuration(message, 5000);\n  toast.success(\n    <span className=\"[word-break:break-word] [overflow-wrap:anywhere]\">\n      {message}\n    </span>,\n    { ...TOAST_OPTIONS, duration },\n  );\n};\n\nexport const displaySuccessToastWithLink = (\n  message: string,\n  linkLabel: string,\n  href: string,\n) => {\n  const duration = calculateToastDuration(`${message} ${linkLabel}`, 5000);\n  toast.success(\n    <span className=\"[word-break:break-word] [overflow-wrap:anywhere]\">\n      {message}{\" \"}\n      <a\n        className=\"underline hover:no-underline\"\n        href={buildAgentCanvasPath(href)}\n      >\n        {linkLabel}\n      </a>\n    </span>,\n    { ...TOAST_OPTIONS, duration },\n  );\n};\n\n/**\n * Neutral, non-success notice \u2014 used when an action completed but the outcome\n * is qualified (e.g. a secret was saved but the active backend can't consume it\n * yet). Renders without the success checkmark so it doesn't read as \"all good\".\n */\nexport const displayWarningToast = (message: string) => {\n  const duration = calculateToastDuration(message, 6000);\n  toast(\n    <span className=\"[word-break:break-word] [overflow-wrap:anywhere]\">\n      {message}\n    </span>,\n    { ...TOAST_OPTIONS, icon: \"\u26a0\ufe0f\", duration },\n  );\n};\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/utils/dropdown-classes.ts",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[min(60vh,calc(100dvh-5rem))]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 71,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 71,
          "endColumn": 40
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cn } from \"#/utils/utils\";\nimport { formControlShellClassName } from \"#/utils/form-control-classes\";\n\n/** Snap hover colors instantly \u2014 no transition-colors delay on menus/dropdowns. */\nexport const dropdownInstantColorClassName = \"transition-none\";\n\n/** 2px vertical gap between rows in a dropdown/context menu list. */\nexport const dropdownMenuListGapClassName = \"gap-0.5\";\n\n/** Standard horizontal gap between a row icon and its label. */\nexport const dropdownMenuRowGapClassName = \"gap-2\";\n\n/** Flex column shell for a dropdown menu item list. */\nexport const dropdownMenuListClassName = cn(\n  \"flex flex-col\",\n  dropdownMenuListGapClassName,\n);\n\n/** Combobox/select trigger shell with instant hover colors. */\nexport const dropdownTriggerShellClassName = cn(\n  \"bg-tertiary border border-border-input rounded p-2\",\n  \"flex items-center gap-2\",\n  formControlShellClassName,\n  dropdownInstantColorClassName,\n  \"group w-full gap-2 px-3 text-muted hover:text-contrast\",\n);\n\n/** Standard white-label menu row. */\nexport const dropdownMenuRowClassName = cn(\n  \"group flex w-full cursor-pointer items-center rounded px-2 py-2 text-left text-sm font-normal text-contrast\",\n  dropdownMenuRowGapClassName,\n  \"hover:bg-interactive-hover disabled:cursor-not-allowed disabled:opacity-60\",\n  dropdownInstantColorClassName,\n);\n\n/** Menu row using foreground token (context menus). */\nexport const dropdownMenuRowForegroundClassName = cn(\n  \"group flex w-full cursor-pointer items-center rounded px-2 py-2 text-start text-sm font-normal\",\n  dropdownMenuRowGapClassName,\n  \"text-foreground hover:bg-interactive-hover\",\n  \"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent\",\n  dropdownInstantColorClassName,\n);\n\n/** Icon inside a menu row \u2014 muted until row hover/focus. */\nexport const dropdownMenuRowIconClassName = cn(\n  \"shrink-0 text-muted group-hover:text-contrast group-focus-visible:text-contrast\",\n  dropdownInstantColorClassName,\n);\n\n/** Wrapper for SVG/React icon nodes inside a menu row. */\nexport const dropdownMenuRowIconWrapperClassName = cn(\n  \"flex size-4 shrink-0 items-center justify-center [&_svg]:text-current\",\n  dropdownMenuRowIconClassName,\n);\n\n/** Enum/filter dropdown trigger chip. */\nexport const dropdownFilterTriggerClassName = cn(\n  \"inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium\",\n  \"border-border bg-base-secondary text-contrast\",\n  \"focus-visible:border-contrast/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-contrast/20\",\n  dropdownInstantColorClassName,\n);\n\n/**\n * Tall sidebar/context menus: cap height to the viewport and scroll with the\n * custom scrollbar when content overflows (no scrollbar when everything fits).\n */\nexport const dropdownMenuViewportScrollClassName = cn(\n  \"overflow-y-auto custom-scrollbar\",\n  \"max-h-[min(60vh,calc(100dvh-5rem))]\",\n);\n\n/** Footer action row inside a dropdown panel. */\nexport const dropdownFooterActionClassName = cn(\n  \"group flex w-full items-center rounded-md px-2 py-2 text-sm font-normal text-contrast\",\n  dropdownMenuRowGapClassName,\n  \"hover:bg-interactive-hover\",\n  dropdownInstantColorClassName,\n);\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/utils/form-control-classes.ts",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"transition-[background-color,border-color,box-shadow,opacity]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 21,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 21,
          "endColumn": 66
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"!transition-[background-color,border-color,box-shadow,opacity]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 32,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 32,
          "endColumn": 67
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"transition-[transform]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 39,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 39,
          "endColumn": 27
        },
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[100px]\" hardcodes an off-token value. Nearest on the scale: rounded-4xl (32px), rounded-3xl (24px).",
          "line": 50,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 50,
          "endColumn": 84
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 4,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cn } from \"#/utils/utils\";\n\n/** 36px control height \u2014 shared by fields, dropdowns, and buttons. */\nexport const formControlHeightClassName = \"h-9 min-h-9\";\n\nexport const formControlRadiusClassName = \"rounded-lg\";\n\nexport const formControlBorderClassName = \"border border-border\";\n\nexport const formControlSurfaceClassName = \"bg-base-secondary\";\n\n/** Shared transition duration for form controls and chrome buttons. */\nexport const formControlTransitionDurationClassName = \"duration-75\";\n\nexport const formControlHeroUiTransitionDurationClassName = \"!duration-75\";\n\nexport const formControlMotionReduceClassName = \"motion-reduce:transition-none\";\n\n/** Shell properties that animate on hover/focus; foreground color snaps instantly. */\nexport const formControlTransitionPropertiesClassName =\n  \"transition-[background-color,border-color,box-shadow,opacity]\";\n\n/** Animate shell chrome on hover/focus; foreground color snaps instantly. */\nexport const formControlTransitionClassName = cn(\n  formControlTransitionPropertiesClassName,\n  formControlTransitionDurationClassName,\n  formControlMotionReduceClassName,\n);\n\n/** HeroUI input wrappers ship `transition-colors`; override so caret/text hover is instant. */\nexport const formControlHeroUiWrapperTransitionClassName = cn(\n  \"!transition-[background-color,border-color,box-shadow,opacity]\",\n  formControlHeroUiTransitionDurationClassName,\n  formControlMotionReduceClassName,\n);\n\n/** Transform-only transitions (e.g. combobox carets). */\nexport const formControlTransformTransitionClassName = cn(\n  \"transition-[transform]\",\n  formControlTransitionDurationClassName,\n  formControlMotionReduceClassName,\n);\n\n/** Muted icon/pill controls: instant foreground, fading shell on hover. */\nexport const formControlMutedHoverClassName =\n  \"hover:text-contrast hover:bg-contrast/10\";\n\n/** Text/icon pill triggers in the chat input actions row. */\nexport const chatInputPillButtonClassName = cn(\n  \"inline-flex items-center gap-1 rounded-[100px] border border-transparent px-1.5\",\n  \"text-sm font-normal leading-5 text-muted whitespace-nowrap min-w-0 cursor-pointer\",\n  formControlTransitionClassName,\n  formControlMutedHoverClassName,\n);\n\n/** Circular icon triggers in the chat input actions row. */\nexport const chatInputIconButtonClassName = cn(\n  \"flex items-center justify-center rounded-full text-muted cursor-pointer\",\n  formControlTransitionClassName,\n  formControlMutedHoverClassName,\n);\n\nexport const formControlFocusClassName =\n  \"focus:border-contrast/40 focus:ring-1 focus:ring-contrast/20 focus:outline-none\";\n\nexport const formControlFocusWithinClassName =\n  \"focus-within:border-contrast/40 focus-within:ring-1 focus-within:ring-contrast/20\";\n\nexport const formControlDisabledClassName =\n  \"disabled:cursor-not-allowed disabled:opacity-60\";\n\n/** Native text inputs and HeroUI Autocomplete wrappers. */\nexport const formControlFieldClassName = cn(\n  formControlHeightClassName,\n  formControlRadiusClassName,\n  formControlBorderClassName,\n  formControlSurfaceClassName,\n  formControlTransitionClassName,\n  formControlFocusClassName,\n  formControlDisabledClassName,\n  \"w-full min-w-0 px-3 text-sm text-contrast placeholder:text-tertiary-alt\",\n);\n\n/** Settings screens keep italic placeholders on form controls. */\nexport const formControlSettingsFieldClassName = cn(\n  formControlFieldClassName,\n  \"placeholder:italic\",\n  formControlHeroUiWrapperTransitionClassName,\n);\n\n/** Multiline fields share border/radius/focus styling without a fixed height. */\nexport const formControlMultilineFieldClassName = cn(\n  formControlRadiusClassName,\n  formControlBorderClassName,\n  formControlSurfaceClassName,\n  formControlTransitionClassName,\n  formControlFocusClassName,\n  formControlDisabledClassName,\n  \"w-full min-w-0 px-3 py-2 text-sm text-contrast placeholder:text-tertiary-alt\",\n);\n\n/** Combobox / search shell (icon + input), e.g. skills toolbar. */\nexport const formControlShellClassName = cn(\n  formControlHeightClassName,\n  \"relative flex min-w-0 items-center\",\n  formControlRadiusClassName,\n  formControlBorderClassName,\n  formControlSurfaceClassName,\n  formControlTransitionClassName,\n  formControlFocusWithinClassName,\n);\n\n/** Borderless input nested inside {@link formControlShellClassName}. */\nexport const formControlInlineInputClassName = cn(\n  \"min-w-0 flex-1 border-0 bg-transparent px-3 text-sm outline-none\",\n  \"placeholder:text-tertiary-alt\",\n  \"[&::-webkit-search-cancel-button]:hidden\",\n);\n\n/** Primary/secondary/danger action buttons. */\nexport const formControlButtonClassName = cn(\n  formControlHeightClassName,\n  \"inline-flex w-fit cursor-pointer items-center justify-center gap-2 px-3\",\n  formControlRadiusClassName,\n  formControlTransitionClassName,\n  \"text-sm font-normal disabled:cursor-not-allowed disabled:opacity-30\",\n);\n\n/** Helper text under a left-aligned {@link SettingsSwitch} (40px track + gap-2). */\nexport const formControlSwitchDescriptionClassName = \"pl-12\";\n\n/** Outlined back navigation control with tertiary hover fill (settings sub-pages, detail views). */\nexport const formControlBackNavButtonClassName = cn(\n  formControlHeightClassName,\n  \"inline-flex items-center gap-2 self-start px-2\",\n  formControlRadiusClassName,\n  formControlBorderClassName,\n  \"text-sm font-normal leading-5 text-muted cursor-pointer\",\n  formControlTransitionClassName,\n  \"hover:bg-tertiary hover:text-contrast\",\n);\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/utils/git-control-bar-classes.ts",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"rounded-[100px]\" hardcodes an off-token value. Nearest on the scale: rounded-4xl (32px), rounded-3xl (24px).",
          "line": 4,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValueNearScale",
          "endLine": 4,
          "endColumn": 68
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cn } from \"#/utils/utils\";\n\nexport const gitControlBarActionButtonBaseClassName =\n  \"flex flex-row gap-1 items-center justify-center rounded-[100px]\";\n\nexport function gitControlBarActionButtonClassName(isEnabled: boolean) {\n  return cn(\n    gitControlBarActionButtonBaseClassName,\n    isEnabled\n      ? \"bg-surface hover:bg-tertiary cursor-pointer text-contrast\"\n      : \"bg-surface cursor-not-allowed opacity-50 text-muted\",\n  );\n}\n\nexport function gitControlBarActionIconColor(isEnabled: boolean) {\n  return isEnabled ? \"var(--oh-contrast)\" : \"var(--oh-muted)\";\n}\n\nexport const gitControlBarActionLabelClassName =\n  \"font-normal text-sm leading-5 truncate\";\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/utils/hover-reveal-classes.ts",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"min-w-[3.75rem]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 78,
          "column": 13,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 78,
          "endColumn": 30
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cn } from \"#/utils/utils\";\n\n/**\n * Full Tailwind candidates for fine-hover media \u2014 devices where CSS `:hover`\n * is a reliable primary interaction (mouse/trackpad). Coarse-pointer /\n * touch-primary devices match the inverse and keep overflow actions always\n * visible + clickable. Kept as complete string literals so the production CSS\n * scanner can emit the arbitrary-variant rules (dynamic prefix concatenation\n * is not discoverable).\n */\nconst FINE_HOVER_ACTION_CLASSES = [\n  \"[@media(hover:hover)_and_(pointer:fine)]:pointer-events-none\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:invisible\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:opacity-0\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:group-hover:pointer-events-auto\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:group-hover:visible\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:group-hover:opacity-100\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:pointer-events-auto\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:visible\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:opacity-100\",\n] as const;\n\nconst FINE_HOVER_YIELD_CLASSES = [\n  \"[@media(hover:hover)_and_(pointer:fine)]:opacity-100\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:group-hover:opacity-0\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:opacity-0\",\n] as const;\n\nconst FINE_HOVER_RESERVE_CLASSES = [\n  \"[@media(hover:hover)_and_(pointer:fine)]:min-w-0\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:group-hover:min-w-[3.75rem]\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:min-w-[3.75rem]\",\n] as const;\n\nconst FINE_HOVER_PINNED_TIMESTAMP_CLASSES = [\n  \"[@media(hover:hover)_and_(pointer:fine)]:flex\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:group-hover:hidden\",\n  \"[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:hidden\",\n] as const;\n\n/**\n * Overlay action chrome (ellipsis, pin, etc.): always interactable on touch;\n * hover/focus-reveal only on fine-pointer hover devices.\n */\nexport function hoverRevealActionClassName(forceVisible = false): string {\n  if (forceVisible) {\n    return \"pointer-events-auto visible opacity-100\";\n  }\n\n  return cn(\n    \"pointer-events-auto visible opacity-100\",\n    ...FINE_HOVER_ACTION_CLASSES,\n  );\n}\n\n/**\n * Companion for timestamps that yield space to hover-reveal actions:\n * hidden on touch (actions stay visible); on fine-pointer devices, visible\n * until the row is hovered / focused / menu-open.\n */\nexport function hoverRevealYieldClassName(forceHidden = false): string {\n  if (forceHidden) {\n    return \"opacity-0\";\n  }\n\n  return cn(\"opacity-0\", ...FINE_HOVER_YIELD_CLASSES);\n}\n\n/**\n * Reserve trailing space for hover-reveal actions. Always reserved on touch;\n * on fine-pointer devices, reserved on hover / focus / open.\n */\nexport function hoverRevealReserveClassName(forceReserved = false): string {\n  if (forceReserved) {\n    return \"min-w-[3.75rem]\";\n  }\n\n  return cn(\"min-w-[3.75rem]\", ...FINE_HOVER_RESERVE_CLASSES);\n}\n\n/**\n * Absolute-positioned timestamp that sits under a pinned-card ellipsis slot:\n * shown at rest on fine-pointer devices, hidden when the row reveals actions\n * (or when the menu is open / on touch where the ellipsis stays visible).\n */\nexport function hoverRevealPinnedTimestampClassName(\n  forceHidden = false,\n): string {\n  if (forceHidden) {\n    return \"hidden\";\n  }\n\n  return cn(\"hidden\", ...FINE_HOVER_PINNED_TIMESTAMP_CLASSES);\n}\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/utils/modal-classes.ts",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"-tracking-[0.01em]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 5,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 5,
          "endColumn": 70
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cn } from \"#/utils/utils\";\n\n/** Primary modal heading on dark overlay surfaces. */\nexport const modalTitleClassName = cn(\n  \"text-xl leading-6 -tracking-[0.01em] font-medium text-modal-title\",\n);\n\n/** Compact modal header bar title (folder browser, manage workspaces). */\nexport const modalTitleSmClassName = cn(\"text-sm font-medium text-modal-title\");\n\n/** Large modal title used by form-style dialogs. */\nexport const modalTitleLgClassName = cn(\"text-lg font-medium text-modal-title\");\n\n/** Alias of {@link modalTitleLgClassName} for historical call sites. */\nexport const modalTitleLgMediumClassName = modalTitleLgClassName;\n",
      "usedDeprecatedRules": []
    },
    {
      "file": "src/utils/settings-list-classes.ts",
      "messages": [
        {
          "ruleId": "shadcn/no-arbitrary-values",
          "severity": 1,
          "message": "\"max-h-[min(70vh,39rem)]\" hardcodes an off-token value. Use a theme token or scale value instead.",
          "line": 23,
          "column": 3,
          "nodeType": "Literal",
          "messageId": "arbitraryValue",
          "endLine": 23,
          "endColumn": 42
        }
      ],
      "suppressedMessages": [],
      "errorCount": 0,
      "fatalErrorCount": 0,
      "warningCount": 1,
      "fixableErrorCount": 0,
      "fixableWarningCount": 0,
      "source": "import { cn } from \"#/utils/utils\";\nimport {\n  formControlBorderClassName,\n  formControlRadiusClassName,\n  formControlSurfaceClassName,\n  formControlTransitionClassName,\n} from \"#/utils/form-control-classes\";\n\n/** 48px row height \u2014 taller than form controls for settings list/table rows. */\nexport const settingsListRowHeightClassName = \"h-12 min-h-12\";\n\n/** Bordered list shell shared by secrets, LLM profiles, and similar settings tables. */\nexport const settingsListContainerClassName = cn(\n  formControlBorderClassName,\n  formControlRadiusClassName,\n  formControlSurfaceClassName,\n  \"overflow-hidden\",\n);\n\n/** Scrollable variant for long settings lists (e.g. secrets). */\nexport const settingsListScrollContainerClassName = cn(\n  settingsListContainerClassName,\n  \"overflow-auto max-h-[min(70vh,39rem)]\",\n);\n\nexport const settingsListDividerClassName = \"divide-y divide-border\";\n\n/** Interactive row hover on dark list surfaces (base-secondary). */\nexport const settingsListRowHoverClassName = \"hover:bg-interactive-hover-low\";\n\nexport const settingsListRowClassName = cn(\n  settingsListRowHeightClassName,\n  \"flex items-center px-3\",\n  formControlTransitionClassName,\n  settingsListRowHoverClassName,\n);\n\nexport const settingsListTableRowClassName = cn(\n  settingsListRowHoverClassName,\n  formControlTransitionClassName,\n  \"border-t border-border first:border-t-0\",\n);\n\nexport const settingsListTableHeadClassName = cn(\n  formControlSurfaceClassName,\n  \"sticky top-0 z-10 border-b border-border\",\n);\n\nexport const settingsListTableHeaderCellClassName = cn(\n  settingsListRowHeightClassName,\n  \"px-3 text-left text-sm font-medium align-middle\",\n);\n\nexport const settingsListTableCellClassName = cn(\n  settingsListRowHeightClassName,\n  \"px-3 text-sm align-middle min-w-0\",\n);\n\nexport const settingsListIconActionButtonClassName = cn(\n  \"inline-flex cursor-pointer items-center justify-center rounded-md p-1 text-muted\",\n  formControlTransitionClassName,\n  \"hover:bg-interactive-hover-low hover:text-contrast\",\n);\n",
      "usedDeprecatedRules": []
    }
  ],
  "sourceHashes": {
    ".agents/skills/frontend-development/SKILL.md": "66a97b8ebfc20e8b2215be285ad2ba12a1fc1358653cb11e21c25bfe69175f30",
    ".agents/skills/frontend-development/references/guide.md": "c266b771cbf3ce89e9d26bca93d25d12f7745efbe61ccbf0eeac42fcce593441",
    "AGENTS.md": "69eae34c8f4cfdbb12234a6dd26e63f8997426779c76c67c60d6ffb11ffb53b6",
    "config/defaults.json": "65936cc1edcb9b6c44a4491b805da52b72ada1f07697c89940f9a5c2da1db60f",
    "eslint.config.js": "4fbd7c967d161864d9d7df9956017d8784794905c3fa7bd2ad24b1acceb72184",
    "package-lock.json": "8240e2e3024d723be7a34e7dcc2309d9e04a4a0de0587d00849ef2a673883e7f",
    "package.json": "ee3b15e1a67d009d4789cd05d9642d4e5e721b95e73f2caac5030a91ecf3ad0d",
    "src/components/conversation-events/chat/event-message-components/corrective-nudge-message.tsx": "f3eb68315b90f46c92a2edce7fc5d907f307dd47d979db703513ff963f190ed5",
    "src/components/conversation-events/chat/task-tracking/task-item.tsx": "da8039c9ea763a460a708526a3b1e59d7256e64370d600a2e31bd9c948f1a9b2",
    "src/components/conversation-events/chat/task-tracking/task-list-section.tsx": "82d2ce0b8ad9b2af497a487747e3ab05e324f7b2b4f4645a04866b572f9e4997",
    "src/components/features/automations/add-automation-menu.tsx": "8819f11c1affe0c4bb8cf95b2a6f363d4cc9acc6abbace1bd148d6ebf1b687fe",
    "src/components/features/automations/automation-list-row.tsx": "9563c6f0fd1cadd677d919446797aa73b639d469b6d8f0c8516b6e22b6257d5f",
    "src/components/features/automations/automation-view-toggle.tsx": "4eb4f612371d1185c5ebca598fda7f02a672d3d1e773a11589341f2622eafe1b",
    "src/components/features/automations/create-instructions.tsx": "27402699da366872fcfa81076bdba9c62a104c54f1516b5468ff665b627f0e61",
    "src/components/features/automations/dashboard/automations-dashboard-controls.tsx": "883b4a9009f4035a7edc9ee0929aaa10832e389697d0e4a95c8fd64e349a3f22",
    "src/components/features/automations/detail/active-status-badge.tsx": "292ef5fc6033fa45a6cbcc3a211d11e95bd7baae279fca4f3990100824057323",
    "src/components/features/automations/detail/run-logs-modal.tsx": "b17158268f00775f16e179bcfab26ed56b23f8a2d6752334468fc6aee16cca54",
    "src/components/features/automations/detail/run-phase.tsx": "2df6661d75c0b22ab8b3dffbf1d5c819bf057a6a1345abe46263c8150b30b3c1",
    "src/components/features/automations/detail/run-status-badge.tsx": "76ef615b746d591a87656b2b352d2d9af4efefb64b2ecab5c0aa29eefede5675",
    "src/components/features/automations/detail/script-section.tsx": "04c0e2768e467ffdffddb4ee42d56fe61f30ea902d94e7e21c887c44aaa60dce",
    "src/components/features/automations/import-automation-modal.tsx": "ee73b13bcccfa6ee53da4ea596afa016e9398884ad2189b5b9030ab842586100",
    "src/components/features/automations/kebab-menu.tsx": "79b9680623ebc836bc997fb15eb3cf992481f220a94ad82e73196d568da01205",
    "src/components/features/automations/recommended-automations-rail.tsx": "e31badbda9670dec308520c40101aa586314e5409a2211465565517c2d13a8cf",
    "src/components/features/automations/setup/automation-setup-panel.tsx": "313fe2f007aa0b811979ba45531571626dc1de5bd7da1754288ac719751b280f",
    "src/components/features/automations/setup/automation-setup-prompt-stack.tsx": "df5364c5e0da1b0f7a2f697dc79568e12d5257d03ca0b1a7afd06baafd4a699f",
    "src/components/features/backends/backend-form-modal.tsx": "0235d5f7353a5eb6707baaf787fc94cf34f597b81718d82733e756d4679f369c",
    "src/components/features/backends/backend-row.tsx": "2150a19bd8bcea1374bcb007577ae20a27cc90b711f03ce22928fe20f41f1350",
    "src/components/features/backends/backend-version.tsx": "b8efb3c9396c0d4179cf4f91e5e190917ac4d0cdcd2f0ae06116b7c0ac5033c9",
    "src/components/features/backends/environment-switch-overlay.tsx": "47a3bfbe99a1bb6018d3034458ae19b59348fa1b63b490d09fb1b9f977c4e785",
    "src/components/features/backends/manage-backends-modal.tsx": "89d0896190bdeb699b46e093053ea7982676874c3338231e61cbeed6dc676d5c",
    "src/components/features/backends/managed-agents/digitalocean-connect-panel.tsx": "eede1d652c839d58b52719dc6ce9f5a0bfeca61c844230e96f252a73ebd0d3e4",
    "src/components/features/backends/managed-agents/managed-agents-session-row.tsx": "7b3eb91e926c7994e111f39c6300502c148a8b2b9287f70196cbdb994989a9ca",
    "src/components/features/canvas-extensions/add-canvas-extension-modal.tsx": "54ab93813a189c13165887393a1d8196949bc7f0115682ce6a15ff918bbd4142",
    "src/components/features/canvas-extensions/canvas-extension-card.tsx": "284368ee45063a37af566b3aabb57f500fb216474bf1850cc97889813cbe6b0c",
    "src/components/features/chat/btw-messages.tsx": "5e7a344448c167253154735484bccf2209f62d59ce7f9aa2f913f809f6bcc74e",
    "src/components/features/chat/change-agent-button.tsx": "b0af4ba80585647644408698e3210fae83c13777027ba36b52094541b69e699f",
    "src/components/features/chat/chat-markdown-path-code.tsx": "ce1fbe13dac3fef58b6758e7b672e4c00f3954ea6171a47ff71e94cdb0df2512",
    "src/components/features/chat/chat-message.tsx": "de3ec0117422d1d77a7dc3f4d0e2c357a9ed94f00f145ed4525fbec3bdbb4366",
    "src/components/features/chat/chat-send-button.tsx": "cc95b2dfc6417a12441a62a22c4d2550cc21c6741f82b3efa591421b8de6b84b",
    "src/components/features/chat/chat-status-indicator.tsx": "ffc45d575f990d3436b7e17c7b731aaef28e95c23bfd8b6a101de7f3cf5866b8",
    "src/components/features/chat/chat-suggestions.tsx": "1add6b204db71a0f26d3a18b9161f478843519742fd6875c635bc84f7d281540",
    "src/components/features/chat/components/chat-input-actions.tsx": "a2b907c93e0cf0ca8029f265332ec5f5fea3cac01c855289c0edac1474d449eb",
    "src/components/features/chat/components/chat-input-container.tsx": "c592a70bf3d9c20a5a8d49cc5e9f825bfa919e5507310632e7629aa80d9bc60d",
    "src/components/features/chat/components/chat-input-llm-profile-picker.tsx": "dad67627832e7d0e9c63359b9df2b6adbb4efd07de97c8b88d83bd3d1c0c5623",
    "src/components/features/chat/components/chat-input-model.tsx": "fe388598295e8c62caa9f4662cecdeb96e19b3b04c3ceacd1920cdaaad82b630",
    "src/components/features/chat/components/chat-input-profile-picker.tsx": "75b29b8c31e0beb2c3e048fa4311bda162615b2599c67a0d505e17e175e875c9",
    "src/components/features/chat/components/context-window-meter.tsx": "d02a741b6345a5df781518d94b4083a16af7a68492670ddbbdb136dda232316d",
    "src/components/features/chat/components/context-window-ring.tsx": "b4e3d43114c7412e127e88f513939acd48ef33e610c26ba913bbe45acb98a436",
    "src/components/features/chat/drag-over.tsx": "4d7fbe90efe7cec5892001a665f39784f6a64a6e062ee7349c9b9233e49b1664",
    "src/components/features/chat/git-control-bar-branch-button.tsx": "5291ef320037971787ceae933de62ba59c5d3a6bd8895642c8a86a2c4995533a",
    "src/components/features/chat/git-control-bar-repo-button.tsx": "623e2c38eabeb4dc5859b59fe71ceaddddeab3a886db105bd4f5ad1128fb7a58",
    "src/components/features/chat/plan-preview.tsx": "70446a8da1cae6fb39c9bc567a447ed52773ba4aa08ef4a93504274fe2366c67",
    "src/components/features/chat/task-tracking/task-item.tsx": "2f1d1bea42e79e3c6ad0f92da51eba204a464fa2039828bad57dd2aebc8ddae8",
    "src/components/features/chat/tool-visualizers/primitives/code-block.tsx": "62422020233f0423e6ea451a33a9a2f2bfb5f6d516131799d3f1b77c7aa6736b",
    "src/components/features/chat/tool-visualizers/primitives/key-value-grid.tsx": "1a9ef01d71b922a40927939d66c2fdf19d2be2ff334f7a55db98c8e1ca98564c",
    "src/components/features/chat/tool-visualizers/primitives/markdown-file-preview.tsx": "086b7854a41434327e90051a351bf6d4cb206e54ca809f48fd3a3264948eda00",
    "src/components/features/chat/uploaded-file.tsx": "e2740b765444d7f99b6807a7b27a19f566dbcdb39d17e498f9bd3820c7a8b104",
    "src/components/features/chat/user-message-body.tsx": "4008f4634ee2aefadda50f5270173e52834df602d0ce566382a56543d38cabd9",
    "src/components/features/chat/workspace-mode-selector.tsx": "22be6a02f6788cdddc4b7b152184ac28e6d1c7226a406f43dd66d655ae2ce9c1",
    "src/components/features/command-menu/command-menu-trigger.tsx": "741d4558e3695ff672924f8db23810446db03a689747d7617b7c89d9026c2fbd",
    "src/components/features/command-menu/command-menu.tsx": "864cc89874a1a5e515494b775f6a70b4dc9fac47506ba25f0a009f0ba7c5b0cf",
    "src/components/features/context-menu/context-menu-icon-text-with-description.tsx": "cb94d2826b67bb9be467228b786996104dd816c432e861aea8c23cdad60f8f5f",
    "src/components/features/controls/agent-status.tsx": "529305d9deeefdc60d67570b91d4837d5c49701548aec69649e54ae04b62d845",
    "src/components/features/controls/server-status.tsx": "5acbd23411ef573ab1f5258418c896e5ee37836c17d53113a1aa1d72e6ef9204",
    "src/components/features/controls/tools-context-menu.tsx": "843ca153e6579fb202436de9a58f3b71910c3bfaaacdaa5628478cb908888e26",
    "src/components/features/conversation-panel/advanced-conversation-options-modal.tsx": "ef6247f2661e9a129e2a9e23cd2c4eca61c40c1df85229c0975a0fd4a80e293e",
    "src/components/features/conversation-panel/budget-progress-bar.tsx": "de0a52378c599e206235b2ee9ec6c2d3c0d858fd4c2a8914643338a0df07b6e7",
    "src/components/features/conversation-panel/cloud-new-conversation-menu.tsx": "f1be2831c4cee88df83b128673927b41f71214af47cba9bd89be51c874913610",
    "src/components/features/conversation-panel/conversation-active-tag-filters.tsx": "281c63265f917c9b5e9b857a624706f62e6d60351ac724da3ee5cbc0b5c4f4f6",
    "src/components/features/conversation-panel/conversation-card/conversation-card-context-menu.tsx": "c89915ae3f4baf6eb01379b6ee470b962ee3ee0643efd99b2b9063523d0ad721",
    "src/components/features/conversation-panel/conversation-card/conversation-card-preview.tsx": "6360499b7e240a4c283abf96220de34b094ff7db04c92584e00a6ced64c31677",
    "src/components/features/conversation-panel/conversation-card/conversation-card.tsx": "7aa3b77225ee7de32ee6c8649b31702f051fd9637a69a72c5f1b24616bc684b2",
    "src/components/features/conversation-panel/conversation-card/conversation-tag-chips.tsx": "3f4b90e5b93f536ae33ee964507393f134198d804e08f6f8c4f8228114063420",
    "src/components/features/conversation-panel/conversation-layouts-menu.tsx": "9c9696e66f1f4b6f8263010a8d13d371a022c1489d24f5872210d6831b0ff4a2",
    "src/components/features/conversation-panel/conversation-panel-wrapper.tsx": "eb20360281863b77d0c8c35706e5fca0934dce59d1ea8fc08222d2d27e9d8480",
    "src/components/features/conversation-panel/conversation-panel.tsx": "948b81295f6ed3c15212a3275bafd2a6980ae134643610594475dc4c74811187",
    "src/components/features/conversation-panel/exit-conversation-modal.tsx": "135a397cf5e9792ac2acab38f4fc93403cced74f9cb9ff60f6b53ee131c4ad44",
    "src/components/features/conversation-panel/hook-event-item.tsx": "c1248211b16919d947e41554008a7449de71c95b07203e838ac82ab03841edc0",
    "src/components/features/conversation-panel/hook-matcher-content.tsx": "d2509e41ac502a7a0dccf957293cd3631765623719bee34876d4e6b26dc4cfb7",
    "src/components/features/conversation-panel/hooks-modal.tsx": "51ee239056707f8c076df805ece86390e0a21c5dbbf61e298962526b5b34fead",
    "src/components/features/conversation-panel/local-new-conversation-menu.tsx": "8522c34b79251014c613df86469e9f6ba7c1e9c721b8d0bcf14fcad3c71ed411",
    "src/components/features/conversation-panel/menu-heading.tsx": "fb0e7ce0948466325349b308c7526af468d018cd33129ab43d2e4dfda0623d52",
    "src/components/features/conversation-panel/menu-row.tsx": "23ff73aa3019ea3bbf2166ee138f9fee5698d0891c5eb65a76fb5b12ff95f075",
    "src/components/features/conversation-panel/plugins-modal.tsx": "03afa80903e4e555567c91b87606524f032e9886eb945963b89421a21bfce5c9",
    "src/components/features/conversation-panel/skill-item.tsx": "bc2b473fbfaff1e8bb4af18eb3a4a69e3942c0d3b429fb8c1f89cc96dc5bee74",
    "src/components/features/conversation-panel/skill-triggers.tsx": "bc15ea429d87dc1c16020115843b6569f748753db155854f90ef9229f2a4336b",
    "src/components/features/conversation-panel/skills-modal-section.tsx": "1787bdce7dfdae89b6142849b3ba84a9b4920920dfc425f0f1473fead06d8141",
    "src/components/features/conversation-panel/skills-modal.tsx": "76d7f055ccc24c1d3dc5aba0ff6da47d93e390695c368913e910c4920761401f",
    "src/components/features/conversation-panel/start-task-card/start-task-status-badge.tsx": "d1cf6ef1aab183797e2f1eae54a0b00e49c514534154bae13295e84b52ed66de",
    "src/components/features/conversation-panel/start-task-card/start-task-status-indicator.tsx": "4b72ef118d9f3f95ac2e53dad4bdc3ad5e509ff132d7436c5ffe51aa69271d14",
    "src/components/features/conversation-panel/system-message-modal.tsx": "e99ccfaecd21629fcf39d61ebb8f5e39fd2134522ab3f2cb9fe635a811d8f614",
    "src/components/features/conversation/conversation-git-actions-menu.tsx": "b759c42f0fd3b73f03ac17a5c5a6cd9fd3f224a3ee2f6c4e407fe86223616969",
    "src/components/features/conversation/conversation-main/conversation-main.tsx": "0757978a336cdfc46bbc3cb63b1bc30d375324dc261011f59a38b8620fe79298",
    "src/components/features/conversation/conversation-name-context-menu.tsx": "99bd9b1fb8357b8e74cc2637f501c43c75470060e692df6d41294034a92b0e24",
    "src/components/features/conversation/conversation-name-with-status.tsx": "375b8057249efaa9c4660e6a184316ff937a5cae31cf281292559547d1bad7bc",
    "src/components/features/conversation/conversation-overview-context-menu.tsx": "52e496511c6431c441801a4f74805273d17f458f0d7e2ff4754bb29c292f1c45",
    "src/components/features/conversation/conversation-overview-drawer.tsx": "0c0865fbc59ec602561241fc83ee88b3b48a54c4fbe1c27b563e9a9feafbc12c",
    "src/components/features/conversation/conversation-overview-panel.tsx": "91b2f2d1206795182511a809bf7c819ec4d1fd06cbee10da5df86404df588863",
    "src/components/features/conversation/conversation-overview-toggle.tsx": "c00662d74170378782055521e286e7ea9bf34125d34b4cbbe7972eb893203917",
    "src/components/features/conversation/conversation-tabs/conversation-planner-build-bar.tsx": "ee45cbd0bf5c6ed2617fe92e5c71cc336b12fb4a399b744ffede84f4019526df",
    "src/components/features/conversation/conversation-tabs/conversation-tabs-context-menu.tsx": "9b11b4299268295a8681254263ce2598fa2c98c4018997911dcd0b077bc67b33",
    "src/components/features/conversation/conversation-tabs/conversation-tabs.tsx": "419612d2d9f9925e7fddda516b25a0061b29d6661657e5bdc92a502faf331618",
    "src/components/features/conversation/conversation-tabs/drawer-vscode-link.tsx": "51a87b10baea9e3c5c1c7d491a8a5b9391ea7e7f6ece195443e64ec6eea2e87e",
    "src/components/features/conversation/usage-panel/context-meter.tsx": "df0d64ba4ee5da1858e54903157bff00510b41ca474fb372a64bf45fc67c7bd3",
    "src/components/features/diff-viewer/commit-row.tsx": "05dd951b13061131d448b1ff44a6f998658dbbd44437550550acbc9ff3567528",
    "src/components/features/diff-viewer/editor-container.tsx": "9366dfb6c1503e4586bfc5177a7fc7e7eae0994f3b69ff57929fdf03808e76a8",
    "src/components/features/diff-viewer/uncommitted-changes-row.tsx": "056eafc73e7b5e239e592d78fbee40ec02d897e016092a360d94dd2dd71e4059",
    "src/components/features/files-tab/file-content-viewer.tsx": "d6bd534ba87d0572f16e78c38c98ca750e0e008fa4e10e736a0a6d6dda95b1f8",
    "src/components/features/files-tab/file-quick-row.tsx": "3b04b4fe68b2f273887c012903ce41969c40f2652b8bcff0c7b0aadc2ca67f2f",
    "src/components/features/files-tab/highlighted-source-view.tsx": "11eeebf71d745e728b64e69abb613a091de05bf66573b838c9f5ffa8e8173d86",
    "src/components/features/files-tab/workspace-file-discovery-settings.tsx": "08ca3cdb474ecb3f8a16e95bbe965a4cff67d5b2718c4d07df8ef0c6579c5a28",
    "src/components/features/home/featured-automations/automation-run-activity-sparkline.tsx": "c95c98be8d8de58b283bbedf65da98376d89b6d4ea9f31764c1242be7e91182c",
    "src/components/features/home/featured-automations/home-automation-menu.tsx": "cf259b5101db2a2ff660225ed6c4231e3806a3bde1dbcba2fc45cac3f07e1b4a",
    "src/components/features/home/featured-automations/pinned-automation-card.tsx": "ed1334867ffa01514e64974398d4290eb48dac8fe98500a18055c781f1720880",
    "src/components/features/home/featured-automations/running-automations-list.tsx": "faeeef19e1d2053c5b164f0be7c2939844fac356f0e188f2619a4702b94ff2f1",
    "src/components/features/home/home-chat-launcher.tsx": "b4ca82e8612187bfc16e7bbbd87b277d66bb9dd178e8e62d7a50bbd575d50f21",
    "src/components/features/home/home-header/home-header-title.tsx": "ef348efd182b0addd71eadd2edf704fa4a19ff01841829061a08c91573da7ff4",
    "src/components/features/home/home-launcher-mode-toggle.tsx": "7c4d7ff30d09a826053605c56dc354862024c24e1100e61355863c31f53b0794",
    "src/components/features/home/shared/clear-button.tsx": "b2fc52ca7448cc2ecebba819c9e1befad7984dd17565c907c1dd212b2d4929b9",
    "src/components/features/home/shared/generic-dropdown-menu.tsx": "73f6de0903558fd4c8ca3ac67756423e722270a7b9c1821f613df3e9c07c8ca7",
    "src/components/features/home/workspace-dropdown/folder-browser-modal.tsx": "4e182c923a27d8e0c1651f874e7c030cc17dc1347db0c97a2494b68bcc831be6",
    "src/components/features/home/workspace-dropdown/manage-workspaces-modal.tsx": "11df16d54b02305a4569c473c401d086450f810628ae20985ccd135a2afc4898",
    "src/components/features/images/image-lightbox.tsx": "6f51eace7a1d1663808d1c856e73ea3ba5828cc41e0ae0af6b88befa3eee23c4",
    "src/components/features/launch/plugin-launch-modal.tsx": "549f52ebd0f21d471de85aac93ccd0a1a91b08f1adaa2aab0036dd3d110f6789",
    "src/components/features/manifest/manifest-setup-dialog.tsx": "028fd2167ecc341a2637ab847c10976e9bb7eea7542382c87deebcddd0ccc724",
    "src/components/features/markdown/code.tsx": "7608445d4a211f5ef1ea3f30600515bca13480e08de7fae23e690786e66798ca",
    "src/components/features/markdown/headings.tsx": "46f0bcb9e52fe79de1c12c980ffd6de19365e6cbb32f8d29604acdf4059640ac",
    "src/components/features/markdown/markdown-table-scroll.tsx": "907735c0f30bb69842267225329d7d7ea7b936f9012d5d79396ae420abe30c41",
    "src/components/features/mcp-logo-badge.tsx": "7b98f2ab6055b039ab590aab6b63a9bc341b6beb15e870b72e782ad7e680f9f4",
    "src/components/features/mcp-page/custom-server-editor.tsx": "68994ebe6098cb4575dd0352e69348e63f717c2a41cb09fabfe5fe3bc9569218",
    "src/components/features/mcp-page/install-server-modal.tsx": "b149a455171fd4ee699ecd91c7d5e8b544c42fe77d033d62669e7e97c36736c6",
    "src/components/features/mcp-page/mcp-logo-stack-badge.tsx": "c18fb272625aa16a1ee0b9f9af41a672a8d54a7f53af06fcd324ce0548147c14",
    "src/components/features/mcp-page/save-as-secret-toggle.tsx": "74ffd925b788f5eb5c8eabb6e3c574fc940866e6c1e3d0a82c3995bd036e2112",
    "src/components/features/onboarding/onboarding-modal.tsx": "b23dc208f9e2453ad947c34f894599aa3bca93653bb29d7c694d62d02df73171",
    "src/components/features/onboarding/steps/say-hello-step.tsx": "64e60bee8f8136085179de955bea50bca9fc10bea387babf3dddbe2e17e93870",
    "src/components/features/onboarding/steps/setup-llm-step.tsx": "2c4b6d605c3915056751c45bd799ad3f7c267bc5d29f577bb14d0ea061b7f211",
    "src/components/features/plugins/add-plugin-modal.tsx": "ac2325678ba14520fda642e954e564a5adfe0f8501f8103b054f71c8ee8ac0d3",
    "src/components/features/plugins/plugin-detail-modal.tsx": "045d1e4dce56c526deb55341ef4f5835dd8eced9d0e6e87ba40151b7aebb7230",
    "src/components/features/plugins/plugin-picker-modal.tsx": "7012d4f0b84d29c929b32ac696d02753f7de2bf20d8696e6bf1cbdadf532a16b",
    "src/components/features/plugins/plugin-picker-trigger.tsx": "43cf608c7879f9597ea08c0d1a6c22c3d23f1aab362f31b2d78df9fa8227adc4",
    "src/components/features/settings/acp-credentials-section.tsx": "e51273f9d295b0dc23a71cb758d45e31ad14118eb38e8c34c6d70c0ae35c3771",
    "src/components/features/settings/agent-canvas-update-card.tsx": "407a42f125e255b576d164d88b4f2e9391d0d73c9d0c866e4018227631b13044",
    "src/components/features/settings/agent-canvas-version-modal.tsx": "078e56bc2c81691f81cce8c4be1545c3f96bb9a5bbac86be8d06b18c15503eb7",
    "src/components/features/settings/agent-canvas-version-tile.tsx": "9f8e9855b8abef54656986c6185ba04f96acd41a0d8fe04b37857cd5d0eefdb5",
    "src/components/features/settings/agent-profiles/agent-profile-actions-menu.tsx": "e4715393266423d520488da594b976e60be031dd00dff77753e9e94e3a1b4987",
    "src/components/features/settings/backend-synced-settings-badge.tsx": "5619125525e4a356f7d3f6ba4b216424de891c317f20108efd13f49988cd007f",
    "src/components/features/settings/brand-button.tsx": "dc7d26a079505d37840e4b276e5fc055d690b348935b9c00baa1d7a3d8336602",
    "src/components/features/settings/llm-profiles/add-models-modal.tsx": "0dc54dcec4ec329b3e9c7d212da53f78f5c90b078f45994a6a15de098b0d719d",
    "src/components/features/settings/llm-profiles/profile-actions-menu.tsx": "e31c1f985548b067eb7d92f4566f284396a161dafd289028634f4dbe0805d183",
    "src/components/features/settings/llm-profiles/provider-connection-actions-menu.tsx": "8bd5028dd88c37b305a232f2934941dcf482e4253792a7e77b26fec623e5d45a",
    "src/components/features/settings/llm-settings/openai-subscription-auth-card.tsx": "24c57d85ab0c392b5f3095d0f4f198c1b1d43d9497b4ca30400d9537cf9061b7",
    "src/components/features/settings/meta-llm-profiles/meta-llm-settings-view.tsx": "e31f29d2d252ff335bb9570a513ef13c80f1cf28aaac3ab18a69f8d4b926e8aa",
    "src/components/features/settings/meta-llm-profiles/meta-profile-actions-menu.tsx": "46e3ea6f5afcfbe5c6aec1065433960f76cedbc73e15960e77ee4fd372712d55",
    "src/components/features/settings/meta-llm-profiles/meta-profile-editor.tsx": "9e410b418c17c4f80c6efa6c554d0744f3245711bd4629b6b54952636b206bdb",
    "src/components/features/settings/meta-llm-profiles/meta-profile-row.tsx": "a335ba7e5a93f037b343e1db96a7bb5c518dca24691373227c4b612cdbe58554",
    "src/components/features/settings/settings-nav-header.tsx": "84e0e595763656d56e37002decf2af794ef60b8d788b151170ed78cfeaa9ab2f",
    "src/components/features/settings/settings-switch.tsx": "2220708b930e82c5f2a086782a7f6219da894d5d5a4bf649dea69c79eb300747",
    "src/components/features/settings/upgrade-button.tsx": "087c7951c349afb40ada66c452501f8eb36ffa332e623d0f9c5d760a3ebc0f71",
    "src/components/features/setup-guide/super-admin-setup-guide.tsx": "6d1e46f970c638acc32eb85a5fc86239fbf302ddd0910830e9c6e72d252ccf1a",
    "src/components/features/sidebar/mars-session-panel.tsx": "a2c7f6f1514e2818df7ff617b2bb46cbcef1038f41de719f350b95f302e5b9d1",
    "src/components/features/sidebar/sidebar-layout.ts": "b2818b96c353d761616dd906db1ee227bd71bae21b771ba4cf42cc57e8078471",
    "src/components/features/sidebar/sidebar-rail-body.tsx": "989ec7bfe68293fe370094f68f7b118b765326fa4de79bc3f63d857b4819aa3f",
    "src/components/features/sidebar/sidebar.tsx": "552c8e878a5385263998217864a3d69755ab231f7853b636f16bc6511980db5a",
    "src/components/features/skills/add-skill-modal.tsx": "d69bbdbaf6f7e85728af8685423f904235775a5dabfb1365c31636a41fa7b018",
    "src/components/features/skills/build-skill-pills.tsx": "fcc820eadd46906c87919e03d730274fd165eafb35cd2c87256ea9bb90bce092",
    "src/components/features/skills/extensions-navigation.tsx": "5bb3d53088e21e1747bab269fadde55023b3cd12efc62a4704b5c26a88ce9347",
    "src/components/features/skills/skill-card-pill-row.tsx": "e5beb421c79a7b69445d23358e328c902eb3ea346e8a8f2563d17499caf6d7ed",
    "src/components/features/skills/skill-detail-modal.tsx": "a3f88d7c3ba6c2321468a0a61a7988d12839a4b351016b9c49b02d061ce0d8e7",
    "src/components/features/skills/skill-facet-rail.tsx": "93a2796bb7336d7471d024614911d04d287fd45b18bdb3ba0c00448ce79e496a",
    "src/components/features/skills/skill-facet-row.tsx": "fd72c333bc72cf786e92dc45c80db9cd18b2f5a2ec4a7a948400273108d4f7e9",
    "src/components/features/skills/skill-filters-modal.tsx": "0f76340fe527f2183b2229280e29f8ab6598a0c80e8071ee6ae78b8fdb5dd8b0",
    "src/components/features/skills/skill-icon-badge.tsx": "8c9417343844f07e396e552cca07554a82f36babe1262de4637eaed543aff742",
    "src/components/features/skills/skill-type-badge.tsx": "c0547c10df2116959dffd5f376753f9a04954d13cf1f9c45fc7f4650646fa029",
    "src/components/features/skills/skills-toolbar.tsx": "31688860c3828de694eb31369521f809854920f46de94c183f3e163a91a77801",
    "src/components/features/suggestions/suggestion-item.tsx": "a9b6925928ec277e3fcef827babee036cde492cd1bd07f19417b410d193d7cd5",
    "src/components/shared/badge.tsx": "b157c1b9435f8387c1916270ab26714d119e494795c26afcc752dcec64e33459",
    "src/components/shared/buttons/circle-plus-check-toggle.tsx": "1171413800b765ca0825f0347c3648091ee19f1850cd26adc2610b352dc3c839",
    "src/components/shared/buttons/styled-tooltip.tsx": "90048ff3f18b59e353bdffeffa424bce34ab0d989fea5571a324e0ee4c1184b1",
    "src/components/shared/modals/modal-backdrop.tsx": "38216adf4261490cb38324844c748d815e85a392f2ad4f02dfb89403380f73da",
    "src/components/shared/modals/settings/model-selector.tsx": "c8ca5b07d58ca20591dad414baa6ef9517c74a90dd9ec43e6870d3a3d1d0af7b",
    "src/components/shared/risk-alert.tsx": "e2c2f6b1fea86f97a25fb5aa93c527424e3cbaee6b8d584d1c9567798d357ab4",
    "src/components/shared/text-shimmer.tsx": "0f711d4a015d7c606f092f9a0db99355d290853ae2acf134123fcae04aae41c7",
    "src/routes/agent-settings.tsx": "9f2e246d8e0281be736a787cecb6194a9c10976c5c9eced3d1968f9e7742709b",
    "src/routes/automation-setup-new.tsx": "f49be56ee35ec31c0d27557cec9610410f0f224b55d912cec478fd5f174f244b",
    "src/routes/device-verify.tsx": "30edd53f524494b56adb3514a36356de2cef3774e4ef38885e597df97ccc9db3",
    "src/routes/files-tab.tsx": "b112d931f4788fb874ec1e6a2bed8ef22d295a5ef6b52ed54de252dcf122cce5",
    "src/routes/meta-llm-settings.tsx": "3db16f596619bb4b765e956c7616d93ebae29cf349510bac94492ee9e60a2e0f",
    "src/ui/dropdown/dropdown-menu.tsx": "0b393ed547600c12153b2c36d05521dae3eb4a4ddfdea5d7bf44fbe2ee2195b5",
    "src/ui/dropdown/dropdown.tsx": "ce107e70e4610c8426c3d88197e81873f254700a12a853010c6f905635ee2d57",
    "src/ui/pre.tsx": "4111f9ec113756d55391eebe116264d63fcb57e69a2c6928d0bd8159b181c3c8",
    "src/ui/typography.tsx": "3dafeb7fc4e660c40ec06517534d94b8b87323b3bdc49bec87f70624f4dea8d1",
    "src/utils/custom-toast-handlers.tsx": "eb49f63cbe69e6c21bb29af64360a3dd95d72fb2ca30319b49cb4daf6351e341",
    "src/utils/dropdown-classes.ts": "fda5023bd6b7a9484edf669a92acd907a5587fab318ff7c4f4cd60fac6fd8669",
    "src/utils/form-control-classes.ts": "6989f8a01bdf926cdeff5b20d8b6f9ce5f475e6bc11b200f3854f606d44168f1",
    "src/utils/git-control-bar-classes.ts": "de0971a85bd9e5d39f469c343215a253aaeaab767984594cdbeb5df554fb5e2e",
    "src/utils/hover-reveal-classes.ts": "54f6fb286c1af8270e562f6f67409906a2b42924960ef509b0b8cca79ea2a09b",
    "src/utils/modal-classes.ts": "94b883172573e7756a1ebc668a50c6f3be6a456625ecefd067d83c26235ff57f",
    "src/utils/settings-list-classes.ts": "c7cf74daff52d3808bddeb2ddcc5f6282e166ca8430c50598a610c312d42a54a"
  },
  "comparison": {
    "priorExactSha": "70b8f9069dec3d1c39a04d755521ebe6bd252aab",
    "priorPublicAuditSha256": "8df2711affa0ca96c803da644331b17fb662c37f6870123854568f3b831cce58",
    "priorTotals": {
      "filesLinted": 1419,
      "filesWithDiagnostics": 192,
      "errors": 0,
      "warnings": 615,
      "fatalErrors": 0
    },
    "warningDelta": -4,
    "comparisonMethod": "Match repository-relative file, rule, diagnostic category and cited utility/property, ignoring line movement and nearest-token suggestion changes, preserving multiplicity.",
    "diagnosticDelta": [
      {
        "kind": "removed",
        "file": "src/components/features/automations/setup/automation-setup-prompt-stack.tsx",
        "ruleId": "shadcn/no-arbitrary-values",
        "messageId": "arbitraryValueWithScale",
        "utilityOrMessagePrefix": "w-[260px]",
        "count": 1
      },
      {
        "kind": "removed",
        "file": "src/components/features/files-tab/tree-node.tsx",
        "ruleId": "shadcn/no-inline-styles",
        "messageId": "inlineStyle",
        "utilityOrMessagePrefix": "Inline style sets paddingLeft",
        "count": 2
      },
      {
        "kind": "removed",
        "file": "src/components/features/sidebar/sidebar.tsx",
        "ruleId": "shadcn/no-inline-styles",
        "messageId": "inlineStyle",
        "utilityOrMessagePrefix": "Inline style sets transitionDuration",
        "count": 2
      },
      {
        "kind": "added",
        "file": "src/components/features/automations/setup/automation-setup-prompt-stack.tsx",
        "ruleId": "shadcn/no-arbitrary-values",
        "messageId": "arbitraryValue",
        "utilityOrMessagePrefix": "w-[min(28rem,calc(100vw-3rem))]",
        "count": 1
      }
    ]
  },
  "notes": [
    "This is an audit of exact integrated main. Proposed follow-up batches are not subtracted from its warning totals.",
    "npm run lint includes make-i18n, TypeScript checks, ESLint src, and the repository Prettier check; all completed successfully.",
    "The current repository-pinned npm 11.12.1 was used for installation and every nested npm run command. Node 24.22.1 satisfies the >=24 engine; the repository Volta Node pin is recorded separately.",
    "The audit changed no tracked source, lint configuration, verification skill, specification, or dependency lock. Generated ignored localization/type artifacts are normal command outputs.",
    "This run replays the committed lock only; it does not independently revalidate publication age or vulnerability status of existing locked versions.",
    "No application stack was launched and no live-UI verification is claimed by this lint audit."
  ]
}
