cardinal-ai 0.2.14 → 0.2.17

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/cardinal.css +83 -4
  3. data/app/controllers/asana_controller.rb +31 -0
  4. data/app/controllers/boards_controller.rb +25 -1
  5. data/app/controllers/cards_controller.rb +21 -1
  6. data/app/controllers/messages_controller.rb +16 -1
  7. data/app/helpers/application_helper.rb +54 -0
  8. data/app/javascript/controllers/archive_drop_controller.js +41 -0
  9. data/app/javascript/controllers/attach_controller.js +80 -0
  10. data/app/javascript/controllers/board_column_controller.js +6 -0
  11. data/app/jobs/compact_job.rb +12 -0
  12. data/app/jobs/resume_run_job.rb +8 -0
  13. data/app/jobs/summary_job.rb +9 -0
  14. data/app/models/board.rb +10 -0
  15. data/app/models/card.rb +19 -0
  16. data/app/models/column.rb +3 -3
  17. data/app/services/agent/runner.rb +18 -3
  18. data/app/services/asana.rb +73 -0
  19. data/app/services/github_issues.rb +49 -0
  20. data/app/views/asana/new_card.html.erb +59 -0
  21. data/app/views/boards/archive.html.erb +56 -0
  22. data/app/views/boards/issues.html.erb +41 -0
  23. data/app/views/boards/show.html.erb +8 -0
  24. data/app/views/cards/_card.html.erb +6 -2
  25. data/app/views/cards/_compact_panel.html.erb +5 -3
  26. data/app/views/cards/_detail.html.erb +16 -5
  27. data/app/views/cards/_summary_panel.html.erb +5 -3
  28. data/app/views/cards/new.html.erb +3 -0
  29. data/app/views/columns/_column.html.erb +8 -1
  30. data/app/views/events/_event.html.erb +2 -1
  31. data/config/routes.rb +10 -0
  32. data/db/cable_schema.rb +15 -3
  33. data/db/migrate/20260705120001_add_issue_number_to_cards.rb +5 -0
  34. data/db/migrate/20260705193935_add_settings_to_boards.rb +6 -0
  35. data/db/migrate/20260705225451_add_asana_url_to_cards.rb +5 -0
  36. data/db/queue_schema.rb +74 -62
  37. data/lib/cardinal/version.rb +1 -1
  38. metadata +12 -1
@@ -0,0 +1,73 @@
1
+ require "net/http"
2
+
3
+ # Asana import (card #7): paste a task URL, get a card. First use walks
4
+ # through connecting a Personal Access Token, which lives as a 0600 file in
5
+ # .cardinal/ (like the Claude token) — never in the database, never in git.
6
+ module Asana
7
+ API = "https://app.asana.com/api/1.0".freeze
8
+
9
+ class Error < StandardError; end
10
+
11
+ def self.token_path
12
+ Pathname(File.expand_path(ENV["CARDINAL_DATA_DIR"].presence || Rails.root.join(".cardinal"))).join("asana-token")
13
+ end
14
+
15
+ def self.connected? = !!File.size?(token_path)
16
+ def self.token = File.read(token_path).strip
17
+
18
+ def self.save_token!(value)
19
+ FileUtils.mkdir_p(File.dirname(token_path))
20
+ File.write(token_path, value.strip)
21
+ File.chmod(0o600, token_path)
22
+ end
23
+
24
+ def self.disconnect! = FileUtils.rm_f(token_path)
25
+
26
+ # Cheapest possible "does this token work" — also gives us a name to show.
27
+ def self.verify!(candidate)
28
+ data = request("/users/me", candidate)
29
+ data["name"].presence || data["email"].presence || "connected"
30
+ end
31
+
32
+ # Task URLs come in several vintages (/0/<project>/<task>, /0/.../f,
33
+ # /1/<ws>/project/<p>/task/<t>) — the task gid is the last long digit run.
34
+ def self.task_gid(url)
35
+ url.to_s.scan(/\d{6,}/).last or raise Error, "That doesn't look like an Asana task URL"
36
+ end
37
+
38
+ def self.import!(board, url)
39
+ data = request("/tasks/#{task_gid(url)}?opt_fields=name,notes,permalink_url,tags.name", token)
40
+ permalink = data["permalink_url"].presence || url
41
+ if (existing = board.cards.find_by(asana_url: permalink))
42
+ return existing
43
+ end
44
+
45
+ inbox = board.columns.inbox.order(:position).first
46
+ card = board.cards.create!(
47
+ column: inbox,
48
+ title: data["name"].presence || "Asana task",
49
+ asana_url: permalink,
50
+ tags: Array(data["tags"]).filter_map { |t| t["name"] }.first(5),
51
+ description: "#{data["notes"].presence || "(no description on the Asana task)"}\n\n---\n_Imported from Asana: #{permalink}_"
52
+ )
53
+ card.log!("status_change", actor: "user", text: "Imported from Asana: #{permalink}")
54
+ card
55
+ end
56
+
57
+ def self.request(path, auth)
58
+ uri = URI("#{API}#{path}")
59
+ http = Net::HTTP.new(uri.host, uri.port)
60
+ http.use_ssl = true
61
+ http.open_timeout = 10
62
+ http.read_timeout = 15
63
+ response = http.get(uri.request_uri, { "Authorization" => "Bearer #{auth}" })
64
+ unless response.code.to_i == 200
65
+ raise Error, "Asana said no (HTTP #{response.code}) — check the token, and that it can see this task"
66
+ end
67
+ JSON.parse(response.body)["data"]
68
+ rescue JSON::ParserError
69
+ raise Error, "Asana returned something unreadable"
70
+ rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
71
+ raise Error, "Couldn't reach Asana (#{e.class.name.demodulize}) — check your connection"
72
+ end
73
+ end
@@ -0,0 +1,49 @@
1
+ # GitHub Issues sync (card #49): issues are the adjacent primitive to cards —
2
+ # gh is already authenticated, so import is a listing + a click, and closing
3
+ # happens naturally via "Closes #N" in the card's PR body.
4
+ module GithubIssues
5
+ Issue = Struct.new(:number, :title, :body, :labels, keyword_init: true)
6
+
7
+ def self.available?(board)
8
+ board.repo_url.present? && board.local_path.present?
9
+ end
10
+
11
+ # Open issues, newest first. Empty array on any failure (no remote, gh not
12
+ # authed, offline) — the modal explains instead of erroring.
13
+ def self.list(board)
14
+ out, status = Open3.capture2e(
15
+ "gh", "issue", "list", "--state", "open", "--limit", "50",
16
+ "--json", "number,title,body,labels", chdir: board.local_path
17
+ )
18
+ return [] unless status.success?
19
+ JSON.parse(out).map do |i|
20
+ Issue.new(number: i["number"], title: i["title"], body: i["body"].to_s,
21
+ labels: Array(i["labels"]).map { |l| l["name"] })
22
+ end
23
+ rescue JSON::ParserError
24
+ []
25
+ end
26
+
27
+ # One click → one card in the inbox, tagged with the issue's labels, body
28
+ # carried as the description with provenance. Best-effort backlink comment
29
+ # on the issue so the GitHub side knows where the work went.
30
+ def self.import!(board, number)
31
+ issue = list(board).find { |i| i.number == number.to_i }
32
+ raise ArgumentError, "issue ##{number} not found among open issues" unless issue
33
+
34
+ existing = board.cards.find_by(issue_number: issue.number)
35
+ return existing if existing
36
+
37
+ inbox = board.columns.inbox.order(:position).first
38
+ card = board.cards.create!(
39
+ column: inbox, title: issue.title, issue_number: issue.number,
40
+ tags: issue.labels.first(5),
41
+ description: "#{issue.body.presence || "(no issue body)"}\n\n---\n_Imported from GitHub issue ##{issue.number}._"
42
+ )
43
+ card.log!("status_change", actor: "user", text: "Imported from GitHub issue ##{issue.number}")
44
+ Open3.capture2e("gh", "issue", "comment", issue.number.to_s,
45
+ "--body", "Tracking in Cardinal as card ##{card.number}. The pull request, when opened, will link back and close this issue on merge.",
46
+ chdir: board.local_path)
47
+ card
48
+ end
49
+ end
@@ -0,0 +1,59 @@
1
+ <%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
2
+ <div class="modal-backdrop" data-controller="modal" data-action="click->modal#backdrop">
3
+ <div class="modal modal-sm">
4
+ <header class="modal-header">
5
+ <h1>◯ New from Asana</h1>
6
+ <button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
7
+ </header>
8
+ <div class="modal-body">
9
+ <% if @error.present? %><p class="form-error"><%= @error %></p><% end %>
10
+
11
+ <% if @connected %>
12
+ <% if @just_connected.present? %>
13
+ <p class="hint asana-connected">✅ Connected as <strong><%= @just_connected %></strong> — you won't need to do that again.</p>
14
+ <% end %>
15
+ <p class="hint">Paste the Asana task's URL (open the task, copy the address bar or
16
+ use "Copy task link"). Cardinal pulls the title, description, and tags into a new
17
+ Tasks card that links back to Asana.</p>
18
+
19
+ <%= form_with url: asana_import_path, class: "card-edit",
20
+ data: { turbo_frame: "_top" } do |f| %>
21
+ <label>Asana task URL</label>
22
+ <%= f.text_field :url, required: true, autofocus: true,
23
+ placeholder: "https://app.asana.com/0/1200000000000000/1205000000000000" %>
24
+ <div class="card-edit-actions">
25
+ <button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
26
+ <%= f.submit "Import task" %>
27
+ </div>
28
+ <% end %>
29
+ <details class="advanced-rules">
30
+ <summary>Connection</summary>
31
+ <p class="hint">Your Asana token lives in <code>.cardinal/asana-token</code> on this machine only.</p>
32
+ <%= button_to "Unlink Asana", asana_disconnect_path, class: "btn-cancel",
33
+ form: { data: { turbo_frame: "modal" } } %>
34
+ </details>
35
+
36
+ <% else %>
37
+ <p class="hint"><strong>One-time setup:</strong> Cardinal talks to Asana with a
38
+ Personal Access Token that stays on this machine
39
+ (<code>.cardinal/asana-token</code>, never committed, never in the database).</p>
40
+ <ol class="asana-steps">
41
+ <li>Open <a href="https://app.asana.com/0/my-apps" target="_blank" rel="noopener">app.asana.com/0/my-apps</a> (Asana → Settings → Apps → Developer console)</li>
42
+ <li>Click <strong>Create new token</strong>, name it something like <code>cardinal</code></li>
43
+ <li>Copy the token and paste it below</li>
44
+ </ol>
45
+
46
+ <%= form_with url: asana_connect_path, class: "card-edit" do |f| %>
47
+ <label>Personal Access Token</label>
48
+ <%= f.password_field :token, required: true, autofocus: true,
49
+ placeholder: "1/1234567890:abcdef…", autocomplete: "off" %>
50
+ <div class="card-edit-actions">
51
+ <button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
52
+ <%= f.submit "Connect Asana" %>
53
+ </div>
54
+ <% end %>
55
+ <% end %>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ <% end %>
@@ -0,0 +1,56 @@
1
+ <header class="topbar">
2
+ <div class="topbar-left">
3
+ <h1><%= link_to "Cardinal AI", root_path %> <span class="sep">▸</span> <%= @board.name %> <span class="sep">▸</span> Archive</h1>
4
+ </div>
5
+ <div class="topbar-right">
6
+ <%= link_to "← Back to board", root_path, class: "theme-toggle" %>
7
+ </div>
8
+ </header>
9
+
10
+ <main class="archive-page" data-controller="filter">
11
+ <div class="archive-tools">
12
+ <input type="search" class="global-search" placeholder="Search the archive… /"
13
+ data-filter-target="global"
14
+ data-action="input->filter#global keydown->filter#clear">
15
+ <span class="hint"><%= pluralize(@cards.size, "archived card") %></span>
16
+ </div>
17
+
18
+ <div class="archive-rails" data-controller="autosave">
19
+ <%= form_with url: board_path(autosave: 1), method: :patch,
20
+ data: { autosave_target: "form" } do %>
21
+ <%= hidden_field_tag "board[archive_accepts_from][]", "" %>
22
+ <span class="rails-label">Drag-to-archive from:
23
+ <span class="autosave-status" data-autosave-target="status"></span></span>
24
+ <% @board.columns.each do |column| %>
25
+ <label class="check-row inline-check">
26
+ <%= check_box_tag "board[archive_accepts_from][]", column.id,
27
+ @board.archive_accepts?(column), data: { action: "change->autosave#save" } %>
28
+ <%= column.name %>
29
+ </label>
30
+ <% end %>
31
+ <span class="hint">Checked columns can drop cards straight onto the 🗄 button on the board. Explicit only — nothing checked means no drag target. The card's own Archive button always works.</span>
32
+ <% end %>
33
+ </div>
34
+
35
+ <% if @cards.any? %>
36
+ <ul class="archive-list">
37
+ <% @cards.each do |card| %>
38
+ <li class="card archive-row" data-search="<%= [card.title, "##{card.number}", Array(card.tags).join(" "), card.description.to_s.truncate(400)].join(" ").downcase %>">
39
+ <div class="archive-main">
40
+ <span class="archive-title">#<%= card.number %> <%= card.title %></span>
41
+ <span class="archive-meta">
42
+ <% card.tags.each do |tag| %><span class="tag"><%= tag %></span><% end %>
43
+ from <%= card.column.name %> · archived <%= card.updated_at.strftime("%b %-d") %>
44
+ <% if card.total_cost.positive? %> · $<%= card.total_cost.round(2) %><% end %>
45
+ <% if card.pr_url.present? %> · <a href="<%= card.pr_url %>" target="_blank" rel="noopener">PR ↗</a><% end %>
46
+ </span>
47
+ </div>
48
+ <%= button_to "↩ Restore", unarchive_card_path(card), class: "theme-toggle restore-btn",
49
+ title: "Back to #{card.column.name}" %>
50
+ </li>
51
+ <% end %>
52
+ </ul>
53
+ <% else %>
54
+ <p class="empty">Nothing archived yet. Cards can be archived from their detail view (Advanced) once they're done.</p>
55
+ <% end %>
56
+ </main>
@@ -0,0 +1,41 @@
1
+ <%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
2
+ <div class="modal-backdrop" data-controller="modal" data-action="click->modal#backdrop">
3
+ <div class="modal modal-sm">
4
+ <header class="modal-header">
5
+ <h1>⬇ GitHub Issues</h1>
6
+ <button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
7
+ </header>
8
+ <div class="modal-body">
9
+ <p class="hint">
10
+ Open issues on <code><%= @board.repo_url.presence || "(no remote)" %></code>. Importing
11
+ creates a Tasks card carrying the issue body and labels; the card's pull request will
12
+ say <code>Closes #N</code>, so merging through Done closes the issue on GitHub.
13
+ </p>
14
+
15
+ <% if @issues.any? %>
16
+ <ul class="issue-list">
17
+ <% @issues.each do |issue| %>
18
+ <li class="issue-row">
19
+ <div>
20
+ <strong>#<%= issue.number %></strong> <%= issue.title %>
21
+ <% issue.labels.first(4).each do |label| %><span class="tag"><%= label %></span><% end %>
22
+ </div>
23
+ <% if (card_number = @imported[issue.number]) %>
24
+ <%= link_to "card ##{card_number}", card_path(card_number),
25
+ class: "hint", data: { turbo_frame: "modal", turbo_action: "advance" } %>
26
+ <% else %>
27
+ <%= button_to "Import", import_issue_board_path(number: issue.number),
28
+ form: { data: { turbo_frame: "modal" } } %>
29
+ <% end %>
30
+ </li>
31
+ <% end %>
32
+ </ul>
33
+ <% elsif !GithubIssues.available?(@board) %>
34
+ <p class="empty">This board's repo has no GitHub remote — nothing to sync.</p>
35
+ <% else %>
36
+ <p class="empty">No open issues found (or gh isn't authenticated — try <code>gh auth status</code>).</p>
37
+ <% end %>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ <% end %>
@@ -9,6 +9,8 @@
9
9
  <%= button_to "⇣ Pull", pull_board_path, form_class: "pull-form",
10
10
  form: { title: "git pull --ff-only in #{@board.local_path} — fetch what Done has merged" },
11
11
  data: { turbo_submits_with: "⇣ Pulling…" }, class: "theme-toggle" %>
12
+ <%= link_to "⬇ Issues", issues_board_path, data: { turbo_frame: "modal" },
13
+ class: "theme-toggle", title: "Import open GitHub issues as cards" %>
12
14
  <%# Repo deep dive (card #12): one-shot read-only mapping of the repo, stored
13
15
  as a brief and injected into worker prompts. The button greys→reds as the
14
16
  brief falls behind HEAD; a stale brief over-anchors, so it nags at 10+.
@@ -96,6 +98,12 @@
96
98
  </div>
97
99
  </details>
98
100
  <% end %>
101
+ <% archived_count = @board.cards.archived.count %>
102
+ <span class="archive-drop" data-controller="archive-drop"
103
+ data-accepts="<%= Array(@board.archive_accepts_from).join(",") %>">
104
+ <%= link_to "🗄#{" #{archived_count}" if archived_count.positive?}", archive_board_path,
105
+ class: "theme-toggle archive-link", title: "Archive — click to browse; drag a card here to archive it (allowed columns are set on the archive page)" %>
106
+ </span>
99
107
  <details class="new-column">
100
108
  <summary>+ Column</summary>
101
109
  <%= form_with url: columns_path, class: "new-column-form" do |f| %>
@@ -1,5 +1,5 @@
1
1
  <article class="card status-<%= card.status %>" id="<%= dom_id(card) %>" data-card-id="<%= card.number %>"
2
- data-search="<%= [card.title, "##{card.number}", Array(card.tags).join(" "), card.description.to_s.truncate(400)].join(" ").downcase %>">
2
+ data-search="<%= [card.title, "##{card.number}", Array(card.tags).join(" "), strip_attachment_tokens(card.description).truncate(400)].join(" ").downcase %>">
3
3
  <%= link_to card_path(card), class: "card-link", data: { turbo_frame: "modal", turbo_action: "advance" } do %>
4
4
  <div class="card-title">
5
5
  <%= card.title %>
@@ -23,6 +23,8 @@
23
23
  <% card.tags.each do |tag| %><span class="tag"><%= tag %></span><% end %>
24
24
  <% if card.awaiting_assistant? %>
25
25
  <span class="chip agent-chip thinking-chip">🪶 <span class="typing-dots mini"><span></span><span></span><span></span></span></span>
26
+ <% elsif card.planning_ready? %>
27
+ <span class="chip agent-chip ready-chip" title="The assistant has posted a Ready-for-execution brief — drag onward when you are">🪶 ready</span>
26
28
  <% elsif card.discussing? && card.events.conversation.last&.actor == "assistant" %>
27
29
  <span class="chip agent-chip">🪶 replied</span>
28
30
  <% end %>
@@ -32,10 +34,12 @@
32
34
  </div>
33
35
  <% end %>
34
36
  <% has_cost = card.total_cost.positive? || card.total_output_tokens.positive? %>
35
- <% if has_cost || card.pr_url.present? %>
37
+ <% if has_cost || card.pr_url.present? || card.asana_url.present? %>
36
38
  <div class="card-footer">
37
39
  <% if card.pr_url.present? %>
38
40
  <a class="footer-pr" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> ↗</a>
41
+ <% elsif card.asana_url.present? %>
42
+ <a class="footer-pr" href="<%= card.asana_url %>" target="_blank" rel="noopener" title="Open the source task on Asana">◯ Asana ↗</a>
39
43
  <% else %>
40
44
  <span class="footer-pr"></span>
41
45
  <% end %>
@@ -1,6 +1,10 @@
1
1
  <div id="card_compact" class="summary-panel compact-panel" data-controller="autosave">
2
2
  <div class="summary-head">
3
3
  <h3>Technical compact <span class="autosave-status" data-autosave-target="status"></span></h3>
4
+ <div class="summary-head-right">
5
+ <% if card.compact_generated_at.present? %>
6
+ <span class="summary-stamp">generated <%= time_ago_in_words(card.compact_generated_at) %> ago</span>
7
+ <% end %>
4
8
  <% if card.compact_working? %>
5
9
  <button type="button" class="deep-dive working summary-generate" disabled>
6
10
  <span class="pulse-dot"></span> Generating…
@@ -11,6 +15,7 @@
11
15
  ✨ <%= card.compact.present? ? "Regenerate" : "Generate compact" %>
12
16
  <% end %>
13
17
  <% end %>
18
+ </div>
14
19
  </div>
15
20
 
16
21
  <p class="hint summary-blurb">A dense, technical journal of what was built, decided, and blocked — the AI reads this to resume work without re-exploring the repo. Fully editable; your edits are respected when you regenerate.</p>
@@ -22,7 +27,4 @@
22
27
  disabled: card.compact_working? %>
23
28
  <% end %>
24
29
 
25
- <% if card.compact_generated_at.present? %>
26
- <p class="hint summary-stamp">Last generated <%= time_ago_in_words(card.compact_generated_at) %> ago</p>
27
- <% end %>
28
30
  </div>
@@ -51,7 +51,7 @@
51
51
  <% else %>
52
52
  <div class="timeline-scroll" data-scroll-target="scroller">
53
53
  <% if @card.description.present? %>
54
- <div class="event event-description"><%= render_markdown @card.description %></div>
54
+ <div class="event event-description"><%= render_with_attachments @card.description %></div>
55
55
  <% end %>
56
56
 
57
57
  <div id="card_events">
@@ -73,9 +73,14 @@
73
73
  data-action="scroll#jump">↓ New messages</button>
74
74
 
75
75
  <%= form_with url: card_messages_path(@card), class: "message-form" do |f| %>
76
- <%= f.text_area "message[text]", rows: 2, required: true,
77
- data: { controller: "composer", action: "keydown->composer#keydown" },
78
- placeholder: (@card.column.planning? ? "Discuss this card with the planning assistant…" : "Add a note to this card…") + " (Enter sends, Shift+Enter for a new line)" %>
76
+ <div class="composer-box">
77
+ <label class="note-toggle" title="Record this message without asking any AI to respond. Future agents (the next column's assistant or worker) still read it as context — perfect for a parting instruction right before you drag the card onward.">
78
+ <%= f.check_box "message[note]", {}, "1", "0" %> 📝 note only no AI reply
79
+ </label>
80
+ <%= f.text_area "message[text]", rows: 2, required: true,
81
+ data: { controller: "composer attach", action: "keydown->composer#keydown paste->attach#paste" },
82
+ placeholder: (@card.column.planning? ? "Discuss this card with the planning assistant…" : "Add a note to this card…") + " (Enter sends, Shift+Enter for a new line)" %>
83
+ </div>
79
84
  <% end %>
80
85
  <% end %>
81
86
  </section>
@@ -91,7 +96,8 @@
91
96
  <label>Tags</label>
92
97
  <%= render "cards/tag_picker", board: @card.board, tags: @card.tags %>
93
98
  <label>Description</label>
94
- <%= f.text_area :description, rows: 6 %>
99
+ <%= f.text_area :description, rows: 6,
100
+ data: { controller: "attach", action: "paste->attach#paste" } %>
95
101
  <label>Branch <span class="hint">— optional</span></label>
96
102
  <% if @card.branch_name.present? %>
97
103
  <%# Locked once set — by the user here or by the agent on run start. %>
@@ -229,6 +235,11 @@
229
235
 
230
236
  <details class="advanced-rules panel-advanced">
231
237
  <summary>Advanced</summary>
238
+ <p class="hint">Archiving takes the card off the board but keeps everything — find and restore it under 🗄 Archive in the topbar.</p>
239
+ <%= button_to "🗄 Archive card", archive_card_path(@card),
240
+ class: "cancel-btn",
241
+ form: { data: { turbo_frame: "_top", action: "turbo:submit-end->modal#closeOnSuccess" } },
242
+ disabled: @card.running? %>
232
243
  <p class="hint">Deleting removes the card and its entire history (events, runs, workspace). The remote branch and PR, if any, are left untouched.</p>
233
244
  <%= button_to "🗑 Delete card", card_path(@card), method: :delete,
234
245
  class: "cancel-btn delete-card",
@@ -1,6 +1,10 @@
1
1
  <div id="card_summary" class="summary-panel" data-controller="autosave">
2
2
  <div class="summary-head">
3
3
  <h3>Customer summary <span class="autosave-status" data-autosave-target="status"></span></h3>
4
+ <div class="summary-head-right">
5
+ <% if card.summary_generated_at.present? %>
6
+ <span class="summary-stamp">generated <%= time_ago_in_words(card.summary_generated_at) %> ago</span>
7
+ <% end %>
4
8
  <% if card.summary_working? %>
5
9
  <button type="button" class="deep-dive working summary-generate" disabled>
6
10
  <span class="pulse-dot"></span> Generating…
@@ -11,6 +15,7 @@
11
15
  ✨ <%= card.summary.present? ? "Regenerate" : "Generate summary" %>
12
16
  <% end %>
13
17
  <% end %>
18
+ </div>
14
19
  </div>
15
20
 
16
21
  <p class="hint summary-blurb">A plain-language recap of what this card delivered — ready to drop into a customer chat. Fully editable; your edits are respected when you regenerate.</p>
@@ -22,7 +27,4 @@
22
27
  disabled: card.summary_working? %>
23
28
  <% end %>
24
29
 
25
- <% if card.summary_generated_at.present? %>
26
- <p class="hint summary-stamp">Last generated <%= time_ago_in_words(card.summary_generated_at) %> ago</p>
27
- <% end %>
28
30
  </div>
@@ -25,6 +25,9 @@
25
25
  <%= f.text_field "card[pr_url]", placeholder: "https://github.com/owner/repo/pull/123" %>
26
26
  <p class="hint">Leave blank and the agent picks a branch. Set either to point work at an existing branch or PR.</p>
27
27
  <div class="card-edit-actions">
28
+ <%= link_to "◯ New from Asana", asana_new_card_path, class: "asana-entry",
29
+ data: { turbo_frame: "modal" },
30
+ title: "Import an Asana task as a card — first use walks you through connecting" %>
28
31
  <button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
29
32
  <%= f.submit "Save" %>
30
33
  </div>
@@ -18,6 +18,13 @@
18
18
  <input type="search" class="col-search" placeholder="Filter <%= column.name %>…"
19
19
  data-col-id="<%= column.id %>"
20
20
  data-action="input->filter#column keydown->filter#clear">
21
+ <% if column.inbox? %>
22
+ <%# Quick-add (card #42): title, Enter, done — the modal stays for real write-ups. %>
23
+ <%= form_with url: cards_path, class: "quick-add", data: { turbo_frame: "_top" } do |f| %>
24
+ <%= f.text_field "card[title]", placeholder: "Quick add — type a title, press Enter",
25
+ autocomplete: "off", required: true %>
26
+ <% end %>
27
+ <% end %>
21
28
  <p class="drop-hint">→ <%= column.drag_hint %></p>
22
29
  <div class="cards<%= " cards-clickable" if column.inbox? %>"
23
30
  data-controller="board-column"
@@ -25,7 +32,7 @@
25
32
  <% if column.inbox? %>data-action="click->board-column#newCard"
26
33
  data-board-column-new-url-value="<%= new_card_path %>"<% end %>
27
34
  data-column-id="<%= column.id %>">
28
- <% column.cards.each do |card| %>
35
+ <% column.cards.active.each do |card| %>
29
36
  <%= render "cards/card", card: card %>
30
37
  <% end %>
31
38
  </div>
@@ -13,7 +13,8 @@
13
13
  <span class="event-actor"><%= { "user" => "👤", "agent" => "🤖", "assistant" => "🪶", "system" => "·" }[event.actor] || event.actor %></span>
14
14
  <div class="event-body">
15
15
  <% if event.text %>
16
- <%= render_markdown event.text %>
16
+ <% if event.payload["note"] %><span class="note-flag" title="Note only — no AI was asked to reply; future agents read it as context">📝 note</span><% end %>
17
+ <%= render_with_attachments event.text %>
17
18
  <% else %>
18
19
  <code class="event-kind"><%= event.kind %></code>
19
20
  <% end %>
data/config/routes.rb CHANGED
@@ -5,16 +5,26 @@ Rails.application.routes.draw do
5
5
  post :deep_dive
6
6
  post :pull
7
7
  get :brief
8
+ get :archive
9
+ get :issues
10
+ post :import_issue
8
11
  end
9
12
  resources :cards, only: [:new, :create, :show, :update, :destroy] do
10
13
  member do
11
14
  patch :move
12
15
  post :approve
16
+ post :archive
17
+ post :unarchive
13
18
  post :summarize
14
19
  post :compact
15
20
  end
16
21
  resources :messages, only: [:create]
17
22
  end
23
+ get "asana/new" => "asana#new_card", as: :asana_new_card
24
+ post "asana/connect" => "asana#connect", as: :asana_connect
25
+ post "asana/import" => "asana#import", as: :asana_import
26
+ post "asana/disconnect" => "asana#disconnect", as: :asana_disconnect
27
+
18
28
  resources :columns, only: [:create, :edit, :update, :destroy]
19
29
  resources :runs, only: [] do
20
30
  member do
data/db/cable_schema.rb CHANGED
@@ -1,9 +1,21 @@
1
- ActiveRecord::Schema[7.1].define(version: 1) do
1
+ # This file is auto-generated from the current state of the database. Instead
2
+ # of editing this file, please use the migrations feature of Active Record to
3
+ # incrementally modify your database, and then regenerate this schema definition.
4
+ #
5
+ # This file is the source Rails uses to define your schema when running `bin/rails
6
+ # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
7
+ # be faster and is potentially less error prone than running all of your
8
+ # migrations from scratch. Old migrations may fail to apply correctly if those
9
+ # migrations use external dependencies or application code.
10
+ #
11
+ # It's strongly recommended that you check this file into your version control system.
12
+
13
+ ActiveRecord::Schema[8.1].define(version: 1) do
2
14
  create_table "solid_cable_messages", force: :cascade do |t|
3
15
  t.binary "channel", limit: 1024, null: false
4
- t.binary "payload", limit: 536870912, null: false
5
- t.datetime "created_at", null: false
6
16
  t.integer "channel_hash", limit: 8, null: false
17
+ t.datetime "created_at", null: false
18
+ t.binary "payload", limit: 536870912, null: false
7
19
  t.index ["channel"], name: "index_solid_cable_messages_on_channel"
8
20
  t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
9
21
  t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
@@ -0,0 +1,5 @@
1
+ class AddIssueNumberToCards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :cards, :issue_number, :integer
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ # Board-level knobs (first tenant: which columns may drag-to-archive).
2
+ class AddSettingsToBoards < ActiveRecord::Migration[8.1]
3
+ def change
4
+ add_column :boards, :settings, :json, default: {}, null: false
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ class AddAsanaUrlToCards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :cards, :asana_url, :string
4
+ end
5
+ end