Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/desktop/src/main/ipc/prompt.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,4 +249,10 @@ export function registerPromptIPC(db: PromptDB, folderDb: FolderDB, rawDb: Datab
db.insertVersionDirect(version);
return true;
});

ipcMain.handle(IPC_CHANNELS.PROMPT_MOVE, async (_, promptId: string, newParentId: string | null, newOrder: number) => {
db.movePrompt(promptId, newParentId, newOrder);
syncWorkspace();
return true;
Comment on lines +253 to +256

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

7. Workspace hierarchy not persisted 🐞 Bug ≡ Correctness

PROMPT_MOVE triggers syncWorkspace(), but workspace serialization/parsing omits parentId/order, so
the on-disk workspace (and any restore/sync based on it) will lose the hierarchy and flatten
relationships.
Agent Prompt
### Issue description
The new hierarchical relationships are not written to (or read from) the prompt workspace files. After a move, the DB is correct, but the synced workspace representation drops `parentId`/`order`, so hierarchy is lost when restoring/importing from workspace.

### Issue Context
- `PROMPT_MOVE` explicitly calls `syncWorkspace()`.
- `promptFrontmatter()` does not include `parentId`/`order`.
- `parsePromptFile()` also does not parse `parentId`/`order`.

### Fix Focus Areas
- Include `parentId` and `order` in `promptFrontmatter()`.
- Parse `parentId` and `order` in `parsePromptFile()`.
- Consider backward compatibility (missing fields default to null/0).

- apps/desktop/src/main/ipc/prompt.ipc.ts[253-256]
- apps/desktop/src/main/services/prompt-workspace.ts[243-264]
- apps/desktop/src/main/services/prompt-workspace.ts[633-675]
- apps/desktop/src/main/services/prompt-workspace.ts[864-884]

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

});
Comment on lines +253 to +257

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. Prompt_move ipc unvalidated 📘 Rule violation ⛨ Security

The new IPC_CHANNELS.PROMPT_MOVE handler accepts promptId, newParentId, and newOrder without
validating payload types/ranges before calling database logic. Malformed IPC inputs could cause
runtime errors or unintended DB updates across the process boundary.
Agent Prompt
## Issue description
IPC handler processes unvalidated inputs (`promptId`, `newParentId`, `newOrder`).

## Issue Context
IPC is a process boundary; handlers must validate and reject malformed payloads before performing DB operations.

## Fix Focus Areas
- apps/desktop/src/main/ipc/prompt.ipc.ts[253-257]

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

}
2 changes: 2 additions & 0 deletions apps/desktop/src/preload/api/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,6 @@ export const promptApi = {
prompts: Prompt[];
versions: PromptVersion[];
}) => ipcRenderer.invoke(IPC_CHANNELS.PROMPT_MIGRATE_IDB_BATCH, payload),
move: (promptId: string, newParentId: string | null, newOrder: number) =>
ipcRenderer.invoke(IPC_CHANNELS.PROMPT_MOVE, promptId, newParentId, newOrder),
};
24 changes: 24 additions & 0 deletions apps/desktop/src/renderer/components/layout/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const PromptQuickRewriteDialog = lazy(() => import('../prompt/PromptQuickRewrite
const PromptTableView = lazy(() => import('../prompt/PromptTableView').then(m => ({ default: m.PromptTableView })));
const PromptGalleryView = lazy(() => import('../prompt/PromptGalleryView').then(m => ({ default: m.PromptGalleryView })));
const PromptKanbanView = lazy(() => import('../prompt/PromptKanbanView').then(m => ({ default: m.PromptKanbanView })));
const PromptListView = lazy(() => import('../prompt/PromptListView').then(m => ({ default: m.PromptListView })));
const AiTestModal = lazy(() => import('../prompt/AiTestModal').then(m => ({ default: m.AiTestModal })));
const PromptDetailModal = lazy(() => import('../prompt/PromptDetailModal').then(m => ({ default: m.PromptDetailModal })));
const VariableInputModal = lazy(() => import('../prompt/VariableInputModal').then(m => ({ default: m.VariableInputModal })));
Expand Down Expand Up @@ -367,6 +368,7 @@ function PromptSkillMainContent() {
const sortOrder = usePromptStore((state) => state.sortOrder);
const viewMode = usePromptStore((state) => state.viewMode);
const incrementUsageCount = usePromptStore((state) => state.incrementUsageCount);
const movePrompt = usePromptStore((state) => state.movePrompt);
// Resizable prompt-list pane width (#119)
const promptListPaneWidth = useUIStore((state) => state.promptListPaneWidth);
const setPromptListPaneWidth = useUIStore(
Expand Down Expand Up @@ -2014,6 +2016,28 @@ function PromptSkillMainContent() {
)}
</div>

{/* List view mode: hierarchical list with drag-and-drop */}
{/* 列表视图模式:分层列表支持拖拽 */}
<div
Comment on lines +2019 to +2021

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. Chinese comments in source 📘 Rule violation ⚙ Maintainability

Chinese text was added directly in non-locale source files, which violates the rule forbidding
Chinese characters outside locale JSON. This can lead to untranslated/inconsistent language output
and fails the localization completeness requirement.
Agent Prompt
## Issue description
Chinese characters were added in non-locale source files (comments), which is forbidden by i18n compliance rules.

## Issue Context
Rule requires that Chinese characters appear only in locale JSON files (and any user-facing text must be translated via i18next keys).

## Fix Focus Areas
- apps/desktop/src/renderer/components/layout/MainContent.tsx[2019-2021]
- packages/db/src/prompt.ts[688-691]

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

className={getViewClass('list')}
>
<PromptListHeader count={sortedPrompts.length} />
{viewMode === 'list' && (
<Suspense fallback={loadingFallback}>
<PromptListView
prompts={visiblePrompts}
selectedId={selectedId}
selectedIds={selectedIds}
onSelect={(id) => selectPrompt(id)}
onToggleFavorite={toggleFavorite}
onCopy={handleCopyPrompt}
onContextMenu={handleContextMenu}
onMovePrompt={movePrompt}
/>
</Suspense>
)}
</div>
Comment on lines +2019 to +2039

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

list 模式被渲染了两套容器,会出现重叠和重复渲染。

这里新增了第二个 getViewClass('list') 分支,而上方已存在一个同 mode 的 list 容器。viewMode === 'list' 时两者都会处于激活态。建议只保留一套 list 视图实现(或给旧表格分支独立 mode)。

🤖 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
2019 - 2039, There are two rendered containers for the 'list' view causing
overlap: remove the duplicate branch so only one container uses
getViewClass('list') and renders PromptListHeader plus the PromptListView when
viewMode === 'list'; specifically, keep the single canonical container (the one
that includes PromptListHeader and the Suspense/PromptListView) and delete the
other getViewClass('list') block (or change its mode name if it represents a
different UI), leaving only one conditional render path that uses viewMode ===
'list', PromptListHeader, and PromptListView (and preserves handlers like
selectPrompt, toggleFavorite, handleCopyPrompt, handleContextMenu, movePrompt).


{/* Card view mode: two-column layout */}
{/* 卡片视图模式:左右分栏 */}
<div
Expand Down
235 changes: 214 additions & 21 deletions apps/desktop/src/renderer/components/prompt/PromptListView.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,73 @@
import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { StarIcon, CopyIcon, ImageIcon } from 'lucide-react';
import { StarIcon, CopyIcon, ImageIcon, ChevronRight, ChevronDown, GripVertical } from 'lucide-react';
import type { Prompt } from '@prompthub/shared/types';

interface PromptListViewProps {
prompts: Prompt[];
selectedId: string | null;
selectedIds: string[];
onSelect: (id: string) => void;
onToggleFavorite: (id: string) => void;
onCopy: (prompt: Prompt) => void;
onContextMenu: (e: React.MouseEvent, prompt: Prompt) => void;
onMovePrompt: (promptId: string, newParentId: string | null, newOrder: number) => void;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
Comment on lines +14 to +16

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

repo_root="$(pwd)"
file="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"

echo "== File exists? =="
ls -l "$file"

echo "== Snippet around lines 1-220 =="
sed -n '1,220p' "$file" | nl -ba | sed -n '1,220p'

echo "== Grep onMovePrompt occurrences =="
rg -n "onMovePrompt" "$file"

echo "== Find where onMovePrompt is passed to MainContent (search MainContent props) =="
rg -n "MainContent" "$file" -S

Repository: legeling/PromptHub

Length of output: 289


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo_root="$(pwd)"
file="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"

echo "== File exists? =="
ls -l "$file"

echo "== Snippet around start to 220 =="
sed -n '1,220p' "$file" | nl -ba

echo "== Grep onMovePrompt occurrences =="
rg -n "onMovePrompt" "$file"

echo "== Grep MainContent =="
rg -n "MainContent" "$file" -S

Repository: legeling/PromptHub

Length of output: 290


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo_root="$(pwd)"
file="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"

ls -l "$file"
sed -n '1,240p' "$file" | nl -ba
rg -n "onMovePrompt" "$file"
rg -n "MainContent" "$file" -S

Repository: legeling/PromptHub

Length of output: 236


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"

echo "== File =="
ls -l "$file"

echo "== Lines 1-220 =="
sed -n '1,220p' "$file" | nl -ba

echo "== onMovePrompt =="
rg -n "onMovePrompt" "$file"

echo "== MainContent =="
rg -n "MainContent" "$file" -S

Repository: legeling/PromptHub

Length of output: 265


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"

# show relevant ranges mentioned: 1-90, 35-110, 130-210
for start in 1 35 120 140; do
  end=$((start+70))
  echo "== $file lines $start-$end =="
  sed -n "${start},${end}p" "$file" | nl -ba
done

echo "== all onMovePrompt occurrences =="
rg -n "onMovePrompt" "$file"
echo "== all invocations calling onMovePrompt (looking for identifier usage) =="
rg -n "onMovePrompt\s*\(" "$file"

Repository: legeling/PromptHub

Length of output: 203


🏁 Script executed:

ls -l

Repository: legeling/PromptHub

Length of output: 1083


🏁 Script executed:

file="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"
sed -n '1,220p' "$file" | nl -ba
rg -n "onMovePrompt" "$file"
rg -n "onMovePrompt\s*\(" "$file"
rg -n "keyboard|drag|drop|onDrop|onKey|Enter|Space" "$file" -S

Repository: legeling/PromptHub

Length of output: 3005


🏁 Script executed:

file="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"

echo "== Content 1-90 =="
sed -n '1,90p' "$file" | awk '{printf "%4d: %s\n", NR, $0}'

echo "== Content 90-140 =="
sed -n '90,140p' "$file" | awk '{printf "%4d: %s\n", NR+90, $0}'

echo "== Content 140-210 =="
sed -n '140,210p' "$file" | awk '{printf "%4d: %s\n", NR+140, $0}'

echo "== Content 210-260 =="
sed -n '210,260p' "$file" | awk '{printf "%4d: %s\n", NR+210, $0}'

echo "== Search keyboard handlers in file (key/KeyDown/Enter/Space) =="
rg -n "onKeyDown|onKeyUp|keydown|KeyDown|Enter|Space" "$file" -S

echo "== Search for onMovePrompt assigned/returned in repo (apps/desktop/src) =="
rg -n "onMovePrompt" apps/desktop/src -S

Repository: legeling/PromptHub

Length of output: 13465


🏁 Script executed:

file="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"

echo "== Content 1-90 =="
sed -n '1,90p' "$file" | awk '{printf "%4d: %s\n", NR, $0}'

echo "== Content 90-140 =="
sed -n '90,140p' "$file" | awk '{printf "%4d: %s\n", NR+90, $0}'

echo "== Content 140-210 =="
sed -n '140,210p' "$file" | awk '{printf "%4d: %s\n", NR+140, $0}'

echo "== Content 210-260 =="
sed -n '210,260p' "$file" | awk '{printf "%4d: %s\n", NR+210, $0}'

echo "== Search keyboard handlers in file (key/KeyDown/Enter/Space) =="
rg -n "onKeyDown|onKeyUp|keydown|KeyDown|Enter|Space" "$file" -S

echo "== Search for onMovePrompt assigned/returned in repo (apps/desktop/src) =="
rg -n "onMovePrompt" apps/desktop/src -S

Repository: legeling/PromptHub

Length of output: 13465


🏁 Script executed:

file="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"

echo "== Content 1-90 =="
sed -n '1,90p' "$file" | awk '{printf "%4d: %s\n", NR, $0}'

echo "== Content 90-140 =="
sed -n '90,140p' "$file" | awk '{printf "%4d: %s\n", NR+90, $0}'

echo "== Content 140-210 =="
sed -n '140,210p' "$file" | awk '{printf "%4d: %s\n", NR+140, $0}'

echo "== Content 210-260 =="
sed -n '210,260p' "$file" | awk '{printf "%4d: %s\n", NR+210, $0}'

echo "== Search keyboard handlers in file =="
rg -n "onKeyDown|onKeyUp|keydown|Enter|Space" "$file" -S

echo "== Search for onMovePrompt occurrences in apps/desktop/src =="
rg -n "onMovePrompt" apps/desktop/src -S

Repository: legeling/PromptHub

Length of output: 13373


🏁 Script executed:

file="apps/desktop/src/renderer/components/layout/MainContent.tsx"
rg -n "movePrompt" "$file" -S

Repository: legeling/PromptHub

Length of output: 177


🏁 Script executed:

rg -n "movePrompt" apps/desktop/src/renderer -S

Repository: legeling/PromptHub

Length of output: 1357


🏁 Script executed:

sed -n '1,120p' apps/desktop/src/renderer/stores/prompt.store.ts | awk '{print NR ":" $0}'
sed -n '200,280p' apps/desktop/src/renderer/stores/prompt.store.ts | awk '{print NR ":" $0}'
sed -n '300,420p' apps/desktop/src/renderer/services/database.ts | awk '{print NR ":" $0}'

Repository: legeling/PromptHub

Length of output: 10013


🏁 Script executed:

sed -n '1,120p' apps/desktop/src/renderer/stores/prompt.store.ts | awk '{print NR ":" $0}'
sed -n '200,280p' apps/desktop/src/renderer/stores/prompt.store.ts | awk '{print NR ":" $0}'
sed -n '300,420p' apps/desktop/src/renderer/services/database.ts | awk '{print NR ":" $0}'

Repository: legeling/PromptHub

Length of output: 10013


onMovePrompt 声明为 void 与实际异步实现不匹配,可能导致未处理的 Promise 错误
PromptListView.tsxonMovePrompt: (...) => void,但 MainContent.tsx 传入的是 usePromptStoremovePrompt: async (...) => Promise<void>(内部 await db.movePrompt(...) 等;无 try/catch)。在键盘 Tab(约 51/61)和拖拽 handleDrop(约 160/173)里直接调用 onMovePrompt(...),调用方未 await/catch,Promise reject 可能变成未处理异常且缺少用户反馈。
建议把 onMovePrompt 签名改为 Promise<void>,并在键盘/拖拽触发处统一 awaitcatch 中带上 promptId/newParentId/newOrder 上下文并处理/上抛错误。

Also applies to: 42-62, 151-174

onMovePrompt: (promptId: string, newParentId: string | null, newOrder: number) => void;
🤖 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
14 - 16, The onMovePrompt prop in PromptListView.tsx is declared as returning
void but is implemented/used with async movePrompt (usePromptStore) and is
invoked without awaiting in keyboard handlers (Tab-related code) and handleDrop;
change the prop signature to return Promise<void> (onMovePrompt: (promptId:
string, newParentId: string | null, newOrder: number) => Promise<void>), update
the callers in MainContent.tsx/PromptListView keyboard Tab handlers and the
handleDrop function to await onMovePrompt(...) and wrap calls in try/catch, and
in the catch include contextual details (promptId/newParentId/newOrder) and
surface or rethrow the error so Promise rejections are handled and user
feedback/logging is possible; ensure usePromptStore.movePrompt still returns
Promise<void> and no callers remain unawaited.

Source: Coding guidelines

}

export function PromptListView({
prompts,
selectedId,
selectedIds,
onSelect,
onToggleFavorite,
onCopy,
onContextMenu,
onMovePrompt,
}: PromptListViewProps) {
Comment on lines 19 to 28

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:

#!/bin/bash
set -euo pipefail
ls -la
# show file existence and relevant lines
FILE="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"
if [ -f "$FILE" ]; then
  echo "FOUND: $FILE"
  nl -ba "$FILE" | sed -n '1,120p'
else
  echo "MISSING: $FILE"
  fd -a "PromptListView.tsx" .
fi

Repository: legeling/PromptHub

Length of output: 1563


🏁 Script executed:

#!/bin/bash
set -euo pipefail
ls -la
# show file existence and relevant lines
FILE="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"
if [ -f "$FILE" ]; then
  echo "FOUND: $FILE"
  nl -ba "$FILE" | sed -n '1,160p'
else
  echo "MISSING: $FILE"
  fd -a "PromptListView.tsx" .
fi

Repository: legeling/PromptHub

Length of output: 1563


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"

echo "FOUND? $(test -f "$FILE" && echo yes || echo no): $FILE"
echo "---- signature area ----"
python3 - <<'PY'
import itertools,sys
path="apps/desktop/src/renderer/components/prompt/PromptListView.tsx"
start=1
end=120
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f,1):
        if start<=i<=end:
            print(f"{i:5d}\t{line.rstrip()}")
        if i>end:
            break
PY

echo "---- exact match for export function PromptListView ----"
rg -n --fixed-string "export function PromptListView" "$FILE" || true

Repository: legeling/PromptHub

Length of output: 5818


为导出的 PromptListView 补充显式返回类型(TS 规范)

apps/desktop/src/renderer/components/prompt/PromptListView.tsx 第 19 行的 export function PromptListView(...) 缺少返回类型注解,建议显式标注为 : JSX.Element(或 : React.JSX.Element)。

🤖 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 - 28, The exported function PromptListView is missing an explicit return
type; update its signature to include a JSX return type (e.g., change "export
function PromptListView({...}: PromptListViewProps)" to "export function
PromptListView({...}: PromptListViewProps): JSX.Element" or ":
React.JSX.Element" so TypeScript has an explicit component return type; keep the
function body unchanged and ensure any necessary React types are available from
your TS config/imports.

Source: Coding guidelines

const { t } = useTranslation();
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
const [dropPosition, setDropPosition] = useState<'before' | 'after' | 'inside' | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!selectedId || draggingId) return;

const selectedPrompt = prompts.find(p => p.id === selectedId);
if (!selectedPrompt) return;

if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();

if (e.shiftKey) {
// Shift+Tab: outdent (move to parent level)
if (selectedPrompt.parentId) {
const siblings = prompts.filter(p => p.parentId === selectedPrompt.parentId);
const parentPrompt = prompts.find(p => p.id === selectedPrompt.parentId);
const newOrder = parentPrompt ? prompts.filter(p => p.parentId === parentPrompt?.parentId).length : prompts.filter(p => p.parentId === null).length;
onMovePrompt(selectedId, parentPrompt?.parentId || null, newOrder);
}
} else {
// Tab: indent (move to previous sibling's child)
const siblings = prompts.filter(p => p.parentId === selectedPrompt.parentId).sort((a, b) => (a.order || 0) - (b.order || 0));
const currentIndex = siblings.findIndex(s => s.id === selectedId);

if (currentIndex > 0) {
const prevSibling = siblings[currentIndex - 1];
const childCount = prompts.filter(p => p.parentId === prevSibling.id).length;
onMovePrompt(selectedId, prevSibling.id, childCount);
}
}
}
};

window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedId, prompts, onMovePrompt, draggingId]);
Comment on lines +35 to +69

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

9. Tab key hijacked globally 🐞 Bug ≡ Correctness

PromptListView attaches a window-level keydown listener that prevents default on Tab whenever a
prompt is selected, breaking standard Tab focus navigation across the entire app (including inputs)
even when the list isn’t focused.
Agent Prompt
### Issue description
A global `window` keydown listener intercepts Tab for indentation/outdent, which prevents normal keyboard focus traversal throughout the application whenever `selectedId` is set.

### Issue Context
- The handler is registered on `window` and calls `e.preventDefault()` on Tab.
- There is no check for `event.target` (e.g., input/textarea/contentEditable) and no focus scoping to the list view.

### Fix Focus Areas
- Restrict the handler to the list container (e.g., use `onKeyDown` on a focusable wrapper with `tabIndex={0}`), or at least ignore events when `e.target` is an input/textarea/select/button or `contentEditable`.
- Optionally require a modifier (e.g., Alt+Tab) to avoid overriding standard Tab navigation.

- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[35-69]
- apps/desktop/src/renderer/stores/prompt.store.ts[150-155]

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

Comment on lines +35 to +69

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

全局 Tab 监听会劫持输入控件的键盘导航。

Line 42-44 对 Tab 直接 preventDefault(),未排除 input/textarea/select/contenteditable 场景。用户在可编辑控件中按 Tab 时也会触发层级移动,影响可用性与无障碍操作。

🤖 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
35 - 69, The global Tab key handler in useEffect (handleKeyDown) currently
preventDefault() for Tab without excluding editable elements, causing
input/textarea/select/contentEditable to be hijacked; update handleKeyDown to
early-return when e.target is an editable/focusable element by checking
(e.target instanceof HTMLElement) and then if tagName is INPUT, TEXTAREA, SELECT
or element.isContentEditable (and optionally when closest('[role="textbox"]') or
contenteditable ancestor exists), so the handler only runs for non-editable
areas before any preventDefault()/onMovePrompt logic; keep references to
selectedId, prompts, draggingId and call onMovePrompt unchanged when allowed.


// Format date
// 格式化日期
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
Expand All @@ -40,29 +85,169 @@ export function PromptListView({
}
};

return (
<div className="flex flex-col">
{prompts.map((prompt) => (
const toggleExpand = useCallback((promptId: string) => {
setExpandedIds(prev => {
const newSet = new Set(prev);
if (newSet.has(promptId)) {
newSet.delete(promptId);
} else {
newSet.add(promptId);
}
return newSet;
});
}, []);

const hasChildren = useCallback((promptId: string) => {
return prompts.some(p => p.parentId === promptId);
}, [prompts]);

const getChildren = useCallback((parentId: string | null) => {
return prompts
.filter(p => p.parentId === parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [prompts]);

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

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

const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}, []);

const handleDragEnter = useCallback((e: React.DragEvent, promptId: string) => {
e.preventDefault();
if (draggingId !== promptId) {
setDropTargetId(promptId);
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const y = e.clientY - rect.top;
const height = rect.height;

if (y < height / 3) {
setDropPosition('before');
} else if (y > height * 2 / 3) {
setDropPosition('after');
} else {
setDropPosition('inside');
}
}
}, [draggingId]);

const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDropTargetId(null);
setDropPosition(null);
}, []);

const handleDrop = useCallback((e: React.DragEvent, targetPromptId: string) => {
e.preventDefault();
if (draggingId && draggingId !== targetPromptId) {
const targetPrompt = prompts.find(p => p.id === targetPromptId);
const draggingPrompt = prompts.find(p => p.id === draggingId);

if (targetPrompt && draggingPrompt) {
if (dropPosition === 'inside') {
const childCount = prompts.filter(p => p.parentId === targetPromptId).length;
onMovePrompt(draggingId, targetPromptId, childCount);
} else {
const newParentId = targetPrompt.parentId;
let targetOrder = targetPrompt.order || 0;

if (dropPosition === 'after') {
targetOrder += 1;
}

if (draggingPrompt.parentId === newParentId && draggingPrompt.order && draggingPrompt.order < targetOrder) {
targetOrder -= 1;
}
Comment on lines +169 to +171

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

同级重排时 order=0 会触发错误分支。

Line 169 使用 draggingPrompt.order && ...,当 order0 时条件为假,targetOrder 不会被正确回调,导致插入位置偏移。应改为显式判空判断(如 draggingPrompt.order !== undefined)。

建议修复
-          if (draggingPrompt.parentId === newParentId && draggingPrompt.order && draggingPrompt.order < targetOrder) {
+          if (
+            draggingPrompt.parentId === newParentId &&
+            draggingPrompt.order !== undefined &&
+            draggingPrompt.order < targetOrder
+          ) {
             targetOrder -= 1;
           }
📝 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 (draggingPrompt.parentId === newParentId && draggingPrompt.order && draggingPrompt.order < targetOrder) {
targetOrder -= 1;
}
if (
draggingPrompt.parentId === newParentId &&
draggingPrompt.order !== undefined &&
draggingPrompt.order < targetOrder
) {
targetOrder -= 1;
}
🤖 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
169 - 171, The condition that adjusts targetOrder incorrectly treats
draggingPrompt.order === 0 as falsy; update the check in the block that compares
draggingPrompt.parentId to newParentId so it uses an explicit presence test
(e.g., draggingPrompt.order !== undefined/null) instead of a truthiness test,
ensuring the branch runs for order === 0 and properly decrements targetOrder
when needed.


onMovePrompt(draggingId, newParentId, targetOrder);
}
Comment on lines +151 to +174

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

8. Cycle can crash tree 🐞 Bug ☼ Reliability

PromptListView allows moving a prompt inside any other prompt without preventing drops onto
descendants, enabling parentId cycles; the recursive renderer has no cycle detection and can recurse
indefinitely when expanded.
Agent Prompt
### Issue description
The UI can create cyclic parent relationships (A parent of B, then moving A under B). The tree renderer recursively renders children by `parentId` without a visited-set guard, which can cause infinite recursion/stack overflow.

### Issue Context
- `handleDrop` allows `onMovePrompt(draggingId, targetPromptId, ...)` for `inside` drops.
- Only `draggingId !== targetPromptId` is checked; descendants are not.
- Rendering recurses via `renderTreeNode(child, depth + 1)`.

### Fix Focus Areas
- Before calling `onMovePrompt`, detect whether `targetPromptId` is a descendant of `draggingId` (build a parent->children map from `prompts` and walk).
- Add a defensive cycle guard in rendering (e.g., pass a `visited` set per render path) to prevent hangs even if data is corrupted.
- Consider also validating in main/DB layer (reject moves that introduce cycles).

- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[151-176]
- apps/desktop/src/renderer/components/prompt/PromptListView.tsx[312-316]

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

}
}
setDraggingId(null);
setDropTargetId(null);
setDropPosition(null);
}, [draggingId, dropPosition, prompts, onMovePrompt]);

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

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

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

const renderTreeNode = useCallback((prompt: Prompt, depth: number) => {
const hasKids = hasChildren(prompt.id);
const isExpanded = expandedIds.has(prompt.id);

return (
<div key={prompt.id}>
<div
key={prompt.id}
draggable
onDragStart={(e) => handleDragStart(e, prompt.id)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={(e) => handleDragEnter(e, prompt.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, 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
transition-colors duration-quick relative
${isSelected(prompt.id)
? 'bg-primary/10 border-l-2 border-l-primary'
: 'hover:bg-accent/50'
: 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` }}

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. Inline style paddingleft used 📘 Rule violation ⚙ Maintainability

PromptListView uses an inline style prop to compute indentation, violating the Tailwind-only
styling requirement. Inline styles can bypass design tokens and theming conventions (light/dark).
Agent Prompt
## Issue description
An inline `style={{ paddingLeft: ... }}` is used for indentation.

## Issue Context
UI styling must be Tailwind-only; inline styles are prohibited.

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

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

>
{/* Title and description */}
{/* 标题和描述 */}
<div className="flex items-center gap-1">
{hasKids && (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand(prompt.id);
}}
className="p-0.5 rounded hover:bg-accent transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</button>
Comment on lines +228 to +240

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. Expand button lacks aria-label 📘 Rule violation ≡ Correctness

The expand/collapse icon-only <button> in PromptListView has no accessible name (no
aria-label), which harms screen-reader usability. This violates the accessibility requirement for
interactive elements.
Agent Prompt
## Issue description
Icon-only expand/collapse button has no ARIA label.

## Issue Context
Interactive elements need accessible labels for assistive technologies.

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

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

)}
{!hasKids && <span className="w-5" />}
<GripVertical className="w-4 h-4 text-muted-foreground cursor-grab opacity-0 hover:opacity-100 transition-opacity" />
</div>

<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'
isSelected(prompt.id) ? 'text-primary' : 'text-foreground'
}`}
title={prompt.title}
>
Expand All @@ -85,24 +270,18 @@ export function PromptListView({
)}
</div>

{/* Usage count */}
{/* 使用次数 */}
<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>

{/* Action buttons */}
{/* 操作按钮 */}
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={(e) => {
Expand All @@ -129,7 +308,21 @@ export function PromptListView({
</button>
</div>
</div>
))}

{hasKids && isExpanded && (
<div>
{getChildren(prompt.id).map((child) => renderTreeNode(child, depth + 1))}
</div>
)}
</div>
);
}, [hasChildren, expandedIds, toggleExpand, getChildren, isSelected, isDragging, isDropTarget, dropPosition, onSelect, onContextMenu, handleDragStart, handleDragEnd, handleDragOver, handleDragEnter, handleDragLeave, handleDrop, onCopy, onToggleFavorite, t]);

const rootNodes = getChildren(null);

return (
<div className="flex flex-col">
{rootNodes.map((node) => renderTreeNode(node, 0))}
</div>
);
}
}
Loading
Loading