diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 15e5e9752..5e5dc3b6b 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -604,7 +604,7 @@ Credit balance, top-up purchases, and the transaction log are part of `/subscrip ### AI assistant -The dashboard AI assistant is JWT only: sessions are private to the member who started them. The message and approval runs stream over Server-Sent Events. Each tool the assistant runs is gated by the member's own organization permission bits, so the assistant can never do more than the member could by hand. See the [AI assistant](/guides/ai-assistant/) guide. (API-key and OAuth callers reach the same tools through the [MCP server](/api/mcp/) or the [REST agent-tools surface](/api/agent-tools/), each tool gated by its own scope.) +Remie, the dashboard AI assistant, is JWT only: sessions are private to the member who started them. The message and approval runs stream over Server-Sent Events. Each tool Remie runs is gated by the member's own organization permission bits, so Remie can never do more than the member could by hand. See the [Remie](/guides/ai-assistant/) guide. (API-key and OAuth callers reach the same tools through the [MCP server](/api/mcp/) or the [REST agent-tools surface](/api/agent-tools/), each tool gated by its own scope.) | Method | Path | JWT permission | |--------|------|----------------| diff --git a/docs/content/docs/guides/advisor.mdx b/docs/content/docs/guides/advisor.mdx index 8b4455667..dc4b49e5d 100644 --- a/docs/content/docs/guides/advisor.mdx +++ b/docs/content/docs/guides/advisor.mdx @@ -14,7 +14,8 @@ Advice appears on the page where the problem is, not in a separate inbox you hav | On the row | A colored flag on the mailbox or campaign, opening that row's findings in place. This is the main way you meet the Advisor | | Above the list | One summary line ("3 mailboxes could be doing better") that expands to the full set and holds **Fix all**. Stays collapsed unless something is critical or workspace-wide | | On the nav tab | A badge counting critical and high-severity findings only | -| In the assistant | It reads your open recommendations, so "why is this campaign not getting replies" is answered from measurements rather than guesses | +| In Remie | It reads your open recommendations, so "why is this campaign not getting replies" is answered from measurements rather than guesses | +| From Remie | Open findings appear as **Suggested for you** in an empty Remie conversation, and occasionally one rated Needs attention or Critical as a tip under the top-bar blob. See [suggestions and tips](/guides/ai-assistant/#suggestions-and-tips) | A step's copy problem flags its campaign, since a step has no row. When there is nothing to say, nothing renders. @@ -120,4 +121,4 @@ Findings refresh in the background and when you open a page whose advice went st - [Deliverability](/guides/deliverability/) for the underlying measurements and health bands. - [Warmup](/guides/warmup/) for warmup volume and pool standing. -- [AI assistant](/guides/ai-assistant/) for follow-up questions about a recommendation. +- [Remie](/guides/ai-assistant/) for follow-up questions about a recommendation. diff --git a/docs/content/docs/guides/ai-assistant.mdx b/docs/content/docs/guides/ai-assistant.mdx index ff5378afd..900843063 100644 --- a/docs/content/docs/guides/ai-assistant.mdx +++ b/docs/content/docs/guides/ai-assistant.mdx @@ -1,27 +1,27 @@ --- -title: AI assistant -description: The in-product AI assistant that searches your data, edits records, sends messages with your approval, and manages campaigns, mailboxes, and settings, never doing anything your own role could not. +title: Remie, the AI assistant +description: Remie, Warmbly's in-product AI assistant, searches your data, edits records, sends messages with your approval, and manages campaigns, mailboxes, and settings, never doing anything your own role could not. --- -A chat panel in the dashboard that reads across your contacts, campaigns, mailboxes, unified inbox, CRM, team, and settings, and takes almost any action you could take yourself. Answers stream word by word and each tool step appears as it starts. +Remie is Warmbly's AI assistant: a chat panel in the dashboard that reads across your contacts, campaigns, mailboxes, unified inbox, CRM, team, and settings, and takes almost any action you could take yourself. Answers stream word by word and each tool step appears as it starts. -Open it from the spark button or `Cmd/Ctrl + I`. The panel follows you between pages. +Open Remie from the blue blob in the top bar or `Cmd/Ctrl + I`. The panel follows you between pages. The same assistant also answers in Slack, from a DM, a mention or `/warmbly`. See [Slack](/guides/slack/#the-assistant). -The assistant acts as you: only what your role allows, only in your workspace, never another organization's data. Every change asks first, and **sending is never auto-approved**, even with "Always allow" on. +Remie acts as you: only what your role allows, only in your workspace, never another organization's data. Every change asks first, and **sending is never auto-approved**, even with "Always allow" on. -The assistant needs a paid plan; a free trial has no credit allowance and the AI surfaces stay locked. Beyond that, access is the **Use AI** role permission (on for admins and managers, off for viewers); without it the assistant and AI drafting are hidden. Admins can cap per-member spend by day, week, or month. See [AI credits](/guides/ai-credits/) and [Team and roles](/guides/team-roles/). +Remie needs a paid plan; a free trial has no credit allowance and the AI surfaces stay locked. Beyond that, access is the **Use AI** role permission (on for admins and managers, off for viewers); without it Remie and AI drafting are hidden. Admins can cap per-member spend by day, week, or month. See [AI credits](/guides/ai-credits/) and [Team and roles](/guides/team-roles/). ## Conversations and the panel -Conversations are **private to you by default**. An admin can enable **Shared history** under Settings > Workspace > AI assistant, after which every member with Use AI sees, opens, continues, and deletes any conversation, attributed by owner. Turning it on exposes existing conversations, so decide deliberately. Delete individually from the history rail, or **Clear history** to remove every conversation you own. +Conversations are **private to you by default**. An admin can enable **Shared history** under Settings > Workspace > Remie, after which every member with Use AI sees, opens, continues, and deletes any conversation, attributed by owner. Turning it on exposes existing conversations, so decide deliberately. Delete individually from the history rail, or **Clear history** to remove every conversation you own. Conversations run as tabs: open several with **+**, and a run keeps streaming in its own tab while you read another. Expanding gives a full workspace with searchable history grouped by day. -The panel resizes by dragging its inner edge, which also takes the keyboard once focused: arrow keys nudge it (hold `Shift` for a bigger step), `Home` and `End` go to the narrowest and widest the window allows, and `Enter` or a double-click restores the default. It docks to either screen edge. On desktop it also pops out into a floating window (the pop-out button, `Alt + P`, or dragging the header away), remembering position and size. +Docked, the panel resizes by dragging its inner edge, which also takes the keyboard once focused: arrow keys nudge it (hold `Shift` for a bigger step), `Home` and `End` go to the narrowest and widest the window allows, and `Enter` or a double-click restores the default. On desktop Remie opens as a floating window you can drag by its header and resize from any edge; it remembers position and size. Dock it to either screen edge with the dock button or `Alt + P`, and pop it out again the same way or by dragging the header away. | Shortcut | Action | |----------|--------| @@ -30,9 +30,23 @@ The panel resizes by dragging its inner edge, which also takes the keyboard once | `Alt + M` | Minimize | | `?` | All shortcuts | -**Work continues without the panel.** Minimizing docks it to a status bar showing live state: working with a step count, waiting on approval, or holding an unread response. Closing it entirely also keeps runs going, and the spark button shows a pulsing dot while streaming, amber when an approval waits, and solid when a response is ready. +**Work continues without the panel.** Minimizing docks it to a status bar showing live state: working with how long the run has taken, waiting on approval, or holding an unread response. Closing it entirely also keeps runs going, and the blob in the top bar shows the state: it swirls while Remie works, turns amber when an approval waits, flashes green when a run finishes, and carries a dot while a response is unread. -## What you can ask +## Suggestions and tips + +Remie reads what the [Advisor](/guides/advisor/) measures in your workspace and suggests fixes for it, never generic prompts. An empty conversation lists them under **Suggested for you**, one line per kind of problem ("3 new mailboxes are sending at full volume"). Choosing one asks Remie to fix it: it checks the current state, proposes the smallest change, and shows each one as an approval card first. + +Now and then Remie also offers one of these in a small bubble under the blob in the top bar, with **Fix with Remie** and **Not now**. Tips are deliberately rare so they stay worth reading: + +- only problems the Advisor rates **Needs attention** or **Critical** +- at most two a day, at least 30 minutes apart, never in your first moments on a page +- only once you pause: not while you type or scroll, not with a dialog open, and never while the Remie panel is open +- each kind of problem once, unless it gets worse, and one on the page you are on comes first +- dismissing two in a row quiets them for three days, and the bell in the bubble pauses them for a week + +These preferences are yours alone, kept in your browser for each workspace. + +## What you can ask Remie - Find, read, create, and delete contacts, edit fields, labels, subscription, and notes, and bulk-edit - Read a contact's activity timeline and the emails you sent them @@ -55,10 +69,10 @@ It knows what page you are on, so "summarize this campaign" and "draft a reply h Sending is stricter and never auto-approves. The card shows the full message and sending mailbox, and only that exact message goes out. It refuses suppressed addresses. Starting a campaign and enabling an automation carry their own approvals, since both put mail in motion. -**Drafts stay drafts.** Campaigns the assistant builds are never started and automations are left disabled; you get a button to open them in the real editor. Copy it writes is grounded in your workspace [voice profile](/guides/unibox/) and follows the same human-writing rules as the rest of Warmbly. +**Drafts stay drafts.** Campaigns Remie builds are never started and automations are left disabled; you get a button to open them in the real editor. Copy it writes is grounded in your workspace [voice profile](/guides/unibox/) and follows the same human-writing rules as the rest of Warmbly. ## Credits and privacy Each step costs one AI credit, and a request is capped at a set number of steps (shown as the run progresses) so nothing runs away. Remaining credits show at the panel's bottom, and the stop button ends a run anytime. On a self-hosted install with a free or local model, runs are not charged and a "free model" notice appears. -Changes the assistant makes (a created task, an updated contact) appear in the shared activity feed like any other edit, so teammates' views stay current. +Changes Remie makes (a created task, an updated contact) appear in the shared activity feed like any other edit, so teammates' views stay current. diff --git a/docs/content/docs/guides/ai-credits.mdx b/docs/content/docs/guides/ai-credits.mdx index 461bef142..1cc5eee5b 100644 --- a/docs/content/docs/guides/ai-credits.mdx +++ b/docs/content/docs/guides/ai-credits.mdx @@ -25,7 +25,7 @@ Each action reserves a flat **minimum** up front (which also gates an empty bala | Action | Minimum | |--------|---------| | Writing assistant draft, selection edit ("Edit with AI") | 1 | -| AI assistant step | 1 per turn | +| Remie step | 1 per turn | | Reply draft, compose draft, contact research run | 2 | | AI spam analysis of a campaign step, and each Re-check | 2 | | Single-shot automation AI step (classify, extract, generate) | 1 | diff --git a/docs/content/docs/guides/ai-skills.mdx b/docs/content/docs/guides/ai-skills.mdx index 21ddd8c5b..97c577d22 100644 --- a/docs/content/docs/guides/ai-skills.mdx +++ b/docs/content/docs/guides/ai-skills.mdx @@ -1,9 +1,9 @@ --- title: AI skills -description: Teach your AI features how your team works by writing reusable playbooks the assistant, research, and reply drafts follow. +description: Teach your AI features how your team works by writing reusable playbooks Remie, research, and reply drafts follow. --- -AI skills are short playbooks you write once and your AI features reuse everywhere. A skill captures how your team actually works: how you qualify a lead, handle a common objection, or run a first call. The assistant, contact research, and reply drafts all read your enabled skills, so their output follows your process instead of a generic default. +AI skills are short playbooks you write once and your AI features reuse everywhere. A skill captures how your team actually works: how you qualify a lead, handle a common objection, or run a first call. Remie, contact research, and reply drafts all read your enabled skills, so their output follows your process instead of a generic default. Skills live under **Settings, AI skills** and require the **Manage settings** permission. @@ -52,7 +52,7 @@ Description: How to ask for and frame a first call. ## Where skills apply -- **The assistant**: follows relevant skills when it drafts, plans, or answers. +- **Remie**: follows relevant skills when it drafts, plans, or answers. - **Contact research**: uses skills to judge what is worth surfacing about a contact. - **Reply drafts**: writes replies that follow your playbooks. diff --git a/docs/content/docs/guides/connect-mcp-tools.mdx b/docs/content/docs/guides/connect-mcp-tools.mdx index 72e5f6cee..99f383db1 100644 --- a/docs/content/docs/guides/connect-mcp-tools.mdx +++ b/docs/content/docs/guides/connect-mcp-tools.mdx @@ -1,9 +1,9 @@ --- title: Connect MCP tools -description: Give the AI assistant extra tools by connecting an external MCP server, reviewing what it exposes, and turning it on. +description: Give Remie extra tools by connecting an external MCP server, reviewing what it exposes, and turning it on. --- -The AI assistant can use tools from external [MCP](https://modelcontextprotocol.io) servers you connect, on top of Warmbly's built-in ones. Connect your own server or a third-party one, review the tools it offers, and enable it. From then on the assistant can call those tools, always asking you first. +Remie, the AI assistant, can use tools from external [MCP](https://modelcontextprotocol.io) servers you connect, on top of Warmbly's built-in ones. Connect your own server or a third-party one, review the tools it offers, and enable it. From then on Remie can call those tools, always asking you first. Connections live under **Settings, Connections** and require the **Manage settings** permission. @@ -11,28 +11,28 @@ Connections live under **Settings, Connections** and require the **Manage settin Press **Add server** and provide: -- **Name**: how the connection appears, and part of each tool's name in the assistant. +- **Name**: how the connection appears, and part of each tool's name in Remie. - **Server URL**: an `https` URL to a streamable-HTTP MCP endpoint. Only public https URLs are allowed; internal or private addresses are rejected. - **Authentication**: **None**, or a **Bearer token**. A bearer token is stored encrypted with your workspace key and is never shown again or returned by the API. -When you connect, Warmbly reaches out to the server, runs the MCP handshake, and lists the tools it offers. The connection starts **disabled** so nothing is exposed to the assistant until you have looked at the tools. +When you connect, Warmbly reaches out to the server, runs the MCP handshake, and lists the tools it offers. The connection starts **disabled** so nothing is exposed to Remie until you have looked at the tools. ## Reviewing and enabling -Each connected server shows the tools Warmbly discovered. Read them, and when you are comfortable, flip the toggle to enable the server. Only enabled servers' tools reach the assistant. +Each connected server shows the tools Warmbly discovered. Read them, and when you are comfortable, flip the toggle to enable the server. Only enabled servers' tools reach Remie. If the server could not be reached, the connection shows the error instead of a tool list. Fix the URL or token and press the refresh icon to try discovery again. Refresh also picks up any new tools a server has added. -## How the assistant uses external tools +## How Remie uses external tools -External tools behave like Warmbly's own write actions with one important difference: they are **always** approval-gated. The assistant can never run an external tool automatically, even if you have used "always allow" for built-in actions. Every external tool call shows you what it will do and waits for your approval. +External tools behave like Warmbly's own write actions with one important difference: they are **always** approval-gated. Remie can never run an external tool automatically, even if you have used "always allow" for built-in actions. Every external tool call shows you what it will do and waits for your approval. -Each tool appears in the assistant namespaced by your connection, so it is always clear which server a tool belongs to. +Each tool appears in Remie namespaced by your connection, so it is always clear which server a tool belongs to. ## Removing a connection -Disconnect a server any time from its card. Its tools are removed from the assistant immediately, and the stored credentials are deleted. +Disconnect a server any time from its card. Its tools are removed from Remie immediately, and the stored credentials are deleted. -An enabled MCP server's tools run with the assistant, on behalf of the member using it. Only connect servers you trust, and prefer scoped tokens. Because external tool calls always require your approval, nothing runs without you seeing it first. +An enabled MCP server's tools run with Remie, on behalf of the member using it. Only connect servers you trust, and prefer scoped tokens. Because external tool calls always require your approval, nothing runs without you seeing it first. diff --git a/docs/content/docs/guides/contacts-crm.mdx b/docs/content/docs/guides/contacts-crm.mdx index 5e941dece..b947b7e88 100644 --- a/docs/content/docs/guides/contacts-crm.mdx +++ b/docs/content/docs/guides/contacts-crm.mdx @@ -173,7 +173,7 @@ Every contact records its first-touch source: how it entered the workspace, with | API | Created through the API with an API key | The key's name | | Form | Submitted a hosted [form](/guides/forms/) | The form | | Automation | Written by an automation's **Create or update contact** action | The automation | -| AI assistant | Created by the assistant on your behalf | | +| Remie | Created by Remie on your behalf | | | Unknown | Created before Warmbly recorded sources | | Only new contacts get a source. An import or API call that matches an existing contact updates its fields and leaves the original source in place, so an address that arrived by hand and later turned up in a file still reads as manual. diff --git a/docs/content/docs/guides/team-roles.mdx b/docs/content/docs/guides/team-roles.mdx index 4ca73b9b3..598fdddbf 100644 --- a/docs/content/docs/guides/team-roles.mdx +++ b/docs/content/docs/guides/team-roles.mdx @@ -59,7 +59,7 @@ Roles are workspace data. Every workspace starts with three seeded roles that ar | | Manage sequences | Edit step content and spacing | | | View analytics | Deliverability and engagement reports | | | Use integrations | Push contacts and deals to connected tools | -| | Use AI | The assistant and AI drafting, spending shared credits | +| | Use AI | Remie and AI drafting, spending shared credits | | **People** | Manage team | Invite, remove, re-role members | | | Transfer ownership | Hand ownership to another member | | **Sending** | Manage mailboxes | Connect, disconnect, configure senders | diff --git a/docs/content/docs/guides/workspace-export-import.mdx b/docs/content/docs/guides/workspace-export-import.mdx index 940cccf8e..72a36d992 100644 --- a/docs/content/docs/guides/workspace-export-import.mdx +++ b/docs/content/docs/guides/workspace-export-import.mdx @@ -22,7 +22,7 @@ The data is split into groups. Every export includes **Workspace**; the rest are | Campaigns | Campaigns, folders, sequences, senders, linked segments, attachments, the email image library, per-campaign settings, each lead's step progress with its per-link clicks and per-event opens, the colleagues [copied on each lead](/guides/campaigns/#copying-colleagues-on-one-lead), [placement monitors](/guides/placement-tests/#campaign-placement-monitors), and the [unsubscribe links](/guides/unsubscribe/) already in recipients' inboxes | | CRM | Pipelines, deals, tasks, and meeting bookings | | Automations | Automations, connected integrations, lead sync sources, and the workspace's CRM mode: whether it runs on [HubSpot](/guides/hubspot/), its setup choices, the owner matches, and which Warmbly record is which HubSpot record | -| Assistant | Assistant sessions and messages (including conversations held in [Slack](/guides/slack/)), skills, MCP servers, and AI settings | +| Assistant | Remie sessions and messages (including conversations held in [Slack](/guides/slack/)), skills, MCP servers, and AI settings | | Warmup | Warmup participation, routing rules, statistics and inbox placement history, appeals, and the standing of every penalised address, current or removed, so a move is not a way past a block | | Inbox | Unified inbox threads, message bodies, conversation labels, mailbox sync state, and completed automatic-tagging verdicts with their raw probabilities | | Send history | Queued and completed send tasks with their payloads | diff --git a/internal/app/aiagent/service.go b/internal/app/aiagent/service.go index 8fb3b064c..18f800418 100644 --- a/internal/app/aiagent/service.go +++ b/internal/app/aiagent/service.go @@ -530,7 +530,7 @@ func (s *service) runLoop(ctx context.Context, inv aitools.Invocation, sess *mod lastRemaining = bal } } - emit(StreamEvent{Type: evError, Code: "provider_error", Message: "The assistant hit an error. Please try again.", CreditsRemaining: lastRemaining}) + emit(StreamEvent{Type: evError, Code: "provider_error", Message: "Remie hit an error. Please try again.", CreditsRemaining: lastRemaining}) return nil } @@ -567,7 +567,7 @@ func (s *service) runLoop(ctx context.Context, inv aitools.Invocation, sess *mod func (s *service) emitStop(emit func(StreamEvent), reason string, remaining int) { switch reason { case "out_of_credits": - emit(StreamEvent{Type: evError, Code: "insufficient_credits", Message: "You're out of AI credits. Add more to keep using the assistant.", CreditsRemaining: remaining}) + emit(StreamEvent{Type: evError, Code: "insufficient_credits", Message: "You're out of AI credits. Add more to keep using Remie.", CreditsRemaining: remaining}) case "usage_cap": emit(StreamEvent{Type: evError, Code: "usage_cap_exceeded", Message: "AI usage limit reached, please try again later.", CreditsRemaining: remaining}) default: @@ -623,7 +623,7 @@ func (s *service) persist(ctx context.Context, orgID, userID, sessionID uuid.UUI func (s *service) systemPrompt(sess *models.AgentSession, voiceBlock, skillsBlock string) string { var b strings.Builder - b.WriteString(`You are Warmbly's in-product AI assistant. You help the user manage their cold email outreach: contacts, campaigns, the unified inbox, CRM, and automations. Use the available tools to look things up and take actions. Be concise and specific. + b.WriteString(`You are Remie, Warmbly's in-product AI assistant. You help the user manage their cold email outreach: contacts, campaigns, the unified inbox, CRM, and automations. Use the available tools to look things up and take actions. Be concise and specific. Rules: - Read tools run automatically. Write actions (creating or changing data) require the user's approval, which the product handles for you; just call the tool and it will be gated. diff --git a/qa/flows/remie.flow.ts b/qa/flows/remie.flow.ts new file mode 100644 index 000000000..adae100d9 --- /dev/null +++ b/qa/flows/remie.flow.ts @@ -0,0 +1,53 @@ +import type { Page } from "@playwright/test"; +import { env } from "../lib/env.ts"; +import { expect, test } from "../lib/proof.ts"; + +// The Advisor evaluates in the background; ask for a run so a fresh seed has findings to suggest. +async function evaluateAdvisor(page: Page) { + await page.evaluate(async (api) => { + const token = JSON.parse(localStorage.getItem("auth_token") ?? "{}").access_token; + await fetch(`${api}/v1/advisor/refresh`, { method: "POST", headers: { Authorization: `Bearer ${token}` } }); + }, env.apiURL); +} + +test("Remie opens as a floating window and suggests fixes for what the Advisor found", async ({ page, proof }) => { + await page.goto("/app/emails"); + await expect(page.getByRole("row", { name: /dev\.outbound@warmbly\.test/ })).toBeVisible(); + await evaluateAdvisor(page); + await page.reload(); + await expect(page.getByRole("row", { name: /dev\.outbound@warmbly\.test/ })).toBeVisible(); + await proof.chapter("Remie", "The assistant opens as a floating window with fixes for what the Advisor measured"); + + await page.getByRole("button", { name: "Ask Remie" }).click(); + await expect(page.getByText("Hi, I'm Remie")).toBeVisible(); + await expect(page.getByText("Suggested for you")).toBeVisible(); + await proof.dwell(1200); + await proof.shot("remie-suggestions", { caption: "Remie floats over the page and suggests fixes found in this workspace" }); +}); + +test("A Remie run shows live status, folded tool steps, an approval and the answer", async ({ page, proof }) => { + await page.goto("/app/emails"); + await expect(page.getByRole("row", { name: /dev\.outbound@warmbly\.test/ })).toBeVisible(); + await page.getByRole("button", { name: "Ask Remie" }).click(); + await expect(page.getByText("Hi, I'm Remie")).toBeVisible(); + await proof.chapter("A Remie run", "A scripted run (dev builds only) through the same rendering a live run uses"); + + const composer = page.getByPlaceholder(/Ask Remie about/); + await composer.pressSequentially("/demo", { delay: 60 }); + await composer.press("Enter"); + await expect(page.getByText("Reading campaign stats").first()).toBeVisible(); + await proof.shot("remie-working", { + caption: "While Remie works: a live status with a timer, and each tool step as it runs", + ignore: [page.getByText(/^\d+\.\ds$/)], + }); + + const approve = page.getByRole("button", { name: "Approve", exact: true }); + await expect(approve).toBeVisible({ timeout: 20_000 }); + await proof.dwell(1500); + await proof.shot("remie-approval", { caption: "Steps fold away, the answer streams in, and a change waits for approval" }); + + await approve.click(); + await expect(page.getByText(/is paused and nothing else was changed/)).toBeVisible({ timeout: 15_000 }); + await proof.dwell(1200); + await proof.shot("remie-done", { caption: "Approved: the change runs and Remie reports back" }); +}); diff --git a/web/src/app/app/settings/ai-skills/page.tsx b/web/src/app/app/settings/ai-skills/page.tsx index 4b291291f..0e1bde20c 100644 --- a/web/src/app/app/settings/ai-skills/page.tsx +++ b/web/src/app/app/settings/ai-skills/page.tsx @@ -42,7 +42,7 @@ export default function SkillsSettingsPage() { return ( = { writing_assistant: "Writing assistant", writing_edit: "Selection edits", reply_draft: "Reply drafts", - agent_iteration: "Dashboard assistant", + agent_iteration: "Remie", inbox_agent_draft: "Inbox agent", research_run: "Contact research", automation_ai: "Automation AI", diff --git a/web/src/app/app/settings/billing/CreditsCard.tsx b/web/src/app/app/settings/billing/CreditsCard.tsx index 8079a2cec..11cf0915c 100644 --- a/web/src/app/app/settings/billing/CreditsCard.tsx +++ b/web/src/app/app/settings/billing/CreditsCard.tsx @@ -56,7 +56,7 @@ export default function CreditsCard({ isPaid }: { isPaid: boolean }) { return (
{credits.isPending ? (
@@ -251,7 +251,7 @@ function describeContext(row: CreditTransaction): string { if (c.contact_email) parts.push(c.contact_email); if (c.automation_name || c.automation_id) parts.push(`Automation “${c.automation_name || c.automation_id}”`); if (c.thread_id) parts.push(`thread ${c.thread_id.slice(0, 8)}`); - if (c.session_id) parts.push("assistant session"); + if (c.session_id) parts.push("Remie session"); if (c.detail) parts.push(c.detail); if (row.actor_user_id) parts.push("triggered by a teammate"); return parts.join(" · "); @@ -264,7 +264,7 @@ function describeReason(reason: string): string { writing_assistant_refund: "Writing assistant refund", writing_edit: "Selection edit", writing_edit_refund: "Selection edit refund", - agent_iteration: "AI assistant", + agent_iteration: "Remie", reply_draft: "Reply draft", research_run: "Contact research", automation_ai: "Automation AI step", @@ -273,7 +273,7 @@ function describeReason(reason: string): string { campaign_ai_refund: "Campaign switch refund", campaign_ai_search: "Campaign switch web search", reply_draft_refund: "Reply draft refund", - agent_iteration_refund: "AI assistant refund", + agent_iteration_refund: "Remie refund", inbox_agent_draft: "Inbox agent", spam_analysis: "Spam analysis", spam_analysis_refund: "Spam analysis refund", diff --git a/web/src/app/app/settings/connections/page.tsx b/web/src/app/app/settings/connections/page.tsx index 1eb032f2d..f4a7396e6 100644 --- a/web/src/app/app/settings/connections/page.tsx +++ b/web/src/app/app/settings/connections/page.tsx @@ -40,7 +40,7 @@ export default function ConnectionsSettingsPage() { return ( ) : rows.length === 0 ? (

- No connections yet. Add an MCP server to extend what the assistant can do. + No connections yet. Add an MCP server to extend what Remie can do.

) : (
@@ -89,7 +89,7 @@ function ServerCard({ server, canManage }: { server: MCPServer; canManage: boole } } function remove() { - confirm.show(`Disconnect "${server.name}"? Its tools will be removed from the assistant.`, async () => { + confirm.show(`Disconnect "${server.name}"? Its tools will be removed from Remie.`, async () => { await del.mutateAsync(server.id); toast.success("Disconnected"); }); diff --git a/web/src/app/app/settings/workspace/page.tsx b/web/src/app/app/settings/workspace/page.tsx index 694322cc5..335baca09 100644 --- a/web/src/app/app/settings/workspace/page.tsx +++ b/web/src/app/app/settings/workspace/page.tsx @@ -244,7 +244,7 @@ function WorkspaceSettings({ org: currentOrg }: { org: StoreOrganization | null
Date.now()); + React.useEffect(() => { + const t = window.setInterval(() => setNow(Date.now()), 100); + return () => window.clearInterval(t); + }, []); + return ( + + {formatElapsed(Math.max(0, now - since))} + + ); +} + +// The run's status line: what the agent is doing right now, how long it has +// been at it, and how far into its step budget it is. +export function WorkingStatus({ + label, + since, + step = 0, + budget = 0, +}: { + label: string; + since?: number | null; + step?: number; + budget?: number; +}) { + return ( + + + + + {label} + + + {since != null && } + {step > 0 && budget > 0 && ( + + step {step}/{budget} + + )} + + ); +} + +function StepRow({ step }: { step: AgentToolStep }) { + const detail = step.result || step.argsSummary; + return ( + + {step.done ? ( + + + + ) : ( + + )} + + {toolLabel(step.tool, step.done)} + + {detail && ( + + {detail} + + )} + + ); +} + +// A run of consecutive tool calls. One call renders as a single row; several +// fold under a header that stays open while the agent works and collapses to +// a one-line summary once it moves on, unless the user has toggled it. +export function ActivityTrace({ + steps, + live, + onOpen, +}: { + steps: AgentToolStep[]; + live: boolean; + onOpen: (url: string) => void; +}) { + const [userOpen, setUserOpen] = React.useState(null); + const running = steps.some((s) => !s.done); + const multi = steps.length > 1; + const open = !multi || (userOpen ?? (running || live)); + const current = steps.find((s) => !s.done); + const doneCount = steps.filter((s) => s.done).length; + const summary = Array.from(new Set(steps.map((s) => toolLabel(s.tool)))).join(", "); + const drafts = steps.filter( + (s) => + s.done && + s.openURL && + (s.entityType === "campaign" || s.entityType === "automation"), + ); + + return ( +
+ {multi && ( + + )} +
+
+
+ {multi && ( + + )} + {steps.map((s) => ( + + ))} +
+
+
+ {drafts.map((s) => ( + onOpen(s.openURL!)} + className="mt-1.5 h-7 px-2.5 rounded-md bg-white border border-slate-200 hover:border-sky-400 hover:text-sky-700 text-[12px] text-slate-700 inline-flex items-center gap-1.5 transition-colors" + > + + Open {s.entityType === "campaign" ? "campaign" : "automation"} draft + + ))} +
+ ); +} + +// The pause a write or send asks for. Nothing runs until one of the buttons +// is pressed; "always allow" is offered for writes only, never for sends. +export function ApprovalCard({ + pending, + onDecide, +}: { + pending: AgentPending; + onDecide: (d: "approve" | "deny" | "always_allow") => void; +}) { + const isSend = pending.risk === "send"; + return ( + +
+ + + {isSend ? "Ready to send" : "Waiting for your approval"} + + + {isSend ? "Sends email" : "Changes data"} + +
+
+
+ + {toolAction(pending.tool)} +
+ {pending.argsSummary && ( +
+ {pending.argsSummary} +
+ )} +
+
+ + + {!isSend && ( + + )} +
+
+ ); +} diff --git a/web/src/components/app/agent/AgentDevTools.tsx b/web/src/components/app/agent/AgentDevTools.tsx new file mode 100644 index 000000000..482f692aa --- /dev/null +++ b/web/src/components/app/agent/AgentDevTools.tsx @@ -0,0 +1,28 @@ +// Dev-only tools in Remie's empty state: a scripted demo run, so the run UI +// can be seen without an AI provider. Lazy-loaded behind import.meta.env.DEV, +// so none of this reaches a production build. + +import { PlayIcon } from "lucide-react"; + +export default function AgentDevTools({ onDemo }: { onDemo: () => void }) { + return ( +
+
+ Dev preview +
+
+ + + or send /demo in any chat + +
+
+ ); +} diff --git a/web/src/components/app/agent/AgentMark.tsx b/web/src/components/app/agent/AgentMark.tsx index 191244e50..4d3c6c53e 100644 --- a/web/src/components/app/agent/AgentMark.tsx +++ b/web/src/components/app/agent/AgentMark.tsx @@ -1,12 +1,171 @@ -// The assistant's logomark: a large spark with a small companion spark, the -// glyph people read as "AI". Filled and geometric (crisper than the outlined -// lucide Sparkles), single-color so it takes whatever text color it is given -// and reads as product chrome rather than a bolted-on AI widget. -export default function AgentMark({ className }: { className?: string }) { +// The assistant's mark. The icon is a soft blue blob with light swirling inside: +// it breathes at rest, squishes when the pointer arrives (its shine follows the +// pointer), boops on each keystroke when it `listen`s, swirls faster while a +// run works, hops in amber while a run waits on approval, and bounces in green +// when a run finishes. `variant="bare"` is a pixel spark for inline loaders. +// Motion lives in global.css (.amo). + +import React from "react"; +import { useReducedMotion } from "framer-motion"; +import { cn } from "@/lib/utils"; +import { subscribeAgentPulse } from "./agentPulse"; + +export type AgentMarkState = "idle" | "thinking" | "attention"; + +type Props = { + size?: number; + state?: AgentMarkState; + // muted greys a resting mark, for one that should not compete. + tone?: "color" | "muted"; + // Boop on composer keystrokes (pulseAgent). + listen?: boolean; + // Whether a run ending now succeeded; only then does the mark celebrate. + celebrate?: boolean; + // blob is the assistant's icon; bare is a pixel spark, for inline loaders. + variant?: "blob" | "bare"; + className?: string; +}; + +export default function AgentMark({ variant = "blob", ...props }: Props) { + return variant === "bare" ? : ; +} + +// True for a moment after a run finishes well, so the mark can celebrate. +function useJustFinished(state: AgentMarkState, celebrate: boolean): boolean { + const [done, setDone] = React.useState(false); + const prev = React.useRef(state); + React.useEffect(() => { + if (prev.current === "thinking" && state === "idle" && celebrate) setDone(true); + prev.current = state; + }, [state, celebrate]); + React.useEffect(() => { + if (!done) return; + const t = window.setTimeout(() => setDone(false), 1400); + return () => window.clearTimeout(t); + }, [done]); + return done && state === "idle"; +} + +// Restarts a one-shot CSS animation class without a re-render. +function replay(el: HTMLElement | null, cls: string) { + if (!el) return; + el.classList.remove(cls); + void el.offsetWidth; + el.classList.add(cls); +} + +function BlobMark({ + size = 16, + state = "idle", + tone = "color", + listen = false, + celebrate = true, + className, +}: Omit) { + const reduced = useReducedMotion(); + const ref = React.useRef(null); + const done = useJustFinished(state, celebrate); + + React.useEffect(() => { + if (!listen || reduced) return; + return subscribeAgentPulse(() => replay(ref.current, "amo-boop")); + }, [listen, reduced]); + + // Squish on arrival and let the shine follow the pointer. Written straight + // to the element, so moving the cursor never re-renders. + React.useEffect(() => { + const el = ref.current; + if (!el || reduced) return; + const host = (el.closest(".group") as HTMLElement | null) ?? el; + const enter = () => replay(el, "amo-jelly"); + const move = (e: PointerEvent) => { + const r = el.getBoundingClientRect(); + const x = (e.clientX - r.left) / r.width; + const y = (e.clientY - r.top) / r.height; + el.style.setProperty("--amo-sx", `${Math.min(62, Math.max(8, x * 100 - 15))}%`); + el.style.setProperty("--amo-sy", `${Math.min(58, Math.max(8, y * 100 - 11))}%`); + }; + const leave = () => { + el.style.removeProperty("--amo-sx"); + el.style.removeProperty("--amo-sy"); + }; + const end = (e: AnimationEvent) => { + if (e.animationName === "amo-jelly") el.classList.remove("amo-jelly"); + if (e.animationName === "amo-boop") el.classList.remove("amo-boop"); + }; + host.addEventListener("pointerenter", enter); + host.addEventListener("pointermove", move); + host.addEventListener("pointerleave", leave); + el.addEventListener("animationend", end); + return () => { + host.removeEventListener("pointerenter", enter); + host.removeEventListener("pointermove", move); + host.removeEventListener("pointerleave", leave); + el.removeEventListener("animationend", end); + }; + }, [reduced]); + + const look = done ? "done" : tone === "muted" && state === "idle" ? "muted" : state; return ( - - - - + + + + + + + ); +} + +// '#' lit, '+' half lit, '.' off. Row-major, top row first. +function pattern(rows: string[]): number[] { + return rows + .join("") + .split("") + .map((c) => (c === "#" ? 1 : c === "+" ? 0.62 : 0)); +} + +const SPARK = pattern([".+.", "+#+", ".+."]); +// The outer ring, clockwise from the top left. +const RING = [0, 1, 2, 5, 8, 7, 6, 3]; + +function PixelMark({ size = 16, state = "idle", tone = "color", celebrate = true, className }: Omit) { + const reduced = useReducedMotion(); + const done = useJustFinished(state, celebrate); + const cell = size * 0.26; + const gap = Math.max(0.5, size * 0.1); + const look = done ? "done" : tone === "muted" && state === "idle" ? "muted" : state; + return ( + + + {SPARK.map((base, i) => { + let o = base; + let animation: string | undefined; + if (state === "thinking" && !reduced) { + // A light chases around the ring. + const r = RING.indexOf(i); + o = r === -1 ? 1 : 0.2; + if (r !== -1) animation = `amo-chase 0.88s linear ${r * 0.11}s infinite`; + } else if (state === "attention" && !reduced && o > 0) { + animation = "amo-pulse 1.2s ease-in-out infinite"; + } + return ( + + ); + })} + + ); } diff --git a/web/src/components/app/agent/AgentPanel.tsx b/web/src/components/app/agent/AgentPanel.tsx index 92e2ade41..295e8c258 100644 --- a/web/src/components/app/agent/AgentPanel.tsx +++ b/web/src/components/app/agent/AgentPanel.tsx @@ -19,12 +19,7 @@ import { ArrowDownIcon, SquareIcon, PlusIcon, - CheckIcon, - Loader2Icon, - WrenchIcon, - ExternalLinkIcon, AlertTriangleIcon, - ShieldQuestionIcon, Maximize2Icon, Minimize2Icon, MinusIcon, @@ -55,7 +50,7 @@ import { type AgentTab, type AgentTurn, type AgentToolStep, - type AgentPending, + type AgentBlock, } from "@/stores/slices/agentSlice"; import createAgentSession from "@/lib/api/client/app/agent/createAgentSession"; import deleteAgentSession from "@/lib/api/client/app/agent/deleteAgentSession"; @@ -65,6 +60,9 @@ import streamAgentRun from "@/lib/api/client/app/agent/streamAgentRun"; import useAgentSessions from "@/lib/api/hooks/app/agent/useAgentSessions"; import Markdown from "./Markdown"; import AgentMark from "./AgentMark"; +import { ActivityTrace, ApprovalCard, Elapsed, WorkingStatus } from "./AgentActivity"; +import { pulseAgent } from "./agentPulse"; +import RemieSuggestions from "./RemieSuggestions"; import { Kbd } from "@/components/ui/shortcut-tooltip"; import type { AgentStreamEvent, @@ -75,6 +73,12 @@ import type { // Per-run abort controllers, keyed by tab. Kept module-level (not serializable) // so the store stays clean; a page reload drops them along with the run. const aborts = new Map(); +// When each tab's current run began, for the elapsed timer. Same lifetime as aborts. +const runStarts = new Map(); +// Tabs playing the dev demo, whose approvals continue the script instead of +// calling the server. +const demoTabs = new Set(); +const demoStarting = new Set(); let mid = 0; const nextId = () => `m${++mid}`; @@ -123,6 +127,7 @@ export default function AgentPanel() { const floatRect = useAppStore((s) => s.agentFloatRect); const tabs = useAppStore((s) => s.agentTabs); const activeKey = useAppStore((s) => s.agentActiveKey); + const lastRunOk = useAppStore((s) => s.agentLastRunOk); const visible = open && !minimized; // Floating is desktop-only; below sm the panel stays a full-width sheet. @@ -278,6 +283,17 @@ export default function AgentPanel() { .finally(() => hydrating.current.delete(key)); }, [activeTab]); + // A question handed over from elsewhere (askRemie) goes out as soon as the + // panel is showing a tab that can take it. + const queued = useAppStore((s) => s.agentQueuedPrompt); + React.useEffect(() => { + if (!queued || !visible || !activeTab) return; + if (!activeTab.hydrated || activeTab.running || activeTab.pending) return; + useAppStore.getState().agentQueuePrompt(null); + void send(queued); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queued, visible, activeTab?.key, activeTab?.hydrated, activeTab?.running, activeTab?.pending]); + // Follow new content only while pinned to the bottom. React.useEffect(() => { if (pinned) scrollToBottom(); @@ -289,86 +305,141 @@ export default function AgentPanel() { setPinned(el.scrollHeight - el.scrollTop - el.clientHeight < 48); } + // handleEvent applies one stream event to a tab. Live SSE runs and the + // dev demo both feed it, so the demo exercises the real rendering path. + function handleEvent(tabKey: string, ev: AgentStreamEvent) { + const s = useAppStore.getState(); + if (ev.type === "iteration") { + s.agentPatchTab(tabKey, { + ...(typeof ev.credits_remaining === "number" + ? { credits: ev.credits_remaining } + : {}), + ...(typeof ev.budget === "number" ? { budget: ev.budget } : {}), + ...(typeof ev.iteration === "number" + ? { iteration: ev.iteration } + : {}), + ...(ev.free_model ? { freeModel: true } : {}), + }); + return; + } + if (ev.type === "approval_required") { + s.agentPatchTab(tabKey, { + pending: { + toolCallId: ev.tool_call_id || "", + tool: ev.tool || "", + risk: ev.risk || "write", + argsSummary: ev.args_summary, + }, + }); + return; + } + if (ev.type === "done") { + if (typeof ev.credits_remaining === "number") { + s.agentPatchTab(tabKey, { credits: ev.credits_remaining }); + } + return; + } + s.agentUpdateTab(tabKey, (t) => ({ + ...t, + turns: foldEvent(t.turns, ev), + })); + if (ev.type === "error" && typeof ev.credits_remaining === "number") { + s.agentPatchTab(tabKey, { credits: ev.credits_remaining }); + } + } + + function beginRun(tabKey: string): AbortController { + if (!runStarts.has(tabKey)) runStarts.set(tabKey, Date.now()); + useAppStore + .getState() + .agentPatchTab(tabKey, { running: true, pending: null, iteration: 0 }); + const ac = new AbortController(); + aborts.set(tabKey, ac); + return ac; + } + + function finishRun(tabKey: string, aborted: boolean) { + aborts.delete(tabKey); + runStarts.delete(tabKey); + const st = useAppStore.getState(); + // Stopped or ended on an error: the marks must not celebrate it. + const tab = st.agentTabs.find((t) => t.key === tabKey); + const last = tab?.turns[tab.turns.length - 1]; + const failed = last?.blocks[last.blocks.length - 1]?.kind === "error"; + st.setAgentLastRunOk(!aborted && !failed); + st.agentPatchTab(tabKey, { running: false }); + // Finished while the user wasn't looking at this tab: flag it so the + // tab dot, dock, and header icon can say a response is ready. + if (!st.aiAssistantOpen || st.agentMinimized || st.agentActiveKey !== tabKey) { + st.agentPatchTab(tabKey, { unseen: true }); + } + } + async function runStream( tabKey: string, path: string, body: Record, ) { - const store = useAppStore.getState(); - store.agentPatchTab(tabKey, { running: true, pending: null, iteration: 0 }); - const ac = new AbortController(); - aborts.set(tabKey, ac); + const ac = beginRun(tabKey); try { - await streamAgentRun( - path, - body, - (ev) => { - const s = useAppStore.getState(); - if (ev.type === "iteration") { - s.agentPatchTab(tabKey, { - ...(typeof ev.credits_remaining === "number" - ? { credits: ev.credits_remaining } - : {}), - ...(typeof ev.budget === "number" - ? { budget: ev.budget } - : {}), - ...(typeof ev.iteration === "number" - ? { iteration: ev.iteration } - : {}), - ...(ev.free_model ? { freeModel: true } : {}), - }); - return; - } - if (ev.type === "approval_required") { - s.agentPatchTab(tabKey, { - pending: { - toolCallId: ev.tool_call_id || "", - tool: ev.tool || "", - risk: ev.risk || "write", - argsSummary: ev.args_summary, - }, - }); - return; - } - if (ev.type === "done") { - if (typeof ev.credits_remaining === "number") { - s.agentPatchTab(tabKey, { - credits: ev.credits_remaining, - }); - } - return; - } - s.agentUpdateTab(tabKey, (t) => ({ - ...t, - turns: foldEvent(t.turns, ev), - })); - if ( - ev.type === "error" && - typeof ev.credits_remaining === "number" - ) { - s.agentPatchTab(tabKey, { credits: ev.credits_remaining }); - } - }, - ac.signal, - ); + await streamAgentRun(path, body, (ev) => handleEvent(tabKey, ev), ac.signal); } finally { - aborts.delete(tabKey); - const st = useAppStore.getState(); - st.agentPatchTab(tabKey, { running: false }); - // Finished while the user wasn't looking at this tab: flag it so the - // tab dot, dock, and header icon can say a response is ready. - if (!st.aiAssistantOpen || st.agentMinimized || st.agentActiveKey !== tabKey) { - st.agentPatchTab(tabKey, { unseen: true }); - } + finishRun(tabKey, ac.signal.aborted); } } - async function send() { + // Dev only: play a scripted run through handleEvent (see agentDemo.ts). + async function playDemo(part: "open" | "approve" | "deny", tabKey = activeKey) { + if (!import.meta.env.DEV || !tabKey || demoStarting.has(tabKey)) return; + const tab = useAppStore.getState().agentTabs.find((t) => t.key === tabKey); + if (!tab || tab.running) return; + // Held across the import below, so a double click starts one demo. + demoStarting.add(tab.key); + const { DEMO_PROMPT, demoRun } = await import("./agentDemo").finally(() => + demoStarting.delete(tab.key), + ); + if (part === "open") { + demoTabs.add(tab.key); + useAppStore.getState().agentUpdateTab(tab.key, (t) => ({ + ...t, + draft: "", + title: t.title === "New chat" ? "Demo run" : t.title, + turns: [ + ...t.turns, + { id: nextId(), role: "user", blocks: [{ kind: "text", text: DEMO_PROMPT }] }, + ], + })); + } + runStarts.set(tab.key, Date.now()); + const ac = beginRun(tab.key); + try { + for (const step of demoRun(part)) { + await new Promise((r) => window.setTimeout(r, step.wait)); + if (ac.signal.aborted) break; + handleEvent(tab.key, step.ev); + } + } finally { + // The script ends after its approval is answered; real runs follow. + if (part !== "open") demoTabs.delete(tab.key); + finishRun(tab.key, ac.signal.aborted); + } + } + + // send posts the composer draft, or `override` when a question was handed + // over from elsewhere (askRemie). + async function send(override?: string) { const tab = activeTab; if (!tab || tab.running || tab.pending || !tab.hydrated) return; - const text = draft.trim(); + const text = (override ?? draft).trim(); if (!text) return; + if (import.meta.env.DEV && text === "/demo") { + void playDemo("open"); + return; + } const store = useAppStore.getState(); + // A real question ends any demo script playing in this tab. + demoTabs.delete(tab.key); + runStarts.set(tab.key, Date.now()); // running flips on synchronously so a double Enter can't double-send. store.agentUpdateTab(tab.key, (t) => ({ ...t, @@ -392,6 +463,7 @@ export default function AgentPanel() { sid = sess.id; store.agentPatchTab(tab.key, { sessionId: sid }); } catch { + runStarts.delete(tab.key); store.agentUpdateTab(tab.key, (t) => ({ ...t, running: false, @@ -416,6 +488,11 @@ export default function AgentPanel() { decision: "approve" | "deny" | "always_allow", ) { const tab = useAppStore.getState().agentTabs.find((t) => t.key === tabKey); + if (import.meta.env.DEV && tab?.pending && demoTabs.has(tabKey)) { + useAppStore.getState().agentPatchTab(tabKey, { pending: null }); + await playDemo(decision === "deny" ? "deny" : "approve", tabKey); + return; + } if (!tab || !tab.sessionId || !tab.pending) return; useAppStore.getState().agentPatchTab(tabKey, { pending: null }); await runStream(tabKey, `/ai/sessions/${tab.sessionId}/approve`, { @@ -491,9 +568,9 @@ export default function AgentPanel() { // The handle sits on the panel's inner edge, so on the right it widens // as the pointer moves left. direction: side === "right" ? -1 : 1, - label: "Resize the assistant panel", + label: "Resize Remie's panel", controls: "agent-panel", - valueText: (w) => `Assistant panel ${w} pixels`, + valueText: (w) => `Remie panel ${w} pixels`, }); // Keep the floating window inside the viewport when the browser resizes. @@ -700,9 +777,20 @@ export default function AgentPanel() { : !expanded && smUp && "sm:cursor-grab", )} > - +
- Assistant + Remie
{!isFloat && ( @@ -774,7 +862,7 @@ export default function AgentPanel() {