Skip to content

feat: add hierarchical prompt tree relationships#173

Merged
legeling merged 4 commits into
mainfrom
codex/hierarchical-pr-review-20260614105632
Jun 14, 2026
Merged

feat: add hierarchical prompt tree relationships#173
legeling merged 4 commits into
mainfrom
codex/hierarchical-pr-review-20260614105632

Conversation

@legeling

@legeling legeling commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Summary

  • Merge and preserve @jazzson51569's feature/hierarchical-latest contribution for hierarchical prompt drag/drop and keyboard indentation.
  • Harden the V1 hierarchy as logical grouped_under relationships: no child prompt cascade delete, no self-parenting, no descendant cycles, and contiguous sibling order rewrites.
  • Add existing-user SQLite migration for prompts.parent_id / prompts.sort_order, plus IPC and IndexedDB fallback validation.
  • Replace duplicate list rendering with a single tree list and document the prompt relationship direction, including future related_to, variant_of, depends_on, and next_step relation kinds.

Verification

  • pnpm --dir apps/desktop exec vitest run tests/unit/main/prompt-db.test.ts tests/unit/main/database-migration-locks.test.ts
  • pnpm --filter @prompthub/desktop typecheck
  • pnpm --filter @prompthub/desktop lint
  • pnpm --filter @prompthub/desktop build
  • git diff --check

Notes

A mistaken broad pnpm --filter @prompthub/desktop test:run -- ... invocation ran the wider desktop suite and surfaced two existing failures in tests/integration/components/skill-ui.integration.test.tsx; the targeted prompt hierarchy and migration tests pass.

Summary by CodeRabbit

发布说明

  • 新功能

    • 提示词现已支持层级关系,可通过拖拽或键盘快捷键(Tab/Shift+Tab)组织和管理提示词的层级结构。
    • 列表视图升级为树形展示,支持展开/折叠父级提示词以便更好地浏览和编辑。
    • 增强层级管理能力,可将提示词灵活移动到不同父级或调整同级排序。
  • 测试

    • 新增数据库迁移和层级操作的回归测试覆盖。

@vercel

vercel Bot commented Jun 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
prompt-hub Ready Ready Preview, Comment Jun 14, 2026 3:14am

@qodo-code-review

qodo-code-review Bot commented Jun 14, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (5)

Grey Divider


Action required

1. Inline style in PromptListView 📘 Rule violation ⚙ Maintainability 8.6
Description
PromptListView uses an inline style={{ paddingLeft: ... }} to render hierarchy indentation. This
violates the Tailwind-only styling rule and can lead to inconsistent theming and styling drift.
Code

apps/desktop/src/renderer/components/prompt/PromptListView.tsx[407]

+            style={{ paddingLeft: `${depth * 16 + 12}px` }}
Evidence
PR Compliance ID 14 prohibits inline styles. The added tree row uses an inline style prop to set
indentation.

Rule 8.6: UI/component rules: Tailwind-only styling, Lucide icons, accessibility, and dark mode tokens
apps/desktop/src/renderer/components/prompt/PromptListView.tsx[370-408]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PromptListView` uses inline `style` for indentation (`paddingLeft`), but UI styling must be Tailwind-only.

## Issue Context
Indentation is currently calculated from `depth` and applied via inline style. Replace it with a Tailwind-only approach (e.g., render `depth` spacer elements with fixed Tailwind widths like `w-4` / `shrink-0`).

## Fix Focus Areas
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[370-409]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Hardcoded strings in PromptListView 📘 Rule violation ⚙ Maintainability 8.3
Description
PromptListView includes hardcoded Chinese fallbacks (昨天, 天前, 添加收藏) and hardcoded English
ARIA labels (Collapse prompt/Expand prompt). This violates i18n requirements and prevents full
localization across supported locales.
Code

apps/desktop/src/renderer/components/prompt/PromptListView.tsx[R209-500]

+      return date.toLocaleTimeString(undefined, {
+        hour: '2-digit',
+        minute: '2-digit',
+      });
+    }
+
+    if (diffDays === 1) {
      return t('common.yesterday') || '昨天';
-    } else if (diffDays < 7) {
+    }
+
+    if (diffDays < 7) {
      return `${diffDays}${t('common.daysAgo') || '天前'}`;
-    } else {
-      return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
    }
+
+    return date.toLocaleDateString(undefined, {
+      month: 'short',
+      day: 'numeric',
+    });
  };

-  return (
-    <div className="flex flex-col">
-      {prompts.map((prompt) => (
-        <div
-          key={prompt.id}
-          onClick={() => onSelect(prompt.id)}
-          onContextMenu={(e) => onContextMenu(e, prompt)}
-          className={`
-            flex items-center gap-3 px-3 py-2.5 border-b border-border/50 cursor-pointer
-            transition-colors duration-quick
-            ${selectedId === prompt.id
-              ? 'bg-primary/10 border-l-2 border-l-primary'
-              : 'hover:bg-accent/50'
-            }
-          `}
-        >
-          {/* Title and description */}
-          {/* 标题和描述 */}
-          <div className="flex-1 min-w-0">
-            <div className="flex items-center gap-2">
-              <h3
-                className={`font-medium text-sm leading-snug break-words line-clamp-2 ${
-                  selectedId === prompt.id ? 'text-primary' : 'text-foreground'
-                }`}
-                title={prompt.title}
-              >
-                {prompt.title}
-              </h3>
-              {prompt.isFavorite && (
-                <StarIcon className="w-3 h-3 flex-shrink-0 fill-yellow-400 text-yellow-400" />
+  const toggleExpand = useCallback((promptId: string) => {
+    setExpandedIds((current) => {
+      const next = new Set(current);
+      if (next.has(promptId)) {
+        next.delete(promptId);
+      } else {
+        next.add(promptId);
+      }
+      return next;
+    });
+  }, []);
+
+  const handleDragStart = useCallback(
+    (event: ReactDragEvent, promptId: string) => {
+      setDraggingId(promptId);
+      event.dataTransfer.effectAllowed = 'move';
+      event.dataTransfer.setData('text/plain', promptId);
+    },
+    [],
+  );
+
+  const handleDragEnd = useCallback(() => {
+    setDraggingId(null);
+    setDropTargetId(null);
+    setDropPosition(null);
+  }, []);
+
+  const updateDropTarget = useCallback(
+    (event: ReactDragEvent<HTMLElement>, targetPrompt: Prompt) => {
+      event.preventDefault();
+      event.dataTransfer.dropEffect = 'move';
+
+      if (!draggingId || draggingId === targetPrompt.id) {
+        setDropTargetId(null);
+        setDropPosition(null);
+        return;
+      }
+
+      const nextDropPosition = getDropPosition(event);
+      const nextParentId =
+        nextDropPosition === 'inside'
+          ? targetPrompt.id
+          : getVisibleParentId(targetPrompt);
+
+      if (!canMoveToParent(draggingId, nextParentId)) {
+        setDropTargetId(null);
+        setDropPosition(null);
+        return;
+      }
+
+      setDropTargetId(targetPrompt.id);
+      setDropPosition(nextDropPosition);
+    },
+    [canMoveToParent, draggingId, getVisibleParentId],
+  );
+
+  const handleDragLeave = useCallback((event: ReactDragEvent<HTMLElement>) => {
+    const nextTarget = event.relatedTarget;
+    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
+      return;
+    }
+
+    setDropTargetId(null);
+    setDropPosition(null);
+  }, []);
+
+  const handleDrop = useCallback(
+    (event: ReactDragEvent, targetPrompt: Prompt) => {
+      event.preventDefault();
+
+      if (!draggingId || draggingId === targetPrompt.id || !dropPosition) {
+        handleDragEnd();
+        return;
+      }
+
+      if (dropPosition === 'inside') {
+        if (canMoveToParent(draggingId, targetPrompt.id)) {
+          setExpandedIds((current) => new Set(current).add(targetPrompt.id));
+          onMovePrompt(draggingId, targetPrompt.id, getChildren(targetPrompt.id).length);
+        }
+        handleDragEnd();
+        return;
+      }
+
+      const nextParentId = getVisibleParentId(targetPrompt);
+      if (!canMoveToParent(draggingId, nextParentId)) {
+        handleDragEnd();
+        return;
+      }
+
+      const targetSiblings = getChildren(nextParentId).filter(
+        (prompt) => prompt.id !== draggingId,
+      );
+      const targetIndex = targetSiblings.findIndex(
+        (prompt) => prompt.id === targetPrompt.id,
+      );
+      const nextOrder =
+        targetIndex < 0
+          ? targetSiblings.length
+          : targetIndex + (dropPosition === 'after' ? 1 : 0);
+
+      onMovePrompt(draggingId, nextParentId, nextOrder);
+      handleDragEnd();
+    },
+    [
+      canMoveToParent,
+      draggingId,
+      dropPosition,
+      getChildren,
+      getVisibleParentId,
+      handleDragEnd,
+      onMovePrompt,
+    ],
+  );
+
+  const isSelected = useCallback(
+    (promptId: string) => selectedId === promptId || selectedIds.includes(promptId),
+    [selectedId, selectedIds],
+  );
+
+  const isDragging = useCallback(
+    (promptId: string) => draggingId === promptId,
+    [draggingId],
+  );
+
+  const isDropTarget = useCallback(
+    (promptId: string) => dropTargetId === promptId,
+    [dropTargetId],
+  );
+
+  const renderTreeNode = useCallback(
+    (prompt: Prompt, depth: number, ancestors: Set<string>) => {
+      const nextAncestors = new Set(ancestors).add(prompt.id);
+      const children = getChildren(prompt.id).filter(
+        (child) => !nextAncestors.has(child.id),
+      );
+      const hasKids = children.length > 0;
+      const isExpanded = expandedIds.has(prompt.id);
+
+      return (
+        <div key={prompt.id}>
+          <div
+            draggable
+            onDragStart={(event) => handleDragStart(event, prompt.id)}
+            onDragEnd={handleDragEnd}
+            onDragOver={(event) => updateDropTarget(event, prompt)}
+            onDragEnter={(event) => updateDropTarget(event, prompt)}
+            onDragLeave={handleDragLeave}
+            onDrop={(event) => handleDrop(event, prompt)}
+            onClick={() => onSelect(prompt.id)}
+            onContextMenu={(event) => onContextMenu(event, prompt)}
+            className={`
+              flex items-center gap-3 px-3 py-2.5 border-b border-border/50 cursor-pointer
+              transition-colors duration-quick relative
+              ${
+                isSelected(prompt.id)
+                  ? 'bg-primary/10 border-l-2 border-l-primary'
+                  : isDropTarget(prompt.id) && dropPosition === 'inside'
+                    ? 'bg-primary/20 border-l-2 border-l-primary'
+                    : 'hover:bg-accent/50'
+              }
+              ${isDragging(prompt.id) ? 'opacity-50' : ''}
+              ${
+                isDropTarget(prompt.id) && dropPosition === 'inside'
+                  ? 'ring-2 ring-primary/50 ring-inset'
+                  : ''
+              }
+              ${
+                isDropTarget(prompt.id) && dropPosition === 'before'
+                  ? 'border-t-2 border-t-primary'
+                  : ''
+              }
+              ${
+                isDropTarget(prompt.id) && dropPosition === 'after'
+                  ? 'border-b-2 border-b-primary'
+                  : ''
+              }
+            `}
+            style={{ paddingLeft: `${depth * 16 + 12}px` }}
+          >
+            <div className="flex items-center gap-1">
+              {hasKids ? (
+                <button
+                  type="button"
+                  onClick={(event) => {
+                    event.stopPropagation();
+                    toggleExpand(prompt.id);
+                  }}
+                  className="p-0.5 rounded hover:bg-accent transition-colors"
+                  aria-label={isExpanded ? 'Collapse prompt' : 'Expand prompt'}
+                >
+                  {isExpanded ? (
+                    <ChevronDownIcon className="w-4 h-4 text-muted-foreground" />
+                  ) : (
+                    <ChevronRightIcon className="w-4 h-4 text-muted-foreground" />
+                  )}
+                </button>
+              ) : (
+                <span className="w-5" />
              )}
+              <GripVerticalIcon className="w-4 h-4 text-muted-foreground cursor-grab opacity-0 hover:opacity-100 transition-opacity" />
            </div>
-            {prompt.description && (
-              <p className="text-xs text-muted-foreground line-clamp-2 break-words mt-0.5">
-                {prompt.description}
-              </p>
-            )}
-            {prompt.images && prompt.images.length > 0 && (
-              <div className="flex items-center gap-1 mt-0.5">
-                <ImageIcon className="w-3 h-3 text-muted-foreground" />
-                <span className="text-xs text-muted-foreground">{prompt.images.length}</span>
+
+            <div className="flex-1 min-w-0">
+              <div className="flex items-center gap-2">
+                <h3
+                  className={`font-medium text-sm leading-snug break-words line-clamp-2 ${
+                    isSelected(prompt.id) ? 'text-primary' : 'text-foreground'
+                  }`}
+                  title={prompt.title}
+                >
+                  {prompt.title}
+                </h3>
+                {prompt.isFavorite && (
+                  <StarIcon className="w-3 h-3 flex-shrink-0 fill-yellow-400 text-yellow-400" />
+                )}
              </div>
-            )}
-          </div>
+              {prompt.description && (
+                <p className="text-xs text-muted-foreground line-clamp-2 break-words mt-0.5">
+                  {prompt.description}
+                </p>
+              )}
+              {prompt.images && prompt.images.length > 0 && (
+                <div className="flex items-center gap-1 mt-0.5">
+                  <ImageIcon className="w-3 h-3 text-muted-foreground" />
+                  <span className="text-xs text-muted-foreground">
+                    {prompt.images.length}
+                  </span>
+                </div>
+              )}
+            </div>

-          {/* Usage count */}
-          {/* 使用次数 */}
-          <div className="flex-shrink-0 w-12 text-center">
-            <span className="text-xs text-muted-foreground">
-              {prompt.usageCount || 0}
-            </span>
-          </div>
+            <div className="flex-shrink-0 w-12 text-center">
+              <span className="text-xs text-muted-foreground">
+                {prompt.usageCount || 0}
+              </span>
+            </div>

-          {/* Update time */}
-          {/* 更新时间 */}
-          <div className="flex-shrink-0 w-16 text-right">
-            <span className="text-xs text-muted-foreground">
-              {formatDate(prompt.updatedAt)}
-            </span>
-          </div>
+            <div className="flex-shrink-0 w-16 text-right">
+              <span className="text-xs text-muted-foreground">
+                {formatDate(prompt.updatedAt)}
+              </span>
+            </div>

-          {/* Action buttons */}
-          {/* 操作按钮 */}
-          <div className="flex items-center gap-1 flex-shrink-0">
-            <button
-              onClick={(e) => {
-                e.stopPropagation();
-                onCopy(prompt);
-              }}
-              className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
-              title={t('prompt.copy')}
-            >
-              <CopyIcon className="w-3.5 h-3.5" />
-            </button>
-            <button
-              onClick={(e) => {
-                e.stopPropagation();
-                onToggleFavorite(prompt.id);
-              }}
-              className={`p-1.5 rounded-md transition-colors ${prompt.isFavorite
-                ? 'text-yellow-500 hover:bg-yellow-500/10'
-                : 'text-muted-foreground hover:text-foreground hover:bg-accent'
+            <div className="flex items-center gap-1 flex-shrink-0">
+              <button
+                type="button"
+                onClick={(event) => {
+                  event.stopPropagation();
+                  onCopy(prompt);
+                }}
+                className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
+                title={t('prompt.copy')}
+              >
+                <CopyIcon className="w-3.5 h-3.5" />
+              </button>
+              <button
+                type="button"
+                onClick={(event) => {
+                  event.stopPropagation();
+                  onToggleFavorite(prompt.id);
+                }}
+                className={`p-1.5 rounded-md transition-colors ${
+                  prompt.isFavorite
+                    ? 'text-yellow-500 hover:bg-yellow-500/10'
+                    : 'text-muted-foreground hover:text-foreground hover:bg-accent'
                }`}
-              title={prompt.isFavorite ? t('nav.favorites') : t('prompt.addToFavorites') || '添加收藏'}
-            >
-              <StarIcon className={`w-3.5 h-3.5 ${prompt.isFavorite ? 'fill-current' : ''}`} />
-            </button>
+                title={
+                  prompt.isFavorite
+                    ? t('nav.favorites')
+                    : t('prompt.addToFavorites') || '添加收藏'
+                }
Evidence
PR Compliance ID 10 forbids hardcoded user-facing strings and hardcoded Chinese outside locale JSON.
The added UI includes Chinese fallback literals and non-i18n ARIA labels/titles.

Rule 8.3: Internationalization: no hardcoded user-facing strings; update all locales; follow key/interpolation rules
apps/desktop/src/renderer/components/prompt/PromptListView.tsx[215-220]
apps/desktop/src/renderer/components/prompt/PromptListView.tsx[418-424]
apps/desktop/src/renderer/components/prompt/PromptListView.tsx[496-500]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
User-facing strings and Chinese literals are hardcoded in `PromptListView` instead of using `t()` keys.

## Issue Context
- `formatDate()` returns Chinese fallback strings (`昨天`, `天前`).
- Favorite button title falls back to `添加收藏`.
- Expand/collapse button uses hardcoded English ARIA labels.
All user-facing text must be localized via i18next and new keys must be added to all locale JSON files.

## Fix Focus Areas
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[202-227]
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[411-425]
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[496-507]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Uncommented as type assertions 📘 Rule violation ⚙ Maintainability 8.1
Description
New code introduces as type assertions without justification comments, reducing type-safety and
potentially masking runtime shape mismatches. This violates the TypeScript strictness rule for as
usage.
Code

apps/desktop/src/renderer/services/database.ts[R379-381]

+    getAllRequest.onsuccess = () => {
+      const prompts = getAllRequest.result as Prompt[];
+      const prompt = prompts.find((item) => item.id === promptId);
Evidence
PR Compliance ID 8 disallows un-commented/unjustified as assertions. The IndexedDB fallback and
the migration test both add as ... casts without an explanatory interop comment.

Rule 8.1: TypeScript strictness rules for production and test code
apps/desktop/src/renderer/services/database.ts[379-381]
apps/desktop/tests/unit/main/database-migration-locks.test.ts[169-175]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Uncommented `as` assertions were added in production and test code.

## Issue Context
The compliance rule disallows `as` assertions unless truly necessary for interop and accompanied by an explanatory comment.

## Fix Focus Areas
- apps/desktop/src/renderer/services/database.ts[379-382]
- apps/desktop/tests/unit/main/database-migration-locks.test.ts[169-175]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
4. Exported functions missing return types 📘 Rule violation ⚙ Maintainability 8.1
Description
New exported API surfaces omit explicit return type annotations, making contract drift harder to
detect and weakening strict typing guarantees. This violates the requirement that exported functions
have explicit return types.
Code

apps/desktop/src/preload/api/prompt.ts[R43-44]

+  move: (promptId: string, newParentId: string | null, newOrder: number) =>
+    ipcRenderer.invoke(IPC_CHANNELS.PROMPT_MOVE, promptId, newParentId, newOrder),
Evidence
PR Compliance ID 8 requires explicit return types on exported functions. The added promptApi.move
and exported PromptListView omit explicit return type annotations.

Rule 8.1: TypeScript strictness rules for production and test code
apps/desktop/src/preload/api/prompt.ts[43-44]
apps/desktop/src/renderer/components/prompt/PromptListView.tsx[59-69]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Exported functions must include explicit return type annotations.

## Issue Context
- `promptApi.move` is part of an exported API object but has no explicit return type.
- `PromptListView` is an exported function component without an explicit return type annotation.

## Fix Focus Areas
- apps/desktop/src/preload/api/prompt.ts[43-44]
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[59-69]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Global Tab key hijack 🐞 Bug ≡ Correctness
Description
PromptListView installs a window-level keydown handler that preventDefault()s Tab whenever a prompt
is selected, regardless of where focus is, so Tab stops working inside other UI (e.g.,
EditPromptModal inputs) while list view is mounted. This breaks keyboard navigation/accessibility
and can make prompt editing flows difficult or impossible without the mouse.
Code

apps/desktop/src/renderer/components/prompt/PromptListView.tsx[R148-193]

+  useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (!selectedId || draggingId) return;
+
+      const selectedPrompt = promptById.get(selectedId);
+      if (!selectedPrompt) return;
+      if (event.key !== 'Tab' || event.ctrlKey || event.metaKey) return;
+
+      event.preventDefault();
+
+      const currentParentId = getVisibleParentId(selectedPrompt);
+
+      if (event.shiftKey) {
+        if (!currentParentId) return;
+
+        const parentPrompt = promptById.get(currentParentId);
+        const grandParentId = parentPrompt
+          ? getVisibleParentId(parentPrompt)
+          : null;
+        const parentSiblings = getChildren(grandParentId);
+        const parentIndex = parentSiblings.findIndex(
+          (prompt) => prompt.id === currentParentId,
+        );
+
+        onMovePrompt(
+          selectedId,
+          grandParentId,
+          parentIndex >= 0 ? parentIndex + 1 : parentSiblings.length,
+        );
+        return;
+      }
+
+      const siblings = getChildren(currentParentId);
+      const currentIndex = siblings.findIndex(
+        (prompt) => prompt.id === selectedId,
+      );
+      if (currentIndex <= 0) return;
+
+      const previousSibling = siblings[currentIndex - 1];
+      setExpandedIds((current) => new Set(current).add(previousSibling.id));
+      onMovePrompt(selectedId, previousSibling.id, getChildren(previousSibling.id).length);
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [
Evidence
The handler is registered on window and unconditionally prevents default Tab behavior when a
prompt is selected, with no focus/target filtering. MainContent renders EditPromptModal without
unmounting the list, and the modal contains text inputs that rely on Tab for focus navigation.

apps/desktop/src/renderer/components/prompt/PromptListView.tsx[148-193]
apps/desktop/src/renderer/components/layout/MainContent.tsx[2703-2716]
apps/desktop/src/renderer/components/prompt/EditPromptModal.tsx[846-872]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`PromptListView` captures `Tab` at the `window` level and calls `event.preventDefault()` whenever there is a `selectedId`. This intercepts Tab presses even when the user is typing in an `<input>`/`<textarea>` inside modals rendered on top of the list (e.g. `EditPromptModal`), breaking expected focus traversal.

### Issue Context
The list view is mounted at the same time as shared modals in `MainContent`, so a global handler affects the whole page.

### Fix Focus Areas
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[148-193]

### Recommended fix
Implement one of these (prefer 1):
1) **Attach the key handler to the list container instead of `window`**:
  - Add `tabIndex={0}` to the list container and use `onKeyDown`/`onKeyDownCapture` there.
  - Only run indentation logic when the list container (or a row inside it) is the event target.

2) **If keeping `window` listener, ignore form/editing contexts**:
  - Before `preventDefault()`, check `event.target` / `document.activeElement` and return when:
    - target is `INPUT`, `TEXTAREA`, `SELECT`, or `contentEditable`, or
    - target is inside a modal/dialog region.

This ensures Tab continues to work for text entry and focus navigation while still supporting hierarchy editing when the list has focus.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

6. PromptListView exceeds function limit 📘 Rule violation ⚙ Maintainability 0.7
Description
The updated PromptListView component contains large embedded logic blocks that exceed the 50-line
function guideline, increasing review and maintenance risk. This violates the size/complexity limits
policy for functions.
Code

apps/desktop/src/renderer/components/prompt/PromptListView.tsx[R59-575]

export function PromptListView({
  prompts,
  selectedId,
+  selectedIds,
  onSelect,
  onToggleFavorite,
  onCopy,
  onContextMenu,
+  onMovePrompt,
}: PromptListViewProps) {
  const { t } = useTranslation();
+  const [draggingId, setDraggingId] = useState<string | null>(null);
+  const [dropTargetId, setDropTargetId] = useState<string | null>(null);
+  const [dropPosition, setDropPosition] = useState<DropPosition | null>(null);
+  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
+
+  const promptById = useMemo(() => {
+    return new Map(prompts.map((prompt) => [prompt.id, prompt]));
+  }, [prompts]);
+
+  const getVisibleParentId = useCallback(
+    (prompt: Prompt): string | null => {
+      if (!prompt.parentId || prompt.parentId === prompt.id) {
+        return null;
+      }
+
+      return promptById.has(prompt.parentId) ? prompt.parentId : null;
+    },
+    [promptById],
+  );
+
+  const childrenByParent = useMemo(() => {
+    const groups = new Map<string | null, Prompt[]>();
+
+    for (const prompt of prompts) {
+      const parentId = getVisibleParentId(prompt);
+      const siblings = groups.get(parentId) ?? [];
+      siblings.push(prompt);
+      groups.set(parentId, siblings);
+    }
+
+    for (const siblings of groups.values()) {
+      siblings.sort(comparePromptTreeOrder);
+    }
+
+    return groups;
+  }, [getVisibleParentId, prompts]);
+
+  const getChildren = useCallback(
+    (parentId: string | null) => childrenByParent.get(parentId) ?? [],
+    [childrenByParent],
+  );
+
+  const isDescendantOf = useCallback(
+    (candidateId: string, ancestorId: string): boolean => {
+      let current = promptById.get(candidateId);
+      const visited = new Set<string>();
+
+      while (current) {
+        const parentId = getVisibleParentId(current);
+        if (!parentId) {
+          return false;
+        }
+        if (parentId === ancestorId) {
+          return true;
+        }
+        if (visited.has(parentId)) {
+          return false;
+        }
+
+        visited.add(parentId);
+        current = promptById.get(parentId);
+      }
+
+      return false;
+    },
+    [getVisibleParentId, promptById],
+  );
+
+  const canMoveToParent = useCallback(
+    (promptId: string, parentId: string | null): boolean => {
+      return (
+        !parentId ||
+        (parentId !== promptId && !isDescendantOf(parentId, promptId))
+      );
+    },
+    [isDescendantOf],
+  );
+
+  useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (!selectedId || draggingId) return;
+
+      const selectedPrompt = promptById.get(selectedId);
+      if (!selectedPrompt) return;
+      if (event.key !== 'Tab' || event.ctrlKey || event.metaKey) return;
+
+      event.preventDefault();
+
+      const currentParentId = getVisibleParentId(selectedPrompt);
+
+      if (event.shiftKey) {
+        if (!currentParentId) return;
+
+        const parentPrompt = promptById.get(currentParentId);
+        const grandParentId = parentPrompt
+          ? getVisibleParentId(parentPrompt)
+          : null;
+        const parentSiblings = getChildren(grandParentId);
+        const parentIndex = parentSiblings.findIndex(
+          (prompt) => prompt.id === currentParentId,
+        );
+
+        onMovePrompt(
+          selectedId,
+          grandParentId,
+          parentIndex >= 0 ? parentIndex + 1 : parentSiblings.length,
+        );
+        return;
+      }
+
+      const siblings = getChildren(currentParentId);
+      const currentIndex = siblings.findIndex(
+        (prompt) => prompt.id === selectedId,
+      );
+      if (currentIndex <= 0) return;
+
+      const previousSibling = siblings[currentIndex - 1];
+      setExpandedIds((current) => new Set(current).add(previousSibling.id));
+      onMovePrompt(selectedId, previousSibling.id, getChildren(previousSibling.id).length);
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [
+    draggingId,
+    getChildren,
+    getVisibleParentId,
+    onMovePrompt,
+    promptById,
+    selectedId,
+  ]);

-  // Format date
-  // 格式化日期
  const formatDate = (dateStr: string) => {
    const date = new Date(dateStr);
    const now = new Date();
    const diffMs = now.getTime() - date.getTime();
    const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));

    if (diffDays === 0) {
-      return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
-    } else if (diffDays === 1) {
+      return date.toLocaleTimeString(undefined, {
+        hour: '2-digit',
+        minute: '2-digit',
+      });
+    }
+
+    if (diffDays === 1) {
      return t('common.yesterday') || '昨天';
-    } else if (diffDays < 7) {
+    }
+
+    if (diffDays < 7) {
      return `${diffDays}${t('common.daysAgo') || '天前'}`;
-    } else {
-      return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
    }
+
+    return date.toLocaleDateString(undefined, {
+      month: 'short',
+      day: 'numeric',
+    });
  };

-  return (
-    <div className="flex flex-col">
-      {prompts.map((prompt) => (
-        <div
-          key={prompt.id}
-          onClick={() => onSelect(prompt.id)}
-          onContextMenu={(e) => onContextMenu(e, prompt)}
-          className={`
-            flex items-center gap-3 px-3 py-2.5 border-b border-border/50 cursor-pointer
-            transition-colors duration-quick
-            ${selectedId === prompt.id
-              ? 'bg-primary/10 border-l-2 border-l-primary'
-              : 'hover:bg-accent/50'
-            }
-          `}
-        >
-          {/* Title and description */}
-          {/* 标题和描述 */}
-          <div className="flex-1 min-w-0">
-            <div className="flex items-center gap-2">
-              <h3
-                className={`font-medium text-sm leading-snug break-words line-clamp-2 ${
-                  selectedId === prompt.id ? 'text-primary' : 'text-foreground'
-                }`}
-                title={prompt.title}
-              >
-                {prompt.title}
-              </h3>
-              {prompt.isFavorite && (
-                <StarIcon className="w-3 h-3 flex-shrink-0 fill-yellow-400 text-yellow-400" />
+  const toggleExpand = useCallback((promptId: string) => {
+    setExpandedIds((current) => {
+      const next = new Set(current);
+      if (next.has(promptId)) {
+        next.delete(promptId);
+      } else {
+        next.add(promptId);
+      }
+      return next;
+    });
+  }, []);
+
+  const handleDragStart = useCallback(
+    (event: ReactDragEvent, promptId: string) => {
+      setDraggingId(promptId);
+      event.dataTransfer.effectAllowed = 'move';
+      event.dataTransfer.setData('text/plain', promptId);
+    },
+    [],
+  );
+
+  const handleDragEnd = useCallback(() => {
+    setDraggingId(null);
+    setDropTargetId(null);
+    setDropPosition(null);
+  }, []);
+
+  const updateDropTarget = useCallback(
+    (event: ReactDragEvent<HTMLElement>, targetPrompt: Prompt) => {
+      event.preventDefault();
+      event.dataTransfer.dropEffect = 'move';
+
+      if (!draggingId || draggingId === targetPrompt.id) {
+        setDropTargetId(null);
+        setDropPosition(null);
+        return;
+      }
+
+      const nextDropPosition = getDropPosition(event);
+      const nextParentId =
+        nextDropPosition === 'inside'
+          ? targetPrompt.id
+          : getVisibleParentId(targetPrompt);
+
+      if (!canMoveToParent(draggingId, nextParentId)) {
+        setDropTargetId(null);
+        setDropPosition(null);
+        return;
+      }
+
+      setDropTargetId(targetPrompt.id);
+      setDropPosition(nextDropPosition);
+    },
+    [canMoveToParent, draggingId, getVisibleParentId],
+  );
+
+  const handleDragLeave = useCallback((event: ReactDragEvent<HTMLElement>) => {
+    const nextTarget = event.relatedTarget;
+    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
+      return;
+    }
+
+    setDropTargetId(null);
+    setDropPosition(null);
+  }, []);
+
+  const handleDrop = useCallback(
+    (event: ReactDragEvent, targetPrompt: Prompt) => {
+      event.preventDefault();
+
+      if (!draggingId || draggingId === targetPrompt.id || !dropPosition) {
+        handleDragEnd();
+        return;
+      }
+
+      if (dropPosition === 'inside') {
+        if (canMoveToParent(draggingId, targetPrompt.id)) {
+          setExpandedIds((current) => new Set(current).add(targetPrompt.id));
+          onMovePrompt(draggingId, targetPrompt.id, getChildren(targetPrompt.id).length);
+        }
+        handleDragEnd();
+        return;
+      }
+
+      const nextParentId = getVisibleParentId(targetPrompt);
+      if (!canMoveToParent(draggingId, nextParentId)) {
+        handleDragEnd();
+        return;
+      }
+
+      const targetSiblings = getChildren(nextParentId).filter(
+        (prompt) => prompt.id !== draggingId,
+      );
+      const targetIndex = targetSiblings.findIndex(
+        (prompt) => prompt.id === targetPrompt.id,
+      );
+      const nextOrder =
+        targetIndex < 0
+          ? targetSiblings.length
+          : targetIndex + (dropPosition === 'after' ? 1 : 0);
+
+      onMovePrompt(draggingId, nextParentId, nextOrder);
+      handleDragEnd();
+    },
+    [
+      canMoveToParent,
+      draggingId,
+      dropPosition,
+      getChildren,
+      getVisibleParentId,
+      handleDragEnd,
+      onMovePrompt,
+    ],
+  );
+
+  const isSelected = useCallback(
+    (promptId: string) => selectedId === promptId || selectedIds.includes(promptId),
+    [selectedId, selectedIds],
+  );
+
+  const isDragging = useCallback(
+    (promptId: string) => draggingId === promptId,
+    [draggingId],
+  );
+
+  const isDropTarget = useCallback(
+    (promptId: string) => dropTargetId === promptId,
+    [dropTargetId],
+  );
+
+  const renderTreeNode = useCallback(
+    (prompt: Prompt, depth: number, ancestors: Set<string>) => {
+      const nextAncestors = new Set(ancestors).add(prompt.id);
+      const children = getChildren(prompt.id).filter(
+        (child) => !nextAncestors.has(child.id),
+      );
+      const hasKids = children.length > 0;
+      const isExpanded = expandedIds.has(prompt.id);
+
+      return (
+        <div key={prompt.id}>
+          <div
+            draggable
+            onDragStart={(event) => handleDragStart(event, prompt.id)}
+            onDragEnd={handleDragEnd}
+            onDragOver={(event) => updateDropTarget(event, prompt)}
+            onDragEnter={(event) => updateDropTarget(event, prompt)}
+            onDragLeave={handleDragLeave}
+            onDrop={(event) => handleDrop(event, prompt)}
+            onClick={() => onSelect(prompt.id)}
+            onContextMenu={(event) => onContextMenu(event, prompt)}
+            className={`
+              flex items-center gap-3 px-3 py-2.5 border-b border-border/50 cursor-pointer
+              transition-colors duration-quick relative
+              ${
+                isSelected(prompt.id)
+                  ? 'bg-primary/10 border-l-2 border-l-primary'
+                  : isDropTarget(prompt.id) && dropPosition === 'inside'
+                    ? 'bg-primary/20 border-l-2 border-l-primary'
+                    : 'hover:bg-accent/50'
+              }
+              ${isDragging(prompt.id) ? 'opacity-50' : ''}
+              ${
+                isDropTarget(prompt.id) && dropPosition === 'inside'
+                  ? 'ring-2 ring-primary/50 ring-inset'
+                  : ''
+              }
+              ${
+                isDropTarget(prompt.id) && dropPosition === 'before'
+                  ? 'border-t-2 border-t-primary'
+                  : ''
+              }
+              ${
+                isDropTarget(prompt.id) && dropPosition === 'after'
+                  ? 'border-b-2 border-b-primary'
+                  : ''
+              }
+            `}
+            style={{ paddingLeft: `${depth * 16 + 12}px` }}
+          >
+            <div className="flex items-center gap-1">
+              {hasKids ? (
+                <button
+                  type="button"
+                  onClick={(event) => {
+                    event.stopPropagation();
+                    toggleExpand(prompt.id);
+                  }}
+                  className="p-0.5 rounded hover:bg-accent transition-colors"
+                  aria-label={isExpanded ? 'Collapse prompt' : 'Expand prompt'}
+                >
+                  {isExpanded ? (
+                    <ChevronDownIcon className="w-4 h-4 text-muted-foreground" />
+                  ) : (
+                    <ChevronRightIcon className="w-4 h-4 text-muted-foreground" />
+                  )}
+                </button>
+              ) : (
+                <span className="w-5" />
              )}
+              <GripVerticalIcon className="w-4 h-4 text-muted-foreground cursor-grab opacity-0 hover:opacity-100 transition-opacity" />
            </div>
-            {prompt.description && (
-              <p className="text-xs text-muted-foreground line-clamp-2 break-words mt-0.5">
-                {prompt.description}
-              </p>
-            )}
-            {prompt.images && prompt.images.length > 0 && (
-              <div className="flex items-center gap-1 mt-0.5">
-                <ImageIcon className="w-3 h-3 text-muted-foreground" />
-                <span className="text-xs text-muted-foreground">{prompt.images.length}</span>
+
+            <div className="flex-1 min-w-0">
+              <div className="flex items-center gap-2">
+                <h3
+                  className={`font-medium text-sm leading-snug break-words line-clamp-2 ${
+                    isSelected(prompt.id) ? 'text-primary' : 'text-foreground'
+                  }`}
+                  title={prompt.title}
+                >
+                  {prompt.title}
+                </h3>
+                {prompt.isFavorite && (
+                  <StarIcon className="w-3 h-3 flex-shrink-0 fill-yellow-400 text-yellow-400" />
+                )}
              </div>
-            )}
-          </div>
+              {prompt.description && (
+                <p className="text-xs text-muted-foreground line-clamp-2 break-words mt-0.5">
+                  {prompt.description}
+                </p>
+              )}
+              {prompt.images && prompt.images.length > 0 && (
+                <div className="flex items-center gap-1 mt-0.5">
+                  <ImageIcon className="w-3 h-3 text-muted-foreground" />
+                  <span className="text-xs text-muted-foreground">
+                    {prompt.images.length}
+                  </span>
+                </div>
+              )}
+            </div>

-          {/* Usage count */}
-          {/* 使用次数 */}
-          <div className="flex-shrink-0 w-12 text-center">
-            <span className="text-xs text-muted-foreground">
-              {prompt.usageCount || 0}
-            </span>
-          </div>
+            <div className="flex-shrink-0 w-12 text-center">
+              <span className="text-xs text-muted-foreground">
+                {prompt.usageCount || 0}
+              </span>
+            </div>

-          {/* Update time */}
-          {/* 更新时间 */}
-          <div className="flex-shrink-0 w-16 text-right">
-            <span className="text-xs text-muted-foreground">
-              {formatDate(prompt.updatedAt)}
-            </span>
-          </div>
+            <div className="flex-shrink-0 w-16 text-right">
+              <span className="text-xs text-muted-foreground">
+                {formatDate(prompt.updatedAt)}
+              </span>
+            </div>

-          {/* Action buttons */}
-          {/* 操作按钮 */}
-          <div className="flex items-center gap-1 flex-shrink-0">
-            <button
-              onClick={(e) => {
-                e.stopPropagation();
-                onCopy(prompt);
-              }}
-              className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
-              title={t('prompt.copy')}
-            >
-              <CopyIcon className="w-3.5 h-3.5" />
-            </button>
-            <button
-              onClick={(e) => {
-                e.stopPropagation();
-                onToggleFavorite(prompt.id);
-              }}
-              className={`p-1.5 rounded-md transition-colors ${prompt.isFavorite
-                ? 'text-yellow-500 hover:bg-yellow-500/10'
-                : 'text-muted-foreground hover:text-foreground hover:bg-accent'
+            <div className="flex items-center gap-1 flex-shrink-0">
+              <button
+                type="button"
+                onClick={(event) => {
+                  event.stopPropagation();
+                  onCopy(prompt);
+                }}
+                className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
+                title={t('prompt.copy')}
+              >
+                <CopyIcon className="w-3.5 h-3.5" />
+              </button>
+              <button
+                type="button"
+                onClick={(event) => {
+                  event.stopPropagation();
+                  onToggleFavorite(prompt.id);
+                }}
+                className={`p-1.5 rounded-md transition-colors ${
+                  prompt.isFavorite
+                    ? 'text-yellow-500 hover:bg-yellow-500/10'
+                    : 'text-muted-foreground hover:text-foreground hover:bg-accent'
                }`}
-              title={prompt.isFavorite ? t('nav.favorites') : t('prompt.addToFavorites') || '添加收藏'}
-            >
-              <StarIcon className={`w-3.5 h-3.5 ${prompt.isFavorite ? 'fill-current' : ''}`} />
-            </button>
+                title={
+                  prompt.isFavorite
+                    ? t('nav.favorites')
+                    : t('prompt.addToFavorites') || '添加收藏'
+                }
+              >
+                <StarIcon
+                  className={`w-3.5 h-3.5 ${
+                    prompt.isFavorite ? 'fill-current' : ''
+                  }`}
+                />
+              </button>
+            </div>
          </div>
+
+          {hasKids && isExpanded && (
+            <div>
+              {children.map((child) =>
+                renderTreeNode(child, depth + 1, nextAncestors),
+              )}
+            </div>
+          )}
        </div>
-      ))}
+      );
+    },
+    [
+      dropPosition,
+      expandedIds,
+      formatDate,
+      getChildren,
+      handleDragEnd,
+      handleDragLeave,
+      handleDragStart,
+      handleDrop,
+      isDragging,
+      isDropTarget,
+      isSelected,
+      onContextMenu,
+      onCopy,
+      onSelect,
+      onToggleFavorite,
+      t,
+      toggleExpand,
+      updateDropTarget,
+    ],
+  );
+
+  const rootNodes = useMemo(() => {
+    const attachedIds = new Set<string>();
+
+    const collect = (prompt: Prompt, ancestors: Set<string>) => {
+      if (ancestors.has(prompt.id)) {
+        return;
+      }
+
+      attachedIds.add(prompt.id);
+      const nextAncestors = new Set(ancestors).add(prompt.id);
+      for (const child of getChildren(prompt.id)) {
+        collect(child, nextAncestors);
+      }
+    };
+
+    const roots = getChildren(null);
+    for (const root of roots) {
+      collect(root, new Set());
+    }
+
+    const detached = prompts
+      .filter((prompt) => !attachedIds.has(prompt.id))
+      .sort(comparePromptTreeOrder);
+
+    return [...roots, ...detached];
+  }, [getChildren, prompts]);
+
+  return (
+    <div className="flex flex-col overflow-y-auto">
+      {rootNodes.map((node) => renderTreeNode(node, 0, new Set()))}
    </div>
  );
}
Evidence
PR Compliance ID 2 requires functions to stay under 50 lines unless explicitly justified in the
active change. The new PromptListView implementation is a single large function component spanning
hundreds of lines.

Rule 0.7: Enforce file and function size/complexity limits
apps/desktop/src/renderer/components/prompt/PromptListView.tsx[59-575]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PromptListView` is a very large function component with many responsibilities (tree building, DnD state, keyboard handling, recursive rendering).

## Issue Context
Compliance requires functions to stay under 50 lines unless explicitly justified and well-tested. Consider extracting logic into:
- a tree-building helper module
- a `usePromptTreeDnD` hook
- a `PromptTreeRow` component

## Fix Focus Areas
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[59-575]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Unstable hierarchy ordering 🐞 Bug ☼ Reliability
Description
PromptDB.getChildren() and getAllWithHierarchy() order rows only by sort_order, so when siblings
share the same sort_order (common right after migration because sort_order is added with DEFAULT 0),
returned order is nondeterministic. This can yield inconsistent hierarchy traversal/rendering and
flaky behavior for any caller relying on these helpers.
Code

packages/db/src/prompt.ts[R812-830]

+  getChildren(parentId: string | null): Prompt[] {
+    const stmt = this.db.prepare(
+      parentId === null
+        ? "SELECT * FROM prompts WHERE parent_id IS NULL ORDER BY sort_order"
+        : "SELECT * FROM prompts WHERE parent_id = ? ORDER BY sort_order"
+    );
+    const rows = stmt.all(parentId === null ? [] : [parentId]) as PromptRow[];
+    return rows.map((row) => this.rowToPrompt(row));
+  }
+
+  /**
+   * Get all prompts with hierarchical structure
+   * 获取所有提示词(包含层级结构)
+   */
+  getAllWithHierarchy(): Prompt[] {
+    const stmt = this.db.prepare("SELECT * FROM prompts ORDER BY parent_id NULLS FIRST, sort_order");
+    const rows = stmt.all() as PromptRow[];
+    return rows.map((row) => this.rowToPrompt(row));
+  }
Evidence
The migration adds sort_order with DEFAULT 0 for existing rows and does not assign unique
per-sibling values. The new hierarchy helpers then order only by sort_order, so tied rows can be
returned in arbitrary order.

packages/db/src/init.ts[337-347]
packages/db/src/prompt.ts[812-830]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`PromptDB.getChildren()` and `PromptDB.getAllWithHierarchy()` sort only by `sort_order`. After migrating older DBs, `sort_order` is introduced with `DEFAULT 0` for existing rows, so many rows tie on `sort_order`, and SQLite does not guarantee stable ordering for ties.

### Issue Context
`initDatabase` adds `sort_order INTEGER DEFAULT 0` with no backfill to distinct values per sibling group.

### Fix Focus Areas
- packages/db/src/prompt.ts[812-830]
- packages/db/src/init.ts[337-347]

### Recommended fix
1) Make ordering deterministic by adding tie-breakers consistently:
  - In `getChildren`:
    - Use `ORDER BY sort_order ASC, updated_at DESC, id ASC` (or at least `..., id ASC`).
  - In `getAllWithHierarchy`:
    - Use `ORDER BY parent_id, sort_order ASC, updated_at DESC, id ASC` (and rely on SQLite's NULL-first default), or a null-safe expression if needed.

2) (Optional but stronger) Consider a one-time migration/backfill to assign contiguous `sort_order` values per parent group for pre-existing prompts, to avoid giant tie sets immediately after upgrade.

Ensure the ordering strategy matches `getPromptSiblingIds()` so move/rewrite logic and read helpers stay consistent.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

为桌面端 Prompt 引入父子层级关系:在 SQLite prompts 表新增 parent_id/sort_order 列并补全迁移逻辑;PromptDB 新增 movePrompt/getChildren/getAllWithHierarchy;通过 prompt:move IPC 通道暴露移动能力,renderer 侧提供 IndexedDB 回退路径;PromptListView 重写为可展开树,支持拖拽三落点与 Tab/Shift+Tab 键盘缩进。

Changes

Prompt 层级关系树

Layer / File(s) Summary
共享类型与 IPC 通道定义
packages/shared/types/prompt.ts, packages/shared/constants/ipc-channels.ts, packages/db/src/schema.ts
PromptUpdatePromptDTO 新增可选字段 parentId/orderIPC_CHANNELS 新增 PROMPT_MOVEprompts 表 schema 新增 parent_id/sort_order 列、自引用外键(ON DELETE SET NULL)与对应索引。
SQLite PromptDB 层级字段与 movePrompt
packages/db/src/prompt.ts, packages/db/src/init.ts
PromptRowcreate/update/insertPromptDirect/rowToPrompt 全部补入 parent_id/sort_order;新增 movePrompt(事务内循环检测与兄弟顺序重写)、getChildrengetAllWithHierarchyinit.ts 的列探测与 ALTER TABLE 迁移同步补全两列。
IPC handler、Preload API、IndexedDB 回退与 Store
apps/desktop/src/main/ipc/prompt.ipc.ts, apps/desktop/src/preload/api/prompt.ts, apps/desktop/src/renderer/services/database.ts, apps/desktop/src/renderer/stores/prompt.store.ts
主进程新增 assertPromptMoveInput 校验器与 PROMPT_MOVE handler;preload 新增 promptApi.move;renderer database.ts 新增 movePrompt(优先走主进程,否则 IndexedDB 事务回退含循环检测);store 新增 movePrompt 动作并在调用后刷新列表与触发同步。
PromptListView 树形渲染、拖拽与键盘交互
apps/desktop/src/renderer/components/prompt/PromptListView.tsx
组件从扁平列表重写为可展开树:新增 DropPosition 类型与辅助函数;构建父子分组与展开状态;canMoveToParent 阻止循环层级;Tab/Shift+Tab 键盘缩进;before/after/inside 三落点拖拽生命周期;递归 renderTreeNode 按落点应用样式;rootNodes 防止循环重复渲染;容器改为可滚动。
MainContent 视图切换接线
apps/desktop/src/renderer/components/layout/MainContent.tsx
新增 PromptListView lazy 入口,移除旧 PromptTableView 声明;viewMode === 'list' 分支改为渲染 PromptListView 并传入 onMovePrompt/selectedIds;gallery/kanban 容器重排但接口形式不变。
DB 层级测试与迁移回归测试
apps/desktop/tests/unit/main/prompt-db.test.ts, apps/desktop/tests/unit/main/database-migration-locks.test.ts
新增 hierarchical grouping 测试集(movePrompt 移动、顺序连续性、自引用/后代环拒绝、删除父节点后子节点 parentId 清空);新增迁移回归测试验证旧表补齐 parent_id/sort_order 及默认值。
设计文档、提案与规格
spec/changes/active/desktop-prompt-relationship-tree/*
新增 design.md、proposal.md、implementation.md、specs/prompt-relationships/spec.md 与 tasks.md,定义 V1 语义边界、迁移方案、UI 规则、Scope/Risks 及已完成任务清单。

Sequence Diagram(s)

sequenceDiagram
  participant PromptListView
  participant usePromptStore
  participant database as database.ts
  participant ipcRenderer
  participant MainProcess as Main Process
  participant PromptDB

  rect rgba(99, 132, 255, 0.5)
    note over PromptListView,PromptDB: 拖拽 / Tab 键盘触发移动
    PromptListView->>usePromptStore: movePrompt(id, newParentId, newOrder)
    usePromptStore->>database: db.movePrompt(id, newParentId, newOrder)
  end

  alt window.api.prompt.move 可用(Electron 主进程)
    database->>ipcRenderer: invoke("prompt:move", id, newParentId, newOrder)
    ipcRenderer->>MainProcess: assertPromptMoveInput → db.movePrompt
    MainProcess->>PromptDB: 事务:循环检测 + 兄弟顺序重写
    PromptDB-->>MainProcess: commit
    MainProcess->>MainProcess: syncWorkspace()
    MainProcess-->>database: true
  else IndexedDB 回退
    database->>database: assertPromptMoveAllowed(循环检测)
    database->>database: reorderPromptTree + IDBTransaction 批量 put
  end

  database-->>usePromptStore: void
  usePromptStore->>usePromptStore: fetchPrompts() + scheduleAllSaveSync("prompt:move")
  usePromptStore-->>PromptListView: prompts 状态刷新
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 小兔整理提示词,
拖拽一下变父子,
Tab 键缩进层层叠,
parent_id 记住你,
sort_order 排排站,
环路检测不怕绕,
树形列表真整齐! 🌳

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR标题清晰准确地总结了本次变更的主要目标:添加提示词的层级树关系功能,与changeset中对数据库、IPC、UI组件的全面改造高度对应。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/hierarchical-pr-review-20260614105632

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

feat: add hierarchical prompt tree relationships
✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

Walkthroughs

Description
• Add durable prompt hierarchy fields (parentId/order) with SQLite migration and IPC support.
• Replace list mode with a single hierarchical tree view supporting drag/drop and Tab indentation.
• Harden move/delete semantics (no cascade delete, no cycles, contiguous sibling ordering) with
  tests.
Diagram
graph TD
  A["PromptListView"] --> B["Prompt store"] --> C["renderer movePrompt"]
  C --> D["IPC prompt:move"] --> E[("SQLite PromptDB")]
  C --> F[("IndexedDB prompts")]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Introduce a dedicated prompt_relations table (typed edges)
  • ➕ Matches the documented future taxonomy (related_to/variant_of/depends_on/next_step) without overloading parentId.
  • ➕ Avoids later migration churn if the model becomes a DAG/graph rather than a strict tree.
  • ➕ Can represent multiple relationship kinds simultaneously.
  • ➖ Higher upfront complexity (new table, queries, UI changes, and migrations).
  • ➖ Drag/drop tree UX still needs a tree projection layer.
  • ➖ More work to ship the contributor’s tree interaction quickly.
2. Store order as a fractional/rank key (no full sibling rewrite)
  • ➕ Avoids rewriting contiguous 0..n orders on every move; fewer writes for large sibling sets.
  • ➕ Simplifies concurrent insert/move scenarios.
  • ➖ Requires rank generation/compaction logic and careful sorting semantics.
  • ➖ Harder to reason about and test than contiguous integer order.

Recommendation: The PR’s approach is appropriate for a V1: durable parent_id/sort_order in SQLite (with ON DELETE SET NULL) keeps hierarchy consistent across sessions and enables simple tree rendering and direct manipulation UX. If/when non-tree relationships ship, consider adding prompt_relations and treating parent_id/sort_order as the projection for grouped_under (with a planned migration path).

Grey Divider

File Changes

Enhancement (10)
prompt.ipc.ts Add IPC handler for prompt move with input validation +26/-0

Add IPC handler for prompt move with input validation

• Introduces a shared guard for move inputs and registers a new PROMPT_MOVE IPC handler. The handler calls PromptDB.movePrompt, then syncs the workspace to propagate hierarchy changes.

apps/desktop/src/main/ipc/prompt.ipc.ts


prompt.ts Expose prompt.move() in preload API +2/-0

Expose prompt.move() in preload API

• Adds a promptApi.move method that invokes the new prompt:move IPC channel from the renderer.

apps/desktop/src/preload/api/prompt.ts


PromptListView.tsx Rewrite prompt list into hierarchical tree with drag/drop and Tab indentation +530/-90

Rewrite prompt list into hierarchical tree with drag/drop and Tab indentation

• Implements a tree renderer based on parentId/order with expand/collapse and defensive cycle/missing-parent handling. Adds drag/drop support with before/after/inside drop zones and keyboard Tab/Shift+Tab to indent/outdent, delegating persistence via onMovePrompt.

apps/desktop/src/renderer/components/prompt/PromptListView.tsx


database.ts Add renderer movePrompt() with IPC-first and IndexedDB fallback reordering +140/-0

Add renderer movePrompt() with IPC-first and IndexedDB fallback reordering

• Adds a new movePrompt service that routes to IPC when available, otherwise reorders prompts in IndexedDB. The fallback path validates against self-parenting, missing parents, descendant moves/cycles, and rewrites contiguous sibling order.

apps/desktop/src/renderer/services/database.ts


prompt.store.ts Add store action for prompt move and sync +7/-0

Add store action for prompt move and sync

• Introduces a movePrompt store action that calls db.movePrompt, refreshes prompts, and schedules a sync event to persist/broadcast the updated hierarchy.

apps/desktop/src/renderer/stores/prompt.store.ts


init.ts Require and migrate hierarchy columns in prompts table +14/-0

Require and migrate hierarchy columns in prompts table

• Extends REQUIRED_COLUMNS to include parent_id and sort_order so older databases trigger migration/backup behavior. Adds migrations to ALTER TABLE prompts to include parent_id (self-reference, ON DELETE SET NULL) and sort_order defaults.

packages/db/src/init.ts


prompt.ts Persist hierarchy fields and implement PromptDB.movePrompt/getChildren +170/-8

Persist hierarchy fields and implement PromptDB.movePrompt/getChildren

• Extends prompt row mapping and insert/update paths to include parent_id and sort_order. Adds movePrompt with validation (self-parenting, missing parent, cycle detection) and rewrites sibling order contiguously; also adds getChildren and getAllWithHierarchy helpers.

packages/db/src/prompt.ts


schema.ts Add prompts.parent_id/sort_order columns, foreign key, and indexes +6/-1

Add prompts.parent_id/sort_order columns, foreign key, and indexes

• Updates the base SQLite schema to include parent_id and sort_order with a self-referential FK using ON DELETE SET NULL. Adds indexes for parent_id and sort_order to support tree queries and ordering.

packages/db/src/schema.ts


ipc-channels.ts Add IPC channel for prompt moves +1/-0

Add IPC channel for prompt moves

• Introduces IPC_CHANNELS.PROMPT_MOVE ("prompt:move") for main/renderer communication of hierarchy edits.

packages/shared/constants/ipc-channels.ts


prompt.ts Extend Prompt contract with parentId and order fields +4/-0

Extend Prompt contract with parentId and order fields

• Adds optional parentId and order to the Prompt interface and UpdatePromptDTO, enabling typed hierarchical grouping through the shared contract.

packages/shared/types/prompt.ts


Bug fix (1)
MainContent.tsx Switch list mode to hierarchical PromptListView and remove duplicate rendering +22/-35

Switch list mode to hierarchical PromptListView and remove duplicate rendering

• Replaces the old list/table import with the hierarchical list component and fixes view-mode wiring so gallery/kanban/list render in their correct containers. Passes selection state and onMovePrompt into list mode for tree editing.

apps/desktop/src/renderer/components/layout/MainContent.tsx


Tests (2)
database-migration-locks.test.ts Test migration adds parent_id/sort_order to legacy prompts table +66/-0

Test migration adds parent_id/sort_order to legacy prompts table

• Adds a legacy prompts schema helper and a regression test ensuring initSharedDatabase migrates old prompts tables by adding parent_id and sort_order columns and setting expected defaults.

apps/desktop/tests/unit/main/database-migration-locks.test.ts


prompt-db.test.ts Add unit tests for hierarchical move validation and delete semantics +80/-0

Add unit tests for hierarchical move validation and delete semantics

• Adds coverage for moving prompts under parents with contiguous sibling order, rejecting invalid moves (self-parent, invalid order, missing parent, descendant cycles), and verifying parent deletion clears child parentId rather than deleting children.

apps/desktop/tests/unit/main/prompt-db.test.ts


Documentation (5)
design.md Document prompt relationship tree design and semantics +72/-0

Document prompt relationship tree design and semantics

• Captures module ownership boundaries, the V1 meaning of grouped_under, non-ownership delete semantics, and future relationship kinds beyond the tree projection.

spec/changes/active/desktop-prompt-relationship-tree/design.md


implementation.md Record shipped implementation details and verification commands +35/-0

Record shipped implementation details and verification commands

• Summarizes what shipped (UI, schema/migration, IPC, validation hardening) and records verification steps and follow-up ideas.

spec/changes/active/desktop-prompt-relationship-tree/implementation.md


proposal.md Add proposal outlining motivation, scope, risks, and rollback +34/-0

Add proposal outlining motivation, scope, risks, and rollback

• Explains why the hierarchy feature is being merged now, identifies pre-merge blockers (cascade delete, missing validation/migration), and documents risks and rollback thinking.

spec/changes/active/desktop-prompt-relationship-tree/proposal.md


spec.md Add delta spec for V1 grouped_under behavior and UX scenarios +43/-0

Add delta spec for V1 grouped_under behavior and UX scenarios

• Defines the V1 contract (drag/drop and keyboard scenarios), constraints (no cycles, no cascade deletes), and a forward-looking taxonomy for additional relationship kinds.

spec/changes/active/desktop-prompt-relationship-tree/specs/prompt-relationships/spec.md


tasks.md Track task checklist for hierarchy feature delivery +19/-0

Track task checklist for hierarchy feature delivery

• Adds an implementation checklist covering merge, hardening, tests, migration, docs, and verification steps.

spec/changes/active/desktop-prompt-relationship-tree/tasks.md


Grey Divider

Qodo Logo

@qodo-code-review

Copy link
Copy Markdown

CI Feedback 🧐

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: desktop-verify

Failed stage: Unit tests [❌]

Failed test name: rules workspace storage > always includes built-in global rule descriptors even when target files are missing

Failure summary:

The GitHub Action failed because pnpm test:unit (Vitest) exited with code 1 after multiple unit test
assertion failures in the @prompthub/desktop package.
Failing tests reported in the log:
-
tests/unit/main/rules-workspace.test.ts at :320:25: rules workspace storage > always includes
built-in global rule descriptors even when target files are missing failed because
listRuleDescriptors() returned an empty array ([]) instead of containing the expected built-in
global rule descriptors.
- tests/unit/main/updater-install.test.ts at :170:44: updater install
backup > blocks in-app install for Homebrew-installed macOS builds failed because a spy
(autoUpdater.quitAndInstall) was called once when the test expected it not to be called.
-
tests/unit/main/updater.test.ts failed 3 tests where install source detection returned unknown
instead of expected values:
- at :239:42: expected direct, received unknown (registers
installSource handler and replaces old updater handlers on re-register)
- at :304:11: expected
homebrew, received unknown (detects Homebrew-installed macOS app from Caskroom path)
- at :317:11:
expected direct, received unknown (treats regular macOS app bundle path as direct install)

Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

232:  > @prompthub/desktop@0.5.8 typecheck /home/runner/work/PromptHub/PromptHub/apps/desktop
233:  > tsc --noEmit
234:  ##[group]Run pnpm test:unit
235:  �[36;1mpnpm test:unit�[0m
236:  shell: /usr/bin/bash -e {0}
237:  env:
238:  PNPM_HOME: /home/runner/setup-pnpm/node_modules/.bin
239:  ##[endgroup]
240:  > prompthub-monorepo@0.5.8 test:unit /home/runner/work/PromptHub/PromptHub
241:  > pnpm --filter @prompthub/desktop test:unit
242:  > @prompthub/desktop@0.5.8 test:unit /home/runner/work/PromptHub/PromptHub/apps/desktop
243:  > vitest run tests/unit
244:  �[33mThe CJS build of Vite's Node API is deprecated. See https://vite.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.�[39m
245:  �[1m�[7m�[36m RUN �[39m�[27m�[22m �[36mv2.1.9 �[39m�[90m/home/runner/work/PromptHub/PromptHub/apps/desktop�[39m
246:  �[90mstderr�[2m | tests/unit/stores/skill.store.test.ts�[2m > �[22m�[2mskill store�[2m > �[22m�[2mblocks installing official registry skills when only placeholder frontmatter is available
247:  �[22m�[39mFailed to resolve latest SKILL.md for "pdf", falling back to cached registry content: Error: network down
248:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/stores/skill.store.test.ts:530:26
249:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:146:14
250:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:533:11
251:  at runWithTimeout (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:39:7)
252:  at runTest (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1056:17)
253:  at runSuite (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1205:15)
254:  at runSuite (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1205:15)
255:  at runFiles (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1262:5)
256:  at startTests (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1271:3)
257:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/vitest@2.1.9_@types+node@24.12.4_jsdom@27.3.0/node_modules/�[4mvitest�[24m/dist/chunks/runBaseTests.3qpJUEJM.js:126:11
258:  �[90mstderr�[2m | tests/unit/stores/skill.store.test.ts�[2m > �[22m�[2mskill store�[2m > �[22m�[2mrolls back a created package skill when remote package persistence fails
259:  �[22m�[39mFailed to create local repo for registry skill "failed-package": Error: clone failed
260:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/stores/skill.store.test.ts:1560:26
...

282:  �[22m�[39mSuccessfully installed skill __lock-test-1__ to claude
283:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mP1-11: platform config concurrent safety�[2m > �[22m�[2mconcurrent installToPlatform calls are serialized (no data loss)
284:  �[22m�[39mSuccessfully installed skill __lock-test-2__ to claude
285:  Successfully installed skill __lock-test-3__ to claude
286:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mP1-11: platform config concurrent safety�[2m > �[22m�[2mconcurrent installToPlatform calls are serialized (no data loss)
287:  �[22m�[39mSuccessfully installed skill __lock-test-4__ to claude
288:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mP1-11: platform config concurrent safety�[2m > �[22m�[2mconcurrent installToPlatform calls are serialized (no data loss)
289:  �[22m�[39mSuccessfully uninstalled skill __lock-test-0__ from claude
290:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mP1-11: platform config concurrent safety�[2m > �[22m�[2mconcurrent installToPlatform calls are serialized (no data loss)
291:  �[22m�[39mSuccessfully uninstalled skill __lock-test-1__ from claude
292:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mP1-11: platform config concurrent safety�[2m > �[22m�[2mconcurrent installToPlatform calls are serialized (no data loss)
293:  �[22m�[39mSuccessfully uninstalled skill __lock-test-2__ from claude
294:  Successfully uninstalled skill __lock-test-3__ from claude
295:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mP1-11: platform config concurrent safety�[2m > �[22m�[2mconcurrent installToPlatform calls are serialized (no data loss)
296:  �[22m�[39mSuccessfully uninstalled skill __lock-test-4__ from claude
297:  �[90mstderr�[2m | tests/unit/components/skill-store-remote.test.tsx�[2m > �[22m�[2mSkillStore remote loading�[2m > �[22m�[2mdoes not retry indefinitely after a remote fetch failure
298:  �[22m�[39mFailed to load remote store claude-code: Error: Failed to reach GitHub. Check your network connection or switch to another network and retry.
299:  at mapGitHubStoreError �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/renderer/services/github-skill-store.ts:159:12�[90m)�[39m
300:  at Module.loadGitHubSkillRepo �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/renderer/services/github-skill-store.ts:251:11�[90m)�[39m
301:  �[90m    at processTicksAndRejections (node:internal/process/task_queues:104:5)�[39m
302:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/renderer/components/skill/store-remote-sync.ts:323:16
303:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/renderer/components/skill/store-remote-sync.ts:800:19
304:  �[90mstderr�[2m | tests/unit/components/skill-store-remote.test.tsx�[2m > �[22m�[2mSkillStore remote loading�[2m > �[22m�[2mshows retry and network guidance for GitHub rate-limit failures
305:  �[22m�[39mFailed to load remote store claude-code: Error: GitHub API rate limit reached. Try again in a few minutes, or switch to another network and retry.
306:  at mapGitHubStoreError �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/renderer/services/github-skill-store.ts:151:12�[90m)�[39m
307:  at Module.loadGitHubSkillRepo �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/renderer/services/github-skill-store.ts:251:11�[90m)�[39m
308:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/renderer/components/skill/store-remote-sync.ts:323:16
309:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/renderer/components/skill/store-remote-sync.ts:800:19
310:  �[90mstderr�[2m | tests/unit/components/skill-store-remote.test.tsx�[2m > �[22m�[2mSkillStore remote loading�[2m > �[22m�[2mshows network guidance when GitHub cannot be reached
311:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mSkillInstaller.scanLocal (with real DB)�[2m > �[22m�[2mreturns imported count and empty skipped array for fresh import
312:  �[22m�[39mFailed to load remote store claude-code: Error: 无法连接到 GitHub,请检查当前网络,或切换网络后再试。
313:  at mapGitHubStoreError �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/renderer/services/github-skill-store.ts:159:12�[90m)�[39m
314:  at Module.loadGitHubSkillRepo �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/renderer/services/github-skill-store.ts:251:11�[90m)�[39m
...

330:  Scan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.gemini/antigravity/skills
331:  Scan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.trae/skills
332:  Scan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.trae-cn/skills
333:  Scan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.config/opencode/skills
334:  Scan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.cline/skills
335:  Scan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.codex/skills
336:  Scan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.kilo/skills
337:  Scan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.config/amp/skills
338:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mSkillInstaller.scanLocal (with real DB)�[2m > �[22m�[2mreturns imported count and empty skipped array for fresh import
339:  �[22m�[39mScan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.openclaw/skills
340:  Scan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.qoder/skills
341:  Scan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.qoderwork/skills
342:  Scan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.hermes/skills
343:  Scan path does not exist, skipping: /tmp/scanlocal-test-zeKxLG/.codebuddy/skills
344:  �[90mstderr�[2m | tests/unit/components/skill-store-remote.test.tsx�[2m > �[22m�[2mSkillStore remote loading�[2m > �[22m�[2mshows invalid repository guidance when the GitHub repo is missing
345:  �[22m�[39mFailed to load remote store claude-code: Error: 仓库不存在,或仓库地址无效,请检查 GitHub 仓库地址后重试。
346:  at mapGitHubStoreError �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/renderer/services/github-skill-store.ts:155:12�[90m)�[39m
347:  at Module.loadGitHubSkillRepo �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/renderer/services/github-skill-store.ts:251:11�[90m)�[39m
...

392:  �[22m�[39mScan path does not exist, skipping: /tmp/scanlocal-test-2JmVRg/.cline/skills
393:  Scan path does not exist, skipping: /tmp/scanlocal-test-2JmVRg/.codex/skills
394:  Scan path does not exist, skipping: /tmp/scanlocal-test-2JmVRg/.kilo/skills
395:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mSkillInstaller.scanLocal (with real DB)�[2m > �[22m�[2mreturns zero imported and empty skipped for empty directories
396:  �[22m�[39mScan path does not exist, skipping: /tmp/scanlocal-test-2JmVRg/.config/amp/skills
397:  Scan path does not exist, skipping: /tmp/scanlocal-test-2JmVRg/.openclaw/skills
398:  Scan path does not exist, skipping: /tmp/scanlocal-test-2JmVRg/.qoder/skills
399:  Scan path does not exist, skipping: /tmp/scanlocal-test-2JmVRg/.qoderwork/skills
400:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mSkillInstaller.scanLocal (with real DB)�[2m > �[22m�[2mreturns zero imported and empty skipped for empty directories
401:  �[22m�[39mScan path does not exist, skipping: /tmp/scanlocal-test-2JmVRg/.hermes/skills
402:  Scan path does not exist, skipping: /tmp/scanlocal-test-2JmVRg/.codebuddy/skills
403:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mSkillInstaller.installFromGithub�[2m > �[22m�[2maccepts self-hosted git repository URLs
404:  �[22m�[39mCloning https://gitea.example.com/icelemon/skills to /tmp/skill-installer-test-Wxw6GR/data/skills/icelemon-skills
405:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mSkillInstaller.installFromGithub�[2m > �[22m�[2mremoves the temporary clone after moving a GitHub install into the managed repo
406:  �[22m�[39mCloning https://github.com/owner/repo to /tmp/skill-installer-test-Jd42lo/data/skills/owner-repo
407:  �[90mstdout�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mSkillInstaller.installFromGithub�[2m > �[22m�[2mrolls back the created DB row when post-create persistence fails
408:  �[22m�[39mCloning https://github.com/owner/repo to /tmp/skill-installer-test-BJQznc/data/skills/owner-repo
409:  �[90mstderr�[2m | tests/unit/main/skill-installer.test.ts�[2m > �[22m�[2mSkillInstaller.installFromGithub�[2m > �[22m�[2mrolls back the created DB row when post-create persistence fails
410:  �[22m�[39mInstallation failed: Error: update failed
411:  at Object.<anonymous> �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/main/skill-installer.test.ts:3172:15�[90m)�[39m
...

435:  �[33m�[2m✓�[22m�[39m SkillStore remote loading�[2m > �[22muses batch mode card clicks for selection and keeps detail as an icon action �[33m323�[2mms�[22m�[39m
436:  �[33m�[2m✓�[22m�[39m SkillStore remote loading�[2m > �[22mtoggles select visible back to deselect visible in store batch mode �[33m593�[2mms�[22m�[39m
437:  �[33m�[2m✓�[22m�[39m SkillStore remote loading�[2m > �[22mbatch installs only selected store skills that are not already imported �[33m512�[2mms�[22m�[39m
438:  �[33m�[2m✓�[22m�[39m SkillStore remote loading�[2m > �[22mbatch removes only selected imported store skills from My Skills �[33m626�[2mms�[22m�[39m
439:  �[32m✓�[39m tests/unit/components/skill-i18n-smoke.test.tsx �[2m(�[22m�[2m24 tests�[22m�[2m)�[22m�[33m 12782�[2mms�[22m�[39m
440:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mrenders skill manager actions in english and updates selection summary �[33m2361�[2mms�[22m�[39m
441:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mkeeps the scan preview usable when a modal rescan times out �[33m300�[2mms�[22m�[39m
442:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mlets users choose the skill gallery card column count �[33m684�[2mms�[22m�[39m
443:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mshows deployed and pending as My Skills header filters �[33m942�[2mms�[22m�[39m
444:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mfilters My Skills by source badge from the header dropdown �[33m1388�[2mms�[22m�[39m
445:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mrenders skill detail page chrome in english without chinese fallback text �[33m489�[2mms�[22m�[39m
446:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mchecks and applies source updates from installed GitHub skill detail �[33m986�[2mms�[22m�[39m
447:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mshows project deployment actions for normal library skills �[33m1650�[2mms�[22m�[39m
448:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mrenders project skill preview without leaking raw SKILL.md into the preview sidebar �[33m602�[2mms�[22m�[39m
449:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mdefaults to saved translation and toggles back to original content �[33m803�[2mms�[22m�[39m
450:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mshows a clear configuration error when no usable translation model is configured �[33m310�[2mms�[22m�[39m
451:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mshows a clear timeout error when translation request returns 504 �[33m429�[2mms�[22m�[39m
452:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mwarns when symlink install falls back to copy mode �[33m387�[2mms�[22m�[39m
453:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mexports a full local repo zip from the detail panel �[33m335�[2mms�[22m�[39m
454:  �[33m�[2m✓�[22m�[39m skill i18n smoke�[2m > �[22mpaginates large skill lists �[33m564�[2mms�[22m�[39m
455:  �[32m✓�[39m tests/unit/components/skill-projects-view.test.tsx �[2m(�[22m�[2m22 tests�[22m�[2m)�[22m�[33m 14116�[2mms�[22m�[39m
456:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mshows project skill cards first and opens project detail actions after click �[33m2643�[2mms�[22m�[39m
457:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mshows a source-target action for external symlink project skills �[33m365�[2mms�[22m�[39m
458:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mremoves a registered project after confirmation without deleting files �[33m429�[2mms�[22m�[39m
459:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mkeeps the project skill view selected when opening and returning from project detail �[33m467�[2mms�[22m�[39m
460:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mshows imported card shortcuts and keeps project detail actions for imported skills �[33m945�[2mms�[22m�[39m
461:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mdoes not treat same-name project skills as imported when paths differ �[33m606�[2mms�[22m�[39m
462:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mtreats a project copy with the same directory fingerprint as a My Skills install �[33m819�[2mms�[22m�[39m
463:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mimports project skills into my skills with copy mode �[33m449�[2mms�[22m�[39m
464:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mshows remove from project for already imported project skills �[33m354�[2mms�[22m�[39m
465:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22msupports advanced import targets and custom folders �[33m1265�[2mms�[22m�[39m
466:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22msupports symlink mode when importing my skills into a project �[33m932�[2mms�[22m�[39m
467:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mremembers project import preferences after reopening the modal �[33m2474�[2mms�[22m�[39m
468:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mwarns when background rescan fails after a successful import �[33m487�[2mms�[22m�[39m
469:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mdeploys a project-local skill to the default project target �[33m523�[2mms�[22m�[39m
470:  �[33m�[2m✓�[22m�[39m SkillProjectsView�[2m > �[22mblocks redeploying a project-local skill into its current target tree �[33m432�[2mms�[22m�[39m
471:  �[32m✓�[39m tests/unit/services/self-hosted-sync.test.ts �[2m(�[22m�[2m9 tests�[22m�[2m)�[22m�[90m 50�[2mms�[22m�[39m
472:  �[90mstderr�[2m | tests/unit/components/data-settings.test.tsx�[2m > �[22m�[2mDataSettings�[2m > �[22m�[2mruns S3 connection checks from the settings panel
473:  �[22m�[39mNot implemented: navigation to another Document
474:  �[32m✓�[39m tests/unit/components/data-settings.test.tsx �[2m(�[22m�[2m25 tests�[22m�[2m)�[22m�[33m 7157�[2mms�[22m�[39m
475:  �[33m�[2m✓�[22m�[39m DataSettings�[2m > �[22mshows the real current data path and the pending path after restart �[33m344�[2mms�[22m�[39m
476:  �[33m�[2m✓�[22m�[39m DataSettings�[2m > �[22moffers switching instead of migrating when the selected directory already has data �[33m1318�[2mms�[22m�[39m
477:  �[33m�[2m✓�[22m�[39m DataSettings�[2m > �[22mmigrates immediately after confirmation when the selected directory is empty �[33m1314�[2mms�[22m�[39m
478:  �[33m�[2m✓�[22m�[39m DataSettings�[2m > �[22mdoes not prompt for restart when the chosen data directory is already active �[33m1295�[2mms�[22m�[39m
479:  �[90mstderr�[2m | tests/unit/services/database-backup.test.ts�[2m > �[22m�[2mdatabase-backup restore�[2m > �[22m�[2mfails export when referenced media cannot be read completely
480:  �[22m�[39mFailed to read image broken-image.png: Error: missing image
481:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/services/database-backup.test.ts:479:52
482:  �[32m✓�[39m tests/unit/services/database-backup.test.ts �[2m(�[22m�[2m19 tests�[22m�[2m)�[22m�[90m 129�[2mms�[22m�[39m
483:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:146:14
484:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:533:11
485:  at runWithTimeout (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:39:7)
486:  at runTest (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1056:17)
487:  at runSuite (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1205:15)
488:  at runSuite (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1205:15)
489:  at runFiles (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1262:5)
490:  at startTests (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1271:3)
491:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/vitest@2.1.9_@types+node@24.12.4_jsdom@27.3.0/node_modules/�[4mvitest�[24m/dist/chunks/runBaseTests.3qpJUEJM.js:126:11
492:  �[90mstderr�[2m | tests/unit/services/database-backup.test.ts�[2m > �[22m�[2mdatabase-backup restore�[2m > �[22m�[2mfails export when skill metadata cannot be collected completely
493:  �[22m�[39mFailed to get versions for skill writer: Error: db busy
494:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/services/database-backup.test.ts:495:54
495:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:146:14
496:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:533:11
497:  at runWithTimeout (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:39:7)
498:  at runTest (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1056:17)
499:  at runSuite (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1205:15)
500:  at runSuite (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1205:15)
501:  at runFiles (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1262:5)
502:  at startTests (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1271:3)
503:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/vitest@2.1.9_@types+node@24.12.4_jsdom@27.3.0/node_modules/�[4mvitest�[24m/dist/chunks/runBaseTests.3qpJUEJM.js:126:11
504:  �[90mstderr�[2m | tests/unit/services/database-backup.test.ts�[2m > �[22m�[2mdatabase-backup restore�[2m > �[22m�[2mthrows when backup restore cannot fully write assets
505:  �[22m�[39mFailed to restore image image-1.png: Error: disk full
506:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/services/database-backup.test.ts:704:52
...

510:  at runTest (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1056:17)
511:  at runSuite (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1205:15)
512:  at runSuite (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1205:15)
513:  at runFiles (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1262:5)
514:  at startTests (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1271:3)
515:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/vitest@2.1.9_@types+node@24.12.4_jsdom@27.3.0/node_modules/�[4mvitest�[24m/dist/chunks/runBaseTests.3qpJUEJM.js:126:11
516:  �[32m✓�[39m tests/unit/components/ai-settings-prototype.test.tsx �[2m(�[22m�[2m32 tests�[22m�[2m)�[22m�[33m 14713�[2mms�[22m�[39m
517:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mrenders translated English copy instead of hard-coded Chinese �[33m979�[2mms�[22m�[39m
518:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mupdates endpoint api key and url inline without opening the edit dialog �[33m362�[2mms�[22m�[39m
519:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mpersists chat parameters when adding a chat model �[33m980�[2mms�[22m�[39m
520:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mpersists the vision model capability flag �[33m447�[2mms�[22m�[39m
521:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mpersists image generation as a model capability from the capability section �[33m496�[2mms�[22m�[39m
522:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mpreserves both chat and image parameters for dual-capability models �[33m687�[2mms�[22m�[39m
523:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mkeeps advanced parameters collapsed by default in the add model modal �[33m542�[2mms�[22m�[39m
524:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mpersists image parameters when adding an image model �[33m1414�[2mms�[22m�[39m
525:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mmaps raw network failures to a friendlier connection message �[33m419�[2mms�[22m�[39m
526:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mincludes the model name in success toasts when testing a draft chat model �[33m401�[2mms�[22m�[39m
527:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mincludes the model name in failure toasts when testing a draft chat model �[33m503�[2mms�[22m�[39m
528:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mdefaults known image models to image type when selected from fetched models �[33m471�[2mms�[22m�[39m
529:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mfetches models from the provider fetch button and batch-adds selected models �[33m543�[2mms�[22m�[39m
530:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mbatch model selection�[2m > �[22mbatch-adds all selected models when multiple are chosen �[33m956�[2mms�[22m�[39m
531:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mbatch model selection�[2m > �[22minfers each selected model independently when batch-adding mixed model types �[33m982�[2mms�[22m�[39m
532:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mbatch model selection�[2m > �[22muses the single-add path when only one model is selected �[33m905�[2mms�[22m�[39m
533:  �[33m�[2m✓�[22m�[39m AISettingsPrototype�[2m > �[22mbatch model selection�[2m > �[22mbatch-adds share the same provider, apiKey, and apiUrl from the form �[33m1297�[2mms�[22m�[39m
534:  �[32m✓�[39m tests/unit/main/prompt-db.test.ts �[2m(�[22m�[2m57 tests�[22m�[2m)�[22m�[33m 903�[2mms�[22m�[39m
535:  �[90mstderr�[2m | tests/unit/main/skill-installer-utils.test.ts�[2m > �[22m�[2mskill-installer-utils�[2m > �[22m�[2mgetPlatformSkillsDir�[2m > �[22m�[2mhandles DB read failure gracefully (returns built-in path)
536:  �[22m�[39mFailed to read built-in agent overrides: Error: DB not available
537:  at Proxy.<anonymous> �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/main/skill-installer-utils.test.ts:256:15�[90m)�[39m
538:  at Proxy.mockCall (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+spy@2.1.9/node_modules/�[4m@vitest/spy�[24m/dist/index.js:61:17)
539:  at Proxy.spy (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/tinyspy@3.0.2/node_modules/�[4mtinyspy�[24m/dist/index.js:45:80)
540:  at readBuiltinAgentOverridesFromSettings �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:400:16�[90m)�[39m
541:  at getBuiltinAgentOverride �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:551:10�[90m)�[39m
542:  at getPlatformRootDir �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:558:27�[90m)�[39m
543:  at Module.getPlatformSkillsDir �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:577:19�[90m)�[39m
544:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/main/skill-installer-utils.test.ts:263:28
545:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:146:14
546:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:533:11
547:  �[90mstderr�[2m | tests/unit/main/skill-installer-utils.test.ts�[2m > �[22m�[2mskill-installer-utils�[2m > �[22m�[2mgetPlatformSkillsDir�[2m > �[22m�[2mhandles malformed JSON in DB gracefully
548:  �[22m�[39mFailed to read built-in agent overrides: SyntaxError: Unexpected token 'o', "not valid json!" is not valid JSON
549:  at JSON.parse (<anonymous>)
550:  at parseJsonSetting �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:384:15�[90m)�[39m
551:  at readBuiltinAgentOverridesFromSettings �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:419:7�[90m)�[39m
552:  at getBuiltinAgentOverride �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:551:10�[90m)�[39m
553:  at getPlatformRootDir �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:558:27�[90m)�[39m
554:  at Module.getPlatformSkillsDir �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:577:19�[90m)�[39m
555:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/main/skill-installer-utils.test.ts:279:28
556:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:146:14
557:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:533:11
558:  at runWithTimeout (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:39:7)
559:  �[90mstderr�[2m | tests/unit/main/skill-installer-utils.test.ts�[2m > �[22m�[2mskill-installer-utils�[2m > �[22m�[2mgetPlatformGlobalRulePath�[2m > �[22m�[2mderives the Windsurf global rules file from the platform root
560:  �[22m�[39mFailed to read built-in agent overrides: SyntaxError: Unexpected token 'o', "not valid json!" is not valid JSON
561:  at JSON.parse (<anonymous>)
562:  at parseJsonSetting �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:384:15�[90m)�[39m
563:  at readBuiltinAgentOverridesFromSettings �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:419:7�[90m)�[39m
564:  at getBuiltinAgentOverride �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:551:10�[90m)�[39m
565:  at Module.getPlatformGlobalRulePath �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/skill-installer-utils.ts:590:5�[90m)�[39m
566:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/main/skill-installer-utils.test.ts:289:28
567:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:146:14
568:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:533:11
569:  at runWithTimeout (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:39:7)
570:  at runTest (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1056:17)
571:  �[90mstderr�[2m | tests/unit/main/skill-installer-utils.test.ts�[2m > �[22m�[2mskill-installer-utils�[2m > �[22m�[2mgetPlatformGlobalRulePath�[2m > �[22m�[2muses explicit root overrides for the Windsurf global rules file
572:  �[22m�[39mFailed to read built-in agent overrides: SyntaxError: Unexpected token 'o', "not valid json!" is not valid JSON
573:  at JSON.parse (<anonymous>)
...

792:  Database initialized at: /tmp/prompthub-rules-d42kNh/data/prompthub.db
793:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2mremoves a project rule from files and SQLite index
794:  �[22m�[39m[startup] Pre-0.5.3 backup created at: /tmp/prompthub-rules-d42kNh/data/prompthub.db.backup-before-0.5.3.2026-06-14T03-15-46-502Z.db
795:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2mimports backup records into managed files and SQLite index
796:  �[22m�[39mMigrating: Aligning prompt current_version with latest stored version
797:  Database initialized at: /tmp/prompthub-rules-SxNRaT/data/prompthub.db
798:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2mimports backup records into managed files and SQLite index
799:  �[22m�[39m[startup] Pre-0.5.3 backup created at: /tmp/prompthub-rules-SxNRaT/data/prompthub.db.backup-before-0.5.3.2026-06-14T03-15-46-631Z.db
800:  �[90mstderr�[2m | tests/unit/main/data-recovery.test.ts�[2m > �[22m�[2mData Recovery�[2m > �[22m�[2mdetectRecoverableDatabases�[2m > �[22m�[2mhandles corrupt database files gracefully
801:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2mremoves project rules missing from a replace import
802:  �[22m�[39mMigrating: Aligning prompt current_version with latest stored version
803:  Database initialized at: /tmp/prompthub-rules-1i4cyl/data/prompthub.db
804:  �[90mstdout�[2m | tests/unit/main/data-recovery.test.ts�[2m > �[22m�[2mData Recovery�[2m > �[22m�[2mperformDatabaseRecovery�[2m > �[22m�[2mcopies the source database to the target path
805:  �[22m�[39m[Recovery] Backed up current DB to: /tmp/data-recovery-test-eCnNac/target/prompthub.db.pre-recovery-2026-06-14T03-15-45-149Z
806:  [Recovery] Copied database from /tmp/data-recovery-test-eCnNac/source/prompthub.db to /tmp/data-recovery-test-eCnNac/target/prompthub.db
807:  �[22m�[39m[Recovery] Failed to inspect candidate database at /tmp/data-recovery-test-9o8Ot7/corrupt/prompthub.db: SQLite3Error: file is not a database
808:  �[90mstdout�[2m | tests/unit/main/data-recovery.test.ts�[2m > �[22m�[2mData Recovery�[2m > �[22m�[2mperformDatabaseRecovery�[2m > �[22m�[2mcreates a backup of the existing target database
809:  �[22m�[39m[Recovery] Backed up current DB to: /tmp/data-recovery-test-qpYYgx/target/prompthub.db.pre-recovery-2026-06-14T03-15-45-317Z
810:  [Recovery] Copied database from /tmp/data-recovery-test-qpYYgx/source/prompthub.db to /tmp/data-recovery-test-qpYYgx/target/prompthub.db
811:  at Database._handleError (/home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/node-sqlite3-wasm@0.8.53/node_modules/�[4mnode-sqlite3-wasm�[24m/dist/node-sqlite3-wasm.js:1:5811)
812:  �[90mstdout�[2m | tests/unit/main/data-recovery.test.ts�[2m > �[22m�[2mData Recovery�[2m > �[22m�[2mperformDatabaseRecovery�[2m > �[22m�[2mmerges asset directories from source
...

870:  Database initialized at: /tmp/prompthub-rules-qQ3rvo/data/prompthub.db
871:  [startup] Pre-0.5.3 backup created at: /tmp/prompthub-rules-qQ3rvo/data/prompthub.db.backup-before-0.5.3.2026-06-14T03-15-47-597Z.db
872:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2muses the overridden target file name for built-in global rule descriptors
873:  �[22m�[39mMigrating: Aligning prompt current_version with latest stored version
874:  Database initialized at: /tmp/prompthub-rules-vsSuMo/data/prompthub.db
875:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2muses the overridden target file name for built-in global rule descriptors
876:  �[22m�[39m[startup] Pre-0.5.3 backup created at: /tmp/prompthub-rules-vsSuMo/data/prompthub.db.backup-before-0.5.3.2026-06-14T03-15-47-771Z.db
877:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2msupports custom agent global rule files
878:  �[22m�[39mMigrating: Aligning prompt current_version with latest stored version
879:  Database initialized at: /tmp/prompthub-rules-1fLm7a/data/prompthub.db
880:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2msupports custom agent global rule files
881:  �[22m�[39m[startup] Pre-0.5.3 backup created at: /tmp/prompthub-rules-1fLm7a/data/prompthub.db.backup-before-0.5.3.2026-06-14T03-15-47-982Z.db
882:  �[90mstderr�[2m | tests/unit/main/prompt-workspace.test.ts�[2m > �[22m�[2mprompt workspace storage�[2m > �[22m�[2mimportPromptWorkspaceIntoDatabase resolves duplicate prompt.id by newer updatedAt, trashing losers
883:  �[22m�[39m[prompt-workspace] same-id conflict for prompt dup-id-abc: moving /tmp/prompthub-workspace-jpXP8z/data/prompts/loser__dup-id-abc/prompt.md to .trash/conflicts
884:  �[32m✓�[39m tests/unit/main/prompt-workspace.test.ts �[2m(�[22m�[2m14 tests�[22m�[2m)�[22m�[33m 303�[2mms�[22m�[39m
885:  [prompt-workspace] import completed with skips: 0 folders, 0 prompts, 1 conflict losers. See errors above. User data on disk is untouched; see .trash for orphans and .trash/conflicts for conflict losers.
886:  �[90mstderr�[2m | tests/unit/main/prompt-workspace.test.ts�[2m > �[22m�[2mprompt workspace storage�[2m > �[22m�[2mQ4 passes skippedPromptDirs to Phase 2 so insert-failing imports are not trashed as orphans
887:  �[22m�[39m[prompt-workspace] failed to import prompt fk-fail-xyz789 (FK Fail): SQLite3Error: FOREIGN KEY constraint failed
888:  at Database._handleError (/home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/node-sqlite3-wasm@0.8.53/node_modules/�[4mnode-sqlite3-wasm�[24m/dist/node-sqlite3-wasm.js:1:5811)
889:  at Statement._step (/home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/node-sqlite3-wasm@0.8.53/node_modules/�[4mnode-sqlite3-wasm�[24m/dist/node-sqlite3-wasm.js:1:7500)
890:  at Statement.run (/home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/node-sqlite3-wasm@0.8.53/node_modules/�[4mnode-sqlite3-wasm�[24m/dist/node-sqlite3-wasm.js:1:6254)
891:  at Statement.run (/home/runner/work/PromptHub/PromptHub/packages/db/src/adapter.ts:58:19)
892:  at PromptDB.insertPromptDirect (/home/runner/work/PromptHub/PromptHub/packages/db/src/prompt.ts:531:8)
893:  at importPromptWorkspaceIntoDatabase �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/prompt-workspace.ts:1235:16�[90m)�[39m
894:  at Module.bootstrapPromptWorkspace �[90m(/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39msrc/main/services/prompt-workspace.ts:1392:20�[90m)�[39m
895:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/main/prompt-workspace.test.ts:624:20
896:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:146:14
897:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:533:11
898:  [prompt-workspace] import completed with skips: 0 folders, 1 prompts, 0 conflict losers. See errors above. User data on disk is untouched; see .trash for orphans and .trash/conflicts for conflict losers.
899:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2mdrops cached custom rule descriptors when the custom agent is no longer configured
900:  �[22m�[39mMigrating: Aligning prompt current_version with latest stored version
901:  Database initialized at: /tmp/prompthub-rules-9FWQF7/data/prompthub.db
902:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2mdrops cached custom rule descriptors when the custom agent is no longer configured
903:  �[22m�[39m[startup] Pre-0.5.3 backup created at: /tmp/prompthub-rules-9FWQF7/data/prompthub.db.backup-before-0.5.3.2026-06-14T03-15-48-252Z.db
904:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2mskips missing version files and repairs the index instead of crashing
905:  �[22m�[39mMigrating: Aligning prompt current_version with latest stored version
906:  Database initialized at: /tmp/prompthub-rules-UEF4qm/data/prompthub.db
907:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2mskips missing version files and repairs the index instead of crashing
908:  �[22m�[39m[startup] Pre-0.5.3 backup created at: /tmp/prompthub-rules-UEF4qm/data/prompthub.db.backup-before-0.5.3.2026-06-14T03-15-48-389Z.db
909:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2mdoes not create duplicate initial versions when re-materializing a global rule
910:  �[22m�[39mMigrating: Aligning prompt current_version with latest stored version
911:  Database initialized at: /tmp/prompthub-rules-uEn2yu/data/prompthub.db
912:  �[90mstdout�[2m | tests/unit/main/rules-workspace.test.ts�[2m > �[22m�[2mrules workspace storage�[2m > �[22m�[2mdoes not create duplicate initial versions when re-materializing a global rule
913:  �[22m�[39m[startup] Pre-0.5.3 backup created at: /tmp/prompthub-rules-uEn2yu/data/prompthub.db.backup-before-0.5.3.2026-06-14T03-15-48-523Z.db
914:  �[31m❯�[39m tests/unit/main/rules-workspace.test.ts �[2m(�[22m�[2m17 tests�[22m�[2m | �[22m�[31m1 failed�[39m�[2m)�[22m�[33m 3055�[2mms�[22m�[39m
915:  �[33m�[2m✓�[22m�[39m rules workspace storage�[2m > �[22mkeeps unique history after the version retention limit �[33m338�[2mms�[22m�[39m
...

920:  �[32m✓�[39m tests/unit/components/top-bar.test.tsx �[2m(�[22m�[2m12 tests�[22m�[2m)�[22m�[33m 879�[2mms�[22m�[39m
921:  �[33m�[2m✓�[22m�[39m TopBar�[2m > �[22mrenders the create mode dropdown in a portal when the split button is opened �[33m347�[2mms�[22m�[39m
922:  �[32m✓�[39m tests/unit/components/skill-detail-project-distribution.test.tsx �[2m(�[22m�[2m17 tests�[22m�[2m)�[22m�[33m 11709�[2mms�[22m�[39m
923:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22mdefaults to global distribution when opening a skill detail �[33m922�[2mms�[22m�[39m
924:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22mrequires confirmation before uninstalling a global platform skill �[33m770�[2mms�[22m�[39m
925:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22mshows the skill and platform name after a global platform install succeeds �[33m542�[2mms�[22m�[39m
926:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22mlets users keep copied distributions when deleting from PromptHub �[33m377�[2mms�[22m�[39m
927:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22mdeletes project symlink distributions when deleting the PromptHub skill �[33m394�[2mms�[22m�[39m
928:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22muses repo path and skip semantics when distributing from detail page �[33m842�[2mms�[22m�[39m
929:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22msupports advanced project target folders from the detail page �[33m1628�[2mms�[22m�[39m
930:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22mreuses saved project import target preferences from the detail page �[33m763�[2mms�[22m�[39m
931:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22mskips already imported project targets �[33m883�[2mms�[22m�[39m
932:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22mremoves an already distributed project target from the detail page �[33m1014�[2mms�[22m�[39m
933:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22mwarns instead of copying when the selected target is the source location �[33m550�[2mms�[22m�[39m
934:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22msupports symlink mode with the same skip behavior �[33m1347�[2mms�[22m�[39m
935:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22mshows an error when selected projects have no deploy targets �[33m880�[2mms�[22m�[39m
936:  �[33m�[2m✓�[22m�[39m Skill detail project distribution�[2m > �[22mwarns when background rescan fails after a successful distribution �[33m635�[2mms�[22m�[39m
937:  �[32m✓�[39m tests/unit/components/rules-manager.test.tsx �[2m(�[22m�[2m7 tests�[22m�[2m)�[22m�[33m 2166�[2mms�[22m�[39m
...

999:  �[32m✓�[39m tests/unit/services/skill-store-search.test.ts �[2m(�[22m�[2m24 tests�[22m�[2m)�[22m�[90m 25�[2mms�[22m�[39m
1000:  �[32m✓�[39m tests/unit/components/update-dialog.test.tsx �[2m(�[22m�[2m5 tests�[22m�[2m)�[22m�[33m 961�[2mms�[22m�[39m
1001:  �[33m�[2m✓�[22m�[39m UpdateDialog�[2m > �[22mkeeps download enabled because install creates an automatic data snapshot �[33m598�[2mms�[22m�[39m
1002:  �[32m✓�[39m tests/unit/components/skill-detail-utils.test.ts �[2m(�[22m�[2m11 tests�[22m�[2m)�[22m�[90m 38�[2mms�[22m�[39m
1003:  �[90mstdout�[2m | tests/unit/components/prompt-modal-structure.test.tsx�[2m > �[22m�[2mPrompt modal structure�[2m > �[22m�[2mgenerates an AI rewrite draft and allows undoing it
1004:  �[22m�[39m[AI Service] Stream mode: �[33mfalse�[39m Callbacks provided: �[33mfalse�[39m
1005:  �[32m✓�[39m tests/unit/components/skill-settings.test.tsx �[2m(�[22m�[2m10 tests�[22m�[2m)�[22m�[33m 9647�[2mms�[22m�[39m
1006:  �[33m�[2m✓�[22m�[39m SkillSettings�[2m > �[22mshows the preferred default platform order �[33m643�[2mms�[22m�[39m
1007:  �[33m�[2m✓�[22m�[39m SkillSettings�[2m > �[22mreorders platforms through drag and drop �[33m410�[2mms�[22m�[39m
1008:  �[33m�[2m✓�[22m�[39m SkillSettings�[2m > �[22madds a custom agent root and shows derived asset previews �[33m1604�[2mms�[22m�[39m
1009:  �[33m�[2m✓�[22m�[39m SkillSettings�[2m > �[22mfills the custom agent root path from folder picker �[33m1282�[2mms�[22m�[39m
1010:  �[33m�[2m✓�[22m�[39m SkillSettings�[2m > �[22mrequires confirmation before deleting a custom agent �[33m2391�[2mms�[22m�[39m
1011:  �[33m�[2m✓�[22m�[39m SkillSettings�[2m > �[22mupdates built-in agent override fields from the unified config section �[33m1393�[2mms�[22m�[39m
1012:  �[33m�[2m✓�[22m�[39m SkillSettings�[2m > �[22mresets built-in edit form without persisting until save �[33m324�[2mms�[22m�[39m
1013:  �[33m�[2m✓�[22m�[39m SkillSettings�[2m > �[22msaves cleared built-in override fields as defaults instead of keeping stale values �[33m1102�[2mms�[22m�[39m
1014:  �[90mstdout�[2m | tests/unit/components/prompt-modal-structure.test.tsx�[2m > �[22m�[2mPrompt modal structure�[2m > �[22m�[2msurfaces rewrite failures from the AI service
1015:  �[22m�[39m[AI Service] Stream mode: �[33mfalse�[39m Callbacks provided: �[33mfalse�[39m
1016:  �[32m✓�[39m tests/unit/components/prompt-modal-structure.test.tsx �[2m(�[22m�[2m6 tests�[22m�[2m)�[22m�[33m 3477�[2mms�[22m�[39m
1017:  �[33m�[2m✓�[22m�[39m Prompt modal structure�[2m > �[22mkeeps create modal first screen focused on type and prompt content �[33m1031�[2mms�[22m�[39m
1018:  �[33m�[2m✓�[22m�[39m Prompt modal structure�[2m > �[22mkeeps text prompt reference media inside more settings when editing �[33m377�[2mms�[22m�[39m
1019:  �[33m�[2m✓�[22m�[39m Prompt modal structure�[2m > �[22mkeeps image prompt reference media in basic info when editing �[33m300�[2mms�[22m�[39m
1020:  �[33m�[2m✓�[22m�[39m Prompt modal structure�[2m > �[22mgenerates an AI rewrite draft and allows undoing it �[33m1185�[2mms�[22m�[39m
1021:  �[33m�[2m✓�[22m�[39m Prompt modal structure�[2m > �[22msurfaces rewrite failures from the AI service �[33m306�[2mms�[22m�[39m
1022:  �[32m✓�[39m tests/unit/stores/settings-ai-models.test.ts �[2m(�[22m�[2m8 tests�[22m�[2m)�[22m�[90m 101�[2mms�[22m�[39m
...

1033:  �[32m✓�[39m tests/unit/services/database.test.ts �[2m(�[22m�[2m10 tests�[22m�[2m)�[22m�[90m 17�[2mms�[22m�[39m
1034:  �[32m✓�[39m tests/unit/components/skill-store-installed-state.test.tsx �[2m(�[22m�[2m2 tests�[22m�[2m)�[22m�[33m 614�[2mms�[22m�[39m
1035:  �[33m�[2m✓�[22m�[39m SkillStore installed state�[2m > �[22mshows a Claude Code store skill as imported when a legacy install only matches by content URL �[33m380�[2mms�[22m�[39m
1036:  �[32m✓�[39m tests/unit/services/ai-defaults.test.ts �[2m(�[22m�[2m9 tests�[22m�[2m)�[22m�[90m 10�[2mms�[22m�[39m
1037:  �[32m✓�[39m tests/unit/components/settings-page.test.tsx �[2m(�[22m�[2m7 tests�[22m�[2m)�[22m�[33m 2462�[2mms�[22m�[39m
1038:  �[33m�[2m✓�[22m�[39m SettingsPage�[2m > �[22mshows enabled badge on active cloud backup targets in the data submenu �[33m1133�[2mms�[22m�[39m
1039:  �[33m�[2m✓�[22m�[39m SettingsPage�[2m > �[22mshows a standalone agent management entry in the desktop settings navigation �[33m340�[2mms�[22m�[39m
1040:  �[33m�[2m✓�[22m�[39m SettingsPage�[2m > �[22mlets the model service page own its provider middle column �[33m462�[2mms�[22m�[39m
1041:  �[90mstdout�[2m | tests/unit/components/prompt-quick-rewrite-dialog.test.tsx�[2m > �[22m�[2mPromptQuickRewriteDialog�[2m > �[22m�[2mrenders quick rewrite entry and previews generated draft
1042:  �[22m�[39m[AI Service] Stream mode: �[33mfalse�[39m Callbacks provided: �[33mfalse�[39m
1043:  �[90mstdout�[2m | tests/unit/components/prompt-quick-rewrite-dialog.test.tsx�[2m > �[22m�[2mPromptQuickRewriteDialog�[2m > �[22m�[2mapplies the draft and opens the editor when continue editing is selected
1044:  �[22m�[39m[AI Service] Stream mode: �[33mfalse�[39m Callbacks provided: �[33mfalse�[39m
1045:  �[32m✓�[39m tests/unit/components/data-recovery-dialog.test.tsx �[2m(�[22m�[2m4 tests�[22m�[2m)�[22m�[33m 956�[2mms�[22m�[39m
1046:  �[33m�[2m✓�[22m�[39m DataRecoveryDialog�[2m > �[22mhides start-fresh action in settings-triggered recovery browser �[33m418�[2mms�[22m�[39m
1047:  �[33m�[2m✓�[22m�[39m DataRecoveryDialog�[2m > �[22mrenders multiple candidates, previews the selected source, and restores the chosen one �[33m420�[2mms�[22m�[39m
1048:  �[90mstdout�[2m | tests/unit/components/prompt-quick-rewrite-dialog.test.tsx�[2m > �[22m�[2mPromptQuickRewriteDialog�[2m > �[22m�[2mshows errors when AI rewrite response is invalid
1049:  �[22m�[39m[AI Service] Stream mode: �[33mfalse�[39m Callbacks provided: �[33mfalse�[39m
...

1130:  �[33m�[2m✓�[22m�[39m SkillVersionHistoryModal�[2m > �[22mdeletes one skill snapshot from version history �[33m804�[2mms�[22m�[39m
1131:  �[32m✓�[39m tests/unit/stores/settings-startup.test.ts �[2m(�[22m�[2m4 tests�[22m�[2m)�[22m�[90m 55�[2mms�[22m�[39m
1132:  �[32m✓�[39m tests/unit/components/use-skill-platform.test.ts �[2m(�[22m�[2m3 tests�[22m�[2m)�[22m�[90m 4�[2mms�[22m�[39m
1133:  �[32m✓�[39m tests/unit/components/ai-settings-legacy.test.tsx �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[33m 811�[2mms�[22m�[39m
1134:  �[33m�[2m✓�[22m�[39m AISettings legacy�[2m > �[22mpreserves apiProtocol when adding a Google chat model �[33m809�[2mms�[22m�[39m
1135:  �[32m✓�[39m tests/unit/services/prompt-filter.test.ts �[2m(�[22m�[2m3 tests�[22m�[2m)�[22m�[90m 27�[2mms�[22m�[39m
1136:  �[32m✓�[39m tests/unit/components/project-skill-preview-sidebar-i18n.test.tsx �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[33m 634�[2mms�[22m�[39m
1137:  �[33m�[2m✓�[22m�[39m ProjectSkillPreviewSidebar i18n�[2m > �[22mrenders the project deployment panel in Simplified Chinese without English fallback text �[33m633�[2mms�[22m�[39m
1138:  �[32m✓�[39m tests/unit/main/shortcuts.test.ts �[2m(�[22m�[2m3 tests�[22m�[2m)�[22m�[90m 45�[2mms�[22m�[39m
1139:  �[32m✓�[39m tests/unit/components/close-dialog.test.tsx �[2m(�[22m�[2m3 tests�[22m�[2m)�[22m�[90m 287�[2mms�[22m�[39m
1140:  �[32m✓�[39m tests/unit/main/skill-version-ipc.test.ts �[2m(�[22m�[2m2 tests�[22m�[2m)�[22m�[90m 73�[2mms�[22m�[39m
1141:  �[32m✓�[39m tests/unit/components/toast.test.tsx �[2m(�[22m�[2m4 tests�[22m�[2m)�[22m�[33m 335�[2mms�[22m�[39m
1142:  �[32m✓�[39m tests/unit/services/skill-filter.test.ts �[2m(�[22m�[2m4 tests�[22m�[2m)�[22m�[90m 5�[2mms�[22m�[39m
1143:  �[90mstderr�[2m | tests/unit/main/settings-startup.test.ts�[2m > �[22m�[2mgetMinimizeOnLaunchSetting (issue #115)�[2m > �[22m�[2msurvives a malformed JSON value without throwing
1144:  �[32m✓�[39m tests/unit/main/settings-startup.test.ts �[2m(�[22m�[2m6 tests�[22m�[2m)�[22m�[90m 107�[2mms�[22m�[39m
1145:  �[22m�[39mFailed to read minimizeOnLaunch setting: SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
1146:  at JSON.parse (<anonymous>)
...

1187:  �[32m✓�[39m tests/unit/services/ai-url-preview.test.ts �[2m(�[22m�[2m6 tests�[22m�[2m)�[22m�[90m 6�[2mms�[22m�[39m
1188:  �[32m✓�[39m tests/unit/main/skill-db-versioning.test.ts �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[90m 244�[2mms�[22m�[39m
1189:  �[32m✓�[39m tests/unit/services/issue-version-label.test.ts �[2m(�[22m�[2m5 tests�[22m�[2m)�[22m�[90m 7�[2mms�[22m�[39m
1190:  �[32m✓�[39m tests/unit/components/skill-file-icons.test.ts �[2m(�[22m�[2m2 tests�[22m�[2m)�[22m�[90m 5�[2mms�[22m�[39m
1191:  �[32m✓�[39m tests/unit/services/skill-stats.test.ts �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[90m 14�[2mms�[22m�[39m
1192:  �[32m✓�[39m tests/unit/components/Button.test.tsx �[2m(�[22m�[2m5 tests�[22m�[2m)�[22m�[90m 52�[2mms�[22m�[39m
1193:  �[32m✓�[39m tests/unit/components/prompt-detail-modal.test.tsx �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[33m 501�[2mms�[22m�[39m
1194:  �[33m�[2m✓�[22m�[39m PromptDetailModal�[2m > �[22mshows AI quick edit action in the header �[33m499�[2mms�[22m�[39m
1195:  �[32m✓�[39m tests/unit/stores/settings-language.test.ts �[2m(�[22m�[2m2 tests�[22m�[2m)�[22m�[90m 42�[2mms�[22m�[39m
1196:  �[32m✓�[39m tests/unit/services/skill-registry-builtins.test.ts �[2m(�[22m�[2m2 tests�[22m�[2m)�[22m�[90m 5�[2mms�[22m�[39m
1197:  �[32m✓�[39m tests/unit/components/select.test.tsx �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[33m 353�[2mms�[22m�[39m
1198:  �[33m�[2m✓�[22m�[39m Select�[2m > �[22mrenders dropdown in a portal attached to document.body �[33m351�[2mms�[22m�[39m
1199:  �[32m✓�[39m tests/unit/components/web-workspace-settings.test.tsx �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[90m 62�[2mms�[22m�[39m
1200:  �[90mstderr�[2m | tests/unit/hooks/use-prompt-media-manager.test.ts�[2m > �[22m�[2musePromptMediaManager�[2m > �[22m�[2mshows a dedicated message when self-hosted web blocks internal image URLs
1201:  �[32m✓�[39m tests/unit/hooks/use-prompt-media-manager.test.ts �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[90m 33�[2mms�[22m�[39m
1202:  �[22m�[39mFailed to upload image from URL: Error: Access to internal network addresses is not allowed
1203:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/hooks/use-prompt-media-manager.test.ts:11:28
...

1219:  �[32m✓�[39m tests/unit/components/variable-input-modal.test.tsx �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[90m 78�[2mms�[22m�[39m
1220:  �[32m✓�[39m tests/unit/components/local-image.test.tsx �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[90m 36�[2mms�[22m�[39m
1221:  �[32m✓�[39m tests/unit/components/skill-icon.test.tsx �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[90m 176�[2mms�[22m�[39m
1222:  �[32m✓�[39m tests/unit/services/platform-visibility-integration.test.ts �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[90m 5�[2mms�[22m�[39m
1223:  �[32m✓�[39m tests/unit/components/platform-icon.test.tsx �[2m(�[22m�[2m1 test�[22m�[2m)�[22m�[90m 226�[2mms�[22m�[39m
1224:  �[90mstderr�[2m | tests/unit/main/skill-installer-platform.test.ts�[2m > �[22m�[2mskill-installer-platform symlink install�[2m > �[22m�[2mfalls back to Cherry Studio copy registration when DB-backed symlink creation is not permitted
1225:  �[90mstdout�[2m | tests/unit/main/skill-installer-platform.test.ts�[2m > �[22m�[2mskill-installer-platform symlink install�[2m > �[22m�[2mcopies the managed skill directory into the platform directory
1226:  �[22m�[39mSymlink install unsupported for "demo-skill" on Cherry Studio; falling back to copy install. Reason: EPERM: operation not permitted
1227:  �[22m�[39mSuccessfully installed skill directory for "demo-skill" to Claude Code at /platform/skills/demo-skill
1228:  �[90mstderr�[2m | tests/unit/main/skill-installer-platform.test.ts�[2m > �[22m�[2mskill-installer-platform symlink install�[2m > �[22m�[2mfalls back to copy install when symlink creation returns EPERM
1229:  �[90mstdout�[2m | tests/unit/main/skill-installer-platform.test.ts�[2m > �[22m�[2mskill-installer-platform symlink install�[2m > �[22m�[2mdereferences a root symlink source when copy-installing to a platform
1230:  �[22m�[39mSuccessfully installed skill directory for "linked-demo" to Claude Code at /platform/skills/linked-demo
1231:  �[22m�[39mSymlink install unsupported for "demo-skill" on Claude Code; falling back to copy install. Reason: EPERM: operation not permitted
1232:  �[90mstdout�[2m | tests/unit/main/skill-installer-platform.test.ts�[2m > �[22m�[2mskill-installer-platform symlink install�[2m > �[22m�[2mregisters Cherry Studio installs through its database-backed adapter
1233:  �[22m�[39mSuccessfully registered skill directory for "demo-skill" in Cherry Studio
1234:  �[90mstderr�[2m | tests/unit/main/skill-installer-platform.test.ts�[2m > �[22m�[2mskill-installer-platform symlink install�[2m > �[22m�[2mfalls back to copy install for UNKNOWN errors (Windows without Developer Mode)
1235:  �[90mstdout�[2m | tests/unit/main/skill-installer-platform.test.ts�[2m > �[22m�[2mskill-installer-platform symlink install�[2m > �[22m�[2mallows symlink installs for custom agents
1236:  �[22m�[39mSymlinked "demo-skill" repo directory → Team Agents: /prompthub/skills/demo-skill → /platform/skills/demo-skill
1237:  �[22m�[39mSymlink install unsupported for "demo-skill" on Claude Code; falling back to copy install. Reason: UNKNOWN: unknown symlink failure
1238:  �[90mstdout�[2m | tests/unit/main/skill-installer-platform.test.ts�[2m > �[22m�[2mskill-installer-platform symlink install�[2m > �[22m�[2mfalls back to Cherry Studio copy registration when DB-backed symlink creation is not permitted
1239:  �[90mstderr�[2m | tests/unit/main/skill-installer-platform.test.ts�[2m > �[22m�[2mskill-installer-platform symlink install�[2m > �[22m�[2mrethrows with an actionable error message when no fallback applies
1240:  �[22m�[39mFailed to create symlink for "demo-skill" to Claude Code: Error: disk is full
1241:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/main/skill-installer-platform.test.ts:356:37
1242:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:146:14
1243:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:533:11
1244:  at runWithTimeout (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:39:7)
1245:  at runTest (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1056:17)
1246:  �[90m    at processTicksAndRejections (node:internal/process/task_queues:104:5)�[39m
1247:  at runSuite (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1205:15)
1248:  at runSuite (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1205:15)
1249:  at runFiles (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1262:5)
1250:  at startTests (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1271:3) {
1251:  code: �[32m'ENOSPC'�[39m
1252:  }
1253:  �[90mstderr�[2m | tests/unit/main/skill-installer-platform.test.ts�[2m > �[22m�[2mskill-installer-platform symlink install�[2m > �[22m�[2mpropagates Cherry Studio built-in uninstall rejection without clearing activation or deleting files
1254:  �[22m�[39mFailed to uninstall SKILL.md from Cherry Studio: Error: Cannot uninstall Cherry Studio built-in skill
1255:  at �[90m/home/runner/work/PromptHub/PromptHub/apps/desktop/�[39mtests/unit/main/skill-installer-platform.test.ts:589:26
1256:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:146:14
1257:  at file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:533:11
1258:  at runWithTimeout (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:39:7)
1259:  at runTest (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1056:17)
1260:  �[90m    at processTicksAndRejections (node:internal/process/task_queues:104:5)�[39m
1261:  at runSuite (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1205:15)
1262:  at runSuite (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1205:15)
1263:  at runFiles (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_modules/�[4m@vitest/runner�[24m/dist/index.js:1262:5)
1264:  at startTests (file:///home/runner/work/PromptHub/PromptHub/node_modules/�[4m.pnpm�[24m/@vitest+runner@2.1.9/node_...

: ''
}
`}
style={{ paddingLeft: `${depth * 16 + 12}px` }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Inline style in promptlistview 📘 Rule violation ⚙ Maintainability

8.6

PromptListView uses an inline style={{ paddingLeft: ... }} to render hierarchy indentation. This
violates the Tailwind-only styling rule and can lead to inconsistent theming and styling drift.
Agent Prompt
## Issue description
`PromptListView` uses inline `style` for indentation (`paddingLeft`), but UI styling must be Tailwind-only.

## Issue Context
Indentation is currently calculated from `depth` and applied via inline style. Replace it with a Tailwind-only approach (e.g., render `depth` spacer elements with fixed Tailwind widths like `w-4` / `shrink-0`).

## Fix Focus Areas
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[370-409]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +209 to +500
return date.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
});
}

if (diffDays === 1) {
return t('common.yesterday') || '昨天';
} else if (diffDays < 7) {
}

if (diffDays < 7) {
return `${diffDays}${t('common.daysAgo') || '天前'}`;
} else {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}

return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
};

return (
<div className="flex flex-col">
{prompts.map((prompt) => (
<div
key={prompt.id}
onClick={() => onSelect(prompt.id)}
onContextMenu={(e) => onContextMenu(e, prompt)}
className={`
flex items-center gap-3 px-3 py-2.5 border-b border-border/50 cursor-pointer
transition-colors duration-quick
${selectedId === prompt.id
? 'bg-primary/10 border-l-2 border-l-primary'
: 'hover:bg-accent/50'
}
`}
>
{/* Title and description */}
{/* 标题和描述 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3
className={`font-medium text-sm leading-snug break-words line-clamp-2 ${
selectedId === prompt.id ? 'text-primary' : 'text-foreground'
}`}
title={prompt.title}
>
{prompt.title}
</h3>
{prompt.isFavorite && (
<StarIcon className="w-3 h-3 flex-shrink-0 fill-yellow-400 text-yellow-400" />
const toggleExpand = useCallback((promptId: string) => {
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(promptId)) {
next.delete(promptId);
} else {
next.add(promptId);
}
return next;
});
}, []);

const handleDragStart = useCallback(
(event: ReactDragEvent, promptId: string) => {
setDraggingId(promptId);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', promptId);
},
[],
);

const handleDragEnd = useCallback(() => {
setDraggingId(null);
setDropTargetId(null);
setDropPosition(null);
}, []);

const updateDropTarget = useCallback(
(event: ReactDragEvent<HTMLElement>, targetPrompt: Prompt) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';

if (!draggingId || draggingId === targetPrompt.id) {
setDropTargetId(null);
setDropPosition(null);
return;
}

const nextDropPosition = getDropPosition(event);
const nextParentId =
nextDropPosition === 'inside'
? targetPrompt.id
: getVisibleParentId(targetPrompt);

if (!canMoveToParent(draggingId, nextParentId)) {
setDropTargetId(null);
setDropPosition(null);
return;
}

setDropTargetId(targetPrompt.id);
setDropPosition(nextDropPosition);
},
[canMoveToParent, draggingId, getVisibleParentId],
);

const handleDragLeave = useCallback((event: ReactDragEvent<HTMLElement>) => {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
return;
}

setDropTargetId(null);
setDropPosition(null);
}, []);

const handleDrop = useCallback(
(event: ReactDragEvent, targetPrompt: Prompt) => {
event.preventDefault();

if (!draggingId || draggingId === targetPrompt.id || !dropPosition) {
handleDragEnd();
return;
}

if (dropPosition === 'inside') {
if (canMoveToParent(draggingId, targetPrompt.id)) {
setExpandedIds((current) => new Set(current).add(targetPrompt.id));
onMovePrompt(draggingId, targetPrompt.id, getChildren(targetPrompt.id).length);
}
handleDragEnd();
return;
}

const nextParentId = getVisibleParentId(targetPrompt);
if (!canMoveToParent(draggingId, nextParentId)) {
handleDragEnd();
return;
}

const targetSiblings = getChildren(nextParentId).filter(
(prompt) => prompt.id !== draggingId,
);
const targetIndex = targetSiblings.findIndex(
(prompt) => prompt.id === targetPrompt.id,
);
const nextOrder =
targetIndex < 0
? targetSiblings.length
: targetIndex + (dropPosition === 'after' ? 1 : 0);

onMovePrompt(draggingId, nextParentId, nextOrder);
handleDragEnd();
},
[
canMoveToParent,
draggingId,
dropPosition,
getChildren,
getVisibleParentId,
handleDragEnd,
onMovePrompt,
],
);

const isSelected = useCallback(
(promptId: string) => selectedId === promptId || selectedIds.includes(promptId),
[selectedId, selectedIds],
);

const isDragging = useCallback(
(promptId: string) => draggingId === promptId,
[draggingId],
);

const isDropTarget = useCallback(
(promptId: string) => dropTargetId === promptId,
[dropTargetId],
);

const renderTreeNode = useCallback(
(prompt: Prompt, depth: number, ancestors: Set<string>) => {
const nextAncestors = new Set(ancestors).add(prompt.id);
const children = getChildren(prompt.id).filter(
(child) => !nextAncestors.has(child.id),
);
const hasKids = children.length > 0;
const isExpanded = expandedIds.has(prompt.id);

return (
<div key={prompt.id}>
<div
draggable
onDragStart={(event) => handleDragStart(event, prompt.id)}
onDragEnd={handleDragEnd}
onDragOver={(event) => updateDropTarget(event, prompt)}
onDragEnter={(event) => updateDropTarget(event, prompt)}
onDragLeave={handleDragLeave}
onDrop={(event) => handleDrop(event, prompt)}
onClick={() => onSelect(prompt.id)}
onContextMenu={(event) => onContextMenu(event, prompt)}
className={`
flex items-center gap-3 px-3 py-2.5 border-b border-border/50 cursor-pointer
transition-colors duration-quick relative
${
isSelected(prompt.id)
? 'bg-primary/10 border-l-2 border-l-primary'
: isDropTarget(prompt.id) && dropPosition === 'inside'
? 'bg-primary/20 border-l-2 border-l-primary'
: 'hover:bg-accent/50'
}
${isDragging(prompt.id) ? 'opacity-50' : ''}
${
isDropTarget(prompt.id) && dropPosition === 'inside'
? 'ring-2 ring-primary/50 ring-inset'
: ''
}
${
isDropTarget(prompt.id) && dropPosition === 'before'
? 'border-t-2 border-t-primary'
: ''
}
${
isDropTarget(prompt.id) && dropPosition === 'after'
? 'border-b-2 border-b-primary'
: ''
}
`}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
>
<div className="flex items-center gap-1">
{hasKids ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
toggleExpand(prompt.id);
}}
className="p-0.5 rounded hover:bg-accent transition-colors"
aria-label={isExpanded ? 'Collapse prompt' : 'Expand prompt'}
>
{isExpanded ? (
<ChevronDownIcon className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRightIcon className="w-4 h-4 text-muted-foreground" />
)}
</button>
) : (
<span className="w-5" />
)}
<GripVerticalIcon className="w-4 h-4 text-muted-foreground cursor-grab opacity-0 hover:opacity-100 transition-opacity" />
</div>
{prompt.description && (
<p className="text-xs text-muted-foreground line-clamp-2 break-words mt-0.5">
{prompt.description}
</p>
)}
{prompt.images && prompt.images.length > 0 && (
<div className="flex items-center gap-1 mt-0.5">
<ImageIcon className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{prompt.images.length}</span>

<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3
className={`font-medium text-sm leading-snug break-words line-clamp-2 ${
isSelected(prompt.id) ? 'text-primary' : 'text-foreground'
}`}
title={prompt.title}
>
{prompt.title}
</h3>
{prompt.isFavorite && (
<StarIcon className="w-3 h-3 flex-shrink-0 fill-yellow-400 text-yellow-400" />
)}
</div>
)}
</div>
{prompt.description && (
<p className="text-xs text-muted-foreground line-clamp-2 break-words mt-0.5">
{prompt.description}
</p>
)}
{prompt.images && prompt.images.length > 0 && (
<div className="flex items-center gap-1 mt-0.5">
<ImageIcon className="w-3 h-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{prompt.images.length}
</span>
</div>
)}
</div>

{/* Usage count */}
{/* 使用次数 */}
<div className="flex-shrink-0 w-12 text-center">
<span className="text-xs text-muted-foreground">
{prompt.usageCount || 0}
</span>
</div>
<div className="flex-shrink-0 w-12 text-center">
<span className="text-xs text-muted-foreground">
{prompt.usageCount || 0}
</span>
</div>

{/* Update time */}
{/* 更新时间 */}
<div className="flex-shrink-0 w-16 text-right">
<span className="text-xs text-muted-foreground">
{formatDate(prompt.updatedAt)}
</span>
</div>
<div className="flex-shrink-0 w-16 text-right">
<span className="text-xs text-muted-foreground">
{formatDate(prompt.updatedAt)}
</span>
</div>

{/* Action buttons */}
{/* 操作按钮 */}
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onCopy(prompt);
}}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={t('prompt.copy')}
>
<CopyIcon className="w-3.5 h-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(prompt.id);
}}
className={`p-1.5 rounded-md transition-colors ${prompt.isFavorite
? 'text-yellow-500 hover:bg-yellow-500/10'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onCopy(prompt);
}}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title={t('prompt.copy')}
>
<CopyIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onToggleFavorite(prompt.id);
}}
className={`p-1.5 rounded-md transition-colors ${
prompt.isFavorite
? 'text-yellow-500 hover:bg-yellow-500/10'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={prompt.isFavorite ? t('nav.favorites') : t('prompt.addToFavorites') || '添加收藏'}
>
<StarIcon className={`w-3.5 h-3.5 ${prompt.isFavorite ? 'fill-current' : ''}`} />
</button>
title={
prompt.isFavorite
? t('nav.favorites')
: t('prompt.addToFavorites') || '添加收藏'
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Hardcoded strings in promptlistview 📘 Rule violation ⚙ Maintainability

8.3

PromptListView includes hardcoded Chinese fallbacks (昨天, 天前, 添加收藏) and hardcoded English
ARIA labels (Collapse prompt/Expand prompt). This violates i18n requirements and prevents full
localization across supported locales.
Agent Prompt
## Issue description
User-facing strings and Chinese literals are hardcoded in `PromptListView` instead of using `t()` keys.

## Issue Context
- `formatDate()` returns Chinese fallback strings (`昨天`, `天前`).
- Favorite button title falls back to `添加收藏`.
- Expand/collapse button uses hardcoded English ARIA labels.
All user-facing text must be localized via i18next and new keys must be added to all locale JSON files.

## Fix Focus Areas
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[202-227]
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[411-425]
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[496-507]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +379 to +381
getAllRequest.onsuccess = () => {
const prompts = getAllRequest.result as Prompt[];
const prompt = prompts.find((item) => item.id === promptId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Uncommented as type assertions 📘 Rule violation ⚙ Maintainability

8.1

New code introduces as type assertions without justification comments, reducing type-safety and
potentially masking runtime shape mismatches. This violates the TypeScript strictness rule for as
usage.
Agent Prompt
## Issue description
Uncommented `as` assertions were added in production and test code.

## Issue Context
The compliance rule disallows `as` assertions unless truly necessary for interop and accompanied by an explanatory comment.

## Fix Focus Areas
- apps/desktop/src/renderer/services/database.ts[379-382]
- apps/desktop/tests/unit/main/database-migration-locks.test.ts[169-175]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +43 to +44
move: (promptId: string, newParentId: string | null, newOrder: number) =>
ipcRenderer.invoke(IPC_CHANNELS.PROMPT_MOVE, promptId, newParentId, newOrder),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Exported functions missing return types 📘 Rule violation ⚙ Maintainability

8.1

New exported API surfaces omit explicit return type annotations, making contract drift harder to
detect and weakening strict typing guarantees. This violates the requirement that exported functions
have explicit return types.
Agent Prompt
## Issue description
Exported functions must include explicit return type annotations.

## Issue Context
- `promptApi.move` is part of an exported API object but has no explicit return type.
- `PromptListView` is an exported function component without an explicit return type annotation.

## Fix Focus Areas
- apps/desktop/src/preload/api/prompt.ts[43-44]
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[59-69]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +148 to +193
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!selectedId || draggingId) return;

const selectedPrompt = promptById.get(selectedId);
if (!selectedPrompt) return;
if (event.key !== 'Tab' || event.ctrlKey || event.metaKey) return;

event.preventDefault();

const currentParentId = getVisibleParentId(selectedPrompt);

if (event.shiftKey) {
if (!currentParentId) return;

const parentPrompt = promptById.get(currentParentId);
const grandParentId = parentPrompt
? getVisibleParentId(parentPrompt)
: null;
const parentSiblings = getChildren(grandParentId);
const parentIndex = parentSiblings.findIndex(
(prompt) => prompt.id === currentParentId,
);

onMovePrompt(
selectedId,
grandParentId,
parentIndex >= 0 ? parentIndex + 1 : parentSiblings.length,
);
return;
}

const siblings = getChildren(currentParentId);
const currentIndex = siblings.findIndex(
(prompt) => prompt.id === selectedId,
);
if (currentIndex <= 0) return;

const previousSibling = siblings[currentIndex - 1];
setExpandedIds((current) => new Set(current).add(previousSibling.id));
onMovePrompt(selectedId, previousSibling.id, getChildren(previousSibling.id).length);
};

window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

6. Global tab key hijack 🐞 Bug ≡ Correctness

PromptListView installs a window-level keydown handler that preventDefault()s Tab whenever a prompt
is selected, regardless of where focus is, so Tab stops working inside other UI (e.g.,
EditPromptModal inputs) while list view is mounted. This breaks keyboard navigation/accessibility
and can make prompt editing flows difficult or impossible without the mouse.
Agent Prompt
### Issue description
`PromptListView` captures `Tab` at the `window` level and calls `event.preventDefault()` whenever there is a `selectedId`. This intercepts Tab presses even when the user is typing in an `<input>`/`<textarea>` inside modals rendered on top of the list (e.g. `EditPromptModal`), breaking expected focus traversal.

### Issue Context
The list view is mounted at the same time as shared modals in `MainContent`, so a global handler affects the whole page.

### Fix Focus Areas
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[148-193]

### Recommended fix
Implement one of these (prefer 1):
1) **Attach the key handler to the list container instead of `window`**:
   - Add `tabIndex={0}` to the list container and use `onKeyDown`/`onKeyDownCapture` there.
   - Only run indentation logic when the list container (or a row inside it) is the event target.

2) **If keeping `window` listener, ignore form/editing contexts**:
   - Before `preventDefault()`, check `event.target` / `document.activeElement` and return when:
     - target is `INPUT`, `TEXTAREA`, `SELECT`, or `contentEditable`, or
     - target is inside a modal/dialog region.

This ensures Tab continues to work for text entry and focus navigation while still supporting hierarchy editing when the list has focus.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (4)
spec/changes/active/desktop-prompt-relationship-tree/design.md (1)

41-43: ⚡ Quick win

把这里明确成复合索引。

当前表述容易被实现成两个单列索引,但层级读取/重排真正依赖的是 parent_id + sort_order 组合;单列索引对兄弟列表扫描和顺序重写帮助有限。建议直接写成复合索引,避免后续 schema 按错误形状落地。

📎 建议改法
- Add indexes for parent and sort order.
+ Add a composite index on `(parent_id, sort_order)` for sibling lookup and ordering.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@spec/changes/active/desktop-prompt-relationship-tree/design.md` around lines
41 - 43, The current specification "Add indexes for parent and sort order" is
ambiguous and could be misinterpreted as creating two separate single-column
indexes, but the actual requirement for efficient hierarchical reading and
reordering operations is a composite index on the (parent_id, sort_order)
combination. Modify the index specification to explicitly state that a composite
index should be created on (parent_id, sort_order) together, not as separate
indexes, to ensure proper support for sibling list scanning and order rewriting
operations.
apps/desktop/tests/unit/main/prompt-db.test.ts (1)

276-289: ⚡ Quick win

建议补充“promptId 不存在时应抛错”的回归用例。

当前层级测试集已覆盖多数非法输入;再补一条 db.movePrompt("missing-id", ...) 的断言,可以防止未来回归为静默 no-op。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/tests/unit/main/prompt-db.test.ts` around lines 276 - 289, The
test for movePrompt in the "rejects invalid parent and order inputs" test case
does not cover the scenario where the promptId being moved does not exist. Add
another expect statement that calls db.movePrompt with a non-existent prompt id
(like "missing-id") and verify that it throws an appropriate error message (such
as "Prompt does not exist"). This will prevent future regressions where the
movePrompt method silently ignores moves for non-existent prompts instead of
properly throwing an error.
packages/db/src/schema.ts (1)

232-233: ⚡ Quick win

建议将层级排序索引改为复合索引以匹配实际查询路径。

movePrompt/getChildren 的热点查询是按 parent_id 过滤并按 sort_order 排序;当前两条单列索引在该模式下命中不如复合索引稳定,建议补 prompts(parent_id, sort_order)(可保留或评估替换现有单列索引)。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/schema.ts` around lines 232 - 233, The current single-column
indexes idx_prompts_parent and idx_prompts_sort_order on the prompts table do
not align with the actual query pattern used in movePrompt and getChildren
functions, which filter by parent_id and then sort by sort_order. Add a
composite index on prompts(parent_id, sort_order) to improve query performance
for this common access pattern. You can keep or remove the existing
single-column indexes based on other usage patterns in the codebase, but the
composite index should be prioritized for the hot query path.
apps/desktop/src/renderer/components/prompt/PromptListView.tsx (1)

59-68: ⚡ Quick win

给导出的组件补上显式返回类型。

这里是导出函数,仓库规则要求显式返回类型;补成 : JSX.Element 或等价类型后,后续重构时更容易尽早发现意外的 null/非 JSX 返回。请顺手跑一次现有 typecheck,确认注解后没有暴露隐藏分支。

As per coding guidelines, All exported functions must have explicit return type annotations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/components/prompt/PromptListView.tsx` around lines
59 - 68, The exported PromptListView function is missing an explicit return type
annotation, which violates the repository's coding guidelines that require all
exported functions to have explicit return types. Add an explicit return type
annotation (`: JSX.Element` or equivalent React component type) to the
PromptListView function signature, then run the project's typecheck command to
verify that the annotation does not expose any hidden branches or unexpected
type mismatches that were previously hidden.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/desktop/src/main/ipc/prompt.ipc.ts`:
- Around line 59-70: The error messages in the prompt validation block lack
context about which parameters and values failed validation. Enhance each of the
three error throws (for promptId validation around line 60, newParentId
validation around line 66, and newOrder validation around line 69) to include
the actual parameter values or descriptions in the error message. For example,
include the promptId value, the newParentId value, and the newOrder value in
their respective error messages to help callers quickly identify which parameter
caused the failure.

In `@apps/desktop/src/preload/api/prompt.ts`:
- Around line 43-44: The `move` method in the prompt API lacks an explicit
return type annotation, which weakens type safety for the IPC call chain. Add an
explicit return type annotation to the arrow function (such as `Promise<void>`
or the appropriate return type), and use generic typing on the
`ipcRenderer.invoke()` call by specifying the expected return type as a generic
parameter. Follow the same pattern used in the `request` method from `ai.ts` to
maintain consistency and comply with the coding guideline requiring all exported
functions to have explicit return type annotations.

In `@apps/desktop/src/renderer/components/layout/MainContent.tsx`:
- Around line 1987-1999: The PromptListHeader component renders sort controls
that have no effect because the PromptListView component does not accept or use
sortBy and sortOrder props, and instead uses hardcoded hierarchy-based sorting.
Either pass the current sortBy and sortOrder state as props to PromptListView
and implement sorting logic based on these props instead of the hardcoded
order/title/id sorting, or conditionally hide/disable the sort controls in
PromptListHeader when viewMode is 'list' to prevent users from interacting with
non-functional controls.

In `@apps/desktop/src/renderer/components/prompt/PromptListView.tsx`:
- Around line 19-23: The onSelect callback in the PromptListViewProps interface
only receives the id string, which loses the mouse event information
(Ctrl/Cmd/Shift modifiers) needed for multi-select functionality. Change the
onSelect callback signature to receive both the prompt object and the MouseEvent
instead of just the id string. Then update all invocations of onSelect
(including at lines 378-379) to pass the complete prompt object and the mouse
event from the row click handler, allowing the parent component to reuse its
existing multi-select logic based on the modifier keys.
- Around line 148-200: The global Tab key listener in the useEffect hook within
PromptListView is intercepting Tab presses on all focusable elements (search
boxes, buttons, menus) and preventing normal keyboard navigation. Add a check at
the beginning of the handleKeyDown function to verify that event.target is
appropriate for tree navigation by either checking that the event originated
from the tree container itself, or by explicitly allowing Tab to pass through
when event.target is an input, button, or other interactive element. This
ensures the custom Tab behavior only applies when navigating the tree list and
does not interfere with other UI components.
- Around line 27-31: The `onMovePrompt` callback in PromptListView.tsx is
declared as synchronous returning void, but since movePrompt performs async
operations (IPC and IndexedDB) that can fail, errors are silently swallowed with
no user feedback. Change the `onMovePrompt` signature to return `Promise<void>`
at line 27-31, then locate all call sites of this callback and await them with
proper error handling (passing errors to a toast or error boundary). The same
change must be applied at the other affected locations in the same file at lines
172-188 and 304-330 where the callback is invoked.

In `@apps/desktop/src/renderer/services/database.ts`:
- Around line 382-384: The condition checking if prompt does not exist currently
performs a silent failure by calling resolve() without any action, which
misleads the caller into thinking the operation succeeded. Instead of silently
returning when the prompt is not found in the if (!prompt) check, throw an
explicit error that includes the promptId context to properly signal the failure
to the caller. This ensures the function adheres to the coding guideline of "No
silent failures" and provides meaningful error information for debugging.
- Line 380: The type assertion `as Prompt[]` in the assignment to the prompts
variable is unnecessary and violates coding guidelines. Remove the `as Prompt[]`
type assertion from the getAllRequest.result assignment to match the pattern
used in similar functions like legacyGetAllPrompts() and legacyGetAllFolders()
that use store.getAll() without type assertions.

In `@packages/db/src/prompt.ts`:
- Around line 705-706: Replace the silent return statement `if (!current)
return;` with an explicit error throw that includes relevant context information
such as the promptId. Instead of returning without notifying the caller, throw
an error (e.g., using Error or a custom exception class) with a descriptive
message that includes the promptId and indicates that the prompt was not found,
ensuring the caller is aware that the operation failed.
- Around line 190-197: The update() method in the prompt.ts file directly
modifies parent_id and sort_order fields without enforcing the hierarchical
constraints and validations that exist in the movePrompt() method, such as cycle
prevention, parent node existence checks, and sibling reordering. This allows
data integrity violations to persist. Remove the parent_id and sort_order update
logic from lines 190-197 (the conditions checking data.parentId and data.order)
to prevent direct hierarchical mutations through update(), and require callers
to use movePrompt() instead for any parent or order changes, which will ensure
all necessary validations are applied.
- Around line 813-819: The stmt.all() call on line 818 wraps the parameter in an
array (stmt.all(parentId === null ? [] : [parentId])), which is inconsistent
with the pattern used on Line 779 that passes parameters directly to stmt.all().
This inconsistency causes the normalizeParams() adaptation layer to receive an
array as a single parameter instead of individual parameters, leading to
potential parameter binding errors. Fix this by following the Line 779 pattern:
when parentId is null, call stmt.all() without arguments; otherwise, call
stmt.all(parentId) to pass the parameter directly without wrapping it in an
array.

---

Nitpick comments:
In `@apps/desktop/src/renderer/components/prompt/PromptListView.tsx`:
- Around line 59-68: The exported PromptListView function is missing an explicit
return type annotation, which violates the repository's coding guidelines that
require all exported functions to have explicit return types. Add an explicit
return type annotation (`: JSX.Element` or equivalent React component type) to
the PromptListView function signature, then run the project's typecheck command
to verify that the annotation does not expose any hidden branches or unexpected
type mismatches that were previously hidden.

In `@apps/desktop/tests/unit/main/prompt-db.test.ts`:
- Around line 276-289: The test for movePrompt in the "rejects invalid parent
and order inputs" test case does not cover the scenario where the promptId being
moved does not exist. Add another expect statement that calls db.movePrompt with
a non-existent prompt id (like "missing-id") and verify that it throws an
appropriate error message (such as "Prompt does not exist"). This will prevent
future regressions where the movePrompt method silently ignores moves for
non-existent prompts instead of properly throwing an error.

In `@packages/db/src/schema.ts`:
- Around line 232-233: The current single-column indexes idx_prompts_parent and
idx_prompts_sort_order on the prompts table do not align with the actual query
pattern used in movePrompt and getChildren functions, which filter by parent_id
and then sort by sort_order. Add a composite index on prompts(parent_id,
sort_order) to improve query performance for this common access pattern. You can
keep or remove the existing single-column indexes based on other usage patterns
in the codebase, but the composite index should be prioritized for the hot query
path.

In `@spec/changes/active/desktop-prompt-relationship-tree/design.md`:
- Around line 41-43: The current specification "Add indexes for parent and sort
order" is ambiguous and could be misinterpreted as creating two separate
single-column indexes, but the actual requirement for efficient hierarchical
reading and reordering operations is a composite index on the (parent_id,
sort_order) combination. Modify the index specification to explicitly state that
a composite index should be created on (parent_id, sort_order) together, not as
separate indexes, to ensure proper support for sibling list scanning and order
rewriting operations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 66008b09-e6b6-4ecc-9dc0-404ff3ba8233

📥 Commits

Reviewing files that changed from the base of the PR and between 28935b5 and 925aed0.

📒 Files selected for processing (18)
  • apps/desktop/src/main/ipc/prompt.ipc.ts
  • apps/desktop/src/preload/api/prompt.ts
  • apps/desktop/src/renderer/components/layout/MainContent.tsx
  • apps/desktop/src/renderer/components/prompt/PromptListView.tsx
  • apps/desktop/src/renderer/services/database.ts
  • apps/desktop/src/renderer/stores/prompt.store.ts
  • apps/desktop/tests/unit/main/database-migration-locks.test.ts
  • apps/desktop/tests/unit/main/prompt-db.test.ts
  • packages/db/src/init.ts
  • packages/db/src/prompt.ts
  • packages/db/src/schema.ts
  • packages/shared/constants/ipc-channels.ts
  • packages/shared/types/prompt.ts
  • spec/changes/active/desktop-prompt-relationship-tree/design.md
  • spec/changes/active/desktop-prompt-relationship-tree/implementation.md
  • spec/changes/active/desktop-prompt-relationship-tree/proposal.md
  • spec/changes/active/desktop-prompt-relationship-tree/specs/prompt-relationships/spec.md
  • spec/changes/active/desktop-prompt-relationship-tree/tasks.md

Comment on lines +59 to +70
if (typeof promptId !== 'string' || promptId.trim().length === 0) {
throw new Error('Prompt id is required');
}
if (
newParentId !== null &&
(typeof newParentId !== 'string' || newParentId.trim().length === 0)
) {
throw new Error('Parent prompt id must be null or a non-empty string');
}
if (!Number.isFinite(newOrder) || newOrder < 0) {
throw new Error('Prompt order must be a non-negative number');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

错误信息缺少上下文,定位非法入参会很慢。

Line 60、Line 66、Line 69 的报错都没有包含失败字段和值,调用端很难快速定位是哪一个参数导致失败。建议在不暴露敏感信息前提下补充 promptId/newParentId/newOrder 的上下文。

As per coding guidelines, "Error messages must include context (what failed, with what input)."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/ipc/prompt.ipc.ts` around lines 59 - 70, The error
messages in the prompt validation block lack context about which parameters and
values failed validation. Enhance each of the three error throws (for promptId
validation around line 60, newParentId validation around line 66, and newOrder
validation around line 69) to include the actual parameter values or
descriptions in the error message. For example, include the promptId value, the
newParentId value, and the newOrder value in their respective error messages to
help callers quickly identify which parameter caused the failure.

Source: Coding guidelines

Comment on lines +43 to +44
move: (promptId: string, newParentId: string | null, newOrder: number) =>
ipcRenderer.invoke(IPC_CHANNELS.PROMPT_MOVE, promptId, newParentId, newOrder),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n apps/desktop/src/preload/api/prompt.ts | head -60

Repository: legeling/PromptHub

Length of output: 2409


🏁 Script executed:

rg -t ts -t tsx "ipcRenderer\.invoke" apps/desktop/src/preload/api/ -A 1 -B 1

Repository: legeling/PromptHub

Length of output: 90


🏁 Script executed:

# 查找是否有类型定义文件
fd -e "d.ts" "ipcRenderer\|electron" apps/

Repository: legeling/PromptHub

Length of output: 44


🏁 Script executed:

# 检查编码指南中关于导出函数的适用范围
head -100 apps/desktop/src/preload/api/prompt.ts

Repository: legeling/PromptHub

Length of output: 2094


🏁 Script executed:

# 查看 TypeScript 配置
find apps/desktop -name "tsconfig.json" | head -5

Repository: legeling/PromptHub

Length of output: 89


🏁 Script executed:

# 查看其他 API 文件的实现方式
find apps/desktop/src/preload/api -name "*.ts" | head -10

Repository: legeling/PromptHub

Length of output: 450


🏁 Script executed:

# 查看 ipcRenderer 的类型定义
rg "ipcRenderer" apps/desktop -t ts --no-heading | grep -i "type\|interface" | head -20

Repository: legeling/PromptHub

Length of output: 44


🏁 Script executed:

cat -n apps/desktop/tsconfig.json

Repository: legeling/PromptHub

Length of output: 1347


🏁 Script executed:

cat -n apps/desktop/src/preload/api/folder.ts

Repository: legeling/PromptHub

Length of output: 982


🏁 Script executed:

cat -n apps/desktop/src/preload/api/ai.ts

Repository: legeling/PromptHub

Length of output: 2362


move 需要显式返回类型注解,保持 IPC 调用链的类型安全。

当前 move 方法缺少返回类型,ipcRenderer.invoke() 的返回值默认推导为宽泛类型,弱化类型契约。参考 ai.tsrequest 方法的做法,建议补充显式返回类型注解(如 Promise<void>),并使用泛型化 ipcRenderer.invoke<ReturnType>()

符合编码指南要求:"All exported functions must have explicit return type annotations."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/preload/api/prompt.ts` around lines 43 - 44, The `move`
method in the prompt API lacks an explicit return type annotation, which weakens
type safety for the IPC call chain. Add an explicit return type annotation to
the arrow function (such as `Promise<void>` or the appropriate return type), and
use generic typing on the `ipcRenderer.invoke()` call by specifying the expected
return type as a generic parameter. Follow the same pattern used in the
`request` method from `ai.ts` to maintain consistency and comply with the coding
guideline requiring all exported functions to have explicit return type
annotations.

Source: Coding guidelines

Comment on lines 1987 to 1999
<PromptListHeader count={sortedPrompts.length} />
{viewMode === 'kanban' && (
{viewMode === 'list' && (
<Suspense fallback={loadingFallback}>
<PromptKanbanView
<PromptListView
prompts={visiblePrompts}
highlightTerms={highlightTerms}
selectedId={selectedId}
selectedIds={selectedIds}
onSelect={(id) => selectPrompt(id)}
onToggleFavorite={toggleFavorite}
onCopy={handleCopyPrompt}
onEdit={(prompt) => setEditingPrompt(prompt)}
onDelete={handleDeletePrompt}
onAiTest={handleAiTestFromTable}
onVersionHistory={handleVersionHistory}
onViewDetail={handleViewDetail}
onContextMenu={handleContextMenu}
onMovePrompt={movePrompt}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

列表视图里的排序控件现在是无效操作。

这一分支继续渲染 PromptListHeader,但 PromptListView 内部固定按 hierarchy 的 order/title/id 排序,而且这里也没有把 sortBy / sortOrder 传下去,所以用户改排序在 list 模式下不会生效。要么把排序状态真正接入树渲染,要么在 list 模式隐藏/禁用这些控件,避免出现“能点但没效果”的 UI。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/components/layout/MainContent.tsx` around lines
1987 - 1999, The PromptListHeader component renders sort controls that have no
effect because the PromptListView component does not accept or use sortBy and
sortOrder props, and instead uses hardcoded hierarchy-based sorting. Either pass
the current sortBy and sortOrder state as props to PromptListView and implement
sorting logic based on these props instead of the hardcoded order/title/id
sorting, or conditionally hide/disable the sort controls in PromptListHeader
when viewMode is 'list' to prevent users from interacting with non-functional
controls.

Comment on lines 19 to 23
interface PromptListViewProps {
prompts: Prompt[];
selectedId: string | null;
selectedIds: string[];
onSelect: (id: string) => void;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

恢复列表视图的多选输入通道。

selectedIds 现在只参与高亮,但行点击只把 id 传回去,组件拿不到 Ctrl/Cmd/Shift 信息,所以树视图里无法切换或扩展多选,列表批量操作会退化成单选。把 onSelect 改成接收 promptMouseEvent,然后直接复用父层现有的多选逻辑会更稳。

♻️ 建议修改
 interface PromptListViewProps {
   prompts: Prompt[];
   selectedId: string | null;
   selectedIds: string[];
-  onSelect: (id: string) => void;
+  onSelect: (prompt: Prompt, event: ReactMouseEvent) => void;
   onToggleFavorite: (id: string) => void;
   onCopy: (prompt: Prompt) => void;
   onContextMenu: (e: ReactMouseEvent, prompt: Prompt) => void;
-            onClick={() => onSelect(prompt.id)}
+            onClick={(event) => onSelect(prompt, event)}

Also applies to: 378-379

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/components/prompt/PromptListView.tsx` around lines
19 - 23, The onSelect callback in the PromptListViewProps interface only
receives the id string, which loses the mouse event information (Ctrl/Cmd/Shift
modifiers) needed for multi-select functionality. Change the onSelect callback
signature to receive both the prompt object and the MouseEvent instead of just
the id string. Then update all invocations of onSelect (including at lines
378-379) to pass the complete prompt object and the mouse event from the row
click handler, allowing the parent component to reuse its existing multi-select
logic based on the modifier keys.

Comment on lines +27 to +31
onMovePrompt: (
promptId: string,
newParentId: string | null,
newOrder: number,
) => void;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

把移动回调改成异步并显式处理失败。

按这组改动的接线,movePrompt 会走持久化和同步路径;但这里把它声明成同步 void,并在调用后立刻结束拖拽/键盘流程。只要 IPC 或 IndexedDB 任一路径报错,用户这边看到的就是“操作没生效”,却没有任何失败反馈。建议把签名改成 Promise<void>,在这里 await,并把错误交给上层 toast 或错误边界处理。

As per coding guidelines, No silent failures. Functions must not swallow errors and return default values. If an operation can fail, the caller must know.

Also applies to: 172-188, 304-330

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/components/prompt/PromptListView.tsx` around lines
27 - 31, The `onMovePrompt` callback in PromptListView.tsx is declared as
synchronous returning void, but since movePrompt performs async operations (IPC
and IndexedDB) that can fail, errors are silently swallowed with no user
feedback. Change the `onMovePrompt` signature to return `Promise<void>` at line
27-31, then locate all call sites of this callback and await them with proper
error handling (passing errors to a toast or error boundary). The same change
must be applied at the other affected locations in the same file at lines
172-188 and 304-330 where the callback is invoked.

Source: Coding guidelines

const getAllRequest = store.getAll();

getAllRequest.onsuccess = () => {
const prompts = getAllRequest.result as Prompt[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's check the file exists and read around line 380
head -n 385 apps/desktop/src/renderer/services/database.ts | tail -n 20

Repository: legeling/PromptHub

Length of output: 677


🏁 Script executed:

# Get more context - read more lines around line 380
wc -l apps/desktop/src/renderer/services/database.ts

Repository: legeling/PromptHub

Length of output: 113


🏁 Script executed:

# Read a broader context around line 380
sed -n '370,390p' apps/desktop/src/renderer/services/database.ts

Repository: legeling/PromptHub

Length of output: 756


🏁 Script executed:

# Check the type of store.getAll() and understand the context better
# Let's find where getAllRequest is used and its type
sed -n '360,420p' apps/desktop/src/renderer/services/database.ts

Repository: legeling/PromptHub

Length of output: 1837


🏁 Script executed:

# Find the Prompt type definition
rg "type Prompt|interface Prompt|class Prompt" apps/desktop/src/renderer --type ts --type tsx

Repository: legeling/PromptHub

Length of output: 90


🏁 Script executed:

# Check imports at the top of the database.ts file
head -n 50 apps/desktop/src/renderer/services/database.ts

Repository: legeling/PromptHub

Length of output: 1325


🏁 Script executed:

# Check if there's any type definition for the result of store.getAll()
# and understand the IndexedDB types being used
rg "IDBRequest|IDBObjectStore|getAll" apps/desktop/src/renderer/services/database.ts -A 2 -B 2

Repository: legeling/PromptHub

Length of output: 2234


🏁 Script executed:

# Check if there are other similar type assertions in the same file with comments
rg "as (Prompt|Folder|PromptVersion)" apps/desktop/src/renderer/services/database.ts -B 2 -A 2

Repository: legeling/PromptHub

Length of output: 245


🏁 Script executed:

# Check if there are any runtime type guards or validation functions used for similar cases
rg "Array.isArray|instanceof|typeof.*===|isPrompt|validatePrompt" apps/desktop/src/renderer/services/database.ts

Repository: legeling/PromptHub

Length of output: 44


🏁 Script executed:

# Check the pattern in getAllPrompts to see how it handles similar situation
sed -n '90,130p' apps/desktop/src/renderer/services/database.ts

Repository: legeling/PromptHub

Length of output: 1311


🏁 Script executed:

# Check if there are any comments near type assertions in the file
rg "as " apps/desktop/src/renderer/services/database.ts -B 3 | head -n 30

Repository: legeling/PromptHub

Length of output: 874


🏁 Script executed:

# Look at how other functions handle similar situations with getAll()
sed -n '140,200p' apps/desktop/src/renderer/services/database.ts

Repository: legeling/PromptHub

Length of output: 1885


🏁 Script executed:

# Check if getAllFolders has similar patterns
rg "getAllFolders" apps/desktop/src/renderer/services/database.ts -A 20

Repository: legeling/PromptHub

Length of output: 748


🏁 Script executed:

# Check the exact type signature of IDBObjectStore.getAll() in TypeScript
# Look for how other similar patterns work
rg "request.result" apps/desktop/src/renderer/services/database.ts -B 5 -A 2

Repository: legeling/PromptHub

Length of output: 1740


🏁 Script executed:

# Check if there are any validation/runtime type checks before using the data
sed -n '375,390p' apps/desktop/src/renderer/services/database.ts

Repository: legeling/PromptHub

Length of output: 649


移除不必要的 as Prompt[] 类型断言。

此处的类型断言违反编码准则。查看同一文件中的其他类似函数(如 legacyGetAllPrompts() 第 171 行和 legacyGetAllFolders() 第 618 行),它们使用相同的 store.getAll() 但无需类型断言。应移除此断言,与代码库模式保持一致。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/services/database.ts` at line 380, The type
assertion `as Prompt[]` in the assignment to the prompts variable is unnecessary
and violates coding guidelines. Remove the `as Prompt[]` type assertion from the
getAllRequest.result assignment to match the pattern used in similar functions
like legacyGetAllPrompts() and legacyGetAllFolders() that use store.getAll()
without type assertions.

Source: Coding guidelines

Comment on lines +382 to +384
if (!prompt) {
resolve();
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

目标 Prompt 不存在时直接成功返回,属于静默失败。

这里直接 resolve() 会让上层误判“移动成功”,但实际未执行任何变更。应显式抛错,并包含 promptId 上下文。

As per coding guidelines, "No silent failures. Functions must not swallow errors and return default values."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/services/database.ts` around lines 382 - 384, The
condition checking if prompt does not exist currently performs a silent failure
by calling resolve() without any action, which misleads the caller into thinking
the operation succeeded. Instead of silently returning when the prompt is not
found in the if (!prompt) check, throw an explicit error that includes the
promptId context to properly signal the failure to the caller. This ensures the
function adheres to the coding guideline of "No silent failures" and provides
meaningful error information for debugging.

Source: Coding guidelines

Comment thread packages/db/src/prompt.ts
Comment on lines +190 to +197
if (data.parentId !== undefined) {
updates.push("parent_id = ?");
values.push(data.parentId);
}
if (data.order !== undefined) {
updates.push("sort_order = ?");
values.push(data.order);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

避免通过 update() 直接写层级字段,当前会绕过 movePrompt() 的约束与顺序重写。

Line 190-197 直接更新 parent_id/sort_order,但不会执行防环、父节点存在性校验、兄弟连续重排。再结合 apps/desktop/src/main/ipc/prompt.ipc.tsPROMPT_UPDATE -> db.update(id, data),会留下可持久化的层级脏数据风险。

建议修复(示例)
-    if (data.parentId !== undefined) {
-      updates.push("parent_id = ?");
-      values.push(data.parentId);
-    }
-    if (data.order !== undefined) {
-      updates.push("sort_order = ?");
-      values.push(data.order);
-    }
+    if (data.parentId !== undefined || data.order !== undefined) {
+      throw new Error("Hierarchy fields must be updated via movePrompt()");
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (data.parentId !== undefined) {
updates.push("parent_id = ?");
values.push(data.parentId);
}
if (data.order !== undefined) {
updates.push("sort_order = ?");
values.push(data.order);
}
if (data.parentId !== undefined || data.order !== undefined) {
throw new Error("Hierarchy fields must be updated via movePrompt()");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/prompt.ts` around lines 190 - 197, The update() method in the
prompt.ts file directly modifies parent_id and sort_order fields without
enforcing the hierarchical constraints and validations that exist in the
movePrompt() method, such as cycle prevention, parent node existence checks, and
sibling reordering. This allows data integrity violations to persist. Remove the
parent_id and sort_order update logic from lines 190-197 (the conditions
checking data.parentId and data.order) to prevent direct hierarchical mutations
through update(), and require callers to use movePrompt() instead for any parent
or order changes, which will ensure all necessary validations are applied.

Comment thread packages/db/src/prompt.ts
Comment on lines +705 to +706
if (!current) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

请在 prompt 不存在时显式抛错,避免静默失败。

Line 705 当前 if (!current) return; 会让调用方误以为移动成功,建议抛出带上下文的错误(例如包含 promptId)。

As per coding guidelines, "No silent failures. Functions must not swallow errors and return default values. If an operation can fail, the caller must know."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/prompt.ts` around lines 705 - 706, Replace the silent return
statement `if (!current) return;` with an explicit error throw that includes
relevant context information such as the promptId. Instead of returning without
notifying the caller, throw an error (e.g., using Error or a custom exception
class) with a descriptive message that includes the promptId and indicates that
the prompt was not found, ensuring the caller is aware that the operation
failed.

Source: Coding guidelines

Comment thread packages/db/src/prompt.ts
Comment on lines +813 to +819
const stmt = this.db.prepare(
parentId === null
? "SELECT * FROM prompts WHERE parent_id IS NULL ORDER BY sort_order"
: "SELECT * FROM prompts WHERE parent_id = ? ORDER BY sort_order"
);
const rows = stmt.all(parentId === null ? [] : [parentId]) as PromptRow[];
return rows.map((row) => this.rowToPrompt(row));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== adapter.ts: 查询 all/get/run 的签名 =="
fd -i "adapter.ts" | xargs -I{} sh -c 'echo "--- {} ---"; rg -n "all\\(|get\\(|run\\(" "{}" -C2'

echo "== prompt.ts: 现有 stmt.all 调用模式 =="
rg -n "stmt\\.all\\(" packages/db/src/prompt.ts -C2

Repository: legeling/PromptHub

Length of output: 2740


🏁 Script executed:

# Check normalizeParams implementation in adapter.ts
rg -n "normalizeParams" packages/db/src/adapter.ts -A 10

# Check all stmt.all() calls in prompt.ts to see patterns
rg -n "stmt\.all\(" packages/db/src/prompt.ts -B 2 -A 2

# Also check if there are other similar db calls to understand expected pattern
rg -n "this\.db\.prepare" packages/db/src/prompt.ts -A 3 | head -60

Repository: legeling/PromptHub

Length of output: 5773


第 818 行的参数传递方式需要修正,与同文件其他调用的约定不一致。

Line 818 使用 stmt.all(parentId === null ? [] : [parentId]) 将参数包装在数组中,而同文件 Line 779 使用 stmt.all(parentId) 直接传递参数。这种不一致会导致适配层 normalizeParams() 接收到数组作为单参数(params[0]),最终将嵌套数组传给底层 SQLite 绑定,可能导致参数数量不匹配或查询异常。

应修正为:const rows = stmt.all(parentId === null ? [] : parentId === undefined ? [] : [parentId]) as PromptRow[]; 或更简洁地采用 Line 779 的模式:(parentId === null ? stmt.all() : stmt.all(parentId))

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/prompt.ts` around lines 813 - 819, The stmt.all() call on
line 818 wraps the parameter in an array (stmt.all(parentId === null ? [] :
[parentId])), which is inconsistent with the pattern used on Line 779 that
passes parameters directly to stmt.all(). This inconsistency causes the
normalizeParams() adaptation layer to receive an array as a single parameter
instead of individual parameters, leading to potential parameter binding errors.
Fix this by following the Line 779 pattern: when parentId is null, call
stmt.all() without arguments; otherwise, call stmt.all(parentId) to pass the
parameter directly without wrapping it in an array.

@legeling legeling merged commit db0e94a into main Jun 14, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants