Skip to content

feat(platform): provider detail drawer + api-key suffix masking#1729

Merged
Israeltheminer merged 5 commits into
mainfrom
feat/platform-provider-detail-drawer
May 22, 2026
Merged

feat(platform): provider detail drawer + api-key suffix masking#1729
Israeltheminer merged 5 commits into
mainfrom
feat/platform-provider-detail-drawer

Conversation

@Israeltheminer
Copy link
Copy Markdown
Collaborator

@Israeltheminer Israeltheminer commented May 21, 2026

Summary

  • Extract the 1.4k-line provider settings route into a reusable provider-detail-drawer component; the route now just mounts it.
  • Provider model picker UX: search, Show more pagination, Select all, add-model confirmation, and a Showing N of M models counter.
  • Provider API keys now mask as start … suffix (6 + 4 chars) so admins can disambiguate keys with the same vendor prefix and cross-check against the vendor dashboard. Adds a new fetchConfiguredProviderModels Convex action that uses the stored secret.
  • Better Auth API keys: capture the trailing 4 plaintext chars at creation time via an after-hook on /api-key/create, persist them in a new nullable suffix column on the apikey table, and render keys in the dashboard as start … suffix. Pre-existing rows fall back to prefix-only.
  • Drop examples/providers/openai.json and shrink the bundled OpenRouter example.
  • de / en / fr translations.

Test plan

  • Open Settings → Providers → row click opens the drawer; details, defaults, capabilities, and models all render.
  • Edit general / defaults / models / API key from the drawer; saves persist after reload.
  • Fetch models for a configured provider returns the vendor list using the stored secret.
  • Model picker: search filters, Show more paginates, Select all honors the filter, Add model prompts for confirmation.
  • Settings → API keys: newly created keys render as start … suffix; legacy keys still render with prefix only.
  • bun run check is green locally.

Summary by CodeRabbit

Release Notes

  • New Features

    • Provider settings can now be viewed and edited in an inline drawer.
    • Models can be fetched from providers and added/removed with confirmation dialogs.
    • API key display now shows a truncated format (first and last characters) for easier recognition.
  • Improvements

    • Model management UI enhanced with search, pagination, and bulk selection capabilities.
    • Updated translations for provider and model management workflows.

Review Change Stack

- Extract the 1.4k-line provider settings page into a reusable
  `provider-detail-drawer` component; the route now mounts the drawer.
- Add model picker UX: search, "show more" pagination, select-all,
  add-model confirmation, and a Showing-N-of-M counter.
- Mask provider API keys as `start … suffix` (6 chars + 4 chars) so
  users can match against the vendor dashboard. Adds a new
  `fetchConfiguredProviderModels` action that uses the stored secret.
- Capture the trailing 4 chars of Better Auth API keys at creation
  time via an after-hook on `/api-key/create`, store them in a new
  nullable `suffix` column on the `apikey` table, and render keys as
  `start … suffix` in the API Keys table. Pre-existing rows fall back
  to prefix-only.
- Drop the obsolete `examples/providers/openai.json` and shrink the
  bundled OpenRouter example.
- Translations for de / en / fr.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

📝 Walkthrough

Walkthrough

This PR consolidates provider management functionality by introducing an API key suffix tracking feature, creating a new provider detail drawer component, refactoring the provider model selection UI with unified search and pagination, and simplifying navigation via deep-linking. The backend gains a new action to fetch provider model catalogs, and example configurations are updated. Translations are added across multiple languages for the enhanced model management UI.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • tale-project/tale#1728: Updates to AI providers "Models Card" search, pagination, and checkbox toggle behavior align with the unified model list refactoring in this PR.
  • tale-project/tale#1693: Model search and provider settings/model management flows overlap with the ModelsSection and provider-model UX refactoring.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main changes: addition of a provider detail drawer component and API key suffix masking for better key disambiguation.
Description check ✅ Passed The description includes a clear summary with key changes, links objectives, and provides a comprehensive test plan with checkboxes covering drawer opening, model picker features, API key masking, and bun run check verification.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/platform-provider-detail-drawer

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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

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: 10

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

Inline comments:
In `@examples/providers/openrouter.json`:
- Around line 17-19: Update the tags for the provider entry with id
"anthropic/claude-sonnet-4.6": change the tags array from ["chat"] to include
"vision" (e.g., ["chat","vision"]) so the model is correctly marked as
multimodal/vision-capable; locate the provider object with id
"anthropic/claude-sonnet-4.6" and update its "tags" property accordingly.

In
`@services/platform/app/features/settings/api-keys/hooks/use-api-keys-table-config.tsx`:
- Around line 42-47: The rendering currently sets head = row.original.start ||
row.original.prefix and hides the suffix when both start and prefix are missing;
change the fallback so that if head is falsy but row.original.suffix exists we
still render the suffix (e.g., show just the tail or "… {tail}") instead of '-'
— update the component in use-api-keys-table-config.tsx where head and tail are
computed and the JSX return so the conditional becomes: if head show (head +
optional tail), else if tail show tail (or a prefixed ellipsis + tail), else
show '-'. This ensures row.original.suffix is used when start/prefix are absent.

In
`@services/platform/app/features/settings/providers/components/provider-add-panel.tsx`:
- Around line 227-231: The fetch loop re-triggers after an error because
hasFetched remains false; change the catch block in the provider model fetch
logic to setHasFetched(true) (and keep setFetchError and
setFetchedCredentials(null)) so a failed fetch stops the debounce auto-retry but
still allows manual retry via the existing button; ensure the effect that clears
the gate on credential edits still runs when credential fields change (the same
effect that calls setHasFetched(false) on input changes) so user edits re-enable
fetching.

In
`@services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx`:
- Around line 873-889: The current flow calls saveConfig(...) before
saveSecret.mutateAsync(...), which can leave config persisted if the secret
write fails; change the logic so both writes are atomic: either call
saveSecret.mutateAsync(...) first and only call saveConfig({ models:
updatedModels }) if the secret write succeeds, or perform both operations via a
single backend endpoint that applies both changes transactionally; if you cannot
change the backend, implement a compensating rollback by catching errors from
saveSecret.mutateAsync and reverting the saved config (using saveConfig with the
previous models snapshot) inside the catch block; update relevant symbols:
saveConfig, saveSecret.mutateAsync, setSavingSecret, form, modelKeyAction,
orgSlug, providerName, and updatedModels.
- Around line 1374-1418: The new cost input labels/placeholders are hardcoded;
update provider-detail-drawer.tsx to use the translation hook (useT) for all
user-facing strings in the Input components for inputCostPerMillion,
outputCostPerMillion, and imageCostPerImage (labels and placeholders), and any
conditional text for the image-generation branch; replace the literal strings
("Input cost (USD / 1M tokens)", "Output cost (USD / 1M tokens)", "Cost per
image (USD)", "e.g., 2.50", "e.g., 10.00", "e.g., 0.06") with t('your.key')
calls from useT(namespace) so the HStack/Input controls render translated text
across locales while keeping existing props (value, onChange, min, step) intact.
- Around line 288-323: SectionHeader's props currently omit children so passed
nested content from ApiKeySection isn't rendered; add children?: React.ReactNode
to the SectionHeader props signature and include {children} in the returned JSX
(for example next to or instead of the existing edit button as appropriate) so
nested header actions passed into <SectionHeader> are displayed; update the
function parameter destructuring to accept children and ensure the component
still types onEdit, title, description, and editLabel as before.
- Around line 315-322: The action buttons in ProviderDetailDrawer use
conflicting focus-visible classes that suppress the outline; remove the
`focus-visible:outline-none` token from the className strings for the
edit/test/undo/remove buttons so the `focus-visible:outline-ring`,
`focus-visible:outline-2` and `focus-visible:outline-offset-2` rules can take
effect — locate the buttons tied to handlers like onEdit, onTest (or test
action), onUndo and onRemove in the ProviderDetailDrawer component and delete
`focus-visible:outline-none` from their className values.

In `@services/platform/convex/providers/file_actions.ts`:
- Around line 1163-1168: Wrap the decrypt/parse sequence that uses
resolveProviderSecretsPath, decryptSecretsFile and parseProviderSecrets in a
try/catch and normalize any thrown errors into a provider-level error with the
PROVIDER_FETCH_FAILED code; specifically, catch errors from
decryptSecretsFile(...) and parseProviderSecrets(...), then throw a new error
(or return the expected error object) that sets/uses PROVIDER_FETCH_FAILED as
the error code/message and attach the original error as a cause or inner detail
for diagnostics so low-level decrypt/parse failures do not leak raw errors to
callers.

In `@services/platform/messages/de.json`:
- Line 1158: Update the translation value for the key "apiKeyUpdated" to use the
informal German pronoun and sentence case; replace the current string "Ihr
API-Schlüssel wurde erfolgreich gespeichert." with "Dein API-Schlüssel wurde
erfolgreich gespeichert." so it matches the project's informal register (du)
used elsewhere.

In `@services/platform/messages/fr.json`:
- Line 1158: Update the French translation value for the key "apiKeyUpdated" to
use informal register and sentence case: replace the formal "Votre" with the
informal "Ta" so the message reads "Ta clé API a été enregistrée avec succès."
Ensure you only modify the string value for the "apiKeyUpdated" key.
🪄 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: ASSERTIVE

Plan: Pro

Run ID: b850dfd2-c2e9-45af-8a37-10daf3efb0d2

📥 Commits

Reviewing files that changed from the base of the PR and between d217a38 and d1012ce.

⛔ Files ignored due to path filters (1)
  • services/platform/convex/betterAuth/_generated/component.ts is excluded by !**/_generated/**
📒 Files selected for processing (18)
  • examples/providers/openai.json
  • examples/providers/openrouter.json
  • services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.test.tsx
  • services/platform/app/features/settings/api-keys/components/api-key-row-actions.test.tsx
  • services/platform/app/features/settings/api-keys/components/api-keys-table.test.tsx
  • services/platform/app/features/settings/api-keys/hooks/use-api-keys-table-config.tsx
  • services/platform/app/features/settings/api-keys/types.ts
  • services/platform/app/features/settings/providers/components/provider-add-panel.tsx
  • services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx
  • services/platform/app/features/settings/providers/components/providers-table.tsx
  • services/platform/app/features/settings/providers/hooks/mutations.ts
  • services/platform/app/routes/dashboard/$id/settings/providers/$providerName.tsx
  • services/platform/convex/auth.ts
  • services/platform/convex/betterAuth/schema.ts
  • services/platform/convex/providers/file_actions.ts
  • services/platform/messages/de.json
  • services/platform/messages/en.json
  • services/platform/messages/fr.json
💤 Files with no reviewable changes (1)
  • examples/providers/openai.json

Comment thread examples/providers/openrouter.json Outdated
Comment on lines +17 to +19
"id": "anthropic/claude-sonnet-4.6",
"displayName": "anthropic/claude-sonnet-4.6",
"tags": ["chat"]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant section around the referenced lines
echo "=== File: examples/providers/openrouter.json (lines 1-60) ==="
nl -ba examples/providers/openrouter.json | sed -n '1,80p'

echo "=== Occurrences of claude-sonnet-4.6 ==="
rg -n "anthropic/claude-sonnet-4\.6" -S examples/providers/openrouter.json

Repository: tale-project/tale

Length of output: 184


🌐 Web query:

OpenRouter "claude-sonnet-4.6" vision-models collection

💡 Result:

OpenRouter “claude-sonnet-4.6” corresponds to the model id: anthropic/claude-sonnet-4.6, and it is surfaced under OpenRouter’s Anthropic Claude Sonnet 4.6 model page (a model “collection” in the sense of the OpenRouter model listing, not a separate API endpoint/collection identifier). [1][2] How to call it (model id) 1) Use model: "anthropic/claude-sonnet-4.6". [1] 2) Example (OpenRouter Node SDK) from OpenRouter’s page for this model: - openrouter.chat.send({ model: "anthropic/claude-sonnet-4.6", messages: [...] }) [1] Vision support OpenRouter’s model page for anthropic/claude-sonnet-4.6 describes it as a multimodal model (“Image” is listed alongside “Text” as supported input/output modalities). [2] Model behavior notes that affect vision requests For Claude 4.6 Opus/Sonnet, OpenRouter uses adaptive thinking by default; the migration guide shows how to set thinking via reasoning in requests. This can matter if you are testing/benchmarking outputs, including vision-grounded tasks. [3]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

path = Path("examples/providers/openrouter.json")
text = path.read_text(encoding="utf-8").splitlines()

def show(start, end):
    for i in range(start, min(end, len(text))):
        print(f"{i+1:4d}: {text[i]}")

print("=== File head (1-60) ===")
show(1, 60)

print("\n=== Find claude-sonnet-4.6 and surrounding lines ===")
for idx, line in enumerate(text):
    if "anthropic/claude-sonnet-4.6" in line:
        s = max(0, idx-5); e = min(len(text), idx+10)
        show(s+1, e)
PY

echo "=== rg occurrences ==="
rg -n "anthropic/claude-sonnet-4\.6" examples/providers/openrouter.json

Repository: tale-project/tale

Length of output: 1902


🌐 Web query:

site:openrouter.ai "anthropic/claude-sonnet-4.6" vision multimodal image text

💡 Result:

Anthropic Claude Sonnet 4.6 (model id: anthropic/claude-sonnet-4.6) is a multimodal “vision-capable” model via OpenRouter, meaning you can include image inputs (and text) in the same request for image understanding (description/OCR-style analysis). OpenRouter describes multimodal image input support using the chat completions API with image content blocks (image_url) and notes that models are automatically filtered based on modality support; image requests go to vision-capable models [1]. How to send an image + text to a compatible model (OpenRouter unified API style) 1) Use /api/v1/chat/completions. 2) Put the text prompt and then image(s) in the messages content array using image_url content type (URL or base64 data URIs are supported) [1][2]. 3) OpenRouter requires image-capable/vision-compatible models for image processing; it filters models based on modalities present in your request [1]. Relevant API schema details for Anthropic message creation (OpenRouter’s Anthropic-compatible wrapper) OpenRouter’s Anthropic message creation docs include image MIME types (image/jpeg, image/png, image/gif, image/webp) and image block parameters that support base64 or URL sources [3]. Caveat I did not find an official Anthropic platform document in the retrieved sources explicitly stating “Claude Sonnet 4.6 supports vision”; however, OpenRouter’s multimodal docs clearly state how to send images to vision-capable models and how model compatibility is determined by modality support [1]. To confirm 100% for your specific endpoint/provider setup, check that your model listing includes vision/image input support (OpenRouter filters automatically) [1].

Citations:


Add the vision tag to anthropic/claude-sonnet-4.6 (OpenRouter)

OpenRouter treats Claude Sonnet 4.6 as multimodal/vision-capable, so labeling it as ["chat"] misrepresents capabilities.

Suggested fix
-      "tags": ["chat"]
+      "tags": ["chat", "vision"]
📝 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
"id": "anthropic/claude-sonnet-4.6",
"displayName": "anthropic/claude-sonnet-4.6",
"tags": ["chat"]
"id": "anthropic/claude-sonnet-4.6",
"displayName": "anthropic/claude-sonnet-4.6",
"tags": ["chat", "vision"]
🤖 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 `@examples/providers/openrouter.json` around lines 17 - 19, Update the tags for
the provider entry with id "anthropic/claude-sonnet-4.6": change the tags array
from ["chat"] to include "vision" (e.g., ["chat","vision"]) so the model is
correctly marked as multimodal/vision-capable; locate the provider object with
id "anthropic/claude-sonnet-4.6" and update its "tags" property accordingly.

Comment on lines +42 to +47
const head = row.original.start || row.original.prefix;
const tail = row.original.suffix;
return (
<Text as="span" variant="muted" className="font-mono text-sm">
{head ? (tail ? `${head} … ${tail}` : head) : '-'}
</Text>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle suffix-only fallback in key rendering.

If start and prefix are missing but suffix exists, the current branch shows - and hides usable key identity data.

Proposed fix
-              {head ? (tail ? `${head} … ${tail}` : head) : '-'}
+              {head
+                ? tail
+                  ? `${head} … ${tail}`
+                  : head
+                : tail
+                  ? `… ${tail}`
+                  : '-'}
🤖 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
`@services/platform/app/features/settings/api-keys/hooks/use-api-keys-table-config.tsx`
around lines 42 - 47, The rendering currently sets head = row.original.start ||
row.original.prefix and hides the suffix when both start and prefix are missing;
change the fallback so that if head is falsy but row.original.suffix exists we
still render the suffix (e.g., show just the tail or "… {tail}") instead of '-'
— update the component in use-api-keys-table-config.tsx where head and tail are
computed and the JSX return so the conditional becomes: if head show (head +
optional tail), else if tail show tail (or a prefixed ellipsis + tail), else
show '-'. This ensures row.original.suffix is used when start/prefix are absent.

Comment on lines +227 to +231
} catch (error) {
console.error('Failed to fetch models:', error);
setFetchError(t('providers.fetchModelsError'));
setHasFetched(false);
setFetchedCredentials(null);
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

Stop auto-fetching after the first failed attempt.

On any fetch error, hasFetched stays false, so the debounce effect schedules another request 500ms later. Invalid credentials or a transient outage will loop until the user edits a field or closes the panel, repeatedly hitting the provider endpoint and spamming logs.

One localized fix
     } catch (error) {
       console.error('Failed to fetch models:', error);
       setFetchError(t('providers.fetchModelsError'));
-      setHasFetched(false);
-      setFetchedCredentials(null);
+      setHasFetched(true);
+      setFetchedCredentials({ baseUrl, apiKey });
     }

This preserves manual retry via the button, while the credential-change effect still clears the gate when either field changes.

Also applies to: 532-538

🤖 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
`@services/platform/app/features/settings/providers/components/provider-add-panel.tsx`
around lines 227 - 231, The fetch loop re-triggers after an error because
hasFetched remains false; change the catch block in the provider model fetch
logic to setHasFetched(true) (and keep setFetchError and
setFetchedCredentials(null)) so a failed fetch stops the debounce auto-retry but
still allows manual retry via the existing button; ensure the effect that clears
the gate on credential edits still runs when credential fields change (the same
effect that calls setHasFetched(false) on input changes) so user edits re-enable
fetching.

Comment on lines +288 to +323
function SectionHeader({
title,
description,
onEdit,
editLabel,
}: {
title: string;
description?: string;
onEdit: () => void;
editLabel: string;
}) {
return (
<HStack
justify="between"
align={description ? 'start' : 'center'}
className="border-b px-5 py-4"
>
<Stack gap={1}>
<Text className="text-[15px] font-semibold tracking-[-0.01em]">
{title}
</Text>
{description && (
<Text className="text-muted-foreground text-[13px]">
{description}
</Text>
)}
</Stack>
<button
type="button"
onClick={onEdit}
className="text-muted-foreground hover:text-foreground focus-visible:outline-ring flex shrink-0 items-center gap-1.5 rounded-sm text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-none"
>
<Pencil className="size-3.5" />
{editLabel}
</button>
</HStack>
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 | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the file
FILE="services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx"
test -f "$FILE" && echo "Found: $FILE" || { echo "Missing: $FILE"; exit 1; }

# Print the SectionHeader definition region (around the cited lines)
nl -ba "$FILE" | sed -n '250,380p'

# Find and print where SectionHeader is used (including around the cited Line 544)
nl -ba "$FILE" | sed -n '500,650p'

# Quick structural search: all usages of SectionHeader in the file
rg -n "SectionHeader\s*<|<SectionHeader|function SectionHeader|const SectionHeader" "$FILE"

Repository: tale-project/tale

Length of output: 217


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx"
test -f "$FILE"

echo "=== SectionHeader definition (approx 250-380) ==="
cat -n "$FILE" | sed -n '250,380p'

echo "=== Usages around reported Line 544 (approx 500-650) ==="
cat -n "$FILE" | sed -n '500,650p'

echo "=== All SectionHeader usages (full lines) ==="
rg -n "SectionHeader" "$FILE"

Repository: tale-project/tale

Length of output: 10001


Add a children slot to SectionHeader.

SectionHeader’s props type omits children, but ApiKeySection passes a nested header action (<button>…</button>) inside <SectionHeader> (around lines 544-557). Add children?: React.ReactNode to SectionHeader’s props and render {children} in the header so the action actually appears.

🤖 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
`@services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx`
around lines 288 - 323, SectionHeader's props currently omit children so passed
nested content from ApiKeySection isn't rendered; add children?: React.ReactNode
to the SectionHeader props signature and include {children} in the returned JSX
(for example next to or instead of the existing edit button as appropriate) so
nested header actions passed into <SectionHeader> are displayed; update the
function parameter destructuring to accept children and ensure the component
still types onEdit, title, description, and editLabel as before.

Comment on lines +315 to +322
<button
type="button"
onClick={onEdit}
className="text-muted-foreground hover:text-foreground focus-visible:outline-ring flex shrink-0 items-center gap-1.5 rounded-sm text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-none"
>
<Pencil className="size-3.5" />
{editLabel}
</button>
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx"

# Sanity check: file exists
test -f "$FILE" || { echo "Missing $FILE"; exit 1; }

# Show the exact regions referenced by the review (315-322, 549-556, 1443-1468) with line numbers
sed -n '300,340p' "$FILE" | nl -ba | sed -n '1,120p'
echo "-----"
sed -n '520,585p' "$FILE" | nl -ba | sed -n '1,140p'
echo "-----"
sed -n '1410,1495p' "$FILE" | nl -ba | sed -n '1,140p'

# Find all focus-visible:outline-none occurrences in the file
echo "-----"
rg -n "focus-visible:outline-none" "$FILE" || true

# Find any "outline-none" usage in the file (broader than focus-visible)
echo "-----"
rg -n "outline-none" "$FILE" || true

Repository: tale-project/tale

Length of output: 105


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx"
test -f "$FILE" || { echo "Missing $FILE"; exit 1; }

echo "=== Region 315-322 ==="
awk 'NR>=315 && NR<=322 {printf "%d\t%s\n", NR, $0}' "$FILE"
echo "=== Region 549-556 ==="
awk 'NR>=549 && NR<=556 {printf "%d\t%s\n", NR, $0}' "$FILE"
echo "=== Region 1443-1468 (trimmed to first 80 lines) ==="
awk 'NR>=1443 && NR<=1525 {printf "%d\t%s\n", NR, $0}' "$FILE"

echo "=== Occurrences: focus-visible:outline-none ==="
rg -n "focus-visible:outline-none" "$FILE" || true

echo "=== Occurrences: outline-none (broader) ==="
rg -n "outline-none" "$FILE" || true

Repository: tale-project/tale

Length of output: 7151


🌐 Web query:

Tailwind CSS v4 focus-visible:outline-none outline-hidden outline-none only removes outline style

💡 Result:

In Tailwind CSS v4, the outline-none and outline-hidden utilities serve distinct purposes, and the change from v3 is intentional to ensure better accessibility and semantic clarity [1][2]. 1. outline-none: This utility now performs exactly what its name implies: it sets outline-style: none [1][3]. It is intended for use cases where you are providing your own custom focus styles (e.g., using rings or shadows) and want to completely suppress the browser's default focus outline [4][2]. 2. outline-hidden: This utility is the recommended replacement for the behavior of outline-none found in Tailwind CSS v3 [1][2]. While it suppresses the visual outline in standard rendering, it maintains a visible outline when the user's system is in forced-colors mode (e.g., Windows High Contrast Mode) [4][5]. This is critical for accessibility, as it ensures that focus remains visible to users who rely on high-contrast settings [1][5]. Summary of differences: - Standard Rendering: Both outline-none and outline-hidden set outline-style: none [6][5]. - Forced-Colors Mode: outline-hidden preserves a visible outline for accessibility; outline-none does not [1][5]. If you are upgrading from v3, you should generally replace your existing outline-none classes with outline-hidden to maintain the accessibility behavior you previously had [5][2]. You should only use outline-none if you specifically intend to remove the outline entirely in all scenarios, including high-contrast modes [4][2].

Citations:


Fix keyboard focus styling: remove focus-visible:outline-none from action buttons

  • In services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx, the edit/test/undo/remove action buttons combine focus-visible:outline-ring + focus-visible:outline-2 + focus-visible:outline-offset-2 with focus-visible:outline-none. In Tailwind v4, outline-none sets outline-style: none on focus-visible, which suppresses the intended focus outline for keyboard users.
  • Remove focus-visible:outline-none from the buttons at the referenced blocks (~315-322, ~549-556, ~1443-1468).
Proposed fix
- className="text-muted-foreground hover:text-foreground focus-visible:outline-ring ... focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-none"
+ className="text-muted-foreground hover:text-foreground focus-visible:outline-ring ... focus-visible:outline-2 focus-visible:outline-offset-2"
📝 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
<button
type="button"
onClick={onEdit}
className="text-muted-foreground hover:text-foreground focus-visible:outline-ring flex shrink-0 items-center gap-1.5 rounded-sm text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-none"
>
<Pencil className="size-3.5" />
{editLabel}
</button>
<button
type="button"
onClick={onEdit}
className="text-muted-foreground hover:text-foreground focus-visible:outline-ring flex shrink-0 items-center gap-1.5 rounded-sm text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2"
>
<Pencil className="size-3.5" />
{editLabel}
</button>
🤖 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
`@services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx`
around lines 315 - 322, The action buttons in ProviderDetailDrawer use
conflicting focus-visible classes that suppress the outline; remove the
`focus-visible:outline-none` token from the className strings for the
edit/test/undo/remove buttons so the `focus-visible:outline-ring`,
`focus-visible:outline-2` and `focus-visible:outline-offset-2` rules can take
effect — locate the buttons tied to handlers like onEdit, onTest (or test
action), onUndo and onRemove in the ProviderDetailDrawer component and delete
`focus-visible:outline-none` from their className values.

Comment on lines +873 to +889
try {
await saveConfig({ models: updatedModels });
if ((form.apiKey.trim() || modelKeyAction === 'remove') && orgSlug) {
setSavingSecret(true);
try {
await saveSecret.mutateAsync({
orgSlug,
providerName,
modelKeys: {
[form.id]:
modelKeyAction === 'remove' ? '' : form.apiKey.trim(),
},
});
} finally {
setSavingSecret(false);
}
}
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 | 🏗️ Heavy lift

Avoid persisting the model config before the secret write succeeds.

If saveConfig() succeeds and saveSecret.mutateAsync() fails, the model update is already committed but the API key change is lost. That leaves a half-applied save while the dialog stays open as if nothing landed. Please move both writes behind a single backend operation or add compensating rollback.

🤖 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
`@services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx`
around lines 873 - 889, The current flow calls saveConfig(...) before
saveSecret.mutateAsync(...), which can leave config persisted if the secret
write fails; change the logic so both writes are atomic: either call
saveSecret.mutateAsync(...) first and only call saveConfig({ models:
updatedModels }) if the secret write succeeds, or perform both operations via a
single backend endpoint that applies both changes transactionally; if you cannot
change the backend, implement a compensating rollback by catching errors from
saveSecret.mutateAsync and reverting the saved config (using saveConfig with the
previous models snapshot) inside the catch block; update relevant symbols:
saveConfig, saveSecret.mutateAsync, setSavingSecret, form, modelKeyAction,
orgSlug, providerName, and updatedModels.

Comment on lines +1374 to +1418
<HStack gap={3}>
<Input
label="Input cost (USD / 1M tokens)"
type="number"
value={form.inputCostPerMillion}
onChange={(e) =>
setForm((f) => ({
...f,
inputCostPerMillion: e.target.value,
}))
}
placeholder="e.g., 2.50"
min={0}
step={0.01}
/>
<Input
label="Output cost (USD / 1M tokens)"
type="number"
value={form.outputCostPerMillion}
onChange={(e) =>
setForm((f) => ({
...f,
outputCostPerMillion: e.target.value,
}))
}
placeholder="e.g., 10.00"
min={0}
step={0.01}
/>
</HStack>
{form.tags.includes('image-generation') && (
<Input
label="Cost per image (USD)"
type="number"
value={form.imageCostPerImage}
onChange={(e) =>
setForm((f) => ({
...f,
imageCostPerImage: e.target.value,
}))
}
placeholder="e.g., 0.06"
min={0}
step={0.01}
/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Translate the new cost field copy.

These labels and placeholders bypass useT, so the new drawer will show mixed-language controls in non-English locales.

As per coding guidelines, “Every user-facing string goes through the translation layer via useT(namespace) hook … No hardcoded user-facing strings; always use the translation hook.”

🤖 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
`@services/platform/app/features/settings/providers/components/provider-detail-drawer.tsx`
around lines 1374 - 1418, The new cost input labels/placeholders are hardcoded;
update provider-detail-drawer.tsx to use the translation hook (useT) for all
user-facing strings in the Input components for inputCostPerMillion,
outputCostPerMillion, and imageCostPerImage (labels and placeholders), and any
conditional text for the image-generation branch; replace the literal strings
("Input cost (USD / 1M tokens)", "Output cost (USD / 1M tokens)", "Cost per
image (USD)", "e.g., 2.50", "e.g., 10.00", "e.g., 0.06") with t('your.key')
calls from useT(namespace) so the HStack/Input controls render translated text
across locales while keeping existing props (value, onChange, min, step) intact.

Comment on lines +1163 to +1168
const secretsPath = resolveProviderSecretsPath(
args.orgSlug,
args.providerName,
);
const secrets = parseProviderSecrets(await decryptSecretsFile(secretsPath));
const apiKey = secrets.apiKey;
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

Normalize configured-secret read failures to PROVIDER_FETCH_FAILED.

Line 1167 can throw raw decrypt/parse errors, which breaks stable client error handling and may surface low-level filesystem/decryption messages instead of action-level error codes.

Proposed fix
-    const secrets = parseProviderSecrets(await decryptSecretsFile(secretsPath));
-    const apiKey = secrets.apiKey;
+    let apiKey: string | undefined;
+    try {
+      const secrets = parseProviderSecrets(await decryptSecretsFile(secretsPath));
+      apiKey = secrets.apiKey;
+    } catch (err) {
+      throw new ConvexError({
+        code: 'PROVIDER_FETCH_FAILED',
+        message: `Cannot read provider credentials for "${args.providerName}"`,
+      });
+    }
🤖 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 `@services/platform/convex/providers/file_actions.ts` around lines 1163 - 1168,
Wrap the decrypt/parse sequence that uses resolveProviderSecretsPath,
decryptSecretsFile and parseProviderSecrets in a try/catch and normalize any
thrown errors into a provider-level error with the PROVIDER_FETCH_FAILED code;
specifically, catch errors from decryptSecretsFile(...) and
parseProviderSecrets(...), then throw a new error (or return the expected error
object) that sets/uses PROVIDER_FETCH_FAILED as the error code/message and
attach the original error as a cause or inner detail for diagnostics so
low-level decrypt/parse failures do not leak raw errors to callers.

Comment thread services/platform/messages/de.json Outdated
"tagTranscription": "Transkription",
"details": "Anbieterdetails",
"capabilities": "Fähigkeiten",
"apiKeyUpdated": "Ihr API-Schlüssel wurde erfolgreich gespeichert."
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use informal German pronoun in success toast copy.

"Ihr API-Schlüssel wurde erfolgreich gespeichert." uses formal register. This locale is using informal address elsewhere; switch to a dein form for consistency (for example: "Dein API-Schlüssel wurde erfolgreich gespeichert.").

As per coding guidelines: "Use sentence case in every translation; use informal form across all languages (du in German, tu in French, never Sie or vous)".

🤖 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 `@services/platform/messages/de.json` at line 1158, Update the translation
value for the key "apiKeyUpdated" to use the informal German pronoun and
sentence case; replace the current string "Ihr API-Schlüssel wurde erfolgreich
gespeichert." with "Dein API-Schlüssel wurde erfolgreich gespeichert." so it
matches the project's informal register (du) used elsewhere.

Comment thread services/platform/messages/fr.json Outdated
"tagTranscription": "Transcription",
"details": "Détails du fournisseur",
"capabilities": "Capacités",
"apiKeyUpdated": "Votre clé API a été enregistrée avec succès."
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use informal French register in success toast

Line 1158 uses formal register (“Votre”), but this locale requires informal form.

Suggested fix
-      "apiKeyUpdated": "Votre clé API a été enregistrée avec succès."
+      "apiKeyUpdated": "Ta clé API a été enregistrée avec succès."

As per coding guidelines: "Use sentence case in every translation; use informal form across all languages (du in German, tu in French, never Sie or vous)".

📝 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
"apiKeyUpdated": "Votre clé API a été enregistrée avec succès."
"apiKeyUpdated": "Ta clé API a été enregistrée avec succès."
🤖 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 `@services/platform/messages/fr.json` at line 1158, Update the French
translation value for the key "apiKeyUpdated" to use informal register and
sentence case: replace the formal "Votre" with the informal "Ta" so the message
reads "Ta clé API a été enregistrée avec succès." Ensure you only modify the
string value for the "apiKeyUpdated" key.

Israeltheminer and others added 4 commits May 21, 2026 23:39
- Drop unused `displayNameFromId` helper and `fields` destructure in
  provider-add-panel.
- Replace `!` non-null assertions with locally-bound `formIndex` so the
  `!= null` narrowing carries into the IconButton onClick closures.
- Drop redundant `as number` assertions on the drawer's row.configuredIndex.
- Use `undefined` early-return in the auto-fetch debounce effect so it
  satisfies oxlint's consistent-return.
- Let SectionHeader accept children so the API-key section can render
  its "Test connection" pill next to the edit button.
- Remove 7 orphan i18n keys left behind when the 1.4k-line provider
  route was replaced by the drawer (backToProviders,
  costPerMillionTokens, modelApiKeyOverrideIndicator,
  modelOverrideIndicator, modelsSelected, providerOptions.indicator,
  selectAllModels) across de/en/fr.
These files were modified as part of local development and should not
have been committed. Restoring to main's content so the PR scope stays
on the provider drawer.
- api-keys table: render `… {suffix}` when the row has a suffix but no
  start/prefix (e.g. an SDK return that omits the prefix), instead of
  collapsing to `-`.
- provider-add-panel: on a failed model fetch, set `hasFetched=true`
  and snapshot the failing credentials so the debounced auto-fetch
  effect stops re-firing every render. The cleanup effect re-arms on
  the next credential change, and the manual Fetch button stays
  available for an immediate retry.
- provider-detail-drawer: drop `focus-visible:outline-none` from the
  edit/test/undo/remove buttons — it was suppressing the visible focus
  ring set by the `outline-ring`/`outline-2`/`outline-offset-2`
  classes immediately before it.
- provider-detail-drawer: save the per-model API key BEFORE the
  config update. If the secret write fails we now bail out without
  leaving an orphaned config row that points at a key the encrypted
  file doesn't have.
- provider-detail-drawer: i18n the new cost input labels and
  placeholders (input cost, output cost, image cost) across de/en/fr.
- file_actions: wrap `decryptSecretsFile` + `parseProviderSecrets` in
  `fetchConfiguredProviderModels` with a try/catch that normalises any
  thrown error into a `PROVIDER_FETCH_FAILED` ConvexError, matching
  the surrounding action's error contract.
@Israeltheminer Israeltheminer merged commit 493349a into main May 22, 2026
24 of 25 checks passed
@Israeltheminer Israeltheminer deleted the feat/platform-provider-detail-drawer branch May 22, 2026 09:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant