responsiveness fix
This commit is contained in:
parent
4ad05bf137
commit
f7a4753217
9 changed files with 396 additions and 13 deletions
|
|
@ -58,6 +58,7 @@ from app.services.reply_extractor import (
|
||||||
)
|
)
|
||||||
from app.services.thread_service import (
|
from app.services.thread_service import (
|
||||||
attach_message_to_thread,
|
attach_message_to_thread,
|
||||||
|
create_thread,
|
||||||
message_event_at,
|
message_event_at,
|
||||||
threading_fields_from_parent,
|
threading_fields_from_parent,
|
||||||
)
|
)
|
||||||
|
|
@ -102,8 +103,8 @@ def build_handoff_subject(
|
||||||
del rule_name
|
del rule_name
|
||||||
subject = " · ".join(
|
subject = " · ".join(
|
||||||
[
|
[
|
||||||
_safe_subject_part(sender_name, "unknown sender"),
|
|
||||||
"handoff",
|
"handoff",
|
||||||
|
_safe_subject_part(sender_name, "unknown sender"),
|
||||||
_safe_subject_part(external_sender, "unknown sender"),
|
_safe_subject_part(external_sender, "unknown sender"),
|
||||||
_safe_subject_part(external_subject, "(No subject)"),
|
_safe_subject_part(external_subject, "(No subject)"),
|
||||||
]
|
]
|
||||||
|
|
@ -111,6 +112,48 @@ def build_handoff_subject(
|
||||||
return subject[:998].rstrip()
|
return subject[:998].rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
_EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
_SUBJECT_PREFIX = re.compile(r"^(?:re|fw|fwd)\s*:\s*", re.IGNORECASE)
|
||||||
|
_HANDOFF_OPENER = re.compile(
|
||||||
|
r"^handoff(?:\s*·\s*|\s*\|\s*|\s+-\s+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_HANDOFF_SEPARATOR = re.compile(r"\s*·\s*|\s*\|\s*|\s+-\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_human_initiated_subject(subject: str | None) -> dict | None:
|
||||||
|
"""Read `handoff · Name · email · subject` from a human-started email.
|
||||||
|
|
||||||
|
The word handoff must come first. The three parts after it are the
|
||||||
|
customer name, the customer email, and the subject the customer should
|
||||||
|
see. Separate them with ·, |, or a spaced hyphen.
|
||||||
|
"""
|
||||||
|
cleaned = re.sub(r"[\r\n]+", " ", subject or "").strip()
|
||||||
|
while True:
|
||||||
|
without_prefix = _SUBJECT_PREFIX.sub("", cleaned, count=1).strip()
|
||||||
|
if without_prefix == cleaned:
|
||||||
|
break
|
||||||
|
cleaned = without_prefix
|
||||||
|
opener = _HANDOFF_OPENER.match(cleaned)
|
||||||
|
if opener is None:
|
||||||
|
return None
|
||||||
|
parts = _HANDOFF_SEPARATOR.split(cleaned[opener.end() :], maxsplit=2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
return None
|
||||||
|
customer_name = parts[0].strip()
|
||||||
|
customer_email = parts[1].strip().lower()
|
||||||
|
customer_subject = parts[2].strip()
|
||||||
|
if not customer_name or not customer_subject:
|
||||||
|
return None
|
||||||
|
if _EMAIL_PATTERN.fullmatch(customer_email) is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"customer_name": customer_name,
|
||||||
|
"customer_email": customer_email,
|
||||||
|
"subject": customer_subject,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def redact_human_address(
|
def redact_human_address(
|
||||||
value: str | None,
|
value: str | None,
|
||||||
*,
|
*,
|
||||||
|
|
@ -304,6 +347,223 @@ async def resolve_handoff_from_inbound(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def start_human_initiated_handoff(
|
||||||
|
database,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
agent_email: str,
|
||||||
|
parsed: dict,
|
||||||
|
body_text: str,
|
||||||
|
body_html: str | None,
|
||||||
|
raw_mime: dict,
|
||||||
|
delivery_key: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Send the first customer email when a Human Owner writes to Gentic.
|
||||||
|
|
||||||
|
Subject shape: handoff · Customer name · customer@email · Subject
|
||||||
|
The customer receives a normal new email. Later replies from that
|
||||||
|
customer are mirrored back on the same handoff thread.
|
||||||
|
"""
|
||||||
|
initiated = parse_human_initiated_subject(parsed.get("subject"))
|
||||||
|
if initiated is None:
|
||||||
|
return False
|
||||||
|
sender = parsed.get("sender") or {}
|
||||||
|
human_email = (sender.get("address") or "").strip().lower()
|
||||||
|
if not human_email:
|
||||||
|
return False
|
||||||
|
rules = (
|
||||||
|
await database.human_handoff_rules.find(
|
||||||
|
{
|
||||||
|
"account_id": account_id,
|
||||||
|
"owner_email": human_email,
|
||||||
|
"active": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.sort([("priority", 1), ("created_at", 1), ("_id", 1)])
|
||||||
|
.to_list(length=1)
|
||||||
|
)
|
||||||
|
if not rules:
|
||||||
|
return False
|
||||||
|
rule = rules[0]
|
||||||
|
|
||||||
|
delivery_marker = f"handoff-init:{account_id}:{delivery_key}"
|
||||||
|
if await database.messages.find_one({"handoff_delivery_key": delivery_marker}):
|
||||||
|
return True
|
||||||
|
|
||||||
|
extracted = extract_new_reply_content(body_text=body_text, body_html=body_html)
|
||||||
|
rich = None
|
||||||
|
try:
|
||||||
|
raw = await get_raw_mime_storage().get(raw_mime)
|
||||||
|
rich = extract_new_reply_mime_content(raw)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"handoff_initiate_rich_extract_failed delivery=%s",
|
||||||
|
delivery_key,
|
||||||
|
)
|
||||||
|
new_text = (
|
||||||
|
rich.body_text if rich and rich.body_text else extracted["new_reply_content"]
|
||||||
|
).strip()
|
||||||
|
new_html = rich.body_html if rich and rich.body_html else None
|
||||||
|
new_text = (
|
||||||
|
redact_human_address(
|
||||||
|
new_text,
|
||||||
|
human_email=human_email,
|
||||||
|
agent_email=agent_email,
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
new_html = redact_human_address(
|
||||||
|
new_html,
|
||||||
|
human_email=human_email,
|
||||||
|
agent_email=agent_email,
|
||||||
|
)
|
||||||
|
if not new_text or not validate_external_reply_body(new_text):
|
||||||
|
logger.info(
|
||||||
|
"handoff_initiate_not_sent account_id=%s reason=empty_or_private_body",
|
||||||
|
account_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
_, domain, sender_name = await _account_and_domain(account_id, agent_email)
|
||||||
|
customer_name = initiated["customer_name"]
|
||||||
|
customer_email = initiated["customer_email"]
|
||||||
|
customer_subject = initiated["subject"]
|
||||||
|
internet_message_id = await deliver_email(
|
||||||
|
sender=agent_email,
|
||||||
|
sender_name=sender_name,
|
||||||
|
recipients=[customer_email],
|
||||||
|
cc=[],
|
||||||
|
bcc=[],
|
||||||
|
subject=customer_subject,
|
||||||
|
body_text=new_text,
|
||||||
|
body_html=new_html,
|
||||||
|
domain=domain,
|
||||||
|
reply_to=agent_email,
|
||||||
|
envelope_sender=agent_email,
|
||||||
|
inline_parts=getattr(rich, "inline_parts", None),
|
||||||
|
file_attachments=getattr(rich, "file_attachments", None),
|
||||||
|
)
|
||||||
|
now = _now()
|
||||||
|
normalized_id = canonical_message_id(internet_message_id)
|
||||||
|
external = {
|
||||||
|
"direction": "outbound",
|
||||||
|
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||||
|
"message_origin": MESSAGE_ORIGIN_HUMAN_OWNER,
|
||||||
|
"generated_by": GENERATED_BY_HANDOFF_BRIDGE,
|
||||||
|
"account_id": account_id,
|
||||||
|
"sender": {"name": sender_name, "address": agent_email.lower()},
|
||||||
|
"to": [{"name": customer_name, "address": customer_email}],
|
||||||
|
"cc": [],
|
||||||
|
"bcc": [],
|
||||||
|
"subject": customer_subject,
|
||||||
|
"snippet": _snippet(new_text),
|
||||||
|
"new_reply_content": new_text,
|
||||||
|
"body_text": new_text,
|
||||||
|
"body_html": new_html,
|
||||||
|
"attachments": [],
|
||||||
|
"created_at": now,
|
||||||
|
"sent_at": now,
|
||||||
|
"status": "accepted",
|
||||||
|
"thread_relation": "new_thread",
|
||||||
|
"thread_resolution_method": "human_handoff_initiate",
|
||||||
|
**message_id_fields(normalized_id),
|
||||||
|
}
|
||||||
|
inserted = await database.messages.insert_one(external)
|
||||||
|
external["_id"] = inserted.inserted_id
|
||||||
|
thread_id = await create_thread(
|
||||||
|
database,
|
||||||
|
account_id=account_id,
|
||||||
|
subject=customer_subject,
|
||||||
|
message_id=external["_id"],
|
||||||
|
event_at=now,
|
||||||
|
)
|
||||||
|
await database.mailbox_entries.insert_one(
|
||||||
|
{
|
||||||
|
"account_id": account_id,
|
||||||
|
"message_id": external["_id"],
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"mailbox": "sent",
|
||||||
|
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||||
|
"direction": "outbound",
|
||||||
|
"sender": external["sender"],
|
||||||
|
"recipients": external["to"],
|
||||||
|
"subject": customer_subject,
|
||||||
|
"snippet": external["snippet"],
|
||||||
|
"attachments": [],
|
||||||
|
"status": "accepted",
|
||||||
|
"is_read": True,
|
||||||
|
"is_starred": False,
|
||||||
|
"event_at": now,
|
||||||
|
"created_at": now,
|
||||||
|
"trashed_at": None,
|
||||||
|
"deletion_state": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
handoff = await _create_handoff_mapping(
|
||||||
|
database,
|
||||||
|
account_id=account_id,
|
||||||
|
agent_email=agent_email,
|
||||||
|
external_message={
|
||||||
|
"_id": external["_id"],
|
||||||
|
"sender": {"name": customer_name, "address": customer_email},
|
||||||
|
"subject": customer_subject,
|
||||||
|
"message_id": normalized_id,
|
||||||
|
},
|
||||||
|
external_thread_id=thread_id,
|
||||||
|
rule=rule,
|
||||||
|
)
|
||||||
|
human_message = await store_handoff_message(
|
||||||
|
database,
|
||||||
|
account_id=account_id,
|
||||||
|
thread_id=handoff["handoff_thread_id"],
|
||||||
|
direction="inbound",
|
||||||
|
sender={"name": sender.get("name") or "", "address": human_email},
|
||||||
|
recipients=[{"name": sender_name, "address": agent_email.lower()}],
|
||||||
|
subject=handoff["private_subject"],
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
internet_message_id=parsed.get("message_id"),
|
||||||
|
in_reply_to=parsed.get("in_reply_to"),
|
||||||
|
references=parsed.get("references") or [],
|
||||||
|
handoff_id=handoff["_id"],
|
||||||
|
event_at=parsed.get("sent_at") or now,
|
||||||
|
raw_mime=raw_mime,
|
||||||
|
handoff_delivery_key=delivery_marker,
|
||||||
|
new_reply_content=new_text,
|
||||||
|
new_reply_html=new_html,
|
||||||
|
quoted_content=extracted.get("quoted_content", ""),
|
||||||
|
message_origin=MESSAGE_ORIGIN_HUMAN_OWNER,
|
||||||
|
generated_by=GENERATED_BY_HUMAN,
|
||||||
|
relay_status=RELAY_STATUS_SENT,
|
||||||
|
)
|
||||||
|
await database.messages.update_one(
|
||||||
|
{"_id": human_message["_id"]},
|
||||||
|
{"$set": {"generated_external_message_id": external["_id"]}},
|
||||||
|
)
|
||||||
|
await database.messages.update_one(
|
||||||
|
{"_id": external["_id"]},
|
||||||
|
{"$set": {"generated_from_handoff_message_id": human_message["_id"]}},
|
||||||
|
)
|
||||||
|
await database.human_handoffs.update_one(
|
||||||
|
{"_id": handoff["_id"]},
|
||||||
|
{
|
||||||
|
"$set": {
|
||||||
|
"status": HANDOFF_STATUS_HUMAN_REPLIED,
|
||||||
|
"current_owner": OWNER_AGENT,
|
||||||
|
"last_human_message_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"handoff_initiated account_id=%s handoff_id=%s customer=%s",
|
||||||
|
account_id,
|
||||||
|
handoff["_id"],
|
||||||
|
customer_email,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _record_link(
|
async def _record_link(
|
||||||
database,
|
database,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from app.services.human_handoff_service import (
|
||||||
maybe_trigger_handoff,
|
maybe_trigger_handoff,
|
||||||
process_human_reply,
|
process_human_reply,
|
||||||
resolve_handoff_from_inbound,
|
resolve_handoff_from_inbound,
|
||||||
|
start_human_initiated_handoff,
|
||||||
)
|
)
|
||||||
from app.services.message_ids import canonical_message_id, message_id_fields
|
from app.services.message_ids import canonical_message_id, message_id_fields
|
||||||
from app.services.mime_parser import parse_mime
|
from app.services.mime_parser import parse_mime
|
||||||
|
|
@ -154,6 +155,23 @@ class InboundHandler:
|
||||||
references=parsed.get("references", []),
|
references=parsed.get("references", []),
|
||||||
)
|
)
|
||||||
if handoff is None:
|
if handoff is None:
|
||||||
|
started = await start_human_initiated_handoff(
|
||||||
|
mongo_db,
|
||||||
|
account_id=account_id,
|
||||||
|
agent_email=recipient,
|
||||||
|
parsed=parsed,
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
raw_mime=raw_mime,
|
||||||
|
delivery_key=delivery_key,
|
||||||
|
)
|
||||||
|
if started:
|
||||||
|
logger.info(
|
||||||
|
"handoff_initiated_from_human account_id=%s message_id=%s",
|
||||||
|
account_id,
|
||||||
|
internet_message_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
external_recipients.append(recipient)
|
external_recipients.append(recipient)
|
||||||
continue
|
continue
|
||||||
await process_human_reply(
|
await process_human_reply(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ TEXT_QUOTE_MARKERS = [
|
||||||
PRIVATE_LEAK_MARKERS = [
|
PRIVATE_LEAK_MARKERS = [
|
||||||
"human handoff",
|
"human handoff",
|
||||||
" · handoff · ",
|
" · handoff · ",
|
||||||
|
"handoff · ",
|
||||||
|
"handoff - ",
|
||||||
|
"handoff | ",
|
||||||
"a conversation needs your attention",
|
"a conversation needs your attention",
|
||||||
"why this was handed off",
|
"why this was handed off",
|
||||||
"why you're receiving this",
|
"why you're receiving this",
|
||||||
|
|
|
||||||
|
|
@ -282,7 +282,7 @@ def test_private_subject_stays_off_the_external_plane():
|
||||||
external_sender="customer@example.com",
|
external_sender="customer@example.com",
|
||||||
external_subject="Invoice 42",
|
external_subject="Invoice 42",
|
||||||
)
|
)
|
||||||
assert private.startswith("Parth Dixit · handoff ·")
|
assert private.startswith("handoff · Parth Dixit ·")
|
||||||
assert "Invoice 42" in private
|
assert "Invoice 42" in private
|
||||||
assert "Finance approvals" not in private
|
assert "Finance approvals" not in private
|
||||||
|
|
||||||
|
|
@ -332,7 +332,7 @@ async def test_first_matching_rule_creates_a_new_linked_handoff():
|
||||||
assert result["rule_name"] == "Finance approvals"
|
assert result["rule_name"] == "Finance approvals"
|
||||||
assert result["external_thread_id"] == external_thread_id
|
assert result["external_thread_id"] == external_thread_id
|
||||||
assert result["handoff_thread_id"]
|
assert result["handoff_thread_id"]
|
||||||
assert result["private_subject"].startswith("Parth Dixit · handoff ·")
|
assert result["private_subject"].startswith("handoff · Parth Dixit ·")
|
||||||
assert database.handoff_delivery_jobs.items[0]["kind"] == "MIRROR_TO_HUMAN"
|
assert database.handoff_delivery_jobs.items[0]["kind"] == "MIRROR_TO_HUMAN"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from app.services.human_handoff_service import (
|
||||||
_mirror_from_identity,
|
_mirror_from_identity,
|
||||||
build_handoff_subject,
|
build_handoff_subject,
|
||||||
maybe_trigger_handoff,
|
maybe_trigger_handoff,
|
||||||
|
parse_human_initiated_subject,
|
||||||
process_human_reply,
|
process_human_reply,
|
||||||
redact_human_address,
|
redact_human_address,
|
||||||
resolve_handoff_from_inbound,
|
resolve_handoff_from_inbound,
|
||||||
|
|
@ -97,6 +98,32 @@ def test_mirror_from_stays_on_gentic_address():
|
||||||
assert agent_address == "suyash@gentic.email"
|
assert agent_address == "suyash@gentic.email"
|
||||||
|
|
||||||
|
|
||||||
|
def test_human_initiated_subject_accepts_dot_hyphen_and_bar():
|
||||||
|
assert parse_human_initiated_subject(
|
||||||
|
"handoff · Suyash Dixit · suyashdixit091@gmail.com · hello"
|
||||||
|
) == {
|
||||||
|
"customer_name": "Suyash Dixit",
|
||||||
|
"customer_email": "suyashdixit091@gmail.com",
|
||||||
|
"subject": "hello",
|
||||||
|
}
|
||||||
|
assert parse_human_initiated_subject(
|
||||||
|
"handoff - Suyash Dixit - suyashdixit091@gmail.com - Need a quote"
|
||||||
|
)["subject"] == "Need a quote"
|
||||||
|
parsed = parse_human_initiated_subject(
|
||||||
|
"Re: handoff | Suyash Dixit | SuyashDixit091@gmail.com | hello - there"
|
||||||
|
)
|
||||||
|
assert parsed["customer_email"] == "suyashdixit091@gmail.com"
|
||||||
|
assert parsed["customer_name"] == "Suyash Dixit"
|
||||||
|
assert parsed["subject"] == "hello - there"
|
||||||
|
|
||||||
|
|
||||||
|
def test_human_initiated_subject_rejects_incomplete_patterns():
|
||||||
|
assert parse_human_initiated_subject("Suyash Dixit · handoff · a@b.com · hello") is None
|
||||||
|
assert parse_human_initiated_subject("handoff · Suyash Dixit · not-an-email · hello") is None
|
||||||
|
assert parse_human_initiated_subject("handoff-Suyash Dixit-a@b.com-hello") is None
|
||||||
|
assert parse_human_initiated_subject("hello from the customer") is None
|
||||||
|
|
||||||
|
|
||||||
def test_private_subject_contains_customer_name_and_external_subject():
|
def test_private_subject_contains_customer_name_and_external_subject():
|
||||||
subject = build_handoff_subject(
|
subject = build_handoff_subject(
|
||||||
sender_name="Parth Dixit",
|
sender_name="Parth Dixit",
|
||||||
|
|
@ -104,7 +131,7 @@ def test_private_subject_contains_customer_name_and_external_subject():
|
||||||
external_subject="Invoice 42",
|
external_subject="Invoice 42",
|
||||||
)
|
)
|
||||||
assert subject == (
|
assert subject == (
|
||||||
"Parth Dixit · handoff · parthdixit091@gmail.com · Invoice 42"
|
"handoff · Parth Dixit · parthdixit091@gmail.com · Invoice 42"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
LogOut,
|
LogOut,
|
||||||
Mail,
|
Mail,
|
||||||
Menu,
|
Menu,
|
||||||
|
PanelLeftOpen,
|
||||||
HandHelping,
|
HandHelping,
|
||||||
Plug,
|
Plug,
|
||||||
Send,
|
Send,
|
||||||
|
|
@ -173,10 +174,16 @@ export default function DashboardPage() {
|
||||||
window.location.assign("/login");
|
window.location.assign("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeMobileMenu() {
|
||||||
|
if (window.matchMedia("(max-width: 767px)").matches) {
|
||||||
|
setMenuOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectCompanyItem(label) {
|
function selectCompanyItem(label) {
|
||||||
setCompanyActive(label);
|
setCompanyActive(label);
|
||||||
setAgentNav(null);
|
setAgentNav(null);
|
||||||
setMenuOpen(false);
|
closeMobileMenu();
|
||||||
setSheet(null);
|
setSheet(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,7 +200,7 @@ export default function DashboardPage() {
|
||||||
setActive(item);
|
setActive(item);
|
||||||
setLens("all");
|
setLens("all");
|
||||||
}
|
}
|
||||||
setMenuOpen(false);
|
closeMobileMenu();
|
||||||
setSheet(null);
|
setSheet(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,7 +209,7 @@ export default function DashboardPage() {
|
||||||
setAgentNav(null);
|
setAgentNav(null);
|
||||||
setActive("Inbox");
|
setActive("Inbox");
|
||||||
setLens(name);
|
setLens(name);
|
||||||
setMenuOpen(false);
|
closeMobileMenu();
|
||||||
setSheet(null);
|
setSheet(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,7 +218,7 @@ export default function DashboardPage() {
|
||||||
setActive("Inbox");
|
setActive("Inbox");
|
||||||
setLens("all");
|
setLens("all");
|
||||||
setAgentNav(actionKey);
|
setAgentNav(actionKey);
|
||||||
setMenuOpen(false);
|
closeMobileMenu();
|
||||||
const compact = window.matchMedia("(max-width: 1199px)").matches;
|
const compact = window.matchMedia("(max-width: 1199px)").matches;
|
||||||
setSheet(actionKey === "handoff" || !compact ? null : "agent");
|
setSheet(actionKey === "handoff" || !compact ? null : "agent");
|
||||||
}
|
}
|
||||||
|
|
@ -221,7 +228,7 @@ export default function DashboardPage() {
|
||||||
setActive("Inbox");
|
setActive("Inbox");
|
||||||
setLens("all");
|
setLens("all");
|
||||||
setAgentNav(actionKey);
|
setAgentNav(actionKey);
|
||||||
setMenuOpen(false);
|
closeMobileMenu();
|
||||||
setSheet("agent");
|
setSheet("agent");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,7 +236,7 @@ export default function DashboardPage() {
|
||||||
setCompanyActive(null);
|
setCompanyActive(null);
|
||||||
setActive("Inbox");
|
setActive("Inbox");
|
||||||
setLens("all");
|
setLens("all");
|
||||||
setMenuOpen(false);
|
closeMobileMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSheet(next) {
|
function openSheet(next) {
|
||||||
|
|
@ -326,7 +333,13 @@ export default function DashboardPage() {
|
||||||
<aside className={menuOpen ? "dashboard-sidebar open" : "dashboard-sidebar"}>
|
<aside className={menuOpen ? "dashboard-sidebar open" : "dashboard-sidebar"}>
|
||||||
<div className="dashboard-brand-row">
|
<div className="dashboard-brand-row">
|
||||||
<Brand />
|
<Brand />
|
||||||
<button aria-label="Close navigation" type="button" onClick={() => setMenuOpen(false)}><X size={19} /></button>
|
<button
|
||||||
|
aria-label={menuOpen ? "Collapse navigation" : "Expand navigation"}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMenuOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
{menuOpen ? <X size={19} /> : <PanelLeftOpen size={19} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isOrgOnboarding && (
|
{!isOrgOnboarding && (
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,20 @@ export default function HumanHandoff({ onOpenExternal }) {
|
||||||
<p className="handoff-page-lead">
|
<p className="handoff-page-lead">
|
||||||
Manage conversations that your Agent has handed over to a Human Owner.
|
Manage conversations that your Agent has handed over to a Human Owner.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="handoff-start-help">
|
||||||
|
<strong>Start the first email yourself</strong>
|
||||||
|
<p>
|
||||||
|
From the human mailbox, send a new email to your Gentic address.
|
||||||
|
Put the word handoff first. Then add the customer’s name, their email address, and the subject they should see.
|
||||||
|
Separate those four parts with a dot · , a hyphen - , or a bar |.
|
||||||
|
</p>
|
||||||
|
<p className="handoff-start-example">handoff · Suyash Dixit · suyashdixit091@gmail.com · hello</p>
|
||||||
|
<p>
|
||||||
|
Gentic sends that as a normal first email. The customer only sees “hello”, from your Gentic address.
|
||||||
|
Your From address must be the human email saved on an active handoff rule.
|
||||||
|
When the customer replies to Gentic, that reply is mirrored back to you the same way as any other handoff.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<nav className="human-handoff-tabs" aria-label="Human Handoff">
|
<nav className="human-handoff-tabs" aria-label="Human Handoff">
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,13 @@
|
||||||
padding:16px 8px 12px;
|
padding:16px 8px 12px;
|
||||||
z-index:12;
|
z-index:12;
|
||||||
}
|
}
|
||||||
.dashboard-brand-row { justify-content:center; padding:0; }
|
.dashboard-brand-row { flex-direction:column; justify-content:center; padding:0; gap:6px; }
|
||||||
.dashboard-brand-row .brand > span:not(.brand-mark),
|
.dashboard-brand-row .brand > span:not(.brand-mark),
|
||||||
.dashboard-compose span,
|
.dashboard-compose span,
|
||||||
.dashboard-sidebar-section h2,
|
.dashboard-sidebar-section h2,
|
||||||
.dashboard-nav-item em,
|
.dashboard-nav-item em,
|
||||||
.dashboard-profile > div { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
.dashboard-profile > div { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||||
.dashboard-brand-row > button,
|
.dashboard-brand-row > button { display:grid; width:32px; height:32px; color:#c8c3d4; }
|
||||||
.dashboard-profile > button { display:none; }
|
.dashboard-profile > button { display:none; }
|
||||||
.dashboard-compose { width:44px; min-height:40px; margin:16px auto 0; padding:0; }
|
.dashboard-compose { width:44px; min-height:40px; margin:16px auto 0; padding:0; }
|
||||||
.dashboard-nav-item { justify-content:center; padding:0; gap:0; }
|
.dashboard-nav-item { justify-content:center; padding:0; gap:0; }
|
||||||
|
|
@ -111,6 +111,50 @@
|
||||||
max-width:calc(100vw - 48px);
|
max-width:calc(100vw - 48px);
|
||||||
height:min(78vh,calc(100vh - 32px));
|
height:min(78vh,calc(100vh - 32px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard:has(.dashboard-sidebar.open) { grid-template-columns:228px minmax(0,1fr); }
|
||||||
|
.dashboard-sidebar.open {
|
||||||
|
width:228px;
|
||||||
|
padding:var(--space-4) var(--space-3) var(--space-3);
|
||||||
|
}
|
||||||
|
.dashboard-sidebar.open .dashboard-brand-row { flex-direction:row; justify-content:space-between; padding:0 6px 2px; }
|
||||||
|
.dashboard-sidebar.open .dashboard-brand-row .brand > span:not(.brand-mark),
|
||||||
|
.dashboard-sidebar.open .dashboard-compose span,
|
||||||
|
.dashboard-sidebar.open .dashboard-sidebar-section h2,
|
||||||
|
.dashboard-sidebar.open .dashboard-nav-item span,
|
||||||
|
.dashboard-sidebar.open .dashboard-profile > div {
|
||||||
|
position:static;
|
||||||
|
width:auto;
|
||||||
|
height:auto;
|
||||||
|
clip:auto;
|
||||||
|
overflow:visible;
|
||||||
|
margin:0;
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
.dashboard-sidebar.open .dashboard-nav-item em {
|
||||||
|
position:static;
|
||||||
|
width:auto;
|
||||||
|
min-width:18px;
|
||||||
|
height:18px;
|
||||||
|
clip:auto;
|
||||||
|
overflow:visible;
|
||||||
|
margin-left:auto;
|
||||||
|
padding:0 6px;
|
||||||
|
}
|
||||||
|
.dashboard-sidebar.open .dashboard-sidebar-section h2 { margin:0 0 var(--space-2); padding:0 var(--space-2); }
|
||||||
|
.dashboard-sidebar.open .dashboard-compose {
|
||||||
|
width:calc(100% - 8px);
|
||||||
|
margin:var(--space-4) var(--space-1) 0;
|
||||||
|
padding:0 var(--space-4);
|
||||||
|
}
|
||||||
|
.dashboard-sidebar.open .dashboard-nav-item { justify-content:flex-start; padding:0 var(--space-3); gap:var(--space-3); }
|
||||||
|
.dashboard-sidebar.open .dashboard-nav-item span { position:static; width:auto; height:auto; clip:auto; overflow:visible; flex:1; min-width:0; }
|
||||||
|
.dashboard-sidebar.open .dashboard-profile {
|
||||||
|
grid-template-columns:34px minmax(0,1fr) 31px;
|
||||||
|
padding-left:5px;
|
||||||
|
padding-right:5px;
|
||||||
|
}
|
||||||
|
.dashboard-sidebar.open .dashboard-profile > button { display:grid; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width:767px) {
|
@media (max-width:767px) {
|
||||||
|
|
|
||||||
|
|
@ -560,6 +560,10 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
||||||
font-weight:400;
|
font-weight:400;
|
||||||
color:#77758A;
|
color:#77758A;
|
||||||
}
|
}
|
||||||
|
.handoff-start-help { max-width:640px; margin-top:14px; padding:12px 14px; border:1px solid #e8e5ec; border-radius:12px; background:#fff; }
|
||||||
|
.handoff-start-help strong { display:block; color:#171424; font-size:13px; }
|
||||||
|
.handoff-start-help p { margin:6px 0 0; color:#77758a; font-size:12px; line-height:1.5; }
|
||||||
|
.handoff-start-example { color:#3d3948; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
|
||||||
.human-handoff-tabs {
|
.human-handoff-tabs {
|
||||||
margin-top:24px;
|
margin-top:24px;
|
||||||
height:38px;
|
height:38px;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue