Module: RubyLLM::Agents::ApplicationHelper

Includes:
Chartkick::Helper
Defined in:
app/helpers/ruby_llm/agents/application_helper.rb

Overview

View helpers for the RubyLLM::Agents dashboard

Provides formatting utilities for displaying execution data, including number formatting, URL helpers, and JSON syntax highlighting.

Constant Summary collapse

WIKI_BASE_URL =

Wiki base URL for documentation links

"https://github.com/adham90/ruby_llm-agents/wiki/"
DOC_PAGES =

Page to documentation mapping

{
  "dashboard/index" => "Dashboard",
  "agents/index" => "Agent-DSL",
  "agents/show" => "Agent-DSL",
  "executions/index" => "Execution-Tracking",
  "executions/show" => "Execution-Tracking",
  "tenants/index" => "Multi-Tenancy",
  "system_config/show" => "Configuration"
}.freeze

Instance Method Summary collapse

Instance Method Details

#all_tenants_urlString

Returns the URL for “All Tenants” (clears tenant filter)

Removes tenant_id from query params to show unfiltered results.

Returns:

  • (String)

    URL without tenant filtering



62
63
64
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 62

def all_tenants_url
  url_for(request.query_parameters.except("tenant_id"))
end

#comparison_badge(change_pct, metric_type) ⇒ ActiveSupport::SafeBuffer

Renders a comparison badge based on change percentage

Determines if a metric change is significant and returns an appropriate badge indicating improvement, regression, or stability.

Parameters:

  • change_pct (Float)

    Percentage change between versions

  • metric_type (Symbol)

    Type of metric (:success_rate, :cost, :tokens, :duration, :count)

Returns:

  • (ActiveSupport::SafeBuffer)

    HTML badge element



223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 223

def comparison_badge(change_pct, metric_type)
  threshold = case metric_type
  when :success_rate then 5
  when :cost, :tokens then 15
  when :duration then 20
  when :count then 25
  else 10
  end

  # Determine what "improvement" means for this metric
  # For cost/tokens/duration: negative change is good (lower is better)
  # For success_rate/count: positive change is good (higher is better)
  is_improvement = case metric_type
  when :success_rate, :count then change_pct > threshold
  when :cost, :tokens, :duration then change_pct < -threshold
  else false
  end

  is_regression = case metric_type
  when :success_rate, :count then change_pct < -threshold
  when :cost, :tokens, :duration then change_pct > threshold
  else false
  end

  if is_improvement
    (:span, class: "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-500/20 rounded-full") do
      safe_join([
        (:svg, class: "w-3 h-3", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
          (:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 10l7-7m0 0l7 7m-7-7v18")
        end,
        "Improved"
      ])
    end
  elsif is_regression
    (:span, class: "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-500/20 rounded-full") do
      safe_join([
        (:svg, class: "w-3 h-3", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
          (:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M19 14l-7 7m0 0l-7-7m7 7V3")
        end,
        "Regressed"
      ])
    end
  else
    (:span, class: "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-full") do
      safe_join([
        (:svg, class: "w-3 h-3", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
          (:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 12h14")
        end,
        "Stable"
      ])
    end
  end
end

#comparison_indicator(change_pct, metric_type: :count) ⇒ ActiveSupport::SafeBuffer, String

Compact comparison indicator with arrow for now strip metrics

Shows a colored arrow indicator showing percentage change vs previous period. For errors/cost/duration: decrease is good (green). For success/tokens: increase is good.

Parameters:

  • change_pct (Float, nil)

    Percentage change from previous period

  • metric_type (Symbol) (defaults to: :count)

    Type of metric (:success, :errors, :cost, :duration, :tokens)

Returns:

  • (ActiveSupport::SafeBuffer, String)

    HTML span with indicator or empty string



285
286
287
288
289
290
291
292
293
294
295
296
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 285

def comparison_indicator(change_pct, metric_type: :count)
  return "".html_safe if change_pct.nil?

  # For errors/cost/duration, decrease is good. For success/tokens, increase is good.
  positive_is_good = metric_type.in?(%i[success tokens count])
  is_improvement = positive_is_good ? change_pct > 0 : change_pct < 0

  arrow = (change_pct > 0) ? "\u2191" : "\u2193"
  color = is_improvement ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"

  (:span, "#{arrow}#{change_pct.abs}%", class: "text-xs font-medium #{color} ml-1")
end

#comparison_row_class(change_pct, metric_type) ⇒ String

Returns the appropriate row background class based on change significance

Parameters:

  • change_pct (Float)

    Percentage change

  • metric_type (Symbol)

    Type of metric

Returns:

  • (String)

    Tailwind CSS classes for row background



348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 348

def comparison_row_class(change_pct, metric_type)
  threshold = case metric_type
  when :success_rate then 5
  when :cost, :tokens then 15
  when :duration then 20
  when :count then 25
  else 10
  end

  is_improvement = case metric_type
  when :success_rate, :count then change_pct > threshold
  when :cost, :tokens, :duration then change_pct < -threshold
  else false
  end

  is_regression = case metric_type
  when :success_rate, :count then change_pct < -threshold
  when :cost, :tokens, :duration then change_pct > threshold
  else false
  end

  if is_improvement
    "bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800"
  elsif is_regression
    "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
  else
    "bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-700"
  end
end

#comparison_summary_badge(improvements_count, regressions_count, v2_label) ⇒ ActiveSupport::SafeBuffer

Generates an overall comparison summary based on multiple metrics

Parameters:

  • metrics (Array<Hash>)

    Array of metric comparison results

Returns:

  • (ActiveSupport::SafeBuffer)

    HTML summary banner



382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 382

def comparison_summary_badge(improvements_count, regressions_count, v2_label)
  if improvements_count >= 3 && regressions_count == 0
    (:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-500/20 rounded-lg") do
      safe_join([
        (:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
          (:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z")
        end,
        "v#{v2_label} shows overall improvement"
      ])
    end
  elsif regressions_count >= 3 && improvements_count == 0
    (:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-500/20 rounded-lg") do
      safe_join([
        (:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
          (:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z")
        end,
        "v#{v2_label} shows overall regression"
      ])
    end
  elsif improvements_count > 0 || regressions_count > 0
    (:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-amber-700 dark:text-amber-300 bg-amber-100 dark:bg-amber-500/20 rounded-lg") do
      safe_join([
        (:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
          (:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4")
        end,
        "v#{v2_label} shows mixed results"
      ])
    end
  else
    (:span, class: "inline-flex items-center gap-1 px-3 py-1 text-sm font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-lg") do
      safe_join([
        (:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
          (:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 12h14")
        end,
        "No significant changes"
      ])
    end
  end
end

#documentation_url(page_key = nil) ⇒ String?

Returns the documentation URL for the current page or a specific page key

Examples:

Get documentation URL for current page

documentation_url #=> "https://github.com/adham90/ruby_llm-agents/wiki/Agent-DSL"

Get documentation URL for specific page

documentation_url("dashboard/index") #=> "https://github.com/adham90/ruby_llm-agents/wiki/Dashboard"

Parameters:

  • page_key (String, nil) (defaults to: nil)

    Optional page key (e.g., “agents/index”)

Returns:

  • (String, nil)

    The documentation URL or nil if no mapping exists



36
37
38
39
40
41
42
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 36

def documentation_url(page_key = nil)
  key = page_key || "#{controller_name}/#{action_name}"
  doc_page = DOC_PAGES[key]
  return nil unless doc_page

  "#{WIKI_BASE_URL}#{doc_page}"
end

#format_duration_ms(ms) ⇒ String

Formats milliseconds to human-readable duration

Parameters:

  • ms (Numeric, nil)

    Duration in milliseconds

Returns:

  • (String)

    Human-readable duration (e.g., “150ms”, “2.5s”, “1.2m”)



331
332
333
334
335
336
337
338
339
340
341
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 331

def format_duration_ms(ms)
  return "0ms" if ms.nil? || ms.zero?

  if ms < 1000
    "#{ms.round}ms"
  elsif ms < 60_000
    "#{(ms / 1000.0).round(1)}s"
  else
    "#{(ms / 60_000.0).round(1)}m"
  end
end

#highlight_json(obj) ⇒ ActiveSupport::SafeBuffer

Syntax-highlights a Ruby object as pretty-printed JSON

Converts the object to JSON and applies color highlighting using Tailwind CSS classes.

Examples:

highlight_json({ name: "test", count: 42 })

Parameters:

  • obj (Object)

    Any JSON-serializable Ruby object

Returns:

  • (ActiveSupport::SafeBuffer)

    HTML-safe highlighted JSON string

See Also:



171
172
173
174
175
176
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 171

def highlight_json(obj)
  return "" if obj.nil?

  json_string = JSON.pretty_generate(obj)
  highlight_json_string(json_string)
end

#highlight_json_string(json_string) ⇒ ActiveSupport::SafeBuffer

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Syntax-highlights a JSON string with Tailwind CSS colors

Tokenizes the JSON and wraps each token type in a span with appropriate color classes:

  • Purple (text-purple-600): Object keys

  • Green (text-green-600): String values

  • Blue (text-blue-600): Numbers

  • Amber (text-amber-600): Booleans (true/false)

  • Gray (text-gray-400): null values

The tokenizer uses a character-by-character approach:

  1. Identifies token type by first character

  2. Parses complete token (handling escapes in strings)

  3. Determines if strings are keys (followed by colon)

  4. Wraps each token in appropriate span

Parameters:

  • json_string (String)

    A valid JSON string

Returns:

  • (ActiveSupport::SafeBuffer)

    HTML-safe highlighted output



441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 441

def highlight_json_string(json_string)
  return "" if json_string.blank?

  # Phase 1: Tokenization
  # Convert JSON string into array of typed tokens for later rendering
  tokens = []
  i = 0
  chars = json_string.chars

  while i < chars.length
    char = chars[i]

    case char
    when '"'
      # String token: starts with quote, ends with unescaped quote
      # Handles escape sequences like \" and \\
      str_start = i
      i += 1
      while i < chars.length
        if chars[i] == "\\"
          i += 2
        elsif chars[i] == '"'
          i += 1
          break
        else
          i += 1
        end
      end
      tokens << {type: :string, value: chars[str_start...i].join}
    when /[0-9-]/
      # Number token: starts with digit or minus, continues with digits/decimals/exponents
      num_start = i
      i += 1
      while i < chars.length && chars[i] =~ /[0-9.eE+-]/
        i += 1
      end
      tokens << {type: :number, value: chars[num_start...i].join}
    when "t"
      # Boolean token: check for "true" keyword
      if chars[i, 4].join == "true"
        tokens << {type: :boolean, value: "true"}
        i += 4
      else
        tokens << {type: :text, value: char}
        i += 1
      end
    when "f"
      # Boolean token: check for "false" keyword
      if chars[i, 5].join == "false"
        tokens << {type: :boolean, value: "false"}
        i += 5
      else
        tokens << {type: :text, value: char}
        i += 1
      end
    when "n"
      # Null token: check for "null" keyword
      if chars[i, 4].join == "null"
        tokens << {type: :null, value: "null"}
        i += 4
      else
        tokens << {type: :text, value: char}
        i += 1
      end
    when ":", ",", "{", "}", "[", "]", " ", "\n", "\t"
      # Punctuation token: structural characters and whitespace
      tokens << {type: :punct, value: char}
      i += 1
    else
      # Fallback for unexpected characters
      tokens << {type: :text, value: char}
      i += 1
    end
  end

  # Phase 2: Rendering
  # Convert tokens to HTML with color classes
  # Key detection: strings followed by colon are object keys (purple)
  # Value strings get different color (green)
  result = []
  tokens.each_with_index do |token, idx|
    case token[:type]
    when :string
      # Key detection algorithm:
      # Look ahead past any whitespace tokens to find next punctuation
      # If next non-whitespace punct is ':', this string is an object key
      is_key = false
      (idx + 1...tokens.length).each do |j|
        if tokens[j][:type] == :punct
          if tokens[j][:value] == ":"
            is_key = true
            break
          elsif !/\s/.match?(tokens[j][:value])
            # Non-whitespace punct that isn't colon - not a key
            break
          end
          # Skip whitespace and continue looking
        else
          break
        end
      end

      escaped_value = ERB::Util.html_escape(token[:value])
      result << if is_key
        %(<span class="text-purple-600 dark:text-purple-400">#{escaped_value}</span>)
      else
        %(<span class="text-green-600 dark:text-green-400">#{escaped_value}</span>)
      end
    when :number
      result << %(<span class="text-blue-600 dark:text-blue-400">#{token[:value]}</span>)
    when :boolean
      result << %(<span class="text-amber-600 dark:text-amber-400">#{token[:value]}</span>)
    when :null
      result << %(<span class="text-gray-400">#{token[:value]}</span>)
    else
      result << ERB::Util.html_escape(token[:value])
    end
  end

  result.join.html_safe
end

#number_to_human_short(number, prefix: nil, precision: 1) ⇒ String

Formats large numbers with human-readable suffixes (K, M, B)

Examples:

Basic usage

number_to_human_short(1234567) #=> "1.2M"

With currency prefix

number_to_human_short(1500, prefix: "$") #=> "$1.5K"

With custom precision

number_to_human_short(1234567, precision: 2) #=> "1.23M"

Small numbers

number_to_human_short(0.00123, precision: 1) #=> "0.0012"

Parameters:

  • number (Numeric, nil)

    The number to format

  • prefix (String, nil) (defaults to: nil)

    Optional prefix (e.g., “$” for currency)

  • precision (Integer) (defaults to: 1)

    Decimal places to show (default: 1)

Returns:

  • (String)

    Formatted number with suffix



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 80

def number_to_human_short(number, prefix: nil, precision: 1)
  return "#{prefix}0" if number.nil? || number.zero?

  abs_number = number.to_f.abs
  formatted = if abs_number >= 1_000_000_000
    "#{(number / 1_000_000_000.0).round(precision)}B"
  elsif abs_number >= 1_000_000
    "#{(number / 1_000_000.0).round(precision)}M"
  elsif abs_number >= 1_000
    "#{(number / 1_000.0).round(precision)}K"
  elsif abs_number < 1 && abs_number > 0
    number.round(precision + 3).to_s
  else
    number.round(precision).to_s
  end

  "#{prefix}#{formatted}"
end

#range_display_name(range) ⇒ String

Returns human-readable display name for time range

Examples:

range_display_name("7d") #=> "7 Days"

Parameters:

  • range (String)

    Range parameter (today, 7d, 30d, 90d, custom)

Returns:

  • (String)

    Human-readable range name



304
305
306
307
308
309
310
311
312
313
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 304

def range_display_name(range)
  case range
  when "today" then "Today"
  when "7d" then "7 Days"
  when "30d" then "30 Days"
  when "90d" then "90 Days"
  when "custom" then "Custom"
  else "Today"
  end
end

#range_presetsArray<Hash>

Returns preset range options for the time range dropdown

Returns:

  • (Array<Hash>)

    Array of label: pairs



318
319
320
321
322
323
324
325
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 318

def range_presets
  [
    {value: "today", label: "Today"},
    {value: "7d", label: "7 Days"},
    {value: "30d", label: "30 Days"},
    {value: "90d", label: "90 Days"}
  ]
end

#render_configured_badge(configured) ⇒ ActiveSupport::SafeBuffer

Renders a configured/not configured badge

Parameters:

  • configured (Boolean)

    Whether the setting is configured

Returns:

  • (ActiveSupport::SafeBuffer)

    HTML badge element



115
116
117
118
119
120
121
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 115

def render_configured_badge(configured)
  if configured
    '<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">Configured</span>'.html_safe
  else
    '<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Not configured</span>'.html_safe
  end
end

#render_enabled_badge(enabled) ⇒ ActiveSupport::SafeBuffer

Renders an enabled/disabled badge

Parameters:

  • enabled (Boolean)

    Whether the feature is enabled

Returns:

  • (ActiveSupport::SafeBuffer)

    HTML badge element



103
104
105
106
107
108
109
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 103

def render_enabled_badge(enabled)
  if enabled
    '<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">Enabled</span>'.html_safe
  else
    '<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Disabled</span>'.html_safe
  end
end

#render_sparkline(trend_data, metric_key, color_class: "text-blue-500") ⇒ ActiveSupport::SafeBuffer

Renders an SVG sparkline chart from trend data

Creates a simple polyline SVG for inline trend visualization. Used in version comparison to show historical performance.

Parameters:

  • trend_data (Array<Hash>)

    Array of daily data points

  • metric_key (Symbol)

    The metric to chart (:count, :success_rate, :avg_cost, etc.)

  • color_class (String) (defaults to: "text-blue-500")

    Tailwind color class for the line

Returns:

  • (ActiveSupport::SafeBuffer)

    SVG sparkline element



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 187

def render_sparkline(trend_data, metric_key, color_class: "text-blue-500")
  return "".html_safe if trend_data.blank? || trend_data.length < 2

  values = trend_data.map { |d| d[metric_key].to_f }
  max_val = values.max || 1
  min_val = values.min || 0
  range = max_val - min_val
  range = 1 if range.zero?

  # Generate SVG polyline points
  points = values.each_with_index.map do |val, i|
    x = (i.to_f / (values.length - 1)) * 100
    y = 28 - ((val - min_val) / range * 24) + 2 # 2px padding top/bottom
    "#{x.round(2)},#{y.round(2)}"
  end.join(" ")

  (:svg, class: "w-full h-8", viewBox: "0 0 100 30", preserveAspectRatio: "none") do
    (:polyline, nil,
      points: points,
      fill: "none",
      stroke: "currentColor",
      "stroke-width": "2",
      "stroke-linecap": "round",
      "stroke-linejoin": "round",
      class: color_class)
  end
end

#ruby_llm_agentsModule

Returns the URL helpers for the engine’s routes

Use this to generate paths and URLs within the dashboard views.

Examples:

Generate execution path

ruby_llm_agents.execution_path(execution)

Generate agents index URL

ruby_llm_agents.agents_url

Returns:

  • (Module)

    URL helpers module with path/url methods



53
54
55
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 53

def ruby_llm_agents
  RubyLLM::Agents::Engine.routes.url_helpers
end

Renders a sortable column header link with arrow indicator

Replaces inline ‘raw()` calls in views with safe content_tag usage.

Parameters:

  • column (String)

    The sort column name

  • label (String)

    Display label for the header

  • current_column (String)

    Currently active sort column

  • current_direction (String)

    Current sort direction (“asc”/“desc”)

  • extra_class (String) (defaults to: "")

    Additional CSS classes

Returns:

  • (ActiveSupport::SafeBuffer)

    HTML link element



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'app/helpers/ruby_llm/agents/application_helper.rb', line 133

def sort_header_link(column, label, current_column:, current_direction:, extra_class: "")
  is_active = column == current_column
  next_dir = (is_active && current_direction == "asc") ? "desc" : "asc"
  url = url_for(request.query_parameters.merge(sort: column, direction: next_dir, page: 1))

  arrow = if is_active && current_direction == "asc"
    (:svg, class: "w-2.5 h-2.5 inline", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
      (:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 15l7-7 7 7")
    end
  elsif is_active
    (:svg, class: "w-2.5 h-2.5 inline", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
      (:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M19 9l-7 7-7-7")
    end
  else
    "".html_safe
  end

  active_class = is_active ? "text-gray-700 dark:text-gray-300" : ""
  opacity_class = is_active ? "opacity-100" : "opacity-0 group-hover:opacity-50"

  link_to url, class: "group inline-flex items-center gap-0.5 hover:text-gray-700 dark:hover:text-gray-300 #{active_class} #{extra_class}" do
    safe_join([
      (:span, label),
      (:span, arrow, class: "#{opacity_class} transition-opacity")
    ])
  end
end