Skip to content

feat: align provider group management with provider list#1033

Merged
ding113 merged 2 commits intodevfrom
fix/provider-group-tab-parity
Apr 19, 2026
Merged

feat: align provider group management with provider list#1033
ding113 merged 2 commits intodevfrom
fix/provider-group-tab-parity

Conversation

@ding113
Copy link
Copy Markdown
Owner

@ding113 ding113 commented Apr 18, 2026

Summary

Related PRs

Changes

Core Changes (provider-group-tab.tsx)

  • Inline editing for group rows: Cost multiplier and description now editable inline via popover (desktop) or drawer (mobile)
  • Provider type icons: Group member rows now show provider type icons and badges like the main provider list
  • Shared provider editor: Clicking a provider name in group members opens the same editor dialog used in the main list
  • Batch management in members: Full batch toolbar support (select all, invert, select by type) inside expanded group panels
  • Responsive UX: Uses popover on desktop, drawer on mobile for all inline edits

Supporting Changes

  • provider-batch-toolbar.tsx: Added showSelectByGroup prop to hide group dropdown when inside a group context
  • provider-list.tsx: Added onEditProvider callback prop for external edit triggers
  • provider-manager.tsx: Manages shared provider editor dialog; handles edit requests from both list and group tab views
  • i18n updates: New translation keys for group multiplier, description editing, and provider type labels (5 languages)

Test Coverage

  • New test file: provider-group-tab.test.tsx - Tests provider edit request forwarding from group tab
  • Extended existing tests: Batch toolbar group selection hiding, provider manager group tab integration

Testing

Automated Tests

bun run build
bun run lint
bun run lint:fix
bun run typecheck
bun run test
bunx vitest run tests/unit/settings/providers/provider-manager.test.tsx tests/unit/settings/providers/provider-batch-toolbar-selection.test.tsx tests/unit/settings/providers/provider-group-tab.test.tsx

Manual Testing

  1. Navigate to Settings > Providers > Groups tab
  2. Expand a group with members by clicking the chevron
  3. Verify provider type icons and badges appear in member rows
  4. Click a provider name → should open shared provider editor dialog
  5. Click cost multiplier in group row → inline popover should open
  6. Test batch selection in member panel: "Select All", "Invert", "Select by Type"
  7. On mobile: verify inline edits use drawer instead of popover

Checklist

  • Code follows project conventions
  • Self-review completed
  • Tests pass locally
  • i18n updated (5 languages)

Description enhanced by Claude AI

Greptile Summary

This PR extends the provider group management tab with inline editing for cost multiplier and description, provider type icons/badges in member rows, shared provider editor forwarding, and batch selection within expanded group panels — aligning the group tab UX with the main provider list.

The view-mode preservation bug noted in the previous review thread (where opening the provider editor from the groups tab would switch the view to "list") has been addressed in the accompanying fix commit by removing the setViewMode("list") call from handleOpenProviderEditor.

Confidence Score: 5/5

Safe to merge — the view-mode preservation bug from the prior review thread is resolved, all i18n keys are present across 5 languages, and the new inline editing and batch-management paths are logically consistent with the existing provider list patterns.

Both remaining findings are P2: an i18n namespace reuse nit in InlineTextEditPopover and a potential test-flakiness note for the async useTransition flush. Neither affects production correctness or data integrity.

No files require special attention. provider-group-tab.tsx is the largest change but is well-structured and correctly mirrors existing patterns.

Important Files Changed

Filename Overview
src/app/[locale]/settings/providers/_components/provider-group-tab.tsx Core file: adds InlineTextEditPopover for description editing, provider type icons in MemberRow, edit forwarding via onRequestEditProvider, and batch management inside GroupMembersPanel. Logic is sound and consistent with the main provider list patterns.
src/app/[locale]/settings/providers/_components/provider-manager.tsx handleOpenProviderEditor no longer calls setViewMode("list"), preserving the groups view when editing a provider from the group tab. handleRequestEditProvider bridges the group tab callback to the shared dialog.
src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-toolbar.tsx Adds showSelectByGroup prop (default true) to hide the group dropdown when inside a group context; correctly gates rendering behind the flag.
src/app/[locale]/settings/providers/_components/provider-list.tsx Adds optional onEditProvider callback prop; no functional changes to existing behavior.
tests/unit/settings/providers/provider-group-tab.test.tsx New test file covering provider edit forwarding and admin/non-admin visibility. Test uses a single Promise.resolve() tick to flush async group loading via useTransition, which may be fragile on slower environments.
tests/unit/settings/providers/provider-manager.test.tsx Adds test verifying that the groups view is preserved when a provider editor is opened from the group tab; covers the view-mode bug fix.
tests/unit/settings/providers/provider-batch-toolbar-selection.test.tsx Extends existing batch toolbar tests with a case for showSelectByGroup=false hiding the group dropdown; clean and sufficient.
messages/en/settings/providers/providerGroups.json Adds new i18n keys: groupMultiplierLabel, groupDescriptionLabel, noDescription, descriptionTooLong, openProviderEditor; all present and correctly translated across all 5 language files.

Sequence Diagram

sequenceDiagram
    participant User
    participant ProviderGroupTab
    participant GroupMembersPanel
    participant MemberRow
    participant ProviderManager
    participant ProviderForm

    User->>ProviderGroupTab: Click chevron (expand group)
    ProviderGroupTab->>GroupMembersPanel: Render with members[]

    User->>MemberRow: Click provider name / Edit icon
    MemberRow->>GroupMembersPanel: onRequestEditProvider(providerId)
    GroupMembersPanel->>ProviderGroupTab: onRequestEditProvider(providerId)
    ProviderGroupTab->>ProviderManager: onRequestEditProvider(providerId)
    ProviderManager->>ProviderManager: setEditingProviderId(id) [viewMode stays groups]
    ProviderManager->>ProviderForm: Open Dialog with provider

    User->>MemberRow: Click cost multiplier cell
    MemberRow->>MemberRow: InlineEditPopover opens
    User->>MemberRow: Enter new value, Save
    MemberRow->>MemberRow: editProvider(id, group_priorities)
    MemberRow->>ProviderGroupTab: onSaved() → fetchGroups()

    User->>GroupMembersPanel: Enter batch mode, select members
    GroupMembersPanel->>GroupMembersPanel: ProviderBatchToolbar showSelectByGroup=false
    User->>GroupMembersPanel: Open batch edit
    GroupMembersPanel->>GroupMembersPanel: ProviderBatchDialog providers=members
    GroupMembersPanel->>ProviderGroupTab: onSuccess → onSaved()
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/app/[locale]/settings/providers/_components/provider-group-tab.tsx
Line: 851

Comment:
**i18n namespace reuse for common labels**

`InlineTextEditPopover` borrows `save` and `cancel` from the `providerGroups` namespace while the existing `InlineEditPopover` uses the dedicated `settings.providers.inlineEdit` namespace for the same labels. The translations happen to match today, but if either namespace is updated independently (e.g. a different button label style), the two components will diverge silently.

Consider reusing the `inlineEdit` namespace here:
```suggestion
  const t = useTranslations("settings.providers.inlineEdit");
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: tests/unit/settings/providers/provider-group-tab.test.tsx
Line: 154-157

Comment:
**Single microtask tick may not flush useTransition async work**

`startLoadTransition(async () => { const result = await getProviderGroups(); ... })` schedules an async transition. A single `await Promise.resolve()` flushes one microtask level but may not be enough to settle the state update that follows the `await getProviderGroups()` call — meaning the groups table (and therefore the expand button) might not yet be in the DOM when the assertion runs.

If this test ever becomes flaky, replace with:
```ts
await act(async () => {
  await new Promise((resolve) => setTimeout(resolve, 0));
});
```
or use `await vi.runAllTimersAsync()` if fake timers are configured.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (2): Last reviewed commit: "fix: preserve group view context in prov..." | Re-trigger Greptile

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 18, 2026

📝 Walkthrough

Walkthrough

在供应商分组与管理功能中新增/扩展多语言文案键,重构 ProviderGroupTab 以接收外部 providers 并支持内联/多选编辑,添加 ProviderManager 的单供应商编辑对话流,并为相关交互新增单元测试与可选渲染属性。

Changes

Cohort / File(s) Summary
国际化文案扩展
messages/en/settings/providers/providerGroups.json, messages/ja/settings/providers/providerGroups.json, messages/ru/settings/providers/providerGroups.json, messages/zh-CN/settings/providers/providerGroups.json, messages/zh-TW/settings/providers/providerGroups.json
新增 8 个消息键(groupMembersHintproviderTypegroupMultiplierLabelgroupDescriptionLabelnoDescriptiondescriptionTooLongopenProviderEditor),并调整 savePrioritySuccess 排列/标点。
批处理工具栏与选择行为
src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-toolbar.tsx
新增可选 prop showSelectByGroup?: boolean(默认 true),将“按组选择”下拉渲染改为依赖该 prop 与 uniqueGroups.length > 0 的组合条件。
提供者列表编辑回调
src/app/[locale]/settings/providers/_components/provider-list.tsx
ProviderList 添加可选 onEditProvider?: (provider: ProviderDisplay) => void,并将其传递给 ProviderRichListItemonEdit
ProviderGroupTab 重构(核心改动)
src/app/[locale]/settings/providers/_components/provider-group-tab.tsx
将组件签名改为接收 providers, isAdmin, onRequestEditProvider,移除内部 fetch,新增 GroupMembersPanel、MemberRow、Inline 编辑组件,合并保存/校验逻辑(描述长度、成本倍率验证),简化删除错误处理,并在保存优先级后使 React Query 失效刷新。
ProviderManager:集成单项编辑对话流
src/app/[locale]/settings/providers/_components/provider-manager.tsx
新增 editingProviderId 状态、编辑打开/关闭回调,渲染包含 ProviderForm mode="edit" 的 Dialog,并将 ProviderGroupTab/ProviderList 与编辑回调连接。
单元测试新增/调整
tests/unit/settings/providers/provider-batch-toolbar-selection.test.tsx, tests/unit/settings/providers/provider-group-tab.test.tsx, tests/unit/settings/providers/provider-manager.test.tsx
新增测试覆盖:batch-toolbar 的 showSelectByGroup 隐藏行为、ProviderGroupTab 的成员展示及编辑回调行为、ProviderManager 的打开编辑对话流;包含对 React Query 环境与若干模块的 mock。

代码审查预估工作量

🎯 4 (复杂) | ⏱️ ~60 分钟

可能相关的PR

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR标题准确概括了主要变更——使提供商分组管理与提供商列表交互模型保持一致,包括内联编辑、类型图标和共享编辑器的引入。
Description check ✅ Passed 提交描述清晰地关联到变更集,详细说明了在提供商组管理选项卡中添加的内联编辑、提供商类型图标、共享编辑器和批量管理功能。

✏️ 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 fix/provider-group-tab-parity

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.

@github-actions
Copy link
Copy Markdown
Contributor

🧪 测试结果

测试类型 状态
代码质量
单元测试
集成测试
API 测试

总体结果: ✅ 所有测试通过

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f39a05634e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +804 to +808
<Button
size="sm"
variant="ghost"
className="h-8 px-2"
onClick={() => onRequestEditProvider(member.id)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restrict group-member edit entry to admins

When canEdit is false (non-admin users), this button still calls onRequestEditProvider, which opens the shared ProviderForm dialog through ProviderManager. That bypasses the existing UI permission model used in the provider list (where edit entry points are hidden for non-admins) and exposes an edit workflow to unauthorized users. Even if backend saves are rejected, non-admins can still access sensitive configuration screens and trigger edit attempts; the edit entry points in MemberRow should be gated by canEdit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

已处理。成员行的名称点击入口和右侧编辑按钮现在都按 canEdit 门控;非管理员只会看到只读名称,不会再触发共享编辑弹窗。对应回归已补到 provider-group-tab.test.tsx

Comment on lines +366 to +378
const handleOpenProviderEditor = useCallback((provider: ProviderDisplay) => {
setViewMode("list");
setEditingProviderId(provider.id);
}, []);

const handleRequestEditProvider = useCallback(
(providerId: number) => {
const provider = providers.find((item) => item.id === providerId);
if (!provider) return;
handleOpenProviderEditor(provider);
},
[handleOpenProviderEditor, providers]
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 View mode switches away from groups after editing a provider

handleOpenProviderEditor always calls setViewMode("list"), so clicking the edit entry in the groups panel switches the view to list. After the user closes the dialog, they land on the list tab rather than returning to the groups tab they came from. Since the editor dialog is a modal overlay mounted at ProviderManager level and independent of viewMode, the setViewMode("list") call isn't needed for the dialog to appear.

Consider keeping the view state when editing from the groups context:

Suggested change
const handleOpenProviderEditor = useCallback((provider: ProviderDisplay) => {
setViewMode("list");
setEditingProviderId(provider.id);
}, []);
const handleRequestEditProvider = useCallback(
(providerId: number) => {
const provider = providers.find((item) => item.id === providerId);
if (!provider) return;
handleOpenProviderEditor(provider);
},
[handleOpenProviderEditor, providers]
);
const handleRequestEditProvider = useCallback(
(providerId: number) => {
const provider = providers.find((item) => item.id === providerId);
if (!provider) return;
// Open the editor without forcibly switching away from groups view
setEditingProviderId(provider.id);
},
[providers]
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/settings/providers/_components/provider-manager.tsx
Line: 366-378

Comment:
**View mode switches away from groups after editing a provider**

`handleOpenProviderEditor` always calls `setViewMode("list")`, so clicking the edit entry in the groups panel switches the view to list. After the user closes the dialog, they land on the list tab rather than returning to the groups tab they came from. Since the editor dialog is a modal overlay mounted at `ProviderManager` level and independent of `viewMode`, the `setViewMode("list")` call isn't needed for the dialog to appear.

Consider keeping the view state when editing from the groups context:

```suggestion
  const handleRequestEditProvider = useCallback(
    (providerId: number) => {
      const provider = providers.find((item) => item.id === providerId);
      if (!provider) return;
      // Open the editor without forcibly switching away from groups view
      setEditingProviderId(provider.id);
    },
    [providers]
  );
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

已处理。handleOpenProviderEditor 不再切换 viewMode,现在只设置 editingProviderId,因此从 groups 视图打开编辑弹窗后会保留当前上下文。对应测试已更新。

Comment on lines +818 to +1011
interface InlineTextEditPopoverProps {
value: string;
emptyLabel: string;
label: string;
placeholder: string;
validator: (value: string) => string | null;
onSave: (value: string) => Promise<boolean>;
}

function InlineTextEditPopover({
value,
emptyLabel,
label,
placeholder,
validator,
onSave,
}: InlineTextEditPopoverProps) {
const t = useTranslations("settings.providers.providerGroups");
const isDesktop = useMediaQuery("(min-width: 768px)");
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
const [saving, setSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

const trimmedDraft = draft.trim();
const validationError = useMemo(() => validator(trimmedDraft), [trimmedDraft, validator]);
const canSave = !saving && validationError == null;

useEffect(() => {
if (!open) return;
const raf = requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
return () => cancelAnimationFrame(raf);
}, [open]);

const resetDraft = useCallback(() => {
setDraft(value);
}, [value]);

const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
setDraft(value);
} else {
resetDraft();
setSaving(false);
}
setOpen(nextOpen);
},
[resetDraft, value]
);

const handleCancel = useCallback(() => {
resetDraft();
setOpen(false);
}, [resetDraft]);

const handleSave = useCallback(async () => {
if (!canSave) return;
setSaving(true);
try {
const ok = await onSave(trimmedDraft);
if (ok) {
setOpen(false);
}
} finally {
setSaving(false);
}
}, [canSave, onSave, trimmedDraft]);

const stopPropagation = (event: React.SyntheticEvent) => {
event.stopPropagation();
};

const trigger = (
<button
type="button"
className={cn(
"max-w-full rounded-sm text-left underline-offset-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
value
? "text-muted-foreground hover:underline"
: "text-muted-foreground/80 italic hover:underline"
)}
onPointerDown={stopPropagation}
onClick={(event) => {
event.stopPropagation();
if (!isDesktop) handleOpenChange(true);
}}
>
<span className="line-clamp-2">{value || emptyLabel}</span>
</button>
);

const inputProps = {
ref: inputRef,
value: draft,
placeholder,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value),
disabled: saving,
"aria-label": label,
"aria-invalid": validationError != null,
onPointerDown: stopPropagation,
onClick: stopPropagation,
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
event.stopPropagation();
if (event.key === "Escape") {
event.preventDefault();
handleCancel();
}
if (event.key === "Enter") {
event.preventDefault();
void handleSave();
}
},
};

const content = (
<div className="grid gap-2">
<div className="hidden text-xs font-medium md:block">{label}</div>
<Input {...inputProps} className="w-full md:w-[320px]" />
{validationError ? <div className="text-xs text-destructive">{validationError}</div> : null}
<div className="flex items-center justify-end gap-2 pt-1">
<Button type="button" size="sm" variant="outline" onClick={handleCancel} disabled={saving}>
{t("cancel")}
</Button>
<Button type="button" size="sm" onClick={handleSave} disabled={!canSave}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("save")}
</Button>
</div>
</div>
);

if (!isDesktop) {
return (
<>
{trigger}
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{label}</DrawerTitle>
</DrawerHeader>
<div className="px-4 pb-6">
<div className="grid gap-3">
<Input
{...inputProps}
className="text-base"
onPointerDown={undefined}
onClick={undefined}
/>
{validationError ? (
<div className="text-sm text-destructive">{validationError}</div>
) : null}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
onClick={handleCancel}
disabled={saving}
className="flex-1"
size="lg"
>
{t("cancel")}
</Button>
<Button onClick={handleSave} disabled={!canSave} className="flex-1" size="lg">
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("save")}
</Button>
</div>
</div>
</div>
</DrawerContent>
</Drawer>
</>
);
}

return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
className="w-auto p-3"
onPointerDown={stopPropagation}
onClick={stopPropagation}
>
{content}
</PopoverContent>
</Popover>
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 InlineTextEditPopover duplicates the existing InlineEditPopover pattern

InlineTextEditPopover (lines 818–1011) re-implements the same popover/drawer responsive pattern as InlineEditPopover in inline-edit-popover.tsx, including identical requestAnimationFrame focus logic, resetDraft, handleOpenChange, Drawer vs Popover branching, and keyboard shortcut handling (Enter/Escape). The only difference is that it works on string instead of number.

Extracting this into a shared InlineTextEditPopover file (or generalising the existing InlineEditPopover with a generic type parameter) would avoid maintaining two parallel implementations of the same UI pattern.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/settings/providers/_components/provider-group-tab.tsx
Line: 818-1011

Comment:
**`InlineTextEditPopover` duplicates the existing `InlineEditPopover` pattern**

`InlineTextEditPopover` (lines 818–1011) re-implements the same popover/drawer responsive pattern as `InlineEditPopover` in `inline-edit-popover.tsx`, including identical `requestAnimationFrame` focus logic, `resetDraft`, `handleOpenChange`, Drawer vs Popover branching, and keyboard shortcut handling (`Enter`/`Escape`). The only difference is that it works on `string` instead of `number`.

Extracting this into a shared `InlineTextEditPopover` file (or generalising the existing `InlineEditPopover` with a generic type parameter) would avoid maintaining two parallel implementations of the same UI pattern.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/app/[locale]/settings/providers/_components/provider-group-tab.tsx (2)

276-279: ⚠️ Potential issue | 🟠 Major

把所有编辑入口都按 isAdmin / canEdit 门控。

现在非管理员仍能看到“新增群组”、群组编辑/删除按钮,以及成员行的 provider 编辑入口;这些都会触发修改流程。既然该组件已经传入 isAdmin 并向成员面板传入 canEdit,这些入口也应一致隐藏或禁用。

建议调整
-        <Button onClick={openCreateDialog} size="sm">
-          <Plus className="mr-1.5 h-4 w-4" />
-          {t("addGroup")}
-        </Button>
+        {isAdmin ? (
+          <Button onClick={openCreateDialog} size="sm">
+            <Plus className="mr-1.5 h-4 w-4" />
+            {t("addGroup")}
+          </Button>
+        ) : null}
-                        <div className="flex items-center justify-end gap-1">
-                          <Button
-                            variant="ghost"
-                            size="icon"
-                            className="h-8 w-8"
-                            onClick={() => openEditDialog(group)}
-                            title={t("editGroup")}
-                          >
-                            <Pencil className="h-3.5 w-3.5" />
-                          </Button>
-                          <Button
-                            variant="ghost"
-                            size="icon"
-                            className="h-8 w-8 text-destructive hover:text-destructive"
-                            onClick={() => openDeleteConfirm(group)}
-                            disabled={isDefault}
-                            title={isDefault ? t("cannotDeleteDefault") : t("deleteGroup")}
-                          >
-                            <Trash2 className="h-3.5 w-3.5" />
-                          </Button>
-                        </div>
+                        {isAdmin ? (
+                          <div className="flex items-center justify-end gap-1">
+                            <Button
+                              variant="ghost"
+                              size="icon"
+                              className="h-8 w-8"
+                              onClick={() => openEditDialog(group)}
+                              title={t("editGroup")}
+                            >
+                              <Pencil className="h-3.5 w-3.5" />
+                            </Button>
+                            <Button
+                              variant="ghost"
+                              size="icon"
+                              className="h-8 w-8 text-destructive hover:text-destructive"
+                              onClick={() => openDeleteConfirm(group)}
+                              disabled={isDefault}
+                              title={isDefault ? t("cannotDeleteDefault") : t("deleteGroup")}
+                            >
+                              <Trash2 className="h-3.5 w-3.5" />
+                            </Button>
+                          </div>
+                        ) : null}
-            <button
-              type="button"
-              className="truncate text-left font-medium underline-offset-4 hover:underline"
-              onClick={() => onRequestEditProvider(member.id)}
-            >
-              {member.name}
-            </button>
+            {canEdit ? (
+              <button
+                type="button"
+                className="truncate text-left font-medium underline-offset-4 hover:underline"
+                onClick={() => onRequestEditProvider(member.id)}
+              >
+                {member.name}
+              </button>
+            ) : (
+              <span className="truncate text-left font-medium">{member.name}</span>
+            )}
@@
-        <Button
-          size="sm"
-          variant="ghost"
-          className="h-8 px-2"
-          onClick={() => onRequestEditProvider(member.id)}
-          title={t("openProviderEditor")}
-        >
-          <Edit className="h-4 w-4" />
-        </Button>
+        {canEdit ? (
+          <Button
+            size="sm"
+            variant="ghost"
+            className="h-8 px-2"
+            onClick={() => onRequestEditProvider(member.id)}
+            title={t("openProviderEditor")}
+            aria-label={t("openProviderEditor")}
+          >
+            <Edit className="h-4 w-4" />
+          </Button>
+        ) : null}

Also applies to: 373-393, 773-812

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/settings/providers/_components/provider-group-tab.tsx
around lines 276 - 279, The Add Group button and all edit/delete provider
controls must be gated by the passed isAdmin/canEdit props: update the component
in provider-group-tab.tsx so the Plus/Add Group Button (openCreateDialog), group
edit/delete buttons, and the provider edit entry in the member row check isAdmin
or canEdit before rendering or enablement; use the existing isAdmin prop for
top-level controls and the canEdit prop passed down to the members panel (e.g.
where provider edit links/buttons are rendered) to conditionally hide or disable
those elements so non-admins cannot see or trigger modification flows (also
apply same gating logic to the other edit/delete button blocks referenced in the
file).

178-216: ⚠️ Potential issue | 🟡 Minor

让弹窗保存路径也校验描述长度。

validateDescription() 只用于 inline 编辑;通过新增/编辑群组弹窗保存时,form.description 可以超过 500 字符并直接提交,和新增的 descriptionTooLong 约束不一致。

建议调整
     const trimmedName = form.name.trim();
+    const trimmedDescription = form.description.trim();
     if (!editingGroup && !trimmedName) {
       toast.error(t("nameRequired"));
       return;
     }
+    if (trimmedDescription.length > 500) {
+      toast.error(t("descriptionTooLong"));
+      return;
+    }
 
     startSaveTransition(async () => {
       if (editingGroup) {
         const ok = await saveGroupPatch(editingGroup.id, {
           costMultiplier,
-          description: form.description.trim() || null,
+          description: trimmedDescription || null,
         });
@@
       const result = await createProviderGroup({
         name: trimmedName,
         costMultiplier,
-        description: form.description.trim() || undefined,
+        description: trimmedDescription || undefined,
       });

Also applies to: 255-261

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/settings/providers/_components/provider-group-tab.tsx
around lines 178 - 216, handleSave currently doesn't validate description length
so form.description can exceed the 500-char constraint enforced elsewhere
(validateDescription). Add a length check in handleSave before calling
saveGroupPatch/createProviderGroup: compute const desc =
form.description.trim(); if (desc.length > 500) {
toast.error(t("descriptionTooLong")); return; } then use desc in the payload
(description: editingGroup ? (desc || null) : (desc || undefined)) so both edit
and create paths respect the same 500-character constraint and preserve existing
null/undefined handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/`[locale]/settings/providers/_components/provider-manager.tsx:
- Around line 366-369: The handler handleOpenProviderEditor currently forces the
UI back to list view by calling setViewMode("list"); remove that call so opening
the provider editor only sets the editing id (setEditingProviderId(provider.id))
and leaves viewMode unchanged; update handleOpenProviderEditor to only call
setEditingProviderId(provider.id) to preserve the group context and expanded
state when the Dialog (provider editor) is rendered independently.

In `@tests/unit/settings/providers/provider-group-tab.test.tsx`:
- Around line 39-93: makeProvider 的返回对象缺少若干 ProviderDisplay 的必填字段,导致类型检查失败;在
makeProvider(...) 返回的字面量中添加缺失字段
disableSessionReuse、activeTimeStart、activeTimeEnd、allowedClients、blockedClients、swapCacheTtlBilling、codexServiceTierPreference、anthropicAdaptiveThinking(使用合理的默认值,例如布尔/字符串/数组/数字/null
等),并保留 ...overrides 以允许覆盖,以确保 ProviderDisplay 类型完整且测试 fixture 正常通过类型检查。

---

Outside diff comments:
In `@src/app/`[locale]/settings/providers/_components/provider-group-tab.tsx:
- Around line 276-279: The Add Group button and all edit/delete provider
controls must be gated by the passed isAdmin/canEdit props: update the component
in provider-group-tab.tsx so the Plus/Add Group Button (openCreateDialog), group
edit/delete buttons, and the provider edit entry in the member row check isAdmin
or canEdit before rendering or enablement; use the existing isAdmin prop for
top-level controls and the canEdit prop passed down to the members panel (e.g.
where provider edit links/buttons are rendered) to conditionally hide or disable
those elements so non-admins cannot see or trigger modification flows (also
apply same gating logic to the other edit/delete button blocks referenced in the
file).
- Around line 178-216: handleSave currently doesn't validate description length
so form.description can exceed the 500-char constraint enforced elsewhere
(validateDescription). Add a length check in handleSave before calling
saveGroupPatch/createProviderGroup: compute const desc =
form.description.trim(); if (desc.length > 500) {
toast.error(t("descriptionTooLong")); return; } then use desc in the payload
(description: editingGroup ? (desc || null) : (desc || undefined)) so both edit
and create paths respect the same 500-character constraint and preserve existing
null/undefined handling.
🪄 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: bb13840d-f37e-4b1a-976d-4669d1cb5483

📥 Commits

Reviewing files that changed from the base of the PR and between b991c3a and f39a056.

📒 Files selected for processing (12)
  • messages/en/settings/providers/providerGroups.json
  • messages/ja/settings/providers/providerGroups.json
  • messages/ru/settings/providers/providerGroups.json
  • messages/zh-CN/settings/providers/providerGroups.json
  • messages/zh-TW/settings/providers/providerGroups.json
  • src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-toolbar.tsx
  • src/app/[locale]/settings/providers/_components/provider-group-tab.tsx
  • src/app/[locale]/settings/providers/_components/provider-list.tsx
  • src/app/[locale]/settings/providers/_components/provider-manager.tsx
  • tests/unit/settings/providers/provider-batch-toolbar-selection.test.tsx
  • tests/unit/settings/providers/provider-group-tab.test.tsx
  • tests/unit/settings/providers/provider-manager.test.tsx

Comment on lines +39 to +93
function makeProvider(overrides: Partial<ProviderDisplay> = {}): ProviderDisplay {
return {
id: 1,
name: "Provider A",
url: "https://api.example.com",
maskedKey: "sk-***",
isEnabled: true,
weight: 1,
priority: 1,
costMultiplier: 1.5,
groupTag: "premium",
groupPriorities: { premium: 3 },
providerType: "claude",
providerVendorId: null,
preserveClientIp: false,
modelRedirects: null,
allowedModels: null,
mcpPassthroughType: "none",
mcpPassthroughUrl: null,
limit5hUsd: null,
limitDailyUsd: null,
dailyResetMode: "fixed",
dailyResetTime: "00:00",
limitWeeklyUsd: null,
limitMonthlyUsd: null,
limitTotalUsd: null,
limitConcurrentSessions: 1,
maxRetryAttempts: null,
circuitBreakerFailureThreshold: 1,
circuitBreakerOpenDuration: 60,
circuitBreakerHalfOpenSuccessThreshold: 1,
proxyUrl: null,
proxyFallbackToDirect: false,
firstByteTimeoutStreamingMs: 0,
streamingIdleTimeoutMs: 0,
requestTimeoutNonStreamingMs: 0,
websiteUrl: null,
faviconUrl: null,
cacheTtlPreference: null,
context1mPreference: null,
codexReasoningEffortPreference: null,
codexReasoningSummaryPreference: null,
codexTextVerbosityPreference: null,
codexParallelToolCallsPreference: null,
anthropicMaxTokensPreference: null,
anthropicThinkingBudgetPreference: null,
geminiGoogleSearchPreference: null,
tpm: null,
rpm: null,
rpd: null,
cc: null,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
...overrides,
};
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
# 查看 ProviderDisplay 必填字段与测试 fixture 字段,确认是否仍有遗漏。
rg -n -A90 'export interface ProviderDisplay' --glob 'src/types/provider.ts'
rg -n -A70 'function makeProvider' --glob 'tests/unit/settings/providers/provider-group-tab.test.tsx'

Repository: ding113/claude-code-hub

Length of output: 11803


补齐 ProviderDisplay 的必填字段,避免类型检查失败。

makeProvider() 缺少以下必填字段:disableSessionReuseactiveTimeStartactiveTimeEndallowedClientsblockedClientsswapCacheTtlBillingcodexServiceTierPreferenceanthropicAdaptiveThinking。fixture 不完整会导致 TypeScript 类型检查失败。

建议补齐 fixture
     providerVendorId: null,
     preserveClientIp: false,
+    disableSessionReuse: false,
     modelRedirects: null,
+    activeTimeStart: null,
+    activeTimeEnd: null,
     allowedModels: null,
+    allowedClients: [],
+    blockedClients: [],
     mcpPassthroughType: "none",
     mcpPassthroughUrl: null,
@@
     faviconUrl: null,
     cacheTtlPreference: null,
+    swapCacheTtlBilling: false,
     context1mPreference: null,
@@
     codexTextVerbosityPreference: null,
     codexParallelToolCallsPreference: null,
+    codexServiceTierPreference: null,
     anthropicMaxTokensPreference: null,
     anthropicThinkingBudgetPreference: null,
+    anthropicAdaptiveThinking: null,
     geminiGoogleSearchPreference: null,
📝 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
function makeProvider(overrides: Partial<ProviderDisplay> = {}): ProviderDisplay {
return {
id: 1,
name: "Provider A",
url: "https://api.example.com",
maskedKey: "sk-***",
isEnabled: true,
weight: 1,
priority: 1,
costMultiplier: 1.5,
groupTag: "premium",
groupPriorities: { premium: 3 },
providerType: "claude",
providerVendorId: null,
preserveClientIp: false,
modelRedirects: null,
allowedModels: null,
mcpPassthroughType: "none",
mcpPassthroughUrl: null,
limit5hUsd: null,
limitDailyUsd: null,
dailyResetMode: "fixed",
dailyResetTime: "00:00",
limitWeeklyUsd: null,
limitMonthlyUsd: null,
limitTotalUsd: null,
limitConcurrentSessions: 1,
maxRetryAttempts: null,
circuitBreakerFailureThreshold: 1,
circuitBreakerOpenDuration: 60,
circuitBreakerHalfOpenSuccessThreshold: 1,
proxyUrl: null,
proxyFallbackToDirect: false,
firstByteTimeoutStreamingMs: 0,
streamingIdleTimeoutMs: 0,
requestTimeoutNonStreamingMs: 0,
websiteUrl: null,
faviconUrl: null,
cacheTtlPreference: null,
context1mPreference: null,
codexReasoningEffortPreference: null,
codexReasoningSummaryPreference: null,
codexTextVerbosityPreference: null,
codexParallelToolCallsPreference: null,
anthropicMaxTokensPreference: null,
anthropicThinkingBudgetPreference: null,
geminiGoogleSearchPreference: null,
tpm: null,
rpm: null,
rpd: null,
cc: null,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
...overrides,
};
function makeProvider(overrides: Partial<ProviderDisplay> = {}): ProviderDisplay {
return {
id: 1,
name: "Provider A",
url: "https://api.example.com",
maskedKey: "sk-***",
isEnabled: true,
weight: 1,
priority: 1,
costMultiplier: 1.5,
groupTag: "premium",
groupPriorities: { premium: 3 },
providerType: "claude",
providerVendorId: null,
preserveClientIp: false,
disableSessionReuse: false,
modelRedirects: null,
activeTimeStart: null,
activeTimeEnd: null,
allowedModels: null,
allowedClients: [],
blockedClients: [],
mcpPassthroughType: "none",
mcpPassthroughUrl: null,
limit5hUsd: null,
limitDailyUsd: null,
dailyResetMode: "fixed",
dailyResetTime: "00:00",
limitWeeklyUsd: null,
limitMonthlyUsd: null,
limitTotalUsd: null,
limitConcurrentSessions: 1,
maxRetryAttempts: null,
circuitBreakerFailureThreshold: 1,
circuitBreakerOpenDuration: 60,
circuitBreakerHalfOpenSuccessThreshold: 1,
proxyUrl: null,
proxyFallbackToDirect: false,
firstByteTimeoutStreamingMs: 0,
streamingIdleTimeoutMs: 0,
requestTimeoutNonStreamingMs: 0,
websiteUrl: null,
faviconUrl: null,
cacheTtlPreference: null,
swapCacheTtlBilling: false,
context1mPreference: null,
codexReasoningEffortPreference: null,
codexReasoningSummaryPreference: null,
codexTextVerbosityPreference: null,
codexParallelToolCallsPreference: null,
codexServiceTierPreference: null,
anthropicMaxTokensPreference: null,
anthropicThinkingBudgetPreference: null,
anthropicAdaptiveThinking: null,
geminiGoogleSearchPreference: null,
tpm: null,
rpm: null,
rpd: null,
cc: null,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
...overrides,
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/settings/providers/provider-group-tab.test.tsx` around lines 39 -
93, makeProvider 的返回对象缺少若干 ProviderDisplay 的必填字段,导致类型检查失败;在 makeProvider(...)
返回的字面量中添加缺失字段
disableSessionReuse、activeTimeStart、activeTimeEnd、allowedClients、blockedClients、swapCacheTtlBilling、codexServiceTierPreference、anthropicAdaptiveThinking(使用合理的默认值,例如布尔/字符串/数组/数字/null
等),并保留 ...overrides 以允许覆盖,以确保 ProviderDisplay 类型完整且测试 fixture 正常通过类型检查。

@github-actions github-actions bot added the size/XL Extra Large PR (> 1000 lines) label Apr 18, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request significantly enhances the provider groups management interface. Key changes include the introduction of inline editing for group cost multipliers and descriptions, the addition of multi-select and batch action capabilities for group members, and the integration of a shared provider editor dialog within the manager. The UI has been refined with better spacing, icons, and badges, supported by updated translations across multiple locales and new unit tests. Feedback is provided regarding the extraction of a large sub-component to improve file maintainability.

Comment on lines +818 to +1011
interface InlineTextEditPopoverProps {
value: string;
emptyLabel: string;
label: string;
placeholder: string;
validator: (value: string) => string | null;
onSave: (value: string) => Promise<boolean>;
}

function InlineTextEditPopover({
value,
emptyLabel,
label,
placeholder,
validator,
onSave,
}: InlineTextEditPopoverProps) {
const t = useTranslations("settings.providers.providerGroups");
const isDesktop = useMediaQuery("(min-width: 768px)");
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
const [saving, setSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

const trimmedDraft = draft.trim();
const validationError = useMemo(() => validator(trimmedDraft), [trimmedDraft, validator]);
const canSave = !saving && validationError == null;

useEffect(() => {
if (!open) return;
const raf = requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
return () => cancelAnimationFrame(raf);
}, [open]);

const resetDraft = useCallback(() => {
setDraft(value);
}, [value]);

const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
setDraft(value);
} else {
resetDraft();
setSaving(false);
}
setOpen(nextOpen);
},
[resetDraft, value]
);

const handleCancel = useCallback(() => {
resetDraft();
setOpen(false);
}, [resetDraft]);

const handleSave = useCallback(async () => {
if (!canSave) return;
setSaving(true);
try {
const ok = await onSave(trimmedDraft);
if (ok) {
setOpen(false);
}
} finally {
setSaving(false);
}
}, [canSave, onSave, trimmedDraft]);

const stopPropagation = (event: React.SyntheticEvent) => {
event.stopPropagation();
};

const trigger = (
<button
type="button"
className={cn(
"max-w-full rounded-sm text-left underline-offset-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
value
? "text-muted-foreground hover:underline"
: "text-muted-foreground/80 italic hover:underline"
)}
onPointerDown={stopPropagation}
onClick={(event) => {
event.stopPropagation();
if (!isDesktop) handleOpenChange(true);
}}
>
<span className="line-clamp-2">{value || emptyLabel}</span>
</button>
);

const inputProps = {
ref: inputRef,
value: draft,
placeholder,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value),
disabled: saving,
"aria-label": label,
"aria-invalid": validationError != null,
onPointerDown: stopPropagation,
onClick: stopPropagation,
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
event.stopPropagation();
if (event.key === "Escape") {
event.preventDefault();
handleCancel();
}
if (event.key === "Enter") {
event.preventDefault();
void handleSave();
}
},
};

const content = (
<div className="grid gap-2">
<div className="hidden text-xs font-medium md:block">{label}</div>
<Input {...inputProps} className="w-full md:w-[320px]" />
{validationError ? <div className="text-xs text-destructive">{validationError}</div> : null}
<div className="flex items-center justify-end gap-2 pt-1">
<Button type="button" size="sm" variant="outline" onClick={handleCancel} disabled={saving}>
{t("cancel")}
</Button>
<Button type="button" size="sm" onClick={handleSave} disabled={!canSave}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("save")}
</Button>
</div>
</div>
);

if (!isDesktop) {
return (
<>
{trigger}
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{label}</DrawerTitle>
</DrawerHeader>
<div className="px-4 pb-6">
<div className="grid gap-3">
<Input
{...inputProps}
className="text-base"
onPointerDown={undefined}
onClick={undefined}
/>
{validationError ? (
<div className="text-sm text-destructive">{validationError}</div>
) : null}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
onClick={handleCancel}
disabled={saving}
className="flex-1"
size="lg"
>
{t("cancel")}
</Button>
<Button onClick={handleSave} disabled={!canSave} className="flex-1" size="lg">
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("save")}
</Button>
</div>
</div>
</div>
</DrawerContent>
</Drawer>
</>
);
}

return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
className="w-auto p-3"
onPointerDown={stopPropagation}
onClick={stopPropagation}
>
{content}
</PopoverContent>
</Popover>
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The new InlineTextEditPopover component is quite large and self-contained. To improve maintainability and reusability, consider extracting it into its own file, similar to how InlineEditPopover is handled. This will make provider-group-tab.tsx shorter and more focused on its primary responsibility.

const handleRequestEditProvider = useCallback(
(providerId: number) => {
const provider = providers.find((item) => item.id === providerId);
if (!provider) return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[HIGH] [UX-ISSUE] View mode switches away from groups after editing a provider

Why this is a problem:
When a user clicks to edit a provider from the groups tab, handleOpenProviderEditor forcibly calls setViewMode("list"). This causes the user to lose their context - after closing the editor dialog, they land on the list tab instead of returning to the groups tab they came from.

Since the editor dialog is a modal overlay mounted at ProviderManager level and independent of viewMode, the setViewMode call is not required for the dialog to appear.

Suggested fix:
Remove the forced view mode switch when editing from groups context:

Suggested change
if (!provider) return;
const handleOpenProviderEditor = useCallback((provider: ProviderDisplay) => {
setEditingProviderId(provider.id);
}, []);

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Code Review Summary

This PR aligns the provider group tab with the provider list interaction model by adding inline editing, provider type icons, batch management, and shared provider editor support. The implementation is well-structured with proper i18n coverage (5 languages) and test coverage for new functionality. One high-priority UX issue was identified.

PR Size: XL

  • Lines changed: 1160 (976 additions + 184 deletions)
  • Files changed: 12

Note: Despite the XL label, most of the line count comes from i18n JSON files (5 locales with identical structure) and test files. The core logic changes are concentrated in 2 files (provider-group-tab.tsx and provider-manager.tsx).

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 0 0 0
Security 0 0 0 0
Error Handling 0 0 0 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 0 0 0
UX 0 1 0 0
Simplification 0 0 0 0

High Priority Issues (Should Fix)

  1. View mode switches away from groups after editing (provider-manager.tsx:374)
    • handleOpenProviderEditor forcibly calls setViewMode("list") when opening the provider editor, even when triggered from the groups tab. After closing the dialog, the user lands on the list tab instead of returning to groups. The dialog is a modal overlay independent of viewMode, so the view switch is unnecessary. Confidence: 95

Review Coverage

  • Logic and correctness
  • Security (OWASP Top 10)
  • Error handling
  • Type safety
  • Documentation accuracy
  • Test coverage
  • Code clarity

Automated review by Claude AI

@ding113
Copy link
Copy Markdown
Owner Author

ding113 commented Apr 18, 2026

已根据上一轮 review 更新:

  • 保留从 groups 视图打开供应商编辑弹窗时的当前视图上下文,不再强制切回 list
  • 补齐分组页所有修改入口的 isAdmin / canEdit 门控
  • 在新增/编辑分组弹窗保存路径补上 500 字描述长度校验
  • 新增对应单测覆盖非管理员门控与 groups 视图编辑联动

本地已重新执行:

  • bun run build
  • bun run lint
  • bun run lint:fix
  • bun run typecheck
  • bun run test

@github-actions
Copy link
Copy Markdown
Contributor

🧪 测试结果

测试类型 状态
代码质量
单元测试
集成测试
API 测试

总体结果: ✅ 所有测试通过

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
tests/unit/settings/providers/provider-group-tab.test.tsx (1)

39-93: ⚠️ Potential issue | 🟠 Major

补齐 ProviderDisplay 必填字段,避免测试类型检查失败。

makeProvider() 返回值标注为 ProviderDisplay,但 fixture 仍少了这些字段:disableSessionReuseactiveTimeStartactiveTimeEndallowedClientsblockedClientsswapCacheTtlBillingcodexServiceTierPreferenceanthropicAdaptiveThinking。严格类型检查下这个测试文件会直接失败。

建议补齐 fixture 字段
     providerVendorId: null,
     preserveClientIp: false,
+    disableSessionReuse: false,
     modelRedirects: null,
+    activeTimeStart: null,
+    activeTimeEnd: null,
     allowedModels: null,
+    allowedClients: [],
+    blockedClients: [],
     mcpPassthroughType: "none",
     mcpPassthroughUrl: null,
@@
     websiteUrl: null,
     faviconUrl: null,
     cacheTtlPreference: null,
+    swapCacheTtlBilling: false,
     context1mPreference: null,
     codexReasoningEffortPreference: null,
     codexReasoningSummaryPreference: null,
     codexTextVerbosityPreference: null,
     codexParallelToolCallsPreference: null,
+    codexServiceTierPreference: null,
     anthropicMaxTokensPreference: null,
     anthropicThinkingBudgetPreference: null,
+    anthropicAdaptiveThinking: null,
     geminiGoogleSearchPreference: null,

可用只读命令核对当前接口与 fixture:

#!/bin/bash
rg -n -A120 'export interface ProviderDisplay' --iglob 'provider.ts'
rg -n -A80 'function makeProvider' tests/unit/settings/providers/provider-group-tab.test.tsx
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/settings/providers/provider-group-tab.test.tsx` around lines 39 -
93, The fixture function makeProvider returns a value typed as ProviderDisplay
but is missing required fields (disableSessionReuse, activeTimeStart,
activeTimeEnd, allowedClients, blockedClients, swapCacheTtlBilling,
codexServiceTierPreference, anthropicAdaptiveThinking), causing TS failures;
update the object returned by makeProvider to include these missing properties
with appropriate defaults (e.g., false / null / empty arrays / sensible numeric
or string defaults) so it satisfies the ProviderDisplay interface and keep the
function signature and overrides spread intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@tests/unit/settings/providers/provider-group-tab.test.tsx`:
- Around line 39-93: The fixture function makeProvider returns a value typed as
ProviderDisplay but is missing required fields (disableSessionReuse,
activeTimeStart, activeTimeEnd, allowedClients, blockedClients,
swapCacheTtlBilling, codexServiceTierPreference, anthropicAdaptiveThinking),
causing TS failures; update the object returned by makeProvider to include these
missing properties with appropriate defaults (e.g., false / null / empty arrays
/ sensible numeric or string defaults) so it satisfies the ProviderDisplay
interface and keep the function signature and overrides spread intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5bbabf94-246a-45e8-b34d-040fca672c03

📥 Commits

Reviewing files that changed from the base of the PR and between f39a056 and 01335fd.

📒 Files selected for processing (4)
  • src/app/[locale]/settings/providers/_components/provider-group-tab.tsx
  • src/app/[locale]/settings/providers/_components/provider-manager.tsx
  • tests/unit/settings/providers/provider-group-tab.test.tsx
  • tests/unit/settings/providers/provider-manager.test.tsx
✅ Files skipped from review due to trivial changes (1)
  • tests/unit/settings/providers/provider-manager.test.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/app/[locale]/settings/providers/_components/provider-manager.tsx
  • src/app/[locale]/settings/providers/_components/provider-group-tab.tsx

</Badge>
</TableCell>
<TableCell>
{canEdit ? (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Medium] [LOGIC-BUG] Provider edit entry ignores canEdit

Why this is a problem: In src/app/[locale]/settings/providers/_components/provider-group-tab.tsx the provider name button and edit icon both call onRequestEditProvider(member.id) even when canEdit is false (e.g. onClick={() => onRequestEditProvider(member.id)} at the name button and the edit icon). Priority editing is correctly gated behind canEdit, so this creates an inconsistent read-only vs edit surface and can lead to unauthorized edit attempts / confusing UX.

Suggested fix:

// Only allow opening the editor when edits are allowed
{canEdit ? (
  <button
    type="button"
    className="truncate text-left font-medium underline-offset-4 hover:underline"
    onClick={() => onRequestEditProvider(member.id)}
  >
    {member.name}
  </button>
) : (
  <span className="truncate font-medium">{member.name}</span>
)}

{canEdit ? (
  <Button
    size="sm"
    variant="ghost"
    className="h-8 px-2"
    onClick={() => onRequestEditProvider(member.id)}
    title={t("openProviderEditor")}
  >
    <Edit className="h-4 w-4" />
  </Button>
) : null}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

已处理。这里现在和上面的优先级编辑一致,只有 canEdit 时才渲染供应商编辑入口;非管理员场景新增了单测覆盖。

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Code Review Summary

This PR makes large UI changes to provider group management and routes provider editing through a shared editor dialog. One permission-gating inconsistency was found in the new group members row edit entry.

PR Size: XL

  • Lines changed: 1160
  • Files changed: 12
  • Split suggestion: Separate into (1) ProviderGroupTab UI refactor + i18n updates, (2) shared provider editor wiring (ProviderManager/ProviderList), (3) test updates.

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 0 1 0
Security 0 0 0 0
Error Handling 0 0 0 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 0 0 0
Simplification 0 0 0 0

Critical Issues (Must Fix)

  • None.

High Priority Issues (Should Fix)

  • (Confidence 90) [Medium] [LOGIC-BUG] Provider edit entry ignores canEdit (src/app/[locale]/settings/providers/_components/provider-group-tab.tsx:773, src/app/[locale]/settings/providers/_components/provider-group-tab.tsx:804).

Review Coverage

  • Logic and correctness
  • Security (OWASP Top 10)
  • Error handling
  • Type safety
  • Documentation accuracy
  • Test coverage
  • Code clarity

Automated review by Codex AI

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

  • PR #1033 labeled size/XL.
  • Posted 1 inline review comment: src/app/[locale]/settings/providers/_components/provider-group-tab.tsx:773 / :804 — provider edit entry calls onRequestEditProvider(...) even when canEdit is false; suggested gating the name click + edit icon behind canEdit.
  • Submitted the summary review on PR #1033.

@ding113 ding113 merged commit eaf1ac5 into dev Apr 19, 2026
17 checks passed
@github-project-automation github-project-automation bot moved this from Backlog to Done in Claude Code Hub Roadmap Apr 19, 2026
@github-actions github-actions bot mentioned this pull request Apr 19, 2026
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:i18n area:provider area:UI enhancement New feature or request size/XL Extra Large PR (> 1000 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant