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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/cardinal.css +83 -4
- data/app/controllers/asana_controller.rb +31 -0
- data/app/controllers/boards_controller.rb +25 -1
- data/app/controllers/cards_controller.rb +21 -1
- data/app/controllers/messages_controller.rb +16 -1
- data/app/helpers/application_helper.rb +54 -0
- data/app/javascript/controllers/archive_drop_controller.js +41 -0
- data/app/javascript/controllers/attach_controller.js +80 -0
- data/app/javascript/controllers/board_column_controller.js +6 -0
- data/app/jobs/compact_job.rb +12 -0
- data/app/jobs/resume_run_job.rb +8 -0
- data/app/jobs/summary_job.rb +9 -0
- data/app/models/board.rb +10 -0
- data/app/models/card.rb +19 -0
- data/app/models/column.rb +3 -3
- data/app/services/agent/runner.rb +18 -3
- data/app/services/asana.rb +73 -0
- data/app/services/github_issues.rb +49 -0
- data/app/views/asana/new_card.html.erb +59 -0
- data/app/views/boards/archive.html.erb +56 -0
- data/app/views/boards/issues.html.erb +41 -0
- data/app/views/boards/show.html.erb +8 -0
- data/app/views/cards/_card.html.erb +6 -2
- data/app/views/cards/_compact_panel.html.erb +5 -3
- data/app/views/cards/_detail.html.erb +16 -5
- data/app/views/cards/_summary_panel.html.erb +5 -3
- data/app/views/cards/new.html.erb +3 -0
- data/app/views/columns/_column.html.erb +8 -1
- data/app/views/events/_event.html.erb +2 -1
- data/config/routes.rb +10 -0
- data/db/cable_schema.rb +15 -3
- data/db/migrate/20260705120001_add_issue_number_to_cards.rb +5 -0
- data/db/migrate/20260705193935_add_settings_to_boards.rb +6 -0
- data/db/migrate/20260705225451_add_asana_url_to_cards.rb +5 -0
- data/db/queue_schema.rb +74 -62
- data/lib/cardinal/version.rb +1 -1
- 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.
|
|
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"><%=
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|