Conversation
The root of the AI chat components: the scrolling region a conversation's turns are rendered into, and the spacing between them. It follows new content while you're at the bottom, stops when you scroll up, and offers a way back.
A whole conversation
Every piece together: a question, reasoning, a run of tool calls with one still going, a reply that quotes a record and proposes something, a permission request, and the plan and workspace beside it. Scroll up inside it to see the jump-to-latest button.
Thought for 4s
The invitations were sent on Monday. I need the job first, then the invitations without a reply, then anything that bounced.
I'll check the invitations sent for that job this week.
Donefind_jobPlatform EngineerTook210ms
{
"query": "Platform Engineer",
"status": "open"
}{
"id": "job_8f2",
"title": "Platform Engineer",
"company": "Acme",
"invitations": 12
}Donesearch_invitationsunanswered · last 7 daysPartly failed1 failedTook2s
{
"job": "job_8f2",
"replied": false,
"since": "7 days ago"
}{
"invitations": [
"Ana Silva",
"Grace Okafor",
"Tom Reyes"
],
"errors": [
{
"id": "inv_31",
"error": "timed out"
}
]
}check_emailtom.reyes@example.comRunning forThree candidates haven't replied since Monday:
- Ana Silva opened hers twice but hasn't answered.
- Grace Okafor hasn't opened hers.
- Tom Reyes's bounced — I'm checking the address now.
Ana did say something relevant last week:
I'd nudge Grace today and leave Ana until after she's back.
send_email to: grace.okafor@example.com
Send Grace a short nudge about the Platform Engineer role.
She hasn't opened the invitation, and the role closes on Friday.
Plan
2/5
- DoneFind the job
- DoneList who hasn't replied
- In progressCheck the bounced address
- Nudge Grace Waiting on you
- To doRemind me about Ana on the 19th
Workspace
3
drafts
- nudge-grace.md
captures
- tom-bounce.eml
- unanswered.csv
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_16rem]">
<div class="flex min-w-0 flex-col gap-4">
<div id="showcase_scroll" class="h-[38rem] overflow-y-auto pr-2">
<%= ai_chat id: "showcase_entries", scroller: "#showcase_scroll" do %>
<%= ai_chat_message "Who hasn't replied to the Platform Engineer invitations yet?", role: :user, id: "showcase_q1" %>
<%= ai_chat_message role: :assistant, id: "showcase_a1" do |turn| %>
<% turn.reasoning duration: 4 do %>
<p>The invitations were sent on Monday. I need the job first, then the invitations without a reply, then
anything that bounced.</p>
<% end %>
<p>I'll check the invitations sent for that job this week.</p>
<% end %>
<%= ai_chat_tool_call name: "find_job", state: :done, icon: :folder, id: "showcase_t1" do |tool| %>
<% tool.summary "Platform Engineer" %>
<% tool.timing duration: 0.21 %>
<% tool.asked({ query: "Platform Engineer", status: "open" }) %>
<% tool.answered({ id: "job_8f2", title: "Platform Engineer", company: "Acme", invitations: 12 }) %>
<% end %>
<div data-ai-chat-timeline="gap" hidden></div>
<%= ai_chat_tool_call name: "search_invitations", state: :done, icon: :messages_square, id: "showcase_t2", open: true do |tool| %>
<% tool.summary "unanswered · last 7 days" %>
<% tool.timing duration: 1.84 %>
<% tool.failures 1 %>
<% tool.asked({ job: "job_8f2", replied: false, since: "7 days ago" }) %>
<% tool.answered({ invitations: [ "Ana Silva", "Grace Okafor", "Tom Reyes" ], errors: [ { id: "inv_31", error: "timed out" } ] }) %>
<% end %>
<div data-ai-chat-timeline="gap" hidden></div>
<%= ai_chat_tool_call name: "check_email", state: :running, id: "showcase_t3" do |tool| %>
<% tool.summary "tom.reyes@example.com" %>
<% tool.timing started_at: @now - 12 %>
<% tool.progress "Waiting on the mail server" %>
<% end %>
<%= ai_chat_message role: :assistant, id: "showcase_a2" do |turn| %>
<% turn.actions do %>
<%= message_actions for: "showcase_a2" do |bar| %>
<% bar.copy "Three candidates haven't replied." %>
<% bar.action "Try again", "#", icon: :rotate_cw, method: :post %>
<% bar.control { ai_chat_branch_picker index: 2, count: 2, previous: "#" } %>
<% end %>
<% end %>
<p>Three candidates haven't replied since Monday:</p>
<ul>
<li><strong>Ana Silva</strong> opened hers twice but hasn't answered.</li>
<li><strong>Grace Okafor</strong> hasn't opened hers.</li>
<li><strong>Tom Reyes</strong>'s bounced — I'm checking the address now.</li>
</ul>
<p>Ana did say something relevant last week:</p>
<%= ai_chat_citation do |cite| %>
<% cite.avatar %>
<% cite.who "Ana Silva" %>
<% cite.when @now - 6.days, url: "#" %>
<% cite.quote "I'm keen, but I'm travelling until the 18th.\nCould we talk after that?" %>
<% end %>
<%= ai_chat_proposal id: "showcase_fact" do |offer| %>
<% offer.claim "Ana is travelling until the 18th" %>
<% offer.meta "About Ana Silva · from her message on the 10th" %>
<% offer.accept { button_to "Remember", "#", class: button_classes(:primary, size: :small) } %>
<% offer.reject { button_to "Dismiss", "#", class: button_classes(size: :small) } %>
<% end %>
<p>I'd nudge Grace today and leave Ana until after she's back.</p>
<% end %>
<%= ai_chat_permission tool: "send_email", state: :waiting, url: "#", id: "showcase_p1" do |ask| %>
<% ask.argument "to", "grace.okafor@example.com" %>
<% ask.summary { tag.p("Send Grace a short nudge about the Platform Engineer role.") } %>
<% ask.reason { tag.p("She hasn't opened the invitation, and the role closes on Friday.") } %>
<% ask.allow confirm: "Send this email to Grace?" %>
<% ask.refuse %>
<% end %>
<% end %>
</div>
<%= form_with url: "#", id: "showcase_composer" do |form| %>
<%= ai_chat_composer form: form, field: :content, state: :waiting, placeholder: "Reply, or / for a command…", stop_form: "showcase_stop" do |composer| %>
<% composer.attach do %>
<button type="button" class="<%= button_classes(:icon) %>" aria-label="Attach a file" title="Attach a file">
<%= Unmagic::Components::Icons.svg(self, :paperclip) %>
</button>
<% end %>
<% end %>
<% end %>
</div>
<div class="h-fit rounded-xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<%= ai_chat_plan id: "showcase_plan" 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 %>
<% plan.step "Remind me about Ana on the 19th" %>
<% end %>
<%= ai_chat_workspace id: "showcase_workspace" do |workspace| %>
<% workspace.file "unanswered.csv", size: 1_240 %>
<% workspace.file "drafts/nudge-grace.md", size: 812 %>
<% workspace.file "captures/tom-bounce.eml", size: 18_400 %>
<% end %>
</div>
</div>Send a message
The composer posts for real. The question is drawn at once, dimmed, until the server confirms it under the same id; the reply then streams in and settles. Press Stop mid-reply to freeze it.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
Ask about your candidates
This one only knows one answer, but it streams it properly.
<%# The browser has no Action Cable, so the server's reply arrives as a list of
flushes in the response, and this driver appends them one at a time. Each is
the real stream_markdown action; the last is the settled turn's upsert. A
flush for a reply that has been stopped is dropped, as a real one would be
refused by the frozen element. %>
<div class="mx-auto flex max-w-2xl flex-col gap-4">
<div id="demo_scroll" class="h-96 overflow-y-auto pr-2">
<%= ai_chat id: "demo_entries", scroller: "#demo_scroll" do |chat| %>
<% chat.welcome do %>
<%= ai_chat_welcome heading: "Ask about your candidates", composer: "demo_composer" do |welcome| %>
<% welcome.body "This one only knows one answer, but it streams it properly." %>
<% welcome.suggestion "Who hasn't replied yet?", icon: :messages_square %>
<% welcome.suggestion "Draft a nudge for…", icon: :pencil, fill: true, value: "Draft a nudge for " %>
<% end %>
<% end %>
<% end %>
</div>
<%= form_with url: ai_chat_messages_path, scope: :message, id: "demo_composer" do |form| %>
<%= ai_chat_composer form: form, field: :content, placeholder: "Ask something, or / for a command…",
stop_form: "demo_stop" do |composer| %>
<% composer.optimistic id: "message[client_id]", container: "#demo_entries" %>
<% composer.menu do %>
<%= ai_chat_slash_menu above: true do |menu| %>
<% menu.item "summarise", description: "Summarise the conversation so far" %>
<% menu.item "invite", description: "Invite a candidate to a job", arguments: %w[email job] %>
<% menu.item "nudge", description: "Draft a follow-up for someone who hasn't replied", arguments: %w[name] %>
<% end %>
<% end %>
<% end %>
<% end %>
<%= ai_chat_stop_form ai_chat_stop_path, id: "demo_stop" %>
<div id="demo_driver" hidden></div>
<script type="module">
const driver = document.getElementById("demo_driver")
new MutationObserver(() => {
for (const list of driver.querySelectorAll("template[data-flushes]")) {
list.remove()
play(Array.from(list.content.children).filter((child) => child.localName === "template"))
}
}).observe(driver, { childList: true })
async function play(frames) {
for (const frame of frames) {
await new Promise((resolve) => setTimeout(resolve, Number(frame.dataset.delay)))
if (document.getElementById(frame.dataset.target)?.hasAttribute("final")) return
document.body.append(frame.content.cloneNode(true))
}
}
</script>
</div>An empty conversation
chat.welcome shows until the first entry arrives.
What can I help with?
It can search your jobs and candidates, draft messages, and keep track of who has replied.
<div class="mx-auto max-w-2xl">
<%= ai_chat id: "welcome_entries", follow: false do |chat| %>
<% chat.welcome do %>
<%= ai_chat_welcome heading: "What can I help with?", composer: "welcome_composer" do |welcome| %>
<% welcome.body "It can search your jobs and candidates, draft messages, and keep track of who has replied." %>
<% welcome.suggestion "Who hasn't replied yet?", icon: :messages_square %>
<% welcome.suggestion "Which job is getting the most views?", icon: :list_checks %>
<% welcome.suggestion "Create a job at Acme", icon: :pencil %>
<% end %>
<% end %>
<% end %>
</div>