mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 21:05:20 +00:00
2d3d9744664b8dffa1e95d5ac69eba2ee3316fca
1769 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2d3d974466 |
fix(ds): canonical separators and destructive tokens in usage/rules tables (#2332)
The LLM usage table (Settings → AI usage) and the rules recent-runs table used hardcoded color classes instead of design-system tokens: - `divide-gray-100` separators — a fixed light gray with no dark-theme variant, so the row dividers render wrong in dark mode. - Raw reds for failed rows (`bg-red-50`/`bg-red-950`, `text-red-500/600`). Swap to the canonical tokens used by every other table (settings/debugs, admin/users, …): - divide-gray-100 -> divide-alpha-black-200 theme-dark:divide-alpha-white-200 - bg-red-50 / bg-red-950/30 -> bg-red-tint-5 / bg-red-tint-10 - text-red-* -> text-destructive (via the icon helper's color: param) Token-only; no structural or behavior change. Co-authored-by: Guillem Arias <guillem.arias@col.vueling.com> |
||
|
|
89eb441145 | fix(sync): discover nightly provider items reflectively (#2334) | ||
|
|
2e384eb833 |
fix(ds): shrink dialog close button to size sm (#2309)
The dialog close button rendered as a :md icon button (44x44px with a 20px glyph) — noticeably larger than the dialog's own action buttons (36px tall) and visually heavy next to the title. Pass size: :sm so the close control is 32x32px with a 16px glyph, matching the action row's weight. 32px still clears the WCAG 2.5.8 (AA) 24px minimum target. |
||
|
|
5937195df0 |
fix(chat): clear assistant bubble on destroy so the chat doesn't hang on Thinking (#2315)
* fix(chat): clear the assistant message bubble when a turn is destroyed When an assistant turn fails before any text streams (e.g. a provider auth/model/network error on the first call), Assistant::Builtin#respond_to destroys the still-pending message. Message only broadcast on create and update, never on destroy, so the rendered 'Thinking…' bubble was never removed — the chat appeared stuck thinking forever even though the job had already errored (and appended an error via chat#add_error below it). Add after_destroy_commit broadcast_remove_to so a destroyed message is removed from the page. * refactor(chat): trim destroy-broadcast comment to one line Project convention asks for comments only when the why is non-obvious; the behaviour is already covered by the commit/PR description. Per review feedback. --------- Co-authored-by: Guillem Arias <guillem.arias@col.vueling.com> |
||
|
|
5608f2b3fa |
fix(settings): give the MCP copy button success feedback (#2314)
* fix(settings): give the MCP copy button success feedback The MCP server URL Copy button copied to the clipboard but showed no feedback. It is a DS::Button (single icon), but clipboard_controller's showSuccess() unconditionally toggled iconDefault/iconSuccess targets — which that markup does not have — so it threw right after the copy and the user saw nothing. Guard the icon-swap path (still used by invite codes, MFA and profiles) and add a fallback that briefly flips the button's own label to Copied! via a new copiedText value. Wire it up on the MCP page. * fix(settings): capture copy button before async clipboard resolve event.currentTarget is null by the time the writeText().then() callback runs (it's only valid during event dispatch), so showSuccess received null and the label never flipped. Capture the button synchronously in copy() and pass it through. Verified in-browser: Copy -> Copied! -> Copy. * refactor(clipboard): unify feedback reset delay, harden label lookup Extract a shared RESET_DELAY_MS so the icon-swap and label-flash paths last the same duration when both copy buttons render on one page. Scope the label lookup to span.truncate (the DS::Button text node) so it ignores any future icon span. Per review feedback. --------- Co-authored-by: Guillem Arias <guillem.arias@col.vueling.com> |
||
|
|
635938ec7b |
fix(ds): normalize legacy tooltip spacing to one recipe (#2311)
The four inline (non-DS::Tooltip) tooltips had drifted: three used p-2 rounded w-64, one used p-3 rounded w-72 with shadow-lg, and all used rounded (4px) where DS::Tooltip uses rounded-md (6px). Unify them on p-2 rounded-md w-64 — radius now matches DS::Tooltip and the lone p-3/w-72/shadow-lg outlier is gone, so the dark tooltips read consistently. |
||
|
|
84547f766c |
fix(dashboard): align sankey zoom-out button with section header (#2313)
The cashflow sankey zoom-out button sat in a bare flex justify-start row. Because the dashboard section body has no horizontal padding (just py-4), the button rendered flush against the card's left edge — 16px left of the section header title and out of line with the rest of the widget. Add px-4 to the button row so it aligns with the header, matching the _net_worth_chart widget's px-4 header row. |
||
|
|
215864fdd9 |
fix(dashboard): apply two-column layout at xl, not 2xl (#2310)
The dashboard two-column layout preference only added 2xl:grid-cols-2 (>=1536px), but the setting copy promises two columns on large screens. On any display narrower than 1536px (most laptops, ~1280-1440px) the toggle did nothing. Lower the breakpoint to xl (>=1280px) so it engages on the screens users actually have while keeping widgets wide enough to stay usable. |
||
|
|
bc0dcdd41b |
fix(ds): neutral text for goals status callout (#2312)
The goals status callout colored its entire body (icon, label and context) with text-warning / text-success / text-secondary, so a behind goal rendered as all-yellow text. That diverges from the DS::Alert recipe, where tinted boxes keep neutral body text (text-primary) and only the icon carries the status color. Drop the text-* tokens from the container, add text-primary, and move the warning/success color onto the icon via color:. |
||
|
|
88343002d1 |
chore(deps): upgrade Rails 7.2 → 8.1 (#2301)
* chore(deps): upgrade Rails 7.2 → 8.1 Rails 7.2 reaches end of life on 2026-08-09. Bump the framework to the current 8.1.x line. - Gemfile: rails "~> 8.0" (resolves 8.1.3); bundle update rails pulls the Rails 8 framework gems plus the bumps it requires — ViewComponent 3.23 → 4.x (Rails 8 support), rails-i18n 7 → 8, rswag, and transitive deps. - app/models/transfer.rb: make Transfer#date nil-safe (inflow_transaction&.entry&.date). Rails 8's date_field evaluates the field default on a new/unpersisted Transfer (the new-transfer form), where the association is nil; without this, TransfersController#new raises "undefined method 'entry' for nil". Matches the &. pattern already used in Transfer#sync_account_later. Framework behavioral defaults are unchanged (config.load_defaults stays as-is). Validated on Rails 8.1.3: zeitwerk:check passes, full suite green (4904 runs, 0 failures, 0 errors), rubocop and brakeman clean. * fix(rails8): style textarea + deterministic property edit system test The Rails 8 gem bump kept config.load_defaults at 7.2, but Rails 8 renamed two ActionView::Helpers::FormBuilder field helpers regardless of defaults: :text_area → :textarea and :check_box → :checkbox. StyledFormBuilder builds its styled helpers from `field_helpers`, so `form.text_area` (e.g. the account "Notes" field) silently fell through to the unstyled base helper and rendered without a label — failing 8 system tests with `Unable to find field "Notes"`. - app/helpers/styled_form_builder.rb: exclude both spellings of the non-text helpers (:check_box and :checkbox) and alias the legacy `text_area` to the Rails 8 `textarea` so existing call sites stay styled. Harmless on Rails 7.2 (old names present instead). - test/system/property_test.rb: open the property edit dialog via the account menu with a retry. The account page issues a Turbo morph refresh shortly after load (turbo_refreshes_with :morph + a family-stream broadcast); opening the modal while that refresh is in flight let the morph re-render the page and wipe the just-loaded #modal turbo-frame. Rails 8 timing made the race deterministic. Retrying once the refresh has settled makes the test stable (confirmed via Turbo frame-load vs full-page morph event traces; 3x green in isolation). - config/brakeman.ignore: the added comment block shifted the pre-existing (already-ignored, Weak) class_eval Dangerous Eval warning from line 5 -> 10, changing its fingerprint. Re-point the existing suppression to the new fingerprint/line so scan_ruby stays green. Validated on Rails 8.1.3: full system suite green (92 runs, 355 assertions, 0 failures, 0 errors), rubocop clean, brakeman 0 warnings, CodeRabbit no findings. * chore(deps): pin rails to the 8.1 minor line (~> 8.1.0) Tighten the constraint from `~> 8.0` to `~> 8.1.0` (>= 8.1.0, < 8.2) so a future `bundle update rails` tracks the 8.1.x line rather than silently jumping to 8.2 when it ships. Matches the upgrade plan's stated intent (target 8.1.x for the EOL runway) and a review note on #2301. No resolved-version changes: bundle install keeps rails at 8.1.3 and every other locked gem unchanged — only the Gemfile.lock DEPENDENCIES constraint line moves. zeitwerk:check still passes; the already-green unit/system suites ran on this exact resolved tree. * chore(rails8): adopt Rails 8.1 framework defaults (config.load_defaults 8.1) The gem bump above kept config.load_defaults at 7.2 so the change set could be reasoned about in stages; this finalizes the upgrade by adopting the modern framework defaults now that the suite is green on Rails 8.1. Rails 8.0 added no new framework defaults (there is no new_framework_defaults_8_0 template), so 7.2 -> 8.1 is the single meaningful step. No incremental new_framework_defaults_8_1.rb opt-in file is needed: the full suites pass with all 8.1 defaults enabled at once. The 8.1 defaults this turns on include action_on_path_relative_redirect=:raise (open-redirect hardening), raise_on_missing_required_finder_order_columns, escape_json_responses=false / escape_js_separators_in_json=false (JSON perf), and Ruby-parser template-dependency tracking. Validated with no application code changes: bin/rails test 4904/0/0, bin/rails test:system 92/0/0, rubocop + brakeman clean. * chore(ci): restore brakeman CheckEOLRails now that the app is on Rails 8.1 config/brakeman.yml existed only to skip brakeman's CheckEOLRails. That check fires on the calendar (it warns 60 days before a framework's EOL and escalates as the date nears), so Rails 7.2's 2026-08-09 EOL turned `bin/brakeman` red (exit 3) on every branch and on main regardless of the diff. The skip carried a TODO to remove it once Sure upgraded off 7.2. This PR puts the app on Rails 8.1 (EOL well in the future), so the skip is obsolete; remove the file (its sole content was the skip) in the same change that makes it unnecessary -- no stale-config window. brakeman auto-loads the file when present and falls back to defaults when absent, and nothing references it explicitly. CheckEOLRuby was already enabled and is unchanged; config/brakeman.ignore is untouched. Validated on Rails 8.1: bin/brakeman runs EOLRails + EOLRuby, 0 warnings, 0 errors, exit 0. |
||
|
|
c701479aee |
fix(ds): goals — uniform New-goal -> grid gap (mb-3 -> mb-4) (#2288)
The New-goal action row used mb-3 while the search row below it uses mb-4. When search is hidden, the New-goal row is the last element before the grid, so the gap-to-grid was mb-3; with search it was mb-4 -- inconsistent depending on state. Bump to mb-4 so the gap is uniform either way. (Audit Group-2 spacing: the other candidates did not hold up on inspection -- providers already has a space-y-4 gap, budgets' 'doubled' gap is correct sequential spacing for 3 elements, dashboard's empty-state gap is unverifiable without an account-less family. So this is the only real one.) |
||
|
|
007f84db1a |
refactor(settings): debugs page onto settings_section (#2289)
Consolidate the bespoke header card + filter card into one settings_section (title + subtitle) -- the canonical surface + an h2 (was a second <h1 font-semibold> below the layout's page-title h1, a heading-level + weight break). The log table stays an edge-to-edge bg-container card on purpose (settings_section's p-4 would inset it and float the thead). Left as follow-ups: the filter inputs' 10x repeated class strings (shared partial) and the bespoke empty state -> DS::EmptyState (needs #2143 on main). Page is super-admin-gated (Admin::BaseController), not renderable in the demo; verified via erb_lint + headless ERB compile + no-stray-markup grep. |
||
|
|
a249f9bda0 |
fix(ds): route mercury/ibkr provider panels onto sibling tokens (#2290)
Two literal-color outliers in app/views/settings/providers/, both fixed by matching the sibling panels in the same directory: - _mercury_panel: the per-item initial avatar used bg-blue-600/10 + text-blue-600. Every other settings/providers panel renders this generic avatar neutral (akahu/brex use bg-surface|bg-container-inset + text-primary). The literal blue was also a dark-mode contrast risk. -> bg-surface + text-primary. - _ibkr_panel: the 'not configured' status dot used a literal bg-gray-400 while the sibling brex panel's equivalent dot uses the bg-surface-inset token (paired with bg-success for the configured state). -> bg-surface-inset. Token-only swaps, theme-safe, no layout change. |
||
|
|
64b4c0fee4 |
Add support for dividend, deposit, withdrawal, and interest trade types to Trades API (#1761)
* Update trades api with support for additional types * rubocop fixes * fix missing amount validation for interest type * define missing schema reference * fix test api_headers to use display_key per guidelines * expand test coverage * replaced duplicate JSON response blocks with helper method * Add DB assertions to linked transfer test and fix invalid date test * update brakeman.ignore fingerpint for refactored code * Update the Brakeman ignore note to document validation for newly permitted keys * fix API key auth in Minitest test to follow correct pattern * update required in trades rswag spec to match the minimum fields that apply to all types * extract dividend handling from build_investment_trade_params to dedicated method * adjust response format to use the existing jbuilder views for Transfers and Transactions * normalize type before passing to create form * validate amount as a positive numeric value + tests * rubocop fixes * Add missing Trades API test coverage and docs - Add Minitest tests for withdrawal (422, transfer linking), interest (explicit ticker), and dividend update - Add rswag 401/403/404/422 response docs for create, update, destroy - Regenerate docs/api/openapi.yaml * Update Security.find line reference in brakeman.ignore note * Mark TransactionResponse account_type as nullable in rswag docs |
||
|
|
d908560ed9 |
feat(cashflow): deep-link category labels to filtered transactions (#2083)
* feat(cashflow): deep-link category labels to filtered transactions Clicking a category's text label in the dashboard cashflow Sankey chart now navigates to the transactions page filtered by that category and the cashflow's active period date range. The colored node bar keeps its existing zoom-into-subcategories behavior; structural nodes (Cash Flow, Surplus) do not navigate. URL-building lives in a pure, unit-tested utils/transactions_filter_url module (mirroring utils/sankey_zoom) pinned in the importmap. Period dates are threaded from the dashboard view into the Stimulus controller via data values. This matches the existing donut chart's click-to-filter behavior. * Update app/javascript/controllers/sankey_chart_controller.js Co-authored-by: Guillem Arias Fauste <gariasf@proton.me> Signed-off-by: Will Wilson <will@willwilson.uk> --------- Signed-off-by: Will Wilson <will@willwilson.uk> Co-authored-by: Guillem Arias Fauste <gariasf@proton.me> |
||
|
|
749d54dd96 |
Fix Plaid sync failure for loan subtypes missing from Loan::SUBTYPES (#2298)
PlaidAccount::TypeMappable maps the Plaid loan subtypes "home equity",
"line of credit", and "business" to home_equity, line_of_credit, and
business — but Loan::SUBTYPES never defined them. Linking any such
account (e.g. a HELOC reported by the institution as loan/"line of
credit") makes the item's sync fail with:
Validation failed: Accountable subtype is not included in the list
and, because Link itself succeeded, the failure is silent in the UI
(same UX gap as #1792).
Add the three subtypes to Loan::SUBTYPES, and add a regression test
asserting every subtype emitted by TYPE_MAPPING is valid for its
accountable so the mapper and models can't drift apart again.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
1157ea8f20 |
fix(sharing): scope import account selects to accessible_by (#1803) (#2194)
* fix(sharing): scope import account selects to accessible_by (#1803)
Three CSV / QIF account selects in import/uploads/show.html.erb and one
PDF-import account select in imports/_pdf_import.html.erb pulled their
options from `@import.family.accounts`. That listed every account in
the family — including the family admin's unshared personal accounts —
in the dropdown shown to any member running an import. Swap each call
site to `Current.user.accessible_accounts` (owned + explicitly shared
accounts only), matching the existing scoping used by the dashboard
sidebar, transactions controller, transfers controller, etc.
Adds a regression test that signs in as family_member and asserts the
unshared-account names from the dylan_family fixtures never appear in
the rendered upload page.
* test(import): scope leak assertions to account select (#2194 CodeRabbit)
CodeRabbit nitpick: assert_match on response.body could pass/fail on
text outside the account dropdown (sidebar, breadcrumb, error message,
etc.) and gave false confidence in the refute_match exclusions. Switch
to assert_select 'select[name="import[account_id]"] option', text: …
so the assertions only see the option nodes the leak test actually
cares about.
* test(import): cover PDF account-select scoping; pluck PDF partial (#2194 review)
jjmata: the _pdf_import.html.erb scoping change was not covered by the
existing test (which only hit /import/uploads). Add a regression test
hitting GET /imports/:id with a PdfImport fixture, asserting the
account dropdown options match accessible accounts only.
Also swap the PDF partial's accounts.map { |a| [a.name, a.id] } for
.pluck(:name, :id) to match the .pluck pattern the other three CSV/QIF
selects already use.
* test(import): stub pdf_uploaded? on PDF leak test (#2194 ci)
The new regression test hit ImportsController#show which redirects to
the upload page when @import.pdf_uploaded? is false. The pdf_with_rows
fixture has neither a pdf_file attached nor a statement, so the
redirect fired before the partial under test ever rendered, failing
with 302 in CI. Stub PdfImport#pdf_uploaded? to true so the test
exercises the account-select scoping path it was written to cover.
* fix(import): scope PDF form to :import so field names match (#2194 ci)
The PDF-import account-select form was `form_with model: import` with
no explicit scope. Because the model is a PdfImport, Rails derived the
param namespace from the class name, so the rendered field was named
`pdf_import[account_id]` — not `import[account_id]`. The
ImportsController#update action accepts either via
`params.dig(:pdf_import, :account_id) || params.dig(:import,
:account_id)` so live submissions still worked, but the regression
test added in
|
||
|
|
0e1b3f8396 |
fix(enable-banking): tolerate any 422 on PDNG fetch (#1805) (#1889)
* fix(enable-banking): tolerate any 422 on PDNG fetch (#1805) * fix(enable-banking): fall back to string key when reading PDNG error |
||
|
|
2075c8c41c |
feat(mcp): OAuth 2.1 auth for MCP — connect Claude.ai with your Sure login (#2234)
* feat(mcp): add OAuth well-known discovery endpoints (RFC 8414 + RFC 9728) Serves /.well-known/oauth-protected-resource (RFC 9728) and /.well-known/oauth-authorization-server (RFC 8414) so MCP clients can auto-discover the authorization server. Both endpoints are unauthenticated and respect APP_URL for reverse-proxy deployments. * feat(mcp): add dynamic client registration endpoint (RFC 7591) POST /register creates a public Doorkeeper::Application on demand so MCP clients (e.g. Claude.ai) can self-register without manual setup. Validates redirect_uris (including blank entries), falls back to "MCP Client" name, returns no client_secret (public client, PKCE only). Rate-limited to 10 registrations/min/IP via Rack::Attack. * feat(mcp): authenticate via Doorkeeper OAuth2, keep MCP_API_TOKEN as fallback MCP endpoint now accepts OAuth2 Bearer tokens issued by Doorkeeper. Falls back to the existing MCP_API_TOKEN env-var flow so self-hosted deployments are not broken. Requires MCP_OAUTH_ENABLED or MCP_API_TOKEN to be set — the endpoint returns 503 otherwise. - OauthBase concern provides APP_URL-aware configured_base_url (trailing slash stripped to prevent double-slash URLs) - Bearer scheme parsed case-insensitively (RFC 7235) - Only read_write scope accepted — read scope would allow mutating tools (CreateGoal, ImportBankStatement), so read-only tokens are rejected - Deactivated users rejected even with a valid Doorkeeper token - WWW-Authenticate header on 401 points to RFC 9728 resource metadata - SHA-256 digest used for constant-time env-var comparison - Rack::Attack throttle added for POST /register - Routes wired: /.well-known/*, /register, use_doorkeeper * fix(mcp): disable Turbo on OAuth consent form for external redirect URIs Turbo was intercepting the authorization form POST and XHR-fetching the redirect_uri (e.g. https://claude.ai/api/mcp/auth_callback), which CORS blocks. Extend the existing turbo_disabled guard to cover any redirect_uri that doesn't originate from the app itself. * feat(mcp): add Settings::McpController with connected clients view - Settings > MCP page (under Advanced) shows the MCP server URL with copy button and step-by-step instructions for connecting Claude.ai - Lists active non-mobile OAuth tokens with app name and revoke action; mobile device tokens are excluded to prevent accidental disconnection - Removes the MCP_OAUTH_ENABLED env-var gate — OAuth auth is always available since Doorkeeper handles consent; MCP_API_TOKEN remains as a self-hosted fallback * fix(mcp): remove client_credentials from grant_types_supported metadata Only authorization_code is supported by the registration endpoint. Advertising client_credentials was misleading — a client that reads the metadata and attempts that flow would get an application with the wrong grant type. |
||
|
|
59edcaa986 | fix: EODHD lookup for EU mutual funds with EUFUND exchange code (#2212) | ||
|
|
77dda53ffb |
feat(ds): one canonical focus ring across primitives (#2140)
* feat(ds): one canonical focus ring across primitives (#2136) Replaces the grab-bag of per-primitive focus indicators (neutral ring-alpha-black/white, outline-gray-900/white, faint form-field ring-4) with a single recipe — the #1737 accessibility follow-up. - New --color-focus-ring token: blue-600 (light) / blue-500 (dark), >=4:1 against both surfaces. - Canonical .focus-ring / .focus-ring-within in components.css: a 2px outline + 2px offset on :focus-visible only. Outline (not a box-shadow ring) so the offset gap is transparent on any surface with no layout shift; :focus-visible so it never shows for mouse/touch. - Applied to every focusable DS primitive: Button (had none), Link, Disclosure summary, Tabs nav, MenuItem (replaces the browser-default box), SearchInput, Tooltip trigger, Popover trigger, Select panel (focus-within), Toggle (peer-driven outline-focus-ring). .form-field adopts it via :focus-within, replacing the ~1:1 ring-4. - Dialog close button is a DS::Button icon variant, so it inherits the focus-visible-only ring and keeps no resting border (fixes "stuck ring"). Verified in-browser, light+dark: focus-visible ring on button, input, and full-width menu row — consistent blue 2px+offset, legible on both surfaces. Remaining follow-up: >=44px touch targets (disclosure trigger, composer send); bespoke notification / account-new close buttons that still carry a permanent border. * fix(ds): #2136 interactive-state follow-ups — touch target + close-button chrome - Disclosure default trigger: add min-h-11 (44px) so the standalone disclosure summary clears the touch-target minimum (was px-3 py-2 ~36px). Composer send + the coming-soon icons are already DS::Button icon/md (w-11 h-11). - Notification close buttons (sync_toast, notice): drop the resting border-alpha-black-50 box ("frame shouts, glyph muted"); keep a bg-container + shadow-xs chip so the corner control stays visible over the page, and brighten the muted glyph on hover (text-subdued -> hover:text-primary). * refactor(ds): focus ring -> neutral hugging box-shadow (was blue outline) Per design feedback: the blue 2px outline + 2px offset read as a loud, detached frame on the otherwise-neutral UI. Switch the canonical .focus-ring to a soft box-shadow ring that hugs the control (follows border-radius, no gap), in the theme-aware neutral focus-ring token (alpha-black/white-400). Transparent outline kept as a forced-colors fallback; toggle peer-driver switched from outline-* to ring-* to match. Still one token, :focus-visible only. Strength is tunable (currently subtle ~1.5:1). * fix(ds): focus ring vanished on shadowed controls — outline, not box-shadow The neutral box-shadow ring lived in the components layer, so any utility-layer shadow-* (or .form-field's focus-within:shadow-none) on the same element overrode it and the ring silently disappeared on shadowed buttons/inputs. Draw the same subtle neutral ring with a hugging `outline` (outline-offset: 0) instead — a separate property with no box-shadow conflict, and it doubles as the forced-colors indicator. Toggle peer-driver switched ring-* -> outline-* to match. Look is unchanged (neutral, hugging, subtle); it just no longer vanishes. * fix(a11y): enlarge sync-toast close-button touch target (p-0.5 -> p-1.5) The hover-revealed close button had ~2px padding around a 20px icon (~24px total), at the WCAG 2.5.8 AAA boundary. p-1.5 brings the interactive area to ~32px. Addresses CodeRabbit review on #2140. * fix(ds): keep form-field's resting halo; stop the outline color flash Two testing findings: - .form-field reverts to its original always-on soft ring (focus-within ring-4 at low alpha, theme-aware) instead of adopting the keyboard-only outline. It's a resting decoration, not a focus indicator, and the lower-opacity halo was the better look. The canonical block's comment documents the deliberate opt-out. - .focus-ring/.focus-ring-within now carry a base transparent 2px outline so consumers with transition-all (form-field had it) animate transparent -> token on focus instead of passing through currentColor, which flashed as a black border appearing and then fading out. * feat(ds): focus-ring token clears WCAG 3:1 non-text contrast alpha-black-400 (20%) measured ~1.6:1 against white — visible but below the AA bar for focus indicators. Bump to the 700 stop (50%): ~3.95:1 on light containers, ~4.6:1 on dark. Recipe unchanged; one token edit via tokens:build. * fix(ds): ring the hand-rolled privacy toggle too The header pair showed two different focus treatments: panel-right (a DS::Button) got the new token ring while the hand-rolled privacy toggle next to it fell back to the browser-default ring — the sweep covered DS primitives but not bespoke buttons. Both privacy toggles (mobile + desktop) now carry .focus-ring. Also documents the transition interplay on the focused-state rule: consumers with transition-colors fade the ring in over 150ms because Tailwind v4's color transition list includes outline-color. Verified settled value at the intended 50% alpha via Playwright. * fix(ds): ring the sidebar and settings nav links The reshoot caught both nav species falling back to the browser's blue default ring — main sidebar items and settings nav items are bespoke link_to markup the primitive sweep missed, and they're the primary keyboard path in the app. Both adopt .focus-ring (main nav adds rounded-lg so the outline follows a shape). * fix(ds): retire the legacy base-layer button ring for the canonical outline The @layer base button rule still painted a ring-2 ring-offset-2 box-shadow on :focus-visible. Box-shadow and outline are independent properties, so .focus-ring (an outline) could never clear it and every button-tag primitive double-painted both indicators on keyboard focus. Apply the canonical recipe to the base button rule itself: every <button> now gets the transparent resting outline + focus-ring token on :focus-visible by default. Two bespoke buttons suppressed the outline with focus:outline-none and relied on the base ring for their keyboard indicator (category dropdown rows, the sign-up password toggle). Drop the suppression so they pick up the canonical outline — :focus-visible keeps it keyboard-only, which is what the suppression was protecting against anyway. * fix(ds): segmented control adopts the canonical focus recipe The segment rule inlined its own focus-visible outline (offset 2, alpha-400 colors) with a comment noting it was temporary until the canonical token landed — this branch is that token. Drop the inlined utilities: button segments get the outline from the base button rule, and link segments now carry .focus-ring. |
||
|
|
5f391f7fff |
fix(settings): preserve content scroll position per page across navigation (#2277)
Settings nav items are plain Turbo Drive links (full-body visits); Turbo only restores window scroll, so the nested overflow-y-auto content container snapped to the top on every settings navigation. Add a settings-scroll Stimulus controller that saves/restores the content scroll keyed by pathname: returning to a page restores its scroll, a new page opens at the top, and a same-page re-render (settings form auto-submit) keeps scroll. Distinct from the nav's preserve-scroll controller, which keys by element id to intentionally carry one position across pages. Verified live: scroll 250 on Preferences -> Appearance opens at top -> back to Preferences restores 250. |
||
|
|
74d0452a2b |
fix(ds): sync-settings env notice used undefined warning-* classes -> DS::Alert (#2278)
settings/hostings/_sync_settings.html.erb rendered the 'configured via env' notice with bg-warning-50 / border-warning-200 / text-warning-600 / text-warning-800 -- none of which exist as Tailwind utilities, so the box rendered fully unstyled (no tint, no border, default text color). Replace the hand-built box with the canonical DS::Alert(:warning), matching the warning-surface recipe and the ds-notice-neutral-text convention. |
||
|
|
8736c7c27e |
refactor(settings): consistency pass — header-less settings_section + guides (#2279)
* refactor(settings): header-less settings_section variant + migrate guides
Add a title:nil branch to settings_section so a page can route through the one
canonical surface recipe (bg-container shadow-border-xs rounded-xl p-4 space-y-4)
without a section header. No visual change to existing titled callers.
Migrate the guides page onto it: replace the hand-rolled card + hardcoded
'Guides' page title with settings_section + t('.page_title') (new locale key).
First step of the settings design-consistency pass.
* refactor(family-exports): un-nest exports list
The exports list rendered 'Exports' three times (page h1 + settings_section
title + inset count header) inside three stacked surfaces (section card >
bg-container-inset > inner bg-container table card). Flatten it:
- settings_section header-less (drop the duplicate title; the page h1 + the
inset count header already label it),
- drop the redundant inner space-y-4 wrapper,
- table sits directly in the inset (remove the inner bg-container card).
Now: one title, one card, one inset.
* refactor(settings): payments + appearances consistency
- payments: subscription summary row was bg-container inside the section's
bg-container (container-on-container) -> bg-container-inset + p-4.
- appearances: toggle-row labels used <h4> (heading-level break inside a
settings_section) -> <p font-medium>.
* refactor(settings): preferences consistency
- month_start_day warning: text-warning bg-warning/10 colored-body-text box
-> DS::Alert(:warning) (neutral-text recipe).
- preview-features block: hand-rolled <section bg-container shadow-border-xs
rounded-xl p-4> -> header-less settings_section (canonical surface).
- toggle-row <h4> -> <p font-medium>; text-[11px] base-currency badge -> text-xs.
* refactor(settings): profiles consistency
- unconfirmed-email notice was hardcoded English -> i18n
(unconfirmed_email_notice_html + resend_confirmation_link keys).
- role + pending chips: bespoke 'rounded-md bg-surface px-1.5 py-0.5 uppercase'
pills -> DS::Pill (tone: gray, badge mode).
- pending-invitation row border: border-alpha-black-25 -> shadow-border-xs
(match the member-row token).
|
||
|
|
d29591cff9 |
fix(ds): unify tab/chip controls on DS::SegmentedControl (#8) (#2284)
* fix(ds): goals status filter -> DS::SegmentedControl The goals status chips were a hand-rolled segmented control: active state toggled via ad-hoc bg-container/shadow-border-xs/text-* classes, NO hover on inactive chips, and a light-only focus ring (ring-alpha-black-100, invisible in dark mode). Migrate to DS::SegmentedControl button segments + toggle the canonical --active class in goals_filter_controller#syncChipState. Gains the dark-safe hover (hover:bg-gray-200 / theme-dark:hover:bg-gray-800) and the canonical focus ring. First control in the tab-consistency pass (#8). * fix(ds): provider filter chips -> DS::SegmentedControl Same hand-rolled segmented control as the goals chips: active toggled via ad-hoc bg-container/shadow/text classes, no inactive hover, light-only ring-alpha-black-100 focus ring. Migrate to DS::SegmentedControl + toggle the canonical --active class in providers_filter_controller#syncChipState. * fix(ds): transaction-type tabs -> DS::SegmentedControl Expense/Income/Transfer tabs were hand-rolled with a hover==active affordance bug: inactive hover raised bg to bg-container, identical to the active state, so hover and selected were indistinguishable. Migrate to DS::SegmentedControl (link segments + icons); the controller toggles the canonical --active class instead of swapping ad-hoc ACTIVE/INACTIVE class lists. The client-side nature switch (expense<->income updates the form's hidden nature field without navigating) is preserved -- verified live: switching to Income flips the active segment AND sets the nature field to inflow. * fix(ds): reports period tabs -> DS::SegmentedControl The Monthly/Quarterly/YTD/Last-6-Months/Custom period selector was five DS::Link ghost/secondary buttons -- a different tab idiom than the rest of the app. Migrate to DS::SegmentedControl link segments (server-rendered active via aria-current; pure navigation, no controller). Now matches the goals / provider / transaction-type controls + the AI provider picker. Also autocorrected a pre-existing single-quote in the next-period aria-label. |
||
|
|
2defccf366 |
fix(ds): dark-mode hover — alpha-black-25 -> surface-hover on date-nav triggers (#2287)
The budget/report period navigation triggers (the prev/next chevrons + the 'Month v' popover button) used hover:bg-alpha-black-25 -- a 3% black overlay that is invisible on dark surfaces, so they had no visible hover affordance in dark mode. Swap to the theme-aware hover:bg-surface-hover token (gray-100 in light, gray-800 in dark) already used by the settings nav. 4 occurrences across budgets/_picker, budgets/_budget_header, reports/index. (Also autocorrected a pre-existing single-quote in reports/index.) |
||
|
|
922b8853a9 |
fix(ds): add-account menu affordance — right-size close, clickable rows (#2276)
The account picker ("What would you like to add?" / "How do you want to add
it?") had affordance gaps:
- The close (x) and back (arrow-left) buttons were size: lg, oversized vs every
other modal close. Drop to size: md.
- The type/method rows had a hover background but no rest-state cue that they
navigate. Add a trailing chevron (subdued, brightens on hover) so they read as
actionable, and move the method-selector rows onto the theme-aware
hover:bg-surface-hover token (were hover:bg-surface).
|
||
|
|
7ebe5154cb |
fix(ds): balance-sheet weight column alignment + category pill padding (#2275)
- Dashboard balance-sheet weight cell wrapped its percentage ("64 %" -> two
lines) for 2-digit values: the account-row cell is w-14 (56px), too narrow
for the 5-bar gauge plus a spaced locale percentage (ca/es format "NN %").
Widen the account-row weight cell to w-20 to match the header/group columns
(also fixing the column misalignment) and add whitespace-nowrap to the value.
- Bump DS::Pill :md vertical padding py-0.5 -> py-1 so category pills (and the
other :md pills) are not vertically cramped.
|
||
|
|
b2b89437b0 |
fix(ds): route remaining literal yellow warning surfaces onto --color-warning (#2250)
* fix(ds): route remaining literal yellow warning surfaces onto --color-warning Completes the #2198 warning consolidation for view-level surfaces: - rules/index recent-runs status badges → DS::Pill (warning / success / error). - accounts/_account_sidebar_tabs missing-data notice: bg-yellow-tint-10 / text-yellow-600 → bg-warning/10 / text-warning. - import/confirms/_mappings unassigned-account notice → bg-warning/10 / border-warning/20 (also fixes a missing dark variant — it was light-yellow in dark mode). - simplefin/_replacement_prompt card → bg-warning/10 / border-warning/20, dropping the now-redundant theme-dark: companions (the token is theme-aware). rules' blue/purple execution-type badges and the red failed-row highlight are left as-is (not warning surfaces). Part of #2198. * fix(ds): neutral text in sidebar missing-data notice (match DS::Alert recipe) Warning surfaces follow the DS::Alert recipe — warning tint + warning-colored icon, but neutral body text (text-primary / text-secondary). The sidebar missing-data notice was the lone holdout still painting its text (and link) with text-warning. Switch to neutral text; keep the bg-warning/10 tint and the warning-colored triangle/chevron as the accent. More readable (color-on-tint text is low-contrast) and consistent with the DS::Alert migrations. * fix(ds): missing-data sidebar notice → static DS::Alert (drop disclosure) The notice was a raw collapsible <details> styled as a warning — a hybrid that hid its own primary action (the "Configure providers" link) and its explanation behind a chevron click. A warning's job is to surface a problem and its fix; a disclosure's job is to hide secondary detail. The two fought each other (triangle-alert "act on this" vs chevron "optional, expand"). Replace with a static DS::Alert(:warning): icon + title + body + the Configure link, always visible. Fixes the affordance, surfaces the action, canonicalizes the last warning-styled raw <details>, and matches the other DS::Alert notices. * fix(ds): grey body text in missing-data alert for title/body hierarchy DS::Alert renders its body in text-primary (same dark as the title). For this notice, drop the description to text-secondary so the title (primary, semibold) reads above the supporting body (grey) — clearer hierarchy. Still neutral (no colored text); the Configure link stays primary + underline so the action remains the prominent element. * fix(ds): bump account_sidebar_tabs cache version v1→v2 Flush stale <details>-markup fragments on deploy. The sidebar missing-data notice migrated from <details> to a static DS::Alert, but the fragment is cached with a 12h TTL — without a key change, old markup renders until expiry (and inconsistently across staggered multi-server cache warmups). Bumping the version string changes the cache-key namespace so every cached fragment is bypassed immediately. |
||
|
|
ab3e7e98c3 |
fix(ds): convert_to_trade price warning — fix dead dark:, use warning token (#2249)
The price-mismatch warning used bare `dark:` variants, but the app's dark mode is the `theme-dark` custom variant — so those styles never applied and the box stayed light-yellow on dark surfaces. Replace the literal yellow palette + dead `dark:` classes with the theme-aware `--color-warning` token (`bg-warning/10`, `border-warning/20`), the warning-colored alert-triangle, and neutral primary/secondary text (matching the DS::Alert recipe). One class now adapts to both themes; the JS targets (priceWarning / priceWarningMessage) are unchanged. Closes #2248. Part of #2198. |
||
|
|
211e407456 |
fix(ds): migrate remaining amber notice boxes to DS::Alert(:warning) (#2247)
Continues the #2198 warning consolidation (after the SSO surfaces). Replaces the last hand-rolled bg-amber-50 notice boxes with DS::Alert: - pages/redis_configuration_error: "why Redis is required" callout → DS::Alert(title:, message:, variant: :warning). - family_exports/new: export "Note" callout → DS::Alert(title:, message:). - sessions/new: "no auth methods enabled" notice → DS::Alert(message:). The DS::Alert warning recipe (warning tint + neutral text + alert-triangle) replaces the literal amber palette while preserving each notice's intent. Part of #2198. After this, the only remaining raw amber-* is the dynamic validation feedback in admin_sso_form_controller.js (deferred — JS form state styling, a separate concern). |
||
|
|
c45b786e86 |
fix(ds): migrate SSO amber warning surfaces to DS::Alert / DS::Pill (#2246)
Apply the resolved warning-hue decision (use --color-warning; no separate amber token) to the admin SSO surfaces flagged in #2198: - admin/sso_providers: the "legacy providers" notice → DS::Alert(:warning); the "ENV configured" badge → DS::Pill(tone: :warning). - settings/securities: the single-OIDC password warning → DS::Alert(:warning). Replaces hand-rolled bg-amber-50 / border-amber-200 / text-amber-800 boxes and an amber badge with the functional warning token via DS components. The DS::Alert warning recipe (warning tint + neutral text + alert-triangle) matches the prior look while clearing the literal-token drift. Part of #2198. |
||
|
|
c05a64ee9b |
fix(ds): canonical destructive red → red-500 (token + button) (#2245)
* fix(ds): canonical destructive red → red-500 (token + button) Per the resolved color decision, --color-destructive moves from red-600 (#EC2222) to red-500 (#F13636), aligning the destructive token with the red-500 already used app-wide for negative amounts and error text. - design/tokens/sure.tokens.json: destructive, border-destructive and button-bg-destructive (red-600→red-500) + button-bg-destructive-hover (red-700→red-600); dark values unchanged (red-400 base / red-500 hover). - Regenerate _generated.css. - buttonish.rb: the destructive button now uses the theme-aware button-bg-destructive / -hover utilities instead of hardcoded bg-red-600/700, removing the last raw-palette destructive bypass. Part of #2134. * fix(ds): keep destructive button fill at red-600 for white-label contrast Addresses review (Codex P2): the canonical-destructive flip to red-500 also lowered the solid destructive button fill to red-500, dropping white-on-red label contrast to ~3.95:1 at text-sm. Keep button-bg-destructive at red-600 / hover red-700 (white-on-red ~4.36:1 — the prior level, no regression). --color-destructive and border-destructive stay red-500: those are red-on- white text/border usages where red-500 is the chosen canonical hue. Solid white-on-red fills legitimately use a darker shade. |
||
|
|
4dbfbf0bc8 |
fix(ds): replace invalid bg-surface-default with bg-surface (#2244)
`bg-surface-default` is not a defined design-system token — Tailwind emits no rule for it, so these surfaces render with no background at all. Five call sites were affected: - rules/index.html.erb — recent-runs table header - settings/llm_usages/show.html.erb — usage table header - settings/ai_prompts/show.html.erb — three prompt-preview boxes Replace with the canonical `bg-surface` token — the same fill the admin/users and settings/debugs table headers already use. Clears a Rule 2 (non-functional token) finding from the weekly DS drift scan. |
||
|
|
034a12f1d8 |
fix(ds): use DS::Disclosure for investment-performance expander (#2243)
The "view details" expander rendered a raw <details>/<summary>, flagged as a Rule 1 (bypassing DS components) finding in the weekly DS drift scan (#2157). Migrate it to the DS::Disclosure :inline variant — the same primitive already used for the goals archived section and provider panels. Gains the canonical focus-visible ring and motion-safe chevron rotation for free; markup, copy, and i18n keys are otherwise unchanged. |
||
|
|
de8cd86f2f |
perf(sync): scope transfer matching after account sync (#2230)
* perf(sync): scope transfer matching after account sync * fix(sync): make transfer lookup index migration reversible |
||
|
|
3ccb82ef9d |
fix(imports): import Actual rows with blank payee (#2282)
* fix(imports): import Actual rows with blank payee Actual Budget exports reconciliation and starting-balance rows with a blank Payee. ActualImport mapped the row name straight from the Payee column with no fallback (unlike Import and MintImport), so a blank Payee produced a blank Entry name. Entry requires a name, and import! wraps all rows in a single transaction, so one blank-payee row failed validation and rolled back the entire import -- surfacing only a generic "Import failed" while the worker logged "done". Fall back to the Notes column (which carries text like "Reconciliation balance adjustment") and then to the default row name, matching the blank-name handling already used by the base importer and MintImport. Add a blank-payee row to the Actual fixture and regression tests covering the Notes fallback, the default fallback, and an end-to-end import that no longer fails on blank-payee rows. * ci(security): skip calendar-based brakeman Rails EOL check The scan_ruby job fails because brakeman's CheckEOLRails warns that Rails 7.2.3.1 reaches end of life on 2026-08-09. That check fires purely on the calendar -- it warns 60 days before the EOL date and escalates in confidence as the date nears (brakeman/checks/eol_check.rb) -- so it turns `bin/brakeman` (exit code 3) red on every branch and on main regardless of the code being scanned. Add config/brakeman.yml (auto-loaded by `bin/brakeman`) skipping only CheckEOLRails. CheckEOLRuby is left enabled because the current Ruby is not near end of life, so that signal is preserved. A TODO records that the skip should be removed when Sure upgrades off Rails 7.2. |
||
|
|
f25fe30a41 |
feat(ai): honor Setting.llm_provider for batch and PDF flows (#2265)
Auto-categorization, merchant detection/enhancement, and PDF/bank-statement extraction hard-coded Provider::Registry.get_provider(:openai), so selecting Anthropic (or running an Anthropic-only self-hosted install) left those operations using/missing OpenAI rather than the chosen provider. Add Provider::Registry.preferred_llm_provider, which resolves the LLM provider honoring Setting.llm_provider with a configured-provider fallback (mirroring how chat picks its provider), and route all six TODO(#2113) call sites through it: - Family::AutoCategorizer#llm_provider - Family::AutoMerchantDetector#llm_provider - ProviderMerchant::Enhancer#llm_provider - PdfImport (process_pdf + extract_bank_statement) - Assistant::Function::ImportBankStatement Provider::Anthropic already implements auto_categorize / auto_detect_merchants / enhance_provider_merchants (#1984) and process_pdf / extract_bank_statement (#1985), so no provider changes are needed — only the wiring. Closes #2113. |
||
|
|
c375b8bf5c |
feat(ai): self-host settings UI for Anthropic provider (5/5) (#1987)
* feat(ai): add Anthropic provider with chat parity (1/5)
Introduces Provider::Anthropic alongside Provider::Openai, implementing
the LlmConcept chat_response contract over the official anthropic Ruby
SDK. Batch ops, PDF, and RAG land in follow-up PRs.
- Provider::Anthropic uses Messages API for sync and streaming responses
- ChatConfig builds requests with ephemeral prompt-cache markers on the
system prompt and the last tool definition
- MessageFormatter reconstructs multi-turn history (text + tool_use +
tool_result blocks) from raw Message records, including the paired
user-role tool_result turn Anthropic requires after every tool_use
- ChatParser maps Anthropic Message into the shared ChatResponse Data
- Registry, Setting, User, Chat default model wired for ANTHROPIC_*
envs and Setting.anthropic_*; LLM_PROVIDER selects between providers
- Responder forwards raw conversation_history (Array<Message>) so
providers without hosted conversation state can rebuild context
- OpenAI provider accepts and ignores the new kwarg (no behavior change)
Tests cover provider init, model gating, MessageFormatter for all turn
shapes, ChatConfig request building (max_tokens, system cache, tool
conversion), ChatParser for text / tool_use / mixed blocks, Registry
discovery, and mocked chat_response success / error / function_request
paths. Live VCR cassettes recorded in a follow-up with a real key.
Stacked PRs: 2/5 batch ops + cost ledger, 3/5 PDF, 4/5 pgvector RAG,
5/5 settings UI + disclosure.
* fix(ai): address PR review on Anthropic provider foundation
Surface fixes raised by Codex + CodeRabbit on PR 1/5:
- Provider::Anthropic#chat_response now accepts (and ignores) a
`messages:` kwarg. Assistant::Responder passes both `messages:`
(OpenAI-shape) and `conversation_history:` (raw Message records) for
cross-provider parity, so the previous signature raised
ArgumentError on the first chat turn through the Anthropic provider.
- Provider::Anthropic#supports_model? bypasses the `claude` prefix
gate when a custom base_url is configured, mirroring the OpenAI
provider. Bedrock-shaped IDs like
`anthropic.claude-sonnet-4-5-20250929-v1:0` and
`claude-opus-4@20250514` are otherwise rejected by
Assistant::Provided#get_model_provider and the chat dies.
- Setting.anthropic_access_token is now in
EncryptedSettingFields::ENCRYPTED_FIELDS so the Anthropic API key
is encrypted at rest like every other provider secret. Previously
plaintext while siblings (openai_access_token, twelve_data_api_key,
external_assistant_token) were ciphertext.
- Chat.default_model falls back to whichever provider is actually
configured. Previously, with LLM_PROVIDER=anthropic but no
Anthropic credentials, the default model resolved to a Claude ID
that no registered provider supported, so chats failed even when
OpenAI was fully configured. Adds Provider::{Anthropic,Openai}#configured?
class methods for the readable callsite.
- Provider::Anthropic.effective_model uses
`ENV["ANTHROPIC_MODEL"].presence || Setting.anthropic_model` so the
Setting lookup is only performed when the env var is absent — the
previous `ENV.fetch(KEY, default)` evaluated the default arg
eagerly on every call.
- Provider::Anthropic::ChatConfig#anthropic_input_schema strips both
`:strict` and `"strict"` keys so JSON-decoded schemas with string
keys cannot leak the OpenAI-only flag through to Anthropic.
Test coverage added: supports_model? bypass on custom endpoints,
chat_response messages: kwarg compatibility, default_model fallback
in the three credential combinations, configured? against ENV +
Setting, strict-flag stripping for both key types, and a
`Setting.expects(:anthropic_model).never` assertion proving the
ENV-precedence test now exercises the lazy path.
All 4365 tests pass (1 pre-existing libvips env error unrelated).
* test(chat): make default_model tests resilient to ENV model overrides
CodeRabbit flagged on PR review: the new default_model tests asserted
against Provider::*::DEFAULT_MODEL, but Chat.default_model actually
returns Provider::*.effective_model.presence (which reads
OPENAI_MODEL / ANTHROPIC_MODEL from the environment). With either env
var set, the tests would fail intermittently even though routing was
correct.
- New default_model tests now assert against the provider's
effective_model directly, so they verify the routing decision
(which provider's value wins) without coupling to the constant.
- Pre-existing "creates with default model" assertions had the same
brittleness; switch them to compare against Chat.default_model so
the chosen model is whatever the env / Setting cascade resolves to.
Verified by running `ANTHROPIC_MODEL=claude-haiku-4-5 OPENAI_MODEL=gpt-4o
bin/rails test test/models/chat_test.rb` — 16 runs, 0 failures
(previously 2 pre-existing failures + 0 from the new tests).
* fix(ai): address local review on Anthropic foundation
- Provider::Anthropic#supports_pdf_processing? bypasses prefix gate for
custom endpoints, mirroring supports_model?
- Provider::Anthropic#initialize raises Error when custom_endpoint? AND
model.blank?, parity with Provider::Openai
- stream_chat_response captures partial usage on mid-stream errors and
records it via the new on_partial callback so chat_response can skip
the duplicate error row in the outer rescue
- safe_accumulated_message swallows the secondary failure when the SDK
cannot reconstruct a snapshot
- langfuse_client memoizes properly (||= instead of =) so repeated calls
don't churn Langfuse instances
- MessageFormatter sorts tool_calls by created_at then id so the
message array is deterministic across replays; skips tool_calls
missing both provider_call_id and provider_id rather than sending
`id: nil` and getting rejected by Anthropic
- Setting.anthropic_access_token default falls back through
ENV["ANTHROPIC_API_KEY"].presence (was missing .presence, so an
empty-string env value bled through)
- User#openai_configured? / #anthropic_configured? delegate to the
Provider::* class methods — single source of truth
- Assistant::Responder renames the OpenAI-shape history builder
conversation_history → openai_messages_payload so the kwarg name
matches the local method name (messages: openai_messages_payload,
conversation_history: chat_message_records)
- Assistant::Builtin stale-history comment updated to reference both
builders
Adds a streaming chat_response test using ad-hoc subclasses of the
SDK event types so the case/when dispatch matches via is_a? without
stubbing class-level === behavior.
* test(ai): add Anthropic tool_use round-trip + multi-tool turn coverage
Addresses @jjmata's "worth confirming" note on PR #1983: tool-use turns
from prior assistant messages must round-trip correctly when retrieved
from the database.
- New `ChatParser → ToolCall::Function → MessageFormatter` test walks
the full path: Anthropic response with a tool_use block →
ChatFunctionRequest → ToolCall::Function.from_function_request →
persisted on the AssistantMessage → MessageFormatter rebuild on the
next turn. Asserts the original `tool_use.id` is preserved end-to-end
as both `tool_use.id` and the paired `tool_result.tool_use_id`, and
that the original `input` hash and serialized result content survive.
- New multi-tool assistant turn test confirms two tool_use blocks on a
single assistant message render as two tool_use blocks followed by
two paired tool_result blocks in a single user-role follow-up,
matching Anthropic's required alternation.
Both tests exercise the existing PR1 code without behavior changes.
* test(ai): require "ostruct" explicitly in Anthropic provider tests
OpenStruct is moving out of Ruby's default load path (warning in 3.4+,
removed in 3.5+). Tests work today because ActiveSupport transitively
loads it, but that's incidental. Match the existing convention in
test/controllers/settings/hostings_controller_test.rb which explicitly
requires ostruct for the same reason.
* fix(ai): sanitize Langfuse warn logs, normalize tool_use.input, dedup history fetch
Addresses three open CodeRabbit findings on PR #1983.
- Provider::Anthropic Langfuse rescue branches no longer include
`e.full_message` in `Rails.logger.warn`. `full_message` bundles the
backtrace + cause chain and on some SDK error types includes the
serialized request/response payload (prompt, model output). Logs
now report `#{e.class}: #{e.message}` only. Three sites:
create_langfuse_trace, log_langfuse_generation, upsert_langfuse_trace.
Note: Provider::Openai has the same pattern (copy-pasted source) —
harmonization deferred to a follow-up cleanup PR; this commit fixes
only the Anthropic provider to keep PR scope tight.
- MessageFormatter#parse_arguments now coerces any non-Hash parsed
result to `{}`. Anthropic's Messages API requires `tool_use.input`
to be a JSON object (map); a stored ToolCall::Function record whose
arguments parse to a scalar, bool, or array (corrupt row, legacy
data, cross-provider bleed) would otherwise produce a payload the
API rejects. Normal flow stores Hash arguments end-to-end so the
fix is defensive — adds 2 tests covering scalar/array JSON strings
and non-String non-Hash inputs.
- Assistant::Responder dedups the chat-history fetch. The previous
layout fired two near-identical `chat.messages.where(...).includes(
:tool_calls).ordered` queries per LLM turn (one for the OpenAI-shape
payload, one for the raw-records kwarg). A new memoized
`complete_chat_messages` fetches once; `chat_message_records` filters
out the current message via `Array#reject`, `openai_messages_payload`
iterates the cached array unchanged. One SQL query per turn instead
of two. Memoization scope = single Responder instance (per LLM call),
so cache invalidation is not a concern.
All 4370 tests pass (1 pre-existing libvips env error unrelated).
Rubocop + brakeman clean.
* fix(ci): replace sk-ant- prefixed test placeholders
Pipelock secret scanner pattern-matches `sk-ant-*` as a real Anthropic
API key and fails the PR security-scan check. Test stubs and
ClimateControl env values used `sk-ant-test`, `sk-ant-from-setting`,
`sk-ant-x`, `sk-ant-y` as obvious placeholders, but the scanner does
not care about value entropy.
Switched to `fake-anthropic-key-*` / `fake-token-*` strings so the
scanner stops flagging them. No production code touched, no behavior
change — Provider::Anthropic still accepts any non-blank token.
* feat(ai): add Anthropic batch ops + LLM cost ledger (2/5)
Implements auto_categorize, auto_detect_merchants, and
enhance_provider_merchants on Provider::Anthropic via forced tool calls,
plus the cost-ledger plumbing they need.
- Provider::Anthropic::AutoCategorizer, AutoMerchantDetector,
ProviderMerchantEnhancer each define a single output tool whose
input_schema mirrors the desired output, then force the model to call
it via tool_choice: { type: "tool", name: ..., disable_parallel_tool_use: true }.
Anthropic guarantees the tool_use.input matches the schema, so there
is no JSON parsing fragility, no <think> tag stripping, and no
json_object/json_schema fallback ladders.
- Concerns::UsageRecorder mirrors the OpenAI sibling but persists
cache_creation_input_tokens / cache_read_input_tokens to dedicated
columns instead of metadata.
- Migration adds cache_creation_tokens, cache_read_tokens (nullable
integers) to llm_usages. OpenAI rows leave them null.
- LlmUsage::PRICING gains Claude 4.x rows (opus-4-7 $15/$75, sonnet-4-6
$3/$15, haiku-4-5 $1/$5 per MTok). infer_provider returns "anthropic"
for claude-* via the existing exact/prefix lookup.
- Provider::Anthropic#chat_response now persists cache columns directly
rather than stashing them in metadata.
- 25-transaction batch cap mirrors the OpenAI provider so the cost
ledger sees the same shape regardless of which provider ran a batch.
Tests cover the forced-tool-call path, null/None normalization,
case-insensitive merchant matching, the missing-tool_use error path,
and Anthropic-specific pricing + provider inference on LlmUsage.
Stacked on #1983 (PR 1/5). 3/5 PDF + vision next.
* fix(ai): attribute Bedrock model IDs to anthropic + clean nil enum
- LlmUsage.infer_provider now returns "anthropic" for Bedrock /
Vertex shaped IDs (anthropic.* and anthropic/*), so cost-ledger
filtering by provider stays correct even when no per-MTok rate is
stored. Previously these IDs fell through to the "openai" default.
- AutoCategorizer drops the redundant nil sentinel from the
category_name enum — the union type [string, null] already permits
null, and some JSON Schema validators reject nil literals inside
enum arrays.
* test(ai): require "ostruct" in Anthropic batch op tests
Same rationale as the PR1 ostruct fix — explicit require so the tests
don't depend on ActiveSupport's transitive load when Ruby 3.5+ removes
OpenStruct from the default load path.
* feat(ai): Anthropic native PDF processing (3/5)
Implements process_pdf and extract_bank_statement on Provider::Anthropic
using the native `document` content block — no rasterization, no text
pre-extraction.
- Provider::Anthropic::PdfProcessor classifies the document, summarizes
it, and extracts statement metadata via a forced report_document_analysis
tool whose input_schema mirrors the existing Provider::Openai output
(document_type from Import::DOCUMENT_TYPES, summary, extracted_data).
- Provider::Anthropic::BankStatementExtractor returns the same
{ transactions, period, account_holder, account_number, bank_name,
opening_balance, closing_balance } shape via report_bank_statement so
downstream pdf_import code is provider-agnostic.
- Both attach the PDF as
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: <b64> } }
— Claude 3.5+ / 4.x accept this natively (up to 32MB / 100 pages).
No pdf-reader, no pdftoppm, no chunking for typical statements.
- supports_pdf_processing? (introduced in PR 1) already returns true for
claude-* models, gating process_pdf with a clear error otherwise.
- Cost ledger rows are persisted via the shared UsageRecorder concern,
including cache_creation/cache_read tokens.
Tests verify the document block shape, tool_choice forcing, normalized
document_type for unknown classifications, transaction normalization
(date / amount / reference → notes), and the missing-tool_use error
path. Blank pdf_content raises before any client call.
Stacked on #1984 (PR 2/5). 4/5 pgvector RAG next.
* fix(ai): guard PDF size + surface bank-statement truncation
- PdfProcessor and BankStatementExtractor raise upfront when
pdf_content.bytesize exceeds MAX_PDF_BYTES (32 MB, matching
Anthropic's hard limit). Previously a 100 MB PDF would be
base64-encoded (~133 MB) and packed into the JSON body before
the API rejected it — peak heap ~270 MB per Sidekiq worker.
- BankStatementExtractor inspects response.stop_reason; when the
model hit max_tokens it logs a warning and flags result[:truncated]
so downstream callers know the transaction list may be incomplete.
- ISO date pattern added to statement_period_start/end schema in
PdfProcessor so the model can't return "March 2026" — Anthropic
enforces the regex via the tool's input_schema.
Tests cover the size guard (raises before any client.messages call),
truncated-result flagging, and the warning log path.
* test(ai): require "ostruct" in Anthropic PDF tests
Match the explicit ostruct require added in PR1/PR2 — same Ruby 3.5+
load-path reason.
* feat(ai): default Anthropic installs to pgvector RAG (4/5)
The provider-agnostic vector store stack (VectorStore::Pgvector + the
Embeddable concern) already shipped to main. This PR closes the
Anthropic loop:
- VectorStore::Registry.adapter_name now returns :pgvector when
Setting.llm_provider == "anthropic" and no explicit
VECTOR_STORE_PROVIDER override is set. Anthropic has no hosted vector
store, so falling back to the local pgvector adapter is the only
correct default. Explicit VECTOR_STORE_PROVIDER still wins.
- SearchFamilyFiles surfaces a longer message when no adapter is wired
up — calling out pgvector + EMBEDDING_URI_BASE as the supported
Anthropic-only path so the user is not stuck with an "OpenAI required"
hint that is no longer accurate.
The Embeddable concern already pulls embeddings from
EMBEDDING_URI_BASE / EMBEDDING_ACCESS_TOKEN (with OpenAI as fallback),
so Anthropic installs point this at Voyage AI, a local Ollama instance,
or OpenAI embeddings — independent of the chat provider.
Tests cover the new default routing, the existing OpenAI default
staying intact, and explicit VECTOR_STORE_PROVIDER overriding the
Anthropic default.
Stacked on #1985 (PR 3/5). 5/5 settings UI + retention disclosure next.
* feat(ai): self-host settings UI for Anthropic provider (5/5)
Adds the Anthropic panel and the install-wide LLM provider selector to
the self-hosting settings page, plus a shared data-retention
disclosure that covers both OpenAI and Anthropic.
- New _llm_provider_selector partial: select for Setting.llm_provider
(openai | anthropic), respects the LLM_PROVIDER env var (disables the
control + shows the "configured through environment variables" hint
when set, mirroring the existing OpenAI panel behaviour), and renders
a compact data-handling block with one-line retention statements for
each provider.
- New _anthropic_settings partial mirrors _openai_settings exactly:
password-field for the API key with **** redaction, optional
base_url (for AWS Bedrock / GCP Vertex), optional default model. All
three fields disable when their ENV var is set.
- show.html.erb renders provider selector + OpenAI panel + Anthropic
panel under the same "General" section so users can configure either
(or both) without switching pages.
- Settings::HostingsController#update now permits and persists
anthropic_access_token (ignoring the **** placeholder, same pattern
as OpenAI), anthropic_base_url, anthropic_model, and llm_provider
(validated against %w[openai anthropic]). On Setting::ValidationError
the rescue branch preserves anthropic_base_url / anthropic_model
input so the form re-renders with the user's typed values intact —
parity with the issue #1824 fix for OpenAI.
- Locale keys added under settings.hostings.{llm_provider_selector,
anthropic_settings}.
Tests cover token update + placeholder redaction, base_url + model
update, llm_provider switch to anthropic, and rejection of unknown
provider values. The existing GET render test still passes, exercising
all three new partials.
Closes the 5/5 Anthropic series stacked on #1986.
* fix(ai): valid Tailwind token + base_url URL validation
- Data-handling block in _llm_provider_selector swaps the invalid
bg-surface-secondary token for bg-container-inset, matching the
inset-card pattern used elsewhere in sure-design-system/components.css.
bg-surface-secondary is not defined anywhere in the design system —
Tailwind treated it as a no-op, so the block rendered with no
background contrast.
- Settings::HostingsController validates anthropic_base_url as a
URI::HTTP (catches https too) and raises Setting::ValidationError
with a localized message when the input is not parseable.
Previously any string was persisted, surfacing as an opaque
connection error at request time instead of an immediate UX failure.
- Blank base_url now clears the setting (was already the case but
exercised explicitly in tests now).
* fix(ci): replace sk-ant- prefixed token in hostings controller test
Same pipelock secret-scan trigger as PR1 fix on registry/anthropic
tests. The sk-ant-* prefix is matched verbatim by the scanner
regardless of value entropy.
* fix(ai): provision pgvector table when it is the default store
#1986 makes pgvector the default vector store for Anthropic installs, but
CreateVectorStoreChunks only ran when VECTOR_STORE_PROVIDER=pgvector was set
explicitly — so a fresh Anthropic-only install migrated without the
vector_store_chunks table and failed on uploads/searches.
Add VectorStore::Registry.pgvector_effective? as the single source of truth
for "is pgvector active?" (explicit env OR the Anthropic default), and a new
idempotent migration that enables the extension + creates the table whenever
pgvector is effective and the table is missing — covering fresh and
already-migrated installs without drift. Addresses Codex P1.
* fix(ai): provision pgvector table for Anthropic-default installs
Migration gated on raw VECTOR_STORE_PROVIDER==pgvector, so an
Anthropic-default install (which selects pgvector implicitly via
Setting.llm_provider without setting VECTOR_STORE_PROVIDER) skipped
table creation and failed later on a missing vector_store_chunks
relation. Route through VectorStore::Registry.pgvector_effective? —
the single source of truth already shared by the adapter selection.
Addresses Codex P1 review finding.
* fix(ai): provision pgvector chunks table on schema-load installs
The ensure-migration only helps db:migrate upgraders. Fresh installs go
through bin/docker-entrypoint's db:prepare, which loads schema.rb (the
conditional table can't be dumped there — it needs the vector extension)
and marks every migration applied without running it. An Anthropic-only
fresh install therefore selected the pgvector adapter but had no table,
failing with raw PG errors on first upload or search.
Two layers close it:
- VectorStore::Pgvector#ensure_schema! provisions the table idempotently
on first use (mirrors CreateVectorStoreChunks; memoized; failures wrap
in VectorStore::Error, which with_response turns into a clean failed
response).
- VectorStore::Registry#build_pgvector now gates on
VectorStore::Pgvector.available? (table exists, or extension present),
so installs whose Postgres lacks pgvector entirely degrade to the
assistant's provider_not_configured message instead of raising
mid-chat.
Also resolves the schema.rb version conflict against main (keep the
branch's 2026_06_01_120000, on top of main's current tables).
* fix(ai): address review nitpicks on pgvector provisioning
- Registry: update the adapter doc comment to mention the
Anthropic-to-pgvector default alongside the openai fallback.
- ensure_schema!: guard the DDL with if_not_exists instead of a Mutex.
Adapter instances are built per call and never shared across threads,
so the realistic race is two processes (web + Sidekiq) provisioning
concurrently; IF NOT EXISTS makes the loser a no-op where a Mutex
would only serialize threads inside one process.
* fix(ai): address review on Anthropic settings UI
- Require an Anthropic model when a custom base URL is saved, mirroring the
OpenAI branch. Auto-submit-on-blur could persist a base URL with no model,
making Provider::Anthropic raise "Model is required..." on every LLM call.
- Narrow the LLM provider selector copy: only chat honors Setting.llm_provider;
categorization, merchant detection and PDF processing still always use OpenAI.
Stop advertising provider switching for those flows until they are wired.
- Reset global Setting.* in test teardown to prevent state leakage, and add a
test covering the new base-URL-requires-model validation.
* feat(ds): conditional LLM provider settings + merged copy
The self-hosting AI section showed both providers' credential blocks at once
and duplicated near-identical copy. Tidy it:
- Replace the provider <select> with a DS::SegmentedControl driving a new
provider-settings Stimulus controller: only the active provider's panel is
shown; switching reveals the other instantly and persists Setting.llm_provider.
- Merge the two byte-identical data-retention lines into one provider-neutral
Data handling note.
- Scope the token-budget copy to OpenAI-compatible calls (read only by
Provider::Openai) and add an inline 'add a key to activate' hint when the
active provider is unconfigured.
UI-only; no provider behavior change.
* feat(ds): responsive LLM provider picker (tabs >=sm, select on mobile)
The segmented tabs overflow a phone viewport once there are 3+ providers
(measured: 4 labels want ~409px in a 319px column at 390px wide). Below sm,
fall back to a native <select> -- which doubles as the submitted field -- while
keeping the segmented tabs at sm and up.
Both controls bind to the same provider-settings Stimulus controller (the
select reads its value, the tabs read data-provider), so adding a 3rd/4th
provider scales on mobile with no layout math.
* fix(hostings): sanitize llm provider selector
* test(hostings): avoid brittle provider hint assertion
---------
Co-authored-by: sure-admin <sure-admin@splashblot.com>
|
||
|
|
3627c4d2d1 | Respect CoinStats wallet rate limits (#2251) | ||
|
|
6d27285c03 |
fix(charts): restyle hover tooltips with soft shadow + larger radius (#2029)
* fix(charts): restyle hover tooltips with soft shadow + larger radius Match the elevation pattern of DS::Select dropdown (shadow-lg + shadow-border-xs) and increase radius (rounded-lg → rounded-xl) + padding (p-2 → p-3) for better breathing. Drops the hard border in favour of the soft drop shadow. time_series_chart_controller: - p-2 → p-3, rounded-lg → rounded-xl - remove border border-secondary - add shadow-lg shadow-border-xs (theme-aware drop + border-edge) - add explicit text-primary (was missing per #2011's drift note) - add z-50 (matches goal_projection + sankey controllers) sankey_chart_controller: - bg-gray-700 text-white → bg-container text-primary (theme-aware; was broken in light mode after dark-bg flip) - p-2 rounded → p-3 rounded-xl - add shadow-lg shadow-border-xs - add font-sans for consistency with the other chart tooltips - add privacy-sensitive class (was missing — sankey money values were rendered in the clear with privacy mode on) DS::Tooltip (icon-trigger help, bg-inverse) is intentionally a different primitive and is not touched. Refs #2011 — className consolidation into a shared module is tracked separately and intentionally not closed by this PR; the new string applies to two of the three call sites today (time_series, sankey). The third (goal_projection_chart_controller) lives on the feat/goals-v2-architecture branch and will adopt the same string when goals v2 merges. * fix(charts): align every chart tooltip on the borderless soft-shadow card One visual contract for all three D3 tooltip surfaces, matching the design reference: p-4, rounded-2xl, shadow-xl, no edge ring in light mode. Dark mode keeps a 1px alpha-white ring since a shadow alone disappears against dark surfaces. - goal_projection_chart_controller drops its hand-copied class string (it still carried the old bordered recipe — the drift this util exists to prevent) and builds its two lines through the shared factory: secondary date line, tabular value line. - New content conventions exported alongside the container contract: context line = text-xs text-secondary, values = font-medium tabular-nums. Time-series and sankey adopt them. - Sankey node titles now escape before .html(); user-named categories were previously interpolated raw into the tooltip markup. * fix(charts): match the tooltip surface to the design reference exactly The previous pass approximated the reference with utility guesses (rounded-2xl, p-4, shadow-xl, dark ring). The actual spec is a hairline border ring composed with a soft 0 8px 24px drop shadow, 10px radius, 12x14 padding, and an 80ms left/top glide. Tailwind shadow utilities can't compose a ring with a custom drop shadow, so the surface moves into the design system as .chart-tooltip (theme-aware: dark swaps the ring to alpha-white and lets it carry the edge). Money/numeric figures also pick up the reference's mono treatment: font-mono + tabular-nums on every value across time-series, sankey, and goal-projection, so digits don't jitter while the scrubber moves. * fix(charts): tighten tooltip padding to 10x12 * feat(charts): give sankey and goal tooltips their missing context rows Chart tooltips answer three stacked questions: context (what am I looking at), value (how much), relation (vs what). Time-series already had all three; the other two were missing rows. - Sankey links showed a bare "$X (Y%)" with no indication of which flow was hovered. Links now lead with a swatch-dotted "Source → Target" context line; nodes get the same dot + name treatment, tying the card to the ribbon color. The context builder escapes node names centrally (they're user-named categories). - Goal projection adds a tertiary relation line — "52% of $20K target" — computed from the payload the chart already carries, for both the saved and projected segments. Hidden when the goal has no positive target. Template is i18n-wired like the existing tooltip strings (goals.show.projection.tooltip_target_relation). Verified with Playwright against the running app: all three surfaces pass computed-style and content assertions. * fix(charts): drop the color dot from sankey tooltip context The hover highlight on the diagram already identifies the ribbon; the swatch repeated it inside the card. Names alone keep the context line quieter. * fix(charts): use the app's sans money treatment in tooltips, not mono font-mono in this codebase marks code, keys, and admin surfaces; money is sans + tabular-nums everywhere else (cards, KPIs, tables). Keep the tabular figures for scrub stability, drop the mono. * fix(charts): address review — glide opt-in, no shadowed var, truncate cap - The 80ms left/top transition moved out of .chart-tooltip: it eased the snap-positioned goal tooltip but made cursor-following tooltips (sankey, time-series) trail the pointer by a frame. Goal projection opts back in via inline style; the component comment documents the split. - setRelation reuses _draw()'s targetAmount const instead of declaring a local 'target' that shadowed the target-date const. - Sankey context line gets max-w-64 so truncate has a constraint to fire against on deep flows. - Component comment now says 10x12 padding, matching the declaration. * Revert `schema.rb` changes --------- Co-authored-by: Juan José Mata <jjmata@jjmata.com> |
||
|
|
3b4aa55516 |
test(goals): pledge-delta integration coverage (follow-up to #2178) (#2206)
* fix(goals): match manual_save pledges by contribution delta, not full balance Closes #2177 * fix(goals): clarify depository-only contribution; lowercase test names Address review: document that valuation_contribution's delta is only consumed for goal-linked Depository accounts, so the positive-delta guard is correct and no liability paydown sign case can arise. Lowercase NOT in two test names to match project convention. * test(goals): pin pledge matching through the reconciliation manager Follow-up to #2178, which matched manual_save pledges by contribution delta but only unit-tested the matcher with an injected delta. The delta derivation itself (the balances-table lookup and the nil-to-0 fallback in valuation_contribution) had no coverage, and reconciliation_manager_test never touched pledges. Two manager-level tests pin the wiring end to end: a 2000 -> 2150 reconcile matches an open 150 pledge, and a same-balance reconcile (zero delta) leaves it open, closing the <= 0 boundary. Also documents the staleness window on valuation_contribution: the prior balance reads the balances table, which recomputes async after the valuation saves, so a second same-day reconcile racing that sync derives a stale delta and self-heals on the next save. * fix(goals): derive same-day re-reconcile delta from the prior valuation The balances table recomputes asynchronously after a reconcile, so a second same-day reconcile racing that sync derived its delta from the pre-first-reconcile row. An overstated delta could wrongly close a larger open pledge — and a wrong match, unlike a miss, never self-heals. Prefer the valuation's own pre-save amount as the prior balance when the reconcile updates an existing valuation; fall back to the balances row, then 0. Same-date re-reconciles are immune to the race; only the cross-date window remains, and only in the miss direction. --------- Co-authored-by: galuis116 <galuis116@gmail.com> |
||
|
|
a3d6a7aede |
feat(ds): DS::EmptyState primitive (#2137) (#2146)
Ships DS::EmptyState — a centered icon / title / optional description /
optional action-slot state — plus a preview, tests, and an exemplar migration
of the recurring-transactions empty screen.
Consolidates the repeated `text-center py-12 + icon + title + description + CTA`
markup across the empty / no-data screens (recurring, imports, statements,
exports, and several connector setups). The audit flagged the self-hosted
feature-disabled pages rendering bare unstyled top-left text ("reads as a 500,
not a state") — wiring those through this primitive is the follow-up.
- app/components/DS/empty_state.rb: render DS::EmptyState.new(icon:, title:,
description:) with an optional `with_action` slot for the CTA.
- Migrate recurring_transactions/index empty branch.
Tests + rubocop + erb_lint clean.
Deferred: the remaining ~10 empty screens + the bare-text feature-disabled
states — same primitive, one migration each.
|
||
|
|
4e0da0c237 |
fix(ds): dark-parity for bespoke green status badges (#2142)
* fix(ds): dark-parity for bespoke green status badges (#2134) The status/type badges in recurring transactions, admin SSO providers, and the categorize group title used solid light-green tints (bg-green-50/100 + text-green-700/800) with no dark variant, so they rendered as pale "stickers" on dark. Add theme-dark variants mirroring DS::Pill's soft recipe (bg-green-tint-10 + text-green-200); dark text contrast 9.5-14.2:1. Their neutral siblings were already token-based. Audit-named _status_pill / _maturity_badge already render via DS::Pill; remaining solid-tint spots are alpha icon-containers (bg-*-500/5,/10) that composite fine on dark. Full DS::Pill consolidation tracked separately. * fix(ds): dark-parity for bespoke amber/blue notices + SSO badge (#2134) Continues the bespoke-badge tail: the raw-amber warning notices (securities SSO warning, admin SSO legacy-providers notice) + the env-configured badge, and the AI tool-call info box, were solid light tints (bg-amber-50/100 / bg-blue-50) with no dark variant -> pale "stickers" on dark. Add theme-dark variants mirroring the soft recipe: alpha-tint bg (amber/blue-500/10-15), lightened border, light text/icon (amber-200/400). Verified legible on dark. Pre-auth (sessions/new) + infra (redis error) amber notices render light-only, left as-is. Broader: these warning boxes want a shared DS notice/callout component (#2137). |
||
|
|
d845e44ff8 |
feat(ai): default Anthropic installs to pgvector RAG (4/5) (#1986)
* feat(ai): add Anthropic provider with chat parity (1/5)
Introduces Provider::Anthropic alongside Provider::Openai, implementing
the LlmConcept chat_response contract over the official anthropic Ruby
SDK. Batch ops, PDF, and RAG land in follow-up PRs.
- Provider::Anthropic uses Messages API for sync and streaming responses
- ChatConfig builds requests with ephemeral prompt-cache markers on the
system prompt and the last tool definition
- MessageFormatter reconstructs multi-turn history (text + tool_use +
tool_result blocks) from raw Message records, including the paired
user-role tool_result turn Anthropic requires after every tool_use
- ChatParser maps Anthropic Message into the shared ChatResponse Data
- Registry, Setting, User, Chat default model wired for ANTHROPIC_*
envs and Setting.anthropic_*; LLM_PROVIDER selects between providers
- Responder forwards raw conversation_history (Array<Message>) so
providers without hosted conversation state can rebuild context
- OpenAI provider accepts and ignores the new kwarg (no behavior change)
Tests cover provider init, model gating, MessageFormatter for all turn
shapes, ChatConfig request building (max_tokens, system cache, tool
conversion), ChatParser for text / tool_use / mixed blocks, Registry
discovery, and mocked chat_response success / error / function_request
paths. Live VCR cassettes recorded in a follow-up with a real key.
Stacked PRs: 2/5 batch ops + cost ledger, 3/5 PDF, 4/5 pgvector RAG,
5/5 settings UI + disclosure.
* fix(ai): address PR review on Anthropic provider foundation
Surface fixes raised by Codex + CodeRabbit on PR 1/5:
- Provider::Anthropic#chat_response now accepts (and ignores) a
`messages:` kwarg. Assistant::Responder passes both `messages:`
(OpenAI-shape) and `conversation_history:` (raw Message records) for
cross-provider parity, so the previous signature raised
ArgumentError on the first chat turn through the Anthropic provider.
- Provider::Anthropic#supports_model? bypasses the `claude` prefix
gate when a custom base_url is configured, mirroring the OpenAI
provider. Bedrock-shaped IDs like
`anthropic.claude-sonnet-4-5-20250929-v1:0` and
`claude-opus-4@20250514` are otherwise rejected by
Assistant::Provided#get_model_provider and the chat dies.
- Setting.anthropic_access_token is now in
EncryptedSettingFields::ENCRYPTED_FIELDS so the Anthropic API key
is encrypted at rest like every other provider secret. Previously
plaintext while siblings (openai_access_token, twelve_data_api_key,
external_assistant_token) were ciphertext.
- Chat.default_model falls back to whichever provider is actually
configured. Previously, with LLM_PROVIDER=anthropic but no
Anthropic credentials, the default model resolved to a Claude ID
that no registered provider supported, so chats failed even when
OpenAI was fully configured. Adds Provider::{Anthropic,Openai}#configured?
class methods for the readable callsite.
- Provider::Anthropic.effective_model uses
`ENV["ANTHROPIC_MODEL"].presence || Setting.anthropic_model` so the
Setting lookup is only performed when the env var is absent — the
previous `ENV.fetch(KEY, default)` evaluated the default arg
eagerly on every call.
- Provider::Anthropic::ChatConfig#anthropic_input_schema strips both
`:strict` and `"strict"` keys so JSON-decoded schemas with string
keys cannot leak the OpenAI-only flag through to Anthropic.
Test coverage added: supports_model? bypass on custom endpoints,
chat_response messages: kwarg compatibility, default_model fallback
in the three credential combinations, configured? against ENV +
Setting, strict-flag stripping for both key types, and a
`Setting.expects(:anthropic_model).never` assertion proving the
ENV-precedence test now exercises the lazy path.
All 4365 tests pass (1 pre-existing libvips env error unrelated).
* test(chat): make default_model tests resilient to ENV model overrides
CodeRabbit flagged on PR review: the new default_model tests asserted
against Provider::*::DEFAULT_MODEL, but Chat.default_model actually
returns Provider::*.effective_model.presence (which reads
OPENAI_MODEL / ANTHROPIC_MODEL from the environment). With either env
var set, the tests would fail intermittently even though routing was
correct.
- New default_model tests now assert against the provider's
effective_model directly, so they verify the routing decision
(which provider's value wins) without coupling to the constant.
- Pre-existing "creates with default model" assertions had the same
brittleness; switch them to compare against Chat.default_model so
the chosen model is whatever the env / Setting cascade resolves to.
Verified by running `ANTHROPIC_MODEL=claude-haiku-4-5 OPENAI_MODEL=gpt-4o
bin/rails test test/models/chat_test.rb` — 16 runs, 0 failures
(previously 2 pre-existing failures + 0 from the new tests).
* fix(ai): address local review on Anthropic foundation
- Provider::Anthropic#supports_pdf_processing? bypasses prefix gate for
custom endpoints, mirroring supports_model?
- Provider::Anthropic#initialize raises Error when custom_endpoint? AND
model.blank?, parity with Provider::Openai
- stream_chat_response captures partial usage on mid-stream errors and
records it via the new on_partial callback so chat_response can skip
the duplicate error row in the outer rescue
- safe_accumulated_message swallows the secondary failure when the SDK
cannot reconstruct a snapshot
- langfuse_client memoizes properly (||= instead of =) so repeated calls
don't churn Langfuse instances
- MessageFormatter sorts tool_calls by created_at then id so the
message array is deterministic across replays; skips tool_calls
missing both provider_call_id and provider_id rather than sending
`id: nil` and getting rejected by Anthropic
- Setting.anthropic_access_token default falls back through
ENV["ANTHROPIC_API_KEY"].presence (was missing .presence, so an
empty-string env value bled through)
- User#openai_configured? / #anthropic_configured? delegate to the
Provider::* class methods — single source of truth
- Assistant::Responder renames the OpenAI-shape history builder
conversation_history → openai_messages_payload so the kwarg name
matches the local method name (messages: openai_messages_payload,
conversation_history: chat_message_records)
- Assistant::Builtin stale-history comment updated to reference both
builders
Adds a streaming chat_response test using ad-hoc subclasses of the
SDK event types so the case/when dispatch matches via is_a? without
stubbing class-level === behavior.
* test(ai): add Anthropic tool_use round-trip + multi-tool turn coverage
Addresses @jjmata's "worth confirming" note on PR #1983: tool-use turns
from prior assistant messages must round-trip correctly when retrieved
from the database.
- New `ChatParser → ToolCall::Function → MessageFormatter` test walks
the full path: Anthropic response with a tool_use block →
ChatFunctionRequest → ToolCall::Function.from_function_request →
persisted on the AssistantMessage → MessageFormatter rebuild on the
next turn. Asserts the original `tool_use.id` is preserved end-to-end
as both `tool_use.id` and the paired `tool_result.tool_use_id`, and
that the original `input` hash and serialized result content survive.
- New multi-tool assistant turn test confirms two tool_use blocks on a
single assistant message render as two tool_use blocks followed by
two paired tool_result blocks in a single user-role follow-up,
matching Anthropic's required alternation.
Both tests exercise the existing PR1 code without behavior changes.
* test(ai): require "ostruct" explicitly in Anthropic provider tests
OpenStruct is moving out of Ruby's default load path (warning in 3.4+,
removed in 3.5+). Tests work today because ActiveSupport transitively
loads it, but that's incidental. Match the existing convention in
test/controllers/settings/hostings_controller_test.rb which explicitly
requires ostruct for the same reason.
* fix(ai): sanitize Langfuse warn logs, normalize tool_use.input, dedup history fetch
Addresses three open CodeRabbit findings on PR #1983.
- Provider::Anthropic Langfuse rescue branches no longer include
`e.full_message` in `Rails.logger.warn`. `full_message` bundles the
backtrace + cause chain and on some SDK error types includes the
serialized request/response payload (prompt, model output). Logs
now report `#{e.class}: #{e.message}` only. Three sites:
create_langfuse_trace, log_langfuse_generation, upsert_langfuse_trace.
Note: Provider::Openai has the same pattern (copy-pasted source) —
harmonization deferred to a follow-up cleanup PR; this commit fixes
only the Anthropic provider to keep PR scope tight.
- MessageFormatter#parse_arguments now coerces any non-Hash parsed
result to `{}`. Anthropic's Messages API requires `tool_use.input`
to be a JSON object (map); a stored ToolCall::Function record whose
arguments parse to a scalar, bool, or array (corrupt row, legacy
data, cross-provider bleed) would otherwise produce a payload the
API rejects. Normal flow stores Hash arguments end-to-end so the
fix is defensive — adds 2 tests covering scalar/array JSON strings
and non-String non-Hash inputs.
- Assistant::Responder dedups the chat-history fetch. The previous
layout fired two near-identical `chat.messages.where(...).includes(
:tool_calls).ordered` queries per LLM turn (one for the OpenAI-shape
payload, one for the raw-records kwarg). A new memoized
`complete_chat_messages` fetches once; `chat_message_records` filters
out the current message via `Array#reject`, `openai_messages_payload`
iterates the cached array unchanged. One SQL query per turn instead
of two. Memoization scope = single Responder instance (per LLM call),
so cache invalidation is not a concern.
All 4370 tests pass (1 pre-existing libvips env error unrelated).
Rubocop + brakeman clean.
* fix(ci): replace sk-ant- prefixed test placeholders
Pipelock secret scanner pattern-matches `sk-ant-*` as a real Anthropic
API key and fails the PR security-scan check. Test stubs and
ClimateControl env values used `sk-ant-test`, `sk-ant-from-setting`,
`sk-ant-x`, `sk-ant-y` as obvious placeholders, but the scanner does
not care about value entropy.
Switched to `fake-anthropic-key-*` / `fake-token-*` strings so the
scanner stops flagging them. No production code touched, no behavior
change — Provider::Anthropic still accepts any non-blank token.
* feat(ai): add Anthropic batch ops + LLM cost ledger (2/5)
Implements auto_categorize, auto_detect_merchants, and
enhance_provider_merchants on Provider::Anthropic via forced tool calls,
plus the cost-ledger plumbing they need.
- Provider::Anthropic::AutoCategorizer, AutoMerchantDetector,
ProviderMerchantEnhancer each define a single output tool whose
input_schema mirrors the desired output, then force the model to call
it via tool_choice: { type: "tool", name: ..., disable_parallel_tool_use: true }.
Anthropic guarantees the tool_use.input matches the schema, so there
is no JSON parsing fragility, no <think> tag stripping, and no
json_object/json_schema fallback ladders.
- Concerns::UsageRecorder mirrors the OpenAI sibling but persists
cache_creation_input_tokens / cache_read_input_tokens to dedicated
columns instead of metadata.
- Migration adds cache_creation_tokens, cache_read_tokens (nullable
integers) to llm_usages. OpenAI rows leave them null.
- LlmUsage::PRICING gains Claude 4.x rows (opus-4-7 $15/$75, sonnet-4-6
$3/$15, haiku-4-5 $1/$5 per MTok). infer_provider returns "anthropic"
for claude-* via the existing exact/prefix lookup.
- Provider::Anthropic#chat_response now persists cache columns directly
rather than stashing them in metadata.
- 25-transaction batch cap mirrors the OpenAI provider so the cost
ledger sees the same shape regardless of which provider ran a batch.
Tests cover the forced-tool-call path, null/None normalization,
case-insensitive merchant matching, the missing-tool_use error path,
and Anthropic-specific pricing + provider inference on LlmUsage.
Stacked on #1983 (PR 1/5). 3/5 PDF + vision next.
* fix(ai): attribute Bedrock model IDs to anthropic + clean nil enum
- LlmUsage.infer_provider now returns "anthropic" for Bedrock /
Vertex shaped IDs (anthropic.* and anthropic/*), so cost-ledger
filtering by provider stays correct even when no per-MTok rate is
stored. Previously these IDs fell through to the "openai" default.
- AutoCategorizer drops the redundant nil sentinel from the
category_name enum — the union type [string, null] already permits
null, and some JSON Schema validators reject nil literals inside
enum arrays.
* test(ai): require "ostruct" in Anthropic batch op tests
Same rationale as the PR1 ostruct fix — explicit require so the tests
don't depend on ActiveSupport's transitive load when Ruby 3.5+ removes
OpenStruct from the default load path.
* feat(ai): Anthropic native PDF processing (3/5)
Implements process_pdf and extract_bank_statement on Provider::Anthropic
using the native `document` content block — no rasterization, no text
pre-extraction.
- Provider::Anthropic::PdfProcessor classifies the document, summarizes
it, and extracts statement metadata via a forced report_document_analysis
tool whose input_schema mirrors the existing Provider::Openai output
(document_type from Import::DOCUMENT_TYPES, summary, extracted_data).
- Provider::Anthropic::BankStatementExtractor returns the same
{ transactions, period, account_holder, account_number, bank_name,
opening_balance, closing_balance } shape via report_bank_statement so
downstream pdf_import code is provider-agnostic.
- Both attach the PDF as
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: <b64> } }
— Claude 3.5+ / 4.x accept this natively (up to 32MB / 100 pages).
No pdf-reader, no pdftoppm, no chunking for typical statements.
- supports_pdf_processing? (introduced in PR 1) already returns true for
claude-* models, gating process_pdf with a clear error otherwise.
- Cost ledger rows are persisted via the shared UsageRecorder concern,
including cache_creation/cache_read tokens.
Tests verify the document block shape, tool_choice forcing, normalized
document_type for unknown classifications, transaction normalization
(date / amount / reference → notes), and the missing-tool_use error
path. Blank pdf_content raises before any client call.
Stacked on #1984 (PR 2/5). 4/5 pgvector RAG next.
* fix(ai): guard PDF size + surface bank-statement truncation
- PdfProcessor and BankStatementExtractor raise upfront when
pdf_content.bytesize exceeds MAX_PDF_BYTES (32 MB, matching
Anthropic's hard limit). Previously a 100 MB PDF would be
base64-encoded (~133 MB) and packed into the JSON body before
the API rejected it — peak heap ~270 MB per Sidekiq worker.
- BankStatementExtractor inspects response.stop_reason; when the
model hit max_tokens it logs a warning and flags result[:truncated]
so downstream callers know the transaction list may be incomplete.
- ISO date pattern added to statement_period_start/end schema in
PdfProcessor so the model can't return "March 2026" — Anthropic
enforces the regex via the tool's input_schema.
Tests cover the size guard (raises before any client.messages call),
truncated-result flagging, and the warning log path.
* test(ai): require "ostruct" in Anthropic PDF tests
Match the explicit ostruct require added in PR1/PR2 — same Ruby 3.5+
load-path reason.
* feat(ai): default Anthropic installs to pgvector RAG (4/5)
The provider-agnostic vector store stack (VectorStore::Pgvector + the
Embeddable concern) already shipped to main. This PR closes the
Anthropic loop:
- VectorStore::Registry.adapter_name now returns :pgvector when
Setting.llm_provider == "anthropic" and no explicit
VECTOR_STORE_PROVIDER override is set. Anthropic has no hosted vector
store, so falling back to the local pgvector adapter is the only
correct default. Explicit VECTOR_STORE_PROVIDER still wins.
- SearchFamilyFiles surfaces a longer message when no adapter is wired
up — calling out pgvector + EMBEDDING_URI_BASE as the supported
Anthropic-only path so the user is not stuck with an "OpenAI required"
hint that is no longer accurate.
The Embeddable concern already pulls embeddings from
EMBEDDING_URI_BASE / EMBEDDING_ACCESS_TOKEN (with OpenAI as fallback),
so Anthropic installs point this at Voyage AI, a local Ollama instance,
or OpenAI embeddings — independent of the chat provider.
Tests cover the new default routing, the existing OpenAI default
staying intact, and explicit VECTOR_STORE_PROVIDER overriding the
Anthropic default.
Stacked on #1985 (PR 3/5). 5/5 settings UI + retention disclosure next.
* fix(ai): provision pgvector table when it is the default store
#1986 makes pgvector the default vector store for Anthropic installs, but
CreateVectorStoreChunks only ran when VECTOR_STORE_PROVIDER=pgvector was set
explicitly — so a fresh Anthropic-only install migrated without the
vector_store_chunks table and failed on uploads/searches.
Add VectorStore::Registry.pgvector_effective? as the single source of truth
for "is pgvector active?" (explicit env OR the Anthropic default), and a new
idempotent migration that enables the extension + creates the table whenever
pgvector is effective and the table is missing — covering fresh and
already-migrated installs without drift. Addresses Codex P1.
* fix(ai): provision pgvector table for Anthropic-default installs
Migration gated on raw VECTOR_STORE_PROVIDER==pgvector, so an
Anthropic-default install (which selects pgvector implicitly via
Setting.llm_provider without setting VECTOR_STORE_PROVIDER) skipped
table creation and failed later on a missing vector_store_chunks
relation. Route through VectorStore::Registry.pgvector_effective? —
the single source of truth already shared by the adapter selection.
Addresses Codex P1 review finding.
* fix(ai): provision pgvector chunks table on schema-load installs
The ensure-migration only helps db:migrate upgraders. Fresh installs go
through bin/docker-entrypoint's db:prepare, which loads schema.rb (the
conditional table can't be dumped there — it needs the vector extension)
and marks every migration applied without running it. An Anthropic-only
fresh install therefore selected the pgvector adapter but had no table,
failing with raw PG errors on first upload or search.
Two layers close it:
- VectorStore::Pgvector#ensure_schema! provisions the table idempotently
on first use (mirrors CreateVectorStoreChunks; memoized; failures wrap
in VectorStore::Error, which with_response turns into a clean failed
response).
- VectorStore::Registry#build_pgvector now gates on
VectorStore::Pgvector.available? (table exists, or extension present),
so installs whose Postgres lacks pgvector entirely degrade to the
assistant's provider_not_configured message instead of raising
mid-chat.
Also resolves the schema.rb version conflict against main (keep the
branch's 2026_06_01_120000, on top of main's current tables).
* fix(ai): address review nitpicks on pgvector provisioning
- Registry: update the adapter doc comment to mention the
Anthropic-to-pgvector default alongside the openai fallback.
- ensure_schema!: guard the DDL with if_not_exists instead of a Mutex.
Adapter instances are built per call and never shared across threads,
so the realistic race is two processes (web + Sidekiq) provisioning
concurrently; IF NOT EXISTS makes the loser a no-op where a Mutex
would only serialize threads inside one process.
|
||
|
|
8a38a82c93 |
fix: Savings Goal Marked Reached While Still Short (#2180)
* fix: saving goal is marke while several states * feat(enable_banking): support MFA/decoupled banks and harden session handling (#2174) Decoupled/MFA banks (e.g. VR Bank in Holstein) were hard-blocked because the authorize flow aborted whenever auth_methods[0] was DECOUPLED. Enable Banking's hosted /auth page actually coordinates decoupled SCA and redirects back with a code, so route these banks through it instead: - Provider#start_authorization accepts and forwards an auth_method param - EnableBankingItem#select_auth_method picks the best method (REDIRECT > DECOUPLED > EMBEDDED), filtering by psu_type and skipping hidden methods - Shared begin_authorization! re-fetches ASPSP metadata on each authorize and reauthorize, so the method is always re-derived (no persistence required) - Remove the DECOUPLED block in the controller Also stop the integration from constantly reporting "session expired": - Only a session-level GET /sessions 401/404 flips the connection to requires_update; per-account 401/404 are retried and no longer kill the whole connection - Reconcile session_expires_at from the API's access.valid_until on every sync - Treat an expired session as a graceful requires_update state instead of raising a bare error No schema changes. Adds covering tests. * fix: request change reach --------- Co-authored-by: Tobias Rahloff <rahloff@gmail.com> |
||
|
|
1448128762 |
perf(reports): collapse investment flow aggregates (#2190)
* perf(reports): collapse investment flow aggregates * fix(reports): avoid shared-account flow duplication |
||
|
|
a0f5e56668 |
perf(transactions): preload new form options (#2189)
* perf(transactions): preload new form options * refactor(transactions): reuse new form account scope |
||
|
|
a461ff97bb |
fix(sync-toast): morph refresh, defer behind modals, DS conformance (#2105)
* fix(sync-toast): morph refresh, defer behind modals, DS conformance Follow-up to #1964 (addresses #2071). - Refresh via Turbo morph visit instead of window.location.reload, so scroll position and data-turbo-permanent elements (the AI chat panel) survive and there is no white flash. - Defer the toast while a <dialog> is open and reveal it on close. A refresh mid-modal closes the dialog and discards its in-progress input, which is the exact data loss this toast exists to prevent. Handles stacked modals. - Refresh CTA and close button now use DS::Button (secondary / icon). The close is always visible, inside the card, focusable, and has an aria-label; the old hover-only corner chip was unreachable on touch and not keyboard-focusable. - Add role="status" / aria-live="polite" to the toast. - Fix icon color: "inverse" is not a key in the icon helper color map, so it silently rendered no color class (dark icon on bg-info). Use "white", which maps to the functional text-inverse token. - Tighten copy: "New data available" / "Refresh". - Sync the broadcast comment with the actual replace/morph behavior. * fix(sync-toast): detach deferred dialog listener on disconnect A toast replaced by a newer broadcast_replace_to while a <dialog> was open kept its 'close' listener attached, so the detached controller fired #reveal()/#arm() when the dialog closed — a spurious auto-refresh from a stale toast (and repeated syncs could queue several). Store the dialog + handler refs and remove the listener in disconnect(). Flagged by codex + coderabbit on #2105. * fix(sync-toast): re-check interaction and dialogs at refresh-fire time The interaction check ran once at arm time but the refresh fired two seconds later. The post-dialog reveal made that window matter: the user closes a dialog sitting on a form, resumes typing, and the timer morphs the page — wiping non-turbo-permanent input, the exact data-loss class this toast exists to prevent. A dialog opened during the window had the mirror problem (the refresh would close it). Bail inside the callback instead, leaving the toast visible for a manual refresh, matching the mid-form behavior. Also documents the dialog-removed-without-close edge on the deferred listener. |
||
|
|
7a0665a8f6 |
fix(ds): one height rail for icon and text buttons (#2202)
* fix(ds): put icon buttons on the text-button height rail Icon-only DS::Button containers were 32/44/48px squares while text buttons of the same nominal size render ~28/36/48px tall, so every mixed header row (icon menu trigger next to text buttons — the transactions index, account pages, the app header) sat misaligned. - buttonish SIZES: icon containers now share the text rail (sm w-7, md w-9, lg w-12 unchanged). - DS::Popover's hand-rolled w-11 trigger joins the rail at w-9. - The layout's hand-rolled privacy toggle (mobile + desktop) matches the md icon-button chrome: w-9, rounded-lg, container-inset hover — it sat at w-8 with a different hover next to a DS icon button. - DS::Select's panel adopts shadow-border-lg, the elevation Menu and Popover already use, replacing the weaker shadow-lg+border-xs combo. Measured on the transactions header after the change: 36/38/36px. * fix(ds): keep the 44px touch target on coarse pointers The height rail trades icon-button size for row alignment, which is a pointer-precision tradeoff: WCAG 2.5.5's 44x44 minimum is about fingers, not mice. sm/md icon containers (and the two off-rail consumers: the popover trigger and the layout privacy toggles) gain pointer-coarse:w-11/h-11, so touch devices keep the full target while fine-pointer layouts get the aligned 36px row. Measured via Playwright: desktop 36x36, iPhone emulation 44x44 on both the menu trigger and the privacy toggle. |