mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-06 08:02:06 +00:00
Merge pull request #823 from warmbly/feat/remie-assistant
feat: Remie, the dashboard AI assistant: animated blob mark, floating panel, live run UI, and Advisor-driven suggestions and tips
This commit is contained in:
@@ -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 |
|
||||
|--------|------|----------------|
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
<Callout type="info" title="What it can and cannot do">
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Callout type="warn" title="Trust the servers you connect">
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -42,7 +42,7 @@ export default function SkillsSettingsPage() {
|
||||
return (
|
||||
<SectionShell
|
||||
title="AI skills"
|
||||
description="Reusable playbooks your AI features follow. Write down how your team qualifies leads, handles objections, or books meetings, and the assistant, research, and reply drafts use them."
|
||||
description="Reusable playbooks your AI features follow. Write down how your team qualifies leads, handles objections, or books meetings, and Remie, research, and reply drafts use them."
|
||||
actions={
|
||||
canManage ? (
|
||||
<button
|
||||
|
||||
@@ -24,7 +24,7 @@ const REASON_LABELS: Record<string, string> = {
|
||||
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",
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function CreditsCard({ isPaid }: { isPaid: boolean }) {
|
||||
return (
|
||||
<Section
|
||||
eyebrow="AI credits"
|
||||
description="Credits power AI features (writing, research, the assistant, automation steps). Your plan grants a monthly allowance that resets each cycle; top-ups never expire."
|
||||
description="Credits power AI features (writing, research, Remie, automation steps). Your plan grants a monthly allowance that resets each cycle; top-ups never expire."
|
||||
>
|
||||
{credits.isPending ? (
|
||||
<div className="h-24 rounded bg-slate-100 animate-pulse" />
|
||||
@@ -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",
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function ConnectionsSettingsPage() {
|
||||
return (
|
||||
<SectionShell
|
||||
title="Connections"
|
||||
description="Connect external MCP servers to give the AI assistant extra tools. Warmbly discovers each server's tools; you review and enable them. The assistant always asks before running an external tool."
|
||||
description="Connect external MCP servers to give Remie extra tools. Warmbly discovers each server's tools; you review and enable them. Remie always asks before running an external tool."
|
||||
actions={
|
||||
canManage ? (
|
||||
<button
|
||||
@@ -59,7 +59,7 @@ export default function ConnectionsSettingsPage() {
|
||||
<div className="h-16 rounded bg-slate-100 animate-pulse" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-[12px] text-slate-500 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -244,7 +244,7 @@ function WorkspaceSettings({ org: currentOrg }: { org: StoreOrganization | null
|
||||
|
||||
<Section
|
||||
eyebrow="AI voice profile"
|
||||
description="Grounds every AI writing surface (assistant, reply drafts, research openers) so drafts sound like you and know what you sell. All optional."
|
||||
description="Grounds every AI writing surface (Remie, reply drafts, research openers) so drafts sound like you and know what you sell. All optional."
|
||||
>
|
||||
<Row
|
||||
label="What you sell"
|
||||
@@ -310,12 +310,12 @@ function WorkspaceSettings({ org: currentOrg }: { org: StoreOrganization | null
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
eyebrow="AI assistant"
|
||||
description="How the assistant's conversation history works across the team."
|
||||
eyebrow="Remie"
|
||||
description="How conversations with Remie work across the team."
|
||||
>
|
||||
<ToggleRow
|
||||
label="Shared history"
|
||||
description="Every member with the Use AI permission sees and can continue every assistant conversation in this workspace, instead of only their own. Turning it on exposes existing conversations to the whole team."
|
||||
description="Every member with the Use AI permission sees and can continue every Remie conversation in this workspace, instead of only their own. Turning it on exposes existing conversations to the whole team."
|
||||
checked={sharedHistory}
|
||||
onChange={onToggleSharedHistory}
|
||||
disabled={!canManageSettings}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// Live-activity primitives for the assistant and the inline AI surfaces, after
|
||||
// the agent patterns in Beautiful UI (beautifului.dev, MIT): a shimmering
|
||||
// status line with an elapsed timer, a collapsible trace of tool steps, and the
|
||||
// approval card that pauses a run.
|
||||
|
||||
import React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CheckIcon, ChevronDownIcon, ExternalLinkIcon, ShieldCheckIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentPending, AgentToolStep } from "@/stores/slices/agentSlice";
|
||||
import AgentMark from "./AgentMark";
|
||||
import { toolAction, toolLabel } from "./toolLabels";
|
||||
|
||||
const EASE_OUT = [0.23, 1, 0.32, 1] as const;
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const s = ms / 1000;
|
||||
if (s < 60) return `${s.toFixed(1)}s`;
|
||||
return `${Math.floor(s / 60)}m ${Math.floor(s % 60)}s`;
|
||||
}
|
||||
|
||||
export function Elapsed({ since, className }: { since: number; className?: string }) {
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
React.useEffect(() => {
|
||||
const t = window.setInterval(() => setNow(Date.now()), 100);
|
||||
return () => window.clearInterval(t);
|
||||
}, []);
|
||||
return (
|
||||
<span className={cn("font-mono tabular-nums text-slate-400", className)}>
|
||||
{formatElapsed(Math.max(0, now - since))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<motion.div
|
||||
role="status"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: EASE_OUT }}
|
||||
className="flex items-center gap-2.5 text-[12.5px]"
|
||||
>
|
||||
<AgentMark variant="bare" size={16} state="thinking" />
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={label}
|
||||
initial={{ opacity: 0, y: 3 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -3 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="ai-shimmer-text font-medium"
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
{since != null && <Elapsed since={since} className="text-[11.5px]" />}
|
||||
{step > 0 && budget > 0 && (
|
||||
<span className="font-mono text-[11px] tabular-nums text-slate-300">
|
||||
step {step}/{budget}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({ step }: { step: AgentToolStep }) {
|
||||
const detail = step.result || step.argsSummary;
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT }}
|
||||
className="flex min-h-7 items-center gap-2 py-0.5 text-[12px]"
|
||||
>
|
||||
{step.done ? (
|
||||
<motion.span
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 600, damping: 24 }}
|
||||
className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-full bg-emerald-50 text-emerald-600"
|
||||
>
|
||||
<CheckIcon className="size-2.5" strokeWidth={3} />
|
||||
</motion.span>
|
||||
) : (
|
||||
<span className="size-3.5 shrink-0 animate-spin rounded-full border-[1.5px] border-slate-200 border-t-sky-500" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 font-medium",
|
||||
step.done ? "text-slate-700" : "ai-shimmer-text",
|
||||
)}
|
||||
>
|
||||
{toolLabel(step.tool, step.done)}
|
||||
</span>
|
||||
{detail && (
|
||||
<span className="min-w-0 truncate text-[11.5px] text-slate-400" title={detail}>
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<boolean | null>(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 (
|
||||
<div>
|
||||
{multi && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setUserOpen(!open)}
|
||||
className="group -mx-1.5 flex h-7 max-w-[calc(100%+12px)] items-center gap-2 rounded-md px-1.5 text-[12px] transition-colors hover:bg-slate-100"
|
||||
>
|
||||
<AgentMark
|
||||
variant="bare"
|
||||
size={14}
|
||||
state={running ? "thinking" : "idle"}
|
||||
tone={running ? "color" : "muted"}
|
||||
/>
|
||||
{current ? (
|
||||
<span className="ai-shimmer-text shrink-0 font-medium">
|
||||
{toolLabel(current.tool, false)}
|
||||
</span>
|
||||
) : (
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
className="shrink-0 font-medium text-slate-600"
|
||||
>
|
||||
Ran {steps.length} steps
|
||||
</motion.span>
|
||||
)}
|
||||
{running ? (
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums text-slate-400">
|
||||
{doneCount}/{steps.length}
|
||||
</span>
|
||||
) : (
|
||||
!open && (
|
||||
<span className="min-w-0 truncate text-[11.5px] text-slate-400">
|
||||
{summary}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 text-slate-400 transition-transform duration-300",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ gridTemplateRows: open ? "1fr" : "0fr", opacity: open ? 1 : 0 }}
|
||||
inert={!open}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className={cn("relative", multi && "ml-[6px] pl-3.5")}>
|
||||
{multi && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute bottom-2 left-0 top-1 w-px bg-slate-200"
|
||||
/>
|
||||
)}
|
||||
{steps.map((s) => (
|
||||
<StepRow key={s.id} step={s} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{drafts.map((s) => (
|
||||
<motion.button
|
||||
key={s.id}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: EASE_OUT }}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<ExternalLinkIcon className="w-3 h-3" />
|
||||
Open {s.entityType === "campaign" ? "campaign" : "automation"} draft
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 520, damping: 34 }}
|
||||
className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-[0_8px_24px_-12px_rgba(15,23,42,0.25)]"
|
||||
>
|
||||
<div className="flex h-8 items-center gap-2 border-b border-amber-100 bg-amber-50/70 px-3">
|
||||
<AgentMark variant="bare" size={14} state="attention" />
|
||||
<span className="text-[11.5px] font-medium text-amber-800">
|
||||
{isSend ? "Ready to send" : "Waiting for your approval"}
|
||||
</span>
|
||||
<span className="ml-auto rounded bg-white/70 px-1.5 py-px text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-700 ring-1 ring-amber-200">
|
||||
{isSend ? "Sends email" : "Changes data"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 text-[12.5px] font-medium text-slate-900">
|
||||
<ShieldCheckIcon className="size-3.5 shrink-0 text-slate-400" />
|
||||
{toolAction(pending.tool)}
|
||||
</div>
|
||||
{pending.argsSummary && (
|
||||
<div className="mt-1.5 whitespace-pre-wrap break-words rounded-md border border-slate-200 bg-slate-50 px-2 py-1.5 font-mono text-[11.5px] leading-relaxed text-slate-600">
|
||||
{pending.argsSummary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 pb-3">
|
||||
<button
|
||||
onClick={() => onDecide("approve")}
|
||||
className="h-7 px-3 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
{isSend ? "Send" : "Approve"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDecide("deny")}
|
||||
className="h-7 px-3 rounded-md border border-slate-200 hover:border-slate-300 text-[12px] text-slate-700 transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
{!isSend && (
|
||||
<button
|
||||
onClick={() => onDecide("always_allow")}
|
||||
className="ml-auto h-7 px-2.5 rounded-md text-[12px] text-slate-500 hover:text-slate-800 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
Always allow
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mt-6 w-full max-w-[340px] rounded-lg border border-dashed border-slate-300 p-3 text-left">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
Dev preview
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDemo}
|
||||
className="h-7 px-3 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<PlayIcon className="size-3" fill="currentColor" />
|
||||
Play demo run
|
||||
</button>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
or send <code className="font-mono text-slate-500">/demo</code> in any chat
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" ? <PixelMark {...props} /> : <BlobMark {...props} />;
|
||||
}
|
||||
|
||||
// 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<Props, "variant">) {
|
||||
const reduced = useReducedMotion();
|
||||
const ref = React.useRef<HTMLSpanElement>(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 (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
|
||||
<path d="M9.5 4c.62 4.7 3.3 7.38 8 8-4.7.62-7.38 3.3-8 8-.62-4.7-3.3-7.38-8-8 4.7-.62 7.38-3.3 8-8Z" />
|
||||
<path d="M18.5 2c.31 2.35 1.65 3.69 4 4-2.35.31-3.69 1.65-4 4-.31-2.35-1.65-3.69-4-4 2.35-.31 3.69-1.65 4-4Z" />
|
||||
</svg>
|
||||
<span
|
||||
ref={ref}
|
||||
aria-hidden
|
||||
className={cn("amo amo-b", `amo-${look}`, className)}
|
||||
style={{ width: size, height: size, fontSize: size }}
|
||||
>
|
||||
<span className="amo-body">
|
||||
<span className="amo-swirl" />
|
||||
</span>
|
||||
<span className="amo-shine" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// '#' 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<Props, "variant">) {
|
||||
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 (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("amo amo-bare", `amo-${look}`, className)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<span className="amo-grid" style={{ gridTemplateColumns: `repeat(3, ${cell}px)`, gap }}>
|
||||
{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 (
|
||||
<span
|
||||
key={i}
|
||||
className="amo-px"
|
||||
style={{ width: cell, height: cell, animation, "--o": o } as React.CSSProperties}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, AbortController>();
|
||||
// When each tab's current run began, for the elapsed timer. Same lifetime as aborts.
|
||||
const runStarts = new Map<string, number>();
|
||||
// Tabs playing the dev demo, whose approvals continue the script instead of
|
||||
// calling the server.
|
||||
const demoTabs = new Set<string>();
|
||||
const demoStarting = new Set<string>();
|
||||
|
||||
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<string, unknown>,
|
||||
) {
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<AgentMark className="w-4 h-4 text-sky-600" />
|
||||
<AgentMark
|
||||
size={20}
|
||||
listen
|
||||
celebrate={lastRunOk}
|
||||
state={
|
||||
activeTab?.pending
|
||||
? "attention"
|
||||
: activeTab?.running
|
||||
? "thinking"
|
||||
: "idle"
|
||||
}
|
||||
/>
|
||||
<div className="text-[13px] font-semibold text-slate-900">
|
||||
Assistant
|
||||
Remie
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{!isFloat && (
|
||||
@@ -774,7 +862,7 @@ export default function AgentPanel() {
|
||||
<button
|
||||
onClick={closePanel}
|
||||
title="Close"
|
||||
aria-label="Close assistant"
|
||||
aria-label="Close Remie"
|
||||
className="size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
@@ -805,9 +893,13 @@ export default function AgentPanel() {
|
||||
)}
|
||||
>
|
||||
{activeTab && !activeTab.hydrated && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-slate-400">
|
||||
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
|
||||
Loading conversation…
|
||||
<div role="status" aria-label="Loading conversation" className="space-y-4">
|
||||
<div className="ml-auto h-8 w-2/5 rounded-2xl rounded-br-sm skeleton-shimmer" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-11/12 rounded skeleton-shimmer" />
|
||||
<div className="h-3 w-4/5 rounded skeleton-shimmer" />
|
||||
<div className="h-3 w-3/5 rounded skeleton-shimmer" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab &&
|
||||
@@ -815,10 +907,8 @@ export default function AgentPanel() {
|
||||
activeTab.turns.length === 0 &&
|
||||
!activeTab.running && (
|
||||
<EmptyState
|
||||
onPick={(q) => {
|
||||
setDraft(q);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
onDemo={() => void playDemo("open")}
|
||||
onAsk={(q) => void send(q)}
|
||||
/>
|
||||
)}
|
||||
{activeTab?.turns.map((t, i) => (
|
||||
@@ -840,23 +930,12 @@ export default function AgentPanel() {
|
||||
/>
|
||||
)}
|
||||
{activeTab?.running && !activeTab.pending && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.16 }}
|
||||
className="flex items-center gap-2 text-[12px]"
|
||||
>
|
||||
<AgentMark className="w-3.5 h-3.5 text-sky-500 animate-pulse" />
|
||||
<span className="ai-shimmer-text font-medium">
|
||||
Working…
|
||||
</span>
|
||||
{activeTab.iteration > 0 && (
|
||||
<span className="font-mono tabular-nums text-slate-300">
|
||||
step {activeTab.iteration}/
|
||||
{activeTab.budget}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
<WorkingStatus
|
||||
label={runPhase(activeTab.turns)}
|
||||
since={runStarts.get(activeTab.key)}
|
||||
step={activeTab.iteration}
|
||||
budget={activeTab.budget}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -884,7 +963,10 @@ export default function AgentPanel() {
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length > draft.length) pulseAgent();
|
||||
setDraft(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
@@ -899,7 +981,7 @@ export default function AgentPanel() {
|
||||
placeholder={
|
||||
activeTab?.pending
|
||||
? "Respond to the approval above first"
|
||||
: "Ask about contacts, campaigns, your inbox…"
|
||||
: "Ask Remie about contacts, campaigns, your inbox…"
|
||||
}
|
||||
disabled={composerLocked}
|
||||
className="flex-1 resize-none bg-transparent py-1 text-[13px] leading-5 text-slate-900 placeholder:text-slate-400 outline-none max-h-32 disabled:opacity-60"
|
||||
@@ -918,7 +1000,7 @@ export default function AgentPanel() {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={send}
|
||||
onClick={() => void send()}
|
||||
disabled={!draft.trim() || composerLocked}
|
||||
title="Send"
|
||||
aria-label="Send message"
|
||||
@@ -1020,7 +1102,7 @@ function TabBar({
|
||||
)}
|
||||
>
|
||||
{t.running ? (
|
||||
<Loader2Icon className="w-3 h-3 shrink-0 animate-spin text-sky-500" />
|
||||
<AgentMark variant="bare" size={12} state="thinking" />
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -1077,6 +1159,7 @@ function DockBar({
|
||||
onRestore: (key: string | null) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const lastRunOk = useAppStore((s) => s.agentLastRunOk);
|
||||
const focus =
|
||||
tabs.find((t) => t.running) ??
|
||||
tabs.find((t) => t.pending) ??
|
||||
@@ -1088,13 +1171,10 @@ function DockBar({
|
||||
let status: React.ReactNode;
|
||||
if (focus?.running) {
|
||||
status = (
|
||||
<span className="inline-flex items-center gap-1.5 text-slate-500">
|
||||
<Loader2Icon className="w-3 h-3 animate-spin text-sky-500" />
|
||||
Working
|
||||
{focus.iteration > 0 && (
|
||||
<span className="font-mono tabular-nums text-slate-400">
|
||||
{focus.iteration}/{focus.budget}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="ai-shimmer-text font-medium">Working</span>
|
||||
{runStarts.has(focus.key) && (
|
||||
<Elapsed since={runStarts.get(focus.key)!} className="text-[11px]" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
@@ -1144,11 +1224,15 @@ function DockBar({
|
||||
onRestore(focus?.key ?? null);
|
||||
}
|
||||
}}
|
||||
className="h-10 pl-2.5 pr-1 rounded-lg border border-slate-200 bg-white shadow-lg shadow-slate-900/10 flex items-center gap-2 cursor-pointer hover:border-slate-300 transition-colors"
|
||||
className="group h-10 pl-2.5 pr-1 rounded-lg border border-slate-200 bg-white shadow-lg shadow-slate-900/10 flex items-center gap-2 cursor-pointer hover:border-slate-300 transition-colors"
|
||||
>
|
||||
<AgentMark className="w-4 h-4 text-sky-600 shrink-0" />
|
||||
<AgentMark
|
||||
size={20}
|
||||
celebrate={lastRunOk}
|
||||
state={focus?.pending ? "attention" : focus?.running ? "thinking" : "idle"}
|
||||
/>
|
||||
<span className="max-w-[160px] truncate text-[12.5px] font-medium text-slate-800">
|
||||
{focus?.title ?? "Assistant"}
|
||||
{focus?.title ?? "Remie"}
|
||||
</span>
|
||||
<span className="text-[11.5px]">{status}</span>
|
||||
<span className="flex items-center gap-0.5 pl-1">
|
||||
@@ -1158,7 +1242,7 @@ function DockBar({
|
||||
onRestore(focus?.key ?? null);
|
||||
}}
|
||||
title="Restore"
|
||||
aria-label="Restore assistant"
|
||||
aria-label="Restore Remie"
|
||||
className="size-6 rounded inline-flex items-center justify-center text-slate-400 hover:text-slate-900 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<ChevronUpIcon className="w-3.5 h-3.5" />
|
||||
@@ -1169,7 +1253,7 @@ function DockBar({
|
||||
onClose();
|
||||
}}
|
||||
title="Close"
|
||||
aria-label="Close assistant"
|
||||
aria-label="Close Remie"
|
||||
className="size-6 rounded inline-flex items-center justify-center text-slate-400 hover:text-slate-900 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<XIcon className="w-3.5 h-3.5" />
|
||||
@@ -1349,7 +1433,7 @@ function SessionSidebar({
|
||||
<button
|
||||
onClick={() =>
|
||||
confirm.show(
|
||||
"Clear your entire assistant history in this workspace? Every conversation and transcript is removed for good.",
|
||||
"Clear all your conversations with Remie in this workspace? Every conversation and transcript is removed for good.",
|
||||
async () => {
|
||||
await clearAgentSessions();
|
||||
// Close every tab tied to a stored session;
|
||||
@@ -1523,9 +1607,21 @@ const TurnView = React.memo(function TurnView({
|
||||
);
|
||||
}
|
||||
const lastIdx = turn.blocks.length - 1;
|
||||
const segments = segmentBlocks(turn.blocks);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{turn.blocks.map((b, i) => {
|
||||
{segments.map((seg, si) => {
|
||||
if (seg.kind === "tools") {
|
||||
return (
|
||||
<ActivityTrace
|
||||
key={seg.key}
|
||||
steps={seg.steps}
|
||||
live={streaming && si === segments.length - 1}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { block: b, index: i } = seg;
|
||||
if (b.kind === "text") {
|
||||
if (!b.text) return null;
|
||||
// The trailing block of a live run types itself out; the
|
||||
@@ -1536,9 +1632,7 @@ const TurnView = React.memo(function TurnView({
|
||||
<Markdown key={i} text={b.text} onOpen={onOpen} />
|
||||
);
|
||||
}
|
||||
if (b.kind === "tool") {
|
||||
return <ToolStepRow key={i} step={b.step} onOpen={onOpen} />;
|
||||
}
|
||||
if (b.kind === "tool") return null;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -1577,186 +1671,80 @@ function SmoothText({
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [shown, text]);
|
||||
return <Markdown text={text.slice(0, Math.min(shown, text.length))} onOpen={onOpen} caret />;
|
||||
}
|
||||
|
||||
function ToolStepRow({
|
||||
step,
|
||||
onOpen,
|
||||
}: {
|
||||
step: AgentToolStep;
|
||||
onOpen: (url: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="rounded-md border border-slate-200 bg-slate-50/60 px-2.5 py-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[11.5px] text-slate-600">
|
||||
{step.done ? (
|
||||
<motion.span
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 600, damping: 24 }}
|
||||
className="shrink-0 inline-flex"
|
||||
>
|
||||
<CheckIcon className="w-3 h-3 text-emerald-600" />
|
||||
</motion.span>
|
||||
) : (
|
||||
<Loader2Icon className="w-3 h-3 animate-spin text-slate-400 shrink-0" />
|
||||
)}
|
||||
<WrenchIcon className="w-3 h-3 text-slate-400 shrink-0" />
|
||||
<span className="font-medium text-slate-700">
|
||||
{toolLabel(step.tool)}
|
||||
</span>
|
||||
{step.result && (
|
||||
<span className="text-slate-400 truncate" title={step.result}>
|
||||
— {step.result}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{step.done &&
|
||||
step.openURL &&
|
||||
(step.entityType === "campaign" ||
|
||||
step.entityType === "automation") && (
|
||||
<button
|
||||
onClick={() => onOpen(step.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"
|
||||
>
|
||||
<ExternalLinkIcon className="w-3 h-3" />
|
||||
Open {step.entityType === "campaign" ? "campaign" : "automation"}{" "}
|
||||
draft
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
<Markdown
|
||||
text={text.slice(0, Math.min(shown, text.length))}
|
||||
onOpen={onOpen}
|
||||
caret
|
||||
typing={shown < text.length}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalCard({
|
||||
pending,
|
||||
onDecide,
|
||||
}: {
|
||||
pending: AgentPending;
|
||||
onDecide: (d: "approve" | "deny" | "always_allow") => void;
|
||||
}) {
|
||||
const isSend = pending.risk === "send";
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50/70 p-3">
|
||||
<div className="flex items-center gap-1.5 text-[12px] font-medium text-amber-800">
|
||||
<ShieldQuestionIcon className="w-3.5 h-3.5" />
|
||||
{isSend ? "Send this?" : "Approve this action?"}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-slate-700">
|
||||
<span className="font-medium">{toolLabel(pending.tool)}</span>
|
||||
{pending.argsSummary && (
|
||||
<span className="text-slate-500"> — {pending.argsSummary}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => onDecide("approve")}
|
||||
className="h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
{isSend ? "Send" : "Approve"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDecide("deny")}
|
||||
className="h-7 px-3 rounded-md border border-slate-200 hover:border-slate-300 text-[12px] text-slate-700 transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
{!isSend && (
|
||||
<button
|
||||
onClick={() => onDecide("always_allow")}
|
||||
className="h-7 px-2.5 rounded-md text-[12px] text-slate-500 hover:text-slate-800 transition-colors"
|
||||
>
|
||||
Always allow
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
type Segment =
|
||||
| { kind: "tools"; key: string; steps: AgentToolStep[] }
|
||||
| { kind: "block"; block: AgentBlock; index: number };
|
||||
|
||||
// segmentBlocks folds consecutive tool calls into one trace segment, keyed by
|
||||
// its first step so a trace keeps its open/closed state as steps stream in.
|
||||
function segmentBlocks(blocks: AgentBlock[]): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
blocks.forEach((b, index) => {
|
||||
if (b.kind !== "tool") {
|
||||
out.push({ kind: "block", block: b, index });
|
||||
return;
|
||||
}
|
||||
const tail = out[out.length - 1];
|
||||
if (tail && tail.kind === "tools") tail.steps.push(b.step);
|
||||
else out.push({ kind: "tools", key: b.step.id, steps: [b.step] });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const STARTERS = [
|
||||
"Which leads went cold and need a follow-up?",
|
||||
"Summarize replies in my inbox from this week",
|
||||
"Draft a reply to my latest positive reply",
|
||||
"How are my campaigns performing?",
|
||||
];
|
||||
// runPhase names what a live run is doing from the tail of its transcript.
|
||||
function runPhase(turns: AgentTurn[]): string {
|
||||
const last = turns[turns.length - 1];
|
||||
if (!last || last.role !== "assistant") return "Remie is thinking";
|
||||
const b = last.blocks[last.blocks.length - 1];
|
||||
if (b?.kind === "tool" && !b.step.done) return "Remie is working";
|
||||
if (b?.kind === "text" && b.live) return "Remie is writing";
|
||||
return "Remie is thinking";
|
||||
}
|
||||
|
||||
function EmptyState({ onPick }: { onPick: (q: string) => void }) {
|
||||
// Dev builds only; the constant false in production drops the chunk.
|
||||
const AgentDevTools = import.meta.env.DEV ? React.lazy(() => import("./AgentDevTools")) : null;
|
||||
|
||||
function EmptyState({ onDemo, onAsk }: { onDemo: () => void; onAsk: (q: string) => void }) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center px-6 py-10">
|
||||
<AgentMark className="w-6 h-6 text-sky-600 mb-3" />
|
||||
<div className="text-[13px] font-semibold text-slate-900">
|
||||
How can I help?
|
||||
</div>
|
||||
<p className="text-[12px] text-slate-500 mt-1 leading-relaxed max-w-[280px]">
|
||||
Ask me to find contacts, check a campaign, draft a reply, or set up a
|
||||
draft campaign. I ask before changing anything.
|
||||
<span className="group mb-3 inline-flex p-2">
|
||||
<AgentMark size={44} listen />
|
||||
</span>
|
||||
<div className="text-[14px] font-semibold text-slate-900">Hi, I'm Remie</div>
|
||||
<p className="mt-1 max-w-[260px] text-[12px] leading-relaxed text-slate-500">
|
||||
I can look into your outreach and fix things for you. I always ask before changing
|
||||
anything.
|
||||
</p>
|
||||
<div className="mt-4 w-full max-w-[320px] space-y-1.5">
|
||||
{STARTERS.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => onPick(q)}
|
||||
className="w-full text-left px-3 py-2 rounded-md border border-slate-200 hover:border-sky-300 hover:bg-sky-50/50 text-[12px] text-slate-600 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-x-3 gap-y-1.5 text-[10.5px] text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Kbd combo="mod+i" variant="light" /> toggle
|
||||
</span>
|
||||
<span className="hidden sm:inline-flex items-center gap-1">
|
||||
<Kbd combo="alt+n" variant="light" /> new chat
|
||||
</span>
|
||||
<span className="hidden sm:inline-flex items-center gap-1">
|
||||
<Kbd combo="alt+p" variant="light" /> pop out
|
||||
</span>
|
||||
<span className="hidden sm:inline-flex items-center gap-1">
|
||||
<Kbd combo="alt+m" variant="light" /> minimize
|
||||
</span>
|
||||
<RemieSuggestions onPick={onAsk} />
|
||||
<div className="mt-6 flex items-center gap-1.5 text-[10.5px] text-slate-400">
|
||||
<Kbd combo="mod+i" variant="light" /> to open or close
|
||||
<span aria-hidden>·</span>
|
||||
<button
|
||||
onClick={() => useAppStore.getState().setShortcutsModalOpen(true)}
|
||||
className="underline decoration-dotted underline-offset-2 hover:text-slate-600 transition-colors"
|
||||
className="hover:text-slate-600 transition-colors"
|
||||
>
|
||||
all shortcuts
|
||||
shortcuts
|
||||
</button>
|
||||
</div>
|
||||
{AgentDevTools && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AgentDevTools onDemo={onDemo} />
|
||||
</React.Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// toolLabel renders a friendly label for a tool name.
|
||||
function toolLabel(tool: string): string {
|
||||
const map: Record<string, string> = {
|
||||
search_contacts: "Searched contacts",
|
||||
get_contact: "Read contact",
|
||||
update_contact_fields: "Update contact",
|
||||
add_tag: "Add label",
|
||||
remove_tag: "Remove label",
|
||||
list_campaigns: "Listed campaigns",
|
||||
get_campaign_stats: "Campaign stats",
|
||||
create_campaign_draft: "Create campaign draft",
|
||||
create_automation_draft: "Create automation draft",
|
||||
create_task: "Create task",
|
||||
create_deal: "Create deal",
|
||||
list_threads: "Listed threads",
|
||||
get_thread: "Read thread",
|
||||
draft_reply: "Drafted reply",
|
||||
search_web: "Searched the web",
|
||||
fetch_url: "Fetched a page",
|
||||
};
|
||||
return map[tool] || tool.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function resourceFromPath(path: string): string {
|
||||
const m = path.match(
|
||||
/\/app\/(campaigns|contacts|automations)\/([0-9a-f-]{8,})/i,
|
||||
|
||||
@@ -69,26 +69,37 @@ function inline(
|
||||
return out;
|
||||
}
|
||||
|
||||
// Blinking insertion caret appended to streaming text so mid-generation
|
||||
// output reads as "still typing" instead of stalled.
|
||||
function Caret() {
|
||||
// Insertion caret appended to streaming text so mid-generation output reads
|
||||
// as "still typing" instead of stalled. Solid while text arrives, blinking
|
||||
// while the model is between chunks.
|
||||
function Caret({ typing }: { typing: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="ai-caret ml-0.5 inline-block h-[1em] w-[2px] translate-y-[0.15em] rounded-sm bg-sky-500"
|
||||
className={
|
||||
"ai-caret ml-0.5 inline-block h-[1em] w-[2px] translate-y-[0.15em] rounded-sm bg-sky-500" +
|
||||
(typing ? " is-typing" : "")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Characters at the end of a still-typing paragraph that render blurred, so
|
||||
// new text resolves into place instead of popping in.
|
||||
const STREAM_TAIL = 6;
|
||||
|
||||
export default function Markdown({
|
||||
text,
|
||||
onOpen,
|
||||
caret = false,
|
||||
typing = false,
|
||||
}: {
|
||||
text: string;
|
||||
onOpen?: (url: string) => void;
|
||||
// Render a blinking caret at the end of the last paragraph (streaming).
|
||||
// Render a caret at the end of the last paragraph (streaming).
|
||||
caret?: boolean;
|
||||
// Text is still being revealed: hold the caret and blur the newest characters.
|
||||
typing?: boolean;
|
||||
}) {
|
||||
const blocks: React.ReactNode[] = [];
|
||||
// Fence splitting leaves prose at even indexes, code at odd ones; a
|
||||
@@ -171,16 +182,27 @@ export default function Markdown({
|
||||
const last = blocks[blocks.length - 1];
|
||||
if (React.isValidElement(last) && last.type === "p") {
|
||||
const el = last as React.ReactElement<{ children?: React.ReactNode }>;
|
||||
const kids = React.Children.toArray(el.props.children);
|
||||
const end = kids[kids.length - 1];
|
||||
if (typing && typeof end === "string" && end.length > 0) {
|
||||
const cut = Math.max(0, end.length - STREAM_TAIL);
|
||||
kids[kids.length - 1] = end.slice(0, cut);
|
||||
kids.push(
|
||||
<span key="tail" className="ai-stream-tail">
|
||||
{end.slice(cut)}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
blocks[blocks.length - 1] = React.cloneElement(
|
||||
el,
|
||||
{},
|
||||
...React.Children.toArray(el.props.children),
|
||||
<Caret key="caret" />,
|
||||
...kids,
|
||||
<Caret key="caret" typing={typing} />,
|
||||
);
|
||||
} else {
|
||||
blocks.push(
|
||||
<p key="caret-line">
|
||||
<Caret />
|
||||
<Caret typing={typing} />
|
||||
</p>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// "Suggested for you" in an empty Remie conversation: the problems the Advisor
|
||||
// measured in this workspace, one line per kind. Choosing one asks Remie to fix
|
||||
// it, through its own tools and approval cards. Nothing renders when there is
|
||||
// nothing worth suggesting.
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SEVERITY_DOT, SEVERITY_RANK } from "@/lib/api/models/app/advisor/Advisor";
|
||||
import { useAdvisorFindings } from "@/lib/api/hooks/app/advisor/useAdvisor";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import { fixPromptFor, suggestionsFrom } from "./remieTips";
|
||||
|
||||
export default function RemieSuggestions({ onPick }: { onPick: (prompt: string) => void }) {
|
||||
// Findings are read under View analytics, the Advisor's own permission.
|
||||
const canSeeAdvisor = usePermission("VIEW_ANALYTICS");
|
||||
const { data } = useAdvisorFindings({ limit: 50 }, canSeeAdvisor);
|
||||
const items = React.useMemo(() => suggestionsFrom(data ?? [], SEVERITY_RANK.medium).slice(0, 4), [data]);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-6 w-full max-w-[340px] text-left">
|
||||
<div className="mb-2 px-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
Suggested for you
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white divide-y divide-slate-100">
|
||||
{items.map((s, i) => (
|
||||
<motion.button
|
||||
key={s.key}
|
||||
type="button"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, delay: 0.04 + i * 0.05, ease: [0.23, 1, 0.32, 1] }}
|
||||
onClick={() => onPick(fixPromptFor(s))}
|
||||
className="group/row flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<span className={cn("size-1.5 shrink-0 rounded-full", SEVERITY_DOT[s.severity])} />
|
||||
<span className="line-clamp-2 min-w-0 flex-1 text-[12.5px] leading-snug text-slate-700 group-hover/row:text-slate-900">
|
||||
{s.text}
|
||||
</span>
|
||||
<ArrowRightIcon className="size-3.5 shrink-0 text-slate-300 transition-all group-hover/row:translate-x-0.5 group-hover/row:text-sky-600" />
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Remie's speech bubble under the header blob: one problem worth fixing,
|
||||
// offered at a moment that does not interrupt, with one way to act on it.
|
||||
// remieTips.ts decides whether and which; this decides when (idle, nothing else
|
||||
// open, the tab visible). It never shows while Remie's panel is open, where the
|
||||
// same suggestions are listed instead.
|
||||
|
||||
import React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { BellOffIcon, XIcon } from "lucide-react";
|
||||
import { useAppStore } from "@/stores";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import { SEVERITY_RANK } from "@/lib/api/models/app/advisor/Advisor";
|
||||
import { useAdvisorFindings } from "@/lib/api/hooks/app/advisor/useAdvisor";
|
||||
import AgentMark from "./AgentMark";
|
||||
import { askRemie } from "./askRemie";
|
||||
import {
|
||||
TIP_RULES,
|
||||
fixPromptFor,
|
||||
loadMemory,
|
||||
pauseForWeek,
|
||||
pickTip,
|
||||
pruneSeen,
|
||||
recordEngaged,
|
||||
recordIgnored,
|
||||
recordShown,
|
||||
saveMemory,
|
||||
suggestionsFrom,
|
||||
type RemieSuggestion,
|
||||
type TipMemory,
|
||||
} from "./remieTips";
|
||||
|
||||
// Layers that mean the member is in the middle of something.
|
||||
const BUSY_SELECTOR = '[role="dialog"], [role="alertdialog"], [data-floating]';
|
||||
|
||||
// `anchor` wraps the blob button, so clicking the blob is not a dismissal.
|
||||
export default function RemieTip({ anchor }: { anchor: React.RefObject<HTMLElement | null> }) {
|
||||
const userId = useAppStore((s) => s.user?.id ?? "");
|
||||
const orgId = useAppStore((s) => s.currentOrganization?.id ?? "");
|
||||
const panelOpen = useAppStore((s) => s.aiAssistantOpen && !s.agentMinimized);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [wide, setWide] = React.useState(() => window.matchMedia("(min-width: 640px)").matches);
|
||||
React.useEffect(() => {
|
||||
const m = window.matchMedia("(min-width: 640px)");
|
||||
const fn = () => setWide(m.matches);
|
||||
m.addEventListener("change", fn);
|
||||
return () => m.removeEventListener("change", fn);
|
||||
}, []);
|
||||
|
||||
// Findings are read under View analytics, the Advisor's own permission.
|
||||
const canSeeAdvisor = usePermission("VIEW_ANALYTICS");
|
||||
const enabled = !!userId && !!orgId && wide && canSeeAdvisor;
|
||||
const { data: findings } = useAdvisorFindings({ limit: 50 }, enabled);
|
||||
const suggestions = React.useMemo(
|
||||
() => suggestionsFrom(findings ?? [], SEVERITY_RANK.low),
|
||||
[findings],
|
||||
);
|
||||
|
||||
const [tip, setTip] = React.useState<RemieSuggestion | null>(null);
|
||||
const hovered = React.useRef(false);
|
||||
const bubble = React.useRef<HTMLDivElement>(null);
|
||||
// A bubble that unmounts under the cursor never sees pointerleave.
|
||||
React.useEffect(() => {
|
||||
hovered.current = false;
|
||||
}, [tip]);
|
||||
|
||||
const update = React.useCallback(
|
||||
(fn: (m: TipMemory) => TipMemory) => {
|
||||
if (!userId || !orgId) return;
|
||||
saveMemory(userId, orgId, fn(loadMemory(userId, orgId)));
|
||||
},
|
||||
[userId, orgId],
|
||||
);
|
||||
|
||||
// Last time the member typed, clicked or scrolled. A tip waits for a pause.
|
||||
const mountedAt = React.useRef(Date.now());
|
||||
const lastInput = React.useRef(Date.now());
|
||||
React.useEffect(() => {
|
||||
const mark = () => {
|
||||
lastInput.current = Date.now();
|
||||
};
|
||||
const opts = { capture: true, passive: true };
|
||||
window.addEventListener("keydown", mark, opts);
|
||||
window.addEventListener("pointerdown", mark, opts);
|
||||
window.addEventListener("wheel", mark, opts);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", mark, opts);
|
||||
window.removeEventListener("pointerdown", mark, opts);
|
||||
window.removeEventListener("wheel", mark, opts);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Forget kinds of problem that have closed.
|
||||
React.useEffect(() => {
|
||||
if (findings) update((m) => pruneSeen(m, new Set(suggestions.map((s) => s.key))));
|
||||
}, [findings, suggestions, update]);
|
||||
|
||||
// Check now and then whether this is a good moment. Local only: findings
|
||||
// arrive through the realtime advisor invalidation, not by polling.
|
||||
React.useEffect(() => {
|
||||
if (!enabled || suggestions.length === 0 || tip || panelOpen) return;
|
||||
const t = window.setInterval(() => {
|
||||
const now = Date.now();
|
||||
if (document.hidden) return;
|
||||
if (now - mountedAt.current < TIP_RULES.warmupMs) return;
|
||||
if (now - lastInput.current < TIP_RULES.idleMs) return;
|
||||
if (document.querySelector(BUSY_SELECTOR)) return;
|
||||
const m = loadMemory(userId, orgId);
|
||||
const next = pickTip(suggestions, m, now, pathname);
|
||||
if (!next) return;
|
||||
saveMemory(userId, orgId, recordShown(m, next, now));
|
||||
setTip(next);
|
||||
}, 2000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [enabled, suggestions, tip, panelOpen, pathname, userId, orgId]);
|
||||
|
||||
// Something else claiming attention (a dialog, a popover) puts the tip away
|
||||
// without counting it against tips.
|
||||
React.useEffect(() => {
|
||||
if (!tip) return;
|
||||
const t = window.setInterval(() => {
|
||||
if (document.querySelector(BUSY_SELECTOR)) setTip(null);
|
||||
}, 500);
|
||||
return () => window.clearInterval(t);
|
||||
}, [tip]);
|
||||
|
||||
// A tip nobody touches leaves on its own and counts as not wanted.
|
||||
React.useEffect(() => {
|
||||
if (!tip) return;
|
||||
let t = 0;
|
||||
const hide = () => {
|
||||
if (hovered.current) {
|
||||
t = window.setTimeout(hide, 2000);
|
||||
return;
|
||||
}
|
||||
update((m) => recordIgnored(m, Date.now()));
|
||||
setTip(null);
|
||||
};
|
||||
t = window.setTimeout(hide, TIP_RULES.autoHideMs);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [tip, update]);
|
||||
|
||||
// Opening Remie takes over: the panel lists suggestions itself.
|
||||
React.useEffect(() => {
|
||||
if (panelOpen && tip) setTip(null);
|
||||
}, [panelOpen, tip]);
|
||||
|
||||
const close = React.useCallback(
|
||||
(how: "ignored" | "engaged") => {
|
||||
update((m) => (how === "ignored" ? recordIgnored(m, Date.now()) : recordEngaged(m)));
|
||||
setTip(null);
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
// Click-away and Escape, through the shared layer stack like every floating layer.
|
||||
useClickOutside(!!tip, () => close("ignored"), [bubble, anchor]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{tip && (
|
||||
<motion.div
|
||||
ref={bubble}
|
||||
key={tip.key}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
initial={{ opacity: 0, y: -8, scale: 0.92 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.96 }}
|
||||
transition={{ type: "spring", stiffness: 460, damping: 30 }}
|
||||
style={{ transformOrigin: "calc(100% - 14px) -6px" }}
|
||||
onPointerEnter={() => {
|
||||
hovered.current = true;
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
hovered.current = false;
|
||||
}}
|
||||
className="absolute right-0 top-full z-40 mt-2.5 w-[300px] rounded-2xl border border-slate-200/80 bg-white p-3.5 shadow-[0_18px_44px_-14px_rgba(15,23,42,0.3)]"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -top-[5px] right-[10px] size-2.5 rotate-45 rounded-[2px] border-l border-t border-slate-200/80 bg-white"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentMark size={18} />
|
||||
<span className="text-[12px] font-semibold text-slate-900">Remie</span>
|
||||
<div className="ml-auto -mr-1.5 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
update((m) => pauseForWeek(m, Date.now()));
|
||||
setTip(null);
|
||||
}}
|
||||
title="Pause tips for a week"
|
||||
aria-label="Pause tips for a week"
|
||||
className="size-6 inline-flex items-center justify-center rounded-md text-slate-300 hover:text-slate-600 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<BellOffIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => close("ignored")}
|
||||
aria-label="Dismiss"
|
||||
className="size-6 inline-flex items-center justify-center rounded-md text-slate-300 hover:text-slate-600 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[13px] leading-snug text-slate-800">{tip.text}</p>
|
||||
<div className="mt-3 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
close("engaged");
|
||||
askRemie(fixPromptFor(tip));
|
||||
}}
|
||||
className="h-7 px-3 rounded-lg bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium transition-colors"
|
||||
>
|
||||
Fix with Remie
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => close("engaged")}
|
||||
className="h-7 px-2.5 rounded-lg text-[12px] text-slate-500 hover:text-slate-800 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Dev-only scripted agent run. AgentPanel plays these through the same event
|
||||
// handler a real SSE stream feeds, so the whole run UI (status line, tool
|
||||
// trace, streamed text, approval card) can be seen without an AI provider.
|
||||
// Loaded through a dynamic import behind import.meta.env.DEV, so production
|
||||
// builds never include it. Start one with "/demo" in the composer.
|
||||
|
||||
import type { AgentStreamEvent } from "@/lib/api/models/app/agent/Agent";
|
||||
|
||||
export type DemoStep = { wait: number; ev: AgentStreamEvent };
|
||||
|
||||
export const DEMO_PROMPT =
|
||||
"How are my campaigns doing? Pause anything that is hurting deliverability.";
|
||||
|
||||
const CAMPAIGN = 'campaign: "Agency re-engagement"';
|
||||
|
||||
// Streams a reply the way a provider does: small word groups at an uneven
|
||||
// pace, then the authoritative full block.
|
||||
function say(text: string): DemoStep[] {
|
||||
const out: DemoStep[] = [];
|
||||
const words = text.split(/(\s+)/);
|
||||
for (let i = 0; i < words.length; i += 4) {
|
||||
out.push({
|
||||
wait: 30 + ((i * 7) % 50),
|
||||
ev: { type: "text_delta", text: words.slice(i, i + 4).join("") },
|
||||
});
|
||||
}
|
||||
out.push({ wait: 120, ev: { type: "text", text } });
|
||||
return out;
|
||||
}
|
||||
|
||||
function tool(name: string, args: string, result: string, ms: number): DemoStep[] {
|
||||
return [
|
||||
{ wait: 260, ev: { type: "tool_start", tool: name, args_summary: args } },
|
||||
{ wait: ms, ev: { type: "tool_result", tool: name, result } },
|
||||
];
|
||||
}
|
||||
|
||||
function iteration(n: number, credits: number): DemoStep {
|
||||
return {
|
||||
wait: 200,
|
||||
ev: { type: "iteration", iteration: n, budget: 12, credits_remaining: credits },
|
||||
};
|
||||
}
|
||||
|
||||
export function demoRun(part: "open" | "approve" | "deny"): DemoStep[] {
|
||||
if (part === "approve") {
|
||||
return [
|
||||
iteration(4, 479),
|
||||
...tool("set_campaign_status", `${CAMPAIGN}, status: "paused"`, "Paused Agency re-engagement", 1100),
|
||||
{ wait: 700, ev: { type: "iteration", iteration: 5, budget: 12 } },
|
||||
...say(
|
||||
"Done. **Agency re-engagement** is paused and nothing else was changed. Resume it from the campaign page once the list is cleaned.",
|
||||
),
|
||||
{ wait: 100, ev: { type: "done", credits_remaining: 478 } },
|
||||
];
|
||||
}
|
||||
if (part === "deny") {
|
||||
return [
|
||||
iteration(4, 479),
|
||||
...say(
|
||||
"Okay, I left it running. Keep an eye on its bounce rate: at 10% the sending mailbox can be blocked from the warmup pool.",
|
||||
),
|
||||
{ wait: 100, ev: { type: "done", credits_remaining: 479 } },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ wait: 900, ev: { type: "iteration", iteration: 1, budget: 12, credits_remaining: 482 } },
|
||||
...tool("list_campaigns", "status: active", "4 active campaigns", 1100),
|
||||
iteration(2, 481),
|
||||
...tool("get_campaign_stats", 'campaign: "Q4 founder outreach"', "41% opened, 6.2% replied, 0.4% bounced", 1200),
|
||||
...tool("get_campaign_stats", CAMPAIGN, "9% opened, 0.3% replied, 7.8% bounced", 1000),
|
||||
...tool("get_mailbox", 'mailbox: "sam@getacme.io"', "warmup throttled, 22% spam placement", 900),
|
||||
...tool("get_advisor_summary", "", "2 open recommendations", 800),
|
||||
iteration(3, 480),
|
||||
{ wait: 1200, ev: { type: "iteration", iteration: 3, budget: 12 } },
|
||||
...say(
|
||||
[
|
||||
"Three of your four active campaigns look healthy. **Q4 founder outreach** is the standout at 41% opened and 6.2% replied.",
|
||||
"",
|
||||
"**Agency re-engagement** is the problem:",
|
||||
"",
|
||||
"- 7.8% of its sends bounced this week, past the 5% line where providers start to push back",
|
||||
"- it sends from `sam@getacme.io`, which is already throttled at 22% spam placement",
|
||||
"- only 0.3% replied, so pausing it costs almost nothing",
|
||||
"",
|
||||
"I'd pause it now and clean the list before it runs again.",
|
||||
].join("\n"),
|
||||
),
|
||||
{
|
||||
wait: 500,
|
||||
ev: {
|
||||
type: "approval_required",
|
||||
tool: "set_campaign_status",
|
||||
risk: "write",
|
||||
tool_call_id: "demo-1",
|
||||
args_summary: `${CAMPAIGN}, status: "paused"`,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// A keystroke signal from the assistant composer to any AgentMark rendered with
|
||||
// `listen`, so the mark bounces while someone types to it.
|
||||
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
export function pulseAgent() {
|
||||
subscribers.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
export function subscribeAgentPulse(fn: () => void): () => void {
|
||||
subscribers.add(fn);
|
||||
return () => {
|
||||
subscribers.delete(fn);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// askRemie opens Remie on a fresh conversation and sends `prompt` there, for
|
||||
// "Ask Remie" buttons outside the panel. The panel picks the question up once
|
||||
// its tab is ready (see agentQueuedPrompt), so this never races hydration.
|
||||
|
||||
import { useAppStore } from "@/stores";
|
||||
|
||||
export function askRemie(prompt: string) {
|
||||
const s = useAppStore.getState();
|
||||
const active = s.agentTabs.find((t) => t.key === s.agentActiveKey);
|
||||
// Reuse an untouched tab rather than stacking empty ones; a typed draft counts as touched.
|
||||
const blank =
|
||||
active && !active.sessionId && active.turns.length === 0 && !active.running && !active.draft.trim();
|
||||
if (!blank) s.agentNewTab();
|
||||
s.agentQueuePrompt(prompt);
|
||||
s.setAgentMinimized(false);
|
||||
s.setAIAssistantOpen(true);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AdvisorFinding } from "@/lib/api/models/app/advisor/Advisor";
|
||||
import { SEVERITY_RANK } from "@/lib/api/models/app/advisor/Advisor";
|
||||
import {
|
||||
TIP_RULES,
|
||||
emptyMemory,
|
||||
fixPromptFor,
|
||||
pickTip,
|
||||
pruneSeen,
|
||||
recordEngaged,
|
||||
recordIgnored,
|
||||
recordShown,
|
||||
suggestionsFrom,
|
||||
tipBlockedReason,
|
||||
} from "./remieTips";
|
||||
|
||||
const NOW = Date.UTC(2026, 9, 4, 12, 0, 0);
|
||||
|
||||
function finding(
|
||||
id: string,
|
||||
severity: AdvisorFinding["severity"],
|
||||
kind = id,
|
||||
surface: AdvisorFinding["surface"] = "emails",
|
||||
extra: Partial<AdvisorFinding> = {},
|
||||
): AdvisorFinding {
|
||||
return {
|
||||
id,
|
||||
organization_id: "org",
|
||||
detector_key: kind,
|
||||
category: "mailbox",
|
||||
severity,
|
||||
surface,
|
||||
status: "open",
|
||||
impact: 1,
|
||||
title: `t-${id}`,
|
||||
detail: "d",
|
||||
remedy: `r-${id}`,
|
||||
narrated: false,
|
||||
first_seen_at: new Date(NOW),
|
||||
last_seen_at: new Date(NOW),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const all = (fs: AdvisorFinding[]) => suggestionsFrom(fs, SEVERITY_RANK.low);
|
||||
|
||||
describe("suggestionsFrom", () => {
|
||||
it("folds one kind of problem into one suggestion named by its group title", () => {
|
||||
const group = { group_title: "{count} new mailboxes are sending at full volume" };
|
||||
const fs = [
|
||||
finding("a", "high", "ramp", "emails", group),
|
||||
finding("b", "high", "ramp", "emails", group),
|
||||
finding("c", "medium", "cap"),
|
||||
];
|
||||
const got = all(fs);
|
||||
expect(got.map((s) => s.text)).toEqual(["2 new mailboxes are sending at full volume", "t-c"]);
|
||||
expect(got[0].findings).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps findings without a group title as their own lines", () => {
|
||||
expect(all([finding("a", "high", "k"), finding("b", "high", "k")]).map((s) => s.text)).toEqual(["t-a", "t-b"]);
|
||||
});
|
||||
|
||||
it("drops what is not open or below the floor", () => {
|
||||
const fs = [finding("a", "low"), { ...finding("b", "critical"), status: "applied" as const }];
|
||||
expect(suggestionsFrom(fs, SEVERITY_RANK.medium)).toEqual([]);
|
||||
});
|
||||
|
||||
it("asks Remie to fix every finding with the recommended change", () => {
|
||||
const group = { group_title: "{count} things" };
|
||||
const fs = [
|
||||
finding("a", "high", "k", "emails", {
|
||||
...group,
|
||||
action: { tool: "x", args: {}, label: "Start at 20/day instead" },
|
||||
}),
|
||||
finding("b", "high", "k", "emails", group),
|
||||
];
|
||||
const prompt = fixPromptFor(all(fs)[0]);
|
||||
expect(prompt).toContain("1. t-a. Recommended: Start at 20/day instead");
|
||||
expect(prompt).toContain("2. t-b. Recommended: r-b");
|
||||
expect(prompt).toContain("ask me before changing anything");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickTip", () => {
|
||||
it("never offers polish", () => {
|
||||
expect(pickTip(all([finding("a", "medium"), finding("b", "low")]), emptyMemory(), NOW, "/app")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the most severe kind", () => {
|
||||
expect(pickTip(all([finding("a", "critical"), finding("b", "high")]), emptyMemory(), NOW, "/app")?.key).toBe("a");
|
||||
});
|
||||
|
||||
it("prefers the current page within the same severity only", () => {
|
||||
const fs = [
|
||||
finding("a", "critical", "a", "campaigns"),
|
||||
finding("c", "critical", "c", "emails"),
|
||||
finding("b", "high", "b", "emails"),
|
||||
];
|
||||
expect(pickTip(all(fs), emptyMemory(), NOW, "/app/emails")?.key).toBe("c");
|
||||
});
|
||||
|
||||
it("offers a suggestion once, and again only when it gets worse", () => {
|
||||
const before = NOW - TIP_RULES.gapMs - 1;
|
||||
const m = recordShown(emptyMemory(), all([finding("a", "high")])[0], before);
|
||||
expect(pickTip(all([finding("a", "high")]), m, NOW, "/app")).toBeNull();
|
||||
expect(pickTip(all([finding("a", "critical")]), m, NOW, "/app")?.key).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("attention limits", () => {
|
||||
it("keeps a gap between tips", () => {
|
||||
const m = recordShown(emptyMemory(), all([finding("a", "high")])[0], NOW - 60_000);
|
||||
expect(tipBlockedReason(m, NOW)).toBe("too-soon");
|
||||
});
|
||||
|
||||
it("stops at the daily limit and resets the next day", () => {
|
||||
let m = emptyMemory();
|
||||
for (let i = 0; i < TIP_RULES.perDay; i++) {
|
||||
m = recordShown(m, all([finding(`x${i}`, "high")])[0], NOW - (TIP_RULES.perDay - i) * (TIP_RULES.gapMs + 1));
|
||||
}
|
||||
expect(tipBlockedReason(m, NOW)).toBe("daily-limit");
|
||||
expect(tipBlockedReason(m, NOW + 24 * 60 * 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
it("backs off after tips are ignored in a row, and engagement resets the streak", () => {
|
||||
let m = recordIgnored(emptyMemory(), NOW);
|
||||
m = recordEngaged(m);
|
||||
m = recordIgnored(m, NOW);
|
||||
expect(m.pausedUntil).toBe(0);
|
||||
m = recordIgnored(m, NOW);
|
||||
expect(tipBlockedReason(m, NOW + 60_000)).toBe("paused");
|
||||
expect(tipBlockedReason(m, NOW + TIP_RULES.backoffMs + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("forgets kinds that closed", () => {
|
||||
const m = recordShown(emptyMemory(), all([finding("a", "high")])[0], NOW);
|
||||
expect(pruneSeen(m, new Set()).seen).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
// When Remie may interrupt with a tip, and which one. Tips are Advisor findings
|
||||
// worth acting on, offered through Remie's speech bubble. The rules exist so a
|
||||
// tip stays rare enough to be read: a tip shown too often is a tip ignored.
|
||||
//
|
||||
// Memory is per member and workspace, in localStorage. It records which kinds
|
||||
// of problem were already offered (and at what severity, so one that gets
|
||||
// worse may be offered once more), today's count, and how tips were received.
|
||||
|
||||
import type { AdvisorFinding, AdvisorSurface } from "@/lib/api/models/app/advisor/Advisor";
|
||||
import { SEVERITY_RANK, groupFindings, groupTitle } from "@/lib/api/models/app/advisor/Advisor";
|
||||
|
||||
export const TIP_RULES = {
|
||||
// Only findings that are hurting sending now, or will.
|
||||
minRank: SEVERITY_RANK.high,
|
||||
perDay: 2,
|
||||
gapMs: 30 * 60_000,
|
||||
// Nothing in the first moments of a visit; people arrive to do something.
|
||||
warmupMs: 20_000,
|
||||
// The member must have paused (no keys, clicks or scrolling) this long.
|
||||
idleMs: 4_000,
|
||||
// A bubble nobody touches leaves on its own.
|
||||
autoHideMs: 15_000,
|
||||
// This many tips closed or ignored in a row pauses tips for backoffMs.
|
||||
ignoreStreak: 2,
|
||||
backoffMs: 3 * 24 * 60 * 60_000,
|
||||
} as const;
|
||||
|
||||
export type TipMemory = {
|
||||
// Kind of problem (detector key) -> the severity rank it had when offered.
|
||||
seen: Record<string, number>;
|
||||
day: string;
|
||||
shownToday: number;
|
||||
lastShownAt: number;
|
||||
ignored: number;
|
||||
pausedUntil: number;
|
||||
off: boolean;
|
||||
};
|
||||
|
||||
export const emptyMemory = (): TipMemory => ({
|
||||
seen: {},
|
||||
day: "",
|
||||
shownToday: 0,
|
||||
lastShownAt: 0,
|
||||
ignored: 0,
|
||||
pausedUntil: 0,
|
||||
off: false,
|
||||
});
|
||||
|
||||
// The member's local calendar day, so "two a day" resets at their midnight.
|
||||
const dayOf = (now: number) => {
|
||||
const d = new Date(now);
|
||||
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
||||
};
|
||||
|
||||
// The dashboard tab each surface's fix lives on, to prefer a tip about the page
|
||||
// the member is already looking at.
|
||||
const SURFACE_PATH: Record<AdvisorSurface, string> = {
|
||||
campaigns: "/app/campaigns",
|
||||
emails: "/app/emails",
|
||||
deliverability: "/app/deliverability",
|
||||
contacts: "/app/contacts",
|
||||
analytics: "/app/analytics",
|
||||
settings: "/app/settings",
|
||||
};
|
||||
|
||||
export function onSurfacePage(f: AdvisorFinding, pathname: string): boolean {
|
||||
return pathname.startsWith(SURFACE_PATH[f.surface]);
|
||||
}
|
||||
|
||||
// One thing Remie suggests: the Advisor's own grouping of open findings (from
|
||||
// two of a kind), so Remie and the Advisor strip name the same problem alike.
|
||||
export type RemieSuggestion = {
|
||||
key: string;
|
||||
text: string;
|
||||
severity: AdvisorFinding["severity"];
|
||||
findings: AdvisorFinding[];
|
||||
};
|
||||
|
||||
export function suggestionsFrom(findings: AdvisorFinding[], minRank: number): RemieSuggestion[] {
|
||||
const open = findings.filter((f) => f.status === "open" && SEVERITY_RANK[f.severity] >= minRank);
|
||||
return groupFindings(open, 2).map((g) => ({
|
||||
key: g.key,
|
||||
text: groupTitle(g),
|
||||
severity: g.lead.severity,
|
||||
findings: g.members,
|
||||
}));
|
||||
}
|
||||
|
||||
// Why no tip may show right now, or null when one may. Findings are not
|
||||
// consulted here; this is only about the member's attention.
|
||||
export function tipBlockedReason(m: TipMemory, now: number): string | null {
|
||||
if (m.off) return "off";
|
||||
if (m.pausedUntil > now) return "paused";
|
||||
if (m.day === dayOf(now) && m.shownToday >= TIP_RULES.perDay) return "daily-limit";
|
||||
if (now - m.lastShownAt < TIP_RULES.gapMs) return "too-soon";
|
||||
return null;
|
||||
}
|
||||
|
||||
// pickTip chooses the suggestion to offer, or null. Suggestions arrive most
|
||||
// severe first; one about the current page wins within the same severity.
|
||||
export function pickTip(
|
||||
suggestions: RemieSuggestion[],
|
||||
m: TipMemory,
|
||||
now: number,
|
||||
pathname: string,
|
||||
): RemieSuggestion | null {
|
||||
if (tipBlockedReason(m, now)) return null;
|
||||
const fresh = suggestions.filter((s) => {
|
||||
const rank = SEVERITY_RANK[s.severity];
|
||||
if (rank < TIP_RULES.minRank) return false;
|
||||
const offered = m.seen[s.key];
|
||||
return offered === undefined || rank > offered;
|
||||
});
|
||||
if (fresh.length === 0) return null;
|
||||
const top = SEVERITY_RANK[fresh[0].severity];
|
||||
const sameRank = fresh.filter((s) => SEVERITY_RANK[s.severity] === top);
|
||||
return sameRank.find((s) => s.findings.some((f) => onSurfacePage(f, pathname))) ?? fresh[0];
|
||||
}
|
||||
|
||||
export function recordShown(m: TipMemory, s: RemieSuggestion, now: number): TipMemory {
|
||||
const today = dayOf(now);
|
||||
return {
|
||||
...m,
|
||||
seen: { ...m.seen, [s.key]: SEVERITY_RANK[s.severity] },
|
||||
day: today,
|
||||
shownToday: (m.day === today ? m.shownToday : 0) + 1,
|
||||
lastShownAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
// The member acted on a tip (fixed it or chose Not now): the streak of
|
||||
// ignored tips resets.
|
||||
export function recordEngaged(m: TipMemory): TipMemory {
|
||||
return { ...m, ignored: 0 };
|
||||
}
|
||||
|
||||
// The member closed a tip or let it time out. Enough of those in a row and
|
||||
// Remie goes quiet for a while.
|
||||
export function recordIgnored(m: TipMemory, now: number): TipMemory {
|
||||
const ignored = m.ignored + 1;
|
||||
if (ignored >= TIP_RULES.ignoreStreak) {
|
||||
return { ...m, ignored: 0, pausedUntil: now + TIP_RULES.backoffMs };
|
||||
}
|
||||
return { ...m, ignored };
|
||||
}
|
||||
|
||||
export function pauseForWeek(m: TipMemory, now: number): TipMemory {
|
||||
return { ...m, pausedUntil: now + 7 * 24 * 60 * 60_000 };
|
||||
}
|
||||
|
||||
// Kinds that no longer have an open finding are dropped, so the memory cannot
|
||||
// grow forever and a problem that comes back later may be mentioned again.
|
||||
export function pruneSeen(m: TipMemory, openKinds: Set<string>): TipMemory {
|
||||
const seen: Record<string, number> = {};
|
||||
for (const [key, rank] of Object.entries(m.seen)) if (openKinds.has(key)) seen[key] = rank;
|
||||
return { ...m, seen };
|
||||
}
|
||||
|
||||
const keyFor = (userId: string, orgId: string) => `remie.tips.${userId}.${orgId}`;
|
||||
|
||||
export function loadMemory(userId: string, orgId: string): TipMemory {
|
||||
try {
|
||||
const raw = localStorage.getItem(keyFor(userId, orgId));
|
||||
return raw ? { ...emptyMemory(), ...JSON.parse(raw) } : emptyMemory();
|
||||
} catch {
|
||||
return emptyMemory();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveMemory(userId: string, orgId: string, m: TipMemory) {
|
||||
try {
|
||||
localStorage.setItem(keyFor(userId, orgId), JSON.stringify(m));
|
||||
} catch {
|
||||
// Storage full or blocked: tips just forget, which errs toward quiet.
|
||||
}
|
||||
}
|
||||
|
||||
// What Remie is asked to do for a suggestion: each finding with the change the
|
||||
// Advisor recommends, fixed through Remie's own tools and approvals.
|
||||
export function fixPromptFor(s: RemieSuggestion): string {
|
||||
const lines = s.findings.map((f, i) => {
|
||||
const fix = f.action?.label ?? f.remedy;
|
||||
return `${i + 1}. ${f.title}. Recommended: ${fix}`;
|
||||
});
|
||||
return [
|
||||
s.findings.length > 1 ? "Please fix these:" : "Please fix this:",
|
||||
...lines,
|
||||
"Check the current state first, make the smallest change that resolves each one, and ask me before changing anything.",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Human labels for agent tool calls, in two tenses: what the agent is doing
|
||||
// while the call runs ("Searching contacts") and what it did once it returns
|
||||
// ("Searched contacts"). Derived from the tool name so a tool added to
|
||||
// internal/app/aitools reads well without an entry here.
|
||||
|
||||
type Tense = { active: string; done: string };
|
||||
|
||||
const VERBS: Record<string, Tense> = {
|
||||
add: { active: "Adding", done: "Added" },
|
||||
bulk: { active: "Editing", done: "Edited" },
|
||||
cancel: { active: "Canceling", done: "Canceled" },
|
||||
complete: { active: "Completing", done: "Completed" },
|
||||
compose: { active: "Composing", done: "Composed" },
|
||||
create: { active: "Creating", done: "Created" },
|
||||
delete: { active: "Deleting", done: "Deleted" },
|
||||
disconnect: { active: "Disconnecting", done: "Disconnected" },
|
||||
draft: { active: "Drafting", done: "Drafted" },
|
||||
fetch: { active: "Fetching", done: "Fetched" },
|
||||
get: { active: "Reading", done: "Read" },
|
||||
invite: { active: "Inviting", done: "Invited" },
|
||||
list: { active: "Listing", done: "Listed" },
|
||||
load: { active: "Loading", done: "Loaded" },
|
||||
mark: { active: "Marking", done: "Marked" },
|
||||
mint: { active: "Creating", done: "Created" },
|
||||
move: { active: "Moving", done: "Moved" },
|
||||
preview: { active: "Previewing", done: "Previewed" },
|
||||
remove: { active: "Removing", done: "Removed" },
|
||||
revoke: { active: "Revoking", done: "Revoked" },
|
||||
rotate: { active: "Rotating", done: "Rotated" },
|
||||
run: { active: "Running", done: "Ran" },
|
||||
search: { active: "Searching", done: "Searched" },
|
||||
send: { active: "Sending", done: "Sent" },
|
||||
set: { active: "Updating", done: "Updated" },
|
||||
snooze: { active: "Snoozing", done: "Snoozed" },
|
||||
submit: { active: "Submitting", done: "Submitted" },
|
||||
unsnooze: { active: "Unsnoozing", done: "Unsnoozed" },
|
||||
update: { active: "Updating", done: "Updated" },
|
||||
verify: { active: "Verifying", done: "Verified" },
|
||||
};
|
||||
|
||||
// Whole-name overrides where the verb + object reading would be clumsy.
|
||||
const NAMED: Record<string, Tense> = {
|
||||
search_web: { active: "Searching the web", done: "Searched the web" },
|
||||
fetch_url: { active: "Reading a web page", done: "Read a web page" },
|
||||
load_skill: { active: "Loading a skill", done: "Loaded a skill" },
|
||||
get_thread: { active: "Reading the thread", done: "Read the thread" },
|
||||
list_threads: { active: "Scanning the inbox", done: "Scanned the inbox" },
|
||||
get_dashboard_analytics: { active: "Reading analytics", done: "Read analytics" },
|
||||
get_advisor_summary: { active: "Checking the advisor", done: "Checked the advisor" },
|
||||
bulk_edit_contacts: { active: "Editing contacts", done: "Edited contacts" },
|
||||
add_tag: { active: "Adding a label", done: "Added a label" },
|
||||
remove_tag: { active: "Removing a label", done: "Removed a label" },
|
||||
set_thread_labels: { active: "Labeling the thread", done: "Labeled the thread" },
|
||||
};
|
||||
|
||||
export function toolTense(tool: string): Tense {
|
||||
const named = NAMED[tool];
|
||||
if (named) return named;
|
||||
const [verb, ...rest] = tool.split("_");
|
||||
const object = rest.join(" ");
|
||||
const v = VERBS[verb];
|
||||
if (!v) {
|
||||
const plain = tool.replace(/_/g, " ");
|
||||
const cap = plain.charAt(0).toUpperCase() + plain.slice(1);
|
||||
return { active: cap, done: cap };
|
||||
}
|
||||
return {
|
||||
active: object ? `${v.active} ${object}` : v.active,
|
||||
done: object ? `${v.done} ${object}` : v.done,
|
||||
};
|
||||
}
|
||||
|
||||
export function toolLabel(tool: string, done = true): string {
|
||||
const t = toolTense(tool);
|
||||
return done ? t.done : t.active;
|
||||
}
|
||||
|
||||
// The imperative form an approval asks about ("Update contact fields").
|
||||
export function toolAction(tool: string): string {
|
||||
const plain = tool.replace(/_/g, " ");
|
||||
return plain.charAt(0).toUpperCase() + plain.slice(1);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import useTypewriter from "./useTypewriter";
|
||||
import formatUsage from "./usage";
|
||||
import AgentMark from "@/components/app/agent/AgentMark";
|
||||
|
||||
export interface AIDraftController {
|
||||
phase: "idle" | "busy" | "review" | "question";
|
||||
@@ -256,7 +257,7 @@ export default function AIDraftBar({
|
||||
transition={{ type: "spring", stiffness: 480, damping: 34 }}
|
||||
className="pointer-events-auto h-8 pl-3 pr-1.5 rounded-full border border-slate-200 bg-white/95 backdrop-blur shadow-[0_8px_24px_-8px_rgba(15,23,42,0.25)] flex items-center gap-2"
|
||||
>
|
||||
<SparklesIcon className="w-3.5 h-3.5 text-sky-500 animate-pulse shrink-0" />
|
||||
<AgentMark variant="bare" size={14} state="thinking" />
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={stage}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SmileIcon,
|
||||
BriefcaseIcon,
|
||||
} from "lucide-react";
|
||||
import AgentMark from "@/components/app/agent/AgentMark";
|
||||
|
||||
export interface AIQuickAction {
|
||||
key: string;
|
||||
@@ -110,7 +111,7 @@ export default function AIEditPopover({
|
||||
if (phase === "busy") {
|
||||
return (
|
||||
<div style={{ width: AI_CARD_WIDTH }} className="px-3 py-2.5 flex items-center gap-2">
|
||||
<SparklesIcon className="w-3.5 h-3.5 text-sky-500 animate-pulse shrink-0" />
|
||||
<AgentMark variant="bare" size={14} state="thinking" />
|
||||
<span className="ai-shimmer-text text-[12px] font-medium">Rewriting…</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-[10px] text-slate-400">
|
||||
<Kbd combo="esc" variant="light" /> cancel
|
||||
|
||||
@@ -22,6 +22,7 @@ import buildError from "@/lib/helper/buildError";
|
||||
import formatUsage from "@/components/app/ai/usage";
|
||||
import { useAnchoredFloating, caretReference } from "@/hooks/useAnchoredFloating";
|
||||
import { passageHTML, passageText, replacePassage } from "./richTextPassage";
|
||||
import AgentMark from "@/components/app/agent/AgentMark";
|
||||
|
||||
const CONTEXT_WINDOW = 1500;
|
||||
|
||||
@@ -260,8 +261,8 @@ export default function RichTextAICaret({ editor }: { editor: Editor }) {
|
||||
>
|
||||
{phase === "busy" ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||
<SparklesIcon className="h-3.5 w-3.5 shrink-0 animate-pulse text-sky-500" />
|
||||
<span className="text-[12px] font-medium text-slate-600">Writing…</span>
|
||||
<AgentMark variant="bare" size={14} state="thinking" />
|
||||
<span className="ai-shimmer-text text-[12px] font-medium">Writing…</span>
|
||||
</div>
|
||||
) : phase === "applied" ? (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2">
|
||||
|
||||
@@ -30,6 +30,7 @@ import ShortcutTooltip, { Kbd } from "@/components/ui/shortcut-tooltip";
|
||||
import textareaRangeRect, { type RangeRect } from "./textareaRange";
|
||||
import useTypewriter from "./useTypewriter";
|
||||
import formatUsage from "./usage";
|
||||
import AgentMark from "@/components/app/agent/AgentMark";
|
||||
|
||||
// Context window around the caret sent with freeform prompts, so long drafts
|
||||
// don't blow past the endpoint's prompt cap.
|
||||
@@ -405,7 +406,7 @@ export default function TextareaAICaret({
|
||||
>
|
||||
{phase === "busy" ? (
|
||||
<div className="px-3 py-2.5 flex items-center gap-2">
|
||||
<SparklesIcon className="w-3.5 h-3.5 text-sky-500 animate-pulse shrink-0" />
|
||||
<AgentMark variant="bare" size={14} state="thinking" />
|
||||
<span className="ai-shimmer-text text-[12px] font-medium">Writing…</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-[10px] text-slate-400">
|
||||
<Kbd combo="esc" variant="light" /> cancel
|
||||
|
||||
@@ -1560,7 +1560,7 @@ export function sourceLabel(source?: string | null): string {
|
||||
case "api":
|
||||
return "Created via the API";
|
||||
case "ai_assistant":
|
||||
return "Created by the AI assistant";
|
||||
return "Created by Remie";
|
||||
case "form":
|
||||
return "Submitted a form";
|
||||
case "automation":
|
||||
@@ -1635,7 +1635,7 @@ function createdLabel(source?: string | null): string {
|
||||
case "campaign":
|
||||
return "Added from campaign";
|
||||
case "ai_assistant":
|
||||
return "Created by AI assistant";
|
||||
return "Created by Remie";
|
||||
case "form":
|
||||
return "Submitted a form";
|
||||
case "automation":
|
||||
|
||||
@@ -7,7 +7,6 @@ import React from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
SparklesIcon,
|
||||
Loader2Icon,
|
||||
ExternalLinkIcon,
|
||||
CopyIcon,
|
||||
CheckIcon,
|
||||
@@ -25,6 +24,8 @@ import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import { TextInput } from "@/components/ui/field";
|
||||
import useAiMetered from "@/hooks/useAiMetered";
|
||||
import { WorkingStatus } from "@/components/app/agent/AgentActivity";
|
||||
import AgentMark from "@/components/app/agent/AgentMark";
|
||||
|
||||
export default function ResearchTab({ contactId }: { contactId: string }) {
|
||||
const research = useContactResearch(contactId);
|
||||
@@ -34,9 +35,14 @@ export default function ResearchTab({ contactId }: { contactId: string }) {
|
||||
|
||||
const runs = research.data?.data ?? [];
|
||||
const latest = runs.find((r) => r.status === "succeeded" || r.status === "nothing_found");
|
||||
const inFlight = runs.some((r) => r.status === "queued" || r.status === "running");
|
||||
const pendingRun = runs.find((r) => r.status === "queued" || r.status === "running");
|
||||
const inFlight = !!pendingRun;
|
||||
// The timer counts from the queued run when there is one, else from the click.
|
||||
const [clickedAt, setClickedAt] = React.useState<number | null>(null);
|
||||
const since = pendingRun ? new Date(pendingRun.created_at).getTime() : clickedAt;
|
||||
|
||||
async function runResearch() {
|
||||
setClickedAt(Date.now());
|
||||
try {
|
||||
await toast.promise(run.mutateAsync(objective.trim()), {
|
||||
loading: "Researching…",
|
||||
@@ -78,7 +84,7 @@ export default function ResearchTab({ contactId }: { contactId: string }) {
|
||||
className="h-7 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{run.isPending || inFlight ? (
|
||||
<Loader2Icon className="w-3 h-3 animate-spin" />
|
||||
<AgentMark variant="bare" size={13} state="thinking" className="!text-white" />
|
||||
) : (
|
||||
<SparklesIcon className="w-3 h-3" />
|
||||
)}
|
||||
@@ -87,8 +93,14 @@ export default function ResearchTab({ contactId }: { contactId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(run.isPending || inFlight) && (
|
||||
<div className="rounded-md border border-slate-200 px-3 py-2.5">
|
||||
<WorkingStatus label="Researching this contact" since={since} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{research.isPending ? (
|
||||
<div className="h-24 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="h-24 rounded skeleton-shimmer" />
|
||||
) : !latest ? (
|
||||
<EmptyResearch />
|
||||
) : (
|
||||
|
||||
@@ -267,7 +267,7 @@ const ENUM_LABELS: Record<string, string> = {
|
||||
sheet_sync: "Google Sheets sync",
|
||||
crm_sync: "CRM sync",
|
||||
api: "API",
|
||||
ai_assistant: "AI assistant",
|
||||
ai_assistant: "Remie",
|
||||
form: "Form submission",
|
||||
automation: "Automation",
|
||||
valid: "Valid",
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
// This component is purely the row. Layout (where it sits) is decided by
|
||||
// AppShell, not here.
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { ChevronRight, Menu, Search } from "lucide-react";
|
||||
import { Logo } from "@/components/svg";
|
||||
import AgentMark from "@/components/app/agent/AgentMark";
|
||||
import RemieTip from "@/components/app/agent/RemieTip";
|
||||
import { useAppStore } from "@/stores";
|
||||
import { ConnectionIndicator } from "@/components/shared/ConnectionIndicator";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
@@ -194,9 +196,8 @@ function Crumb({ children }: { children: React.ReactNode }) {
|
||||
return <div className="flex items-center gap-2 min-w-0">{children}</div>;
|
||||
}
|
||||
|
||||
// The assistant toggle, with a live status badge so background work is never
|
||||
// invisible: pulsing sky while a run streams, amber when a tool waits for
|
||||
// approval, solid sky when a finished response hasn't been read yet.
|
||||
// Remie's toggle. Working, waiting on an approval and finishing all show on the
|
||||
// blob itself, the dot marks an unread reply, and tips hang off it (RemieTip).
|
||||
function AssistantButton() {
|
||||
const open = useAppStore((s) => s.aiAssistantOpen);
|
||||
const minimized = useAppStore((s) => s.agentMinimized);
|
||||
@@ -204,6 +205,8 @@ function AssistantButton() {
|
||||
const setMinimized = useAppStore((s) => s.setAgentMinimized);
|
||||
const tabs = useAppStore((s) => s.agentTabs);
|
||||
const canAI = usePermission("USE_AI");
|
||||
const lastRunOk = useAppStore((s) => s.agentLastRunOk);
|
||||
const anchor = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (!canAI) return null;
|
||||
|
||||
@@ -212,7 +215,9 @@ function AssistantButton() {
|
||||
const unseen = tabs.some((t) => t.unseen);
|
||||
|
||||
return (
|
||||
<ShortcutTooltip label="AI assistant" combo="mod+I" side="bottom">
|
||||
// Relative so Remie's tip bubble can hang off the blob.
|
||||
<div ref={anchor} className="relative">
|
||||
<ShortcutTooltip label="Ask Remie" combo="mod+I" side="bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (open && minimized) {
|
||||
@@ -225,23 +230,21 @@ function AssistantButton() {
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
aria-label="AI assistant"
|
||||
className="relative flex items-center justify-center size-7 rounded-md text-slate-500 hover:text-sky-700 hover:bg-sky-50 transition-colors"
|
||||
aria-label="Ask Remie"
|
||||
className="group relative flex items-center justify-center size-7 rounded-md hover:bg-sky-50 transition-colors"
|
||||
>
|
||||
<AgentMark className="w-4 h-4" />
|
||||
{(running || pending || unseen) && (
|
||||
<span
|
||||
className={
|
||||
"absolute top-0.5 right-0.5 size-1.5 rounded-full ring-2 ring-white " +
|
||||
(running
|
||||
? "bg-sky-500 animate-pulse"
|
||||
: pending
|
||||
? "bg-amber-500"
|
||||
: "bg-sky-500")
|
||||
}
|
||||
/>
|
||||
<AgentMark
|
||||
size={20}
|
||||
celebrate={lastRunOk}
|
||||
state={pending ? "attention" : running ? "thinking" : "idle"}
|
||||
/>
|
||||
{/* Working and waiting show on the mark itself; the dot is an unread reply. */}
|
||||
{unseen && !running && !pending && (
|
||||
<span className="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-sky-500 ring-2 ring-white" />
|
||||
)}
|
||||
</button>
|
||||
</ShortcutTooltip>
|
||||
<RemieTip anchor={anchor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -307,10 +307,128 @@ svg.loading circle {
|
||||
background-size: 220% 100%;
|
||||
animation: ai-shimmer 1.6s linear infinite;
|
||||
}
|
||||
/* A soft blur over the newest streamed characters. */
|
||||
.ai-stream-tail {
|
||||
filter: blur(1.6px);
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 20%, rgba(0, 0, 0, 0.2));
|
||||
mask-image: linear-gradient(90deg, #000 20%, rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
/* The assistant mark (AgentMark): a soft blue blob with light swirling inside, or
|
||||
a bare pixel spark for inline loaders. Colors and the swirl angle are
|
||||
registered properties, so a state change fades and the swirl can turn. */
|
||||
@property --amo-a { syntax: "<color>"; inherits: true; initial-value: #7dd3fc; }
|
||||
@property --amo-b { syntax: "<color>"; inherits: true; initial-value: #0ea5e9; }
|
||||
@property --amo-c { syntax: "<color>"; inherits: true; initial-value: #2563eb; }
|
||||
@property --amo-angle { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
|
||||
.amo {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
color: #0ea5e9;
|
||||
transition: --amo-a 0.6s, --amo-b 0.6s, --amo-c 0.6s, color 0.4s,
|
||||
scale 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.amo-body {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
overflow: hidden;
|
||||
border-radius: 46% 54% 52% 48% / 52% 46% 54% 48%;
|
||||
box-shadow: 0 0.1em 0.3em -0.08em color-mix(in srgb, var(--amo-b) 70%, transparent);
|
||||
animation: amo-blob 5s ease-in-out infinite;
|
||||
}
|
||||
.amo-swirl {
|
||||
position: absolute;
|
||||
inset: -30%;
|
||||
background: conic-gradient(from var(--amo-angle), var(--amo-a), var(--amo-b), var(--amo-c), var(--amo-a));
|
||||
filter: blur(0.1em);
|
||||
animation: amo-turn 7s linear infinite;
|
||||
}
|
||||
.amo-shine {
|
||||
position: absolute;
|
||||
left: var(--amo-sx, 22%);
|
||||
top: var(--amo-sy, 16%);
|
||||
width: 32%;
|
||||
height: 22%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(closest-side, rgb(255 255 255 / 0.9), rgb(255 255 255 / 0));
|
||||
rotate: -28deg;
|
||||
transition: left 0.3s ease-out, top 0.3s ease-out;
|
||||
}
|
||||
.amo-muted { --amo-a: #e2e8f0; --amo-b: #94a3b8; --amo-c: #cbd5e1; color: #94a3b8; }
|
||||
.dark .amo-muted { --amo-a: #62666d; --amo-b: #35373c; --amo-c: #26272b; color: #62666d; }
|
||||
.amo-attention { --amo-a: #fcd34d; --amo-b: #f59e0b; --amo-c: #ea580c; color: #f59e0b; }
|
||||
.amo-done { --amo-a: #6ee7b7; --amo-b: #10b981; --amo-c: #0d9488; color: #10b981; }
|
||||
.amo-thinking .amo-body { animation-duration: 1.6s; }
|
||||
.amo-thinking .amo-swirl { animation-duration: 1.2s; }
|
||||
.amo-b.amo-attention { animation: amo-hop 1.4s ease-in-out infinite; }
|
||||
.amo-b.amo-done { animation: amo-happy 0.7s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||||
.amo-muted .amo-body { animation-duration: 9s; }
|
||||
.amo-muted .amo-shine { opacity: 0.6; }
|
||||
.group:hover .amo { scale: 1.1; }
|
||||
.group:active .amo { scale: 0.9; }
|
||||
.amo.amo-jelly { animation: amo-jelly 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||||
.amo.amo-boop { animation: amo-boop 0.28s ease-out; }
|
||||
@keyframes amo-blob {
|
||||
0%, 100% { border-radius: 46% 54% 52% 48% / 52% 46% 54% 48%; transform: scale(1, 1); }
|
||||
33% { border-radius: 58% 42% 46% 54% / 44% 56% 46% 56%; transform: scale(1.04, 0.97); }
|
||||
66% { border-radius: 42% 58% 56% 44% / 56% 44% 58% 42%; transform: scale(0.97, 1.04); }
|
||||
}
|
||||
@keyframes amo-turn {
|
||||
from { --amo-angle: 0deg; }
|
||||
to { --amo-angle: 360deg; }
|
||||
}
|
||||
@keyframes amo-jelly {
|
||||
0%, 100% { scale: 1; }
|
||||
30% { scale: 1.22 0.84; }
|
||||
55% { scale: 0.9 1.12; }
|
||||
75% { scale: 1.06 0.96; }
|
||||
}
|
||||
@keyframes amo-boop {
|
||||
0%, 100% { scale: 1; }
|
||||
40% { scale: 1.16 0.88; }
|
||||
}
|
||||
@keyframes amo-hop {
|
||||
0%, 55%, 100% { translate: 0 0; }
|
||||
18% { translate: 0 -0.16em; }
|
||||
32% { translate: 0 0; }
|
||||
42% { translate: 0 -0.06em; }
|
||||
}
|
||||
@keyframes amo-happy {
|
||||
0%, 100% { scale: 1; translate: 0 0; }
|
||||
30% { scale: 1.18 0.88; translate: 0 0; }
|
||||
55% { scale: 0.94 1.1; translate: 0 -0.14em; }
|
||||
}
|
||||
/* The bare pixel spark, for inline loaders. */
|
||||
.amo-grid { position: relative; display: grid; }
|
||||
.amo-px {
|
||||
border-radius: 24%;
|
||||
background: currentColor;
|
||||
opacity: var(--o, 0);
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
@keyframes amo-pulse {
|
||||
0%, 100% { opacity: var(--o); }
|
||||
50% { opacity: calc(var(--o) * 0.35); }
|
||||
}
|
||||
@keyframes amo-chase {
|
||||
0%, 100% { opacity: 0.2; }
|
||||
12% { opacity: 1; }
|
||||
45% { opacity: 0.2; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.amo, .amo *, .amo.amo-jelly, .amo.amo-boop { animation: none !important; }
|
||||
}
|
||||
/* The caret holds still while text is arriving and blinks only while waiting. */
|
||||
.ai-caret.is-typing {
|
||||
animation: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ai-shimmer-text { animation: none; background: none; color: #64748b; }
|
||||
.ai-caret { animation: none; }
|
||||
.ai-sheen { animation: none; background: rgba(14, 165, 233, 0.06); }
|
||||
.ai-stream-tail { filter: none; -webkit-mask-image: none; mask-image: none; }
|
||||
}
|
||||
|
||||
/* A light sweep across live status text, tinted from the text's own colour. */
|
||||
|
||||
@@ -109,7 +109,7 @@ export const globalShortcuts: GlobalShortcut[] = [
|
||||
// ── The assistant, which is reachable while typing ───────────────────────
|
||||
{
|
||||
keys: ['Ctrl', 'i'],
|
||||
description: 'Open / close the assistant',
|
||||
description: 'Open / close Remie',
|
||||
group: 'assistant',
|
||||
whileTyping: true,
|
||||
available: () => checkPermission('USE_AI'),
|
||||
@@ -372,7 +372,7 @@ export const shortcutGroupTitles: Record<ShortcutGroupId, string> = {
|
||||
navigation: 'Navigation',
|
||||
list: 'Lists',
|
||||
actions: 'Actions',
|
||||
assistant: 'Assistant',
|
||||
assistant: 'Remie',
|
||||
}
|
||||
|
||||
const allRows: ShortcutRow[] = [...globalShortcuts, ...panelShortcuts]
|
||||
|
||||
@@ -29,7 +29,7 @@ export default async function streamAgentRun(
|
||||
});
|
||||
} catch (e) {
|
||||
if ((e as Error)?.name === "AbortError") return;
|
||||
onEvent({ type: "error", message: "Could not reach the assistant." });
|
||||
onEvent({ type: "error", message: "Could not reach Remie." });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -40,10 +40,10 @@ export default async function streamAgentRun(
|
||||
onEvent({
|
||||
type: "error",
|
||||
code: j.code,
|
||||
message: j.message || "The assistant is unavailable.",
|
||||
message: j.message || "Remie is unavailable.",
|
||||
});
|
||||
} catch {
|
||||
onEvent({ type: "error", message: "The assistant is unavailable." });
|
||||
onEvent({ type: "error", message: "Remie is unavailable." });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ export const CONTACT_SOURCES = [
|
||||
{ value: "api", label: "API" },
|
||||
{ value: "form", label: "Form submission" },
|
||||
{ value: "automation", label: "Automation" },
|
||||
{ value: "ai_assistant", label: "AI assistant" },
|
||||
{ value: "ai_assistant", label: "Remie" },
|
||||
];
|
||||
|
||||
export const TRIGGER_FIELDS: Record<string, TriggerFieldDef[]> = {
|
||||
|
||||
@@ -47,7 +47,7 @@ export const PERMISSION_CATALOG: PermissionDef[] = [
|
||||
{ key: "MANAGE_SEQUENCES", bit: PERMISSION_BITS.MANAGE_SEQUENCES, label: "Manage steps", description: "Edit step content + spacing inside a campaign.", category: "data" },
|
||||
{ key: "VIEW_ANALYTICS", bit: PERMISSION_BITS.VIEW_ANALYTICS, label: "View analytics", description: "See deliverability + engagement reports.", category: "data" },
|
||||
{ key: "USE_INTEGRATIONS", bit: PERMISSION_BITS.USE_INTEGRATIONS, label: "Use integrations", description: "Push contacts and deals to connected CRMs and tools.", category: "data" },
|
||||
{ key: "USE_AI", bit: PERMISSION_BITS.USE_AI, label: "Use AI", description: "Use the AI assistant and AI drafting (spends workspace credits).", category: "data" },
|
||||
{ key: "USE_AI", bit: PERMISSION_BITS.USE_AI, label: "Use AI", description: "Use Remie, the AI assistant, and AI drafting (spends workspace credits).", category: "data" },
|
||||
// People
|
||||
{ key: "MANAGE_TEAM", bit: PERMISSION_BITS.MANAGE_TEAM, label: "Manage team", description: "Invite, remove and re-role members.", category: "people" },
|
||||
{ key: "TRANSFER_OWNERSHIP", bit: PERMISSION_BITS.TRANSFER_OWNERSHIP, label: "Transfer ownership", description: "Hand workspace ownership to another member.", category: "people" },
|
||||
|
||||
@@ -114,11 +114,17 @@ export interface AgentSlice {
|
||||
// its width in the normal (non-expanded) mode.
|
||||
agentSide: 'left' | 'right'
|
||||
agentWidth: number
|
||||
// Floating mode: the panel detaches into a movable, resizable window.
|
||||
// Floating mode (the default): the panel is a movable, resizable window.
|
||||
// agentSide is kept as the dock-back target. Rect is null until the first
|
||||
// pop-out (the component computes a default from the viewport).
|
||||
agentFloating: boolean
|
||||
agentFloatRect: AgentFloatRect | null
|
||||
// A question handed to Remie from elsewhere in the dashboard (a tip's "Ask
|
||||
// Remie"). The panel sends it once a tab is ready, then clears it.
|
||||
agentQueuedPrompt: string | null
|
||||
// Whether the most recent run ended without an error or a stop, so the
|
||||
// mark only celebrates a run that actually succeeded.
|
||||
agentLastRunOk: boolean
|
||||
|
||||
setAgentExpanded: (v: boolean) => void
|
||||
setAgentMinimized: (v: boolean) => void
|
||||
@@ -129,6 +135,8 @@ export interface AgentSlice {
|
||||
// Size is clamped to the float bounds; position clamping is the caller's
|
||||
// job (it knows the viewport).
|
||||
setAgentFloatRect: (r: AgentFloatRect) => void
|
||||
agentQueuePrompt: (prompt: string | null) => void
|
||||
setAgentLastRunOk: (ok: boolean) => void
|
||||
// Ensure at least one tab exists (called when the panel first opens).
|
||||
agentEnsureTab: () => void
|
||||
// Open a brand-new empty conversation tab and focus it.
|
||||
@@ -151,8 +159,10 @@ export const createAgentSlice: StateCreator<AgentSlice, [], [], AgentSlice> = (s
|
||||
agentMinimized: false,
|
||||
agentSide: 'right',
|
||||
agentWidth: AGENT_DEFAULT_WIDTH,
|
||||
agentFloating: false,
|
||||
agentFloating: true,
|
||||
agentFloatRect: null,
|
||||
agentQueuedPrompt: null,
|
||||
agentLastRunOk: true,
|
||||
|
||||
setAgentExpanded: (agentExpanded) => set({ agentExpanded }),
|
||||
setAgentMinimized: (agentMinimized) => set({ agentMinimized }),
|
||||
@@ -170,6 +180,9 @@ export const createAgentSlice: StateCreator<AgentSlice, [], [], AgentSlice> = (s
|
||||
},
|
||||
}),
|
||||
|
||||
agentQueuePrompt: (agentQueuedPrompt) => set({ agentQueuedPrompt }),
|
||||
setAgentLastRunOk: (agentLastRunOk) => set({ agentLastRunOk }),
|
||||
|
||||
agentEnsureTab: () => {
|
||||
if (get().agentTabs.length > 0) return
|
||||
const tab = makeTab()
|
||||
|
||||
@@ -38,6 +38,14 @@ export const useAppStore = create<AppStore>()(
|
||||
}),
|
||||
{
|
||||
name: 'warmbly-storage',
|
||||
// v1: Remie opens as a floating window. A docked value stored before
|
||||
// that was the old default, not a choice, so it is reset once.
|
||||
version: 1,
|
||||
migrate: (persisted, version) => {
|
||||
const p = (persisted ?? {}) as Partial<AppStore>
|
||||
if (version < 1) p.agentFloating = true
|
||||
return p as AppStore
|
||||
},
|
||||
// Rehydration does not go through the slice setters, so re-clamp the
|
||||
// stored values that have bounds. Without this a value from an older build
|
||||
// (or a hand-edited one) renders as `width: NaNpx`.
|
||||
|
||||
Reference in New Issue
Block a user