feat: Settings page comprehensive redesign (#936)#939
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
WalkthroughAdds observability sink management end-to-end: new backend endpoints ( Suggested labels
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Code Review
This pull request implements a significant update to the settings and observability infrastructure, introducing a tabbed interface for settings, a split-pane diff editor with schema-aware linting and autocomplete, and a new management UI for log sinks. Key backend changes include refined provider health probing and new endpoints for sink configuration. Technical feedback identifies a critical syntax error in Python exception handling and a missing persistence implementation for the log sinks UI. Further improvements are recommended for the editor's diffing logic and linter accuracy, as well as ensuring that CPU-bound validation tasks are offloaded to a thread pool to maintain event loop responsiveness.
| return ApiResponse( | ||
| data=TestSinkConfigResponse(valid=False, error=msg), | ||
| ) | ||
| except MemoryError, RecursionError: |
web/src/pages/SettingsSinksPage.tsx
Outdated
| const handleSave = useCallback(() => { | ||
| // TODO: Persist via settings API (updateSetting for sink_overrides/custom_sinks) | ||
| // For now, just refresh to pick up any changes | ||
| fetchSinks() | ||
| }, [fetchSinks]) |
There was a problem hiding this comment.
The handleSave function is currently a placeholder with a TODO. This means that any changes made to log sinks in the UI (adding, editing, or toggling) are not actually persisted to the backend. Given that the PR description claims 'full CRUD' for sinks, this persistence logic should be implemented to update the sink_overrides or custom_sinks settings.
| @post( | ||
| "/observability/sinks/_test", | ||
| guards=[require_ceo_or_manager], | ||
| sync_to_thread=False, |
There was a problem hiding this comment.
Setting sync_to_thread=False for the test_sink_config endpoint is risky. This endpoint performs JSON parsing and complex configuration validation via build_log_config_from_settings, which are CPU-bound operations. Running them in the main thread can block the event loop, especially if called frequently during rapid editing in the UI. It is generally better to let Litestar manage sync handlers in a thread pool.
sync_to_thread=True,| const customSink: Record<string, unknown> = { | ||
| file_path: path, | ||
| level, | ||
| json_format: jsonFormat, | ||
| } |
There was a problem hiding this comment.
The enabled state is missing from the payload when building a custom sink configuration in buildPayload. While default sinks include the enabled flag in their overrides, custom sinks should also respect the toggle state from the UI so that the test endpoint can validate the full intended configuration.
const customSink: Record<string, unknown> = {
file_path: path,
level,
json_format: jsonFormat,
enabled,
}
| export function computeLineDiff( | ||
| serverText: string, | ||
| editedText: string, | ||
| ): LineDiff[] { | ||
| const serverLines = serverText.split('\n') | ||
| const editedLines = editedText.split('\n') | ||
| const diffs: LineDiff[] = [] | ||
| const maxLen = Math.max(serverLines.length, editedLines.length) | ||
|
|
||
| for (let i = 0; i < maxLen; i++) { | ||
| const s = serverLines[i] | ||
| const e = editedLines[i] | ||
| if (s === undefined) { | ||
| // Line exists only in edited -- added | ||
| diffs.push({ line: i + 1, kind: 'added' }) | ||
| } else if (e === undefined) { | ||
| // Line exists only in server -- removed | ||
| // Show at the last edited line (clamped) | ||
| diffs.push({ | ||
| line: Math.min(i + 1, editedLines.length), | ||
| kind: 'removed', | ||
| }) | ||
| } else if (s !== e) { | ||
| diffs.push({ line: i + 1, kind: 'changed' }) | ||
| } | ||
| } | ||
|
|
||
| return diffs | ||
| } |
There was a problem hiding this comment.
The computeLineDiff function implements a simple positional comparison rather than a standard diffing algorithm (like Myers). This means that inserting or deleting a single line at the top of the file will cause every subsequent line to be marked as 'changed', which is misleading in a diff view. Consider using a library like diff-match-patch or implementing a more robust diffing logic if a true diff view is desired.
| function findJsonKeyPosition( | ||
| text: string, | ||
| namespace: string, | ||
| key?: string, | ||
| ): { from: number; to: number } | null { | ||
| // Search for the key pattern: "keyName": | ||
| const searchKey = key ?? namespace | ||
| // eslint-disable-next-line security/detect-non-literal-regexp -- input is escaped via escapeRegex | ||
| const pattern = new RegExp(`"${escapeRegex(searchKey)}"\\s*:`) | ||
| const match = pattern.exec(text) | ||
| if (match) { | ||
| return { from: match.index, to: match.index + searchKey.length + 2 } | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| /** | ||
| * Attempts to find the character position of a YAML key in the document. | ||
| * Returns { from, to } spanning the key. | ||
| */ | ||
| function findYamlKeyPosition( | ||
| text: string, | ||
| namespace: string, | ||
| key?: string, | ||
| ): { from: number; to: number } | null { | ||
| const searchKey = key ?? namespace | ||
| // YAML keys at indentation level: "keyName:" | ||
| // eslint-disable-next-line security/detect-non-literal-regexp -- input is escaped via escapeRegex | ||
| const pattern = new RegExp(`^(\\s*)${escapeRegex(searchKey)}\\s*:`, 'm') | ||
| const match = pattern.exec(text) | ||
| if (match) { | ||
| const offset = match.index + (match[1]?.length ?? 0) | ||
| return { from: offset, to: offset + searchKey.length } | ||
| } | ||
| return null | ||
| } |
There was a problem hiding this comment.
The linter's key position finding logic (findJsonKeyPosition and findYamlKeyPosition) is not namespace-aware. It simply searches for the first occurrence of a key string in the document. In settings files where multiple namespaces might use the same keys (e.g., enabled, path, description), the linter will highlight the wrong line if the error occurs in a namespace other than the first one containing that key.
Dependency ReviewThe following issues were found:
Snapshot WarningsEnsure that dependencies are being submitted on PR branches. Re-running this action after a short time may resolve the issue. See the documentation for more information and troubleshooting advice. License Issuesweb/package.json
OpenSSF Scorecard
Scanned Files
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #939 +/- ##
==========================================
- Coverage 92.21% 92.15% -0.07%
==========================================
Files 600 600
Lines 31940 32052 +112
Branches 3108 3115 +7
==========================================
+ Hits 29454 29537 +83
- Misses 1957 1985 +28
- Partials 529 530 +1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Actionable comments posted: 26
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
web/src/styles/design-tokens.css (1)
89-91:⚠️ Potential issue | 🟡 MinorUpdate density-switching guidance to include
.density-medium.Line 90 still says density is switched with only
.density-denseor.density-sparse, but this block now documents a Medium level and the file defines.density-medium. Please include it to avoid misleading usage docs.Suggested patch
- * Switch density by adding .density-dense or .density-sparse to <html>. + * Switch density by adding .density-dense, .density-medium, or .density-sparse to <html>.Also applies to: 95-97
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/styles/design-tokens.css` around lines 89 - 91, Update the density-switching comment to include the newly supported `.density-medium` option: change the guidance text that currently mentions only `.density-dense` and `.density-sparse` to list `.density-dense`, `.density-medium`, and `.density-sparse` (the comment block that references :root and the density classes), and apply the same addition to the similar guidance occurrences around the later block (lines corresponding to the repeated note at 95-97) so the docs match the actual CSS classes `.density-dense`, `.density-medium`, and `.density-sparse`.web/src/pages/settings/RestartBadge.tsx (1)
11-14: 🧹 Nitpick | 🔵 TrivialPrefer Tailwind size class over arbitrary
text-[10px]value.The tooltip text update is good. However,
text-[10px]is a hardcoded pixel value for font size. Consider usingtext-xs(12px) or defining a design token variable if a smaller size is needed.Suggested fix
className={cn( - 'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium bg-warning/10 text-warning', + 'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium bg-warning/10 text-warning', className, )}As per coding guidelines: "Use design tokens exclusively for colors, typography, and spacing -- never hardcode hex values,
rgba()values with hardcoded colors, pixel values for layout spacing, orfontFamilydeclarations directly in.tsx/.tsfiles."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/RestartBadge.tsx` around lines 11 - 14, Replace the arbitrary pixel font size in the RestartBadge className: remove the `text-[10px]` token in the cn call inside RestartBadge.tsx and replace it with a Tailwind design token (e.g., `text-xs`) or a project typography token class; ensure you update the same className/CSS string passed to the cn(...) call so typography uses a design-token class instead of hardcoded pixels.web/src/pages/settings/DependencyIndicator.tsx (1)
10-29:⚠️ Potential issue | 🟡 MinorRemove unused DependencyIndicator component
The component is not imported or used anywhere in the codebase. SettingRow.tsx does not render it, and no other files reference it. Delete
web/src/pages/settings/DependencyIndicator.tsxas it is dead code.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/DependencyIndicator.tsx` around lines 10 - 29, Delete the unused dead-file web/src/pages/settings/DependencyIndicator.tsx which exports the DependencyIndicator component (and its DependencyIndicatorProps) — remove the file and any related unused imports (e.g., GitBranch) to clean up the codebase and eliminate the unused component.web/src/pages/SettingsPage.tsx (1)
353-390: 🛠️ Refactor suggestion | 🟠 MajorExtract the namespace section renderer out of the
.map()body.This block now mixes filtering, animation, error boundaries, and the observability-only footer in one mapped JSX branch. Pull it into a small component/helper before future changes make this harder to modify safely.
As per coding guidelines, do not create complex (>8 line) JSX inside
.map()-- extract to a shared component instead.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/SettingsPage.tsx` around lines 353 - 390, Map body is too large; extract the JSX that renders each namespace into a small component. Create a new functional component (e.g., NamespaceSectionRenderer or NamespaceSectionWrapper) that accepts ns, entries, dirtyValues, handleValueChange, storeSavingKeys, controllerDisabledMap, activeNamespace, searchQuery, changedKeys and any other used props; move the StaggerItem > ErrorBoundary > NamespaceSection JSX into that component, preserve the key on the StaggerItem when used in the .map, retain the forceOpen and hideHeader logic and the observability-only footerAction conditional (the Link/Button block) inside the new component. Replace the inline JSX in the .map with a single call to the new component (pass through props and ensure NAMESPACE_DISPLAY_NAMES, NAMESPACE_ICONS, filteredByNamespace.get(ns)! usage is moved into the component) and export/import it as needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@tests/unit/providers/test_health_prober.py`:
- Around line 332-364: The test test_run_loop_continues_on_probe_error is
timing-sensitive because it relies on real asyncio.sleep; make it deterministic
by mocking asyncio.sleep (or patching it to an AsyncMock that returns
immediately) so the ProviderHealthProber loop can advance without real time
delays, or alternatively synchronize using an asyncio.Event that you set from
the _counting_get side_effect when call_count reaches 2 and await that event
instead of using await asyncio.sleep(2.5); update references to
config_resolver.get_provider_configs side_effect, prober._stop_event and the
start()/stop() calls so the test waits on the mock/event rather than wall-clock
sleep.
In `@web/src/__tests__/components/ui/TagInput.test.tsx`:
- Around line 31-38: The test for TagInput uses a non-null assertion
removeButtons[1]! which can hide changes in rendered buttons; before clicking,
assert the expected buttons exist (e.g., check removeButtons.length or specific
button presence) so the test fails clearly if the DOM changed, then proceed to
click removeButtons[1] and assert onChange was called; update the test around
the removeButtons variable in the 'removes a specific tag when X is clicked'
test to include that precondition assertion.
In `@web/src/__tests__/hooks/useSettingsKeyboard.test.ts`:
- Around line 1-62: Add tests to assert macOS support by dispatching
KeyboardEvent events with metaKey instead of ctrlKey for the existing handlers
in useSettingsKeyboard; specifically, duplicate the Ctrl+S and Ctrl+/ test cases
but set metaKey: true and verify onSave (when canSave is true) and onSearchFocus
are called, referencing the same renderHook usage of useSettingsKeyboard and the
same event dispatch pattern so the new tests mirror the existing ones for Cmd+S
and Cmd+/.
In `@web/src/__tests__/pages/LoginPage.test.tsx`:
- Line 186: The test "admin creation validates password match" increases the
Jest timeout to 15s to accommodate slow userEvent.type() calls; instead, remove
the widened timeout and eliminate the inter-character delay by using a userEvent
instance with delay disabled: call userEvent.setup({ delay: null }) (e.g., const
user = userEvent.setup({ delay: null })) and change usages of
userEvent.type(...) to await user.type(...), so typing is fast in jsdom and the
test no longer needs the longer timeout.
In `@web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx`:
- Around line 28-33: The test for FloatingSaveBar's hidden state is brittle
because it relies on a CSS class selector; update the test "is hidden when
dirtyCount is 0" to use a resilient selector: either assert only via content
(keep the existing screen.queryByText(/unsaved/) check) or target a stable
identifier added to the component (e.g., a data-testid like
data-testid="floating-save-bar" on the visible bar) and replace
container.querySelector('[class*="sticky"]') with
screen.queryByTestId('floating-save-bar') or another semantic query (role/aria)
to verify absence; modify the FloatingSaveBar component to expose the chosen
test id if needed and update the test to use that stable selector.
In `@web/src/__tests__/pages/settings/RestartBanner.test.tsx`:
- Around line 29-32: The test name for the RestartBanner accessibility spec is
misleading: change the test description in the it(...) call (for the test that
renders <RestartBanner count={1} ... /> and asserts screen.getByRole('alert'))
to reflect the assertion (e.g. 'has alert role for accessibility') or
alternatively change the assertion to check for the intended warning role;
update the string in the it(...) that references RestartBanner so the
description and the assertion (getByRole('alert') vs
getByRole('warning'/'status')) match.
In `@web/src/__tests__/pages/settings/sinks/SinkCard.test.tsx`:
- Around line 96-101: The test name and assertion mismatch: the test in
SinkCard.test.tsx claims to check the console icon for a console sink but only
asserts the Enabled status dot; update the test to actually verify the icon
rendered for sink_type 'console' by either (A) adding a stable test target in
the component (e.g., add data-testid attributes to the Monitor and FileText
icons in SinkCard.tsx) and then assert screen.getByTestId('sink-icon-monitor')
is present when calling render(<SinkCard sink={makeSink({ sink_type: 'console'
})} ...>), or (B) if icons already expose accessible labels, change the
assertion to query that label (e.g., getByLabelText or getByTitle) instead of
only checking the Enabled dot; ensure the test still uses makeSink and updates
the test name to match the new assertion if you choose to keep the simpler
Enabled-dot check.
In `@web/src/__tests__/pages/settings/SourceBadge.test.tsx`:
- Around line 44-50: The test "covers all SettingSource values" only renders
SourceBadge for each SettingSource and unmounts, so add explicit assertions that
SourceBadge renders the expected visible output for each source value: for each
source in the SettingSource[] (['db','env','yaml','default']) render
<SourceBadge source={source} /> and assert the badge text and/or accessible
label and/or CSS class that corresponds to that source (use getByText/getByRole
or container.querySelector on the rendered output); update the test to check
each mapping (e.g., 'db' -> expected label/class, 'env' -> expected label/class,
etc.) so the test fails if a source is not handled by the SourceBadge component.
In `@web/src/components/ui/code-mirror-editor.tsx`:
- Around line 238-248: The effect watching extraExtensions will re-run whenever
the array reference changes, causing unnecessary reconfiguration if parents pass
an inline array; update the component documentation (JSDoc for the component or
the extraExtensions prop) to recommend callers memoize the extensions array with
useMemo (e.g., pass the same array reference) or switch callers to a stable
variable, and optionally mention that consumers can provide a stable reference
to avoid repeated reconfigure calls; reference the extraExtensions prop, the
useEffect that dispatches to extraExtensionsCompartmentRef.current.reconfigure,
and viewRef.current.dispatch when describing the memoization requirement.
In `@web/src/components/ui/tag-input.stories.tsx`:
- Around line 14-42: The stories file is missing the required hover, loading,
and error variants for the new shared TagInput component; add three new Story
exports named Hover, Loading, and Error alongside the existing
Default/Empty/Disabled/ManyTags. Implement each like the existing stories:
supply args and a render function that uses useState(args.value) and returns
<TagInput {...args} value={value} onChange={setValue} />; for Loading set
args.loading = true (or the component's loading prop), for Error set args.error
= 'your error message' (or the error prop your TagInput expects), and for Hover
add a Hover story that either sets a hover prop if TagInput supports it or uses
a play function to call userEvent.hover() on the TagInput DOM node to simulate
:hover during storybook interaction tests. Ensure export names and the render
pattern (useState + setValue) match the existing stories.
- Around line 16-18: The Render functions for the stories (Default, Empty,
ManyTags) initialize local state with useState(args.value) but don't update when
Storybook Controls change; inside each Render (the function rendering <TagInput
{...args} value={value} onChange={setValue} />) add a useEffect that watches
args.value and calls setValue(args.value) so the TagInput local state stays in
sync with control updates.
In `@web/src/components/ui/tag-input.tsx`:
- Around line 64-113: The container currently uses listbox/listbox option
semantics (role="listbox" and role="option" with aria-selected) but the
component is a freeform input with remove buttons, so change the container and
item semantics to neutral grouping (e.g., replace role="listbox" with
role="group" or no landmark) and remove role="option"/aria-selected from the tag
spans in the value.map render; ensure the interactive input (inputRef) receives
an accessible name by adding aria-label or aria-labelledby on the input element
(or expose a prop to pass one) so assistive tech can identify the control; keep
the remove button aria-labels (aria-label={`Remove ${item}`}) and retain
keyboard handlers like handleKeyDown/removeAt unchanged.
- Around line 75-112: Extract the chip JSX inside value.map into a new child
component (e.g., TagChip or Chip) and pass it the props it needs (item, index,
disabled, removeAt) so the map body in TagInput is under 8 lines; preserve
role="option", aria-selected, aria-label and the X button behavior
(stopPropagation + removeAt(i)). Replace the hardcoded input class min-w-[80px]
with a design-token-based Tailwind spacing utility (e.g., min-w-20/min-w-24 or a
CSS variable token like min-w-[var(--size-...)]) so layout uses design tokens
rather than a pixel literal; update any prop/type definitions (TagInput/TagChip)
accordingly. Ensure no visual/ARIA changes.
- Around line 17-25: The addItems callback currently only filters new items
against existing value, so duplicates inside the incoming items (e.g., "a,a")
still get added; update addItems to normalize and deduplicate the incoming batch
before merging by trimming and filtering out falsy items, then remove any item
already present in value and remove duplicates within the batch (e.g., using a
Set or filtering by first index) while preserving order, and finally call
onChange([...value, ...dedupedNewItems]) only if dedupedNewItems.length > 0; use
the existing symbols addItems, value, and onChange to locate and patch the
function.
In `@web/src/hooks/useSettingsKeyboard.ts`:
- Around line 19-25: The keyboard handler in useSettingsKeyboard compares e.key
to a lowercase literal ('s'), which fails when e.key is 'S' (Caps Lock/Shift);
update the handler in useSettingsKeyboard to perform case-insensitive matching
(e.g., use e.key.toLowerCase() when comparing for 's') while keeping the '/'
comparison unchanged, and ensure you still call e.preventDefault() and
onSave()/onSearchFocus() as before.
In `@web/src/pages/settings/CodeEditorPanel.tsx`:
- Around line 315-336: The diffSummary useMemo does a naive index-based line
comparison (in CodeEditorPanel's diffSummary) which can mismatch the editor's
gutter markers; either document this limitation next to the diffSummary
definition (add a short comment like "This is a simplified line-by-line
comparison; reorder/move operations won't match gutter counts") or replace the
logic to compute counts using the same diff algorithm used by
diffGutterExtension (invoke or port its line-diff routine to calculate
added/removed/changed totals) so the summary and gutter remain consistent.
In `@web/src/pages/settings/DependencyIndicator.tsx`:
- Around line 17-20: Replace the arbitrary pixel font-size in the className for
the DependencyIndicator component: remove text-[10px] and use the appropriate
Tailwind/design-token class (e.g., text-xs or your project's small-badge token)
so typography follows design tokens; update the className passed to cn in
DependencyIndicator (and mirror the same change pattern used in
RestartBadge.tsx) to ensure consistent small-badge typography across the
codebase.
In `@web/src/pages/settings/NamespaceSection.tsx`:
- Around line 114-143: renderGroups() contains two large JSX branches inside the
.map(), so extract the group's shell and row rendering into a small
helper/component (e.g., GroupBlock or GroupRows) that accepts props: group
(string), groupEntries (array), hideHeader (boolean), renderRow (function), and
anim (object). Move the header and the inner mapping (both the plain div and the
motion.div animated variant) into that new component so renderGroups() simply
maps groups.entries() to <GroupBlock ... />; preserve the existing key logic
(`${entry.definition.namespace}/${entry.definition.key}`), keep the index-based
delay (i * anim.staggerDelay) for the animated branch, and ensure the outer
container still conditionally applies className={groups.size > 1 ? 'py-2' :
undefined} and renders the header when groups.size > 1.
In `@web/src/pages/settings/SearchInput.tsx`:
- Around line 72-75: The inline result count currently uses a hardcoded text
size and can overlap the input text and is not accessible; update the
SearchInput component to reserve right padding on the input when local &&
resultCount !== undefined so multi-digit counts never overlap, replace the
span's hardcoded "text-[10px]" with the appropriate design-token class for
small/label typography, and make the count an accessible live status by adding
role="status" and aria-live="polite" (or include a visually hidden label) on the
element that renders resultCount; reference the existing local and resultCount
usage and adjust the input's className/padding and the span rendering to use
design token classes instead of pixel values.
In `@web/src/pages/settings/SettingsHealthSection.stories.tsx`:
- Around line 39-45: The NamespaceSelected story is missing the onSelect handler
expected by NamespaceTabBar; update the NamespaceSelected Story object
(NamespaceSelected.args) to include an onSelect prop like AllActive does (e.g.,
a noop or Storybook action) so the component receives the required handler and
TypeScript/runtime errors are avoided.
In `@web/src/pages/settings/sinks/SinkFormDrawer.tsx`:
- Around line 51-88: In buildPayload, add the same !isConsole guard when adding
rotation to the customSink so console sinks never get a rotation block (modify
the conditional that currently checks rotationStrategy !== 'none' to also
require !isConsole), and validate maxBytes and backupCount before converting
them: parse them with Number/parseInt, verify they are present, numeric
(Number.isFinite), and non-negative (or >=0 for backupCount), call the
appropriate error setter(s) and return null on invalid input (similar to
setFilePathError), otherwise use the validated numeric values in the rotation
object (referencing buildPayload, customSink, rotationStrategy, maxBytes,
backupCount).
In `@web/src/pages/settings/SourceBadge.stories.tsx`:
- Around line 15-16: The story names/memo are misleading: the Story export
ConfigFile (args: { source: 'yaml' }) actually renders null just like
DefaultRendersNull (args: { source: 'default' }); rename or annotate ConfigFile
to indicate it renders null (e.g., YamlRendersNull or ConfigFileRendersNull) and
update the nearby comment that currently states only `default` renders nothing
so it mentions `yaml` too; update any displayName or story title if present to
match the new name (referencing the exports ConfigFile and DefaultRendersNull
and the comment on the story describing render behavior).
In `@web/src/pages/SettingsPage.tsx`:
- Around line 84-85: When applying the search/advanced-mode filters, ensure the
selected namespace is cleared if it no longer exists in the filtered results:
after computing the filtered list (the logic that uses searchRef/useState),
check whether activeNamespace is included in that filteredNamespaces array and
call setActiveNamespace(null) when it is not (also add this check wherever
filteredNamespaces is recomputed, e.g. the filtering effect/handler that updates
visible namespaces). Update the code around activeNamespace and
setActiveNamespace so the tab bar and section pane get reset whenever the
current activeNamespace is removed by filtering.
- Around line 371-385: The footerAction currently nests a Button component
inside the Link (an <a>), creating invalid interactive nesting; remove the
Button and replace it with a non-interactive element (e.g., a styled <div> or
<span>) that uses the same styling and classes (the surrounding Link and its
className remain) or alternatively make the entire card the single interactive
control by keeping Link as the wrapper and moving any button styling into a
plain element; update the part of the JSX referencing Link and Button so only
the Link is interactive (preserve the "Open" label and visual appearance but not
a <button> element).
In `@web/src/pages/SettingsSinksPage.tsx`:
- Around line 56-60: handleSave currently discards the SinkInfo from
SinkFormDrawer and just calls fetchSinks; change it to accept the emitted
SinkInfo (or an id + partial), call the settings API (e.g. updateSetting) to
persist into the appropriate key (sink_overrides or custom_sinks) and on success
either update local state or call fetchSinks to refresh; ensure handleSave (used
by SinkFormDrawer) returns/awaits the API promise and catches/logs errors so the
drawer only closes after a successful save. Reference: handleSave, fetchSinks,
SinkFormDrawer, and updateSetting/sink_overrides/custom_sinks.
- Around line 18-20: Instead of storing the whole SinkInfo object in editSink,
store only the selected sink's identifier (e.g. selectedSinkId via
setSelectedSinkId) and keep isNewSink/drawerOpen logic; then derive the current
edited sink on each render by looking up sinks.find(s => s.id ===
selectedSinkId) (use the same identifier symbol wherever editSink was used, e.g.
replace references to editSink with derivedCurrentSink) so the drawer form
always binds to the latest sink from the store and avoids saving stale data;
update any handlers that set editSink (replace setEditSink(...) with
setSelectedSinkId(...)) and ensure the drawer key/identifier logic uses
selectedSinkId so WS auto-refresh reflects up-to-date sink fields.
---
Outside diff comments:
In `@web/src/pages/settings/DependencyIndicator.tsx`:
- Around line 10-29: Delete the unused dead-file
web/src/pages/settings/DependencyIndicator.tsx which exports the
DependencyIndicator component (and its DependencyIndicatorProps) — remove the
file and any related unused imports (e.g., GitBranch) to clean up the codebase
and eliminate the unused component.
In `@web/src/pages/settings/RestartBadge.tsx`:
- Around line 11-14: Replace the arbitrary pixel font size in the RestartBadge
className: remove the `text-[10px]` token in the cn call inside RestartBadge.tsx
and replace it with a Tailwind design token (e.g., `text-xs`) or a project
typography token class; ensure you update the same className/CSS string passed
to the cn(...) call so typography uses a design-token class instead of hardcoded
pixels.
In `@web/src/pages/SettingsPage.tsx`:
- Around line 353-390: Map body is too large; extract the JSX that renders each
namespace into a small component. Create a new functional component (e.g.,
NamespaceSectionRenderer or NamespaceSectionWrapper) that accepts ns, entries,
dirtyValues, handleValueChange, storeSavingKeys, controllerDisabledMap,
activeNamespace, searchQuery, changedKeys and any other used props; move the
StaggerItem > ErrorBoundary > NamespaceSection JSX into that component, preserve
the key on the StaggerItem when used in the .map, retain the forceOpen and
hideHeader logic and the observability-only footerAction conditional (the
Link/Button block) inside the new component. Replace the inline JSX in the .map
with a single call to the new component (pass through props and ensure
NAMESPACE_DISPLAY_NAMES, NAMESPACE_ICONS, filteredByNamespace.get(ns)! usage is
moved into the component) and export/import it as needed.
In `@web/src/styles/design-tokens.css`:
- Around line 89-91: Update the density-switching comment to include the newly
supported `.density-medium` option: change the guidance text that currently
mentions only `.density-dense` and `.density-sparse` to list `.density-dense`,
`.density-medium`, and `.density-sparse` (the comment block that references
:root and the density classes), and apply the same addition to the similar
guidance occurrences around the later block (lines corresponding to the repeated
note at 95-97) so the docs match the actual CSS classes `.density-dense`,
`.density-medium`, and `.density-sparse`.
🪄 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: Repository UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: c89853c3-fc65-4575-9ce1-56ff94d02312
⛔ Files ignored due to path filters (1)
web/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (63)
CLAUDE.mddocs/design/brand-and-ux.mddocs/design/operations.mddocs/design/page-structure.mdsrc/synthorg/api/app.pysrc/synthorg/api/controllers/settings.pysrc/synthorg/observability/sink_config_builder.pysrc/synthorg/providers/capabilities.pysrc/synthorg/providers/health.pysrc/synthorg/providers/health_prober.pysrc/synthorg/settings/definitions/api.pytests/unit/api/controllers/test_provider_health.pytests/unit/api/controllers/test_settings_sinks.pytests/unit/providers/test_health.pytests/unit/providers/test_health_prober.pyweb/CLAUDE.mdweb/package.jsonweb/src/__tests__/components/layout/Sidebar.test.tsxweb/src/__tests__/components/ui/TagInput.test.tsxweb/src/__tests__/hooks/useSettingsKeyboard.test.tsweb/src/__tests__/pages/LoginPage.test.tsxweb/src/__tests__/pages/SettingsPage.test.tsxweb/src/__tests__/pages/settings/FloatingSaveBar.test.tsxweb/src/__tests__/pages/settings/RestartBanner.test.tsxweb/src/__tests__/pages/settings/SettingsHealthSection.test.tsxweb/src/__tests__/pages/settings/SourceBadge.test.tsxweb/src/__tests__/pages/settings/sinks/SinkCard.test.tsxweb/src/__tests__/pages/tasks/TaskDetailPanel.test.tsxweb/src/__tests__/stores/sinks.test.tsweb/src/api/endpoints/settings.tsweb/src/api/types.tsweb/src/components/ui/code-mirror-editor.tsxweb/src/components/ui/tag-input.stories.tsxweb/src/components/ui/tag-input.tsxweb/src/hooks/useSettingsKeyboard.tsweb/src/pages/SettingsPage.tsxweb/src/pages/SettingsSinksPage.tsxweb/src/pages/settings/CodeEditorPanel.tsxweb/src/pages/settings/DependencyIndicator.tsxweb/src/pages/settings/FloatingSaveBar.stories.tsxweb/src/pages/settings/FloatingSaveBar.tsxweb/src/pages/settings/NamespaceSection.tsxweb/src/pages/settings/RestartBadge.tsxweb/src/pages/settings/RestartBanner.stories.tsxweb/src/pages/settings/RestartBanner.tsxweb/src/pages/settings/SearchInput.stories.tsxweb/src/pages/settings/SearchInput.tsxweb/src/pages/settings/SettingField.tsxweb/src/pages/settings/SettingRow.stories.tsxweb/src/pages/settings/SettingRow.tsxweb/src/pages/settings/SettingsHealthSection.stories.tsxweb/src/pages/settings/SettingsHealthSection.tsxweb/src/pages/settings/SourceBadge.stories.tsxweb/src/pages/settings/SourceBadge.tsxweb/src/pages/settings/editor-extensions.tsweb/src/pages/settings/sinks/SinkCard.stories.tsxweb/src/pages/settings/sinks/SinkCard.tsxweb/src/pages/settings/sinks/SinkFormDrawer.tsxweb/src/router/index.tsxweb/src/router/routes.tsweb/src/stores/sinks.tsweb/src/styles/design-tokens.cssweb/src/utils/constants.ts
| it('removes a specific tag when X is clicked', async () => { | ||
| const user = userEvent.setup() | ||
| const onChange = vi.fn() | ||
| render(<TagInput value={['a', 'b', 'c']} onChange={onChange} />) | ||
| const removeButtons = screen.getAllByRole('button', { name: /remove/i }) | ||
| await user.click(removeButtons[1]!) // Remove 'b' | ||
| expect(onChange).toHaveBeenCalledWith(['a', 'c']) | ||
| }) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Consider adding an assertion before the non-null assertion.
The removeButtons[1]! assumes exactly 3 remove buttons exist. If the component implementation changes, this could silently fail or throw an unclear error.
const removeButtons = screen.getAllByRole('button', { name: /remove/i })
+ expect(removeButtons).toHaveLength(3)
await user.click(removeButtons[1]!) // Remove 'b'📝 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.
| it('removes a specific tag when X is clicked', async () => { | |
| const user = userEvent.setup() | |
| const onChange = vi.fn() | |
| render(<TagInput value={['a', 'b', 'c']} onChange={onChange} />) | |
| const removeButtons = screen.getAllByRole('button', { name: /remove/i }) | |
| await user.click(removeButtons[1]!) // Remove 'b' | |
| expect(onChange).toHaveBeenCalledWith(['a', 'c']) | |
| }) | |
| it('removes a specific tag when X is clicked', async () => { | |
| const user = userEvent.setup() | |
| const onChange = vi.fn() | |
| render(<TagInput value={['a', 'b', 'c']} onChange={onChange} />) | |
| const removeButtons = screen.getAllByRole('button', { name: /remove/i }) | |
| expect(removeButtons).toHaveLength(3) | |
| await user.click(removeButtons[1]!) // Remove 'b' | |
| expect(onChange).toHaveBeenCalledWith(['a', 'c']) | |
| }) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/__tests__/components/ui/TagInput.test.tsx` around lines 31 - 38, The
test for TagInput uses a non-null assertion removeButtons[1]! which can hide
changes in rendered buttons; before clicking, assert the expected buttons exist
(e.g., check removeButtons.length or specific button presence) so the test fails
clearly if the DOM changed, then proceed to click removeButtons[1] and assert
onChange was called; update the test around the removeButtons variable in the
'removes a specific tag when X is clicked' test to include that precondition
assertion.
| import { renderHook } from '@testing-library/react' | ||
| import { useSettingsKeyboard } from '@/hooks/useSettingsKeyboard' | ||
|
|
||
| describe('useSettingsKeyboard', () => { | ||
| it('calls onSave on Ctrl+S when canSave is true', () => { | ||
| const onSave = vi.fn() | ||
| const onSearchFocus = vi.fn() | ||
| renderHook(() => | ||
| useSettingsKeyboard({ onSave, onSearchFocus, canSave: true }), | ||
| ) | ||
| const event = new KeyboardEvent('keydown', { | ||
| key: 's', | ||
| ctrlKey: true, | ||
| bubbles: true, | ||
| }) | ||
| document.dispatchEvent(event) | ||
| expect(onSave).toHaveBeenCalledOnce() | ||
| }) | ||
|
|
||
| it('does not call onSave when canSave is false', () => { | ||
| const onSave = vi.fn() | ||
| const onSearchFocus = vi.fn() | ||
| renderHook(() => | ||
| useSettingsKeyboard({ onSave, onSearchFocus, canSave: false }), | ||
| ) | ||
| const event = new KeyboardEvent('keydown', { | ||
| key: 's', | ||
| ctrlKey: true, | ||
| bubbles: true, | ||
| }) | ||
| document.dispatchEvent(event) | ||
| expect(onSave).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('calls onSearchFocus on Ctrl+/', () => { | ||
| const onSave = vi.fn() | ||
| const onSearchFocus = vi.fn() | ||
| renderHook(() => | ||
| useSettingsKeyboard({ onSave, onSearchFocus, canSave: false }), | ||
| ) | ||
| const event = new KeyboardEvent('keydown', { | ||
| key: '/', | ||
| ctrlKey: true, | ||
| bubbles: true, | ||
| }) | ||
| document.dispatchEvent(event) | ||
| expect(onSearchFocus).toHaveBeenCalledOnce() | ||
| }) | ||
|
|
||
| it('cleans up event listener on unmount', () => { | ||
| const spy = vi.spyOn(document, 'removeEventListener') | ||
| const { unmount } = renderHook(() => | ||
| useSettingsKeyboard({ | ||
| onSave: vi.fn(), | ||
| onSearchFocus: vi.fn(), | ||
| canSave: false, | ||
| }), | ||
| ) | ||
| unmount() | ||
| expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function)) | ||
| spy.mockRestore() | ||
| }) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Good test coverage for keyboard shortcuts.
The test suite covers the core functionality well. Consider adding tests for metaKey (Cmd on macOS) to ensure cross-platform support:
🧪 Suggested additional test cases
it('calls onSave on Cmd+S (macOS) when canSave is true', () => {
const onSave = vi.fn()
const onSearchFocus = vi.fn()
renderHook(() =>
useSettingsKeyboard({ onSave, onSearchFocus, canSave: true }),
)
const event = new KeyboardEvent('keydown', {
key: 's',
metaKey: true,
bubbles: true,
})
document.dispatchEvent(event)
expect(onSave).toHaveBeenCalledOnce()
})
it('calls onSearchFocus on Cmd+/ (macOS)', () => {
const onSave = vi.fn()
const onSearchFocus = vi.fn()
renderHook(() =>
useSettingsKeyboard({ onSave, onSearchFocus, canSave: false }),
)
const event = new KeyboardEvent('keydown', {
key: '/',
metaKey: true,
bubbles: true,
})
document.dispatchEvent(event)
expect(onSearchFocus).toHaveBeenCalledOnce()
})🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/__tests__/hooks/useSettingsKeyboard.test.ts` around lines 1 - 62, Add
tests to assert macOS support by dispatching KeyboardEvent events with metaKey
instead of ctrlKey for the existing handlers in useSettingsKeyboard;
specifically, duplicate the Ctrl+S and Ctrl+/ test cases but set metaKey: true
and verify onSave (when canSave is true) and onSearchFocus are called,
referencing the same renderHook usage of useSettingsKeyboard and the same event
dispatch pattern so the new tests mirror the existing ones for Cmd+S and Cmd+/.
| }) | ||
|
|
||
| it('admin creation validates password match', async () => { | ||
| it('admin creation validates password match', { timeout: 15000 }, async () => { |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Consider whether this timeout increase addresses the root cause.
The coding guidelines discourage widening timing margins for flaky tests. However, this test involves multiple userEvent.type() calls which are inherently slow in jsdom environments. If the slowness is due to the testing library's simulation of user input rather than application code, this timeout increase is acceptable.
If this test is consistently slow, consider using userEvent.setup({ delay: null }) to disable the inter-character delay, which can significantly speed up typing simulations.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/__tests__/pages/LoginPage.test.tsx` at line 186, The test "admin
creation validates password match" increases the Jest timeout to 15s to
accommodate slow userEvent.type() calls; instead, remove the widened timeout and
eliminate the inter-character delay by using a userEvent instance with delay
disabled: call userEvent.setup({ delay: null }) (e.g., const user =
userEvent.setup({ delay: null })) and change usages of userEvent.type(...) to
await user.type(...), so typing is fast in jsdom and the test no longer needs
the longer timeout.
| it('is hidden when dirtyCount is 0', () => { | ||
| const { container } = render(<FloatingSaveBar {...defaultProps} dirtyCount={0} />) | ||
| expect(screen.queryByText(/unsaved/)).not.toBeInTheDocument() | ||
| // The ConfirmDialog is still mounted but not open, so only check for visible bar content | ||
| expect(container.querySelector('[class*="sticky"]')).not.toBeInTheDocument() | ||
| }) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Consider a more resilient selector for hidden state verification.
The container.querySelector('[class*="sticky"]') assertion couples the test to the component's CSS implementation. If the sticky class name changes (e.g., Tailwind purging or refactoring), this test will break silently.
Consider using a data-testid or relying solely on the content-based assertion which already covers the hidden behavior:
it('is hidden when dirtyCount is 0', () => {
const { container } = render(<FloatingSaveBar {...defaultProps} dirtyCount={0} />)
expect(screen.queryByText(/unsaved/)).not.toBeInTheDocument()
- // The ConfirmDialog is still mounted but not open, so only check for visible bar content
- expect(container.querySelector('[class*="sticky"]')).not.toBeInTheDocument()
+ // Save/Discard buttons should also not be visible
+ expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument()
})🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx` around lines 28 -
33, The test for FloatingSaveBar's hidden state is brittle because it relies on
a CSS class selector; update the test "is hidden when dirtyCount is 0" to use a
resilient selector: either assert only via content (keep the existing
screen.queryByText(/unsaved/) check) or target a stable identifier added to the
component (e.g., a data-testid like data-testid="floating-save-bar" on the
visible bar) and replace container.querySelector('[class*="sticky"]') with
screen.queryByTestId('floating-save-bar') or another semantic query (role/aria)
to verify absence; modify the FloatingSaveBar component to expose the chosen
test id if needed and update the test to use that stable selector.
| const [activeNamespace, setActiveNamespace] = useState<SettingNamespace | null>(null) | ||
| const searchRef = useRef<{ focus: () => void }>(null) |
There was a problem hiding this comment.
Reset activeNamespace when filtering removes it.
If search or advanced-mode filtering drops the selected namespace to zero results, activeNamespace stays set. The tab bar then shows no selected tab and the section pane goes blank even though other namespaces still have matches.
🛠️ Suggested fix
const namespaceCounts = useMemo(
() => new Map(NAMESPACE_ORDER.map((ns) => [ns, filteredByNamespace.get(ns)?.length ?? 0])),
[filteredByNamespace],
)
+
+ useEffect(() => {
+ if (activeNamespace && (namespaceCounts.get(activeNamespace) ?? 0) === 0) {
+ setActiveNamespace(null)
+ }
+ }, [activeNamespace, namespaceCounts])Also applies to: 156-159, 354-355
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/SettingsPage.tsx` around lines 84 - 85, When applying the
search/advanced-mode filters, ensure the selected namespace is cleared if it no
longer exists in the filtered results: after computing the filtered list (the
logic that uses searchRef/useState), check whether activeNamespace is included
in that filteredNamespaces array and call setActiveNamespace(null) when it is
not (also add this check wherever filteredNamespaces is recomputed, e.g. the
filtering effect/handler that updates visible namespaces). Update the code
around activeNamespace and setActiveNamespace so the tab bar and section pane
get reset whenever the current activeNamespace is removed by filtering.
web/src/pages/SettingsSinksPage.tsx
Outdated
| const handleSave = useCallback(() => { | ||
| // TODO: Persist via settings API (updateSetting for sink_overrides/custom_sinks) | ||
| // For now, just refresh to pick up any changes | ||
| fetchSinks() | ||
| }, [fetchSinks]) |
There was a problem hiding this comment.
Saving is currently a no-op.
This handler ignores the SinkInfo emitted by SinkFormDrawer and just refetches the existing list, so add/edit/enable/disable flows close the drawer without persisting anything.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/SettingsSinksPage.tsx` around lines 56 - 60, handleSave
currently discards the SinkInfo from SinkFormDrawer and just calls fetchSinks;
change it to accept the emitted SinkInfo (or an id + partial), call the settings
API (e.g. updateSetting) to persist into the appropriate key (sink_overrides or
custom_sinks) and on success either update local state or call fetchSinks to
refresh; ensure handleSave (used by SinkFormDrawer) returns/awaits the API
promise and catches/logs errors so the drawer only closes after a successful
save. Reference: handleSave, fetchSinks, SinkFormDrawer, and
updateSetting/sink_overrides/custom_sinks.
f9d0945 to
716d07e
Compare
There was a problem hiding this comment.
Actionable comments posted: 14
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
web/src/styles/design-tokens.css (1)
89-97:⚠️ Potential issue | 🟡 MinorUpdate density-switch guidance to include
.density-medium.Line 90 still says switching uses only
.density-denseor.density-sparse, but Line 96 introduces.density-medium. Please keep the instruction in sync to avoid confusion.Proposed doc fix
- * Switch density by adding .density-dense or .density-sparse to <html>. + * Switch density by adding .density-dense, .density-medium, or .density-sparse to <html>.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/styles/design-tokens.css` around lines 89 - 97, Update the density-switch guidance so it lists all available classes: modify the sentence that currently reads "Switch density by adding .density-dense or .density-sparse to <html>" to include .density-medium (e.g., ".density-dense, .density-medium, or .density-sparse"), and verify the preceding/following lines referencing :root and the density level bullets (Dense, Balanced, Medium, Sparse) remain consistent with the class names .density-dense, .density-medium, .density-sparse and the default :root behavior.src/synthorg/observability/sink_config_builder.py (1)
316-343:⚠️ Potential issue | 🟠 MajorNormalize
file_pathbefore constructingSinkConfig.Line 342 uses the untrimmed
raw_path. A value like" logs/app.log "passes validation but yields a distinct path and can evade expected conflict semantics. Normalize once and use the normalized value downstream.🔧 Proposed fix
raw_path = entry["file_path"] if not isinstance(raw_path, str) or not raw_path.strip(): msg = ( f"custom_sinks[{index}].file_path must be a non-empty string, " f"got {raw_path!r}" ) raise ValueError(msg) + file_path = raw_path.strip() @@ return SinkConfig( sink_type=SinkType.FILE, level=level, - file_path=raw_path, + file_path=file_path, rotation=rotation, json_format=json_format, )As per coding guidelines, "Validate at system boundaries (user input, external APIs, config files)."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/synthorg/observability/sink_config_builder.py` around lines 316 - 343, Normalize and reuse the trimmed/normalized file path instead of raw_path when building the SinkConfig: compute a single normalized path (e.g., strip surrounding whitespace and apply os.path.normpath or equivalent) from raw_path, run the existing validations against that normalized value, and pass the normalized value to SinkConfig(file_path=...) (symbols: raw_path, SinkConfig, file_path, SinkType.FILE); ensure the variable used for checks and rotation logic is the normalized path so downstream conflict semantics behave as expected.src/synthorg/providers/health.py (1)
166-191: 🧹 Nitpick | 🔵 TrivialDocument the ZeroDivisionError risk more prominently or add a guard.
The docstring mentions "ZeroDivisionError if empty" but callers must rely on this contract. Consider either:
- Raising a clearer
ValueErrorwith a message, or- Adding an
assert recordsat the top for fail-fast with a clearer tracebackThis is defensive since all current call sites pre-check, but future maintenance could miss this.
🛡️ Optional defensive guard
def _aggregate_records( records: list[ProviderHealthRecord], ) -> ProviderHealthSummary: """Aggregate a non-empty list of health records into a summary. ... """ + if not records: + msg = "_aggregate_records requires non-empty records list" + raise ValueError(msg) total = len(records)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/synthorg/providers/health.py` around lines 166 - 191, The _aggregate_records function currently can raise a ZeroDivisionError when called with an empty records list; change it to fail-fast with a clear ValueError by adding an explicit guard at the top of _aggregate_records that checks if records is empty and raises ValueError("records must be a non-empty list") (or similar message) so callers of ProviderHealthSummary construction (avg_response_time_ms, error_rate_percent_24h, calls_last_24h) receive a clear, descriptive exception instead of a ZeroDivisionError.
♻️ Duplicate comments (16)
web/src/pages/settings/SourceBadge.stories.tsx (1)
15-16:⚠️ Potential issue | 🟡 MinorRename the
yamlstory and fix the null-render comment.At Line 15,
ConfigFileis misleading becausesource: 'yaml'renders nothing. At Line 25, the comment says onlydefaultrenders nothing, butyamldoes too.Suggested patch
-export const ConfigFile: Story = { args: { source: 'yaml' } } +export const YamlRendersNull: Story = { args: { source: 'yaml' } } - {/* default renders nothing */} + {/* yaml and default render nothing */}Also applies to: 25-25
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/SourceBadge.stories.tsx` around lines 15 - 16, Rename the misleading story ConfigFile to something explicit like YamlRendersNull (or YamlSourceRendersNull) since args: { source: 'yaml' } renders nothing, and update the DefaultRendersNull story comment/text to state that both 'default' and 'yaml' sources render null; adjust any exported names or references (ConfigFile -> YamlRendersNull) so imports/exports remain consistent and the comment accurately reflects that both source: 'default' and source: 'yaml' produce null output.web/src/__tests__/pages/settings/SourceBadge.test.tsx (1)
44-50:⚠️ Potential issue | 🟡 MinorMake the “covers all SettingSource values” test assert behavior.
Line 44-50 only checks render/unmount and won’t catch mapping regressions. Assert expected label/null per source in one exhaustive map.
Suggested patch
it('covers all SettingSource values', () => { - // Exhaustive check that every SettingSource is handled - const sources: SettingSource[] = ['db', 'env', 'yaml', 'default'] - for (const source of sources) { - const { unmount } = render(<SourceBadge source={source} />) - unmount() + const expected: Record<SettingSource, string | null> = { + db: 'Modified', + env: 'ENV', + yaml: null, + default: null, + } + + for (const source of Object.keys(expected) as SettingSource[]) { + const { container, unmount } = render(<SourceBadge source={source} />) + const label = expected[source] + if (label) { + expect(screen.getByText(label)).toBeInTheDocument() + } else { + expect(container.firstChild).toBeNull() + } + unmount() } })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/pages/settings/SourceBadge.test.tsx` around lines 44 - 50, The test "covers all SettingSource values" currently only renders and unmounts; replace the loop in the it block to assert actual behavior for each SettingSource by mapping expected outputs and verifying SourceBadge renders the expected label or nothing: use the SettingSource[] values ('db','env','yaml','default') and for each call render(<SourceBadge source={source} />) then assert the DOM contains the expected text (or is empty/null) for that source (use queries like getByText or queryByText) and unmount after each assertion so regressions in SourceBadge's mapping are caught.web/src/pages/settings/DependencyIndicator.tsx (1)
17-20: 🛠️ Refactor suggestion | 🟠 MajorReplace hardcoded badge font size with tokenized sizing (already flagged previously).
Line 18 still uses
text-[10px]. Please switch to a semantic/token class (e.g.,text-xs) for consistency with design-system constraints.♻️ Proposed fix
- 'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium bg-accent/5 text-text-muted', + 'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium bg-accent/5 text-text-muted',As per coding guidelines, "Use design tokens exclusively for colors, typography, and spacing -- never ... pixel values ... in
.tsx/.tsfiles."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/DependencyIndicator.tsx` around lines 17 - 20, The badge in DependencyIndicator (className inside the component rendering the badge) uses a hardcoded utility text-[10px]; replace that with a semantic token class (e.g., text-xs or the project's equivalent token) to comply with design-system typography tokens, so update the class list in the component where className={cn(...)} to remove text-[10px] and use the tokenized class instead while keeping the other classes intact.web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx (1)
28-32:⚠️ Potential issue | 🟡 MinorUse a semantic hidden-state assertion.
container.querySelector('[class*="sticky"]')couples this test to a Tailwind implementation detail. Assert that the visible bar actions/content are absent, or query a stable test id on the bar instead.Suggested change
- const { container } = render(<FloatingSaveBar {...defaultProps} dirtyCount={0} />) + render(<FloatingSaveBar {...defaultProps} dirtyCount={0} />) expect(screen.queryByText(/unsaved/)).not.toBeInTheDocument() - // The ConfirmDialog is still mounted but not open, so only check for visible bar content - expect(container.querySelector('[class*="sticky"]')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /discard/i })).not.toBeInTheDocument()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx` around lines 28 - 32, The test couples to a CSS implementation detail by querying container.querySelector('[class*="sticky"]'); update the assertion to use the FloatingSaveBar's semantic output instead: query for visible bar content or actions (e.g., button text like "Save" or a stable data-testid on FloatingSaveBar) and assert those are not in the document when dirtyCount is 0; if needed, add a stable test id to the FloatingSaveBar component (e.g., data-testid="floating-save-bar") and replace the CSS query with screen.queryByTestId('floating-save-bar') or screen.queryByText('Save') to check absence, while leaving the ConfirmDialog mounting comment intact.web/src/__tests__/components/ui/TagInput.test.tsx (1)
35-36:⚠️ Potential issue | 🟡 MinorAssert the remove-button count before indexing.
removeButtons[1]!turns DOM drift into a runtime exception instead of a clear test failure. Check the length first, then click the second button.Suggested change
render(<TagInput value={['a', 'b', 'c']} onChange={onChange} />) const removeButtons = screen.getAllByRole('button', { name: /remove/i }) - await user.click(removeButtons[1]!) // Remove 'b' + expect(removeButtons).toHaveLength(3) + await user.click(removeButtons[1]) // Remove 'b' expect(onChange).toHaveBeenCalledWith(['a', 'c'])🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/components/ui/TagInput.test.tsx` around lines 35 - 36, The test currently indexes removeButtons[1] after calling screen.getAllByRole('button', { name: /remove/i }) which can throw if the DOM changed; before calling user.click(removeButtons[1]!), assert the array length (e.g., expect(removeButtons).toHaveLength or expect(removeButtons.length).toBeGreaterThanOrEqual(2)) to produce a clear test failure instead of a runtime exception, then perform user.click on the second button; reference the removeButtons variable, screen.getAllByRole and the user.click call when making the change.web/src/components/ui/tag-input.tsx (3)
17-23:⚠️ Potential issue | 🟡 MinorDeduplicate within the incoming batch.
This only filters against the existing
value, so a paste likea,aora\nastill appends duplicates in one update. Normalize against both the current tags and items already accepted in this batch before callingonChange.Suggested change
const addItems = useCallback( (items: string[]) => { - const trimmed = items.map((s) => s.trim()).filter(Boolean) - const unique = trimmed.filter((item) => !value.includes(item)) - if (unique.length > 0) { - onChange([...value, ...unique]) + const seen = new Set(value) + const nextItems: string[] = [] + for (const item of items.map((s) => s.trim()).filter(Boolean)) { + if (!seen.has(item)) { + seen.add(item) + nextItems.push(item) + } + } + if (nextItems.length > 0) { + onChange([...value, ...nextItems]) } },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.tsx` around lines 17 - 23, The addItems callback currently only filters new items against existing value, so duplicates inside the incoming items (e.g., "a,a" or repeated lines) can still be added; update addItems to normalize the incoming batch by trimming and filtering, then remove duplicates present either in value or earlier in the same batch (e.g., by building a Set or tracking seen items) before constructing unique and calling onChange([...value, ...unique]). Ensure you reference the existing symbols addItems, value, and onChange when making the change.
75-112: 🛠️ Refactor suggestion | 🟠 MajorExtract the chip renderer and drop the
80pxliteral.The chip body inside
value.map()is already a large JSX branch, andmin-w-[80px]hardcodes spacing in a shared UI component. Pull the chip into a small helper/component and switch to a token/Tailwind width utility such asmin-w-20ormin-w-24.As per coding guidelines, "Do not create complex (>8 line) JSX inside
.map()-- extract to a shared component instead." and "Use design tokens exclusively for colors, typography, and spacing -- never hardcode hex values,rgba()values with hardcoded colors, pixel values for layout spacing, orfontFamilydeclarations directly in.tsx/.tsfiles."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.tsx` around lines 75 - 112, The chip JSX inside the value.map in tag-input.tsx is too large and the input uses a hardcoded min-w-[80px]; extract the mapped chip into a small helper/component (e.g., TagChip or Chip) that accepts props {item, index, occurrence, disabled, onRemove} and moves the span/button/aria logic out of the .map(), then call that component from value.map (keep using removeAt(i) by forwarding a handler). Also replace the input className min-w-[80px] with a tokenized Tailwind utility like min-w-20 or min-w-24 to remove the hardcoded pixel value. Ensure you preserve refs (inputRef), state bindings (draft, setDraft), and handlers (handleKeyDown, handlePaste).
64-113:⚠️ Potential issue | 🟠 MajorReplace the fake listbox semantics.
This is a freeform chip editor, but
role="listbox"/role="option"promises option navigation and selection semantics. The nested remove buttons and the textbox make that pattern invalid, andaria-label="Tag list"still does not give the textbox an accessible name. Use neutral group/list semantics here, or implement a real combobox/listbox pattern and exposearia-label/aria-labelledbyfor the input.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.tsx` around lines 64 - 113, The component currently misuses listbox semantics: change the container div's role from "listbox" to a neutral grouping such as role="list" (or remove role entirely) and change each tag span from role="option" to role="listitem" and remove aria-selected; then give the text input a proper accessible name by adding aria-label or aria-labelledby (pass through a prop or use the existing placeholder prop) on the input element (refer to the outer div where role="listbox" is set, the tag span elements using role="option", and the input element with ref inputRef and handlers handleKeyDown/handlePaste).web/src/components/ui/tag-input.stories.tsx (2)
14-42: 🛠️ Refactor suggestion | 🟠 MajorAdd the missing shared-component story states.
This new shared UI control still lacks the required hover, loading, and error variants, so Storybook does not cover the full review contract for the component. Add them, or explicitly document why those states are intentionally not applicable here.
Based on learnings, "All story files must include a
.stories.tsxfile alongside new shared components with all states (default, hover, loading, error, empty)."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.stories.tsx` around lines 14 - 42, The story file for TagInput is missing the required hover, loading, and error story variants; add Story objects named Hover, Loading, and Error (similar pattern to Default/Empty/ManyTags) that pass appropriate args to the TagInput component (e.g., simulate hover state via a prop or play function, set a loading prop or state for Loading, and an error prop/message for Error) or add a short comment/documentation in the file asserting why hover/loading/error are not applicable; reference the existing Story exports (Default, Empty, Disabled, ManyTags) and the TagInput component when adding or documenting these states so Storybook fulfils the review contract.
14-42:⚠️ Potential issue | 🟡 MinorUse one controlled render helper for every interactive story.
Default/Empty/ManyTagsfreezeargs.valueafter the initial render, andDisableddoes not pass the requiredonChangeprop at all. A shared controlled render wrapper (useArgsoruseEffect+useState) fixes both the stale Controls behavior and the missing handler.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.stories.tsx` around lines 14 - 42, The Default, Empty, and ManyTags stories create a local state from args.value which becomes stale and Disabled omits the required onChange; replace those per-story render blocks with a single controlled render helper (e.g., a RenderWithControl function used by Default/Empty/ManyTags/Disabled) that uses useArgs (or useEffect + useState) to keep args in sync and always passes an onChange handler to TagInput, referencing the existing story names Default/Empty/ManyTags/Disabled and the TagInput component so the Controls stay live and the component always receives onChange even when disabled.web/src/pages/settings/NamespaceSection.tsx (1)
114-142: 🛠️ Refactor suggestion | 🟠 MajorExtract the group block out of
renderGroups().This helper now contains two large JSX branches inside
.map(), which makes the header/animation split harder to maintain. Pull the group shell into a small helper/component and keep the mapped body shallow.As per coding guidelines, "Do not create complex (>8 line) JSX inside
.map()-- extract to a shared component instead."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/NamespaceSection.tsx` around lines 114 - 142, The renderGroups() function currently builds a full group shell and two large JSX branches inside the .map(), so extract the repeated group container into a small component (e.g., GroupBlock or GroupSection) that accepts props: group (string), groupEntries (array), hideHeader (boolean), anim (object) and renderRow (function). Move the surrounding <div key={group} ...> and optional <h3> header into that new component, and keep the .map() body shallow by mapping only the entries to either simple <div key=...>{renderRow(entry)}</div> or a single mapped motion wrapper inside the new component; preserve the existing keys ( `${entry.definition.namespace}/${entry.definition.key}` ), the conditional header logic, and the animation props (initial/animate/transition) when hideHeader is false. Ensure renderGroups() now returns groups.entries().map(([group, groupEntries]) => <GroupBlock ... />) so the JSX inside the .map() is minimal and easy to maintain.web/src/__tests__/hooks/useSettingsKeyboard.test.ts (1)
5-48:⚠️ Potential issue | 🟡 MinorAdd Cmd (
metaKey) coverage for macOS shortcut parity.The suite validates
Ctrl+S/Ctrl+/but missesCmd+S/Cmd+/, which leaves cross-platform shortcut behavior unverified.✅ Suggested test additions
+ it('calls onSave on Cmd+S when canSave is true', () => { + const onSave = vi.fn() + const onSearchFocus = vi.fn() + renderHook(() => + useSettingsKeyboard({ onSave, onSearchFocus, canSave: true }), + ) + const event = new KeyboardEvent('keydown', { + key: 's', + metaKey: true, + bubbles: true, + }) + document.dispatchEvent(event) + expect(onSave).toHaveBeenCalledOnce() + }) + + it('calls onSearchFocus on Cmd+/', () => { + const onSave = vi.fn() + const onSearchFocus = vi.fn() + renderHook(() => + useSettingsKeyboard({ onSave, onSearchFocus, canSave: false }), + ) + const event = new KeyboardEvent('keydown', { + key: '/', + metaKey: true, + bubbles: true, + }) + document.dispatchEvent(event) + expect(onSearchFocus).toHaveBeenCalledOnce() + })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/hooks/useSettingsKeyboard.test.ts` around lines 5 - 48, Update the tests in useSettingsKeyboard.test.ts to also assert macOS Cmd behavior by adding cases that dispatch KeyboardEvent with metaKey: true (instead of ctrlKey) for both 's' and '/' keys; specifically add tests that render useSettingsKeyboard({ onSave, onSearchFocus, canSave: true }) and dispatch a keydown with key: 's', metaKey: true and expect onSave to have been called, and another that renders with canSave: false and dispatches key: '/', metaKey: true and expects onSearchFocus to have been called, referencing the existing onSave, onSearchFocus, and useSettingsKeyboard test setup so behavior parity with Ctrl shortcuts is covered.web/src/hooks/useSettingsKeyboard.ts (1)
19-25: 🧹 Nitpick | 🔵 TrivialConsider case-insensitive key matching.
The key comparison
e.key === 's'may fail when Caps Lock is active (returns'S'). Usinge.key.toLowerCase()ensures consistent behavior.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/hooks/useSettingsKeyboard.ts` around lines 19 - 25, Change the direct key comparison to be case-insensitive: replace the check in useSettingsKeyboard (the block using e.key === 's') with e.key?.toLowerCase() === 's' so Caps Lock or Shift won't break saving; keep the existing e.preventDefault(), canSave and onSave invocation intact and leave the '/' check as-is for onSearchFocus.web/src/__tests__/pages/settings/RestartBanner.test.tsx (1)
29-32: 🧹 Nitpick | 🔵 TrivialTest name doesn't match the assertion.
The test is named
'has warning role for accessibility'but assertsrole="alert". Consider aligning the name with the assertion.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/pages/settings/RestartBanner.test.tsx` around lines 29 - 32, Rename the test description to match the assertion: update the it(...) string in RestartBanner.test.tsx from "has warning role for accessibility" to "has alert role for accessibility" (the test renders <RestartBanner count={1} .../> and asserts screen.getByRole('alert')) so the name accurately reflects the checked role on RestartBanner.web/src/pages/settings/SettingsHealthSection.stories.tsx (1)
39-45:⚠️ Potential issue | 🟡 MinorAdd missing
onSelecthandler toNamespaceSelectedstory.The
NamespaceSelectedstory is missing theonSelectprop that's provided inAllActive. This may cause TypeScript errors or runtime issues ifonSelectis required byNamespaceTabBar.Proposed fix
export const NamespaceSelected: Story = { args: { namespaces, activeNamespace: 'budget', namespaceCounts: counts, + onSelect: () => {}, }, }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/SettingsHealthSection.stories.tsx` around lines 39 - 45, The NamespaceSelected Story is missing the onSelect handler required by NamespaceTabBar; mirror the AllActive story by adding an onSelect prop to NamespaceSelected (e.g., the same callback or a noop) so TypeScript/runtime errors are avoided. Update the NamespaceSelected args to include onSelect (matching the function used in AllActive) to satisfy NamespaceTabBar's required prop.web/src/components/ui/code-mirror-editor.tsx (1)
24-35: 🧹 Nitpick | 🔵 TrivialLGTM — clear JSDoc documentation for the new extension points.
The
extensionsandonViewReadyprops are well-documented and provide a clean API for parent components to inject CodeMirror behavior. The ref-based callback pattern foronViewReadyavoids recreating the mount effect.Consider adding a
@remarksnote recommendinguseMemofor theextensionsarray to avoid unnecessary reconfiguration on re-renders when parents pass inline arrays.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/code-mirror-editor.tsx` around lines 24 - 35, Add a `@remarks` to the existing JSDoc for the extensions prop in the CodeMirrorEditor component recommending callers memoize the extensions array (e.g., via React's useMemo) to prevent unnecessary reconfiguration on re-renders; update the comment block that documents "extensions?: Extension[]" and reference the onViewReady prop briefly so consumers understand the intended usage pattern.
🤖 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/synthorg/providers/health_prober.py`:
- Around line 274-278: Add an explicit runtime assertion that config.base_url is
not None before calling _build_ping_url (e.g., in the body of _probe_one)
instead of using the "# type: ignore[arg-type]" suppression; assert the
precondition, then call _build_ping_url(config.base_url,
config.litellm_provider) and remove the type ignore so static typing and runtime
safety match the guarantee already enforced by _probe_all.
In `@web/src/api/types.ts`:
- Around line 1376-1380: The SinkRotation.type currently uses a broad string for
strategy; change SinkRotation.strategy to the string-literal union that matches
the backend RotationStrategy enum (i.e., 'builtin' | 'external') so TypeScript
enforces valid values; update the interface named SinkRotation (and any related
type usages) to use this union instead of plain string to improve type safety
and prevent invalid assignments.
In `@web/src/pages/settings/editor-extensions.ts`:
- Around line 547-642: The backward scan in jsonCompletionSource incorrectly
counts braces that appear inside string literals (variables braceDepth and
currentNamespace), so modify the scan loop to track string boundaries and
escaped quotes: maintain an inString boolean toggled when encountering unescaped
double quotes and ignore '{' and '}' when inString, and handle backslash escapes
so \" doesn't toggle inString; keep the rest of the logic (valueMatch/keyMatch,
currentNamespace extraction) unchanged so suggestions still use schema.keys and
schema.namespaces.
In `@web/src/pages/settings/FloatingSaveBar.tsx`:
- Line 33: In the FloatingSaveBar component locate the className string that
contains "shadow-lg" and replace that hardcoded Tailwind shadow with the
design-token-based shadow, e.g. remove "shadow-lg" and use the CSS variable
token form such as "shadow-[var(--so-shadow-card-hover)]" (or the project’s
equivalent token class) so the component uses var(--so-shadow-card-hover) for
elevation instead of a fixed shadow utility.
In `@web/src/pages/settings/NamespaceSection.tsx`:
- Around line 147-159: The header currently renders a <button> that contains an
<h2> (see JSX around hideHeader, forceOpen, setCollapsed, isOpen, contentId,
icon, displayName), which is invalid because button content must be phrasing
content and breaks assistive-tech heading navigation; change the markup so the
heading wraps the button (e.g., render <h2> as the outer element and move the
button inside it) or replace the <h2> with a span if you must keep the button
outermost, and ensure aria-expanded and aria-controls remain on the interactive
element and the visual classes (p-card, text-left, etc.) are moved appropriately
to preserve styling and layout.
In `@web/src/pages/settings/SearchInput.stories.tsx`:
- Around line 5-9: The Storybook story meta for SearchInput should enforce
accessibility testing; update the meta object (named meta for SearchInput) to
include parameters.a11y.test set to 'error' so Storybook 10 will run WCAG checks
(i.e., extend the existing parameters entry on the meta object to include a11y:
{ test: 'error' }).
In `@web/src/pages/settings/SettingField.tsx`:
- Around line 29-46: ArraySettingField currently renders TagInput but never runs
per-item validation (validate()/validator_pattern), so invalid tags pass
locally; update ArraySettingField to validate each item produced by
parseArrayItems(value) against the setting's validator_pattern (use the same
logic as validate()/isArraySetting) and surface errors to TagInput (e.g., via an
error prop) or prevent adding invalid items and avoid calling onChange with
invalid data; apply the same change to the sibling tag-backed array handler at
the 129-135 block so both use validate()/validator_pattern and consistently
show/prevent validation errors before save.
- Around line 16-26: The parseArrayItems function currently calls JSON.parse on
blank or whitespace-only strings which leads to unnecessary warnings; modify
parseArrayItems to short-circuit and return an empty array when value is empty
or only whitespace (e.g., check if value.trim() === '' and return []), then
proceed with the existing JSON.parse logic and fallback; reference the
parseArrayItems function to implement this pre-parse check so unset array
settings are treated as empty arrays without logging errors.
In `@web/src/pages/settings/SettingRow.tsx`:
- Around line 77-80: The container currently applies controllerDisabled to add
'pointer-events-none', which prevents the title tooltip from being reachable;
remove or avoid adding 'pointer-events-none' when composing the className for
the main wrapper in SettingRow (where controllerDisabled is used) so the
title={controllerDisabled ? 'Enable the parent setting to configure this option'
: undefined} can be hovered, but keep the visual disabled styles (e.g.,
opacity-50, cursor-not-allowed) so the row still looks disabled; update the
class composition logic around controllerDisabled to only apply visual classes
and not pointer-events-none.
In `@web/src/pages/settings/SettingsHealthSection.tsx`:
- Around line 70-93: Extract the JSX inside visibleNamespaces.map into a new
presentational component (e.g., NamespaceTabButton) that accepts props { ns,
count, icon, activeNamespace, onSelect } and uses NAMESPACE_DISPLAY_NAMES,
namespaceCounts/namespaceIcons values and cn for classes; replace the inline map
return with a single <NamespaceTabButton key={ns} .../> per ns, moving the
button markup and logic (aria-selected, tabIndex, onClick, conditional classes
and icon/rendering) into the new component so the .map callback stays simple and
under the mapped-JSX limit.
- Around line 45-46: Compute an effectiveNamespace fallback when the current
activeNamespace is not present in visibleNamespaces (e.g., const
effectiveNamespace = visibleNamespaces.includes(activeNamespace) ?
activeNamespace : "All") and use that effectiveNamespace for selection/tabIndex
logic; update the tablist so that the "All" button and the mapped namespace
buttons use effectiveNamespace (not activeNamespace) for aria-selected and
tabIndex decisions, ensuring "All" becomes focusable and selected when the
previously active namespace is filtered out.
In `@web/src/pages/settings/sinks/SinkCard.tsx`:
- Around line 54-72: Replace the hardcoded utility class "text-[10px]" with the
semantic design token class "text-micro" in the SinkCard JSX; update the span
showing sink.level (className that includes levelColor), the span for JSON/Text
({sink.json_format ? 'JSON' : 'Text'}), the "Default" span ({sink.is_default}),
the rotation paragraph (Rotation: ...), and the routes paragraph (Routes: ...
title={sink.routing_prefixes.join(', ')}), keeping all other class names and
markup intact.
In `@web/src/pages/settings/sinks/SinkFormDrawer.tsx`:
- Around line 113-117: In handleSave, the rotation object built from
rotationStrategy, maxBytes and backupCount can contain NaN because
Number(maxBytes) and Number(backupCount) are used without validation; update
handleSave to validate that maxBytes and backupCount are present and parse to
positive integers when rotationStrategy !== 'none' (e.g., use parseInt/Number
and isFinite checks and > 0), show a validation error or prevent save if
invalid, and only assign rotation.max_bytes and rotation.backup_count when the
parsed values are valid (or provide sensible defaults) so the saved rotation
object never contains NaN or invalid numbers.
In `@web/src/stores/sinks.ts`:
- Around line 38-57: In saveSink, the custom-sink branch currently overwrites
all custom_sinks by sending JSON.stringify([custom]) to updateSetting; change it
to first read existing custom_sinks (via the same settings API or
get().fetchSinks()/current store state), parse the JSON array, merge or replace
the entry matching custom.file_path (or append if not present), then
JSON.stringify the updated array and pass that to
updateSetting('observability','custom_sinks', ...); keep the existing
default-sink behavior (overrides path using buildOverrideForSink) and ensure you
still call get().fetchSinks() after the update and preserve the error handling
in saveSink.
---
Outside diff comments:
In `@src/synthorg/observability/sink_config_builder.py`:
- Around line 316-343: Normalize and reuse the trimmed/normalized file path
instead of raw_path when building the SinkConfig: compute a single normalized
path (e.g., strip surrounding whitespace and apply os.path.normpath or
equivalent) from raw_path, run the existing validations against that normalized
value, and pass the normalized value to SinkConfig(file_path=...) (symbols:
raw_path, SinkConfig, file_path, SinkType.FILE); ensure the variable used for
checks and rotation logic is the normalized path so downstream conflict
semantics behave as expected.
In `@src/synthorg/providers/health.py`:
- Around line 166-191: The _aggregate_records function currently can raise a
ZeroDivisionError when called with an empty records list; change it to fail-fast
with a clear ValueError by adding an explicit guard at the top of
_aggregate_records that checks if records is empty and raises
ValueError("records must be a non-empty list") (or similar message) so callers
of ProviderHealthSummary construction (avg_response_time_ms,
error_rate_percent_24h, calls_last_24h) receive a clear, descriptive exception
instead of a ZeroDivisionError.
In `@web/src/styles/design-tokens.css`:
- Around line 89-97: Update the density-switch guidance so it lists all
available classes: modify the sentence that currently reads "Switch density by
adding .density-dense or .density-sparse to <html>" to include .density-medium
(e.g., ".density-dense, .density-medium, or .density-sparse"), and verify the
preceding/following lines referencing :root and the density level bullets
(Dense, Balanced, Medium, Sparse) remain consistent with the class names
.density-dense, .density-medium, .density-sparse and the default :root behavior.
---
Duplicate comments:
In `@web/src/__tests__/components/ui/TagInput.test.tsx`:
- Around line 35-36: The test currently indexes removeButtons[1] after calling
screen.getAllByRole('button', { name: /remove/i }) which can throw if the DOM
changed; before calling user.click(removeButtons[1]!), assert the array length
(e.g., expect(removeButtons).toHaveLength or
expect(removeButtons.length).toBeGreaterThanOrEqual(2)) to produce a clear test
failure instead of a runtime exception, then perform user.click on the second
button; reference the removeButtons variable, screen.getAllByRole and the
user.click call when making the change.
In `@web/src/__tests__/hooks/useSettingsKeyboard.test.ts`:
- Around line 5-48: Update the tests in useSettingsKeyboard.test.ts to also
assert macOS Cmd behavior by adding cases that dispatch KeyboardEvent with
metaKey: true (instead of ctrlKey) for both 's' and '/' keys; specifically add
tests that render useSettingsKeyboard({ onSave, onSearchFocus, canSave: true })
and dispatch a keydown with key: 's', metaKey: true and expect onSave to have
been called, and another that renders with canSave: false and dispatches key:
'/', metaKey: true and expects onSearchFocus to have been called, referencing
the existing onSave, onSearchFocus, and useSettingsKeyboard test setup so
behavior parity with Ctrl shortcuts is covered.
In `@web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx`:
- Around line 28-32: The test couples to a CSS implementation detail by querying
container.querySelector('[class*="sticky"]'); update the assertion to use the
FloatingSaveBar's semantic output instead: query for visible bar content or
actions (e.g., button text like "Save" or a stable data-testid on
FloatingSaveBar) and assert those are not in the document when dirtyCount is 0;
if needed, add a stable test id to the FloatingSaveBar component (e.g.,
data-testid="floating-save-bar") and replace the CSS query with
screen.queryByTestId('floating-save-bar') or screen.queryByText('Save') to check
absence, while leaving the ConfirmDialog mounting comment intact.
In `@web/src/__tests__/pages/settings/RestartBanner.test.tsx`:
- Around line 29-32: Rename the test description to match the assertion: update
the it(...) string in RestartBanner.test.tsx from "has warning role for
accessibility" to "has alert role for accessibility" (the test renders
<RestartBanner count={1} .../> and asserts screen.getByRole('alert')) so the
name accurately reflects the checked role on RestartBanner.
In `@web/src/__tests__/pages/settings/SourceBadge.test.tsx`:
- Around line 44-50: The test "covers all SettingSource values" currently only
renders and unmounts; replace the loop in the it block to assert actual behavior
for each SettingSource by mapping expected outputs and verifying SourceBadge
renders the expected label or nothing: use the SettingSource[] values
('db','env','yaml','default') and for each call render(<SourceBadge
source={source} />) then assert the DOM contains the expected text (or is
empty/null) for that source (use queries like getByText or queryByText) and
unmount after each assertion so regressions in SourceBadge's mapping are caught.
In `@web/src/components/ui/code-mirror-editor.tsx`:
- Around line 24-35: Add a `@remarks` to the existing JSDoc for the extensions
prop in the CodeMirrorEditor component recommending callers memoize the
extensions array (e.g., via React's useMemo) to prevent unnecessary
reconfiguration on re-renders; update the comment block that documents
"extensions?: Extension[]" and reference the onViewReady prop briefly so
consumers understand the intended usage pattern.
In `@web/src/components/ui/tag-input.stories.tsx`:
- Around line 14-42: The story file for TagInput is missing the required hover,
loading, and error story variants; add Story objects named Hover, Loading, and
Error (similar pattern to Default/Empty/ManyTags) that pass appropriate args to
the TagInput component (e.g., simulate hover state via a prop or play function,
set a loading prop or state for Loading, and an error prop/message for Error) or
add a short comment/documentation in the file asserting why hover/loading/error
are not applicable; reference the existing Story exports (Default, Empty,
Disabled, ManyTags) and the TagInput component when adding or documenting these
states so Storybook fulfils the review contract.
- Around line 14-42: The Default, Empty, and ManyTags stories create a local
state from args.value which becomes stale and Disabled omits the required
onChange; replace those per-story render blocks with a single controlled render
helper (e.g., a RenderWithControl function used by
Default/Empty/ManyTags/Disabled) that uses useArgs (or useEffect + useState) to
keep args in sync and always passes an onChange handler to TagInput, referencing
the existing story names Default/Empty/ManyTags/Disabled and the TagInput
component so the Controls stay live and the component always receives onChange
even when disabled.
In `@web/src/components/ui/tag-input.tsx`:
- Around line 17-23: The addItems callback currently only filters new items
against existing value, so duplicates inside the incoming items (e.g., "a,a" or
repeated lines) can still be added; update addItems to normalize the incoming
batch by trimming and filtering, then remove duplicates present either in value
or earlier in the same batch (e.g., by building a Set or tracking seen items)
before constructing unique and calling onChange([...value, ...unique]). Ensure
you reference the existing symbols addItems, value, and onChange when making the
change.
- Around line 75-112: The chip JSX inside the value.map in tag-input.tsx is too
large and the input uses a hardcoded min-w-[80px]; extract the mapped chip into
a small helper/component (e.g., TagChip or Chip) that accepts props {item,
index, occurrence, disabled, onRemove} and moves the span/button/aria logic out
of the .map(), then call that component from value.map (keep using removeAt(i)
by forwarding a handler). Also replace the input className min-w-[80px] with a
tokenized Tailwind utility like min-w-20 or min-w-24 to remove the hardcoded
pixel value. Ensure you preserve refs (inputRef), state bindings (draft,
setDraft), and handlers (handleKeyDown, handlePaste).
- Around line 64-113: The component currently misuses listbox semantics: change
the container div's role from "listbox" to a neutral grouping such as
role="list" (or remove role entirely) and change each tag span from
role="option" to role="listitem" and remove aria-selected; then give the text
input a proper accessible name by adding aria-label or aria-labelledby (pass
through a prop or use the existing placeholder prop) on the input element (refer
to the outer div where role="listbox" is set, the tag span elements using
role="option", and the input element with ref inputRef and handlers
handleKeyDown/handlePaste).
In `@web/src/hooks/useSettingsKeyboard.ts`:
- Around line 19-25: Change the direct key comparison to be case-insensitive:
replace the check in useSettingsKeyboard (the block using e.key === 's') with
e.key?.toLowerCase() === 's' so Caps Lock or Shift won't break saving; keep the
existing e.preventDefault(), canSave and onSave invocation intact and leave the
'/' check as-is for onSearchFocus.
In `@web/src/pages/settings/DependencyIndicator.tsx`:
- Around line 17-20: The badge in DependencyIndicator (className inside the
component rendering the badge) uses a hardcoded utility text-[10px]; replace
that with a semantic token class (e.g., text-xs or the project's equivalent
token) to comply with design-system typography tokens, so update the class list
in the component where className={cn(...)} to remove text-[10px] and use the
tokenized class instead while keeping the other classes intact.
In `@web/src/pages/settings/NamespaceSection.tsx`:
- Around line 114-142: The renderGroups() function currently builds a full group
shell and two large JSX branches inside the .map(), so extract the repeated
group container into a small component (e.g., GroupBlock or GroupSection) that
accepts props: group (string), groupEntries (array), hideHeader (boolean), anim
(object) and renderRow (function). Move the surrounding <div key={group} ...>
and optional <h3> header into that new component, and keep the .map() body
shallow by mapping only the entries to either simple <div
key=...>{renderRow(entry)}</div> or a single mapped motion wrapper inside the
new component; preserve the existing keys (
`${entry.definition.namespace}/${entry.definition.key}` ), the conditional
header logic, and the animation props (initial/animate/transition) when
hideHeader is false. Ensure renderGroups() now returns
groups.entries().map(([group, groupEntries]) => <GroupBlock ... />) so the JSX
inside the .map() is minimal and easy to maintain.
In `@web/src/pages/settings/SettingsHealthSection.stories.tsx`:
- Around line 39-45: The NamespaceSelected Story is missing the onSelect handler
required by NamespaceTabBar; mirror the AllActive story by adding an onSelect
prop to NamespaceSelected (e.g., the same callback or a noop) so
TypeScript/runtime errors are avoided. Update the NamespaceSelected args to
include onSelect (matching the function used in AllActive) to satisfy
NamespaceTabBar's required prop.
In `@web/src/pages/settings/SourceBadge.stories.tsx`:
- Around line 15-16: Rename the misleading story ConfigFile to something
explicit like YamlRendersNull (or YamlSourceRendersNull) since args: { source:
'yaml' } renders nothing, and update the DefaultRendersNull story comment/text
to state that both 'default' and 'yaml' sources render null; adjust any exported
names or references (ConfigFile -> YamlRendersNull) so imports/exports remain
consistent and the comment accurately reflects that both source: 'default' and
source: 'yaml' produce null output.
🪄 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: Repository UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 4177e9bc-0feb-4995-a434-21e46ed361c4
⛔ Files ignored due to path filters (1)
web/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (64)
CLAUDE.mddocs/design/brand-and-ux.mddocs/design/operations.mddocs/design/page-structure.mdsrc/synthorg/api/app.pysrc/synthorg/api/controllers/settings.pysrc/synthorg/api/lifecycle.pysrc/synthorg/observability/sink_config_builder.pysrc/synthorg/providers/capabilities.pysrc/synthorg/providers/health.pysrc/synthorg/providers/health_prober.pysrc/synthorg/settings/definitions/api.pytests/unit/api/controllers/test_provider_health.pytests/unit/api/controllers/test_settings_sinks.pytests/unit/providers/test_health.pytests/unit/providers/test_health_prober.pyweb/CLAUDE.mdweb/package.jsonweb/src/__tests__/components/layout/Sidebar.test.tsxweb/src/__tests__/components/ui/TagInput.test.tsxweb/src/__tests__/hooks/useSettingsKeyboard.test.tsweb/src/__tests__/pages/LoginPage.test.tsxweb/src/__tests__/pages/SettingsPage.test.tsxweb/src/__tests__/pages/settings/FloatingSaveBar.test.tsxweb/src/__tests__/pages/settings/RestartBanner.test.tsxweb/src/__tests__/pages/settings/SettingsHealthSection.test.tsxweb/src/__tests__/pages/settings/SourceBadge.test.tsxweb/src/__tests__/pages/settings/sinks/SinkCard.test.tsxweb/src/__tests__/pages/tasks/TaskDetailPanel.test.tsxweb/src/__tests__/stores/sinks.test.tsweb/src/api/endpoints/settings.tsweb/src/api/types.tsweb/src/components/ui/code-mirror-editor.tsxweb/src/components/ui/tag-input.stories.tsxweb/src/components/ui/tag-input.tsxweb/src/hooks/useSettingsKeyboard.tsweb/src/pages/SettingsPage.tsxweb/src/pages/SettingsSinksPage.tsxweb/src/pages/settings/CodeEditorPanel.tsxweb/src/pages/settings/DependencyIndicator.tsxweb/src/pages/settings/FloatingSaveBar.stories.tsxweb/src/pages/settings/FloatingSaveBar.tsxweb/src/pages/settings/NamespaceSection.tsxweb/src/pages/settings/RestartBadge.tsxweb/src/pages/settings/RestartBanner.stories.tsxweb/src/pages/settings/RestartBanner.tsxweb/src/pages/settings/SearchInput.stories.tsxweb/src/pages/settings/SearchInput.tsxweb/src/pages/settings/SettingField.tsxweb/src/pages/settings/SettingRow.stories.tsxweb/src/pages/settings/SettingRow.tsxweb/src/pages/settings/SettingsHealthSection.stories.tsxweb/src/pages/settings/SettingsHealthSection.tsxweb/src/pages/settings/SourceBadge.stories.tsxweb/src/pages/settings/SourceBadge.tsxweb/src/pages/settings/editor-extensions.tsweb/src/pages/settings/sinks/SinkCard.stories.tsxweb/src/pages/settings/sinks/SinkCard.tsxweb/src/pages/settings/sinks/SinkFormDrawer.tsxweb/src/router/index.tsxweb/src/router/routes.tsweb/src/stores/sinks.tsweb/src/styles/design-tokens.cssweb/src/utils/constants.ts
| # base_url is guaranteed non-None: _probe_all filters out | ||
| # providers without it before calling _probe_one. | ||
| url = _build_ping_url(config.base_url, config.litellm_provider) # type: ignore[arg-type] | ||
| auth_type = str(config.auth_type) | ||
| headers = _build_auth_headers(auth_type, config.api_key) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Consider removing the type ignore with an explicit assertion.
While the # type: ignore[arg-type] is justified by the filtering in _probe_all, an explicit assertion would provide runtime safety and eliminate the type suppression.
Optional refactor
- # base_url is guaranteed non-None: _probe_all filters out
- # providers without it before calling _probe_one.
- url = _build_ping_url(config.base_url, config.litellm_provider) # type: ignore[arg-type]
+ # base_url is guaranteed non-None: _probe_all filters out
+ # providers without it before calling _probe_one.
+ assert config.base_url is not None # filtered by _probe_all
+ url = _build_ping_url(config.base_url, config.litellm_provider)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/synthorg/providers/health_prober.py` around lines 274 - 278, Add an
explicit runtime assertion that config.base_url is not None before calling
_build_ping_url (e.g., in the body of _probe_one) instead of using the "# type:
ignore[arg-type]" suppression; assert the precondition, then call
_build_ping_url(config.base_url, config.litellm_provider) and remove the type
ignore so static typing and runtime safety match the guarantee already enforced
by _probe_all.
| function jsonCompletionSource( | ||
| schema: CompletionSchemaInfo, | ||
| ): (ctx: CompletionContext) => CompletionResult | null { | ||
| return (ctx: CompletionContext) => { | ||
| const text = ctx.state.doc.toString() | ||
| const pos = ctx.pos | ||
|
|
||
| // Get the text before cursor for context analysis | ||
| const before = text.slice(0, pos) | ||
|
|
||
| // Check if we're typing a key (after { or , and before :) | ||
| // Determine nesting depth to know if we're at namespace or key level | ||
| let braceDepth = 0 | ||
| let currentNamespace: string | null = null | ||
|
|
||
| // Walk backwards to determine context | ||
| for (let i = pos - 1; i >= 0; i--) { | ||
| const ch = text[i] | ||
| if (ch === '{') { | ||
| braceDepth++ | ||
| if (braceDepth === 1) { | ||
| // We're inside the root object -- suggest namespaces | ||
| break | ||
| } | ||
| if (braceDepth === 2) { | ||
| // We're inside a namespace object -- find which one | ||
| // Look backwards from this opening brace to find the key | ||
| const preceding = text.slice(0, i).trimEnd() | ||
| const nsMatch = /"(\w+)"\s*:\s*$/.exec(preceding) | ||
| if (nsMatch) { | ||
| currentNamespace = nsMatch[1] ?? null | ||
| } | ||
| break | ||
| } | ||
| } else if (ch === '}') { | ||
| braceDepth-- | ||
| } | ||
| } | ||
|
|
||
| // Check if we're in a string value position (after "key": ) | ||
| // Look for pattern: "someKey": "| (cursor in a value string) | ||
| const valueMatch = /"(\w+)"\s*:\s*"([^"]*?)$/.exec(before) | ||
| if (valueMatch && currentNamespace) { | ||
| const settingKey = valueMatch[1] ?? '' | ||
| const partial = valueMatch[2] ?? '' | ||
| const keyInfo = schema.keys.get(currentNamespace) | ||
| const setting = keyInfo?.find((k) => k.key === settingKey) | ||
| if (setting && setting.enumValues.length > 0) { | ||
| const from = pos - partial.length | ||
| return { | ||
| from, | ||
| options: setting.enumValues.map((val) => ({ | ||
| label: val, | ||
| type: 'enum', | ||
| detail: `${currentNamespace}/${settingKey}`, | ||
| })), | ||
| } | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| // Check if we're typing a key name (inside quotes at key position) | ||
| // Pattern: after { or , or newline, possibly whitespace, then "partial | ||
| const keyMatch = /(?:^|[{,])\s*"(\w*)$/.exec(before) | ||
| if (!keyMatch) return null | ||
|
|
||
| const partial = keyMatch[1] ?? '' | ||
| const from = pos - partial.length | ||
|
|
||
| if (braceDepth >= 2 && currentNamespace) { | ||
| // Inside a namespace -- suggest setting keys | ||
| const keyInfo = schema.keys.get(currentNamespace) | ||
| if (!keyInfo) return null | ||
| return { | ||
| from, | ||
| options: keyInfo.map((k) => ({ | ||
| label: k.key, | ||
| type: 'property', | ||
| detail: `${k.type}${k.enumValues.length > 0 ? ` (${k.enumValues.join(' | ')})` : ''}`, | ||
| info: k.description, | ||
| })), | ||
| } | ||
| } | ||
|
|
||
| // At root level -- suggest namespaces | ||
| return { | ||
| from, | ||
| options: schema.namespaces.map((ns) => ({ | ||
| label: ns, | ||
| type: 'keyword', | ||
| detail: 'namespace', | ||
| info: `Settings namespace: ${ns}`, | ||
| })), | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
JSON autocomplete context detection could miss edge cases.
The jsonCompletionSource walks backwards through braces to determine context depth, but doesn't account for braces inside string values. For example, a value like "some { text }" could throw off the brace counting.
This is a minor edge case since settings values rarely contain braces, but worth noting for robustness.
💡 Consider tracking string boundaries
For more robust context detection, consider skipping characters inside quoted strings when counting braces. However, given the controlled nature of settings documents, this may be over-engineering for the current use case.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/settings/editor-extensions.ts` around lines 547 - 642, The
backward scan in jsonCompletionSource incorrectly counts braces that appear
inside string literals (variables braceDepth and currentNamespace), so modify
the scan loop to track string boundaries and escaped quotes: maintain an
inString boolean toggled when encountering unescaped double quotes and ignore
'{' and '}' when inString, and handle backslash escapes so \" doesn't toggle
inString; keep the rest of the logic (valueMatch/keyMatch, currentNamespace
extraction) unchanged so suggestions still use schema.keys and
schema.namespaces.
| const visibleNamespaces = namespaces.filter((ns) => (namespaceCounts.get(ns) ?? 0) > 0) | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n web/src/pages/settings/SettingsHealthSection.tsx | sed -n '40,95p'Repository: Aureliolo/synthorg
Length of output: 2331
🏁 Script executed:
cat -n web/src/pages/settings/SettingsHealthSection.tsx | head -45Repository: Aureliolo/synthorg
Length of output: 1862
Ensure the tablist remains keyboard-accessible when the selected namespace is filtered out.
When activeNamespace is filtered from visibleNamespaces (count drops to zero), the "All" button gets tabIndex={-1} and all mapped namespace tabs also get tabIndex={-1}. This leaves keyboard users with no focusable element in the tablist.
Add fallback logic to select "All" when the active namespace is no longer visible:
Suggested change
const visibleNamespaces = namespaces.filter((ns) => (namespaceCounts.get(ns) ?? 0) > 0)
+ const effectiveNamespace =
+ activeNamespace !== null && !visibleNamespaces.includes(activeNamespace)
+ ? null
+ : activeNamespace
return (Then replace activeNamespace with effectiveNamespace in the aria-selected and tabIndex logic for both the "All" button (line 58–59) and the mapped namespace buttons (lines 78–79).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/settings/SettingsHealthSection.tsx` around lines 45 - 46,
Compute an effectiveNamespace fallback when the current activeNamespace is not
present in visibleNamespaces (e.g., const effectiveNamespace =
visibleNamespaces.includes(activeNamespace) ? activeNamespace : "All") and use
that effectiveNamespace for selection/tabIndex logic; update the tablist so that
the "All" button and the mapped namespace buttons use effectiveNamespace (not
activeNamespace) for aria-selected and tabIndex decisions, ensuring "All"
becomes focusable and selected when the previously active namespace is filtered
out.
| {visibleNamespaces.map((ns) => { | ||
| const count = namespaceCounts.get(ns) ?? 0 | ||
| const icon = namespaceIcons?.[ns] | ||
| return ( | ||
| <button | ||
| key={ns} | ||
| type="button" | ||
| role="tab" | ||
| aria-selected={activeNamespace === ns} | ||
| tabIndex={activeNamespace === ns ? 0 : -1} | ||
| onClick={() => onSelect(ns)} | ||
| className={cn( | ||
| 'inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-xs font-semibold transition-all duration-200', | ||
| activeNamespace === ns | ||
| ? 'bg-accent/10 text-accent' | ||
| : 'text-text-secondary hover:bg-card-hover hover:text-foreground', | ||
| )} | ||
| > | ||
| {icon && <span className="shrink-0">{icon}</span>} | ||
| {NAMESPACE_DISPLAY_NAMES[ns]} | ||
| <span className="font-normal text-text-muted">{count}</span> | ||
| </button> | ||
| ) | ||
| })} |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Extract the namespace tab button out of the map.
Each mapped namespace renders a full button tree, which already exceeds the repo's mapped-JSX limit. A small NamespaceTabButton helper would keep this list readable and easier to extend.
As per coding guidelines, "Do not create complex (>8 line) JSX inside .map() -- extract to a shared component instead."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/settings/SettingsHealthSection.tsx` around lines 70 - 93,
Extract the JSX inside visibleNamespaces.map into a new presentational component
(e.g., NamespaceTabButton) that accepts props { ns, count, icon,
activeNamespace, onSelect } and uses NAMESPACE_DISPLAY_NAMES,
namespaceCounts/namespaceIcons values and cn for classes; replace the inline map
return with a single <NamespaceTabButton key={ns} .../> per ns, moving the
button markup and logic (aria-selected, tabIndex, onClick, conditional classes
and icon/rendering) into the new component so the .map callback stays simple and
under the mapped-JSX limit.
716d07e to
b9b98d6
Compare
There was a problem hiding this comment.
Actionable comments posted: 17
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
src/synthorg/observability/sink_config_builder.py (1)
317-323:⚠️ Potential issue | 🟠 MajorNormalize
file_pathbefore constructingSinkConfig.Line 317 validates with
strip(), but Line 342 storesraw_pathunchanged. Leading/trailing whitespace can bypass intended collision semantics and create confusing duplicate paths.Suggested fix
- raw_path = entry["file_path"] - if not isinstance(raw_path, str) or not raw_path.strip(): + raw_path = entry["file_path"] + if not isinstance(raw_path, str): + msg = ( + f"custom_sinks[{index}].file_path must be a non-empty string, " + f"got {raw_path!r}" + ) + raise ValueError(msg) + file_path = raw_path.strip() + if not file_path: msg = ( f"custom_sinks[{index}].file_path must be a non-empty string, " f"got {raw_path!r}" ) raise ValueError(msg) @@ - file_path=raw_path, + file_path=file_path,Also applies to: 342-342
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/synthorg/observability/sink_config_builder.py` around lines 317 - 323, The code validates raw_path with raw_path.strip() but then passes the unnormalized raw_path into the SinkConfig, which allows leading/trailing whitespace to create duplicate/ambiguous paths; update the builder so that after validation you set normalized_path = raw_path.strip() (or similarly normalized value) and use that normalized_path when constructing the SinkConfig (and any downstream storage/keys) — change references to raw_path in the SinkConfig creation site (and the variable used around where level is parsed) to use the normalized value to ensure consistent collision semantics.src/synthorg/api/controllers/settings.py (1)
51-60:⚠️ Potential issue | 🟠 MajorThe generic update request is still too small for sink saves.
The new sinks UI persists
sink_overridesandcustom_sinksthrough this model, butvalueis still capped at 8192 characters while_testalready allows 65536. A few custom sinks with routing/rotation can validate successfully in_testand then fail on the actual save path.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/synthorg/api/controllers/settings.py` around lines 51 - 60, The UpdateSettingRequest model's value field is limited to 8192 characters causing sink saves to fail; update the Field definition on UpdateSettingRequest.value to allow at least 65536 characters (matching the _test path) so sink_overrides and custom_sinks can persist; locate the UpdateSettingRequest class and change the Field(max_length=8192, ...) to Field(max_length=65536, ...) (or remove the max_length restriction if you prefer unlimited saves) while keeping model_config unchanged.web/src/pages/SettingsPage.tsx (1)
358-395: 🛠️ Refactor suggestion | 🟠 MajorExtract the namespace renderer out of this
.map().This callback now contains the full
StaggerItem/ErrorBoundary/NamespaceSectiontree plus the observability footer CTA, which hurts readability and violates the repo rule for large mapped JSX. Pull it into a dedicated component/helper and pass the namespace-specific props in.As per coding guidelines, "Do not create complex (>8 line) JSX inside
.map()-- extract to a shared component instead."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/SettingsPage.tsx` around lines 358 - 395, The JSX inside the .map over NAMESPACE_ORDER is too large and should be extracted into a dedicated renderer component to improve readability; create a new component (e.g., NamespaceRenderer or renderNamespaceItem) that accepts props like ns, entries, dirtyValues, handleValueChange, storeSavingKeys, controllerDisabledMap, effectiveNamespace, searchQuery, changedKeys and returns the StaggerItem -> ErrorBoundary -> NamespaceSection tree including the conditional observability footer CTA; replace the inline map body with a simple .map(ns => <NamespaceRenderer key={ns} ns={ns} ... />) and ensure the observability footer logic (ns === 'observability') is kept inside the new component so NamespaceSection usage and prop names (displayName from NAMESPACE_DISPLAY_NAMES, icon from NAMESPACE_ICONS) remain unchanged.
♻️ Duplicate comments (18)
web/src/__tests__/pages/LoginPage.test.tsx (1)
186-186: 🧹 Nitpick | 🔵 TrivialPrefer fixing test speed instead of extending timeout.
Line 186 widens timeout to 15s for a simple validation flow; this can hide hangs and slows failure feedback. Keep the default timeout unless there’s a proven long-running path.
♻️ Proposed change
- it('admin creation validates password match', { timeout: 15000 }, async () => { + it('admin creation validates password match', async () => {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/pages/LoginPage.test.tsx` at line 186, The test "admin creation validates password match" currently overrides Jest's default timeout ({ timeout: 15000 }) — remove that timeout and make the test deterministic and fast instead: revert the it(...) call to the normal signature and ensure the test uses synchronous DOM updates (use userEvent.type or fireEvent.change, avoid arbitrary setTimeouts), await the actual DOM change with screen.findByText or waitFor with a short explicit timeout only if needed, and flush pending promises via await act or await waitFor(() => ...) so the password-mismatch validation assertion runs immediately; update the test block (the it(...) for "admin creation validates password match") to use these patterns so the extended timeout is no longer necessary.web/src/pages/settings/SearchInput.tsx (1)
72-80:⚠️ Potential issue | 🟡 MinorReplace hardcoded
text-[10px]with design token and ensure adequate padding.Two issues remain:
text-[10px]is a hardcoded pixel value — usetext-micro(or equivalent design token) for consistent typography.- When both
resultCountand the clear button are visible, the result badge atright-8may overlap with longer counts or input text. Consider increasing the input'spr-padding or positioning the badge further left.Proposed fix for typography
{local && resultCount !== undefined && ( <span - className="absolute right-8 top-1/2 -translate-y-1/2 text-[10px] text-text-muted" + className="absolute right-8 top-1/2 -translate-y-1/2 text-micro text-text-muted" role="status" aria-live="polite" >As per coding guidelines: "Use design tokens exclusively for colors, typography, and spacing."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/SearchInput.tsx` around lines 72 - 80, In the SearchInput component adjust the result badge span: replace the hardcoded class text-[10px] with the design token class text-micro and increase spacing so it cannot overlap the clear button — either move the badge left (change right-8 to right-12) or increase the input's padding-right (e.g., pr-12) so the badge and clear button have room; update the span's class and the input's padding class accordingly while keeping the existing role/aria-live attributes.web/src/pages/settings/NamespaceSection.tsx (1)
114-143: 🛠️ Refactor suggestion | 🟠 MajorThis reintroduces large JSX branches inside
.map().
renderGroups()is back to rendering two multiline branches directly inside the group iteration, which makes the animation/header split harder to maintain and extends the exact pattern that was already called out earlier in review. Please pull the group shell + row list into a small helper/component and keep this function to data mapping only.As per coding guidelines, do not create complex (>8 line) JSX inside
.map()-- extract to a shared component instead.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/NamespaceSection.tsx` around lines 114 - 143, renderGroups currently contains large multiline JSX branches inside the groups.map which violates the guideline; extract the group shell and the row-list rendering into a small helper component (e.g., NamespaceGroup or GroupSection) that accepts props: group (string), groupEntries (Entry[]), hideHeader (boolean), renderRow (function), and anim/staggerDelay, and move the conditional header + row mapping (including the motion.div animation) into that component so renderGroups only maps data to <NamespaceGroup key={group} ... />; preserve the existing keys (`${entry.definition.namespace}/${entry.definition.key}`) and behavior of hideHeader and animation when moving code.web/src/__tests__/hooks/useSettingsKeyboard.test.ts (1)
5-48:⚠️ Potential issue | 🟡 MinorAdd Cmd (
metaKey) shortcut test coverage.Current suite only validates Ctrl variants. Since shortcuts are specified as Ctrl/Cmd, add
metaKeycases to guard macOS behavior regressions.Suggested test additions
+ it('calls onSave on Cmd+S when canSave is true', () => { + const onSave = vi.fn() + const onSearchFocus = vi.fn() + renderHook(() => + useSettingsKeyboard({ onSave, onSearchFocus, canSave: true }), + ) + const event = new KeyboardEvent('keydown', { + key: 's', + metaKey: true, + bubbles: true, + }) + document.dispatchEvent(event) + expect(onSave).toHaveBeenCalledOnce() + }) + + it('calls onSearchFocus on Cmd+/', () => { + const onSave = vi.fn() + const onSearchFocus = vi.fn() + renderHook(() => + useSettingsKeyboard({ onSave, onSearchFocus, canSave: false }), + ) + const event = new KeyboardEvent('keydown', { + key: '/', + metaKey: true, + bubbles: true, + }) + document.dispatchEvent(event) + expect(onSearchFocus).toHaveBeenCalledOnce() + })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/hooks/useSettingsKeyboard.test.ts` around lines 5 - 48, Add test cases to assert that the keyboard shortcuts also trigger when the Meta/Cmd key is used: update tests for useSettingsKeyboard to include dispatching KeyboardEvent with metaKey: true for the Save shortcut (key: 's') when canSave is true and for the SearchFocus shortcut (key: '/') and assert onSave and onSearchFocus respectively; keep existing Ctrl cases, mirror their setup (renderHook calling useSettingsKeyboard with onSave/onSearchFocus) and use expect(...).toHaveBeenCalledOnce() or not as appropriate.web/src/pages/settings/SourceBadge.stories.tsx (1)
25-25:⚠️ Potential issue | 🟡 MinorFix misleading story comment for null-render sources.
Line 25 says only
defaultrenders nothing, butyamlalso renders nothing. This can confuse story readers.Suggested fix
- {/* default renders nothing */} + {/* yaml and default render nothing */}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/SourceBadge.stories.tsx` at line 25, Update the misleading comment in SourceBadge.stories.tsx: change the line that currently reads "default renders nothing" to clarify which stories render nothing (e.g., "default and yaml render nothing") so readers aren't confused; locate the comment near the Story definitions for the SourceBadge stories (the story names 'default' and 'yaml') and update the text accordingly.web/src/components/ui/code-mirror-editor.tsx (1)
133-134:⚠️ Potential issue | 🟠 MajorAvoid
[]as the default forextensions; it causes reconfigure churn.On Line 133,
extensions: extraExtensions = []creates a new array every render. That makes the Line 238 effect run and dispatch reconfiguration repeatedly even when callers pass nothing.Suggested fix (stable default reference)
import { useEffect, useRef } from 'react' import { EditorState, Compartment, type Extension } from '@codemirror/state' @@ import { cn } from '@/lib/utils' + +const EMPTY_EXTENSIONS: readonly Extension[] = [] @@ export function CodeMirrorEditor({ @@ - extensions: extraExtensions = [], + extensions: extraExtensions = EMPTY_EXTENSIONS, onViewReady, }: CodeMirrorEditorProps) {Also applies to: 238-248
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/code-mirror-editor.tsx` around lines 133 - 134, The prop default `extensions: extraExtensions = []` creates a new array each render causing the reconfigure effect (which reads extraExtensions and dispatches reconfiguration) to run repeatedly; fix it by using a stable default reference instead of an inline `[]` — either make a module-level constant (e.g., DEFAULT_EXTENSIONS = []) and default `extraExtensions` to that, or default the prop to `undefined` and inside the component normalize with a memoized value (useMemo) so `extraExtensions` is referentially stable; update usage sites and the reconfigure effect that reads `extraExtensions` to rely on the stabilized value.web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx (1)
28-33:⚠️ Potential issue | 🟡 MinorUse semantic assertions instead of a CSS-class selector for hidden state.
Line 32 is brittle (
[class*="sticky"]) and tied to implementation details. Prefer role/text-based absence checks for visible controls.Suggested test update
- it('is hidden when dirtyCount is 0', () => { - const { container } = render(<FloatingSaveBar {...defaultProps} dirtyCount={0} />) + it('is hidden when dirtyCount is 0', () => { + render(<FloatingSaveBar {...defaultProps} dirtyCount={0} />) expect(screen.queryByText(/unsaved/)).not.toBeInTheDocument() - // The ConfirmDialog is still mounted but not open, so only check for visible bar content - expect(container.querySelector('[class*="sticky"]')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /discard/i })).not.toBeInTheDocument() })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx` around lines 28 - 33, Test is using a brittle CSS class selector to detect hidden state; replace it with semantic queries. In the "is hidden when dirtyCount is 0" test for FloatingSaveBar, remove the container.querySelector('[class*="sticky"]') assertion and instead assert absence of the visible controls and dialog using semantic queries — e.g. use screen.queryByRole('button', { name: /save/i }) and screen.queryByRole('button', { name: /discard|cancel/i }) to ensure the bar controls are not present, and use screen.queryByRole('dialog') or screen.queryByText(/unsaved/i) to verify the ConfirmDialog is not open/visible. Ensure you keep the comment about ConfirmDialog being mounted but not open and only check for visible elements.web/src/components/ui/tag-input.stories.tsx (2)
16-18:⚠️ Potential issue | 🟡 MinorResync controlled stories when Controls change.
Each render initializes local state from
args.valueonce, so Storybook Controls can drift from whatTagInputrenders after the first change. Add a smalluseEffectthat updates local state wheneverargs.valuechanges.Run the following script to confirm the current pattern:
#!/bin/bash set -euo pipefail rg -n -C2 'useState\(args\.value\)|useEffect\(' web/src/components/ui/tag-input.stories.tsxExpected result: the file shows the three
useState(args.value)initializations and no correspondinguseEffectresync.Also applies to: 24-26, 38-40
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.stories.tsx` around lines 16 - 18, The story's Render function initializes local state with useState(args.value) but never resyncs when args.value changes, causing Storybook Controls to drift; inside the Render function that returns <TagInput {...args} value={value} onChange={setValue} /> (the Render function using useState(args.value)), add a useEffect that watches args.value and calls setValue(args.value) so the local value follows control updates; apply the same fix to the other Render variants where useState(args.value) is used.
14-42:⚠️ Potential issue | 🟡 MinorAdd the missing shared-component story states.
TagInputis a new shared UI component, but this file still only covers default/empty/disabled/many-tags. Please addHover,Loading, andErrorvariants so Storybook matches the required shared-component contract. Based on learnings, "All story files must include a.stories.tsxfile alongside new shared components with all states (default, hover, loading, error, empty)."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.stories.tsx` around lines 14 - 42, Add three new stories named Hover, Loading, and Error for the TagInput component similar to the existing Default/Empty/ManyTags stories: create Hover using the same render pattern (useState with args.value) and add a story-level parameter to simulate the hover state (e.g., parameters: { pseudo: { hover: true } } or a simple decorator that applies a hover class), create Loading with args that pass the component's loading prop (e.g., args: { value: [...], loading: true }) and use the same render function pattern, and create Error with args that pass an error prop/message (e.g., args: { value: [...], error: 'Error message' }) also using the same render function; ensure the story names are exactly Hover, Loading, and Error and reuse the TagInput render pattern (function Render, useState, setValue) so Storybook includes all required shared-component states.web/src/components/ui/tag-input.tsx (2)
5-10:⚠️ Potential issue | 🟠 MajorGive the textbox a labelable API.
The outer group's
aria-labeldoes not name the inner<input>, andTagInputPropscurrently gives consumers noid/aria-label/aria-labelledbyescape hatch. Screen readers will still reach an unnamed text field here unless the textbox itself becomes labelable.Also applies to: 79-80, 108-118
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.tsx` around lines 5 - 10, TagInputProps currently lacks an API to label the inner textbox, so screen readers can't associate the outer group aria with the actual <input>; extend TagInputProps to accept optional id?: string, ariaLabel?: string, and ariaLabelledBy?: string (or ariaLabelledby?: string matching casing used) and forward these to the inner input element inside the TagInput component (and use id when linking labels/aria-labelledby on the outer wrapper if present) so consumers can provide an accessible label for the textbox; update any places that render the outer group (references around the group wrapper and the input render logic) to prefer ariaLabelledBy/ariaLabel when present and fall back to id for label association.
82-107: 🛠️ Refactor suggestion | 🟠 MajorExtract the chip markup out of the
.map()callback.This shared component still renders a full chip + remove button inline inside
value.map(). Pull it into a smallTagChiphelper/component so the map body stays trivial and easier to maintain. As per coding guidelines, "web/src/**/*.{ts,tsx}: Do not create complex (>8 line) JSX inside.map()-- extract to a shared component instead."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.tsx` around lines 82 - 107, The JSX for each tag chip inside value.map is too large—extract it into a small TagChip component that receives props {item, index, disabled, removeAt} (and optionally className) and is responsible for computing the stableKey (using the existing occurrence logic), rendering the span, label and conditional remove button that calls removeAt(index) and stops propagation; then replace the current map body with a trivial value.map((item, i) => <TagChip item={item} index={i} disabled={disabled} removeAt={removeAt} />) to keep the .map callback simple and reusable.web/src/__tests__/pages/settings/sinks/SinkCard.test.tsx (1)
96-100:⚠️ Potential issue | 🟡 MinorAssert the icon or rename the test.
This only proves the enabled
StatusBadgerendered. It would pass for a file sink too, so the test never verifies the console-specific icon.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/pages/settings/sinks/SinkCard.test.tsx` around lines 96 - 100, The test "shows console icon for console sink type" currently only checks the Enabled StatusBadge and doesn't verify the console-specific icon; update the SinkCard test: render SinkCard with makeSink({ sink_type: 'console' }) and assert the console (Monitor) icon is present by querying a unique identifier from the rendered icon (e.g., aria-label, title, test-id or SVG role/name exposed by the Monitor icon) in addition to the StatusBadge, or if you prefer not to assert the icon, rename the test to reflect that it only verifies the enabled StatusBadge (e.g., "renders enabled status badge for console sink") and keep the existing assertion; target the SinkCard component and the test case that uses makeSink({ sink_type: 'console' }).web/src/pages/settings/SettingField.tsx (1)
30-47:⚠️ Potential issue | 🟠 MajorPreserve array item validation in the
TagInputpath.This branch bypasses
validate()entirely, so any array setting with avalidator_patternnow accepts invalid tags locally and only fails after save. Please validate each item before callingonChange(...)or surface an error state fromTagInput.Also applies to: 130-137
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/SettingField.tsx` around lines 30 - 47, ArraySettingField currently converts tags to JSON and calls onChange without per-item validation, bypassing the existing validate() / validator_pattern logic; update ArraySettingField (and the similar block referenced at lines 130-137) to validate each parsed item before calling onChange by reusing the same validation routine (e.g., call the existing validateItem/validate/validator_pattern logic or a helper that applies validator_pattern) and either prevent invalid items from being added (filter them out) or surface an error state to TagInput so the UI reflects validation failures rather than letting invalid tags be saved.web/src/pages/settings/SettingsHealthSection.tsx (2)
70-93: 🛠️ Refactor suggestion | 🟠 MajorExtract the mapped tab button into a small component.
The JSX inside
visibleNamespaces.map(...)is already over the repo's mapped-JSX limit. Pull it into aNamespaceTabButtonhelper/component so this tab bar stays readable and easier to extend.As per coding guidelines, "Do not create complex (>8 line) JSX inside
.map()-- extract to a shared component instead."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/SettingsHealthSection.tsx` around lines 70 - 93, The mapped JSX in visibleNamespaces.map is too large; extract it into a small presentational component called NamespaceTabButton. Create a component that accepts props (ns: string/enum, count: number from namespaceCounts.get(ns), icon from namespaceIcons?.[ns], activeNamespace, onSelect, and displayName via NAMESPACE_DISPLAY_NAMES[ns]) and renders the button using the same className logic (using cn) and ARIA props; then replace the inline JSX in visibleNamespaces.map with <NamespaceTabButton ... /> passing ns, count, icon, activeNamespace, and onSelect. Ensure the new component is exported/defined near SettingsHealthSection so imports and references (namespaceCounts, namespaceIcons, NAMESPACE_DISPLAY_NAMES, cn) resolve.
45-60:⚠️ Potential issue | 🟠 MajorKeep one tab focusable when the active namespace disappears.
When
activeNamespaceis filtered out ofvisibleNamespaces, every tab in this tablist ends up withtabIndex={-1}. Fall back to "All" for the selection/tabIndex logic so keyboard users always have a focusable tab.Also applies to: 78-79
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/SettingsHealthSection.tsx` around lines 45 - 60, When the current activeNamespace is filtered out and visibleNamespaces doesn't include it, all tabs become unfocusable; fix by computing an effectiveActiveNamespace (e.g., use activeNamespace if visibleNamespaces.includes(activeNamespace) else null) and use that effectiveActiveNamespace for aria-selected and tabIndex logic and for deciding the "All" fallback when calling onSelect; apply the same fallback logic where tabIndex/aria-selected are computed (also update the other tab buttons at the second occurrence around the 78-79 block) so there's always exactly one focusable tab.web/src/pages/settings/CodeEditorPanel.tsx (1)
368-380: 🛠️ Refactor suggestion | 🟠 MajorReplace
text-[10px]with the semantic typography token.These labels hardcode a font size in JSX. Use the existing micro-text token instead so the editor header stays inside the design system.
As per coding guidelines, "Use design tokens exclusively for colors, typography, and spacing -- never hardcode hex values, `rgba()` values with hardcoded colors, pixel values for layout spacing, or `fontFamily` declarations directly in `.tsx`/`.ts` files."♻️ Replace the hardcoded size
- <span className="text-[10px] font-medium uppercase tracking-wider text-text-muted">Current</span> + <span className="text-micro font-medium uppercase tracking-wider text-text-muted">Current</span> @@ - <span className="text-[10px] font-medium uppercase tracking-wider text-text-muted">Edited</span> + <span className="text-micro font-medium uppercase tracking-wider text-text-muted">Edited</span>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/CodeEditorPanel.tsx` around lines 368 - 380, Replace the hardcoded font-size utility "text-[10px]" used on the editor header labels with the semantic typography token (e.g., "text-micro") so the labels adhere to the design system; update both occurrences in the JSX where the span with classes "text-[10px] font-medium uppercase tracking-wider text-text-muted" appears (the blocks near the Current label rendered alongside CodeMirrorEditor and the Edited label rendered when splitView is true), ensuring the className uses the micro token instead of the literal pixel utility.web/src/pages/settings/sinks/SinkFormDrawer.tsx (1)
53-58:⚠️ Potential issue | 🟠 MajorMake test/save use the same validated rotation values.
The three rotation branches currently disagree: the default test payload uses raw
Number(...), the custom test payload uses||fallbacks, and save usesNumber.isFinite(...)fallbacks. That meansbackupCount=0is tested as5, negative values can still be tested, and “Test Config” can validate a different rotation than “Save” persists.Also applies to: 77-82, 113-118
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/sinks/SinkFormDrawer.tsx` around lines 53 - 58, The rotation numeric parsing is inconsistent across the test and save branches (raw Number(...), || fallbacks, and Number.isFinite checks), causing mismatches (e.g., backupCount=0 becoming 5); create and use a single validation helper (e.g., validateRotationValues or normalizeRotation) that takes rotationStrategy, maxBytes, backupCount, coerces to numbers, enforces finite/non-negative rules and default fallbacks, and then replace the ad-hoc parsing in the override.rotation assignment and the test-payload branches (the blocks that build the test payload and the save payload where rotation is set) so both "Test Config" and "Save" use the same validated rotation values consistently.web/src/pages/SettingsPage.tsx (1)
376-389:⚠️ Potential issue | 🟠 MajorDon't nest
ButtoninsideLink.
Linkis already the interactive control here. Putting a real<button>inside it creates invalid interactive nesting and breaks keyboard/assistive-tech semantics; render the “Open” affordance as a styled non-interactive element instead.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/SettingsPage.tsx` around lines 376 - 389, The footerAction block nests a real Button inside a Link (when ns === 'observability'), which is invalid interactive nesting; modify the JSX so the Link remains the sole interactive element by replacing the <Button> used for the “Open” affordance with a non-interactive element (e.g., a <div> or <span>) that keeps the same classes ("w-full justify-center", variant styling) and tabIndex={-1}/aria-hidden as needed, and remove any button semantics; update the footerAction block (the Link containing the "Log Sinks" card) to use that styled non-interactive element instead of the Button component.
🤖 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/synthorg/api/controllers/settings.py`:
- Around line 451-468: The code currently catches Exception in the svc.get call
and returns a fallback, which hides real failures; change the logic in the block
around svc.get (used by list_sinks()) so that only SettingNotFoundError returns
the fallback, and any other exception is not swallowed—either re-raise the
exception or log it and raise a new error so operators see DB/decryption/service
failures; specifically remove or alter the generic except Exception branch that
references SETTINGS_OBSERVABILITY_VALIDATION_FAILED and ensure svc.get,
SettingNamespace.OBSERVABILITY, and SettingNotFoundError behavior surfaces real
errors instead of fabricating defaults.
In `@src/synthorg/providers/health.py`:
- Around line 140-146: The current _validate_zero_calls_consistency on
ProviderHealthSummary only prevents avg_response_time_ms when calls_last_24h ==
0; extend it (or add a single model_validator mode="after") to enforce the full
contract: if calls_last_24h == 0 then avg_response_time_ms must be None and
last_check_timestamp must be None, and if calls_last_24h > 0 then
avg_response_time_ms must be not None and last_check_timestamp must be not None;
raise ValueError with clear messages referencing the offending fields when the
invariants fail.
In `@tests/unit/api/controllers/test_settings_sinks.py`:
- Around line 243-333: Multiple near-duplicate negative POST tests
(test_invalid_json_returns_error, test_invalid_sink_identifier_returns_error,
test_disable_console_returns_error, test_invalid_level_returns_error,
test_custom_sink_missing_path_returns_error) differ only by request payload and
expected error text; refactor them into a single parametrized test using
pytest.mark.parametrize that supplies tuples of (json_payload,
expected_error_substring) and asserts resp.status_code, body["data"]["valid"] is
False and expected_error_substring in body["data"]["error"]; update or remove
the individual test_* functions and keep one parametrized function (e.g.,
test_invalid_sinks_parametrized) that posts to
"/api/v1/settings/observability/sinks/_test" with auth_headers and iterates the
cases.
In `@web/src/__tests__/components/ui/TagInput.test.tsx`:
- Around line 69-72: Extend the "disables input when disabled prop is true" test
for TagInput to assert disabled behavior through its onChange pathway: render
<TagInput value={['a']} onChange={mockFn} disabled /> using a jest.fn() for
onChange, then try to remove the existing tag both by interacting with the tag's
remove control (the chip's remove button/element) and by simulating keyboard
removal (Backspace/Delete) on the textbox; assert that mockFn was not called and
the tag remains present. Reference the TagInput component and the onChange prop
to locate where to add these interactions.
In `@web/src/__tests__/pages/tasks/TaskDetailPanel.test.tsx`:
- Line 159: The test "calls onCancel after confirm dialog confirmation" in
TaskDetailPanel.test.tsx has an unnecessary explicit timeout ({ timeout: 15000
}) for a short deterministic interaction; remove the timeout options object so
the it(...) call uses the default Jest timeout (i.e., change it from it('calls
onCancel after confirm dialog confirmation', { timeout: 15000 }, async () =>
...) to it('calls onCancel after confirm dialog confirmation', async () =>
...)), keeping the async test body and assertions unchanged.
In `@web/src/__tests__/stores/sinks.test.ts`:
- Around line 34-93: Add unit tests for saveSink() in the sinks test suite to
cover the merge/update logic: create tests that (1) verify merging when saving a
default sink (e.g., call useSinksStore.getState().saveSink with a default sink
payload and assert the existing default sink in useSinksStore.sinks is
updated/merged correctly), (2) verify merging for a custom sink (save a custom
sink and assert the store merges fields rather than replacing unintentionally),
and (3) simulate read/parse failures by mocking any file/read utilities or the
sink parsing function to throw or return invalid data and assert saveSink sets
an appropriate error and does not corrupt sinks; use vi.mocked for
listSinks/other API calls and makeSink helpers to prepare initial state and
expectations.
In `@web/src/hooks/useSettingsKeyboard.ts`:
- Around line 15-25: In handleKeyDown add guards to return early when the event
has already been consumed or is an auto-repeat: check e.defaultPrevented and
e.repeat at the top of the handler (inside function handleKeyDown) so the
listener bails out before processing mod+S or '/'; keep the existing checks for
modifier keys (e.metaKey || e.ctrlKey) and then only call onSave (when canSave)
or onSearchFocus if those guards pass. Ensure you reference the existing symbols
handleKeyDown, canSave, onSave, and onSearchFocus when making the change.
In `@web/src/pages/settings/CodeEditorPanel.tsx`:
- Around line 365-391: The split view is forcing grid-cols-2 at all breakpoints
in the CodeEditorPanel rendering; update the className logic that uses splitView
(where the div builds cn('gap-3', splitView ? 'grid grid-cols-2' : 'grid
grid-cols-1')) so that when splitView is true it uses responsive columns (e.g.,
'grid grid-cols-1 md:grid-cols-2') and when false it stays single column; update
references to splitView and the CodeEditorPanel JSX so the editor collapses to
one column on narrow screens and goes side-by-side only at the chosen
breakpoint.
In `@web/src/pages/settings/editor-extensions.ts`:
- Around line 562-584: The backward JSON context scan loop that iterates from
pos-1 to 0 uses braceDepth++ on '{' and braceDepth-- on '}', which is reversed
for a reverse scan; update the loop that references pos, text, braceDepth, and
currentNamespace to flip the brace accounting (increment on '}' and decrement on
'{') so the branch conditions (braceDepth === 1 and === 2) correctly detect root
vs namespace object when scanning backwards; make the same change in the other
mirrored block (lines ~616-629) so currentNamespace is correctly set for nested
keys and enum completion.
- Around line 85-98: computeLineDiff() currently emits only 'added' and
'removed' markers so one-line replacements produce both markers and get deduped;
update the logic that builds diffs (the two loops that push {line, kind} for
added and removed) to detect when an added and removed refer to the same display
line and emit a single {line, kind: 'changed'} instead; concretely, after
collecting candidate added/removed entries (or while pushing them) merge entries
with identical line numbers into a single 'changed' entry (and remove the
duplicate added/removed) so replacements show as kind 'changed'; apply the same
fix at the other similar block referenced around the second occurrence (lines
~196-202) where the same add/remove merging is done.
- Around line 156-176: The theme currently hardcodes spacing/size/and typography
values in the EditorView.theme call (see diffGutterTheme and any other
EditorView.theme/theme objects around the file), so replace literal pixel
widths/margins/paddings/borderRadius and any fontFamily declarations with
existing design tokens (spacing/size/typography tokens) and reference them in
the theme objects instead; locate diffGutterTheme and the other theme
definitions near the bottom of the file, remove hardcoded values (e.g.,
'6px','2px','4px','1px' and any fontFamily entries) and substitute token
variables (or token accessor functions) that represent those measurements and
typography settings so the editor theme inherits dashboard density and
typography rules.
In `@web/src/pages/settings/NamespaceSection.tsx`:
- Around line 149-157: The header currently renders as an enabled button that
ignores clicks when forceOpen is true; change the rendering so it is
non-interactive in that state: in NamespaceSection.tsx, where the button is
created (the element using onClick={() => { if (!forceOpen) setCollapsed((v) =>
!v) }}, aria-expanded={isOpen}, aria-controls={contentId}), conditionally render
either a real button (when !forceOpen) with the existing onClick and
accessibility attrs, or render a non-interactive element (e.g., a div or <h3>)
with the same className/visual styling and aria-expanded/aria-controls removed
or, alternately, set aria-disabled="true" and the disabled attribute and remove
the onClick when forceOpen is true; ensure setCollapsed is only invoked from the
interactive button branch.
In `@web/src/pages/settings/SettingRow.tsx`:
- Around line 73-80: The disabled hint currently uses a generic title on the
container (SettingRow component) which is inaccessible on touch and doesn't
identify the controlling setting; update SettingRow to accept the controlling
setting's label/key (e.g., pass a new prop like controllerLabel or controllerKey
alongside compositeKey), and when controllerDisabled is true render a proper
tooltip element (or visually hidden hint) with a stable id and set
aria-describedby on the root container instead of title; ensure the tooltip
contains the controllerLabel (e.g., "Enable <controllerLabel> to configure this
option") and remove the title attribute so keyboard and touch users can access
the descriptive message reliably.
In `@web/src/pages/settings/sinks/SinkCard.tsx`:
- Around line 23-82: Replace the manual card/header and inline status dot in
SinkCard with the dashboard primitives: use SectionCard to render the outer card
and header area (move the icon, identifier, and actions into SectionCard's
header slot and keep the rotation/routes/level content in its body) and replace
the inline status dot span with StatusBadge (pass sink.enabled to StatusBadge
and keep aria/title semantics). Preserve existing behavior: keep the level badge
using levelColor, the JSON/Text and Default labels, the rotation and routing
text, and the Edit button calling onEdit(sink); ensure any className/spacing
from the original is applied via SectionCard props or wrapper elements so styles
remain consistent.
In `@web/src/pages/settings/sinks/SinkFormDrawer.tsx`:
- Around line 33-46: The form retains stale state across drawer reopen; in
SinkFormDrawer add a useEffect that runs when the prop open becomes true and
resets all local state (filePath, level, enabled, jsonFormat, rotationStrategy,
maxBytes, backupCount, routingPrefixes, testResult, testing, filePathError) back
to their initial values derived from sink (use the same initial logic used in
useState calls such as sink?.identifier === '__console__' ? '' :
(sink?.identifier ?? ''), sink?.level ?? 'INFO', sink?.enabled ?? true,
sink?.json_format ?? false, sink?.rotation?.strategy ?? 'none',
String(sink?.rotation?.max_bytes ?? 10485760),
String(sink?.rotation?.backup_count ?? 5), sink?.routing_prefixes ?
[...sink.routing_prefixes] : []); alternatively you can clear those same fields
inside the onClose handler before closing—ensure testResult and filePathError
are explicitly set to null and testing to false so no stale test state persists.
In `@web/src/stores/sinks.ts`:
- Around line 52-59: The current catch silently swallows errors from
getNamespaceSettings() or JSON.parse so a failed read leaves `existing` as []
and causes overwriting of all `custom_sinks`; instead surface the error and
abort the save: remove the empty catch or rethrow the caught error with context
(e.g., include `getNamespaceSettings` / `custom_sinks` and `entry?.value`) so
the caller can stop the write. Locate the block using
getNamespaceSettings('observability'), the `entry?.value` JSON.parse, and the
`existing` variable in sinks.ts and ensure any parse/read error is thrown (or
returned) rather than proceeding with an empty `existing`.
- Around line 14-19: The current change replaces the entire sink_overrides map
with only the edited sink and buildOverrideForSink omits routing_prefixes,
causing lost overrides and missing routing data; update buildOverrideForSink to
include routing_prefixes (when present) in the returned override object, and
when persisting updates to sink_overrides merge the new override into the
existing sink_overrides map instead of overwriting it (read existing
sink_overrides, assign/replace only the entry for the edited sink key, then save
the merged map).
---
Outside diff comments:
In `@src/synthorg/api/controllers/settings.py`:
- Around line 51-60: The UpdateSettingRequest model's value field is limited to
8192 characters causing sink saves to fail; update the Field definition on
UpdateSettingRequest.value to allow at least 65536 characters (matching the
_test path) so sink_overrides and custom_sinks can persist; locate the
UpdateSettingRequest class and change the Field(max_length=8192, ...) to
Field(max_length=65536, ...) (or remove the max_length restriction if you prefer
unlimited saves) while keeping model_config unchanged.
In `@src/synthorg/observability/sink_config_builder.py`:
- Around line 317-323: The code validates raw_path with raw_path.strip() but
then passes the unnormalized raw_path into the SinkConfig, which allows
leading/trailing whitespace to create duplicate/ambiguous paths; update the
builder so that after validation you set normalized_path = raw_path.strip() (or
similarly normalized value) and use that normalized_path when constructing the
SinkConfig (and any downstream storage/keys) — change references to raw_path in
the SinkConfig creation site (and the variable used around where level is
parsed) to use the normalized value to ensure consistent collision semantics.
In `@web/src/pages/SettingsPage.tsx`:
- Around line 358-395: The JSX inside the .map over NAMESPACE_ORDER is too large
and should be extracted into a dedicated renderer component to improve
readability; create a new component (e.g., NamespaceRenderer or
renderNamespaceItem) that accepts props like ns, entries, dirtyValues,
handleValueChange, storeSavingKeys, controllerDisabledMap, effectiveNamespace,
searchQuery, changedKeys and returns the StaggerItem -> ErrorBoundary ->
NamespaceSection tree including the conditional observability footer CTA;
replace the inline map body with a simple .map(ns => <NamespaceRenderer key={ns}
ns={ns} ... />) and ensure the observability footer logic (ns ===
'observability') is kept inside the new component so NamespaceSection usage and
prop names (displayName from NAMESPACE_DISPLAY_NAMES, icon from NAMESPACE_ICONS)
remain unchanged.
---
Duplicate comments:
In `@web/src/__tests__/hooks/useSettingsKeyboard.test.ts`:
- Around line 5-48: Add test cases to assert that the keyboard shortcuts also
trigger when the Meta/Cmd key is used: update tests for useSettingsKeyboard to
include dispatching KeyboardEvent with metaKey: true for the Save shortcut (key:
's') when canSave is true and for the SearchFocus shortcut (key: '/') and assert
onSave and onSearchFocus respectively; keep existing Ctrl cases, mirror their
setup (renderHook calling useSettingsKeyboard with onSave/onSearchFocus) and use
expect(...).toHaveBeenCalledOnce() or not as appropriate.
In `@web/src/__tests__/pages/LoginPage.test.tsx`:
- Line 186: The test "admin creation validates password match" currently
overrides Jest's default timeout ({ timeout: 15000 }) — remove that timeout and
make the test deterministic and fast instead: revert the it(...) call to the
normal signature and ensure the test uses synchronous DOM updates (use
userEvent.type or fireEvent.change, avoid arbitrary setTimeouts), await the
actual DOM change with screen.findByText or waitFor with a short explicit
timeout only if needed, and flush pending promises via await act or await
waitFor(() => ...) so the password-mismatch validation assertion runs
immediately; update the test block (the it(...) for "admin creation validates
password match") to use these patterns so the extended timeout is no longer
necessary.
In `@web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx`:
- Around line 28-33: Test is using a brittle CSS class selector to detect hidden
state; replace it with semantic queries. In the "is hidden when dirtyCount is 0"
test for FloatingSaveBar, remove the
container.querySelector('[class*="sticky"]') assertion and instead assert
absence of the visible controls and dialog using semantic queries — e.g. use
screen.queryByRole('button', { name: /save/i }) and screen.queryByRole('button',
{ name: /discard|cancel/i }) to ensure the bar controls are not present, and use
screen.queryByRole('dialog') or screen.queryByText(/unsaved/i) to verify the
ConfirmDialog is not open/visible. Ensure you keep the comment about
ConfirmDialog being mounted but not open and only check for visible elements.
In `@web/src/__tests__/pages/settings/sinks/SinkCard.test.tsx`:
- Around line 96-100: The test "shows console icon for console sink type"
currently only checks the Enabled StatusBadge and doesn't verify the
console-specific icon; update the SinkCard test: render SinkCard with makeSink({
sink_type: 'console' }) and assert the console (Monitor) icon is present by
querying a unique identifier from the rendered icon (e.g., aria-label, title,
test-id or SVG role/name exposed by the Monitor icon) in addition to the
StatusBadge, or if you prefer not to assert the icon, rename the test to reflect
that it only verifies the enabled StatusBadge (e.g., "renders enabled status
badge for console sink") and keep the existing assertion; target the SinkCard
component and the test case that uses makeSink({ sink_type: 'console' }).
In `@web/src/components/ui/code-mirror-editor.tsx`:
- Around line 133-134: The prop default `extensions: extraExtensions = []`
creates a new array each render causing the reconfigure effect (which reads
extraExtensions and dispatches reconfiguration) to run repeatedly; fix it by
using a stable default reference instead of an inline `[]` — either make a
module-level constant (e.g., DEFAULT_EXTENSIONS = []) and default
`extraExtensions` to that, or default the prop to `undefined` and inside the
component normalize with a memoized value (useMemo) so `extraExtensions` is
referentially stable; update usage sites and the reconfigure effect that reads
`extraExtensions` to rely on the stabilized value.
In `@web/src/components/ui/tag-input.stories.tsx`:
- Around line 16-18: The story's Render function initializes local state with
useState(args.value) but never resyncs when args.value changes, causing
Storybook Controls to drift; inside the Render function that returns <TagInput
{...args} value={value} onChange={setValue} /> (the Render function using
useState(args.value)), add a useEffect that watches args.value and calls
setValue(args.value) so the local value follows control updates; apply the same
fix to the other Render variants where useState(args.value) is used.
- Around line 14-42: Add three new stories named Hover, Loading, and Error for
the TagInput component similar to the existing Default/Empty/ManyTags stories:
create Hover using the same render pattern (useState with args.value) and add a
story-level parameter to simulate the hover state (e.g., parameters: { pseudo: {
hover: true } } or a simple decorator that applies a hover class), create
Loading with args that pass the component's loading prop (e.g., args: { value:
[...], loading: true }) and use the same render function pattern, and create
Error with args that pass an error prop/message (e.g., args: { value: [...],
error: 'Error message' }) also using the same render function; ensure the story
names are exactly Hover, Loading, and Error and reuse the TagInput render
pattern (function Render, useState, setValue) so Storybook includes all required
shared-component states.
In `@web/src/components/ui/tag-input.tsx`:
- Around line 5-10: TagInputProps currently lacks an API to label the inner
textbox, so screen readers can't associate the outer group aria with the actual
<input>; extend TagInputProps to accept optional id?: string, ariaLabel?:
string, and ariaLabelledBy?: string (or ariaLabelledby?: string matching casing
used) and forward these to the inner input element inside the TagInput component
(and use id when linking labels/aria-labelledby on the outer wrapper if present)
so consumers can provide an accessible label for the textbox; update any places
that render the outer group (references around the group wrapper and the input
render logic) to prefer ariaLabelledBy/ariaLabel when present and fall back to
id for label association.
- Around line 82-107: The JSX for each tag chip inside value.map is too
large—extract it into a small TagChip component that receives props {item,
index, disabled, removeAt} (and optionally className) and is responsible for
computing the stableKey (using the existing occurrence logic), rendering the
span, label and conditional remove button that calls removeAt(index) and stops
propagation; then replace the current map body with a trivial value.map((item,
i) => <TagChip item={item} index={i} disabled={disabled} removeAt={removeAt} />)
to keep the .map callback simple and reusable.
In `@web/src/pages/settings/CodeEditorPanel.tsx`:
- Around line 368-380: Replace the hardcoded font-size utility "text-[10px]"
used on the editor header labels with the semantic typography token (e.g.,
"text-micro") so the labels adhere to the design system; update both occurrences
in the JSX where the span with classes "text-[10px] font-medium uppercase
tracking-wider text-text-muted" appears (the blocks near the Current label
rendered alongside CodeMirrorEditor and the Edited label rendered when splitView
is true), ensuring the className uses the micro token instead of the literal
pixel utility.
In `@web/src/pages/settings/NamespaceSection.tsx`:
- Around line 114-143: renderGroups currently contains large multiline JSX
branches inside the groups.map which violates the guideline; extract the group
shell and the row-list rendering into a small helper component (e.g.,
NamespaceGroup or GroupSection) that accepts props: group (string), groupEntries
(Entry[]), hideHeader (boolean), renderRow (function), and anim/staggerDelay,
and move the conditional header + row mapping (including the motion.div
animation) into that component so renderGroups only maps data to <NamespaceGroup
key={group} ... />; preserve the existing keys
(`${entry.definition.namespace}/${entry.definition.key}`) and behavior of
hideHeader and animation when moving code.
In `@web/src/pages/settings/SearchInput.tsx`:
- Around line 72-80: In the SearchInput component adjust the result badge span:
replace the hardcoded class text-[10px] with the design token class text-micro
and increase spacing so it cannot overlap the clear button — either move the
badge left (change right-8 to right-12) or increase the input's padding-right
(e.g., pr-12) so the badge and clear button have room; update the span's class
and the input's padding class accordingly while keeping the existing
role/aria-live attributes.
In `@web/src/pages/settings/SettingField.tsx`:
- Around line 30-47: ArraySettingField currently converts tags to JSON and calls
onChange without per-item validation, bypassing the existing validate() /
validator_pattern logic; update ArraySettingField (and the similar block
referenced at lines 130-137) to validate each parsed item before calling
onChange by reusing the same validation routine (e.g., call the existing
validateItem/validate/validator_pattern logic or a helper that applies
validator_pattern) and either prevent invalid items from being added (filter
them out) or surface an error state to TagInput so the UI reflects validation
failures rather than letting invalid tags be saved.
In `@web/src/pages/settings/SettingsHealthSection.tsx`:
- Around line 70-93: The mapped JSX in visibleNamespaces.map is too large;
extract it into a small presentational component called NamespaceTabButton.
Create a component that accepts props (ns: string/enum, count: number from
namespaceCounts.get(ns), icon from namespaceIcons?.[ns], activeNamespace,
onSelect, and displayName via NAMESPACE_DISPLAY_NAMES[ns]) and renders the
button using the same className logic (using cn) and ARIA props; then replace
the inline JSX in visibleNamespaces.map with <NamespaceTabButton ... /> passing
ns, count, icon, activeNamespace, and onSelect. Ensure the new component is
exported/defined near SettingsHealthSection so imports and references
(namespaceCounts, namespaceIcons, NAMESPACE_DISPLAY_NAMES, cn) resolve.
- Around line 45-60: When the current activeNamespace is filtered out and
visibleNamespaces doesn't include it, all tabs become unfocusable; fix by
computing an effectiveActiveNamespace (e.g., use activeNamespace if
visibleNamespaces.includes(activeNamespace) else null) and use that
effectiveActiveNamespace for aria-selected and tabIndex logic and for deciding
the "All" fallback when calling onSelect; apply the same fallback logic where
tabIndex/aria-selected are computed (also update the other tab buttons at the
second occurrence around the 78-79 block) so there's always exactly one
focusable tab.
In `@web/src/pages/settings/sinks/SinkFormDrawer.tsx`:
- Around line 53-58: The rotation numeric parsing is inconsistent across the
test and save branches (raw Number(...), || fallbacks, and Number.isFinite
checks), causing mismatches (e.g., backupCount=0 becoming 5); create and use a
single validation helper (e.g., validateRotationValues or normalizeRotation)
that takes rotationStrategy, maxBytes, backupCount, coerces to numbers, enforces
finite/non-negative rules and default fallbacks, and then replace the ad-hoc
parsing in the override.rotation assignment and the test-payload branches (the
blocks that build the test payload and the save payload where rotation is set)
so both "Test Config" and "Save" use the same validated rotation values
consistently.
In `@web/src/pages/settings/SourceBadge.stories.tsx`:
- Line 25: Update the misleading comment in SourceBadge.stories.tsx: change the
line that currently reads "default renders nothing" to clarify which stories
render nothing (e.g., "default and yaml render nothing") so readers aren't
confused; locate the comment near the Story definitions for the SourceBadge
stories (the story names 'default' and 'yaml') and update the text accordingly.
In `@web/src/pages/SettingsPage.tsx`:
- Around line 376-389: The footerAction block nests a real Button inside a Link
(when ns === 'observability'), which is invalid interactive nesting; modify the
JSX so the Link remains the sole interactive element by replacing the <Button>
used for the “Open” affordance with a non-interactive element (e.g., a <div> or
<span>) that keeps the same classes ("w-full justify-center", variant styling)
and tabIndex={-1}/aria-hidden as needed, and remove any button semantics; update
the footerAction block (the Link containing the "Log Sinks" card) to use that
styled non-interactive element instead of the Button component.
🪄 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: Repository UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: e9e7feca-8f8c-46fe-8234-872242552e15
⛔ Files ignored due to path filters (1)
web/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (64)
CLAUDE.mddocs/design/brand-and-ux.mddocs/design/operations.mddocs/design/page-structure.mdsrc/synthorg/api/app.pysrc/synthorg/api/controllers/settings.pysrc/synthorg/api/lifecycle.pysrc/synthorg/observability/sink_config_builder.pysrc/synthorg/providers/capabilities.pysrc/synthorg/providers/health.pysrc/synthorg/providers/health_prober.pysrc/synthorg/settings/definitions/api.pytests/unit/api/controllers/test_provider_health.pytests/unit/api/controllers/test_settings_sinks.pytests/unit/providers/test_health.pytests/unit/providers/test_health_prober.pyweb/CLAUDE.mdweb/package.jsonweb/src/__tests__/components/layout/Sidebar.test.tsxweb/src/__tests__/components/ui/TagInput.test.tsxweb/src/__tests__/hooks/useSettingsKeyboard.test.tsweb/src/__tests__/pages/LoginPage.test.tsxweb/src/__tests__/pages/SettingsPage.test.tsxweb/src/__tests__/pages/settings/FloatingSaveBar.test.tsxweb/src/__tests__/pages/settings/RestartBanner.test.tsxweb/src/__tests__/pages/settings/SettingsHealthSection.test.tsxweb/src/__tests__/pages/settings/SourceBadge.test.tsxweb/src/__tests__/pages/settings/sinks/SinkCard.test.tsxweb/src/__tests__/pages/tasks/TaskDetailPanel.test.tsxweb/src/__tests__/stores/sinks.test.tsweb/src/api/endpoints/settings.tsweb/src/api/types.tsweb/src/components/ui/code-mirror-editor.tsxweb/src/components/ui/tag-input.stories.tsxweb/src/components/ui/tag-input.tsxweb/src/hooks/useSettingsKeyboard.tsweb/src/pages/SettingsPage.tsxweb/src/pages/SettingsSinksPage.tsxweb/src/pages/settings/CodeEditorPanel.tsxweb/src/pages/settings/DependencyIndicator.tsxweb/src/pages/settings/FloatingSaveBar.stories.tsxweb/src/pages/settings/FloatingSaveBar.tsxweb/src/pages/settings/NamespaceSection.tsxweb/src/pages/settings/RestartBadge.tsxweb/src/pages/settings/RestartBanner.stories.tsxweb/src/pages/settings/RestartBanner.tsxweb/src/pages/settings/SearchInput.stories.tsxweb/src/pages/settings/SearchInput.tsxweb/src/pages/settings/SettingField.tsxweb/src/pages/settings/SettingRow.stories.tsxweb/src/pages/settings/SettingRow.tsxweb/src/pages/settings/SettingsHealthSection.stories.tsxweb/src/pages/settings/SettingsHealthSection.tsxweb/src/pages/settings/SourceBadge.stories.tsxweb/src/pages/settings/SourceBadge.tsxweb/src/pages/settings/editor-extensions.tsweb/src/pages/settings/sinks/SinkCard.stories.tsxweb/src/pages/settings/sinks/SinkCard.tsxweb/src/pages/settings/sinks/SinkFormDrawer.tsxweb/src/router/index.tsxweb/src/router/routes.tsweb/src/stores/sinks.tsweb/src/styles/design-tokens.cssweb/src/utils/constants.ts
| try: | ||
| val = await svc.get(SettingNamespace.OBSERVABILITY, key) | ||
| except SettingNotFoundError: | ||
| logger.debug( | ||
| SETTINGS_NOT_FOUND, | ||
| namespace=SettingNamespace.OBSERVABILITY.value, | ||
| key=key, | ||
| ) | ||
| return fallback | ||
| except Exception: | ||
| logger.warning( | ||
| SETTINGS_OBSERVABILITY_VALIDATION_FAILED, | ||
| namespace=SettingNamespace.OBSERVABILITY.value, | ||
| key=key, | ||
| error="Failed to resolve observability setting", | ||
| exc_info=True, | ||
| ) | ||
| return fallback |
There was a problem hiding this comment.
Don't downgrade arbitrary settings-service failures into "missing setting" fallbacks.
The helper says it falls back on not-found, but this branch also hides DB/decryption/service errors and makes list_sinks() return fabricated defaults. Please surface non-SettingNotFoundError failures so operators see the real problem instead of an apparently valid sink list.
Based on learnings, "Handle errors explicitly and never silently swallow exceptions."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/synthorg/api/controllers/settings.py` around lines 451 - 468, The code
currently catches Exception in the svc.get call and returns a fallback, which
hides real failures; change the logic in the block around svc.get (used by
list_sinks()) so that only SettingNotFoundError returns the fallback, and any
other exception is not swallowed—either re-raise the exception or log it and
raise a new error so operators see DB/decryption/service failures; specifically
remove or alter the generic except Exception branch that references
SETTINGS_OBSERVABILITY_VALIDATION_FAILED and ensure svc.get,
SettingNamespace.OBSERVABILITY, and SettingNotFoundError behavior surfaces real
errors instead of fabricating defaults.
| @model_validator(mode="after") | ||
| def _validate_zero_calls_consistency(self) -> Self: | ||
| """Ensure zero calls implies no average response time.""" | ||
| if self.calls_last_24h == 0 and self.avg_response_time_ms is not None: | ||
| msg = "avg_response_time_ms must be None when calls_last_24h is 0" | ||
| raise ValueError(msg) | ||
| return self |
There was a problem hiding this comment.
Finish the ProviderHealthSummary consistency checks.
This only guards the zero-call half of the contract. A caller can still build ProviderHealthSummary(calls_last_24h=3) or ProviderHealthSummary(calls_last_24h=0, last_check_timestamp=...), which leaves the API model contradicting its own field semantics.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/synthorg/providers/health.py` around lines 140 - 146, The current
_validate_zero_calls_consistency on ProviderHealthSummary only prevents
avg_response_time_ms when calls_last_24h == 0; extend it (or add a single
model_validator mode="after") to enforce the full contract: if calls_last_24h ==
0 then avg_response_time_ms must be None and last_check_timestamp must be None,
and if calls_last_24h > 0 then avg_response_time_ms must be not None and
last_check_timestamp must be not None; raise ValueError with clear messages
referencing the offending fields when the invariants fail.
| def test_invalid_json_returns_error( | ||
| self, | ||
| test_client: TestClient[Any], | ||
| auth_headers: dict[str, str], | ||
| ) -> None: | ||
| resp = test_client.post( | ||
| "/api/v1/settings/observability/sinks/_test", | ||
| json={"sink_overrides": "not-json", "custom_sinks": "[]"}, | ||
| headers=auth_headers, | ||
| ) | ||
| assert resp.status_code == 201 | ||
| body = resp.json() | ||
| assert body["data"]["valid"] is False | ||
| assert body["data"]["error"] is not None | ||
| assert "Invalid JSON" in body["data"]["error"] | ||
|
|
||
| def test_invalid_sink_identifier_returns_error( | ||
| self, | ||
| test_client: TestClient[Any], | ||
| auth_headers: dict[str, str], | ||
| ) -> None: | ||
| overrides = json.dumps( | ||
| { | ||
| "nonexistent_sink": {"level": "info"}, | ||
| } | ||
| ) | ||
| resp = test_client.post( | ||
| "/api/v1/settings/observability/sinks/_test", | ||
| json={"sink_overrides": overrides, "custom_sinks": "[]"}, | ||
| headers=auth_headers, | ||
| ) | ||
| assert resp.status_code == 201 | ||
| body = resp.json() | ||
| assert body["data"]["valid"] is False | ||
| assert "Unknown sink identifier" in body["data"]["error"] | ||
|
|
||
| def test_disable_console_returns_error( | ||
| self, | ||
| test_client: TestClient[Any], | ||
| auth_headers: dict[str, str], | ||
| ) -> None: | ||
| overrides = json.dumps( | ||
| { | ||
| "__console__": {"enabled": False}, | ||
| } | ||
| ) | ||
| resp = test_client.post( | ||
| "/api/v1/settings/observability/sinks/_test", | ||
| json={"sink_overrides": overrides, "custom_sinks": "[]"}, | ||
| headers=auth_headers, | ||
| ) | ||
| assert resp.status_code == 201 | ||
| body = resp.json() | ||
| assert body["data"]["valid"] is False | ||
| assert "console" in body["data"]["error"].lower() | ||
|
|
||
| def test_invalid_level_returns_error( | ||
| self, | ||
| test_client: TestClient[Any], | ||
| auth_headers: dict[str, str], | ||
| ) -> None: | ||
| overrides = json.dumps( | ||
| { | ||
| "__console__": {"level": "INVALID_LEVEL"}, | ||
| } | ||
| ) | ||
| resp = test_client.post( | ||
| "/api/v1/settings/observability/sinks/_test", | ||
| json={"sink_overrides": overrides, "custom_sinks": "[]"}, | ||
| headers=auth_headers, | ||
| ) | ||
| assert resp.status_code == 201 | ||
| body = resp.json() | ||
| assert body["data"]["valid"] is False | ||
| assert "Invalid level" in body["data"]["error"] | ||
|
|
||
| def test_custom_sink_missing_path_returns_error( | ||
| self, | ||
| test_client: TestClient[Any], | ||
| auth_headers: dict[str, str], | ||
| ) -> None: | ||
| custom = json.dumps([{"level": "info"}]) | ||
| resp = test_client.post( | ||
| "/api/v1/settings/observability/sinks/_test", | ||
| json={"sink_overrides": "{}", "custom_sinks": custom}, | ||
| headers=auth_headers, | ||
| ) | ||
| assert resp.status_code == 201 | ||
| body = resp.json() | ||
| assert body["data"]["valid"] is False | ||
| assert "file_path" in body["data"]["error"] |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Parametrize the negative _test cases.
These POST cases only vary by payload and expected error text. Folding them into one @pytest.mark.parametrize test will keep the suite shorter and make failures easier to scan.
As per coding guidelines, "Prefer @pytest.mark.parametrize for testing similar cases."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/unit/api/controllers/test_settings_sinks.py` around lines 243 - 333,
Multiple near-duplicate negative POST tests (test_invalid_json_returns_error,
test_invalid_sink_identifier_returns_error, test_disable_console_returns_error,
test_invalid_level_returns_error, test_custom_sink_missing_path_returns_error)
differ only by request payload and expected error text; refactor them into a
single parametrized test using pytest.mark.parametrize that supplies tuples of
(json_payload, expected_error_substring) and asserts resp.status_code,
body["data"]["valid"] is False and expected_error_substring in
body["data"]["error"]; update or remove the individual test_* functions and keep
one parametrized function (e.g., test_invalid_sinks_parametrized) that posts to
"/api/v1/settings/observability/sinks/_test" with auth_headers and iterates the
cases.
| it('disables input when disabled prop is true', () => { | ||
| render(<TagInput value={['a']} onChange={() => {}} disabled />) | ||
| expect(screen.getByRole('textbox')).toBeDisabled() | ||
| }) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Exercise the disabled path through onChange, not just the textbox state.
This only proves the input element is disabled. Because each existing tag also exposes a remove action, add an interaction assertion so a disabled TagInput can't still mutate via chip removal or keyboard shortcuts.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/__tests__/components/ui/TagInput.test.tsx` around lines 69 - 72,
Extend the "disables input when disabled prop is true" test for TagInput to
assert disabled behavior through its onChange pathway: render <TagInput
value={['a']} onChange={mockFn} disabled /> using a jest.fn() for onChange, then
try to remove the existing tag both by interacting with the tag's remove control
(the chip's remove button/element) and by simulating keyboard removal
(Backspace/Delete) on the textbox; assert that mockFn was not called and the tag
remains present. Reference the TagInput component and the onChange prop to
locate where to add these interactions.
| }) | ||
|
|
||
| it('calls onCancel after confirm dialog confirmation', async () => { | ||
| it('calls onCancel after confirm dialog confirmation', { timeout: 15000 }, async () => { |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Avoid timeout inflation for this interaction test.
Line 159 adds a 15s timeout to a short, deterministic flow. Prefer default timeout to keep slow/hanging behavior visible sooner.
♻️ Proposed change
- it('calls onCancel after confirm dialog confirmation', { timeout: 15000 }, async () => {
+ it('calls onCancel after confirm dialog confirmation', async () => {📝 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.
| it('calls onCancel after confirm dialog confirmation', { timeout: 15000 }, async () => { | |
| it('calls onCancel after confirm dialog confirmation', async () => { |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/__tests__/pages/tasks/TaskDetailPanel.test.tsx` at line 159, The test
"calls onCancel after confirm dialog confirmation" in TaskDetailPanel.test.tsx
has an unnecessary explicit timeout ({ timeout: 15000 }) for a short
deterministic interaction; remove the timeout options object so the it(...) call
uses the default Jest timeout (i.e., change it from it('calls onCancel after
confirm dialog confirmation', { timeout: 15000 }, async () => ...) to it('calls
onCancel after confirm dialog confirmation', async () => ...)), keeping the
async test body and assertions unchanged.
| data-setting-key={compositeKey} | ||
| className={cn( | ||
| 'grid grid-cols-[1fr_auto] items-start gap-4 rounded-md px-3 py-3', | ||
| 'transition-colors hover:bg-card-hover', | ||
| controllerDisabled && 'opacity-50', | ||
| 'grid grid-cols-[1fr_auto] items-start gap-grid-gap rounded-md p-card max-[639px]:grid-cols-1', | ||
| 'transition-all duration-200 hover:bg-card-hover hover:-translate-y-px', | ||
| controllerDisabled && 'opacity-50 cursor-not-allowed', | ||
| )} | ||
| style={flashStyle} | ||
| title={controllerDisabled ? 'Enable the parent setting to configure this option' : undefined} |
There was a problem hiding this comment.
Make the disabled-state hint actionable and accessible.
The new fallback here only exposes a generic title message, so users still are not told which controller setting is blocking the field, and the hint is effectively unavailable on touch/unreliable for keyboard access. Please pass the controlling setting label/key through and render it with a real tooltip/aria-describedby pattern instead.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/settings/SettingRow.tsx` around lines 73 - 80, The disabled
hint currently uses a generic title on the container (SettingRow component)
which is inaccessible on touch and doesn't identify the controlling setting;
update SettingRow to accept the controlling setting's label/key (e.g., pass a
new prop like controllerLabel or controllerKey alongside compositeKey), and when
controllerDisabled is true render a proper tooltip element (or visually hidden
hint) with a stable id and set aria-describedby on the root container instead of
title; ensure the tooltip contains the controllerLabel (e.g., "Enable
<controllerLabel> to configure this option") and remove the title attribute so
keyboard and touch users can access the descriptive message reliably.
| <div | ||
| className={cn( | ||
| 'flex flex-col rounded-lg border border-border bg-card', | ||
| 'transition-all duration-200 hover:bg-card-hover hover:-translate-y-px hover:shadow-[var(--so-shadow-card-hover)]', | ||
| !sink.enabled && 'opacity-50', | ||
| )} | ||
| > | ||
| <div className="flex items-center gap-3 p-card"> | ||
| <span className="text-text-secondary"> | ||
| {sink.sink_type === 'console' ? ( | ||
| <Monitor className="size-4" aria-hidden /> | ||
| ) : ( | ||
| <FileText className="size-4" aria-hidden /> | ||
| )} | ||
| </span> | ||
| <span className="min-w-0 flex-1 truncate font-mono text-xs font-medium text-foreground"> | ||
| {sink.identifier} | ||
| </span> | ||
| <span | ||
| className={cn( | ||
| 'inline-flex h-2 w-2 rounded-full', | ||
| sink.enabled ? 'bg-success' : 'bg-text-muted', | ||
| )} | ||
| role="status" | ||
| aria-label={sink.enabled ? 'Enabled' : 'Disabled'} | ||
| title={sink.enabled ? 'Enabled' : 'Disabled'} | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="border-t border-border p-card space-y-2"> | ||
| <div className="flex flex-wrap items-center gap-2"> | ||
| <span className={cn('rounded px-1.5 py-0.5 text-micro font-medium uppercase', levelColor)}> | ||
| {sink.level} | ||
| </span> | ||
| <span className="text-micro text-text-muted"> | ||
| {sink.json_format ? 'JSON' : 'Text'} | ||
| </span> | ||
| {sink.is_default && ( | ||
| <span className="text-micro text-text-muted">Default</span> | ||
| )} | ||
| </div> | ||
| {sink.rotation && ( | ||
| <p className="text-micro text-text-muted"> | ||
| Rotation: {(sink.rotation.max_bytes / 1024 / 1024).toFixed(0)} MB x {sink.rotation.backup_count} | ||
| </p> | ||
| )} | ||
| {sink.routing_prefixes.length > 0 && ( | ||
| <p className="truncate text-micro text-text-muted" title={sink.routing_prefixes.join(', ')}> | ||
| Routes: {sink.routing_prefixes.join(', ')} | ||
| </p> | ||
| )} | ||
| </div> | ||
|
|
||
| <div className="border-t border-border p-card flex justify-end"> | ||
| <Button variant="ghost" size="sm" onClick={() => onEdit(sink)}> | ||
| <Pencil className="mr-1.5 size-3" aria-hidden /> | ||
| Edit | ||
| </Button> | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Compose this from the existing dashboard primitives.
This reimplements both a card-with-header layout and the enabled/disabled badge inline. Please build it from SectionCard and StatusBadge so the sinks grid stays consistent with the rest of the settings UI.
As per coding guidelines, "Do not build card-with-header layouts from scratch -- use the <SectionCard> component instead." and "Do not recreate status dots inline -- use the <StatusBadge> component instead."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/settings/sinks/SinkCard.tsx` around lines 23 - 82, Replace the
manual card/header and inline status dot in SinkCard with the dashboard
primitives: use SectionCard to render the outer card and header area (move the
icon, identifier, and actions into SectionCard's header slot and keep the
rotation/routes/level content in its body) and replace the inline status dot
span with StatusBadge (pass sink.enabled to StatusBadge and keep aria/title
semantics). Preserve existing behavior: keep the level badge using levelColor,
the JSON/Text and Default labels, the rotation and routing text, and the Edit
button calling onEdit(sink); ensure any className/spacing from the original is
applied via SectionCard props or wrapper elements so styles remain consistent.
| export function SinkFormDrawer({ open, onClose, sink, isNew, onTest, onSave }: SinkFormDrawerProps) { | ||
| // State initialized from sink prop. Parent uses key={sink?.identifier} to remount on sink change. | ||
| const [filePath, setFilePath] = useState(sink?.identifier === '__console__' ? '' : (sink?.identifier ?? '')) | ||
| const [level, setLevel] = useState<LogLevel>(sink?.level ?? 'INFO') | ||
| const [enabled, setEnabled] = useState(sink?.enabled ?? true) | ||
| const [jsonFormat, setJsonFormat] = useState(sink?.json_format ?? false) | ||
| const [rotationStrategy, setRotationStrategy] = useState<'builtin' | 'external' | 'none'>(sink?.rotation?.strategy ?? 'none') | ||
| const [maxBytes, setMaxBytes] = useState(String(sink?.rotation?.max_bytes ?? 10485760)) | ||
| const [backupCount, setBackupCount] = useState(String(sink?.rotation?.backup_count ?? 5)) | ||
| const [routingPrefixes, setRoutingPrefixes] = useState<string[]>(sink?.routing_prefixes ? [...sink.routing_prefixes] : []) | ||
| const [testResult, setTestResult] = useState<TestSinkResult | null>(null) | ||
| const [testing, setTesting] = useState(false) | ||
| const [filePathError, setFilePathError] = useState<string | null>(null) | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's view the full SinkFormDrawer.tsx file to see if there are useEffect hooks or onClose handlers
cat web/src/pages/settings/sinks/SinkFormDrawer.tsxRepository: Aureliolo/synthorg
Length of output: 8519
🏁 Script executed:
# Check the parent component that uses SinkFormDrawer to understand how it's instantiated
fd -e tsx -e ts . web/src/pages/settings/sinks/ | head -20Repository: Aureliolo/synthorg
Length of output: 201
🏁 Script executed:
# Search for where SinkFormDrawer is used (parent component)
rg "SinkFormDrawer" web/src/pages/settings/sinks/ -A 3 -B 3Repository: Aureliolo/synthorg
Length of output: 1298
🏁 Script executed:
# Search for the parent component that uses SinkFormDrawer
rg "SinkFormDrawer" web/src -A 5 -B 5 --type tsx --type ts | head -80Repository: Aureliolo/synthorg
Length of output: 89
🏁 Script executed:
# Look for the sink settings page component
find web/src/pages/settings/sinks -name "*.tsx" -o -name "*.ts" | xargs ls -laRepository: Aureliolo/synthorg
Length of output: 348
🏁 Script executed:
# Search for where SinkFormDrawer is used (parent component)
rg "SinkFormDrawer" web/src -A 5 -B 5 | head -100Repository: Aureliolo/synthorg
Length of output: 3596
🏁 Script executed:
# Check for any index or main page file in sinks directory
cat web/src/pages/settings/sinks/SinkCard.tsxRepository: Aureliolo/synthorg
Length of output: 3029
🏁 Script executed:
# View the full SettingsSinksPage.tsx to understand the complete drawer open/close flow
cat web/src/pages/SettingsSinksPage.tsxRepository: Aureliolo/synthorg
Length of output: 4330
Reset all form state when the drawer opens, since component remounting strategy doesn't cover all reopen scenarios.
The key={editSink?.identifier ?? (isNewSink ? '__new__' : '__closed__')} strategy in the parent remounts the component when switching between different sinks, but fails when reopening the same sink (key unchanged) or reopening "add new" flow (key stays '__new__'). In both cases, stale testResult, filePathError, and draft form values persist across drawer close/reopen cycles.
Add a useEffect that clears state when the drawer opens, or manually reset all state in onClose before closing.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/settings/sinks/SinkFormDrawer.tsx` around lines 33 - 46, The
form retains stale state across drawer reopen; in SinkFormDrawer add a useEffect
that runs when the prop open becomes true and resets all local state (filePath,
level, enabled, jsonFormat, rotationStrategy, maxBytes, backupCount,
routingPrefixes, testResult, testing, filePathError) back to their initial
values derived from sink (use the same initial logic used in useState calls such
as sink?.identifier === '__console__' ? '' : (sink?.identifier ?? ''),
sink?.level ?? 'INFO', sink?.enabled ?? true, sink?.json_format ?? false,
sink?.rotation?.strategy ?? 'none', String(sink?.rotation?.max_bytes ??
10485760), String(sink?.rotation?.backup_count ?? 5), sink?.routing_prefixes ?
[...sink.routing_prefixes] : []); alternatively you can clear those same fields
inside the onClose handler before closing—ensure testResult and filePathError
are explicitly set to null and testing to false so no stale test state persists.
b9b98d6 to
32065c2
Compare
There was a problem hiding this comment.
Actionable comments posted: 15
♻️ Duplicate comments (24)
web/src/__tests__/pages/settings/SourceBadge.test.tsx (1)
44-50:⚠️ Potential issue | 🟡 Minor“covers all SettingSource values” is still non-assertive.
This test currently only verifies mount/unmount, so it won’t fail if source mappings break. It should assert expected output per source (visible label vs null).
Proposed test hardening
it('covers all SettingSource values', () => { - // Exhaustive check that every SettingSource is handled - const sources: SettingSource[] = ['db', 'env', 'yaml', 'default'] - for (const source of sources) { - const { unmount } = render(<SourceBadge source={source} />) - unmount() + const expected: Record<SettingSource, string | null> = { + db: 'Modified', + env: 'ENV', + yaml: null, + default: null, + } + + for (const source of Object.keys(expected) as SettingSource[]) { + const { container, unmount } = render(<SourceBadge source={source} />) + const label = expected[source] + if (label) { + expect(screen.getByText(label)).toBeInTheDocument() + } else { + expect(container.firstChild).toBeNull() + } + unmount() } })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/pages/settings/SourceBadge.test.tsx` around lines 44 - 50, The test for SettingSource in SourceBadge currently only mounts/unmounts; change it to assert the rendered output for each SettingSource value by rendering SourceBadge with each source and checking visibility: use render(...) and assert expected text via screen.getByText or screen.queryByText (or similar) for sources that should show a badge (e.g., 'db','env','yaml') and assert null/absence for the source that should render nothing (e.g., 'default'); update the test named 'covers all SettingSource values' to iterate the SettingSource array, map each value to its expected visible label or null, and assert accordingly using SourceBadge and React Testing Library queries.web/src/__tests__/pages/LoginPage.test.tsx (1)
186-186:⚠️ Potential issue | 🟡 MinorRemove the timeout inflation for this deterministic validation test.
Line 186 adds a 15s timeout for a short single-path form check; this can mask slowness regressions and slows feedback in CI.
Suggested change
- it('admin creation validates password match', { timeout: 15000 }, async () => { + it('admin creation validates password match', async () => {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/pages/LoginPage.test.tsx` at line 186, Remove the unnecessary 15s timeout from the deterministic test "admin creation validates password match": edit the `it` call that currently includes the options object `{ timeout: 15000 }` and replace it with a normal `it('admin creation validates password match', async () => { ... })` so the test runs with default timing and doesn't mask regressions or slow CI feedback.web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx (1)
28-33:⚠️ Potential issue | 🟡 MinorUse a stable semantic assertion for hidden state.
Line 32 relies on a CSS implementation detail (
[class*="sticky"]), which is brittle under style refactors. Prefer semantic absence checks (e.g., Save/Discard buttons not present) or a dedicated test id.♻️ Proposed test adjustment
- it('is hidden when dirtyCount is 0', () => { - const { container } = render(<FloatingSaveBar {...defaultProps} dirtyCount={0} />) + it('is hidden when dirtyCount is 0', () => { + render(<FloatingSaveBar {...defaultProps} dirtyCount={0} />) expect(screen.queryByText(/unsaved/)).not.toBeInTheDocument() - // The ConfirmDialog is still mounted but not open, so only check for visible bar content - expect(container.querySelector('[class*="sticky"]')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /discard/i })).not.toBeInTheDocument() })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx` around lines 28 - 33, The test for FloatingSaveBar uses a brittle class selector to assert hidden state; change it to a semantic assertion by checking for the absence of visible Save/Discard controls or a dedicated test id instead: when rendering <FloatingSaveBar {...defaultProps} dirtyCount={0} assert that screen.queryByRole('button', { name: /save/i }) and screen.queryByRole('button', { name: /discard/i }) (or an element queried by a stable data-testid exposed by FloatingSaveBar/ConfirmDialog) are not in the document, ensuring the component is hidden without relying on CSS class names.src/synthorg/providers/health.py (1)
140-146:⚠️ Potential issue | 🟡 MinorComplete
ProviderHealthSummaryconsistency checks in both directions.This validator only blocks one zero-call case. It still allows contradictory payloads (e.g., non-zero calls with missing
avg_response_time_ms/last_check_timestamp, or zero calls with a timestamp).🔧 Proposed fix
`@model_validator`(mode="after") def _validate_zero_calls_consistency(self) -> Self: - """Ensure zero calls implies no average response time.""" - if self.calls_last_24h == 0 and self.avg_response_time_ms is not None: - msg = "avg_response_time_ms must be None when calls_last_24h is 0" - raise ValueError(msg) + """Keep summary fields consistent with call count.""" + if self.calls_last_24h == 0: + if self.avg_response_time_ms is not None: + msg = "avg_response_time_ms must be None when calls_last_24h is 0" + raise ValueError(msg) + if self.last_check_timestamp is not None: + msg = "last_check_timestamp must be None when calls_last_24h is 0" + raise ValueError(msg) + return self + + if self.avg_response_time_ms is None: + msg = "avg_response_time_ms must be set when calls_last_24h is greater than 0" + raise ValueError(msg) + if self.last_check_timestamp is None: + msg = "last_check_timestamp must be set when calls_last_24h is greater than 0" + raise ValueError(msg) return self🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/synthorg/providers/health.py` around lines 140 - 146, The model validator _validate_zero_calls_consistency on ProviderHealthSummary only checks one direction; update it to enforce both directions: if calls_last_24h == 0 then avg_response_time_ms and last_check_timestamp must be None (and raise ValueError with a clear message if not), and if calls_last_24h > 0 then avg_response_time_ms and last_check_timestamp must be present and valid (not None, and avg_response_time_ms >= 0 and last_check_timestamp a valid timestamp) otherwise raise ValueError; keep the method name _validate_zero_calls_consistency, return self, and use descriptive error messages referencing the offending fields.web/src/__tests__/components/ui/TagInput.test.tsx (2)
69-72:⚠️ Potential issue | 🟡 MinorExercise disabled behavior through
onChangepathways too.This test currently proves only textbox disabled state; it should also confirm no mutation via chip removal/backspace when disabled.
🧪 Suggested expansion
-it('disables input when disabled prop is true', () => { - render(<TagInput value={['a']} onChange={() => {}} disabled />) - expect(screen.getByRole('textbox')).toBeDisabled() -}) +it('disables input and prevents tag mutation when disabled', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render(<TagInput value={['a']} onChange={onChange} disabled />) + const input = screen.getByRole('textbox') + expect(input).toBeDisabled() + + const remove = screen.queryByRole('button', { name: /remove/i }) + if (remove) await user.click(remove) + await user.click(input) + await user.keyboard('{Backspace}') + + expect(onChange).not.toHaveBeenCalled() + expect(screen.getByText('a')).toBeInTheDocument() +})🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/components/ui/TagInput.test.tsx` around lines 69 - 72, The test for TagInput's disabled behavior should also assert that no mutations occur via onChange when users attempt chip removal or keyboard deletion: update the test "disables input when disabled prop is true" to use a jest.fn() for onChange, render <TagInput value={['a']} onChange={onChangeMock} disabled />, then simulate user interactions that would normally remove a chip (clicking the chip's remove button and sending a Backspace/Delete keydown on the textbox using userEvent/ fireEvent) and assert onChangeMock was not called; reference the TagInput component and its onChange prop to locate where to add these interaction checks.
35-37:⚠️ Potential issue | 🟡 MinorAssert remove-button count before indexed click.
Using
removeButtons[1]!without a length precondition can fail unclearly if markup changes.🔧 Suggested tweak
const removeButtons = screen.getAllByRole('button', { name: /remove/i }) +expect(removeButtons).toHaveLength(3) await user.click(removeButtons[1]!) // Remove 'b'🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/components/ui/TagInput.test.tsx` around lines 35 - 37, The test uses removeButtons[1] without asserting there are at least two remove buttons; update the test around the removeButtons variable (const removeButtons = screen.getAllByRole('button', { name: /remove/i })) to first assert the expected count (e.g., expect(removeButtons).toHaveLength(3) or expect(removeButtons.length).toBeGreaterThanOrEqual(2)) before calling await user.click(removeButtons[1]) and then keep the existing expect(onChange).toHaveBeenCalledWith(['a', 'c']) assertion so failures are clear when markup changes.web/src/__tests__/hooks/useSettingsKeyboard.test.ts (1)
5-48:⚠️ Potential issue | 🟡 MinorAdd macOS shortcut coverage (
metaKey) for Cmd+S and Cmd+/.Current tests only exercise
ctrlKey. Please mirror these cases withmetaKey: trueto protect cross-platform behavior.🧪 Suggested additions
+it('calls onSave on Cmd+S when canSave is true', () => { + const onSave = vi.fn() + const onSearchFocus = vi.fn() + renderHook(() => + useSettingsKeyboard({ onSave, onSearchFocus, canSave: true }), + ) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', metaKey: true, bubbles: true })) + expect(onSave).toHaveBeenCalledOnce() +}) + +it('calls onSearchFocus on Cmd+/', () => { + const onSave = vi.fn() + const onSearchFocus = vi.fn() + renderHook(() => + useSettingsKeyboard({ onSave, onSearchFocus, canSave: false }), + ) + document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', metaKey: true, bubbles: true })) + expect(onSearchFocus).toHaveBeenCalledOnce() +})🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/hooks/useSettingsKeyboard.test.ts` around lines 5 - 48, Add macOS (Command) coverage to the existing keyboard tests by duplicating the Ctrl+S and Ctrl+/ cases using metaKey: true; specifically update the tests that call useSettingsKeyboard and dispatch KeyboardEvent to also dispatch events with key: 's' and metaKey: true to assert onSave behavior (when canSave true/false) and dispatch key: '/' with metaKey: true to assert onSearchFocus is called, referencing the existing test helpers onSave and onSearchFocus and the hook under test useSettingsKeyboard so both ctrlKey and metaKey behaviors are covered.web/src/components/ui/code-mirror-editor.tsx (1)
24-29: 🧹 Nitpick | 🔵 TrivialConsider adding memoization guidance to the JSDoc.
The
extraExtensionsreconfiguration effect (lines 240-250) runs whenever the array reference changes. If callers pass inline arrays, this causes unnecessary reconfiguration on every render. Consider adding a@remarksnote recommendinguseMemofor the extensions array.Suggested documentation addition
/** * Extra CodeMirror extensions appended after the built-in ones. * Useful for adding diff gutters, linters, or autocomplete from * the consuming page without modifying this shared component. + * + * `@remarks` Wrap in `useMemo` to avoid unnecessary reconfiguration on re-renders. */ extensions?: Extension[]🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/code-mirror-editor.tsx` around lines 24 - 29, Update the JSDoc for the extensions prop in the CodeMirrorEditor component to add a `@remarks` note advising callers to memoize the extensions array (e.g., with React.useMemo) because the internal extraExtensions reconfiguration effect (which watches the array reference) will re-run whenever a new array reference is passed; reference the prop name "extensions" and the internal reconfiguration behavior "extraExtensions" so consumers know why and how to prevent unnecessary reconfigurations.web/src/components/ui/tag-input.stories.tsx (2)
16-18:⚠️ Potential issue | 🟡 MinorSync the controlled stories when Storybook Controls change.
These render functions initialize local state from
args.valueonce, then stop following later control updates. Add a small sync effect in each controlled story so the canvas stays aligned with the Controls panel.Suggested fix
-import { useState } from 'react' +import { useEffect, useState } from 'react' render: function Render(args) { const [value, setValue] = useState(args.value) + useEffect(() => { + setValue(args.value) + }, [args.value]) return <TagInput {...args} value={value} onChange={setValue} /> },Also applies to: 24-26, 38-40
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.stories.tsx` around lines 16 - 18, The controlled story Render initializes local state from args.value but doesn't update when Storybook Controls change; update the story by importing useEffect and adding an effect in the Render function that watches args.value and calls setValue(args.value) so the local state (value) always syncs to controls; locate the Render function in web/src/components/ui/tag-input.stories.tsx (the function that calls useState(args.value) and returns <TagInput {...args} value={value} onChange={setValue} />) and add a useEffect(() => setValue(args.value), [args.value]) to keep the canvas aligned with Controls; apply the same change to the other controlled stories at the referenced spots (lines ~24-26 and ~38-40).
14-42: 🛠️ Refactor suggestion | 🟠 MajorComplete the shared-component story contract.
TagInputis a new shared UI component, but this file still lacks the requiredHover,Loading, andErrorstates. That leaves the Storybook surface incomplete for design review and regression coverage.As per coding guidelines, "All story files must include a
.stories.tsxfile alongside new shared components with all states (default, hover, loading, error, empty)."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.stories.tsx` around lines 14 - 42, Add the missing stories named Hover, Loading, and Error for the TagInput story file to satisfy the shared-component story contract; for each story follow the existing pattern (export const Hover/Loading/Error: Story = { args: {...}, render: function Render(args) { const [value, setValue] = useState(args.value); return <TagInput {...args} value={value} onChange={setValue} /> } }) — for Hover provide sensible args (e.g., value and placeholder) so designers can inspect the hovered visual state, for Loading include a loading flag in args (e.g., loading: true or isLoading: true) to exercise the spinner/state, and for Error include an error prop in args (e.g., error: 'Validation error' or errorMessage) so the error UI is shown; keep names exactly Hover, Loading, Error to match the contract and mirror the render pattern used by Default/Empty/ManyTags.web/src/pages/settings/SettingRow.tsx (1)
72-80:⚠️ Potential issue | 🟠 MajorExpose the dependency reason with an accessible description.
The disabled-state hint is still only a generic
title, so keyboard and touch users do not reliably get the explanation, and it still does not say which controller setting is blocking the field. Please pass the controlling label/key through and surface it with a real tooltip oraria-describedbypattern instead oftitle.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/SettingRow.tsx` around lines 72 - 80, The disabled hint currently uses a title on the container; replace that with an accessible description using aria-describedby when controllerDisabled is true: render a hidden descriptive element (e.g., <span id={`setting-desc-${compositeKey}`}> or a Tooltip component) that contains the controller label/key and reason (like "Disabled because [controllerLabel] is off"), set the container's aria-describedby to that id instead of title, and remove the title attribute; use the existing compositeKey and controllerDisabled variables to build the id and message so keyboard and screen-reader users get the specific dependency reason.web/src/pages/settings/SettingField.tsx (1)
30-47:⚠️ Potential issue | 🟠 MajorPreserve
validator_patternenforcement for tag-backed arrays.
ArraySettingFieldnow sends every tag change straight through toonChange(JSON.stringify(next)), but it never runsvalidate(). Any array setting with a regex validator now accepts invalid items locally and only fails after save.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/SettingField.tsx` around lines 30 - 47, ArraySettingField currently serializes tag changes with onChange(JSON.stringify(next)) without validating items, so regex-backed array settings accept invalid entries locally; update the TagInput onChange handler in ArraySettingField to run the same validation used elsewhere (call the existing validate(...) / validateSetting(...) logic or the module-level validate function) on each candidate item from parseArrayItems and only include items that pass validation (or prevent the change and surface an error) before calling onChange; locate the TagInput onChange inside ArraySettingField and integrate the validation step so invalid tags are filtered/rejected prior to JSON.stringify(next).web/src/__tests__/pages/settings/sinks/SinkCard.test.tsx (1)
96-101:⚠️ Potential issue | 🟡 MinorMake this test assert the icon it names.
This still only checks the
'Enabled'title, so it would pass for any enabled sink regardless ofsink_type. Either rename the test or give the console/file icons stable selectors or accessible labels and assert the actual console icon here.web/src/pages/settings/CodeEditorPanel.tsx (1)
315-336: 🧹 Nitpick | 🔵 TrivialKeep the diff summary on the same algorithm as the gutter.
diffSummarystill counts changes by line index, so simple moves/reorders can report totals that disagree with the gutter markers fromdispatchDiff(). Either reuse the same diff routine here or label this text as approximate.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/CodeEditorPanel.tsx` around lines 315 - 336, The diffSummary calculation currently compares serverText and text by line index which can disagree with the gutter markers produced by dispatchDiff(); replace the ad-hoc line-index counting in diffSummary with the same diff routine used by dispatchDiff() (or call dispatchDiff()/its shared helper) to compute changed/added/removed counts from serverText and text so the summary matches the gutter, ensuring you reference the existing dispatchDiff function or its underlying diff helper when locating and replacing the logic in the diffSummary useMemo.web/src/pages/settings/sinks/SinkCard.tsx (1)
23-82: 🛠️ Refactor suggestion | 🟠 MajorCompose this with
SectionCardandStatusBadge.The component still hand-rolls both the card shell and the enabled dot, so it diverges from the shared settings primitives you're standardizing on.
As per coding guidelines, "Do not build card-with-header layouts from scratch -- use the
<SectionCard>component instead." and "Do not recreate status dots inline -- use the<StatusBadge>component instead."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/sinks/SinkCard.tsx` around lines 23 - 82, Replace the hand-rolled card markup and inline status dot in SinkCard with the shared primitives: use SectionCard for the outer container/header/footer instead of the top-level div and the three inner border-t sections, and use StatusBadge in place of the inline rounded dot span (map sink.enabled to StatusBadge's status/variant props). Keep the existing content (Monitor/FileText icon, sink.identifier, level tag using levelColor, JSON/Text, Default, rotation and routes) but move the edit Button into SectionCard's footer/actions area and call onEdit(sink) from that action; ensure routing_prefixes and rotation rendering logic remains unchanged and any aria/title props are preserved on equivalent elements.src/synthorg/api/controllers/settings.py (1)
451-468:⚠️ Potential issue | 🟠 MajorDon't mask settings-service failures as defaults.
_get_setting_or_default()still catches every exception and returnsfallback, so DB/decryption/service failures show up as a seemingly valid default sink list. OnlySettingNotFoundErrorshould fall back here; other failures need to surface.Based on learnings, "Handle errors explicitly and never silently swallow exceptions."🚫 Let non-not-found errors surface
except SettingNotFoundError: logger.debug( SETTINGS_NOT_FOUND, namespace=SettingNamespace.OBSERVABILITY.value, key=key, ) return fallback - except Exception: - logger.warning( - SETTINGS_OBSERVABILITY_VALIDATION_FAILED, - namespace=SettingNamespace.OBSERVABILITY.value, - key=key, - error="Failed to resolve observability setting", - exc_info=True, - ) - return fallback + except Exception: + logger.exception( + SETTINGS_OBSERVABILITY_VALIDATION_FAILED, + namespace=SettingNamespace.OBSERVABILITY.value, + key=key, + error="Failed to resolve observability setting", + ) + raise🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/synthorg/api/controllers/settings.py` around lines 451 - 468, The current _get_setting_or_default implementation catches all exceptions and returns the fallback, masking service/DB/decryption errors; update it so only SettingNotFoundError is caught and returns fallback, and remove (or rework) the broad "except Exception" block so other exceptions from svc.get (e.g., DB/decryption/service failures) are allowed to propagate instead of being converted into a default; keep the logger.debug for SettingNotFoundError (using SettingNamespace.OBSERVABILITY and key) but do not log-and-swallow other exceptions from svc.get.web/src/components/ui/tag-input.tsx (2)
79-80:⚠️ Potential issue | 🟠 MajorLabel the text field itself.
The wrapper's group label does not give the actual
<input>an accessible name, and the placeholder disappears once tags exist. Exposearia-label/aria-labelledbyonTagInputand forward it to the input so screen readers can identify the control consistently.Also applies to: 108-118
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.tsx` around lines 79 - 80, The TagInput component currently sets aria-label on the wrapper (role="group") but does not pass any accessible name to the actual <input>, so screen readers lose the control name when the placeholder disappears; update the TagInput props to accept aria-label and aria-labelledby (or a single prop like inputAriaLabel/inputAriaLabelledBy), and forward whichever is provided to the internal input element (or fall back to placeholder or "Tags"), ensuring the input receives aria-label/aria-labelledby; apply the same forwarding change to the other TagInput input instance as noted (both places where the input is rendered).
82-107: 🛠️ Refactor suggestion | 🟠 MajorExtract the chip renderer from the loop.
This
.map()body is still far above the 8-line JSX limit for shared dashboard components. Pull it into a smallTagChipcomponent so the control is easier to maintain, story, and test.As per coding guidelines, "Do not create complex (>8 line) JSX inside
.map()-- extract to a shared component instead."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.tsx` around lines 82 - 107, Extract the chip rendering into a new TagChip component: create a small functional component named TagChip that accepts props {item: string, index: number, disabled: boolean, onRemove: (index: number) => void} and returns the span + optional remove button JSX now inside the .map() (keep the same classes, aria-label, X icon usage and stopPropagation behavior, calling onRemove(index)). In the parent (current tag-input component) leave the value.map loop but replace the long JSX with a single <TagChip .../> call; compute the stableKey (const occurrence = value.slice(0, i).filter(v => v === item).length; const stableKey = `${item}::${occurrence}`) in the parent and use it as key, and pass removeAt as onRemove. Ensure prop and handler names match removeAt so existing logic is reused.web/src/__tests__/stores/sinks.test.ts (1)
34-113: 🧹 Nitpick | 🔵 TrivialAdd unit coverage for
saveSink().This suite still never exercises the read/merge/write path that just fixed multiple data-loss bugs. Please add cases for default override merges, custom sink merges, and invalid persisted JSON/shapes so regressions in
saveSink()are caught.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/stores/sinks.test.ts` around lines 34 - 113, Add unit tests for saveSink by exercising useSinksStore.getState().saveSink to cover the read/merge/write path: (1) a test that verifies default override merges into an existing sink (call makeSink to create baseline sink, mock the persisted data to include older overrides, call saveSink and assert persisted result merges defaults correctly), (2) a test that verifies custom_sinks are merged/updated (seed persisted custom_sinks, call saveSink for a custom sink identifier and assert the persisted custom_sinks list is updated and not replaced), and (3) a test that simulates invalid persisted JSON/shape (mock the persistence read to return malformed JSON or wrong shape, call saveSink and assert it handles the error by overwriting with a valid merged sink or returning the expected error state). Use the same test patterns as existing tests (vi.mocked, makeSink, useSinksStore.getState()) and mock the store’s persistence read/write functions (or localStorage adapter used by saveSink) to observe what is written. Ensure assertions check both the in-memory store state and the final persisted payload.web/src/pages/settings/editor-extensions.ts (3)
562-584:⚠️ Potential issue | 🟠 MajorThe reverse JSON context scan is still counting braces the wrong way.
This loop walks backward but increments on
{and decrements on}, so nested objects break at the inner{and never setcurrentNamespace. Key and enum completion inside nested namespace objects therefore fall back to root suggestions. Flip the brace accounting for reverse traversal; while you're here, skip braces inside quoted strings so literal{/}values don't corrupt depth.Also applies to: 616-629
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/editor-extensions.ts` around lines 562 - 584, The reverse JSON context scan in the loop that walks backward from pos is using braceDepth++ on '{' and -- on '}', which is reversed for reverse traversal and fails to detect enclosing namespace objects (so currentNamespace never gets set); update the logic in that block (and the similar block at the other occurrence around lines 616-629) to decrement braceDepth when encountering '{' and increment when encountering '}' during the reverse scan, and additionally ignore braces that appear inside quoted strings (track whether you're inside a string by toggling on encountering unescaped '"' so literal '{'/' }' inside strings don't affect braceDepth); ensure currentNamespace is set using the existing nsMatch logic when the adjusted braceDepth indicates the namespace opening.
85-98:⚠️ Potential issue | 🟠 MajorOne-line replacements still lose the
changedmarker.
computeLineDiff()never emitschanged, and the gutter later keeps only the first marker for a line. A replacement therefore renders as whichever marker arrives first instead of a change. Merge colliding add/remove pairs into a singlechangedentry before the gutter dedupes them.Also applies to: 196-202
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/editor-extensions.ts` around lines 85 - 98, computeLineDiff currently emits separate 'added' and 'removed' entries so one-line replacements are shown as whichever marker gets deduped first; update computeLineDiff to detect pairs of colliding add/remove entries for the same logical line and merge them into a single entry with kind 'changed' before returning/sorting so the gutter's dedupe will render replacements correctly. Specifically, in computeLineDiff where you build the diffs array (references: diffs, kind 'added', kind 'removed'), detect when an added entry and a removed entry target the same line (and likewise in the duplicated logic around the block at the 196-202 region) and replace both with a single { line: <line>, kind: 'changed' } entry prior to sorting/returning. Ensure you adjust both occurrences so all one-line replacements become 'changed' instead of separate add/removes.
156-176: 🛠️ Refactor suggestion | 🟠 MajorThe editor theme still hardcodes spacing and typography in TS.
These theme objects still embed pixel widths, margins, padding, border widths, max height, and direct
fontFamilydeclarations. That bypasses the dashboard design tokens and will drift from the rest of the settings UI. Move the measurements to existingvar(--so-*)tokens and inherit typography from wrapper classes/tokenized styles instead of settingfontFamilyhere.As per coding guidelines, "Use design tokens exclusively for colors, typography, and spacing -- never hardcode hex values,
rgba()values with hardcoded colors, pixel values for layout spacing, orfontFamilydeclarations directly in.tsx/.tsfiles."Also applies to: 459-498
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/editor-extensions.ts` around lines 156 - 176, The theme object diffGutterTheme currently hardcodes spacing and sizing (e.g., width: '6px', marginRight: '2px', width: '4px', borderRadius: '1px') and likely includes other pixel/typography literals elsewhere; replace those pixel values with the appropriate design tokens (e.g., var(--so-spacing-*) / var(--so-radius-*) / var(--so-size-*)) and remove any direct fontFamily declarations so typography is inherited from the wrapper/tokenized styles; update the CSS rules for '.cm-diff-gutter', '.cm-diff-marker', '.cm-diff-marker-*' to use var(--so-*) tokens and inherited font styles, and apply the same changes to the other EditorView.theme objects in this file that contain hardcoded spacing/typography.web/src/pages/settings/sinks/SinkFormDrawer.tsx (1)
53-58:⚠️ Potential issue | 🟠 MajorValidate rotation fields once and reuse the parsed values.
buildPayload()still forwards rawNumber(...)values for default sink overrides and silently defaults the custom/save path. Invalid numeric input can therefore makeTest Configexercise different data thanSave, or send bogus rotation values upstream. ParsemaxBytes/backupCountonce, reject invalid input, and reuse the validated numbers in both code paths.Also applies to: 77-82, 113-119
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/sinks/SinkFormDrawer.tsx` around lines 53 - 58, The buildPayload() flow currently converts maxBytes/backupCount inline and may send invalid numeric values; update the SinkFormDrawer logic to parse and validate maxBytes and backupCount once into numeric variables (e.g., parsedMaxBytes, parsedBackupCount) before creating override.rotation, rejecting or blocking the operation on NaN/negative values, and reuse those validated numbers in all places that construct overrides or test/save payloads (including the other rotation branches currently using Number(maxBytes)/Number(backupCount)); ensure rotationStrategy and override construction use the validated parsed values so Test Config and Save send identical, validated rotation data.tests/unit/api/controllers/test_settings_sinks.py (1)
243-333: 🧹 Nitpick | 🔵 TrivialParametrize the repeated
_testfailure cases.These POST cases only vary by payload and expected error substring. Folding them into one parametrized test will keep the suite shorter and make failures easier to scan.
As per coding guidelines, "Prefer
@pytest.mark.parametrizefor testing similar cases."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unit/api/controllers/test_settings_sinks.py` around lines 243 - 333, Fold the five near-duplicate tests (test_invalid_json_returns_error, test_invalid_sink_identifier_returns_error, test_disable_console_returns_error, test_invalid_level_returns_error, test_custom_sink_missing_path_returns_error) into a single parametrized test using `@pytest.mark.parametrize` that iterates tuples of (payload_dict_or_string, expected_error_substring); in the new test call POST to "/api/v1/settings/observability/sinks/_test" with json={"sink_overrides": ..., "custom_sinks": ...} (use json.dumps where the original used it), assert resp.status_code == 201, assert body["data"]["valid"] is False and assert expected_error_substring in body["data"]["error"] (or .lower() for case-insensitive checks like "console"); keep the same headers (auth_headers) and preserve each original payload and expected substring as a separate param case.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/design/page-structure.md`:
- Around line 134-140: The endpoints list is missing the write-side CRUD for
observability sinks; update the API inventory to include at minimum the
collection create route and per-sink update/delete/enable routes by adding
entries such as POST /settings/observability/sinks (create/save), PUT
/settings/observability/sinks/{id} (update), DELETE
/settings/observability/sinks/{id} (delete), and a route for toggling or setting
enabled state like PATCH /settings/observability/sinks/{id}/enable or POST
/settings/observability/sinks/{id}/_enable so the documented API matches the
described add/edit/custom sink flows and the existing POST
/settings/observability/sinks/_test.
In `@src/synthorg/observability/sink_config_builder.py`:
- Around line 33-41: This module removed the required observability bootstrap:
add "from synthorg.observability import get_logger" and then initialize module
logger with "logger = get_logger(__name__)" near the top of
src/synthorg/observability/sink_config_builder.py so the module-level logging
contract is restored; ensure the import appears with other imports and the
logger variable is defined before any runtime logic that may emit logs (e.g.,
before DEFAULT_FILE_PATHS, CONSOLE_SINK_ID, or any functions/classes in this
file).
In `@tests/unit/providers/test_health_prober.py`:
- Around line 39-55: The helper _make_prober currently uses "configs or {...}"
which treats an empty dict as None and injects a default provider; change the
conditional to an explicit None check so empty dicts are respected: set
config_resolver.get_provider_configs = AsyncMock(return_value=(configs if
configs is not None else {"test-local": _make_local_config()})), allowing
callers to pass {} to create zero-provider tests; update the same pattern in the
other occurrence around lines 289-330 that sets get_provider_configs so both
helpers accept {} correctly.
In `@web/src/pages/settings/CodeEditorPanel.tsx`:
- Around line 348-356: The split-view toggle button in CodeEditorPanel is
missing an accessible pressed state; update the button element that uses
splitView and setSplitView to include aria-pressed={splitView} so assistive tech
receives the explicit two-state state (use the existing splitView variable for
the value), keeping the existing title and aria-label logic intact.
In `@web/src/pages/settings/FloatingSaveBar.stories.tsx`:
- Around line 12-30: Extract the duplicated story args into a shared constant
(e.g., baseArgs) and spread it into each story's args to remove repetition;
specifically create a baseArgs object containing onSave, onDiscard, saving:
false, and saveError: null, then update SingleChange, MultipleChanges, Saving,
WithError, and Hidden to merge their specific overrides (dirtyCount, saving or
saveError) with baseArgs using object spread so future changes to common
defaults only need to be made in one place.
In `@web/src/pages/settings/RestartBanner.stories.tsx`:
- Around line 12-22: Replace the no-op onDismiss callbacks in the Story exports
(Singular, Plural, Hidden) with Storybook's action helper so interactions are
tracked; import action from '@storybook/addon-actions' and pass
action('onDismiss') as the onDismiss arg for each story instead of () => {} to
enable click logging in the Actions panel for the RestartBanner stories.
In `@web/src/pages/settings/SearchInput.tsx`:
- Around line 69-82: The stale badge appears because the status span is shown
whenever local is truthy even while debounced resultCount is from the previous
query; update the render condition in the SearchInput component so the count
only renders when the input is stable by requiring local === value in addition
to resultCount !== undefined (i.e., change the condition that currently checks
local && resultCount !== undefined to local === value && resultCount !==
undefined), ensuring the badge only appears when the visible input (local)
matches the value used to compute resultCount.
In `@web/src/pages/settings/SettingsHealthSection.stories.tsx`:
- Around line 6-10: The Storybook meta for NamespaceTabBar is missing
accessibility test parameters; update the exported meta object (meta) for
component NamespaceTabBar to include a parameters property that sets a11y.test
(e.g., 'error' or the project's chosen level) so Storybook 10 will run
accessibility checks—add parameters: { a11y: { test: 'error' } } (or the repo’s
configured value) to the meta object.
In `@web/src/pages/settings/SettingsHealthSection.tsx`:
- Around line 51-59: The tab-like controls in SettingsHealthSection use
role="tab" but lack tabpanel linkage and also allow an "All" state that shows
multiple sections (breaking tab semantics); update these controls to either (A)
implement full tabs semantics by giving each tab element an id, setting
aria-controls on the tab to point to the corresponding panel id, adding
role="tabpanel" and aria-labelledby on each panel, and ensuring only one panel
is visible when activeNamespace/selectors change (check handleKeyDown and
activeNamespace usage), or (B) convert them to toggle/filter buttons by changing
role="tab" to role="button", removing tab-specific focus logic, and exposing
state with aria-pressed (true/false) while keeping multiple sections visible for
the "All" state; choose one approach and apply the same change to the other
tab-like controls in this file.
In `@web/src/pages/settings/sinks/SinkCard.stories.tsx`:
- Around line 5-9: The Storybook meta for SinkCard is missing the accessibility
test parameter; update the meta object (const meta: Meta<typeof SinkCard>) to
include parameters.a11y.test: 'error' so the story enforces WCAG checks (add the
a11y.test key under the existing parameters object for SinkCard).
In `@web/src/pages/SettingsPage.tsx`:
- Around line 118-128: The restart-banner count is only computed in handleSave,
so saves made via handleCodeSave bypass the restart prompt; extract or reuse the
same restart-counting logic (the computation using dirtyValues, entries, and
checking entry.definition.restart_required) and invoke it after a successful
handleCodeSave, then call setRestartBannerCount(restartCount) the same way
handleSave does (or unify both save flows to call a shared function) so
code-mode saves update restartBannerCount consistently; apply the same fix for
the other code-save handler referenced in the comment (around the 170-200
region).
In `@web/src/pages/SettingsSinksPage.tsx`:
- Around line 27-33: The sinkHandler in useCallback only refetches sinks for
'observability/sink_overrides' and 'observability/custom_sinks', which misses
changes to the root log level; update the condition in sinkHandler (the function
named sinkHandler that calls fetchSinks and is declared with useCallback) to
also call fetchSinks when the event key equals 'observability/root_log_level'
(or otherwise include that observability key), so list_sinks() rebuilds
effective levels after root_log_level updates.
In `@web/src/router/index.tsx`:
- Line 106: Replace the hardcoded 'settings/observability/sinks' path in
router/index.tsx with the corresponding route constant exported from
web/src/router/routes.ts (import the route symbol from routes.ts and use it as
the path for the SettingsSinksPage route); update the import at the top of
router/index.tsx to bring in the route identifier (e.g., the
settings/observability/sinks route constant) and reference that constant in the
route entry instead of the literal string to avoid drift.
In `@web/src/stores/sinks.ts`:
- Around line 47-54: The code currently accepts any JSON-ish payload for
sink_overrides and custom_sinks which lets arrays or wrong top-level types slip
through; before merging, validate parsed JSON strictly: in the sink_overrides
branch (where you call getNamespaceSettings('observability'), parse
overrideEntry.value and assign to existingOverrides) ensure parsed is a plain
object (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
and throw an error if not; likewise in the custom_sinks handling (the similar
block at lines 62-67) ensure parsed is an array (Array.isArray(parsed)) and
throw if not, so you only call existingOverrides[sink.identifier] =
buildOverrideForSink(sink) and updateSetting('observability', 'sink_overrides',
...) when the stored shape is valid.
- Around line 44-70: The client-side merge in the save logic (using
getNamespaceSettings + updateSetting for keys 'sink_overrides' and
'custom_sinks' and building entries with buildOverrideForSink(sink) and
sink.identifier) is racy under concurrent edits; change the flow so the merge
happens server-side or use a versioned compare-and-swap: send only the intended
delta (the new override or custom sink entry) to a server endpoint that merges
into the stored JSON atomically, or include the current setting version/ETag
with updateSetting and have the server reject mismatched versions so the client
can re-read and retry the merge; update the client to call the new server API
(or pass version metadata to updateSetting) instead of reading, merging, and
writing the whole blob locally.
---
Duplicate comments:
In `@src/synthorg/api/controllers/settings.py`:
- Around line 451-468: The current _get_setting_or_default implementation
catches all exceptions and returns the fallback, masking service/DB/decryption
errors; update it so only SettingNotFoundError is caught and returns fallback,
and remove (or rework) the broad "except Exception" block so other exceptions
from svc.get (e.g., DB/decryption/service failures) are allowed to propagate
instead of being converted into a default; keep the logger.debug for
SettingNotFoundError (using SettingNamespace.OBSERVABILITY and key) but do not
log-and-swallow other exceptions from svc.get.
In `@src/synthorg/providers/health.py`:
- Around line 140-146: The model validator _validate_zero_calls_consistency on
ProviderHealthSummary only checks one direction; update it to enforce both
directions: if calls_last_24h == 0 then avg_response_time_ms and
last_check_timestamp must be None (and raise ValueError with a clear message if
not), and if calls_last_24h > 0 then avg_response_time_ms and
last_check_timestamp must be present and valid (not None, and
avg_response_time_ms >= 0 and last_check_timestamp a valid timestamp) otherwise
raise ValueError; keep the method name _validate_zero_calls_consistency, return
self, and use descriptive error messages referencing the offending fields.
In `@tests/unit/api/controllers/test_settings_sinks.py`:
- Around line 243-333: Fold the five near-duplicate tests
(test_invalid_json_returns_error, test_invalid_sink_identifier_returns_error,
test_disable_console_returns_error, test_invalid_level_returns_error,
test_custom_sink_missing_path_returns_error) into a single parametrized test
using `@pytest.mark.parametrize` that iterates tuples of (payload_dict_or_string,
expected_error_substring); in the new test call POST to
"/api/v1/settings/observability/sinks/_test" with json={"sink_overrides": ...,
"custom_sinks": ...} (use json.dumps where the original used it), assert
resp.status_code == 201, assert body["data"]["valid"] is False and assert
expected_error_substring in body["data"]["error"] (or .lower() for
case-insensitive checks like "console"); keep the same headers (auth_headers)
and preserve each original payload and expected substring as a separate param
case.
In `@web/src/__tests__/components/ui/TagInput.test.tsx`:
- Around line 69-72: The test for TagInput's disabled behavior should also
assert that no mutations occur via onChange when users attempt chip removal or
keyboard deletion: update the test "disables input when disabled prop is true"
to use a jest.fn() for onChange, render <TagInput value={['a']}
onChange={onChangeMock} disabled />, then simulate user interactions that would
normally remove a chip (clicking the chip's remove button and sending a
Backspace/Delete keydown on the textbox using userEvent/ fireEvent) and assert
onChangeMock was not called; reference the TagInput component and its onChange
prop to locate where to add these interaction checks.
- Around line 35-37: The test uses removeButtons[1] without asserting there are
at least two remove buttons; update the test around the removeButtons variable
(const removeButtons = screen.getAllByRole('button', { name: /remove/i })) to
first assert the expected count (e.g., expect(removeButtons).toHaveLength(3) or
expect(removeButtons.length).toBeGreaterThanOrEqual(2)) before calling await
user.click(removeButtons[1]) and then keep the existing
expect(onChange).toHaveBeenCalledWith(['a', 'c']) assertion so failures are
clear when markup changes.
In `@web/src/__tests__/hooks/useSettingsKeyboard.test.ts`:
- Around line 5-48: Add macOS (Command) coverage to the existing keyboard tests
by duplicating the Ctrl+S and Ctrl+/ cases using metaKey: true; specifically
update the tests that call useSettingsKeyboard and dispatch KeyboardEvent to
also dispatch events with key: 's' and metaKey: true to assert onSave behavior
(when canSave true/false) and dispatch key: '/' with metaKey: true to assert
onSearchFocus is called, referencing the existing test helpers onSave and
onSearchFocus and the hook under test useSettingsKeyboard so both ctrlKey and
metaKey behaviors are covered.
In `@web/src/__tests__/pages/LoginPage.test.tsx`:
- Line 186: Remove the unnecessary 15s timeout from the deterministic test
"admin creation validates password match": edit the `it` call that currently
includes the options object `{ timeout: 15000 }` and replace it with a normal
`it('admin creation validates password match', async () => { ... })` so the test
runs with default timing and doesn't mask regressions or slow CI feedback.
In `@web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx`:
- Around line 28-33: The test for FloatingSaveBar uses a brittle class selector
to assert hidden state; change it to a semantic assertion by checking for the
absence of visible Save/Discard controls or a dedicated test id instead: when
rendering <FloatingSaveBar {...defaultProps} dirtyCount={0} assert that
screen.queryByRole('button', { name: /save/i }) and screen.queryByRole('button',
{ name: /discard/i }) (or an element queried by a stable data-testid exposed by
FloatingSaveBar/ConfirmDialog) are not in the document, ensuring the component
is hidden without relying on CSS class names.
In `@web/src/__tests__/pages/settings/SourceBadge.test.tsx`:
- Around line 44-50: The test for SettingSource in SourceBadge currently only
mounts/unmounts; change it to assert the rendered output for each SettingSource
value by rendering SourceBadge with each source and checking visibility: use
render(...) and assert expected text via screen.getByText or screen.queryByText
(or similar) for sources that should show a badge (e.g., 'db','env','yaml') and
assert null/absence for the source that should render nothing (e.g., 'default');
update the test named 'covers all SettingSource values' to iterate the
SettingSource array, map each value to its expected visible label or null, and
assert accordingly using SourceBadge and React Testing Library queries.
In `@web/src/__tests__/stores/sinks.test.ts`:
- Around line 34-113: Add unit tests for saveSink by exercising
useSinksStore.getState().saveSink to cover the read/merge/write path: (1) a test
that verifies default override merges into an existing sink (call makeSink to
create baseline sink, mock the persisted data to include older overrides, call
saveSink and assert persisted result merges defaults correctly), (2) a test that
verifies custom_sinks are merged/updated (seed persisted custom_sinks, call
saveSink for a custom sink identifier and assert the persisted custom_sinks list
is updated and not replaced), and (3) a test that simulates invalid persisted
JSON/shape (mock the persistence read to return malformed JSON or wrong shape,
call saveSink and assert it handles the error by overwriting with a valid merged
sink or returning the expected error state). Use the same test patterns as
existing tests (vi.mocked, makeSink, useSinksStore.getState()) and mock the
store’s persistence read/write functions (or localStorage adapter used by
saveSink) to observe what is written. Ensure assertions check both the in-memory
store state and the final persisted payload.
In `@web/src/components/ui/code-mirror-editor.tsx`:
- Around line 24-29: Update the JSDoc for the extensions prop in the
CodeMirrorEditor component to add a `@remarks` note advising callers to memoize
the extensions array (e.g., with React.useMemo) because the internal
extraExtensions reconfiguration effect (which watches the array reference) will
re-run whenever a new array reference is passed; reference the prop name
"extensions" and the internal reconfiguration behavior "extraExtensions" so
consumers know why and how to prevent unnecessary reconfigurations.
In `@web/src/components/ui/tag-input.stories.tsx`:
- Around line 16-18: The controlled story Render initializes local state from
args.value but doesn't update when Storybook Controls change; update the story
by importing useEffect and adding an effect in the Render function that watches
args.value and calls setValue(args.value) so the local state (value) always
syncs to controls; locate the Render function in
web/src/components/ui/tag-input.stories.tsx (the function that calls
useState(args.value) and returns <TagInput {...args} value={value}
onChange={setValue} />) and add a useEffect(() => setValue(args.value),
[args.value]) to keep the canvas aligned with Controls; apply the same change to
the other controlled stories at the referenced spots (lines ~24-26 and ~38-40).
- Around line 14-42: Add the missing stories named Hover, Loading, and Error for
the TagInput story file to satisfy the shared-component story contract; for each
story follow the existing pattern (export const Hover/Loading/Error: Story = {
args: {...}, render: function Render(args) { const [value, setValue] =
useState(args.value); return <TagInput {...args} value={value}
onChange={setValue} /> } }) — for Hover provide sensible args (e.g., value and
placeholder) so designers can inspect the hovered visual state, for Loading
include a loading flag in args (e.g., loading: true or isLoading: true) to
exercise the spinner/state, and for Error include an error prop in args (e.g.,
error: 'Validation error' or errorMessage) so the error UI is shown; keep names
exactly Hover, Loading, Error to match the contract and mirror the render
pattern used by Default/Empty/ManyTags.
In `@web/src/components/ui/tag-input.tsx`:
- Around line 79-80: The TagInput component currently sets aria-label on the
wrapper (role="group") but does not pass any accessible name to the actual
<input>, so screen readers lose the control name when the placeholder
disappears; update the TagInput props to accept aria-label and aria-labelledby
(or a single prop like inputAriaLabel/inputAriaLabelledBy), and forward
whichever is provided to the internal input element (or fall back to placeholder
or "Tags"), ensuring the input receives aria-label/aria-labelledby; apply the
same forwarding change to the other TagInput input instance as noted (both
places where the input is rendered).
- Around line 82-107: Extract the chip rendering into a new TagChip component:
create a small functional component named TagChip that accepts props {item:
string, index: number, disabled: boolean, onRemove: (index: number) => void} and
returns the span + optional remove button JSX now inside the .map() (keep the
same classes, aria-label, X icon usage and stopPropagation behavior, calling
onRemove(index)). In the parent (current tag-input component) leave the
value.map loop but replace the long JSX with a single <TagChip .../> call;
compute the stableKey (const occurrence = value.slice(0, i).filter(v => v ===
item).length; const stableKey = `${item}::${occurrence}`) in the parent and use
it as key, and pass removeAt as onRemove. Ensure prop and handler names match
removeAt so existing logic is reused.
In `@web/src/pages/settings/CodeEditorPanel.tsx`:
- Around line 315-336: The diffSummary calculation currently compares serverText
and text by line index which can disagree with the gutter markers produced by
dispatchDiff(); replace the ad-hoc line-index counting in diffSummary with the
same diff routine used by dispatchDiff() (or call dispatchDiff()/its shared
helper) to compute changed/added/removed counts from serverText and text so the
summary matches the gutter, ensuring you reference the existing dispatchDiff
function or its underlying diff helper when locating and replacing the logic in
the diffSummary useMemo.
In `@web/src/pages/settings/editor-extensions.ts`:
- Around line 562-584: The reverse JSON context scan in the loop that walks
backward from pos is using braceDepth++ on '{' and -- on '}', which is reversed
for reverse traversal and fails to detect enclosing namespace objects (so
currentNamespace never gets set); update the logic in that block (and the
similar block at the other occurrence around lines 616-629) to decrement
braceDepth when encountering '{' and increment when encountering '}' during the
reverse scan, and additionally ignore braces that appear inside quoted strings
(track whether you're inside a string by toggling on encountering unescaped '"'
so literal '{'/' }' inside strings don't affect braceDepth); ensure
currentNamespace is set using the existing nsMatch logic when the adjusted
braceDepth indicates the namespace opening.
- Around line 85-98: computeLineDiff currently emits separate 'added' and
'removed' entries so one-line replacements are shown as whichever marker gets
deduped first; update computeLineDiff to detect pairs of colliding add/remove
entries for the same logical line and merge them into a single entry with kind
'changed' before returning/sorting so the gutter's dedupe will render
replacements correctly. Specifically, in computeLineDiff where you build the
diffs array (references: diffs, kind 'added', kind 'removed'), detect when an
added entry and a removed entry target the same line (and likewise in the
duplicated logic around the block at the 196-202 region) and replace both with a
single { line: <line>, kind: 'changed' } entry prior to sorting/returning.
Ensure you adjust both occurrences so all one-line replacements become 'changed'
instead of separate add/removes.
- Around line 156-176: The theme object diffGutterTheme currently hardcodes
spacing and sizing (e.g., width: '6px', marginRight: '2px', width: '4px',
borderRadius: '1px') and likely includes other pixel/typography literals
elsewhere; replace those pixel values with the appropriate design tokens (e.g.,
var(--so-spacing-*) / var(--so-radius-*) / var(--so-size-*)) and remove any
direct fontFamily declarations so typography is inherited from the
wrapper/tokenized styles; update the CSS rules for '.cm-diff-gutter',
'.cm-diff-marker', '.cm-diff-marker-*' to use var(--so-*) tokens and inherited
font styles, and apply the same changes to the other EditorView.theme objects in
this file that contain hardcoded spacing/typography.
In `@web/src/pages/settings/SettingField.tsx`:
- Around line 30-47: ArraySettingField currently serializes tag changes with
onChange(JSON.stringify(next)) without validating items, so regex-backed array
settings accept invalid entries locally; update the TagInput onChange handler in
ArraySettingField to run the same validation used elsewhere (call the existing
validate(...) / validateSetting(...) logic or the module-level validate
function) on each candidate item from parseArrayItems and only include items
that pass validation (or prevent the change and surface an error) before calling
onChange; locate the TagInput onChange inside ArraySettingField and integrate
the validation step so invalid tags are filtered/rejected prior to
JSON.stringify(next).
In `@web/src/pages/settings/SettingRow.tsx`:
- Around line 72-80: The disabled hint currently uses a title on the container;
replace that with an accessible description using aria-describedby when
controllerDisabled is true: render a hidden descriptive element (e.g., <span
id={`setting-desc-${compositeKey}`}> or a Tooltip component) that contains the
controller label/key and reason (like "Disabled because [controllerLabel] is
off"), set the container's aria-describedby to that id instead of title, and
remove the title attribute; use the existing compositeKey and controllerDisabled
variables to build the id and message so keyboard and screen-reader users get
the specific dependency reason.
In `@web/src/pages/settings/sinks/SinkCard.tsx`:
- Around line 23-82: Replace the hand-rolled card markup and inline status dot
in SinkCard with the shared primitives: use SectionCard for the outer
container/header/footer instead of the top-level div and the three inner
border-t sections, and use StatusBadge in place of the inline rounded dot span
(map sink.enabled to StatusBadge's status/variant props). Keep the existing
content (Monitor/FileText icon, sink.identifier, level tag using levelColor,
JSON/Text, Default, rotation and routes) but move the edit Button into
SectionCard's footer/actions area and call onEdit(sink) from that action; ensure
routing_prefixes and rotation rendering logic remains unchanged and any
aria/title props are preserved on equivalent elements.
In `@web/src/pages/settings/sinks/SinkFormDrawer.tsx`:
- Around line 53-58: The buildPayload() flow currently converts
maxBytes/backupCount inline and may send invalid numeric values; update the
SinkFormDrawer logic to parse and validate maxBytes and backupCount once into
numeric variables (e.g., parsedMaxBytes, parsedBackupCount) before creating
override.rotation, rejecting or blocking the operation on NaN/negative values,
and reuse those validated numbers in all places that construct overrides or
test/save payloads (including the other rotation branches currently using
Number(maxBytes)/Number(backupCount)); ensure rotationStrategy and override
construction use the validated parsed values so Test Config and Save send
identical, validated rotation data.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
| The **observability namespace** includes a dedicated **Sinks** sub-page (`/settings/observability/sinks`) for managing log sink configuration. The sinks page displays all active sinks (console and file) as cards showing identifier, log level, format, rotation policy, and routing prefixes. Operators can edit sink overrides and define custom sinks with a test-before-save workflow. | ||
|
|
||
| The **backup namespace** will include backup management CRUD (trigger, list, restore, delete) in a future iteration, consolidating the BackupController under the Settings page. The current implementation covers backup configuration settings only (schedule, retention, path). | ||
|
|
||
| System-managed settings (e.g. `api/setup_complete`) are hidden from the GUI. Environment-sourced settings display as read-only. | ||
|
|
||
| **API endpoints**: `GET /settings/_schema`, `GET /settings/_schema/{ns}`, `GET /settings`, `GET /settings/{ns}`, `GET /settings/{ns}/{key}`, `PUT /settings/{ns}/{key}`, `DELETE /settings/{ns}/{key}`, `POST /admin/backups`, `GET /admin/backups`, `GET /admin/backups/{id}`, `DELETE /admin/backups/{id}`, `POST /admin/backups/restore` | ||
| **API endpoints**: `GET /settings/_schema`, `GET /settings/_schema/{ns}`, `GET /settings`, `GET /settings/{ns}`, `GET /settings/{ns}/{key}`, `PUT /settings/{ns}/{key}`, `DELETE /settings/{ns}/{key}`, `GET /settings/observability/sinks`, `POST /settings/observability/sinks/_test`, `POST /admin/backups`, `GET /admin/backups`, `GET /admin/backups/{id}`, `DELETE /admin/backups/{id}`, `POST /admin/backups/restore` |
There was a problem hiding this comment.
Document the write side of the sinks API as well.
This section now promises add/edit/custom sink flows, but the endpoint inventory still only shows GET /settings/observability/sinks and _test. Please list the save/update/enable-disable routes as well—at minimum the collection POST route—so the design page matches the implemented CRUD flow.
Based on learnings, "When approved deviations occur, update the relevant docs/design/ page to reflect the new reality."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/design/page-structure.md` around lines 134 - 140, The endpoints list is
missing the write-side CRUD for observability sinks; update the API inventory to
include at minimum the collection create route and per-sink update/delete/enable
routes by adding entries such as POST /settings/observability/sinks
(create/save), PUT /settings/observability/sinks/{id} (update), DELETE
/settings/observability/sinks/{id} (delete), and a route for toggling or setting
enabled state like PATCH /settings/observability/sinks/{id}/enable or POST
/settings/observability/sinks/{id}/_enable so the documented API matches the
described add/edit/custom sink flows and the existing POST
/settings/observability/sinks/_test.
| CONSOLE_SINK_ID: str = "__console__" | ||
|
|
||
| # Set of file paths belonging to DEFAULT_SINKS (reserved, even if disabled). | ||
| _DEFAULT_FILE_PATHS: frozenset[str] = frozenset( | ||
| DEFAULT_FILE_PATHS: frozenset[str] = frozenset( | ||
| s.file_path for s in DEFAULT_SINKS if s.file_path is not None | ||
| ) | ||
|
|
||
| # Valid sink identifiers for overrides. | ||
| _VALID_OVERRIDE_KEYS: frozenset[str] = _DEFAULT_FILE_PATHS | {_CONSOLE_ID} | ||
| _VALID_OVERRIDE_KEYS: frozenset[str] = DEFAULT_FILE_PATHS | {CONSOLE_SINK_ID} |
There was a problem hiding this comment.
Reintroduce required module logger bootstrap.
This business-logic module no longer initializes logger via get_logger(__name__), which breaks the project’s observability contract for synthorg modules.
🔧 Proposed fix
import json
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, cast
+from synthorg.observability import get_logger
from synthorg.observability.config import (
DEFAULT_SINKS,
LogConfig,
@@
from synthorg.observability.enums import LogLevel, RotationStrategy, SinkType
+logger = get_logger(__name__)
+
CONSOLE_SINK_ID: str = "__console__"As per coding guidelines, “Every module with business logic MUST have: from synthorg.observability import get_logger then logger = get_logger(__name__).”
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/synthorg/observability/sink_config_builder.py` around lines 33 - 41, This
module removed the required observability bootstrap: add "from
synthorg.observability import get_logger" and then initialize module logger with
"logger = get_logger(__name__)" near the top of
src/synthorg/observability/sink_config_builder.py so the module-level logging
contract is restored; ensure the import appears with other imports and the
logger variable is defined before any runtime logic that may emit logs (e.g.,
before DEFAULT_FILE_PATHS, CONSOLE_SINK_ID, or any functions/classes in this
file).
| export const SingleChange: Story = { | ||
| args: { dirtyCount: 1, saving: false, onSave: () => {}, onDiscard: () => {}, saveError: null }, | ||
| } | ||
|
|
||
| export const MultipleChanges: Story = { | ||
| args: { dirtyCount: 5, saving: false, onSave: () => {}, onDiscard: () => {}, saveError: null }, | ||
| } | ||
|
|
||
| export const Saving: Story = { | ||
| args: { dirtyCount: 3, saving: true, onSave: () => {}, onDiscard: () => {}, saveError: null }, | ||
| } | ||
|
|
||
| export const WithError: Story = { | ||
| args: { dirtyCount: 2, saving: false, onSave: () => {}, onDiscard: () => {}, saveError: 'Network error: failed to save' }, | ||
| } | ||
|
|
||
| export const Hidden: Story = { | ||
| args: { dirtyCount: 0, saving: false, onSave: () => {}, onDiscard: () => {}, saveError: null }, | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Consider extracting shared story args to reduce repetition.
onSave, onDiscard, and repeated defaults are duplicated across stories; a baseArgs constant would make updates safer.
♻️ Optional DRY refactor
+const baseArgs = {
+ saving: false,
+ onSave: () => {},
+ onDiscard: () => {},
+ saveError: null,
+}
+
export const SingleChange: Story = {
- args: { dirtyCount: 1, saving: false, onSave: () => {}, onDiscard: () => {}, saveError: null },
+ args: { ...baseArgs, dirtyCount: 1 },
}
export const MultipleChanges: Story = {
- args: { dirtyCount: 5, saving: false, onSave: () => {}, onDiscard: () => {}, saveError: null },
+ args: { ...baseArgs, dirtyCount: 5 },
}
export const Saving: Story = {
- args: { dirtyCount: 3, saving: true, onSave: () => {}, onDiscard: () => {}, saveError: null },
+ args: { ...baseArgs, dirtyCount: 3, saving: true },
}
export const WithError: Story = {
- args: { dirtyCount: 2, saving: false, onSave: () => {}, onDiscard: () => {}, saveError: 'Network error: failed to save' },
+ args: { ...baseArgs, dirtyCount: 2, saveError: 'Network error: failed to save' },
}
export const Hidden: Story = {
- args: { dirtyCount: 0, saving: false, onSave: () => {}, onDiscard: () => {}, saveError: null },
+ args: { ...baseArgs, dirtyCount: 0 },
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/settings/FloatingSaveBar.stories.tsx` around lines 12 - 30,
Extract the duplicated story args into a shared constant (e.g., baseArgs) and
spread it into each story's args to remove repetition; specifically create a
baseArgs object containing onSave, onDiscard, saving: false, and saveError:
null, then update SingleChange, MultipleChanges, Saving, WithError, and Hidden
to merge their specific overrides (dirtyCount, saving or saveError) with
baseArgs using object spread so future changes to common defaults only need to
be made in one place.
| // Subscribe to WS system channel for setting updates -- auto-refresh on sink config changes | ||
| const sinkHandler = useCallback((event: WsEvent) => { | ||
| const key = (event.payload as Record<string, unknown> | undefined)?.key as string | undefined | ||
| if (key === 'observability/sink_overrides' || key === 'observability/custom_sinks') { | ||
| fetchSinks() | ||
| } | ||
| }, [fetchSinks]) |
There was a problem hiding this comment.
Refresh the grid when root_log_level changes.
list_sinks() rebuilds effective sink levels from observability/root_log_level, but this WS filter only refetches on sink_overrides and custom_sinks. Editing the root level elsewhere leaves stale levels on this page until a manual reload.
♻️ Include the remaining driver key
- if (key === 'observability/sink_overrides' || key === 'observability/custom_sinks') {
+ if (
+ key === 'observability/sink_overrides' ||
+ key === 'observability/custom_sinks' ||
+ key === 'observability/root_log_level'
+ ) {
fetchSinks()
}📝 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.
| // Subscribe to WS system channel for setting updates -- auto-refresh on sink config changes | |
| const sinkHandler = useCallback((event: WsEvent) => { | |
| const key = (event.payload as Record<string, unknown> | undefined)?.key as string | undefined | |
| if (key === 'observability/sink_overrides' || key === 'observability/custom_sinks') { | |
| fetchSinks() | |
| } | |
| }, [fetchSinks]) | |
| // Subscribe to WS system channel for setting updates -- auto-refresh on sink config changes | |
| const sinkHandler = useCallback((event: WsEvent) => { | |
| const key = (event.payload as Record<string, unknown> | undefined)?.key as string | undefined | |
| if ( | |
| key === 'observability/sink_overrides' || | |
| key === 'observability/custom_sinks' || | |
| key === 'observability/root_log_level' | |
| ) { | |
| fetchSinks() | |
| } | |
| }, [fetchSinks]) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/SettingsSinksPage.tsx` around lines 27 - 33, The sinkHandler in
useCallback only refetches sinks for 'observability/sink_overrides' and
'observability/custom_sinks', which misses changes to the root log level; update
the condition in sinkHandler (the function named sinkHandler that calls
fetchSinks and is declared with useCallback) to also call fetchSinks when the
event key equals 'observability/root_log_level' (or otherwise include that
observability key), so list_sinks() rebuilds effective levels after
root_log_level updates.
| { path: 'providers', element: <ProvidersPage /> }, | ||
| { path: 'providers/:providerName', element: <ProviderDetailPage /> }, | ||
| { path: 'settings', element: <SettingsPage /> }, | ||
| { path: 'settings/observability/sinks', element: <SettingsSinksPage /> }, |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Avoid duplicating the sinks route literal in router config.
web/src/router/routes.ts is the declared route source of truth; hardcoding this path here increases drift risk.
♻️ Proposed refactor
import { lazy, Suspense } from 'react'
import { createBrowserRouter, RouterProvider } from 'react-router'
import { AuthGuard, GuestGuard, SetupCompleteGuard, SetupGuard } from './guards'
+import { ROUTES } from './routes'
+
+const SETTINGS_SINKS_RELATIVE_PATH = ROUTES.SETTINGS_SINKS.replace(/^\//, '')
@@
- { path: 'settings/observability/sinks', element: <SettingsSinksPage /> },
+ { path: SETTINGS_SINKS_RELATIVE_PATH, element: <SettingsSinksPage /> },📝 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.
| { path: 'settings/observability/sinks', element: <SettingsSinksPage /> }, | |
| import { lazy, Suspense } from 'react' | |
| import { createBrowserRouter, RouterProvider } from 'react-router' | |
| import { AuthGuard, GuestGuard, SetupCompleteGuard, SetupGuard } from './guards' | |
| import { ROUTES } from './routes' | |
| const SETTINGS_SINKS_RELATIVE_PATH = ROUTES.SETTINGS_SINKS.replace(/^\//, '') | |
| // ... other code ... | |
| { path: SETTINGS_SINKS_RELATIVE_PATH, element: <SettingsSinksPage /> }, |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/router/index.tsx` at line 106, Replace the hardcoded
'settings/observability/sinks' path in router/index.tsx with the corresponding
route constant exported from web/src/router/routes.ts (import the route symbol
from routes.ts and use it as the path for the SettingsSinksPage route); update
the import at the top of router/index.tsx to bring in the route identifier
(e.g., the settings/observability/sinks route constant) and reference that
constant in the route entry instead of the literal string to avoid drift.
| if (sink.is_default) { | ||
| // Merge with existing overrides instead of replacing all | ||
| let existingOverrides: Record<string, unknown> = {} | ||
| const settings = await getNamespaceSettings('observability') | ||
| const overrideEntry = settings.find((s) => s.definition.key === 'sink_overrides') | ||
| if (overrideEntry?.value) { | ||
| const parsed: unknown = JSON.parse(overrideEntry.value) | ||
| if (parsed && typeof parsed === 'object') existingOverrides = parsed as Record<string, unknown> | ||
| } | ||
| existingOverrides[sink.identifier] = buildOverrideForSink(sink) | ||
| await updateSetting('observability', 'sink_overrides', { value: JSON.stringify(existingOverrides) }) | ||
| } else { | ||
| const custom: Record<string, unknown> = { file_path: sink.identifier, ...buildOverrideForSink(sink) } | ||
| if (sink.routing_prefixes.length > 0) { | ||
| custom.routing_prefixes = [...sink.routing_prefixes] | ||
| } | ||
| // Merge with existing custom sinks instead of overwriting | ||
| let existing: Record<string, unknown>[] = [] | ||
| const customSettings = await getNamespaceSettings('observability') | ||
| const customEntry = customSettings.find((s) => s.definition.key === 'custom_sinks') | ||
| if (customEntry?.value) { | ||
| const parsed: unknown = JSON.parse(customEntry.value) | ||
| if (Array.isArray(parsed)) existing = parsed as Record<string, unknown>[] | ||
| } | ||
| const merged = existing.filter((s) => s.file_path !== sink.identifier) | ||
| merged.push(custom) | ||
| await updateSetting('observability', 'custom_sinks', { value: JSON.stringify(merged) }) |
There was a problem hiding this comment.
This browser-side merge is still lossy under concurrent edits.
Each save reads the current JSON blob, merges locally, then writes the whole setting back. Two tabs/users editing different sinks can race here, and the later write can drop the earlier change. This needs a server-side merge or a versioned compare-and-swap precondition instead of composing the update entirely in the client.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/stores/sinks.ts` around lines 44 - 70, The client-side merge in the
save logic (using getNamespaceSettings + updateSetting for keys 'sink_overrides'
and 'custom_sinks' and building entries with buildOverrideForSink(sink) and
sink.identifier) is racy under concurrent edits; change the flow so the merge
happens server-side or use a versioned compare-and-swap: send only the intended
delta (the new override or custom sink entry) to a server endpoint that merges
into the stored JSON atomically, or include the current setting version/ETag
with updateSetting and have the server reject mismatched versions so the client
can re-read and retry the merge; update the client to call the new server API
(or pass version metadata to updateSetting) instead of reading, merging, and
writing the whole blob locally.
| const settings = await getNamespaceSettings('observability') | ||
| const overrideEntry = settings.find((s) => s.definition.key === 'sink_overrides') | ||
| if (overrideEntry?.value) { | ||
| const parsed: unknown = JSON.parse(overrideEntry.value) | ||
| if (parsed && typeof parsed === 'object') existingOverrides = parsed as Record<string, unknown> | ||
| } | ||
| existingOverrides[sink.identifier] = buildOverrideForSink(sink) | ||
| await updateSetting('observability', 'sink_overrides', { value: JSON.stringify(existingOverrides) }) |
There was a problem hiding this comment.
Reject unexpected persisted JSON shapes before merging.
sink_overrides currently accepts any object-ish payload, so [] slips through and the string-keyed override you assign is dropped by JSON.stringify(). custom_sinks likewise treats any non-array payload as [] and can overwrite stored sinks. Throw on the wrong top-level shape instead of silently continuing.
🛡️ Example guard
if (overrideEntry?.value) {
const parsed: unknown = JSON.parse(overrideEntry.value)
- if (parsed && typeof parsed === 'object') existingOverrides = parsed as Record<string, unknown>
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error('observability/sink_overrides must be a JSON object')
+ }
+ existingOverrides = parsed as Record<string, unknown>
}
@@
if (customEntry?.value) {
const parsed: unknown = JSON.parse(customEntry.value)
- if (Array.isArray(parsed)) existing = parsed as Record<string, unknown>[]
+ if (!Array.isArray(parsed)) {
+ throw new Error('observability/custom_sinks must be a JSON array')
+ }
+ existing = parsed as Record<string, unknown>[]
}Also applies to: 62-67
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/stores/sinks.ts` around lines 47 - 54, The code currently accepts any
JSON-ish payload for sink_overrides and custom_sinks which lets arrays or wrong
top-level types slip through; before merging, validate parsed JSON strictly: in
the sink_overrides branch (where you call getNamespaceSettings('observability'),
parse overrideEntry.value and assign to existingOverrides) ensure parsed is a
plain object (typeof parsed === 'object' && parsed !== null &&
!Array.isArray(parsed)) and throw an error if not; likewise in the custom_sinks
handling (the similar block at lines 62-67) ensure parsed is an array
(Array.isArray(parsed)) and throw if not, so you only call
existingOverrides[sink.identifier] = buildOverrideForSink(sink) and
updateSetting('observability', 'sink_overrides', ...) when the stored shape is
valid.
Redesign the Settings page to match Dashboard/TaskBoard quality bar: - Namespace tab bar with icons for quick navigation (replaces health pills) - Remove all sliders, use number inputs for every numeric setting - TagInput chip component for array settings (CORS origins, exclude paths) - Source badges: only show "Modified" (db) and "ENV", hide yaml/default - Animated section expand with staggered row entrance - Flash animation on WebSocket setting updates - Keyboard shortcuts: Ctrl+S save, Ctrl+/ search focus - Discard confirmation dialog before losing unsaved changes - Code editor split-pane with diff summary - JSON-type values properly embedded (no escaped backslashes) - Dedicated sink configuration sub-page with card grid and test button - Backend sink list/test endpoints for observability sinks - Reclassify 6 API settings as advanced (server, CORS, rate limiting, JWT) - Rename "API" namespace to "Server" for clarity - Search result count badge - Responsive layout for narrow screens - 8 new Storybook stories, 15+ new tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…, polish (#936) Address all 13-agent review findings (6 critical, 11 major) plus user feedback: - Fix useMemo side effect, SinkFormDrawer stale state, AnimatePresence exit - Add allow_inf_nan=False, list_sinks error handling, response validators - Full CRUD sink management with add/edit/enable/disable/rotation - Code editor: diff gutters, inline validation (@codemirror/lint), schema autocomplete - Search text highlighting in setting keys and descriptions - Design token adoption (p-card, gap-grid-gap, gap-section-gap) - Density ordering fix: dense < balanced < medium < sparse - Animation theme preset integration (useAnimationPreset) - Tab-switch cross-fade, smooth expand/collapse - Generic key display names (Enabled -> Backup Enabled) - Dependency indicator removed (visual greying suffices) - Manage Log Sinks inside observability card (matches row layout) - WebSocket auto-refresh on sinks page - CONSOLE_SINK_ID deduplicated, error messages sanitized - 36 new tests, 4 new test files, 3 flaky test timeouts fixed - Doc updates: page-structure, brand-and-ux, operations, web/CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pre-reviewed by 16 agents, 29 findings addressed: - Replace Any types with SettingsService/SinkBuildResult in settings controller - Export DEFAULT_FILE_PATHS from sink_config_builder (remove duplication) - Add MemoryError/RecursionError re-raise in test_sink_config handler - Bind ValueError in list_sinks, include error message in log - Add logging on invalid root_log_level fallback - Parallelize four sequential setting fetches with asyncio.gather - Fix _sanitize_error and _sink_to_dict docstrings - Bidirectional ProviderHealthRecord validator (error_message required on failure) - Document external enrichment contract on ProviderHealthSummary - SSRF-blocked providers skip recording (stay UNKNOWN, not DOWN) - Use urlparse for Ollama port detection instead of string match - Remove dead or-empty fallback on base_url in _probe_one - Add allow_inf_nan=False to ModelCapabilities ConfigDict - Extract health prober lifecycle to _maybe_start_health_prober - NamespaceTabBar WAI-ARIA keyboard navigation (arrow keys, roving tabindex) - CodeEditorPanel split-view toggle aria-label - SinkCard accessible status label (role=status, aria-label) - SinkFormDrawer defensive copy of routingPrefixes - SettingsSinksPage WS handler safe payload cast - Add prober lifecycle tests (start/stop/restart/idempotent/error recovery) - Add SSRF policy enforcement test - Add capabilities mention to CLAUDE.md providers description - Parametrize invalid interval tests - Extract shared probe test boilerplate to _make_prober/_PatchCtx helpers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Backend: - Move _maybe_start_health_prober to lifecycle.py with error handling (non-fatal startup, debug log on skip, improved docstring) - Fix _get_setting_or_default to catch all exceptions (not just SettingNotFoundError), use SETTINGS_NOT_FOUND event constant - Guard _sanitize_error against empty string (NotBlankStr validation) - Add ProviderHealthSummary cross-field validator (zero calls implies no avg_response_time_ms) - Change ProviderHealthRecord.error_message to NotBlankStr | None - Wrap _probe_one in _safe_probe_one for TaskGroup error isolation - Simplify auth_type access (remove dead hasattr guard) - Remove unused logger from sink_config_builder, redundant variable - Fix docstrings: _aggregate_records precondition, _snapshot Returns, _sanitize_error wording, capabilities USD wording Frontend: - Fix log level case mismatch (backend UPPERCASE, frontend was lowercase) - Fix rotation strategy enum drift (remove invalid systemd/logrotate) - Implement sink save persistence via updateSetting (was TODO placeholder) - Add saveSink store action, testConfig error handling - Constrain SinkInfo.level to LogLevel union type - Add enabled field to custom sink buildPayload - LCS-based computeLineDiff (handles insertions without cascading) - Namespace-aware linter key position finding - Add dependency tooltip on greyed-out settings - Add aria-live="polite" to FloatingSaveBar Tests: - Fix flaky asyncio.sleep in test_run_loop_continues_on_probe_error (use asyncio.Event for deterministic sync) - Fix _PatchCtx.__exit__ return value and annotation - Use pytest.approx for float equality in cost assertion - Scope ConfirmDialog button lookup with within(dialog) - Update test/story log levels to uppercase Docs: - Fix stale Agent Detail description in operations.md - Add TokenUsageBar to brand-and-ux.md component inventory Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…None Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Types & contracts: - Constrain SinkRotation.strategy to 'builtin' | 'external' union - Replace text-[10px] with text-micro token in SinkCard, DependencyIndicator - Replace shadow-lg with var(--so-shadow-card-hover) in FloatingSaveBar - Replace min-w-[80px] with min-w-20 in TagInput Semantic HTML & a11y: - Fix button>h2 nesting in NamespaceSection (h2 now wraps button) - Replace role="listbox" with role="group" in TagInput (not a true listbox) - Add role="status" + aria-live="polite" to SearchInput result count - Add a11y.test: 'error' to SearchInput stories - Remove pointer-events-none from disabled SettingRow (blocks tooltip) - Derive effectiveNamespace instead of setState in effect (ESLint rule) Sink CRUD: - Fix handleSave NaN: validate/default rotation numbers, add isConsole guard - Merge custom_sinks with existing instead of overwriting - Derive editSink from store state (not stale captured object) - Default rotation strategy to 'none' (no rotation) instead of 'builtin' - Fix SinkCard.stories/tests: strategy 'rotating' -> 'builtin' Logic: - Case-insensitive key matching in useSettingsKeyboard (Caps Lock safe) - Deduplicate within batch in TagInput addItems - Short-circuit parseArrayItems on blank input - Add missing onSelect prop to SettingsHealthSection story - Rename SourceBadge ConfigFile story to YamlRendersNull - Fix RestartBanner test description: 'warning' -> 'alert' - Update density-tokens comment to mention .density-medium Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Types & contracts: - Increase UpdateSettingRequest.value max_length 8192 -> 65536 (matches TestSinkConfigRequest, prevents sink config save failures) - Normalize file_path whitespace in sink_config_builder before SinkConfig Sink persistence: - Merge default sink overrides with existing (read-modify-write, not overwrite) - Merge custom sinks with existing array (filter + append) - Include routing_prefixes in buildOverrideForSink - Surface getNamespaceSettings/JSON.parse errors (no silent swallow) Frontend: - Responsive split-pane editor (grid-cols-1 md:grid-cols-2) - Replace text-[10px] with text-micro in CodeEditorPanel, SearchInput - Replace Button-inside-Link with styled span (invalid interactive nesting) - Remove unused Button import from SettingsPage - Disabled NamespaceSection button gets disabled attr when forceOpen - Derive effectiveNamespace for tab selection (no setState in effect) - Keyboard handler: add e.defaultPrevented + e.repeat guards - Stable DEFAULT_EXTENSIONS array for CodeMirrorEditor (avoid re-render) - Reserve padding for SearchInput result count badge - Remove TaskDetailPanel unnecessary 15000ms test timeout - Update SourceBadge story comment (default and yaml render nothing) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Accessibility: - Add aria-pressed to CodeEditorPanel split-view toggle button - Change NamespaceTabBar from role="tablist" to role="toolbar" with aria-pressed (was violating WAI-ARIA tab pattern by allowing "All" multi-panel selection) - Update SettingsHealthSection tests to use aria-pressed Functional: - Fix handleCodeSave bypassing restart banner count (code-mode saves now track restart-required settings like GUI saves) - Fix stale search result count during debounce (only show when local === value) - Remove unnecessary 15s test timeout from LoginPage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
32065c2 to
ec65c9c
Compare
There was a problem hiding this comment.
Actionable comments posted: 5
♻️ Duplicate comments (17)
web/src/__tests__/pages/settings/SourceBadge.test.tsx (1)
44-50:⚠️ Potential issue | 🟡 MinorLine 44: exhaustive test still has no assertions.
This loop only checks render/unmount, so it won’t fail on incorrect source mapping behavior. Please assert expected output per source in the loop.
Suggested tightening
it('covers all SettingSource values', () => { - // Exhaustive check that every SettingSource is handled - const sources: SettingSource[] = ['db', 'env', 'yaml', 'default'] - for (const source of sources) { - const { unmount } = render(<SourceBadge source={source} />) - unmount() - } + const expected: Record<SettingSource, string | null> = { + db: 'Modified', + env: 'ENV', + yaml: null, + default: null, + } + for (const source of Object.keys(expected) as SettingSource[]) { + const { container, unmount } = render(<SourceBadge source={source} />) + const label = expected[source] + if (label) { + expect(screen.getByText(label)).toBeInTheDocument() + } else { + expect(container.firstChild).toBeNull() + } + unmount() + } })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/pages/settings/SourceBadge.test.tsx` around lines 44 - 50, The test loop in SourceBadge.test.tsx iterates SettingSource values and only renders/unmounts, so add assertions to verify the rendered output for each source; update the for-loop that renders <SourceBadge source={source} /> (and currently calls render/unmount) to assert the expected DOM/text or class for each SettingSource ('db', 'env', 'yaml', 'default') using the render result (e.g., getByText/getByTestId or container queries) so the test fails if SourceBadge mapping is incorrect; reference SettingSource and the SourceBadge component when adding these assertions.src/synthorg/observability/sink_config_builder.py (1)
20-31:⚠️ Potential issue | 🟠 MajorMissing required logger initialization.
This module contains business logic (JSON parsing, validation, config building) but lacks the required observability setup. The module should initialize the logger as required by project guidelines.
import json from dataclasses import dataclass from types import MappingProxyType from typing import Any, cast +from synthorg.observability import get_logger from synthorg.observability.config import ( DEFAULT_SINKS, LogConfig, RotationConfig, SinkConfig, ) from synthorg.observability.enums import LogLevel, RotationStrategy, SinkType +logger = get_logger(__name__) + CONSOLE_SINK_ID: str = "__console__"As per coding guidelines, "Every module with business logic MUST have:
from synthorg.observability import get_loggerthenlogger = get_logger(__name__)."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/synthorg/observability/sink_config_builder.py` around lines 20 - 31, This module (sink_config_builder.py) is missing the required logger initialization; add the import and logger setup at the top near the other imports by importing get_logger from synthorg.observability and assigning logger = get_logger(__name__) so the module-level business logic (JSON parsing/validation/config builders like SinkConfig, LogConfig, RotationConfig) uses the standardized logger instance.web/src/pages/settings/FloatingSaveBar.stories.tsx (1)
12-30: 🧹 Nitpick | 🔵 TrivialExtract shared base args to avoid repeated story defaults.
onSave,onDiscard,saving, andsaveErrorare duplicated across all stories; centralizing them reduces drift risk.Proposed cleanup
+const baseArgs = { + saving: false, + onSave: () => {}, + onDiscard: () => {}, + saveError: null, +} + export const SingleChange: Story = { - args: { dirtyCount: 1, saving: false, onSave: () => {}, onDiscard: () => {}, saveError: null }, + args: { ...baseArgs, dirtyCount: 1 }, } export const MultipleChanges: Story = { - args: { dirtyCount: 5, saving: false, onSave: () => {}, onDiscard: () => {}, saveError: null }, + args: { ...baseArgs, dirtyCount: 5 }, } export const Saving: Story = { - args: { dirtyCount: 3, saving: true, onSave: () => {}, onDiscard: () => {}, saveError: null }, + args: { ...baseArgs, dirtyCount: 3, saving: true }, } export const WithError: Story = { - args: { dirtyCount: 2, saving: false, onSave: () => {}, onDiscard: () => {}, saveError: 'Network error: failed to save' }, + args: { ...baseArgs, dirtyCount: 2, saveError: 'Network error: failed to save' }, } export const Hidden: Story = { - args: { dirtyCount: 0, saving: false, onSave: () => {}, onDiscard: () => {}, saveError: null }, + args: { ...baseArgs, dirtyCount: 0 }, }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/FloatingSaveBar.stories.tsx` around lines 12 - 30, Create a shared base args object (e.g., const baseArgs = { onSave: () => {}, onDiscard: () => {}, saving: false, saveError: null }) and replace the duplicated fields in each story (SingleChange, MultipleChanges, Saving, WithError, Hidden) by spreading baseArgs and overriding only the story-specific keys (dirtyCount and saving/saveError when needed); this centralizes defaults and prevents duplication.web/src/pages/settings/SettingsHealthSection.tsx (1)
68-89: 🛠️ Refactor suggestion | 🟠 MajorExtract mapped button JSX into a dedicated component/helper.
The JSX inside
.map()is still too large and hurts readability/maintainability.As per coding guidelines, "Do not create complex (>8 line) JSX inside
.map()-- extract to a shared component instead."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/SettingsHealthSection.tsx` around lines 68 - 89, The mapped JSX inside visibleNamespaces.map is too large—extract it into a new presentational component (e.g., NamespaceButton) to improve readability: create a NamespaceButton component that accepts props (ns, count from namespaceCounts.get(ns), icon from namespaceIcons?.[ns], activeNamespace, onSelect, and displayName from NAMESPACE_DISPLAY_NAMES[ns]) and moves the button markup and cn logic there, then replace the inline map body with a simple <NamespaceButton ... /> call using the same keys and handlers so visibleNamespaces.map remains concise.web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx (1)
28-33:⚠️ Potential issue | 🟡 MinorUse semantic assertions for hidden-state verification instead of CSS substring matching.
Line 32 couples the test to implementation details (
[class*="sticky"]), making it brittle.More resilient assertion
- it('is hidden when dirtyCount is 0', () => { - const { container } = render(<FloatingSaveBar {...defaultProps} dirtyCount={0} />) + it('is hidden when dirtyCount is 0', () => { + render(<FloatingSaveBar {...defaultProps} dirtyCount={0} />) expect(screen.queryByText(/unsaved/)).not.toBeInTheDocument() - // The ConfirmDialog is still mounted but not open, so only check for visible bar content - expect(container.querySelector('[class*="sticky"]')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /discard/i })).not.toBeInTheDocument() })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx` around lines 28 - 33, The test "is hidden when dirtyCount is 0" is brittle because it checks a CSS class substring; update it to use a semantic visibility assertion on the FloatingSaveBar root instead: locate the test in FloatingSaveBar.test.tsx and replace the container.querySelector('[class*="sticky"]') assertion with a semantic check such as using screen.getByRole or screen.getByTestId (e.g. getByTestId('floating-save-bar') after adding data-testid="floating-save-bar" to the FloatingSaveBar root if needed) and assert expect(element).not.toBeVisible() or expect(element).toHaveAttribute('aria-hidden','true') so the test verifies hidden state semantically rather than matching CSS classes.web/src/router/index.tsx (1)
106-106: 🧹 Nitpick | 🔵 TrivialUse the route constant from
routes.tsinstead of hardcoding the path.The path
'settings/observability/sinks'is duplicated here whenROUTES.SETTINGS_SINKSalready exists inroutes.ts. This creates drift risk if the path changes in one place but not the other.♻️ Proposed fix
import { lazy, Suspense } from 'react' import { createBrowserRouter, RouterProvider } from 'react-router' import { AuthGuard, GuestGuard, SetupCompleteGuard, SetupGuard } from './guards' +import { ROUTES } from './routes'Then update the route entry:
- { path: 'settings/observability/sinks', element: <SettingsSinksPage /> }, + { path: ROUTES.SETTINGS_SINKS.slice(1), element: <SettingsSinksPage /> },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/router/index.tsx` at line 106, Replace the hardcoded route path string in the router entry that uses SettingsSinksPage with the route constant ROUTES.SETTINGS_SINKS from routes.ts: update the entry { path: 'settings/observability/sinks', element: <SettingsSinksPage /> } to use ROUTES.SETTINGS_SINKS instead, and add an import for ROUTES at the top of web/src/router/index.tsx if missing so the router uses the centralized constant.web/src/__tests__/components/ui/TagInput.test.tsx (2)
69-72:⚠️ Potential issue | 🟡 MinorExercise the disabled path through
onChange, not just the textbox state.This only proves the textbox is disabled. A regression where chip removal still mutates a disabled
TagInputwould still pass here, so use a mockonChangeand assert the remove path is ignored too.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/components/ui/TagInput.test.tsx` around lines 69 - 72, The test only asserts the textbox is disabled but must also verify that user actions (chip removal) do not call onChange; update the test for "disables input when disabled prop is true" to pass a jest mock (e.g., jest.fn()) as onChange when rendering <TagInput value={['a']} disabled /> and then simulate the user removal action for the displayed chip (click the chip's remove button or trigger the same event the UI uses) and assert the mock onChange was not called; reference the TagInput component and the test case name to locate and update the test.
31-37:⚠️ Potential issue | 🟡 MinorAssert the remove-button count before indexing.
Lines 35-36 still rely on
removeButtons[1]!. If the DOM/query changes, this test throws instead of failing with a clear assertion about the missing chip.💚 Suggested fix
const removeButtons = screen.getAllByRole('button', { name: /remove/i }) + expect(removeButtons).toHaveLength(3) await user.click(removeButtons[1]!) // Remove 'b'🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/components/ui/TagInput.test.tsx` around lines 31 - 37, The test for TagInput that clicks removeButtons[1] should first assert the number of remove buttons to avoid an exception; update the test (the 'removes a specific tag when X is clicked' case) to check that removeButtons.length is at least 2 (or equals expected count) before indexing, then proceed to click the intended button and assert onChange was called with ['a','c']; reference the render(<TagInput ... />), removeButtons = screen.getAllByRole('button', { name: /remove/i }) and the onChange expectation when making the change.web/src/components/ui/code-mirror-editor.tsx (1)
24-29: 🧹 Nitpick | 🔵 TrivialDocument that
extensionsmust be memoized.Lines 241-250 reconfigure the compartment whenever the array reference changes. Callers that pass
extensions={[...]}will churn CodeMirror on every render, so the prop docs on Lines 24-29 should explicitly require a stableuseMemo-backed array.♻️ Suggested doc update
/** * Extra CodeMirror extensions appended after the built-in ones. * Useful for adding diff gutters, linters, or autocomplete from * the consuming page without modifying this shared component. + * + * Pass a memoized array (for example via `useMemo`) so the + * reconfigure effect does not run on every parent re-render. */ extensions?: Extension[]Also applies to: 240-250
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/code-mirror-editor.tsx` around lines 24 - 29, The extensions prop (extensions?: Extension[]) must be documented as requiring a stable, memoized array because the component reconfigures the CodeMirror Compartment whenever the array reference changes (causing unnecessary editor churn); update the JSDoc above the extensions prop to explicitly state callers should pass a useMemo-backed array (or otherwise preserve identity) and mention that changing the array reference will trigger reconfiguration of the Compartment so callers should memoize to avoid repeated re-initialization.web/src/pages/settings/sinks/SinkCard.tsx (1)
23-82: 🛠️ Refactor suggestion | 🟠 MajorCompose this from
SectionCardandStatusBadge.This JSX rebuilds both the standard card shell and the enabled/disabled indicator inline, which drifts from the dashboard primitives the repo already standardizes on.
As per coding guidelines, "Do not build card-with-header layouts from scratch -- use the
<SectionCard>component instead." and "Do not recreate status dots inline -- use the<StatusBadge>component instead."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/sinks/SinkCard.tsx` around lines 23 - 82, This component rebuilds a card shell and status dot manually; replace the outer container and header/status markup with the repo primitives: use SectionCard to render the card layout/header (move the icon, identifier, and actions into SectionCard's header/content) and replace the inline status span with StatusBadge (pass sink.enabled to StatusBadge and use its aria/title plumbing); keep existing props/logic (sink, onEdit, levelColor, rotation, routing_prefixes, Button/Pencil usage) but remove the manual border/tail divs and the inline status dot so the UI uses SectionCard and StatusBadge consistently.web/src/pages/settings/SettingRow.tsx (1)
72-80:⚠️ Potential issue | 🟠 MajorThe dependency hint is still title-only and generic.
titleis hover-only, unreliable on touch, and weak for keyboard users, and this message still does not tell people which controller setting they need to enable. Please move this to a real tooltip/aria-describedbypath and pass the controlling setting label/key through.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/SettingRow.tsx` around lines 72 - 80, The current SettingRow component uses title for the dependency hint which is inaccessible; remove the title prop and instead render a proper tooltip or an element with a unique description ID (e.g. `${compositeKey}-controller-desc`) that contains the controller label/key, then wire it up with aria-describedby when controllerDisabled is true; use the existing compositeKey to build the unique id and ensure controllerDisabled toggles both the visual tooltip and the aria-describedby attribute so keyboard and assistive tech users get the exact controlling setting name.web/src/pages/settings/SettingField.tsx (1)
30-47:⚠️ Potential issue | 🟠 MajorArray settings lost client-side regex validation.
This
TagInputpath never reachesvalidate()and no longer has an error state, so anyvalidator_patternon aSIMPLE_ARRAY_SETTINGis now bypassed until save. Please validate each tag beforeonChange(JSON.stringify(next))and surface the failure throughTagInput.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/SettingField.tsx` around lines 30 - 47, ArraySettingField currently converts tags to JSON and calls onChange(JSON.stringify(next)) without per-tag validation, bypassing any validator_pattern for SIMPLE_ARRAY_SETTING; update ArraySettingField to validate each tag (use the existing validate() logic or the same validator used elsewhere for SIMPLE_ARRAY_SETTING) before calling onChange, reject or mark invalid tags and pass that error state into TagInput so it shows validation feedback (e.g., prevent adding invalid items or provide an error prop), and keep parseArrayItems for parsing the initial value; ensure the validation runs on every onChange callback from TagInput and only call onChange(JSON.stringify(next)) when all tags pass validation.web/src/__tests__/pages/settings/sinks/SinkCard.test.tsx (1)
96-100:⚠️ Potential issue | 🟡 MinorThis test doesn't assert the icon it claims to cover.
The assertion only checks the enabled badge, so it passes for both
sink_type="console"andsink_type="file". Either expose a stable selector for the icon or rename the test to match what it actually verifies.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/__tests__/pages/settings/sinks/SinkCard.test.tsx` around lines 96 - 100, The test "shows console icon for console sink type" claims to check the icon but only asserts the enabled badge; update the test or component so the icon is actually asserted: either add a stable selector to the SinkCard icon (e.g., add a data-testid or aria-label on the icon element in SinkCard) and then assert that render(<SinkCard sink={makeSink({ sink_type: 'console' })} ... />) yields the expected icon via screen.getByTestId('sink-icon') or getByLabelText('Monitor icon'), or rename the test to accurately reflect its current assertion (e.g., "shows enabled badge for console sink type"); reference SinkCard and makeSink and the sink_type property when making the change.web/src/components/ui/tag-input.tsx (1)
108-118:⚠️ Potential issue | 🟡 MinorInput lacks accessible name when tags are present.
When
value.length > 0,placeholderbecomesundefinedand the input has no accessible name. Screen reader users won't know the input's purpose. Add an explicitaria-label:<input ref={inputRef} type="text" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} disabled={disabled} placeholder={value.length === 0 ? placeholder : undefined} + aria-label={placeholder ?? 'Add tag'} className="min-w-20 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-text-muted" />🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/ui/tag-input.tsx` around lines 108 - 118, The input element (referenced by inputRef) has no accessible name when value.length > 0 because placeholder is set to undefined; add an explicit aria-label (or aria-labelledby) prop to the <input> so screen readers always get a name — for example derive it from the existing placeholder prop or a new label prop and set aria-label={placeholder || label || "Tags input"} on the same element that uses draft, handleKeyDown, handlePaste and disabled so accessibility is preserved regardless of value length.web/src/pages/settings/editor-extensions.ts (3)
562-584:⚠️ Potential issue | 🟠 MajorReverse the brace accounting in the JSON context scan.
This loop walks backward from the cursor but increments on
{and decrements on}as if it were scanning forward. In nested objects like{"api": {"po|it breaks on the inner{, never setscurrentNamespace, and key/enum completion falls back to root namespace suggestions.Also applies to: 616-629
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/editor-extensions.ts` around lines 562 - 584, The backward JSON context scan has reversed brace accounting: when walking backward from pos the code currently increments on '{' and decrements on '}', which is backward and causes inner namespace braces (e.g. {"api": {"po|) to be mis-detected; change the brace counters in the loop so that encountering '}' increments braceDepth and encountering '{' decrements it, and keep the existing checks for braceDepth === 1 (root) and braceDepth === 2 (namespace) but after you reverse the increment/decrement so currentNamespace is set when you hit the opening brace of a namespace; update the same logic duplicated around the other instance (the block mirrored at the later range noted).
156-176: 🛠️ Refactor suggestion | 🟠 MajorReplace hardcoded editor measurements and typography with tokens.
These theme objects still hardcode px spacing/sizing (
6px,2px,4px,1px,3px,120px) and declarefontFamilyin TS. That bypasses the dashboard density/typography system forweb/src.
As per coding guidelines,web/src/**/*.{ts,tsx}must use design tokens for spacing/typography and must not use pixel spacing literals orfontFamilydeclarations directly in.ts/.tsxfiles.Also applies to: 459-498
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/editor-extensions.ts` around lines 156 - 176, The theme object diffGutterTheme uses hardcoded pixel values (e.g., '6px','2px','4px','1px' and other instances around the file) and declares typography directly; replace these pixel literals with the appropriate design tokens (use the dashboard spacing/size tokens used across web/src, e.g., spacing and dimension tokens) and remove any inline fontFamily declarations in this file, updating CSS property values like width, marginRight, height, borderRadius and any font settings to token references (and mirror the same tokenization for the other similar block referenced at lines ~459-498) so the editor respects the global density/typography system while keeping class selectors (.cm-diff-gutter, .cm-diff-marker, .cm-diff-marker-changed, .cm-diff-marker-added, .cm-diff-marker-removed) intact.
85-98:⚠️ Potential issue | 🟠 MajorEmit
changedmarkers for replacements.
computeLineDiff()still never produceskind: 'changed'. A one-line replacement therefore creates bothaddedandremoved, and the gutter de-dup keeps only the first marker for that line, so edited lines render as additions instead of changes.💡 Merge add/remove pairs into a single
changedmarker- const diffs: LineDiff[] = [] + const diffsByLine = new Map<number, LineDiffKind>() + const pushDiff = (line: number, kind: LineDiffKind) => { + const existing = diffsByLine.get(line) + if ( + (existing === 'added' && kind === 'removed') || + (existing === 'removed' && kind === 'added') + ) { + diffsByLine.set(line, 'changed') + return + } + if (!existing) diffsByLine.set(line, kind) + } @@ - diffs.push({ line: j + 1, kind: 'added' }) + pushDiff(j + 1, 'added') @@ - diffs.push({ line: Math.min(i + 1, m), kind: 'removed' }) + pushDiff(Math.min(i + 1, m), 'removed') @@ - return diffs.sort((a, b) => a.line - b.line) + return [...diffsByLine.entries()] + .map(([line, kind]) => ({ line, kind })) + .sort((a, b) => a.line - b.line)Also applies to: 196-202
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/pages/settings/editor-extensions.ts` around lines 85 - 98, computeLineDiff() currently emits separate 'added' and 'removed' markers for one-line replacements so the gutter de-dup renders them incorrectly; after building diffs (the array pushed using editedMatched/serverMatched) collapse any add+remove pair into a single { line, kind: 'changed' } entry by scanning the diffs array (use the existing diffs, line numbers, and kinds to detect pairs) and replacing both entries with one changed marker; apply the same merge logic to the other similar block referenced (the chunk around the second occurrence) so replacements produce a single 'changed' marker instead of separate add/remove entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/design/operations.md`:
- Around line 1191-1194: Update the settings landing page section for
`/settings` to include the new real-time "Config Health" strip above the
namespace tabs: describe its placement, purpose (aggregated health/status badges
for namespaces, quick alerts, last-updated timestamp), interaction affordances
(click-to-filter namespace, hover details, link to observability sinks), and
behavior with respect to hot-reload/SettingsChangeDispatcher events and
restart_required filtering; reference the existing namespace tab description and
ConfigResolver/SettingsChangeDispatcher/SettingsSubscriber concepts so readers
know how the strip derives its signals and reacts to changes.
In `@src/synthorg/providers/health_prober.py`:
- Around line 66-69: The probe loop can be aborted when urlparse(...).port
raises ValueError for malformed base_url; update the code around is_ollama
detection in health_prober.py to catch ValueError when accessing
urlparse(stripped).port (and any use inside _build_ping_url) so a bad port only
marks that provider as invalid rather than raising; specifically, wrap the port
check used for is_ollama in a try/except ValueError (fallback to None/False) and
add the same defensive try/except inside _build_ping_url (or validate base_url
before calling _build_ping_url) so a single malformed provider config doesn't
stop creating the TaskGroup or probing other providers.
In `@web/src/__tests__/pages/settings/SettingsHealthSection.test.tsx`:
- Around line 100-111: The test currently asserts the numbers '5', '3', '4'
exist anywhere; instead scope each count to its corresponding NamespaceTabBar
tab by selecting the specific tab element (e.g., via getByRole/getByText for the
tab label) and then assert the badge/count within that tab node; update the test
around NamespaceTabBar render to find each tab (using the tab label or role) and
then assert the associated count is present inside that tab element so counts
are bound to the correct namespace.
In `@web/src/pages/settings/editor-extensions.ts`:
- Around line 347-349: The loop currently skips when a namespace value exists
but isn't an object (the `keys` variable) so invalid shapes like `api: true`
produce no diagnostics; instead, detect non-object or array values for `keys`
and emit a diagnostic tied to the `ns`/namespace location explaining "namespace
must be an object of key definitions", referencing the same `keys`/`ns` handling
and `schema.namespaceKeys.get(ns)` logic; replace the `continue` path (where `if
(!keys || typeof keys !== 'object') continue`) with code that creates/reports a
diagnostic for scalar/array namespace values and only continues when `keys` is
truly absent, keeping the `knownKeys` check (`knownKeys =
schema.namespaceKeys.get(ns)`) unchanged.
In `@web/src/pages/settings/SettingsHealthSection.tsx`:
- Around line 24-40: The keyboard handler handleKeyDown uses
container.querySelectorAll('[role="tab"]') but the rendered controls are plain
buttons without role="tab", so navigation never finds targets; update the
selector to match the actual rendered elements (e.g., select 'button' or a
specific data attribute/class used in the tab rendering) or add role="tab" to
the rendered button elements referenced by tablistRef so tabs =
Array.from(container.querySelectorAll<HTMLElement>('<correct-selector>'))
returns the controls; ensure focus calls still target the same elements and
preserve existing logic for ArrowRight/ArrowLeft/Home/End.
---
Duplicate comments:
In `@src/synthorg/observability/sink_config_builder.py`:
- Around line 20-31: This module (sink_config_builder.py) is missing the
required logger initialization; add the import and logger setup at the top near
the other imports by importing get_logger from synthorg.observability and
assigning logger = get_logger(__name__) so the module-level business logic (JSON
parsing/validation/config builders like SinkConfig, LogConfig, RotationConfig)
uses the standardized logger instance.
In `@web/src/__tests__/components/ui/TagInput.test.tsx`:
- Around line 69-72: The test only asserts the textbox is disabled but must also
verify that user actions (chip removal) do not call onChange; update the test
for "disables input when disabled prop is true" to pass a jest mock (e.g.,
jest.fn()) as onChange when rendering <TagInput value={['a']} disabled /> and
then simulate the user removal action for the displayed chip (click the chip's
remove button or trigger the same event the UI uses) and assert the mock
onChange was not called; reference the TagInput component and the test case name
to locate and update the test.
- Around line 31-37: The test for TagInput that clicks removeButtons[1] should
first assert the number of remove buttons to avoid an exception; update the test
(the 'removes a specific tag when X is clicked' case) to check that
removeButtons.length is at least 2 (or equals expected count) before indexing,
then proceed to click the intended button and assert onChange was called with
['a','c']; reference the render(<TagInput ... />), removeButtons =
screen.getAllByRole('button', { name: /remove/i }) and the onChange expectation
when making the change.
In `@web/src/__tests__/pages/settings/FloatingSaveBar.test.tsx`:
- Around line 28-33: The test "is hidden when dirtyCount is 0" is brittle
because it checks a CSS class substring; update it to use a semantic visibility
assertion on the FloatingSaveBar root instead: locate the test in
FloatingSaveBar.test.tsx and replace the
container.querySelector('[class*="sticky"]') assertion with a semantic check
such as using screen.getByRole or screen.getByTestId (e.g.
getByTestId('floating-save-bar') after adding data-testid="floating-save-bar" to
the FloatingSaveBar root if needed) and assert expect(element).not.toBeVisible()
or expect(element).toHaveAttribute('aria-hidden','true') so the test verifies
hidden state semantically rather than matching CSS classes.
In `@web/src/__tests__/pages/settings/sinks/SinkCard.test.tsx`:
- Around line 96-100: The test "shows console icon for console sink type" claims
to check the icon but only asserts the enabled badge; update the test or
component so the icon is actually asserted: either add a stable selector to the
SinkCard icon (e.g., add a data-testid or aria-label on the icon element in
SinkCard) and then assert that render(<SinkCard sink={makeSink({ sink_type:
'console' })} ... />) yields the expected icon via
screen.getByTestId('sink-icon') or getByLabelText('Monitor icon'), or rename the
test to accurately reflect its current assertion (e.g., "shows enabled badge for
console sink type"); reference SinkCard and makeSink and the sink_type property
when making the change.
In `@web/src/__tests__/pages/settings/SourceBadge.test.tsx`:
- Around line 44-50: The test loop in SourceBadge.test.tsx iterates
SettingSource values and only renders/unmounts, so add assertions to verify the
rendered output for each source; update the for-loop that renders <SourceBadge
source={source} /> (and currently calls render/unmount) to assert the expected
DOM/text or class for each SettingSource ('db', 'env', 'yaml', 'default') using
the render result (e.g., getByText/getByTestId or container queries) so the test
fails if SourceBadge mapping is incorrect; reference SettingSource and the
SourceBadge component when adding these assertions.
In `@web/src/components/ui/code-mirror-editor.tsx`:
- Around line 24-29: The extensions prop (extensions?: Extension[]) must be
documented as requiring a stable, memoized array because the component
reconfigures the CodeMirror Compartment whenever the array reference changes
(causing unnecessary editor churn); update the JSDoc above the extensions prop
to explicitly state callers should pass a useMemo-backed array (or otherwise
preserve identity) and mention that changing the array reference will trigger
reconfiguration of the Compartment so callers should memoize to avoid repeated
re-initialization.
In `@web/src/components/ui/tag-input.tsx`:
- Around line 108-118: The input element (referenced by inputRef) has no
accessible name when value.length > 0 because placeholder is set to undefined;
add an explicit aria-label (or aria-labelledby) prop to the <input> so screen
readers always get a name — for example derive it from the existing placeholder
prop or a new label prop and set aria-label={placeholder || label || "Tags
input"} on the same element that uses draft, handleKeyDown, handlePaste and
disabled so accessibility is preserved regardless of value length.
In `@web/src/pages/settings/editor-extensions.ts`:
- Around line 562-584: The backward JSON context scan has reversed brace
accounting: when walking backward from pos the code currently increments on '{'
and decrements on '}', which is backward and causes inner namespace braces (e.g.
{"api": {"po|) to be mis-detected; change the brace counters in the loop so that
encountering '}' increments braceDepth and encountering '{' decrements it, and
keep the existing checks for braceDepth === 1 (root) and braceDepth === 2
(namespace) but after you reverse the increment/decrement so currentNamespace is
set when you hit the opening brace of a namespace; update the same logic
duplicated around the other instance (the block mirrored at the later range
noted).
- Around line 156-176: The theme object diffGutterTheme uses hardcoded pixel
values (e.g., '6px','2px','4px','1px' and other instances around the file) and
declares typography directly; replace these pixel literals with the appropriate
design tokens (use the dashboard spacing/size tokens used across web/src, e.g.,
spacing and dimension tokens) and remove any inline fontFamily declarations in
this file, updating CSS property values like width, marginRight, height,
borderRadius and any font settings to token references (and mirror the same
tokenization for the other similar block referenced at lines ~459-498) so the
editor respects the global density/typography system while keeping class
selectors (.cm-diff-gutter, .cm-diff-marker, .cm-diff-marker-changed,
.cm-diff-marker-added, .cm-diff-marker-removed) intact.
- Around line 85-98: computeLineDiff() currently emits separate 'added' and
'removed' markers for one-line replacements so the gutter de-dup renders them
incorrectly; after building diffs (the array pushed using
editedMatched/serverMatched) collapse any add+remove pair into a single { line,
kind: 'changed' } entry by scanning the diffs array (use the existing diffs,
line numbers, and kinds to detect pairs) and replacing both entries with one
changed marker; apply the same merge logic to the other similar block referenced
(the chunk around the second occurrence) so replacements produce a single
'changed' marker instead of separate add/remove entries.
In `@web/src/pages/settings/FloatingSaveBar.stories.tsx`:
- Around line 12-30: Create a shared base args object (e.g., const baseArgs = {
onSave: () => {}, onDiscard: () => {}, saving: false, saveError: null }) and
replace the duplicated fields in each story (SingleChange, MultipleChanges,
Saving, WithError, Hidden) by spreading baseArgs and overriding only the
story-specific keys (dirtyCount and saving/saveError when needed); this
centralizes defaults and prevents duplication.
In `@web/src/pages/settings/SettingField.tsx`:
- Around line 30-47: ArraySettingField currently converts tags to JSON and calls
onChange(JSON.stringify(next)) without per-tag validation, bypassing any
validator_pattern for SIMPLE_ARRAY_SETTING; update ArraySettingField to validate
each tag (use the existing validate() logic or the same validator used elsewhere
for SIMPLE_ARRAY_SETTING) before calling onChange, reject or mark invalid tags
and pass that error state into TagInput so it shows validation feedback (e.g.,
prevent adding invalid items or provide an error prop), and keep parseArrayItems
for parsing the initial value; ensure the validation runs on every onChange
callback from TagInput and only call onChange(JSON.stringify(next)) when all
tags pass validation.
In `@web/src/pages/settings/SettingRow.tsx`:
- Around line 72-80: The current SettingRow component uses title for the
dependency hint which is inaccessible; remove the title prop and instead render
a proper tooltip or an element with a unique description ID (e.g.
`${compositeKey}-controller-desc`) that contains the controller label/key, then
wire it up with aria-describedby when controllerDisabled is true; use the
existing compositeKey to build the unique id and ensure controllerDisabled
toggles both the visual tooltip and the aria-describedby attribute so keyboard
and assistive tech users get the exact controlling setting name.
In `@web/src/pages/settings/SettingsHealthSection.tsx`:
- Around line 68-89: The mapped JSX inside visibleNamespaces.map is too
large—extract it into a new presentational component (e.g., NamespaceButton) to
improve readability: create a NamespaceButton component that accepts props (ns,
count from namespaceCounts.get(ns), icon from namespaceIcons?.[ns],
activeNamespace, onSelect, and displayName from NAMESPACE_DISPLAY_NAMES[ns]) and
moves the button markup and cn logic there, then replace the inline map body
with a simple <NamespaceButton ... /> call using the same keys and handlers so
visibleNamespaces.map remains concise.
In `@web/src/pages/settings/sinks/SinkCard.tsx`:
- Around line 23-82: This component rebuilds a card shell and status dot
manually; replace the outer container and header/status markup with the repo
primitives: use SectionCard to render the card layout/header (move the icon,
identifier, and actions into SectionCard's header/content) and replace the
inline status span with StatusBadge (pass sink.enabled to StatusBadge and use
its aria/title plumbing); keep existing props/logic (sink, onEdit, levelColor,
rotation, routing_prefixes, Button/Pencil usage) but remove the manual
border/tail divs and the inline status dot so the UI uses SectionCard and
StatusBadge consistently.
In `@web/src/router/index.tsx`:
- Line 106: Replace the hardcoded route path string in the router entry that
uses SettingsSinksPage with the route constant ROUTES.SETTINGS_SINKS from
routes.ts: update the entry { path: 'settings/observability/sinks', element:
<SettingsSinksPage /> } to use ROUTES.SETTINGS_SINKS instead, and add an import
for ROUTES at the top of web/src/router/index.tsx if missing so the router uses
the centralized constant.
🪄 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: Repository UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: f6ff60d4-3426-4873-9e21-d31abc46885c
⛔ Files ignored due to path filters (1)
web/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (62)
CLAUDE.mddocs/design/brand-and-ux.mddocs/design/operations.mddocs/design/page-structure.mdsrc/synthorg/api/app.pysrc/synthorg/api/controllers/settings.pysrc/synthorg/api/lifecycle.pysrc/synthorg/observability/sink_config_builder.pysrc/synthorg/providers/capabilities.pysrc/synthorg/providers/health.pysrc/synthorg/providers/health_prober.pysrc/synthorg/settings/definitions/api.pytests/unit/api/controllers/test_provider_health.pytests/unit/api/controllers/test_settings_sinks.pytests/unit/providers/test_health.pytests/unit/providers/test_health_prober.pyweb/CLAUDE.mdweb/package.jsonweb/src/__tests__/components/layout/Sidebar.test.tsxweb/src/__tests__/components/ui/TagInput.test.tsxweb/src/__tests__/hooks/useSettingsKeyboard.test.tsweb/src/__tests__/pages/SettingsPage.test.tsxweb/src/__tests__/pages/settings/FloatingSaveBar.test.tsxweb/src/__tests__/pages/settings/RestartBanner.test.tsxweb/src/__tests__/pages/settings/SettingsHealthSection.test.tsxweb/src/__tests__/pages/settings/SourceBadge.test.tsxweb/src/__tests__/pages/settings/sinks/SinkCard.test.tsxweb/src/__tests__/stores/sinks.test.tsweb/src/api/endpoints/settings.tsweb/src/api/types.tsweb/src/components/ui/code-mirror-editor.tsxweb/src/components/ui/tag-input.stories.tsxweb/src/components/ui/tag-input.tsxweb/src/hooks/useSettingsKeyboard.tsweb/src/pages/SettingsPage.tsxweb/src/pages/SettingsSinksPage.tsxweb/src/pages/settings/CodeEditorPanel.tsxweb/src/pages/settings/DependencyIndicator.tsxweb/src/pages/settings/FloatingSaveBar.stories.tsxweb/src/pages/settings/FloatingSaveBar.tsxweb/src/pages/settings/NamespaceSection.tsxweb/src/pages/settings/RestartBadge.tsxweb/src/pages/settings/RestartBanner.stories.tsxweb/src/pages/settings/RestartBanner.tsxweb/src/pages/settings/SearchInput.stories.tsxweb/src/pages/settings/SearchInput.tsxweb/src/pages/settings/SettingField.tsxweb/src/pages/settings/SettingRow.stories.tsxweb/src/pages/settings/SettingRow.tsxweb/src/pages/settings/SettingsHealthSection.stories.tsxweb/src/pages/settings/SettingsHealthSection.tsxweb/src/pages/settings/SourceBadge.stories.tsxweb/src/pages/settings/SourceBadge.tsxweb/src/pages/settings/editor-extensions.tsweb/src/pages/settings/sinks/SinkCard.stories.tsxweb/src/pages/settings/sinks/SinkCard.tsxweb/src/pages/settings/sinks/SinkFormDrawer.tsxweb/src/router/index.tsxweb/src/router/routes.tsweb/src/stores/sinks.tsweb/src/styles/design-tokens.cssweb/src/utils/constants.ts
| - **Settings** (`/settings`): Configuration for 7 namespaces (api, memory, budget, security, coordination, observability, backup). Namespace tab bar navigation with single-column layout, basic/advanced mode, GUI/Code edit toggle (split-pane diff view for JSON/YAML). Observability sinks sub-page (`/settings/observability/sinks`) for log sink management with card grid and test-before-save. Backup management CRUD nested under backup namespace. System-managed settings hidden from GUI. Environment-sourced settings read-only. | ||
| - *DB-backed persistence*: 9 namespaces total (api, company, providers, memory, budget, security, coordination, observability, backup) -- company and providers are managed on their own dedicated pages. Setting types: `STRING`, `INTEGER`, `FLOAT`, `BOOLEAN`, `ENUM`, `JSON`. 4-layer resolution: DB > env > YAML > code defaults. Fernet encryption for `sensitive` values. REST API (`GET`/`PUT`/`DELETE` + schema endpoints for dynamic UI generation), change notifications via message bus. | ||
| - *`ConfigResolver`*: Typed scalar accessors assemble full Pydantic config models from individually resolved settings (using `asyncio.TaskGroup` for parallel resolution). Structural data accessors (`get_agents`, `get_departments`, `get_provider_configs`) resolve JSON-typed settings with Pydantic schema validation and graceful fallback to `RootConfig` defaults on invalid data. | ||
| - *Hot-reload*: `SettingsChangeDispatcher` polls the `#settings` bus channel and routes change notifications to registered `SettingsSubscriber` implementations. Settings marked `restart_required=True` are filtered (logged as WARNING, not dispatched). Concrete subscribers: `ProviderSettingsSubscriber` (rebuilds `ModelRouter` on `routing_strategy` change via `AppState.swap_model_router`), `MemorySettingsSubscriber` (advisory logging for non-restart memory settings), `BackupSettingsSubscriber` (toggles `BackupScheduler` on `enabled` change, reschedules interval on `schedule_hours` change). |
There was a problem hiding this comment.
Add the Config Health row to the /settings design summary.
Line 1191 documents the redesigned settings landing page, but it omits the new real-time Config Health strip above the namespace tabs. That row is one of the core dashboard-quality changes in this PR, so leaving it out makes the operations spec incomplete for follow-up work.
Based on learnings: update the relevant docs/design/ page to reflect the new reality.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/design/operations.md` around lines 1191 - 1194, Update the settings
landing page section for `/settings` to include the new real-time "Config
Health" strip above the namespace tabs: describe its placement, purpose
(aggregated health/status badges for namespaces, quick alerts, last-updated
timestamp), interaction affordances (click-to-filter namespace, hover details,
link to observability sinks), and behavior with respect to
hot-reload/SettingsChangeDispatcher events and restart_required filtering;
reference the existing namespace tab description and
ConfigResolver/SettingsChangeDispatcher/SettingsSubscriber concepts so readers
know how the strip derives its signals and reacts to changes.
| # Detect Ollama by provider name or default port (11434). | ||
| is_ollama = ( | ||
| litellm_provider == "ollama" or urlparse(stripped).port == _OLLAMA_DEFAULT_PORT | ||
| ) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
sed -n '65,70p' src/synthorg/providers/health_prober.py
python - <<'PY'
from urllib.parse import urlparse
for url in ("http://host:11434", "http://host:abc", "http://host:99999"):
print(f"URL: {url}")
try:
print(" port:", urlparse(url).port)
except Exception as exc:
print(" ", type(exc).__name__, exc)
PYRepository: Aureliolo/synthorg
Length of output: 471
🏁 Script executed:
rg -A 15 "def _build_ping_url" src/synthorg/providers/health_prober.pyRepository: Aureliolo/synthorg
Length of output: 615
🏁 Script executed:
rg -B 5 -A 30 "def _probe_all" src/synthorg/providers/health_prober.py | head -80Repository: Aureliolo/synthorg
Length of output: 1827
🏁 Script executed:
rg -A 60 "async def _probe_all" src/synthorg/providers/health_prober.pyRepository: Aureliolo/synthorg
Length of output: 2647
Don't let a malformed port in one provider config abort the entire probe cycle.
urlparse(...).port raises ValueError for malformed base_urls like http://host:abc or http://host:99999. Because _build_ping_url() is called in the config loop before the TaskGroup is created, one bad provider config stops probing every provider for the entire cycle.
🛡️ Minimal guard for malformed ports
- is_ollama = (
- litellm_provider == "ollama" or urlparse(stripped).port == _OLLAMA_DEFAULT_PORT
- )
+ parsed = urlparse(stripped)
+ try:
+ port = parsed.port
+ except ValueError:
+ port = None
+ is_ollama = litellm_provider == "ollama" or port == _OLLAMA_DEFAULT_PORTAs per coding guidelines, "Validate at system boundaries (user input, external APIs, config files)." The base_url is a config boundary that needs explicit validation.
📝 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.
| # Detect Ollama by provider name or default port (11434). | |
| is_ollama = ( | |
| litellm_provider == "ollama" or urlparse(stripped).port == _OLLAMA_DEFAULT_PORT | |
| ) | |
| # Detect Ollama by provider name or default port (11434). | |
| parsed = urlparse(stripped) | |
| try: | |
| port = parsed.port | |
| except ValueError: | |
| port = None | |
| is_ollama = litellm_provider == "ollama" or port == _OLLAMA_DEFAULT_PORT |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/synthorg/providers/health_prober.py` around lines 66 - 69, The probe loop
can be aborted when urlparse(...).port raises ValueError for malformed base_url;
update the code around is_ollama detection in health_prober.py to catch
ValueError when accessing urlparse(stripped).port (and any use inside
_build_ping_url) so a bad port only marks that provider as invalid rather than
raising; specifically, wrap the port check used for is_ollama in a try/except
ValueError (fallback to None/False) and add the same defensive try/except inside
_build_ping_url (or validate base_url before calling _build_ping_url) so a
single malformed provider config doesn't stop creating the TaskGroup or probing
other providers.
| it('shows setting count next to each tab', () => { | ||
| render( | ||
| <NamespaceTabBar | ||
| namespaces={namespaces} | ||
| activeNamespace={null} | ||
| onSelect={() => {}} | ||
| namespaceCounts={counts} | ||
| />, | ||
| ) | ||
| expect(screen.getByText('5')).toBeInTheDocument() | ||
| expect(screen.getByText('3')).toBeInTheDocument() | ||
| expect(screen.getByText('4')).toBeInTheDocument() |
There was a problem hiding this comment.
Bind each count assertion to its tab.
Lines 109-111 only prove that 5, 3, and 4 appear somewhere in the DOM. This still passes if the badges are attached to the wrong namespace, so scope the assertions to the corresponding tab button.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/__tests__/pages/settings/SettingsHealthSection.test.tsx` around lines
100 - 111, The test currently asserts the numbers '5', '3', '4' exist anywhere;
instead scope each count to its corresponding NamespaceTabBar tab by selecting
the specific tab element (e.g., via getByRole/getByText for the tab label) and
then assert the badge/count within that tab node; update the test around
NamespaceTabBar render to find each tab (using the tab label or role) and then
assert the associated count is present inside that tab element so counts are
bound to the correct namespace.
| if (!keys || typeof keys !== 'object') continue | ||
| const knownKeys = schema.namespaceKeys.get(ns) | ||
| if (!knownKeys) continue |
There was a problem hiding this comment.
Flag non-object namespace values.
A known namespace with a scalar or array value ({"api": true} / api: true) currently hits continue, so the editor shows no diagnostic even though the settings shape is invalid.
💡 Surface the shape error inline
- if (!keys || typeof keys !== 'object') continue
+ if (!keys || typeof keys !== 'object' || Array.isArray(keys)) {
+ const pos = findKey(text, ns)
+ if (pos) {
+ diagnostics.push({
+ from: pos.from,
+ to: pos.to,
+ severity: 'error',
+ message: `Namespace "${ns}" must map to an object`,
+ })
+ }
+ continue
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/pages/settings/editor-extensions.ts` around lines 347 - 349, The loop
currently skips when a namespace value exists but isn't an object (the `keys`
variable) so invalid shapes like `api: true` produce no diagnostics; instead,
detect non-object or array values for `keys` and emit a diagnostic tied to the
`ns`/namespace location explaining "namespace must be an object of key
definitions", referencing the same `keys`/`ns` handling and
`schema.namespaceKeys.get(ns)` logic; replace the `continue` path (where `if
(!keys || typeof keys !== 'object') continue`) with code that creates/reports a
diagnostic for scalar/array namespace values and only continues when `keys` is
truly absent, keeping the `knownKeys` check (`knownKeys =
schema.namespaceKeys.get(ns)`) unchanged.
- Fix SettingsHealthSection keyboard handler: querySelectorAll used
'[role="tab"]' but buttons no longer have that role after toolbar
conversion; changed selector to 'button'
- Fix JSON autocomplete brace depth: braceDepth===1 (first enclosing
'{') was always treated as root level, but when cursor is inside a
namespace (e.g. "api": { | }), the first '{' IS the namespace.
Now checks for preceding "key": pattern to detect namespace context.
Fixes autocomplete suggesting namespace names instead of setting
keys when typing inside a namespace.
- Fix TagInput input element missing aria-label when tags exist
(placeholder disappears, leaving no accessible name)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
🤖 I have created a release *beep* *boop* --- ## [0.5.2](v0.5.1...v0.5.2) (2026-03-31) ### Features * harden activity feed API ([#838](#838), [#839](#839), [#840](#840)) ([#937](#937)) ([c0234ad](c0234ad)) * provider usage metrics, model capabilities, and active health probing ([#935](#935)) ([1434c9c](1434c9c)) * runtime sink configuration via SettingsService ([#934](#934)) ([16c3f23](16c3f23)) * Settings page comprehensive redesign ([#936](#936)) ([#939](#939)) ([6d9ac8b](6d9ac8b)) ### Maintenance * bump astro from 6.1.1 to 6.1.2 in /site in the all group ([#940](#940)) ([ffa24f0](ffa24f0)) * bump pygments from 2.19.2 to 2.20.0 ([#931](#931)) ([9993088](9993088)) * bump the all group with 2 updates ([#942](#942)) ([aea37f8](aea37f8)) * bump typescript-eslint from 8.57.2 to 8.58.0 in /web in the all group ([#941](#941)) ([24f024c](24f024c)) * split CLAUDE.md into subdirectory files for cli/ and web/ ([#932](#932)) ([f5cfe07](f5cfe07)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Summary
/settings/observability/sinks: card grid layout, full CRUD (add/edit/enable/disable/level/format/rotation/routing), test button, WS auto-refresh, backend endpoints with validation and error sanitizationunknownhealth status for zero-traffic providersReview coverage
Pre-reviewed by 16 specialized agents (code-reviewer, python-reviewer, frontend-reviewer, security-reviewer, silent-failure-hunter, type-design-analyzer, comment-analyzer, logging-audit, resilience-audit, conventions-enforcer, api-contract-drift, pr-test-analyzer, test-quality-reviewer, async-concurrency-reviewer, docs-consistency, issue-resolution-verifier). 29 findings identified and fixed.
Test plan
Closes #936
🤖 Generated with Claude Code