Plan
The checklist an agent is working through, redrawn in place as it goes. A done step recedes and the one being worked stands up, so a long plan reads as a position rather than a list.
A plan under way
Waiting is the one step in colour: the only state that won't move on its own.
Plan
2/5
- DoneFind the job
- DoneList who hasn't replied
- In progressCheck the bounced address
- Nudge Grace Waiting on youNeeds your go-ahead to send
- To doRemind me about Ana on the 19th
<div class="w-80 rounded-xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<%= ai_chat_plan id: "plan_states" do |plan| %>
<% plan.step "Find the job", state: :completed %>
<% plan.step "List who hasn't replied", state: :completed %>
<% plan.step "Check the bounced address", state: :in_progress %>
<% plan.step "Nudge Grace", state: :waiting do %>Needs your go-ahead to send<% end %>
<% plan.step "Remind me about Ana on the 19th" %>
<% end %>
</div>Empty
It still renders, so a broadcast has something to replace.
Plan
Nothing planned — the assistant lays one out when a job has steps.
<div class="w-80 rounded-xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<%= ai_chat_plan id: "plan_empty", empty: "Nothing planned — the assistant lays one out when a job has steps." %>
</div>Kept shut across updates
Collapse the plan or the workspace, then send updates: each replaces the section, as a broadcast would, and it stays the way you left it. Until you choose, the server's open: decides.
Plan
0/4
- In progressFind the job
- To doList who hasn't replied
- To doCheck the bounced address
- To doDraft the nudges
Workspace
No files yet.
Plan
1/4
- DoneFind the job
- In progressList who hasn't replied
- To doCheck the bounced address
- To doDraft the nudges
Workspace
1
- candidates.csv
Plan
2/4
- DoneFind the job
- DoneList who hasn't replied
- In progressCheck the bounced address
- To doDraft the nudges
Workspace
2
- candidates.csv
- bounced.txt
Plan
3/4
- DoneFind the job
- DoneList who hasn't replied
- DoneCheck the bounced address
- In progressDraft the nudges
Workspace
3
nudges
- grace.md
- candidates.csv
- bounced.txt
Plan
4/4
- DoneFind the job
- DoneList who hasn't replied
- DoneCheck the bounced address
- DoneDraft the nudges
Workspace
4
nudges
- grace.md
- ana.md
- candidates.csv
- bounced.txt
<%# The browser has no Action Cable, so each update is a turbo_stream.replace
rendered here in advance and appended to the page when you press the button,
which is how a broadcast arrives. Collapse either section first. %>
<%
steps = [ "Find the job", "List who hasn't replied", "Check the bounced address", "Draft the nudges" ]
files = [ "candidates.csv", "bounced.txt", "nudges/grace.md", "nudges/ana.md" ]
plan = lambda do |done|
ai_chat_plan id: "plan_live_plan" do |p|
steps.each_with_index do |title, index|
p.step title, state: index < done ? :completed : (index == done ? :in_progress : :pending)
end
end
end
workspace = ->(count) { ai_chat_workspace(id: "plan_live_files") { |w| files.first(count).each { |f| w.file f } } }
%>
<div class="flex flex-col items-start gap-4">
<button type="button" id="plan_live_next" class="<%= button_classes %>">Send the next update</button>
<div class="w-80 rounded-xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<%= plan.call(0) %>
<%= workspace.call(0) %>
</div>
<template id="plan_live_updates">
<% (1..steps.size).each do |done| %>
<template>
<%= turbo_stream.replace "plan_live_plan", plan.call(done) %>
<%= turbo_stream.replace "plan_live_files", workspace.call(done) %>
</template>
<% end %>
</template>
<script type="module">
const updates = Array.from(document.getElementById("plan_live_updates").content.children)
let next = 0
document.getElementById("plan_live_next").addEventListener("click", () => {
document.body.append(updates[next].content.cloneNode(true))
next = (next + 1) % updates.length
})
</script>
</div>