cardinal-ai 0.0.1 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +50 -29
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/application.css +10 -0
  6. data/app/assets/stylesheets/cardinal.css +530 -0
  7. data/app/controllers/application_controller.rb +7 -0
  8. data/app/controllers/boards_controller.rb +5 -0
  9. data/app/controllers/cards_controller.rb +129 -0
  10. data/app/controllers/columns_controller.rb +130 -0
  11. data/app/controllers/messages_controller.rb +25 -0
  12. data/app/controllers/runs_controller.rb +58 -0
  13. data/app/helpers/application_helper.rb +35 -0
  14. data/app/javascript/application.js +2 -0
  15. data/app/javascript/controllers/application.js +7 -0
  16. data/app/javascript/controllers/autosave_controller.js +43 -0
  17. data/app/javascript/controllers/board_column_controller.js +96 -0
  18. data/app/javascript/controllers/clipboard_controller.js +18 -0
  19. data/app/javascript/controllers/composer_controller.js +10 -0
  20. data/app/javascript/controllers/index.js +3 -0
  21. data/app/javascript/controllers/modal_controller.js +45 -0
  22. data/app/javascript/controllers/reveal_controller.js +15 -0
  23. data/app/javascript/controllers/scroll_controller.js +44 -0
  24. data/app/javascript/controllers/tags_controller.js +49 -0
  25. data/app/javascript/controllers/theme_controller.js +43 -0
  26. data/app/javascript/controllers/tooltip_controller.js +37 -0
  27. data/app/jobs/ai_task_job.rb +26 -0
  28. data/app/jobs/application_job.rb +7 -0
  29. data/app/jobs/assistant_reply_job.rb +132 -0
  30. data/app/jobs/mark_pr_ready_job.rb +18 -0
  31. data/app/jobs/merge_pr_job.rb +27 -0
  32. data/app/jobs/resume_run_job.rb +30 -0
  33. data/app/jobs/start_run_job.rb +13 -0
  34. data/app/mailers/application_mailer.rb +4 -0
  35. data/app/models/agent_session.rb +8 -0
  36. data/app/models/application_record.rb +3 -0
  37. data/app/models/artifact.rb +8 -0
  38. data/app/models/board.rb +92 -0
  39. data/app/models/card.rb +83 -0
  40. data/app/models/column.rb +134 -0
  41. data/app/models/event.rb +44 -0
  42. data/app/models/run.rb +28 -0
  43. data/app/services/agent/runner.rb +379 -0
  44. data/app/services/agent/workspace.rb +138 -0
  45. data/app/services/card_transition.rb +97 -0
  46. data/app/services/claude_cli.rb +89 -0
  47. data/app/services/rules/compiler.rb +55 -0
  48. data/app/services/rules.rb +92 -0
  49. data/app/services/run_sweeper.rb +53 -0
  50. data/app/views/boards/show.html.erb +79 -0
  51. data/app/views/cards/_card.html.erb +48 -0
  52. data/app/views/cards/_detail.html.erb +190 -0
  53. data/app/views/cards/_tag_picker.html.erb +12 -0
  54. data/app/views/cards/new.html.erb +35 -0
  55. data/app/views/cards/show.html.erb +3 -0
  56. data/app/views/columns/_column.html.erb +25 -0
  57. data/app/views/columns/edit.html.erb +146 -0
  58. data/app/views/events/_event.html.erb +29 -0
  59. data/app/views/layouts/application.html.erb +46 -0
  60. data/app/views/layouts/mailer.html.erb +13 -0
  61. data/app/views/layouts/mailer.text.erb +1 -0
  62. data/app/views/pwa/manifest.json.erb +22 -0
  63. data/app/views/pwa/service-worker.js +26 -0
  64. data/bin/rails +4 -0
  65. data/bin/rake +4 -0
  66. data/cardinal.md +695 -0
  67. data/config/application.rb +60 -0
  68. data/config/boot.rb +13 -0
  69. data/config/bundler-audit.yml +5 -0
  70. data/config/cable.yml +13 -0
  71. data/config/ci.rb +20 -0
  72. data/config/credentials.yml.enc +1 -0
  73. data/config/database.yml +31 -0
  74. data/config/environment.rb +5 -0
  75. data/config/environments/development.rb +78 -0
  76. data/config/environments/production.rb +89 -0
  77. data/config/environments/test.rb +53 -0
  78. data/config/importmap.rb +6 -0
  79. data/config/initializers/assets.rb +7 -0
  80. data/config/initializers/cardinal_bootstrap.rb +12 -0
  81. data/config/initializers/cardinal_instance.rb +20 -0
  82. data/config/initializers/content_security_policy.rb +29 -0
  83. data/config/initializers/filter_parameter_logging.rb +8 -0
  84. data/config/initializers/inflections.rb +16 -0
  85. data/config/initializers/run_sweeper.rb +17 -0
  86. data/config/locales/en.yml +31 -0
  87. data/config/puma.rb +42 -0
  88. data/config/routes.rb +22 -0
  89. data/config/storage.yml +27 -0
  90. data/config.ru +6 -0
  91. data/db/migrate/20260703000001_create_cardinal_schema.rb +78 -0
  92. data/db/migrate/20260703000002_add_agent_runner_fields.rb +7 -0
  93. data/db/migrate/20260704000001_add_parent_to_cards.rb +5 -0
  94. data/db/migrate/20260704000002_add_assistant_session_to_cards.rb +5 -0
  95. data/db/seeds.rb +13 -0
  96. data/docker/agent/Dockerfile +16 -0
  97. data/exe/cardinal +111 -0
  98. data/lib/cardinal/version.rb +1 -1
  99. data/public/400.html +135 -0
  100. data/public/404.html +135 -0
  101. data/public/406-unsupported-browser.html +135 -0
  102. data/public/422.html +135 -0
  103. data/public/500.html +135 -0
  104. data/public/icon.png +0 -0
  105. data/public/icon.svg +3 -0
  106. data/public/robots.txt +1 -0
  107. data/vendor/javascript/sortablejs.js +3378 -0
  108. metadata +236 -9
@@ -0,0 +1,7 @@
1
+ class ApplicationController < ActionController::Base
2
+ # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
3
+ allow_browser versions: :modern
4
+
5
+ # Changes to the importmap will invalidate the etag for HTML responses
6
+ stale_when_importmap_changes
7
+ end
@@ -0,0 +1,5 @@
1
+ class BoardsController < ApplicationController
2
+ def show
3
+ @board = Board.includes(columns: :cards).first!
4
+ end
5
+ end
@@ -0,0 +1,129 @@
1
+ class CardsController < ApplicationController
2
+ before_action :set_card, only: [:show, :update, :move, :approve, :destroy]
3
+
4
+ def new
5
+ @board = Board.first!
6
+ @parent = @board.cards.find_by(id: params[:parent_id])
7
+ end
8
+
9
+ def create
10
+ board = Board.first!
11
+ column = board.columns.inbox.order(:position).first || board.columns.first
12
+ parent = board.cards.find_by(id: params.dig(:card, :parent_id))
13
+ card = board.cards.create!(column:, parent:, **card_params)
14
+ card.log!("status_change", actor: "user", text: parent ? "Card created as a child of ##{parent.number} #{parent.title}" : "Card created")
15
+ parent&.log!("status_change", actor: "user", text: "Child card added: #{card.title}")
16
+ redirect_to root_path
17
+ end
18
+
19
+ # Rarely needed, deliberately buried in the card modal. A working card must
20
+ # be cancelled first — no killing live agents by deleting their card.
21
+ def destroy
22
+ if @card.working?
23
+ redirect_to card_path(@card)
24
+ return
25
+ end
26
+ workspace_path = Agent::Workspace::Local.new(@card).path
27
+ @card.destroy!
28
+ FileUtils.rm_rf(workspace_path)
29
+ redirect_to root_path
30
+ end
31
+
32
+ def show
33
+ @zoom = params[:zoom].presence_in(%w[conversation activity debug]) || "conversation"
34
+ @events = case @zoom
35
+ when "conversation" then @card.events.conversation
36
+ when "activity" then @card.events.activity
37
+ else @card.events
38
+ end
39
+
40
+ # A frame navigation (opening the modal from the board) only needs the modal
41
+ # frame. A direct visit — bookmark, reload, or history restore of /cards/:id —
42
+ # must render the whole board with the modal already open behind it.
43
+ unless turbo_frame_request?
44
+ @board = @card.board
45
+ render "boards/show"
46
+ end
47
+ end
48
+
49
+ def update
50
+ attrs = card_params
51
+ attrs.delete(:title) if params[:autosave] && attrs[:title].blank? # mid-edit blank, not a delete
52
+ # branch_name and pr_url lock once set (by the user or the agent) — never
53
+ # let a later edit clobber a value that work may already depend on.
54
+ attrs.delete(:branch_name) if @card.branch_name.present?
55
+ attrs.delete(:pr_url) if @card.pr_url.present?
56
+ @card.update!(attrs)
57
+ log_changelog!
58
+
59
+ respond_to do |format|
60
+ # Explicitly patch the board face in this tab too — Turbo suppresses a
61
+ # tab's own refresh broadcasts, so the morph won't cover the originator.
62
+ # Autosave must NOT replace the modal (it would steal focus mid-typing).
63
+ format.turbo_stream do
64
+ streams = [turbo_stream.replace(helpers.dom_id(@card), partial: "cards/card", locals: { card: @card })]
65
+ unless params[:autosave]
66
+ @zoom = "conversation"
67
+ @events = @card.events.conversation
68
+ streams << turbo_stream.replace("modal", template: "cards/show", formats: [:html])
69
+ end
70
+ render turbo_stream: streams
71
+ end
72
+ format.html { redirect_to card_path(@card) }
73
+ end
74
+ end
75
+
76
+ # Review verdicts (§3, §14.2). Approve is reversible — the merge happens as
77
+ # Done's entry rule when the human drags the card there.
78
+ def approve
79
+ if @card.in_review?
80
+ @card.update!(status: "approved")
81
+ @card.log!("status_change", actor: "user", text: "Work approved — drag to Done to ship")
82
+ end
83
+ redirect_to card_path(@card)
84
+ end
85
+
86
+ def move
87
+ from_column = @card.column
88
+ to_column = @card.board.columns.find(params[:column_id])
89
+ result = CardTransition.new(@card, to_column: to_column, position: params[:position]&.to_i).call
90
+ if result.success?
91
+ # Fresh markup for the affected columns: Turbo suppresses this tab's own
92
+ # refresh broadcasts, so without this the dragged card keeps its stale
93
+ # face (no queued ghosting, no ticker bump) until a job-thread broadcast.
94
+ render turbo_stream: [from_column, to_column].uniq.map { |col|
95
+ turbo_stream.replace(helpers.dom_id(col), partial: "columns/column", locals: { column: col.reload })
96
+ }
97
+ else
98
+ render json: { error: result.error }, status: :unprocessable_entity
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def set_card
105
+ @card = Board.first!.cards.find_by!(number: params[:id])
106
+ end
107
+
108
+ def card_params
109
+ attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url)
110
+ attrs[:tags] = attrs[:tags].to_s.split(",").map(&:strip).reject(&:blank?) if attrs.key?(:tags)
111
+ attrs.to_h.symbolize_keys
112
+ end
113
+
114
+ # Changelog in the activity timeline (the mechanism already exists). A burst
115
+ # of autosaves coalesces into one entry instead of one per pause-in-typing.
116
+ def log_changelog!
117
+ changed = @card.previous_changes.keys & %w[title description tags branch_name pr_url]
118
+ return if changed.empty?
119
+
120
+ last = @card.events.order(:id).last
121
+ if last&.kind == "status_change" && last.payload["changelog"] && last.created_at > 10.minutes.ago
122
+ fields = (Array(last.payload["fields"]) | changed)
123
+ last.update!(payload: last.payload.merge("fields" => fields, "text" => "Details edited: #{fields.join(", ")}"))
124
+ else
125
+ @card.log!("status_change", actor: "user", changelog: true, fields: changed,
126
+ text: "Details edited: #{changed.join(", ")}")
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,130 @@
1
+ class ColumnsController < ApplicationController
2
+ before_action :set_column, only: [:edit, :update, :destroy]
3
+
4
+ def create
5
+ board = Board.first!
6
+ attrs = params.require(:column).permit(:name, :archetype)
7
+ # Inbox is the board's single intake and can't be created a second time
8
+ # (card #17) — refuse it even from a crafted request, and default any
9
+ # blank/invalid archetype to a non-special stage rather than inbox.
10
+ archetype = (attrs[:archetype].presence_in(Column::ARCHETYPES - %w[inbox])) || "planning"
11
+ board.columns.create!(
12
+ name: attrs[:name],
13
+ archetype: archetype,
14
+ position: (board.columns.maximum(:position) || -1) + 1,
15
+ policy: {}
16
+ )
17
+ redirect_to root_path
18
+ end
19
+
20
+ def edit
21
+ end
22
+
23
+ # The gear modal is the entire policy admin surface (§1, §14.3).
24
+ def update
25
+ attrs = params.require(:column).permit(
26
+ :name, :archetype, :instructions, :model, :effort,
27
+ :concurrency_limit, :max_turns, :timeout_minutes, :plan_approval,
28
+ :on_entry_text, :on_entry_json, :color, :custom_color, :arrivals, :ai,
29
+ accepts_from: []
30
+ )
31
+
32
+ policy = @column.policy.dup
33
+ %w[instructions model effort].each { |k| policy[k] = attrs[k].presence }
34
+ policy["color"] = attrs[:custom_color] == "1" && attrs[:color].to_s.match?(/\A#\h{6}\z/) ? attrs[:color] : nil
35
+ %w[concurrency_limit max_turns timeout_minutes].each do |k|
36
+ policy[k] = attrs[k].present? ? attrs[k].to_i : nil
37
+ end
38
+ policy["plan_approval"] = attrs[:plan_approval] == "1"
39
+ policy["arrivals"] = attrs[:arrivals].presence_in(%w[top bottom])
40
+ policy["ai"] = (attrs[:ai] == "1") if attrs.key?(:ai) # inbox forms omit it — never AI anyway
41
+ # Accept policy (card #15): store allowed source column ids as strings.
42
+ # EXPLICIT ONLY — an empty list means the column accepts from nowhere.
43
+ policy["accepts_from"] = attrs[:accepts_from].to_a.map(&:to_s).reject(&:blank?).presence
44
+
45
+ # Archetype is a TEMPLATE: switching it re-stamps rules + instructions
46
+ # from the new archetype (the submitted fields belong to the old one).
47
+ new_archetype = @column.inbox? ? "inbox" : (attrs[:archetype].presence_in(Column::ARCHETYPES - %w[inbox]) || @column.archetype)
48
+ @archetype_changed = new_archetype != @column.archetype
49
+ if @archetype_changed
50
+ template = Column::ARCHETYPE_TEMPLATES.fetch(new_archetype, {})
51
+ %w[on_entry on_entry_text instructions].each { |k| policy[k] = template[k] }
52
+ end
53
+
54
+ # Rules: plain English is the source of truth (compiled on change); the
55
+ # advanced JSON editor applies only when the English box is empty.
56
+ if !@archetype_changed && attrs[:on_entry_text].present?
57
+ if attrs[:on_entry_text].strip != policy["on_entry_text"].to_s.strip
58
+ begin
59
+ policy["on_entry"] = Rules::Compiler.compile(attrs[:on_entry_text])
60
+ rescue Rules::Compiler::Error => e
61
+ return column_error(e.message)
62
+ end
63
+ end
64
+ policy["on_entry_text"] = attrs[:on_entry_text].strip
65
+ elsif !@archetype_changed && attrs[:on_entry_json].present?
66
+ begin
67
+ policy["on_entry"] = JSON.parse(attrs[:on_entry_json])
68
+ policy.delete("on_entry_text")
69
+ rescue JSON::ParserError => e
70
+ return column_error("on_entry is not valid JSON: #{e.message.truncate(120)}")
71
+ end
72
+ elsif !@archetype_changed
73
+ policy.delete("on_entry")
74
+ policy.delete("on_entry_text")
75
+ end
76
+
77
+ @column.update!(
78
+ name: attrs[:name].presence || @column.name,
79
+ archetype: new_archetype,
80
+ policy: policy.compact
81
+ )
82
+
83
+ if params[:autosave]
84
+ # Silent save: patch the board's column section + clear any prior error.
85
+ # No modal replace — it would steal focus mid-edit.
86
+ streams = [
87
+ turbo_stream.replace(helpers.dom_id(@column), partial: "columns/column", locals: { column: @column.reload }),
88
+ turbo_stream.update("column-form-errors", "")
89
+ ]
90
+ # A re-stamped archetype must re-render the modal (its fields changed
91
+ # server-side); focus loss is fine — the user just picked from a select.
92
+ streams << turbo_stream.replace("modal", template: "columns/edit", formats: [:html]) if @archetype_changed
93
+ render turbo_stream: streams
94
+ else
95
+ redirect_to root_path
96
+ end
97
+ end
98
+
99
+ # Autosave-friendly error: surface in the modal without re-rendering the form.
100
+ def column_error(message)
101
+ if params[:autosave]
102
+ render turbo_stream: turbo_stream.update(
103
+ "column-form-errors",
104
+ helpers.tag.p("#{message} — this field was NOT saved.", class: "form-error")
105
+ ), status: :unprocessable_entity
106
+ else
107
+ @json_error = message
108
+ render :edit, status: :unprocessable_entity
109
+ end
110
+ end
111
+
112
+ def destroy
113
+ if @column.inbox?
114
+ # The Tasks/inbox column is the board's intake — cards enter the flow here
115
+ # to be triaged. It can never be deleted (card #17).
116
+ @json_error = "The Tasks column is the board's intake and can't be deleted."
117
+ render :edit, status: :unprocessable_entity
118
+ elsif @column.cards.none?
119
+ @column.destroy!
120
+ redirect_to root_path
121
+ else
122
+ @json_error = "Column still has cards — move them first."
123
+ render :edit, status: :unprocessable_entity
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ def set_column = @column = Column.find(params[:id])
130
+ end
@@ -0,0 +1,25 @@
1
+ class MessagesController < ApplicationController
2
+ def create
3
+ card = Board.first!.cards.find_by!(number: params[:card_id])
4
+ text = params.require(:message)[:text]
5
+ parked_run = card.runs.where(status: "needs_input").order(:id).last
6
+
7
+ if parked_run
8
+ # Answer / plan feedback: goes back into the same agent session.
9
+ kind = parked_run.phase == "plan" ? "user_message" : "answer"
10
+ card.log!(kind, actor: "user", run: parked_run, text: text)
11
+ ResumeRunJob.perform_later(parked_run.id, text)
12
+ elsif card.column.review? && %w[in_review approved].include?(card.status)
13
+ # Review is entirely human: feedback IS the conversation. A message on a
14
+ # card under review marks it changes_requested; dragging it back to a
15
+ # work column carries the feedback into the next run's briefing.
16
+ card.log!("user_message", actor: "user", text: text)
17
+ card.update!(status: "changes_requested")
18
+ card.log!("status_change", actor: "user", text: "Changes requested — drag the card back to a work column when ready")
19
+ else
20
+ card.log!("user_message", actor: "user", text: text)
21
+ AssistantReplyJob.perform_later(card) if card.column.planning? && card.column.ai?
22
+ end
23
+ redirect_to card_path(card)
24
+ end
25
+ end
@@ -0,0 +1,58 @@
1
+ class RunsController < ApplicationController
2
+ before_action :set_run
3
+
4
+ # Kill switch (cardinal.md §9): TERM the agent subprocess; sweeper and the
5
+ # runner's failure path keep the records honest.
6
+ def cancel
7
+ if run.running? && (pid = run.agent_session.config["pid"])
8
+ Process.kill("TERM", pid) rescue Errno::ESRCH
9
+ end
10
+ if run.running? || run.needs_input?
11
+ run.update!(status: "cancelled", finished_at: Time.current)
12
+ card.update!(status: "failed")
13
+ card.log!("status_change", actor: "user", run: run, text: "Run cancelled by user")
14
+ end
15
+ redirect_to card_path(card)
16
+ end
17
+
18
+ # Plan-approval gate (§4): one click sends the agent from plan to execute.
19
+ def approve
20
+ if run.needs_input? && run.phase == "plan"
21
+ card.log!("plan_approved", actor: "user", run: run, text: "Plan approved")
22
+ ResumeRunJob.perform_later(run.id, "", approve: true)
23
+ end
24
+ redirect_to card_path(card)
25
+ end
26
+
27
+ # Restart a run that parked or failed on its turn budget / timeout. Mirrors
28
+ # Column#kick_queue's branch: resume the saved session for a fresh budget, or
29
+ # (no session left) re-queue for a clean run.
30
+ def restart
31
+ if run.restartable?
32
+ if run.needs_input?
33
+ card.log!("progress", actor: "user", run: run, text: "Restarting run — resuming with a fresh turn budget")
34
+ ResumeRunJob.perform_later(run.id, "")
35
+ elsif run.external_session_id.present?
36
+ # Failed but the session survived: flip back to needs_input so
37
+ # ResumeRunJob's guard passes, then resume it.
38
+ run.update!(status: "needs_input", finished_at: nil)
39
+ card.update!(status: "needs_input")
40
+ card.log!("progress", actor: "user", run: run, text: "Restarting failed run — resuming the saved session with a fresh turn budget")
41
+ ResumeRunJob.perform_later(run.id, "")
42
+ else
43
+ # No session to resume: a clean run from the queue.
44
+ card.update!(status: "queued")
45
+ card.log!("progress", actor: "user", run: run, text: "Restarting failed run — starting a fresh run")
46
+ StartRunJob.perform_later(card.id)
47
+ end
48
+ end
49
+ redirect_to card_path(card)
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :run
55
+
56
+ def set_run = @run = Run.find(params[:id])
57
+ def card = run.card
58
+ end
@@ -0,0 +1,35 @@
1
+ module ApplicationHelper
2
+ # Curated model choices for column policies. A custom value already saved on
3
+ # the column stays selectable.
4
+ def model_options(current)
5
+ options = [
6
+ ["(inherit default)", ""],
7
+ ["Haiku 4.5 — fastest, cheapest", "claude-haiku-4-5-20251001"],
8
+ ["Sonnet 4.6 — balanced, recommended for workers", "claude-sonnet-4-6"],
9
+ ["Opus 4.8 — most capable", "claude-opus-4-8"],
10
+ ["Fable 5 — frontier, expensive", "claude-fable-5"]
11
+ ]
12
+ options << [current, current] if current.present? && options.none? { |_, v| v == current }
13
+ options
14
+ end
15
+
16
+ # Timeline text is Markdown (agents write it constantly). escape_html turns
17
+ # any raw HTML in the text into visible text instead of live DOM — an agent
18
+ # pasting `<div class=...>` inside a code fence must never restyle the page.
19
+ MARKDOWN = Redcarpet::Markdown.new(
20
+ Redcarpet::Render::HTML.new(escape_html: true, hard_wrap: true, safe_links_only: true),
21
+ fenced_code_blocks: true, tables: true, autolink: true,
22
+ strikethrough: true, no_intra_emphasis: true, lax_spacing: true
23
+ )
24
+
25
+ def render_markdown(text)
26
+ MARKDOWN.render(text.to_s).html_safe
27
+ end
28
+
29
+ def info_tip(text)
30
+ tag.span("i", class: "info",
31
+ data: { controller: "tooltip", tooltip_text_value: text,
32
+ action: "mouseenter->tooltip#show mouseleave->tooltip#hide focus->tooltip#show blur->tooltip#hide" },
33
+ tabindex: 0)
34
+ end
35
+ end
@@ -0,0 +1,2 @@
1
+ import "@hotwired/turbo-rails"
2
+ import "controllers"
@@ -0,0 +1,7 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+ application.debug = false
5
+ window.Stimulus = application
6
+
7
+ export { application }
@@ -0,0 +1,43 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Silent autosave for the card details form: debounce edits, submit, whisper.
4
+ export default class extends Controller {
5
+ static targets = ["status", "form"]
6
+
7
+ connect() {
8
+ this.onEnd = (event) => {
9
+ if (!this.hasStatusTarget) return
10
+ this.statusTarget.textContent = event.detail?.success === false ? "✗ not saved" : "Saved ✓"
11
+ clearTimeout(this.fade)
12
+ this.fade = setTimeout(() => (this.statusTarget.textContent = ""), 1500)
13
+ }
14
+ this.element.addEventListener("turbo:submit-end", this.onEnd)
15
+
16
+ // A closing modal must not eat a pending debounce — flush immediately.
17
+ this.onModalClose = () => this.flush()
18
+ document.addEventListener("cardinal:modal-closing", this.onModalClose)
19
+ }
20
+
21
+ disconnect() {
22
+ clearTimeout(this.timer)
23
+ clearTimeout(this.fade)
24
+ this.element.removeEventListener("turbo:submit-end", this.onEnd)
25
+ document.removeEventListener("cardinal:modal-closing", this.onModalClose)
26
+ }
27
+
28
+ flush() {
29
+ if (!this.timer) return
30
+ clearTimeout(this.timer)
31
+ this.timer = null
32
+ this.formTarget.requestSubmit()
33
+ }
34
+
35
+ save() {
36
+ if (this.hasStatusTarget) this.statusTarget.textContent = "…"
37
+ clearTimeout(this.timer)
38
+ this.timer = setTimeout(() => {
39
+ this.timer = null
40
+ this.formTarget.requestSubmit()
41
+ }, 800)
42
+ }
43
+ }
@@ -0,0 +1,96 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { Turbo } from "@hotwired/turbo-rails"
3
+ import Sortable from "sortablejs"
4
+
5
+ // Drag-and-drop for a column's card list. Moving a card PATCHes /cards/:id/move;
6
+ // the server is the authority — a 422 snaps the card back and shows why.
7
+ export default class extends Controller {
8
+ static values = { hint: String, newUrl: String }
9
+
10
+ // A bare click on the column background — not on a card — opens the New Card
11
+ // composer, mirroring the header's [+]. Cards keep their own click (open
12
+ // detail); we only act when the click lands on the container itself.
13
+ newCard(event) {
14
+ if (event.target !== this.element || !this.hasNewUrlValue) return
15
+ document.getElementById("modal").src = this.newUrlValue
16
+ }
17
+
18
+ connect() {
19
+ this.sortable = Sortable.create(this.element, {
20
+ group: "cards",
21
+ animation: 150,
22
+ ghostClass: "card-ghost",
23
+ onStart: () => {
24
+ document.body.classList.add("dragging")
25
+ this.markBlockedColumns()
26
+ },
27
+ onEnd: (event) => {
28
+ document.body.classList.remove("dragging")
29
+ this.clearBlockedColumns()
30
+ this.move(event)
31
+ }
32
+ })
33
+ }
34
+
35
+ // Accept policies made visible: while dragging, columns that won't take a
36
+ // card from THIS column gray out with a blocked hint. Server still rules.
37
+ markBlockedColumns() {
38
+ const sourceId = this.element.dataset.columnId
39
+ document.querySelectorAll(".column").forEach(section => {
40
+ if (section.dataset.colId === sourceId) return // reorder within is always fine
41
+ const allowed = (section.dataset.accepts || "").split(",").filter(Boolean)
42
+ if (!allowed.includes(sourceId)) section.classList.add("drop-blocked")
43
+ })
44
+ }
45
+
46
+ clearBlockedColumns() {
47
+ document.querySelectorAll(".column.drop-blocked").forEach(s => s.classList.remove("drop-blocked"))
48
+ }
49
+
50
+ disconnect() {
51
+ this.sortable?.destroy()
52
+ }
53
+
54
+ async move(event) {
55
+ const cardId = event.item.dataset.cardId
56
+ const columnId = event.to.dataset.columnId
57
+ if (event.to === event.from && event.newIndex === event.oldIndex) return
58
+
59
+ const response = await fetch(`/cards/${cardId}/move`, {
60
+ method: "PATCH",
61
+ headers: {
62
+ "Content-Type": "application/json",
63
+ "Accept": "text/vnd.turbo-stream.html, application/json",
64
+ "X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content
65
+ },
66
+ body: JSON.stringify({ column_id: columnId, position: event.newIndex })
67
+ })
68
+
69
+ if (response.ok) {
70
+ // Server-rendered truth for both columns: instant queued/working
71
+ // styling, ticker counts, queue positions.
72
+ Turbo.renderStreamMessage(await response.text())
73
+ } else {
74
+ // Server said no: snap the card back where it came from and flash a red
75
+ // border so the bounce reads as a rejection, not a glitch.
76
+ event.from.insertBefore(event.item, event.from.children[event.oldIndex])
77
+ const body = await response.json().catch(() => ({}))
78
+ this.flashRejected(event.item, body.error)
79
+ }
80
+ }
81
+
82
+ flashRejected(card, message) {
83
+ // Surface the reason on hover for the life of the flash; the durable
84
+ // record lives in the card's event timeline.
85
+ const priorTitle = card.getAttribute("title")
86
+ if (message) card.setAttribute("title", message)
87
+ card.classList.remove("move-rejected")
88
+ // Force a reflow so re-adding the class restarts the animation.
89
+ void card.offsetWidth
90
+ card.classList.add("move-rejected")
91
+ card.addEventListener("animationend", () => {
92
+ card.classList.remove("move-rejected")
93
+ if (message) priorTitle ? card.setAttribute("title", priorTitle) : card.removeAttribute("title")
94
+ }, { once: true })
95
+ }
96
+ }
@@ -0,0 +1,18 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // GitHub-style copy button: copies textValue, flashes a checkmark.
4
+ export default class extends Controller {
5
+ static values = { text: String }
6
+ static targets = ["button"]
7
+
8
+ async copy() {
9
+ await navigator.clipboard.writeText(this.textValue)
10
+ const original = this.buttonTarget.textContent
11
+ this.buttonTarget.textContent = "✓"
12
+ this.buttonTarget.classList.add("copied")
13
+ setTimeout(() => {
14
+ this.buttonTarget.textContent = original
15
+ this.buttonTarget.classList.remove("copied")
16
+ }, 1200)
17
+ }
18
+ }
@@ -0,0 +1,10 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Chat composer: Enter sends, Shift+Enter inserts a newline.
4
+ export default class extends Controller {
5
+ keydown(event) {
6
+ if (event.key !== "Enter" || event.shiftKey) return
7
+ event.preventDefault()
8
+ if (this.element.value.trim() !== "") this.element.form.requestSubmit()
9
+ }
10
+ }
@@ -0,0 +1,3 @@
1
+ import { application } from "controllers/application"
2
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
3
+ eagerLoadControllersFrom("controllers", application)
@@ -0,0 +1,45 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Near-fullscreen card modal rendered into the "modal" turbo-frame.
4
+ // Closes on Esc, backdrop click, or the ✕ button — unless `sticky`, in
5
+ // which case only the explicit close/cancel buttons (and a successful save)
6
+ // dismiss it. Sticky guards the new-card form so a stray click or Esc can't
7
+ // throw away everything you typed.
8
+ export default class extends Controller {
9
+ static values = { sticky: Boolean }
10
+
11
+ connect() {
12
+ this.escHandler = (e) => { if (e.key === "Escape" && !this.stickyValue) this.close() }
13
+ document.addEventListener("keydown", this.escHandler)
14
+ }
15
+
16
+ disconnect() {
17
+ document.removeEventListener("keydown", this.escHandler)
18
+ }
19
+
20
+ backdrop(event) {
21
+ if (event.target === this.element && !this.stickyValue) this.close()
22
+ }
23
+
24
+ // For _top-targeted forms (create card, deletes): the frame is
25
+ // turbo-permanent, so it survives the follow-up page render — close it
26
+ // explicitly once the submission succeeds.
27
+ closeOnSuccess(event) {
28
+ if (event.detail.success) this.close()
29
+ }
30
+
31
+ close() {
32
+ // Let autosave forms flush any pending debounce before the frame clears.
33
+ document.dispatchEvent(new CustomEvent("cardinal:modal-closing"))
34
+ const frame = this.element.closest("turbo-frame")
35
+ frame.removeAttribute("src")
36
+ frame.innerHTML = ""
37
+ // Opening a card advances the URL to /cards/:id; closing must return the
38
+ // address bar to the board. The board is still rendered behind the permanent
39
+ // frame, so just rewrite the URL — no navigation needed. Back/forward still
40
+ // work via Turbo's history snapshots.
41
+ if (window.location.pathname.startsWith("/cards/")) {
42
+ window.history.pushState({}, "", "/")
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,15 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Show/hide dependent settings when a toggle changes (e.g. the column AI
4
+ // checkbox reveals model/effort/budget settings). Autosave still fires via
5
+ // the change event bubbling to the autosave controller.
6
+ export default class extends Controller {
7
+ static targets = ["toggle", "panel"]
8
+
9
+ connect() { this.sync() }
10
+
11
+ sync() {
12
+ const on = this.toggleTarget.checked
13
+ this.panelTargets.forEach(p => p.classList.toggle("hidden", !on))
14
+ }
15
+ }