From f0fb69f4c70d58bb3aa594ea095bb1530b95ab06 Mon Sep 17 00:00:00 2001 From: fatelei Date: Mon, 3 Aug 2026 14:46:07 +0800 Subject: [PATCH] fix: fix some ui issue --- api/services/skill_management_service.py | 215 +++++++++++++-- .../services/test_skill_management_service.py | 245 +++++++++++++++++ .../__tests__/add-actions.spec.tsx | 81 ++++++ .../skills/__tests__/detail-page.spec.tsx | 107 +++++++- web/features/skills/client.ts | 21 +- .../__tests__/file-tree-drag-preview.spec.ts | 59 ++++ .../detail/__tests__/file-tree-items.spec.tsx | 251 ++++++++++++++++++ .../skills/detail/__tests__/shared.spec.ts | 89 +++++++ .../skills/detail/__tests__/shell.spec.tsx | 34 +++ web/features/skills/detail/builder-panel.tsx | 31 ++- web/features/skills/detail/file-tree.tsx | 27 +- web/features/skills/detail/shared.tsx | 1 + web/i18n/en-US/agent-v-2.json | 5 +- web/i18n/en-US/skill.json | 5 +- web/i18n/ja-JP/skill.json | 7 + web/i18n/zh-Hans/agent-v-2.json | 5 +- web/i18n/zh-Hans/skill.json | 5 +- web/service/base.spec.ts | 37 +++ web/service/base.ts | 8 +- 19 files changed, 1192 insertions(+), 41 deletions(-) create mode 100644 web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/add-actions.spec.tsx create mode 100644 web/features/skills/detail/__tests__/file-tree-drag-preview.spec.ts create mode 100644 web/features/skills/detail/__tests__/file-tree-items.spec.tsx create mode 100644 web/features/skills/detail/__tests__/shared.spec.ts create mode 100644 web/features/skills/detail/__tests__/shell.spec.tsx diff --git a/api/services/skill_management_service.py b/api/services/skill_management_service.py index d4806a6f796..54edf22355d 100644 --- a/api/services/skill_management_service.py +++ b/api/services/skill_management_service.py @@ -108,13 +108,28 @@ draft file operations: - delete: delete a draft file or directory. Never delete SKILL.md. Allowed write targets are SKILL.md and files/directories under scripts/, -references/, and assets/. When revising SKILL.md, preserve valid frontmatter and -include a lowercase kebab-case name, a non-empty description, and -metadata.display-name when appropriate. Do not claim that you published a Skill -or changed anything outside the draft files. Only SKILL.md should contain Skill -frontmatter fields such as name, description, or metadata.display-name. Ordinary -Markdown files under references/ should contain only their own document content -unless the user explicitly asks for YAML frontmatter in that file. +references/, and assets/. When creating or revising SKILL.md, preserve valid +frontmatter and include a meaningful lowercase kebab-case name, a non-empty +description, and metadata.display-name. If the current draft is untitled, never +keep placeholder values such as name: untitled-skill-*, metadata.display-name: +Untitled skill, or the default placeholder description in the completed +SKILL.md. The frontmatter name, metadata.display-name, and first H1 heading must +describe the same Skill. Derive the kebab-case name from the actual Skill title, +for example: +--- +name: customer-issue-tiered-handling +description: Classify and route customer support issues by severity and handling path. +metadata: + display-name: Customer Issue Tiered Handling +--- + +# Customer Issue Tiered Handling + +Do not claim that you published a Skill or changed anything outside the draft +files. Only SKILL.md should contain Skill frontmatter fields such as name, +description, or metadata.display-name. Ordinary Markdown files under references/ +should contain only their own document content unless the user explicitly asks +for YAML frontmatter in that file. Respond with JSON only: { @@ -761,6 +776,19 @@ class SkillManagementService: "status": exc.status_code, } ) + except IntegrityError as exc: + logger.warning("skill_assistant_action_conflict skill_id=%s error=%s", skill_id, exc) + error_message, details = self._skill_name_conflict_from_integrity_error(exc) + payload: dict[str, Any] = { + "event": "error", + "id": message_id, + "code": "skill_name_conflict", + "message": error_message, + "status": 422, + } + if details: + payload["details"] = details + yield self._assistant_sse(payload) except Exception: logger.exception("skill_assistant_action_failed skill_id=%s", skill_id) yield self._assistant_sse( @@ -886,6 +914,15 @@ class SkillManagementService: def _assistant_sse(payload: dict[str, Any]) -> str: return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + @staticmethod + def _skill_name_conflict_from_integrity_error(exc: IntegrityError) -> tuple[str, dict[str, str]]: + text = str(getattr(exc, "orig", exc)) + match = re.search(r"Key \(tenant_id, name\)=\([^,]+,\s*([^)]+)\) already exists", text) + if match: + name = match.group(1) + return f'Skill name "{name}" already exists. Please choose a different name.', {"name": name} + return "Skill name already exists. Please choose a different name.", {} + @classmethod def _sanitize_assistant_operation_content(cls, operation: SkillAssistDraftOperationPayload) -> str | None: content = operation.content @@ -2381,10 +2418,7 @@ class SkillManagementService: display_name: str, current_skill_id: str, ) -> str: - base = re.sub(r"[^a-z0-9]+", "-", display_name.strip().lower()).strip("-") - if not base: - base = _UNTITLED_SKILL_NAME_PREFIX - base = validate_skill_name(base[:64].strip("-") or _UNTITLED_SKILL_NAME_PREFIX) + base = SkillManagementService._name_from_display_name(display_name) names = set( session.scalars(select(Skill.name).where(Skill.tenant_id == tenant_id, Skill.id != current_skill_id)) ) @@ -2398,6 +2432,38 @@ class SkillManagementService: return candidate suffix += 1 + @staticmethod + def _name_from_display_name(display_name: str) -> str: + base = re.sub(r"[^a-z0-9]+", "-", display_name.strip().lower()).strip("-") + if not base: + base = _UNTITLED_SKILL_NAME_PREFIX + return validate_skill_name(base[:64].strip("-") or _UNTITLED_SKILL_NAME_PREFIX) + + @staticmethod + def _ensure_skill_name_available( + session, + *, + tenant_id: str, + current_skill_id: str, + name: str, + ) -> None: + with session.no_autoflush: + existing_id = session.scalar( + select(Skill.id) + .where( + Skill.tenant_id == tenant_id, + Skill.id != current_skill_id, + Skill.name == name, + ) + .limit(1) + ) + if existing_id is not None: + raise SkillManagementServiceError( + "skill_name_conflict", + f'Skill name "{name}" already exists. Please choose a different name.', + details={"name": name}, + ) + @staticmethod def _parse_frontmatter(content: str) -> dict[str, Any]: match = _FRONTMATTER_RE.match(content) @@ -2424,7 +2490,6 @@ class SkillManagementService: ) return payload - @staticmethod @staticmethod def _frontmatter_field_line(content: str, field: str) -> int: match = _FRONTMATTER_RE.match(content) @@ -2507,7 +2572,7 @@ class SkillManagementService: skill.name_manually_edited = True skill.name = name skill.description = self._require_frontmatter_description(frontmatter, content=content) - display_name = self._display_name_override_from_frontmatter(frontmatter) + display_name = self._display_name_from_draft_skill_md(frontmatter=frontmatter, content=content) if display_name is not None: skill.display_name = display_name @@ -2806,6 +2871,9 @@ class SkillManagementService: if skill_md is None or skill_md.kind != SkillFileKind.FILE or skill_md.storage != SkillFileStorage.TEXT: raise SkillManagementServiceError("missing_skill_md", "skill must contain text SKILL.md") skill_md_content = skill_md.content or "" + if not strict_frontmatter and sync_frontmatter_name: + skill_md_content = self._normalize_untitled_draft_skill_md_name(skill=skill, content=skill_md_content) + entries_by_path[_SKILL_MD] = skill_md.model_copy(update={"content": skill_md_content}) if strict_frontmatter: frontmatter = self._parse_frontmatter(skill_md_content) frontmatter_name = self._require_frontmatter_name(frontmatter, content=skill_md_content) @@ -2894,22 +2962,139 @@ class SkillManagementService: except SkillManagementServiceError: return name = frontmatter.get("name") + display_name = self._display_name_override_from_frontmatter(frontmatter) if isinstance(name, str) and name.strip(): try: validated_name = validate_skill_name(name) except ValueError: validated_name = None if validated_name is not None: - if validated_name != skill.name: + session = object_session(skill) + auto_generated_name = False + if ( + self._should_auto_sync_name(skill) + and display_name is not None + and display_name != _UNTITLED_DISPLAY_NAME + and session is not None + ): + generated_name = self._name_from_display_name(display_name) + validated_name = generated_name + auto_generated_name = True + if validated_name != skill.name and session is not None: + self._ensure_skill_name_available( + session, + tenant_id=skill.tenant_id, + current_skill_id=skill.id, + name=validated_name, + ) + if validated_name != skill.name and not auto_generated_name: skill.name_manually_edited = True skill.name = validated_name description = frontmatter.get("description") if isinstance(description, str) and description.strip(): skill.description = description.strip()[:1024] - display_name = self._display_name_override_from_frontmatter(frontmatter) if display_name is not None: skill.display_name = display_name + def _normalize_untitled_draft_skill_md_name(self, *, skill: Skill, content: str) -> str: + """Replace placeholder builder names with the generated display-name slug. + + Skill Builder starts from an untitled draft. Some models preserve the + placeholder ``name: untitled-skill-*`` while correctly generating a + meaningful ``metadata.display-name``. Normalize the file before it is + saved so the editor, detail payload, and future export all show the same + generated kebab-case name. + """ + if not self._should_auto_sync_name(skill): + return content + + try: + frontmatter = self._parse_frontmatter(content) + except SkillManagementServiceError: + return content + + name = frontmatter.get("name") + if not isinstance(name, str) or not name.strip(): + return content + try: + validated_name = validate_skill_name(name) + except ValueError: + return content + display_name = self._display_name_from_draft_skill_md(frontmatter=frontmatter, content=content) + if display_name is None: + return content + + session = object_session(skill) + if session is None: + return content + generated_name = self._name_from_display_name(display_name) + next_content = content + if display_name != self._display_name_override_from_frontmatter(frontmatter): + next_content = self._replace_or_insert_frontmatter_display_name(next_content, display_name) + if generated_name == validated_name: + return next_content + self._ensure_skill_name_available( + session, + tenant_id=skill.tenant_id, + current_skill_id=skill.id, + name=generated_name, + ) + + return re.sub(r"(?m)^name:\s*.*$", f"name: {generated_name}", next_content, count=1) + + def _display_name_from_draft_skill_md(self, *, frontmatter: dict[str, Any], content: str) -> str | None: + display_name = self._display_name_override_from_frontmatter(frontmatter) + if display_name is not None and display_name != _UNTITLED_DISPLAY_NAME: + return display_name + if display_name is None: + return None + + heading = self._first_markdown_heading(content) + if heading is None or heading == _UNTITLED_DISPLAY_NAME: + return None + return heading[:128] + + @staticmethod + def _first_markdown_heading(content: str) -> str | None: + body = _FRONTMATTER_RE.sub("", content, count=1) + match = re.search(r"(?m)^#\s+(.+?)\s*$", body) + if match is None: + return None + heading = match.group(1).strip() + return heading or None + + @staticmethod + def _replace_or_insert_frontmatter_display_name(content: str, display_name: str) -> str: + match = _FRONTMATTER_RE.match(content) + if match is None: + return content + + frontmatter = match.group(1) + escaped_display_name = yaml.safe_dump( + display_name, + allow_unicode=True, + default_flow_style=True, + sort_keys=False, + ).splitlines()[0] + if re.search(r"(?m)^\s*(display-name|display_name)\s*:", frontmatter): + next_frontmatter = re.sub( + r"(?m)^(\s*)(display-name|display_name)\s*:.*$", + lambda match: f"{match.group(1)}display-name: {escaped_display_name}", + frontmatter, + count=1, + ) + elif re.search(r"(?m)^metadata\s*:\s*$", frontmatter): + next_frontmatter = re.sub( + r"(?m)^metadata\s*:\s*$", + f"metadata:\n display-name: {escaped_display_name}", + frontmatter, + count=1, + ) + else: + next_frontmatter = f"{frontmatter}\nmetadata:\n display-name: {escaped_display_name}" + + return f"---\n{next_frontmatter}\n---\n{content[match.end():]}" + def _sync_skill_md_text(self, skill: Skill, content: str) -> str: body = _FRONTMATTER_RE.sub("", content, count=1) metadata = self._parse_frontmatter(content) diff --git a/api/tests/unit_tests/services/test_skill_management_service.py b/api/tests/unit_tests/services/test_skill_management_service.py index d31dd45bb5e..be4eedc230a 100644 --- a/api/tests/unit_tests/services/test_skill_management_service.py +++ b/api/tests/unit_tests/services/test_skill_management_service.py @@ -13,6 +13,7 @@ from uuid import uuid4 import pytest from sqlalchemy import Table, delete, func, select +from sqlalchemy.exc import IntegrityError from core.db.session_factory import session_factory from core.tools.tool_file_manager import ToolFileManager @@ -491,6 +492,69 @@ def test_create_assistant_action_stream_strips_skill_frontmatter_from_reference_ assert reference["content"] == "# Refund Policy\n" +def test_create_assistant_action_stream_reports_skill_name_database_conflict() -> None: + service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) + created = service.create_skill( + tenant_id=TENANT, + user_id=USER, + payload=SkillCreatePayload(name="untitled-skill-1", description="Draft skill."), + ) + model_output = json.dumps( + { + "reply": "已创建用于客户问题分级处理的 skill 草案", + "operations": [ + { + "operation": "upsert_text", + "path": "SKILL.md", + "mime_type": "text/markdown", + "content": ( + "---\n" + "name: customer-issue-triage\n" + "description: Customer issue triage.\n" + "metadata:\n" + " display-name: Customer Issue Triage\n" + "---\n" + "# Customer Issue Triage\n" + ), + } + ], + } + ) + model = SimpleNamespace( + invoke_llm=lambda **_kwargs: SimpleNamespace( + message=SimpleNamespace(get_text_content=lambda: model_output), + ) + ) + manager = SimpleNamespace(get_default_model_instance=lambda **_kwargs: model) + integrity_error = IntegrityError( + "UPDATE skills", + {}, + Exception( + 'duplicate key value violates unique constraint "skill_tenant_name_unique"\n' + "DETAIL: Key (tenant_id, name)=(tenant, customer-issue-triage) already exists." + ), + ) + + with ( + patch("services.skill_management_service.ModelManager.for_tenant", return_value=manager), + patch.object(service, "apply_draft_file_operation", side_effect=integrity_error), + ): + response = list( + service.create_assistant_action_stream( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + message="创建客户问题分级处理 skill", + ) + ) + + events = [json.loads(chunk.removeprefix("data: ").strip()) for chunk in response] + assert [event["event"] for event in events] == ["message", "error"] + assert events[1]["code"] == "skill_name_conflict" + assert events[1]["message"] == 'Skill name "customer-issue-triage" already exists. Please choose a different name.' + assert events[1]["details"] == {"name": "customer-issue-triage"} + + def test_sync_assistant_model_config_updates_debugger_draft() -> None: openai_model = AgentSoulModelConfig( plugin_id="langgenius/openai", @@ -1564,6 +1628,187 @@ def test_apply_draft_file_operation_syncs_frontmatter_display_name_to_db() -> No assert updated["description"] == "Handle refund approvals." +def test_apply_draft_file_operation_generates_name_for_builder_created_skill() -> None: + service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) + created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload()) + + updated = service.apply_draft_file_operation( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + payload=SkillDraftFileOperationPayload( + operation="upsert_text", + path="SKILL.md", + content=( + "---\n" + f"name: {created['name']}\n" + "description: Classify and route customer issues.\n" + "metadata:\n" + " display-name: Customer Issue Tiered Handling\n" + "---\n" + "# Customer Issue Tiered Handling\n" + ), + ), + ) + + skill_md = next(item for item in updated["files"] if item["path"] == "SKILL.md") + assert updated["name"] == "customer-issue-tiered-handling" + assert updated["display_name"] == "Customer Issue Tiered Handling" + assert updated["name_manually_edited"] is False + assert "name: customer-issue-tiered-handling" in skill_md["content"] + assert f"name: {created['name']}" not in skill_md["content"] + + +def test_apply_draft_file_operation_prefers_builder_display_name_for_generated_name() -> None: + service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) + created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload()) + + updated = service.apply_draft_file_operation( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + payload=SkillDraftFileOperationPayload( + operation="upsert_text", + path="SKILL.md", + content=( + "---\n" + "name: customer-issue-triage\n" + "description: Classify and route customer issues.\n" + "metadata:\n" + " display-name: Customer Issue Tiered Handling\n" + "---\n" + "# Customer Issue Tiered Handling\n" + ), + ), + ) + + skill_md = next(item for item in updated["files"] if item["path"] == "SKILL.md") + assert updated["name"] == "customer-issue-tiered-handling" + assert updated["display_name"] == "Customer Issue Tiered Handling" + assert updated["name_manually_edited"] is False + assert "name: customer-issue-tiered-handling" in skill_md["content"] + assert "name: customer-issue-triage" not in skill_md["content"] + + +def test_apply_draft_file_operation_uses_builder_heading_when_display_name_is_placeholder() -> None: + service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) + created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload()) + + updated = service.apply_draft_file_operation( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + payload=SkillDraftFileOperationPayload( + operation="upsert_text", + path="SKILL.md", + content=( + "---\n" + f"name: {created['name']}\n" + "description: Classify and route customer issues.\n" + "metadata:\n" + " display-name: Untitled skill\n" + "---\n" + "# Customer Issue Tiered Handling\n" + ), + ), + ) + + skill_md = next(item for item in updated["files"] if item["path"] == "SKILL.md") + assert updated["name"] == "customer-issue-tiered-handling" + assert updated["display_name"] == "Customer Issue Tiered Handling" + assert updated["name_manually_edited"] is False + assert "name: customer-issue-tiered-handling" in skill_md["content"] + assert "display-name: Customer Issue Tiered Handling" in skill_md["content"] + assert f"name: {created['name']}" not in skill_md["content"] + + +def test_apply_draft_file_operation_keeps_auto_generated_name_in_sync_with_builder_display_name() -> None: + service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) + created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload()) + first_update = service.apply_draft_file_operation( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + payload=SkillDraftFileOperationPayload( + operation="upsert_text", + path="SKILL.md", + content=( + "---\n" + "name: customer-issue-triage\n" + "description: Classify customer issues.\n" + "metadata:\n" + " display-name: Customer Issue Triage\n" + "---\n" + "# Customer Issue Triage\n" + ), + ), + ) + assert first_update["name"] == "customer-issue-triage" + assert first_update["name_manually_edited"] is False + + updated = service.apply_draft_file_operation( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + payload=SkillDraftFileOperationPayload( + operation="upsert_text", + path="SKILL.md", + content=( + "---\n" + "name: customer-issue-triage\n" + "description: Classify and route customer issues.\n" + "metadata:\n" + " display-name: Customer Issue Tiered Handling\n" + "---\n" + "# Customer Issue Tiered Handling\n" + ), + ), + ) + + skill_md = next(item for item in updated["files"] if item["path"] == "SKILL.md") + assert updated["name"] == "customer-issue-tiered-handling" + assert updated["display_name"] == "Customer Issue Tiered Handling" + assert updated["name_manually_edited"] is False + assert "name: customer-issue-tiered-handling" in skill_md["content"] + assert "name: customer-issue-triage" not in skill_md["content"] + + +def test_apply_draft_file_operation_reports_builder_generated_name_conflict() -> None: + service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) + service.create_skill( + tenant_id=TENANT, + user_id=USER, + payload=SkillCreatePayload(name="customer-issue-tiered-handling"), + ) + created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload()) + + with pytest.raises(SkillManagementServiceError) as exc_info: + service.apply_draft_file_operation( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + payload=SkillDraftFileOperationPayload( + operation="upsert_text", + path="SKILL.md", + content=( + "---\n" + "name: customer-issue-triage\n" + "description: Classify and route customer issues.\n" + "metadata:\n" + " display-name: Customer Issue Tiered Handling\n" + "---\n" + "# Customer Issue Tiered Handling\n" + ), + ), + ) + + assert exc_info.value.code == "skill_name_conflict" + assert exc_info.value.details == {"name": "customer-issue-tiered-handling"} + assert exc_info.value.message == ( + 'Skill name "customer-issue-tiered-handling" already exists. Please choose a different name.' + ) + + def test_publish_syncs_frontmatter_display_name_from_existing_draft() -> None: service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload()) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/add-actions.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/add-actions.spec.tsx new file mode 100644 index 00000000000..4241969d93c --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/add-actions.spec.tsx @@ -0,0 +1,81 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { AgentOrchestrateAddActionsProvider } from '../add-actions' +import { + useAgentOrchestrateAddActions, + useRegisterAgentOrchestrateAddAction, +} from '../add-actions-context' +import { AgentOrchestrateViewingVersionContext } from '../read-only-context' + +function RegisteredActionProbe({ onRegister }: { onRegister: () => void }) { + useRegisterAgentOrchestrateAddAction('skills', onRegister) + return +} + +function ActionsProbe() { + const actions = useAgentOrchestrateAddActions() + + return
{actions.skills ? 'registered' : 'empty'}
+} + +function ToggleRegisteredActionProbe({ onRegister }: { onRegister: () => void }) { + const [visible, setVisible] = useState(true) + + return ( + <> + + {visible && } + {!visible && } + + ) +} + +describe('AgentOrchestrateAddActionsProvider', () => { + it('registers add actions for editable drafts', () => { + const action = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('registered')).toBeInTheDocument() + }) + + it('does not expose add actions while viewing a version', () => { + const action = vi.fn() + + render( + + + + + , + ) + + expect(screen.getByText('empty')).toBeInTheDocument() + }) + + it('unregisters add actions when the owning section unmounts', async () => { + const user = userEvent.setup() + const action = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('registered')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'remove action' })) + + await waitFor(() => { + expect(screen.getByText('empty')).toBeInTheDocument() + }) + }) +}) diff --git a/web/features/skills/__tests__/detail-page.spec.tsx b/web/features/skills/__tests__/detail-page.spec.tsx index eda3b9ca169..048f2fffc3c 100644 --- a/web/features/skills/__tests__/detail-page.spec.tsx +++ b/web/features/skills/__tests__/detail-page.spec.tsx @@ -9,6 +9,7 @@ import { detectPlatform } from '@tanstack/react-hotkeys' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import copy from 'copy-to-clipboard' import { beforeEach, describe, expect, it, vi } from 'vitest' import SkillDetailPage from '../detail-page' @@ -54,6 +55,10 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }, })) +vi.mock('copy-to-clipboard', () => ({ + default: vi.fn(), +})) + vi.mock('@/app/components/base/markdown', () => ({ Markdown: ({ content }: { content: string }) =>
{content}
, })) @@ -2290,6 +2295,46 @@ describe('SkillDetailPage', () => { ).toBeEnabled() }) + it('replaces optimistic Skill Builder replies when the assistant stream returns an error', async () => { + const user = userEvent.setup() + mocks.skillDetail = createDefaultSkillDraftDetail() + mocks.sendSkillAssistMessage.mockImplementation(({ onData, onError }) => { + onData?.('已创建用于客户问题分级处理的 skill 草案', true, { + messageId: 'assistant-message', + }) + onError?.( + 'the Skill Authoring assistant could not apply its response', + 'skill_assistant_failed', + ) + return Promise.resolve() + }) + + renderSkillDetailPage() + + await user.click( + await screen.findByRole('button', { + name: 'skill.skillManagement.detail.builder.exampleIssueTriage', + }), + ) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + 'the Skill Authoring assistant could not apply its response', + ) + }) + expect(toast.error).toHaveBeenCalledTimes(1) + const errorMessage = screen.getByText( + 'the Skill Authoring assistant could not apply its response', + ) + expect(errorMessage).toBeInTheDocument() + expect(errorMessage.closest('.rounded-xl')).toHaveClass( + 'border-state-destructive-border', + 'bg-state-destructive-hover', + 'text-text-destructive', + ) + expect(screen.queryByText('已创建用于客户问题分级处理的 skill 草案')).not.toBeInTheDocument() + }) + it('shows a publish confirmation for referenced skills before publishing updates', async () => { const user = userEvent.setup() mocks.skillDetail = createSkillDetail({ reference_count: 1 }) @@ -3870,7 +3915,7 @@ describe('SkillDetailPage', () => { ...primaryModifier, }) - expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.detail.copyFileSuccess') + expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.detail.copyContentSuccess') }) it('cuts the context-menu file with the displayed keyboard shortcut', async () => { @@ -3919,7 +3964,7 @@ describe('SkillDetailPage', () => { await user.click(getFileTreeButton('SKILL.md')) fireEvent.copy(getFileTreeButton('SKILL.md')) await user.click(screen.getByRole('button', { name: 'scripts' })) - fireEvent.paste(document) + fireEvent.paste(screen.getByTestId('skill-detail-sidebar')) await waitFor(() => { expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith( @@ -3934,6 +3979,58 @@ describe('SkillDetailPage', () => { }) }) + it('lets editable fields handle native copy even when page selection contains an empty draft marker', async () => { + const user = userEvent.setup() + const getSelectionSpy = vi.spyOn(window, 'getSelection').mockReturnValue({ + toString: () => '', + } as Selection) + try { + renderSkillDetailPage() + + await waitFor(() => { + expect(getFileTreeButton('SKILL.md')).toBeInTheDocument() + }) + await user.click(getFileTreeButton('SKILL.md')) + const builderInput = screen.getByPlaceholderText( + 'skill.skillManagement.detail.builder.modifyPlaceholder', + ) + fireEvent.copy(builderInput) + + expect(copy).not.toHaveBeenCalledWith('') + expect(toast.success).not.toHaveBeenCalledWith( + 'skill.skillManagement.detail.copyContentSuccess', + ) + expect(toast.success).not.toHaveBeenCalledWith('skill.skillManagement.detail.copyFileSuccess') + } finally { + getSelectionSpy.mockRestore() + } + }) + + it('does not let file-tree copy hotkeys override copying from the builder panel', async () => { + const user = userEvent.setup() + renderSkillDetailPage() + + await waitFor(() => { + expect(getFileTreeButton('SKILL.md')).toBeInTheDocument() + }) + await user.click(getFileTreeButton('SKILL.md')) + + const builderControl = screen.getByRole('button', { + name: 'skill.skillManagement.detail.builder.close', + }) + fireEvent.keyDown(builderControl, { + code: 'KeyC', + key: 'c', + ...primaryModifier, + }) + + expect(copy).not.toHaveBeenCalled() + expect(toast.success).not.toHaveBeenCalledWith( + 'skill.skillManagement.detail.copyContentSuccess', + ) + expect(toast.success).not.toHaveBeenCalledWith('skill.skillManagement.detail.copyFileSuccess') + }) + it('opens only the copied file after pasting it beside the source file', async () => { const user = userEvent.setup() const sourceFile = createSkillDetail().files![0]! @@ -3962,7 +4059,7 @@ describe('SkillDetailPage', () => { }) await user.click(getFileTreeButton('SKILL.md')) fireEvent.copy(getFileTreeButton('SKILL.md')) - fireEvent.paste(document) + fireEvent.paste(screen.getByTestId('skill-detail-sidebar')) await waitFor(() => { expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith( @@ -4036,7 +4133,7 @@ describe('SkillDetailPage', () => { }) await user.click(getFileTreeButton('SKILL.md')) fireEvent.copy(getFileTreeButton('SKILL.md')) - fireEvent.paste(document) + fireEvent.paste(screen.getByTestId('skill-detail-sidebar')) await waitFor(() => { expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(2) @@ -4116,7 +4213,7 @@ describe('SkillDetailPage', () => { await user.click(getFileTreeButton('alpha.md')) fireEvent.click(getFileTreeButton('beta.md'), primaryModifier) fireEvent.copy(getFileTreeButton('beta.md')) - fireEvent.paste(document) + fireEvent.paste(screen.getByTestId('skill-detail-sidebar')) await waitFor(() => { expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(3) diff --git a/web/features/skills/client.ts b/web/features/skills/client.ts index f04903c6799..5a548779d72 100644 --- a/web/features/skills/client.ts +++ b/web/features/skills/client.ts @@ -175,6 +175,7 @@ export function sendSkillAssistMessage({ skillId: string targetPath?: string }) { + let streamErrorHandled = false return ssePost( `/workspaces/current/skills/${encodeURIComponent(skillId)}/assist/messages`, { @@ -186,10 +187,24 @@ export function sendSkillAssistMessage({ }, }, { + silent: true, getAbortController, - onCompleted, - onData, - onError, + onCompleted: (hasError, errorMessage) => { + onCompleted?.(streamErrorHandled && hasError ? false : hasError, errorMessage) + }, + onData: (chunk, isFirstMessage, moreInfo) => { + if (moreInfo.errorMessage) { + streamErrorHandled = true + onError?.(moreInfo.errorMessage, moreInfo.errorCode) + return + } + + onData?.(chunk, isFirstMessage, moreInfo) + }, + onError: (errorMessage, errorCode) => { + streamErrorHandled = true + onError?.(errorMessage, errorCode) + }, onUnhandledEvent, }, ) diff --git a/web/features/skills/detail/__tests__/file-tree-drag-preview.spec.ts b/web/features/skills/detail/__tests__/file-tree-drag-preview.spec.ts new file mode 100644 index 00000000000..31bc6a6448e --- /dev/null +++ b/web/features/skills/detail/__tests__/file-tree-drag-preview.spec.ts @@ -0,0 +1,59 @@ +import type { DragEvent } from 'react' +import { setSkillFileDragPreview } from '../file-tree-drag-preview' + +function createDragEvent(setDragImage: (element: Element, x: number, y: number) => void) { + return { + dataTransfer: { + setDragImage, + }, + } as unknown as DragEvent +} + +describe('setSkillFileDragPreview', () => { + it('renders a named preview for a single dragged file', () => { + const setDragImage = vi.fn() + + setSkillFileDragPreview(createDragEvent(setDragImage), { + count: 1, + iconClassName: 'i-ri-markdown-line', + name: 'SKILL.md', + }) + + const preview = setDragImage.mock.calls[0]?.[0] as HTMLElement + expect(preview).toHaveTextContent('SKILL.md') + expect(preview.querySelector('[aria-hidden="true"]')).toHaveClass('i-ri-markdown-line') + expect(setDragImage).toHaveBeenCalledWith(preview, 10, 12) + }) + + it('renders an item count preview for multiple dragged files', () => { + const setDragImage = vi.fn() + + setSkillFileDragPreview(createDragEvent(setDragImage), { + count: 3, + iconClassName: 'i-ri-markdown-line', + name: 'SKILL.md', + }) + + const preview = setDragImage.mock.calls[0]?.[0] as HTMLElement + expect(preview).toHaveTextContent('3 items') + expect(preview).not.toHaveTextContent('SKILL.md') + expect(setDragImage).toHaveBeenCalledWith(preview, 10, 12) + }) + + it('skips preview creation when dataTransfer cannot set a drag image', () => { + const initialChildCount = document.body.childElementCount + + setSkillFileDragPreview( + { + dataTransfer: {}, + } as unknown as DragEvent, + { + count: 1, + iconClassName: 'i-ri-markdown-line', + name: 'SKILL.md', + }, + ) + + expect(document.body.childElementCount).toBe(initialChildCount) + }) +}) diff --git a/web/features/skills/detail/__tests__/file-tree-items.spec.tsx b/web/features/skills/detail/__tests__/file-tree-items.spec.tsx new file mode 100644 index 00000000000..858d71618e3 --- /dev/null +++ b/web/features/skills/detail/__tests__/file-tree-items.spec.tsx @@ -0,0 +1,251 @@ +import type { + SkillDetailResponse, + SkillFileResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' +import type { FileTreeNode } from '../shared' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { FileTreeItem, FileTreeNameInput } from '../file-tree-items' + +const skillFile: SkillFileResponse = { + id: 'file-1', + path: 'scripts/example.ts', + kind: 'file', + storage: 'text', + mime_type: 'text/typescript', + content: 'export {}\n', + tool_file_id: null, + size: 10, + hash: 'hash-1', +} + +const skillDetail = { + id: 'skill-1', +} as SkillDetailResponse + +function createFileNode(overrides: Partial = {}): FileTreeNode { + return { + file: skillFile, + id: 'node-1', + name: 'example.ts', + path: 'scripts/example.ts', + type: 'file', + ...overrides, + } +} + +function createFolderNode(overrides: Partial = {}): FileTreeNode { + return { + children: [createFileNode()], + id: 'folder-1', + name: 'scripts', + path: 'scripts', + type: 'directory', + ...overrides, + } +} + +function renderFileTreeItem( + overrides: Partial[0]> = {}, + node: FileTreeNode = createFileNode(), +) { + const props: Parameters[0] = { + collapsedFolderPaths: [], + detail: skillDetail, + draggingPaths: [], + dropTarget: undefined, + inlineAction: undefined, + inlineActionLoading: false, + node, + onCancelInlineAction: vi.fn(), + onCopy: vi.fn(), + onCreate: vi.fn(), + onCut: vi.fn(), + onDelete: vi.fn(), + onDropFiles: vi.fn(), + onExpandFolder: vi.fn(), + onItemSelect: vi.fn(), + onMove: vi.fn(), + onRename: vi.fn(), + onSelect: vi.fn(), + onSetDraggingPaths: vi.fn(), + onSetDropTarget: vi.fn(), + onSubmitInlineAction: vi.fn(), + onToggleFolder: vi.fn(), + onUploadFiles: vi.fn(), + readonly: false, + selectedPath: undefined, + selectedPaths: [], + ...overrides, + } + + return { + ...render(), + props, + } +} + +describe('FileTreeItem', () => { + it('submits and cancels inline file names from the keyboard', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + const onCancel = vi.fn() + render( + , + ) + + const input = screen.getByPlaceholderText('File name') + await user.type(input, ' guide.md {Enter}') + await user.keyboard('{Enter}') + + expect(onSubmit).toHaveBeenCalledOnce() + expect(onSubmit).toHaveBeenCalledWith('guide.md') + expect(onCancel).not.toHaveBeenCalled() + }) + + it('selects only the basename when renaming an existing file', async () => { + render( + , + ) + + const input = screen.getByDisplayValue('example.ts') as HTMLInputElement + expect(input.selectionStart).toBe(0) + expect(input.selectionEnd).toBe('example'.length) + }) + + it('cancels an empty inline folder name on blur', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + const onCancel = vi.fn() + render( + , + ) + + await user.click(screen.getByPlaceholderText('Folder name')) + await user.tab() + + expect(onCancel).toHaveBeenCalledOnce() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('selects and pins file nodes from the file button', async () => { + const user = userEvent.setup() + const { props } = renderFileTreeItem() + + const button = screen.getByRole('button', { name: 'example.ts' }) + await user.click(button) + await user.dblClick(button) + + expect(props.onItemSelect).toHaveBeenCalledWith(expect.anything(), expect.anything()) + expect(props.onSelect).toHaveBeenCalledWith('scripts/example.ts', 'preview') + expect(props.onSelect).toHaveBeenCalledWith('scripts/example.ts', 'pinned') + }) + + it('dispatches file action menu commands', async () => { + const user = userEvent.setup() + const { props } = renderFileTreeItem({}, createFileNode({ name: 'example.ts' })) + + const treeItem = screen.getByText('example.ts').closest('[data-skill-file-tree-item]') + expect(treeItem).toBeInstanceOf(HTMLElement) + + await user.click( + within(treeItem as HTMLElement).getByRole('button', { name: 'common.operation.more' }), + ) + await user.click(await screen.findByText('skill.skillManagement.detail.copyFile')) + await user.click( + within(treeItem as HTMLElement).getByRole('button', { name: 'common.operation.more' }), + ) + await user.click(await screen.findByText('skill.skillManagement.detail.cutFile')) + await user.click( + within(treeItem as HTMLElement).getByRole('button', { name: 'common.operation.more' }), + ) + await user.click(await screen.findByText(/common.operation.rename/)) + await user.click( + within(treeItem as HTMLElement).getByRole('button', { name: 'common.operation.more' }), + ) + await user.click(await screen.findByText('common.operation.delete')) + + expect(props.onCopy).toHaveBeenCalledWith('scripts/example.ts') + expect(props.onCut).toHaveBeenCalledWith('scripts/example.ts') + expect(props.onRename).toHaveBeenCalledWith( + expect.objectContaining({ path: 'scripts/example.ts' }), + ) + expect(props.onDelete).toHaveBeenCalledWith( + expect.objectContaining({ path: 'scripts/example.ts' }), + ) + }) + + it('dispatches folder action menu commands and toggles folders', async () => { + const user = userEvent.setup() + const folderNode = createFolderNode() + const { props } = renderFileTreeItem({}, folderNode) + + const folder = screen.getByText('scripts').closest('[data-skill-file-tree-item]') + expect(folder).toBeInstanceOf(HTMLElement) + await user.dblClick(folder as HTMLElement) + expect(props.onToggleFolder).toHaveBeenCalledWith('scripts') + + await user.click( + within(folder as HTMLElement).getByRole('button', { name: 'common.operation.more' }), + ) + await user.click(await screen.findByText('skill.skillManagement.detail.createFileMenu')) + await user.click( + within(folder as HTMLElement).getByRole('button', { name: 'common.operation.more' }), + ) + await user.click(await screen.findByText('skill.skillManagement.detail.createFolderMenu')) + await user.click( + within(folder as HTMLElement).getByRole('button', { name: 'common.operation.more' }), + ) + await user.click(await screen.findByText('skill.skillManagement.detail.uploadFilesMenu')) + + expect(props.onCreate).toHaveBeenCalledWith('file', 'scripts') + expect(props.onCreate).toHaveBeenCalledWith('directory', 'scripts') + }) + + it('renders a rename input for the active inline action', async () => { + const user = userEvent.setup() + const onSubmitInlineAction = vi.fn() + renderFileTreeItem({ + inlineAction: { + kind: 'rename', + nodeType: 'file', + path: 'scripts/example.ts', + }, + onSubmitInlineAction, + }) + + const input = screen.getByDisplayValue('example.ts') + await user.clear(input) + await user.type(input, 'renamed.ts{Enter}') + + expect(onSubmitInlineAction).toHaveBeenCalledWith('renamed.ts') + }) + + it('hides file action controls in read-only mode', () => { + renderFileTreeItem({ readonly: true }) + + expect(screen.getByRole('button', { name: 'example.ts' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.more' })).not.toBeInTheDocument() + }) +}) diff --git a/web/features/skills/detail/__tests__/shared.spec.ts b/web/features/skills/detail/__tests__/shared.spec.ts new file mode 100644 index 00000000000..5097febbd9c --- /dev/null +++ b/web/features/skills/detail/__tests__/shared.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createUploadItemId, + getErrorCode, + getErrorDetailNumber, + getErrorDetailString, + getUploadFileName, + getUploadPath, + isEditableKeyboardTarget, + joinSkillPath, +} from '../shared' + +describe('skill detail shared utilities', () => { + it('reads error codes and details from supported error shapes', () => { + expect(getErrorCode({ code: 'direct' })).toBe('direct') + expect(getErrorCode({ data: { code: 'data' } })).toBe('data') + expect(getErrorCode({ body: { code: 'body' } })).toBe('body') + expect(getErrorCode('error')).toBeUndefined() + + expect( + getErrorDetailNumber({ details: { current_updated_at: 12 } }, 'current_updated_at'), + ).toBe(12) + expect( + getErrorDetailNumber({ data: { details: { current_updated_at: 13 } } }, 'current_updated_at'), + ).toBe(13) + expect( + getErrorDetailString( + { body: { details: { current_file_hash: 'hash' } } }, + 'current_file_hash', + ), + ).toBe('hash') + expect( + getErrorDetailString({ details: { current_file_hash: 1 } }, 'current_file_hash'), + ).toBeUndefined() + }) + + it('normalizes upload paths and names', () => { + const plainFile = new File(['guide'], 'guide.md', { type: 'text/markdown' }) + const nestedFile = new File(['guide'], 'guide.md', { type: 'text/markdown' }) + Object.defineProperty(nestedFile, 'webkitRelativePath', { + configurable: true, + value: 'folder/guide.md', + }) + + expect(joinSkillPath(undefined, '/guide.md')).toBe('guide.md') + expect(joinSkillPath('/references/', '/guide.md')).toBe('references/guide.md') + expect(getUploadPath(plainFile, 'references')).toBe('references/guide.md') + expect(getUploadPath(nestedFile, 'references')).toBe('references/folder/guide.md') + expect(getUploadFileName(nestedFile)).toBe('folder/guide.md') + }) + + it('detects editable keyboard targets', () => { + const input = document.createElement('input') + const textarea = document.createElement('textarea') + const select = document.createElement('select') + const editor = document.createElement('div') + editor.contentEditable = 'true' + const nested = document.createElement('span') + editor.appendChild(nested) + const plain = document.createElement('button') + + expect(isEditableKeyboardTarget(input)).toBe(true) + expect(isEditableKeyboardTarget(textarea)).toBe(true) + expect(isEditableKeyboardTarget(select)).toBe(true) + expect(isEditableKeyboardTarget(editor)).toBe(true) + expect(isEditableKeyboardTarget(nested)).toBe(true) + expect(isEditableKeyboardTarget(plain)).toBe(false) + expect(isEditableKeyboardTarget(null)).toBe(false) + }) + + it('falls back to a deterministic upload item id when randomUUID is unavailable', () => { + const originalCrypto = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: {}, + }) + try { + const file = new File(['guide'], 'guide.md', { type: 'text/markdown' }) + vi.spyOn(file, 'lastModified', 'get').mockReturnValue(123) + + expect(createUploadItemId(file, 2)).toBe('guide.md-5-123-2') + } finally { + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: originalCrypto, + }) + } + }) +}) diff --git a/web/features/skills/detail/__tests__/shell.spec.tsx b/web/features/skills/detail/__tests__/shell.spec.tsx new file mode 100644 index 00000000000..a2f409adaec --- /dev/null +++ b/web/features/skills/detail/__tests__/shell.spec.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { DetailSkeleton, SkillDetailRightPanelRail } from '../shell' + +describe('Skill detail shell', () => { + it('opens right-panel tools from the rail', async () => { + const user = userEvent.setup() + const onOpenBuilder = vi.fn() + const onOpenVersions = vi.fn() + + render( + , + ) + + await user.click( + screen.getByRole('button', { name: 'skill.skillManagement.detail.builder.open' }), + ) + await user.click( + screen.getByRole('button', { name: 'skill.skillManagement.detail.versionHistory' }), + ) + + expect(onOpenBuilder).toHaveBeenCalledOnce() + expect(onOpenVersions).toHaveBeenCalledOnce() + }) + + it('renders the loading skeleton layout', () => { + const { container } = render() + + expect(container.firstChild).toHaveClass('flex', 'h-0', 'grow') + expect(container.querySelectorAll('.opacity-20')).toHaveLength(2) + expect(container.querySelectorAll('.opacity-10')).toHaveLength(3) + }) +}) diff --git a/web/features/skills/detail/builder-panel.tsx b/web/features/skills/detail/builder-panel.tsx index b0c341c1bc7..35f8ac24da9 100644 --- a/web/features/skills/detail/builder-panel.tsx +++ b/web/features/skills/detail/builder-panel.tsx @@ -241,6 +241,21 @@ export function SkillBuilderPanel({ return nextMessages }) } + const replaceAssistantMessageWithError = (assistantMessageId: string, errorMessage: string) => { + rawAssistantMessagesRef.current.set(assistantMessageId, errorMessage) + updateMessages((currentMessages) => + currentMessages.map((message) => + message.id === assistantMessageId + ? { + ...message, + content: errorMessage, + rawContent: errorMessage, + tone: 'error', + } + : message, + ), + ) + } useEffect(() => { detailRef.current = detail @@ -463,7 +478,10 @@ export function SkillBuilderPanel({ thinkingElapsedSecondsRef.current = 0 isSendingRef.current = false assistAbortControllerRef.current = null - if (hasError && errorMessage) toast.error(errorMessage) + if (hasError && errorMessage) { + replaceAssistantMessageWithError(assistantMessageId, errorMessage) + toast.error(errorMessage) + } }, onError: (errorMessage) => { const thinkingDurationSeconds = thinkingElapsedSecondsRef.current @@ -477,7 +495,10 @@ export function SkillBuilderPanel({ thinkingElapsedSecondsRef.current = 0 isSendingRef.current = false assistAbortControllerRef.current = null - if (errorMessage) toast.error(errorMessage) + if (errorMessage) { + replaceAssistantMessageWithError(assistantMessageId, errorMessage) + toast.error(errorMessage) + } }, }).catch((error: unknown) => { const thinkingDurationSeconds = thinkingElapsedSecondsRef.current @@ -578,7 +599,11 @@ export function SkillBuilderPanel({ ) : (
{message.thinkingDurationSeconds !== undefined && ( (null) const referencesRegionRef = useRef(null) const uploadInputRef = useRef(null) const [inlineAction, setInlineAction] = useState() @@ -687,6 +689,13 @@ export function FileTree({ if (filePaths.length === 0) return setClipboard({ mode: 'copy', paths: filePaths }) + const copiedFile = filePaths.length === 1 ? findFileByPath(files, filePaths[0]) : undefined + if (copiedFile && typeof copiedFile.content === 'string') { + copy(copiedFile.content) + toast.success(t(($) => $['skillManagement.detail.copyContentSuccess'])) + return + } + toast.success(t(($) => $['skillManagement.detail.copyFileSuccess'])) } @@ -873,6 +882,8 @@ export function FileTree({ const shortcutTargetPath = selectedPaths[0] ?? selectedPath const fileShortcutEnabled = !readonly && !!shortcutTargetPath && !fileMutation.isPending && !inlineAction + const isInSidebar = (target: EventTarget | null) => + target instanceof Node && !!sidebarRef.current?.contains(target) const handleOpenMenuHotkey = useEffectEvent((event: globalThis.KeyboardEvent) => { if (readonly || fileMutation.isPending || inlineAction) return if (!(event.target instanceof Element) || !event.target.closest('[role="menu"]')) return @@ -916,35 +927,40 @@ export function FileTree({ skillFileHotkeys.cut.command, (event) => { if (!shortcutTargetPath) return + if (!isInSidebar(event.target)) return event.preventDefault() + event.stopPropagation() handleCut(shortcutTargetPath) }, { enabled: fileShortcutEnabled, ignoreInputs: true, - preventDefault: true, - stopPropagation: true, + preventDefault: false, + stopPropagation: false, }, ) useHotkey( skillFileHotkeys.copy.command, (event) => { if (!shortcutTargetPath) return + if (!isInSidebar(event.target)) return event.preventDefault() + event.stopPropagation() handleCopy(shortcutTargetPath) }, { enabled: fileShortcutEnabled, ignoreInputs: true, - preventDefault: true, - stopPropagation: true, + preventDefault: false, + stopPropagation: false, }, ) const handleNativeCopy = useEffectEvent((event: ClipboardEvent) => { if (!fileShortcutEnabled || !shortcutTargetPath) return + if (!isInSidebar(event.target)) return if (isEditableKeyboardTarget(event.target)) return event.preventDefault() @@ -952,6 +968,7 @@ export function FileTree({ }) const handleNativeCut = useEffectEvent((event: ClipboardEvent) => { if (!fileShortcutEnabled || !shortcutTargetPath) return + if (!isInSidebar(event.target)) return if (isEditableKeyboardTarget(event.target)) return event.preventDefault() @@ -970,6 +987,7 @@ export function FileTree({ useEffect(() => { const handlePasteEvent = (event: ClipboardEvent) => { if (readonly || !clipboard || fileMutation.isPending) return + if (!isInSidebar(event.target)) return if (isEditableKeyboardTarget(event.target)) return event.preventDefault() @@ -1058,6 +1076,7 @@ export function FileTree({ return ( <>