@foldspace_npm/harness 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +9 -5
- package/bin/attach.mjs +37 -9
- package/bin/observe.mjs +4 -3
- package/package.json +1 -1
- package/recipes/INDEX.md +6 -2
- package/recipes/account-overview/README.md +26 -0
- package/recipes/account-overview/agent/accounts.ts +159 -0
- package/recipes/account-overview/agent/actions/show_account_overview.ts +61 -0
- package/recipes/account-overview/agent/api/accounts.ts +59 -0
- package/recipes/account-overview/agent/views/brand.ts +22 -0
- package/recipes/account-overview/agent/views/overview.ts +303 -0
- package/recipes/account-overview/fixtures/overview.empty.json +12 -0
- package/recipes/account-overview/fixtures/overview.ok.json +30 -0
- package/recipes/account-overview/fixtures/overview.unsigned.json +9 -0
- package/recipes/account-overview/recipe.json +10 -0
- package/recipes/opportunities-at-risk/README.md +26 -0
- package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +49 -0
- package/recipes/opportunities-at-risk/agent/api/opportunities.ts +30 -0
- package/recipes/opportunities-at-risk/agent/opportunities.ts +75 -0
- package/recipes/opportunities-at-risk/agent/views/at-risk.ts +115 -0
- package/recipes/opportunities-at-risk/agent/views/brand.ts +20 -0
- package/recipes/opportunities-at-risk/fixtures/at-risk.empty.json +4 -0
- package/recipes/opportunities-at-risk/fixtures/at-risk.ok.json +25 -0
- package/recipes/opportunities-at-risk/fixtures/at-risk.unsigned.json +3 -0
- package/recipes/opportunities-at-risk/recipe.json +10 -0
- package/recipes/prepare-for-a-meeting/README.md +27 -0
- package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +70 -0
- package/recipes/prepare-for-a-meeting/agent/api/meetings.ts +24 -0
- package/recipes/prepare-for-a-meeting/agent/meetings.ts +66 -0
- package/recipes/prepare-for-a-meeting/fixtures/prep.ok.json +24 -0
- package/recipes/prepare-for-a-meeting/fixtures/prep.unsigned.json +9 -0
- package/recipes/prepare-for-a-meeting/recipe.json +10 -0
- package/recipes/update-meeting-notes/README.md +22 -0
- package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +36 -0
- package/recipes/update-meeting-notes/agent/api/meetings.ts +22 -0
- package/recipes/update-meeting-notes/fixtures/note.ok.json +3 -0
- package/recipes/update-meeting-notes/recipe.json +10 -0
- package/recipes/upload-contacts/README.md +25 -0
- package/recipes/upload-contacts/agent/actions/upload_contacts.ts +77 -0
- package/recipes/upload-contacts/agent/api/contacts.ts +29 -0
- package/recipes/upload-contacts/agent/contacts.ts +133 -0
- package/recipes/upload-contacts/agent/views/brand.ts +21 -0
- package/recipes/upload-contacts/agent/views/uploader.ts +394 -0
- package/recipes/upload-contacts/fixtures/import.ok.json +9 -0
- package/recipes/upload-contacts/recipe.json +10 -0
- package/src/attach-preflight.mjs +9 -0
- package/src/keep-focus.mjs +49 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ContactDraft, ImportPayload } from "./api/contacts";
|
|
2
|
+
|
|
3
|
+
export type ParsedContact = ContactDraft & {
|
|
4
|
+
valid: boolean;
|
|
5
|
+
reason: string | null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type ContactRow = {
|
|
9
|
+
name: string;
|
|
10
|
+
email: string;
|
|
11
|
+
reason: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ImportOutcome = {
|
|
15
|
+
created: number;
|
|
16
|
+
skipped: number;
|
|
17
|
+
rows: ContactRow[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type Column = "firstName" | "lastName" | "email" | "phone" | "title" | "accountName";
|
|
21
|
+
|
|
22
|
+
function splitCsvLine(line: string): string[] {
|
|
23
|
+
const values: string[] = [];
|
|
24
|
+
let current = "";
|
|
25
|
+
let inQuotes = false;
|
|
26
|
+
for (let i = 0; i < line.length; i++) {
|
|
27
|
+
const char = line[i];
|
|
28
|
+
if (char === '"') {
|
|
29
|
+
inQuotes = !inQuotes;
|
|
30
|
+
} else if (char === "," && !inQuotes) {
|
|
31
|
+
values.push(current.trim());
|
|
32
|
+
current = "";
|
|
33
|
+
} else {
|
|
34
|
+
current += char;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
values.push(current.trim());
|
|
38
|
+
return values;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function columnOf(header: string): Column | null {
|
|
42
|
+
const normalized = header.replace(/['"]/g, "").trim().toLowerCase();
|
|
43
|
+
if (normalized.includes("first") && normalized.includes("name")) return "firstName";
|
|
44
|
+
if (normalized === "firstname" || normalized === "first_name") return "firstName";
|
|
45
|
+
if (normalized.includes("last") && normalized.includes("name")) return "lastName";
|
|
46
|
+
if (normalized === "lastname" || normalized === "last_name") return "lastName";
|
|
47
|
+
if (normalized.includes("email")) return "email";
|
|
48
|
+
if (normalized.includes("phone") || normalized.includes("mobile")) return "phone";
|
|
49
|
+
if (normalized.includes("title")) return "title";
|
|
50
|
+
if (normalized.includes("account") || normalized.includes("company")) return "accountName";
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseContactsCsv(csv: string): ParsedContact[] {
|
|
55
|
+
const lines = csv.trim().split(/\r?\n/).filter((line) => line.trim() !== "");
|
|
56
|
+
if (lines.length < 2) return [];
|
|
57
|
+
|
|
58
|
+
const columns = splitCsvLine(lines[0]).map(columnOf);
|
|
59
|
+
const contacts: ParsedContact[] = [];
|
|
60
|
+
|
|
61
|
+
for (const line of lines.slice(1)) {
|
|
62
|
+
const values = splitCsvLine(line);
|
|
63
|
+
const read = (column: Column) => {
|
|
64
|
+
const index = columns.indexOf(column);
|
|
65
|
+
return index >= 0 ? (values[index] ?? "").trim() : "";
|
|
66
|
+
};
|
|
67
|
+
const contact: ParsedContact = {
|
|
68
|
+
firstName: read("firstName"),
|
|
69
|
+
lastName: read("lastName"),
|
|
70
|
+
email: read("email"),
|
|
71
|
+
phone: read("phone") || null,
|
|
72
|
+
title: read("title") || null,
|
|
73
|
+
accountName: read("accountName") || null,
|
|
74
|
+
valid: true,
|
|
75
|
+
reason: null,
|
|
76
|
+
};
|
|
77
|
+
if (!contact.firstName || !contact.lastName) {
|
|
78
|
+
contact.valid = false;
|
|
79
|
+
contact.reason = "Missing name";
|
|
80
|
+
} else if (!contact.email) {
|
|
81
|
+
contact.valid = false;
|
|
82
|
+
contact.reason = "Missing email";
|
|
83
|
+
} else if (!contact.email.includes("@")) {
|
|
84
|
+
contact.valid = false;
|
|
85
|
+
contact.reason = "Invalid email";
|
|
86
|
+
}
|
|
87
|
+
contacts.push(contact);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return contacts;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function contactName(contact: { firstName?: string; lastName?: string; name?: string | null }): string {
|
|
94
|
+
if (contact.name?.trim()) return contact.name.trim();
|
|
95
|
+
return `${contact.firstName ?? ""} ${contact.lastName ?? ""}`.trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function outcomeFrom(parsed: ParsedContact[], server: ImportPayload): ImportOutcome {
|
|
99
|
+
const heldBack = parsed.filter((row) => !row.valid);
|
|
100
|
+
const serverSkipped = server.skippedContacts ?? [];
|
|
101
|
+
const rows: ContactRow[] = [
|
|
102
|
+
...(server.createdContacts ?? []).map((row) => ({
|
|
103
|
+
name: row.name?.trim() || "Untitled",
|
|
104
|
+
email: row.email?.trim() || "",
|
|
105
|
+
reason: null,
|
|
106
|
+
})),
|
|
107
|
+
...heldBack.map((row) => ({
|
|
108
|
+
name: contactName(row) || "Untitled",
|
|
109
|
+
email: row.email,
|
|
110
|
+
reason: row.reason,
|
|
111
|
+
})),
|
|
112
|
+
...serverSkipped.map((row) => ({
|
|
113
|
+
name: row.name?.trim() || "Untitled",
|
|
114
|
+
email: row.email?.trim() || "",
|
|
115
|
+
reason: row.reason?.trim() || null,
|
|
116
|
+
})),
|
|
117
|
+
];
|
|
118
|
+
const created = typeof server.created === "number" ? server.created : (server.createdContacts ?? []).length;
|
|
119
|
+
const skipped =
|
|
120
|
+
heldBack.length + (typeof server.skipped === "number" ? server.skipped : serverSkipped.length);
|
|
121
|
+
return { created, skipped, rows };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function toDraft(contact: ParsedContact): ContactDraft {
|
|
125
|
+
return {
|
|
126
|
+
firstName: contact.firstName,
|
|
127
|
+
lastName: contact.lastName,
|
|
128
|
+
email: contact.email,
|
|
129
|
+
phone: contact.phone,
|
|
130
|
+
title: contact.title,
|
|
131
|
+
accountName: contact.accountName,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Palette sampled from the production build this recipe came from (a dark CRM).
|
|
2
|
+
// Re-sample these from the host page when that product's brand differs.
|
|
3
|
+
// Record where each value came from in docs/app-profile.md.
|
|
4
|
+
|
|
5
|
+
export const brand = {
|
|
6
|
+
surface: "#0E1729",
|
|
7
|
+
border: "#344256",
|
|
8
|
+
text: "#E0E0E0",
|
|
9
|
+
textStrong: "#FFFFFF",
|
|
10
|
+
muted: "#94A3B8",
|
|
11
|
+
faint: "#64748B",
|
|
12
|
+
accent: "#60A5FA",
|
|
13
|
+
accentDeep: "#3B82F6",
|
|
14
|
+
ok: "#34D399",
|
|
15
|
+
warn: "#FBBF24",
|
|
16
|
+
bad: "#F87171",
|
|
17
|
+
tableHead: "#1E293B",
|
|
18
|
+
disabled: "#475569",
|
|
19
|
+
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
20
|
+
radius: "8px",
|
|
21
|
+
} as const;
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// The import card from the production build. textContent only — never innerHTML
|
|
2
|
+
// with file data. Styles go in `header`. The write runs only after Import.
|
|
3
|
+
// Done is what finishes the card.
|
|
4
|
+
|
|
5
|
+
import { importContacts } from "../api/contacts";
|
|
6
|
+
import { contactName, outcomeFrom, parseContactsCsv, toDraft, type ImportOutcome, type ParsedContact } from "../contacts";
|
|
7
|
+
import { renderFailure, type ApiFailure } from "../utils";
|
|
8
|
+
import { brand } from "./brand";
|
|
9
|
+
|
|
10
|
+
const CSS = `
|
|
11
|
+
.fs-up{background:${brand.surface};border-radius:${brand.radius};padding:16px;font-family:${brand.font};
|
|
12
|
+
color:${brand.text};border:1px solid ${brand.border};width:100%;box-sizing:border-box}
|
|
13
|
+
.fs-up *{box-sizing:border-box;margin:0}
|
|
14
|
+
.fs-up-head{margin-bottom:16px}
|
|
15
|
+
.fs-up-title{font-size:16px;font-weight:600;color:${brand.textStrong};margin-bottom:6px;display:flex;align-items:center;gap:6px}
|
|
16
|
+
.fs-up-sub{font-size:12px;color:${brand.muted};line-height:1.4}
|
|
17
|
+
.fs-up-icon{width:20px;height:20px;color:${brand.accent};flex-shrink:0}
|
|
18
|
+
.fs-up-icon.is-ok{color:${brand.ok}}
|
|
19
|
+
.fs-up-zone{border:2px dashed ${brand.border};border-radius:6px;padding:24px 16px;text-align:center;cursor:pointer;
|
|
20
|
+
background:rgba(96,165,250,.05)}
|
|
21
|
+
.fs-up-zone:hover,.fs-up-zone.is-drag{border-color:${brand.accent};background:rgba(96,165,250,.1)}
|
|
22
|
+
.fs-up-zone svg{width:40px;height:40px;margin:0 auto 12px;color:${brand.accent};display:block}
|
|
23
|
+
.fs-up-zone-text{font-size:13px;color:${brand.text};margin-bottom:6px}
|
|
24
|
+
.fs-up-hint{font-size:11px;color:${brand.faint}}
|
|
25
|
+
.fs-up-file{display:none}
|
|
26
|
+
.fs-up-sum{display:flex;gap:12px;margin-bottom:12px;padding:12px;background:rgba(30,41,59,.5);border-radius:6px;
|
|
27
|
+
flex-wrap:wrap;justify-content:center}
|
|
28
|
+
.fs-up-stat{display:flex;flex-direction:column;gap:2px;flex:1;min-width:80px;align-items:center;text-align:center}
|
|
29
|
+
.fs-up-num{font-size:24px;font-weight:600;color:${brand.textStrong}}
|
|
30
|
+
.fs-up-num.is-ok{color:${brand.ok}}
|
|
31
|
+
.fs-up-num.is-warn{color:${brand.warn}}
|
|
32
|
+
.fs-up-label{font-size:11px;color:${brand.muted};text-transform:uppercase;letter-spacing:.5px}
|
|
33
|
+
.fs-up-scroll{max-height:250px;overflow:auto;border:1px solid ${brand.border};border-radius:6px;margin-bottom:12px}
|
|
34
|
+
.fs-up-table{width:100%;border-collapse:collapse;font-size:12px}
|
|
35
|
+
.fs-up-table th{background:${brand.tableHead};padding:8px 10px;text-align:left;font-weight:600;color:${brand.muted};
|
|
36
|
+
text-transform:uppercase;font-size:10px;letter-spacing:.5px;border-bottom:1px solid ${brand.border}}
|
|
37
|
+
.fs-up-table td{padding:8px 10px;border-bottom:1px solid ${brand.border};color:#E2E8F0}
|
|
38
|
+
.fs-up-ready,.fs-up-invalid{display:inline-flex;align-items:center;padding:4px 8px;border-radius:12px;font-size:11px;font-weight:500}
|
|
39
|
+
.fs-up-ready{background:rgba(52,211,153,.15);color:${brand.ok}}
|
|
40
|
+
.fs-up-invalid{background:rgba(248,113,113,.15);color:${brand.bad}}
|
|
41
|
+
.fs-up-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px;flex-wrap:wrap}
|
|
42
|
+
.fs-up-btn{padding:8px 16px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;font-family:inherit}
|
|
43
|
+
.fs-up-primary{background:${brand.accent};color:${brand.textStrong};border:none}
|
|
44
|
+
.fs-up-primary:hover{background:${brand.accentDeep}}
|
|
45
|
+
.fs-up-primary:disabled{background:${brand.disabled};cursor:not-allowed}
|
|
46
|
+
.fs-up-secondary{background:transparent;border:1px solid ${brand.border};color:${brand.text}}
|
|
47
|
+
.fs-up-spin{display:inline-block;width:16px;height:16px;border:2px solid ${brand.border};border-top-color:${brand.accent};
|
|
48
|
+
border-radius:50%;animation:fs-up-spin .8s linear infinite}
|
|
49
|
+
.fs-up-spin.is-lg{width:32px;height:32px;border-width:2px}
|
|
50
|
+
@keyframes fs-up-spin{to{transform:rotate(360deg)}}
|
|
51
|
+
.fs-up-wait{text-align:center;padding:40px}
|
|
52
|
+
.fs-up-wait-text{margin-top:16px;color:${brand.muted};font-size:14px}
|
|
53
|
+
.fs-up-section{margin-top:12px}
|
|
54
|
+
.fs-up-section-title{font-size:13px;font-weight:600;color:${brand.text};margin-bottom:8px}
|
|
55
|
+
.fs-up-list{max-height:200px;overflow:auto;font-size:12px;line-height:1.5;padding:8px;background:rgba(30,41,59,.3);border-radius:6px}
|
|
56
|
+
.fs-up-item{padding:6px 0;border-bottom:1px solid rgba(52,66,86,.5);display:flex;flex-direction:column;gap:4px}
|
|
57
|
+
.fs-up-name{color:#E2E8F0;font-weight:500}
|
|
58
|
+
.fs-up-email{color:${brand.faint};font-size:11px}
|
|
59
|
+
.fs-up-reason{color:${brand.warn};font-size:10px;font-style:italic}
|
|
60
|
+
.fs-up-tag{background:rgba(96,165,250,.15);color:${brand.accent};padding:3px 8px;border-radius:10px;font-size:10px;font-weight:500}
|
|
61
|
+
.fs-up-rule{height:1px;background:${brand.border};margin:12px 0}
|
|
62
|
+
.fs-up-error{padding:10px;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.3);border-radius:6px;
|
|
63
|
+
color:${brand.bad};font-size:12px;line-height:1.4}
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const UPLOAD_PATH = "M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12";
|
|
67
|
+
const FILE_PATH = "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z";
|
|
68
|
+
const REVIEW_PATH = "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4";
|
|
69
|
+
const DONE_PATH = "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z";
|
|
70
|
+
const UPLOAD_COPY = "Upload a CSV file with your contacts. The file should include columns for firstName, lastName, and email (required). Optional columns: phone, title, accountName.";
|
|
71
|
+
|
|
72
|
+
export type UploadHandlers = {
|
|
73
|
+
onCancel: () => void;
|
|
74
|
+
onEmpty: () => void;
|
|
75
|
+
onDone: (outcome: ImportOutcome) => void;
|
|
76
|
+
onFailed: (failure: ApiFailure) => void;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type CsvFile = { text: () => Promise<string> };
|
|
80
|
+
|
|
81
|
+
function el(tag: string, className: string, text?: string): HTMLElement {
|
|
82
|
+
const node = document.createElement(tag);
|
|
83
|
+
node.className = className;
|
|
84
|
+
if (text !== undefined) node.textContent = text;
|
|
85
|
+
return node;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function button(className: string, text: string): HTMLButtonElement {
|
|
89
|
+
const node = el("button", className, text) as HTMLButtonElement;
|
|
90
|
+
node.type = "button";
|
|
91
|
+
return node;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function icon(className: string, pathD: string, strokeWidth = "2"): SVGElement | null {
|
|
95
|
+
if (typeof document.createElementNS !== "function") return null;
|
|
96
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
97
|
+
svg.setAttribute("class", className);
|
|
98
|
+
svg.setAttribute("fill", "none");
|
|
99
|
+
svg.setAttribute("stroke", "currentColor");
|
|
100
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
101
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
102
|
+
path.setAttribute("stroke-linecap", "round");
|
|
103
|
+
path.setAttribute("stroke-linejoin", "round");
|
|
104
|
+
path.setAttribute("stroke-width", strokeWidth);
|
|
105
|
+
path.setAttribute("d", pathD);
|
|
106
|
+
svg.appendChild(path);
|
|
107
|
+
return svg;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toggleClass(node: HTMLElement, name: string, on: boolean): void {
|
|
111
|
+
const names = node.className.split(/\s+/).filter(Boolean);
|
|
112
|
+
const has = names.includes(name);
|
|
113
|
+
if (on && !has) node.className = [...names, name].join(" ");
|
|
114
|
+
if (!on && has) node.className = names.filter((item) => item !== name).join(" ");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The production build's single-page app listened for this message and routed
|
|
118
|
+
// on it. No other app does — on any other product this click does nothing.
|
|
119
|
+
// Open a record with a navigation route instead (CLAUDE.md, "Product defaults").
|
|
120
|
+
function go(path: string): void {
|
|
121
|
+
const top = window.top;
|
|
122
|
+
if (top && typeof top.postMessage === "function") {
|
|
123
|
+
top.postMessage({ type: "NAVIGATE", path }, "*");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function dash(value: string | null | undefined): string {
|
|
128
|
+
const text = value?.trim();
|
|
129
|
+
return text ? text : "-";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function mountContactUpload(host: HTMLElement, header: HTMLElement, handlers: UploadHandlers): void {
|
|
133
|
+
const style = el("style", "");
|
|
134
|
+
style.textContent = CSS;
|
|
135
|
+
header.append(style);
|
|
136
|
+
|
|
137
|
+
let settled = false;
|
|
138
|
+
let busy = false;
|
|
139
|
+
let parsed: ParsedContact[] = [];
|
|
140
|
+
|
|
141
|
+
const report = (draw: () => void, send: () => void) => {
|
|
142
|
+
if (settled) return;
|
|
143
|
+
settled = true;
|
|
144
|
+
draw();
|
|
145
|
+
send();
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const mount = (card: HTMLElement) => {
|
|
149
|
+
host.textContent = "";
|
|
150
|
+
host.append(card);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const heading = (title: string, subtitle: string, mark: SVGElement | null): HTMLElement => {
|
|
154
|
+
const head = el("div", "fs-up-head");
|
|
155
|
+
const row = el("div", "fs-up-title");
|
|
156
|
+
if (mark) row.append(mark);
|
|
157
|
+
row.append(el("span", "", title));
|
|
158
|
+
head.append(row, el("div", "fs-up-sub", subtitle));
|
|
159
|
+
return head;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const actions = (...nodes: HTMLElement[]): HTMLElement => {
|
|
163
|
+
const row = el("div", "fs-up-actions");
|
|
164
|
+
row.append(...nodes);
|
|
165
|
+
return row;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const stat = (label: string, value: string, tone?: "ok" | "warn"): HTMLElement => {
|
|
169
|
+
const node = el("div", "fs-up-stat");
|
|
170
|
+
node.append(el("div", "fs-up-label", label), el("div", tone ? `fs-up-num is-${tone}` : "fs-up-num", value));
|
|
171
|
+
return node;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const showUpload = () => {
|
|
175
|
+
const card = el("div", "fs-up");
|
|
176
|
+
card.append(heading("Import Contacts from CSV", UPLOAD_COPY, icon("fs-up-icon", UPLOAD_PATH)));
|
|
177
|
+
|
|
178
|
+
const zone = el("div", "fs-up-zone");
|
|
179
|
+
const mark = icon("", FILE_PATH, "1.5");
|
|
180
|
+
if (mark) zone.append(mark);
|
|
181
|
+
zone.append(el("div", "fs-up-zone-text", "Click to select a CSV file or drag and drop"));
|
|
182
|
+
zone.append(el("div", "fs-up-hint", "Supports: .csv files"));
|
|
183
|
+
|
|
184
|
+
const input = document.createElement("input");
|
|
185
|
+
input.type = "file";
|
|
186
|
+
input.className = "fs-up-file";
|
|
187
|
+
input.accept = ".csv";
|
|
188
|
+
zone.append(input);
|
|
189
|
+
|
|
190
|
+
const readFile = async (file: CsvFile | undefined) => {
|
|
191
|
+
if (settled || busy || !file) return;
|
|
192
|
+
const csv = await file.text();
|
|
193
|
+
parsed = parseContactsCsv(csv);
|
|
194
|
+
if (parsed.length === 0) {
|
|
195
|
+
report(
|
|
196
|
+
() => {
|
|
197
|
+
const empty = el("div", "fs-up");
|
|
198
|
+
empty.append(heading("Import Contacts from CSV", UPLOAD_COPY, icon("fs-up-icon", UPLOAD_PATH)));
|
|
199
|
+
empty.append(el("div", "fs-up-error", "The file had no contacts."));
|
|
200
|
+
mount(empty);
|
|
201
|
+
},
|
|
202
|
+
() => handlers.onEmpty(),
|
|
203
|
+
);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
showPreview();
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
zone.addEventListener("click", () => input.click());
|
|
210
|
+
input.addEventListener("click", (event) => {
|
|
211
|
+
if (typeof event.stopPropagation === "function") event.stopPropagation();
|
|
212
|
+
});
|
|
213
|
+
input.addEventListener("change", () => {
|
|
214
|
+
const file = input.files?.[0] as CsvFile | undefined;
|
|
215
|
+
void readFile(file);
|
|
216
|
+
});
|
|
217
|
+
zone.addEventListener("dragover", (event) => {
|
|
218
|
+
if (typeof event.preventDefault === "function") event.preventDefault();
|
|
219
|
+
toggleClass(zone, "is-drag", true);
|
|
220
|
+
});
|
|
221
|
+
zone.addEventListener("dragleave", () => toggleClass(zone, "is-drag", false));
|
|
222
|
+
zone.addEventListener("drop", (event) => {
|
|
223
|
+
if (typeof event.preventDefault === "function") event.preventDefault();
|
|
224
|
+
toggleClass(zone, "is-drag", false);
|
|
225
|
+
const transfer = "dataTransfer" in event ? event.dataTransfer : null;
|
|
226
|
+
const file = transfer?.files?.[0] as CsvFile | undefined;
|
|
227
|
+
void readFile(file);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const cancel = button("fs-up-btn fs-up-secondary", "Cancel");
|
|
231
|
+
cancel.addEventListener("click", () => {
|
|
232
|
+
report(() => mount(el("div", "fs-up")), () => handlers.onCancel());
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
card.append(zone, actions(cancel));
|
|
236
|
+
mount(card);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const showPreview = () => {
|
|
240
|
+
const ready = parsed.filter((row) => row.valid);
|
|
241
|
+
const skipped = parsed.length - ready.length;
|
|
242
|
+
const accounts = new Set(parsed.map((row) => row.accountName?.trim()).filter((name): name is string => Boolean(name)));
|
|
243
|
+
|
|
244
|
+
const card = el("div", "fs-up");
|
|
245
|
+
card.append(
|
|
246
|
+
heading(
|
|
247
|
+
"Review Contacts",
|
|
248
|
+
"Review the contacts below before importing. Invalid contacts will be skipped.",
|
|
249
|
+
icon("fs-up-icon", REVIEW_PATH),
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const summary = el("div", "fs-up-sum");
|
|
254
|
+
summary.append(stat("Total Contacts", String(parsed.length)));
|
|
255
|
+
summary.append(stat("Ready to Import", String(ready.length), "ok"));
|
|
256
|
+
if (skipped > 0) summary.append(stat("Will Be Skipped", String(skipped), "warn"));
|
|
257
|
+
summary.append(stat("Accounts", String(accounts.size)));
|
|
258
|
+
card.append(summary);
|
|
259
|
+
|
|
260
|
+
const table = el("table", "fs-up-table");
|
|
261
|
+
const head = document.createElement("thead");
|
|
262
|
+
const headRow = document.createElement("tr");
|
|
263
|
+
for (const label of ["Name", "Email", "Title", "Account", "Status"]) {
|
|
264
|
+
headRow.append(el("th", "", label));
|
|
265
|
+
}
|
|
266
|
+
head.append(headRow);
|
|
267
|
+
const body = document.createElement("tbody");
|
|
268
|
+
for (const row of parsed) {
|
|
269
|
+
const line = document.createElement("tr");
|
|
270
|
+
line.append(
|
|
271
|
+
el("td", "", contactName(row) || "-"),
|
|
272
|
+
el("td", "", dash(row.email)),
|
|
273
|
+
el("td", "", dash(row.title)),
|
|
274
|
+
el("td", "", dash(row.accountName)),
|
|
275
|
+
);
|
|
276
|
+
const status = document.createElement("td");
|
|
277
|
+
const badge = el("span", row.valid ? "fs-up-ready" : "fs-up-invalid", row.valid ? "Ready" : "Invalid");
|
|
278
|
+
if (!row.valid && row.reason) badge.title = row.reason;
|
|
279
|
+
status.append(badge);
|
|
280
|
+
line.append(status);
|
|
281
|
+
body.append(line);
|
|
282
|
+
}
|
|
283
|
+
table.append(head, body);
|
|
284
|
+
const scroll = el("div", "fs-up-scroll");
|
|
285
|
+
scroll.append(table);
|
|
286
|
+
card.append(scroll);
|
|
287
|
+
|
|
288
|
+
const back = button("fs-up-btn fs-up-secondary", "Back");
|
|
289
|
+
back.addEventListener("click", () => {
|
|
290
|
+
if (settled || busy) return;
|
|
291
|
+
parsed = [];
|
|
292
|
+
showUpload();
|
|
293
|
+
});
|
|
294
|
+
const confirm = button("fs-up-btn fs-up-primary", `Import ${ready.length} Contacts`);
|
|
295
|
+
confirm.disabled = ready.length === 0;
|
|
296
|
+
confirm.addEventListener("click", async () => {
|
|
297
|
+
if (settled || busy || ready.length === 0) return;
|
|
298
|
+
busy = true;
|
|
299
|
+
showImporting(ready.length);
|
|
300
|
+
const res = await importContacts(ready.map(toDraft));
|
|
301
|
+
if (!res.ok) {
|
|
302
|
+
report(
|
|
303
|
+
() => renderFailure(host, res, { notFound: "No import endpoint answered." }),
|
|
304
|
+
() => handlers.onFailed(res),
|
|
305
|
+
);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
showResults(outcomeFrom(parsed, res.data));
|
|
309
|
+
});
|
|
310
|
+
card.append(actions(back, confirm));
|
|
311
|
+
mount(card);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const showImporting = (count: number) => {
|
|
315
|
+
const card = el("div", "fs-up");
|
|
316
|
+
const row = el("div", "fs-up-title");
|
|
317
|
+
row.append(el("span", "fs-up-spin"), el("span", "", "Importing Contacts..."));
|
|
318
|
+
const head = el("div", "fs-up-head");
|
|
319
|
+
head.append(row, el("div", "fs-up-sub", "Please wait while we import your contacts. This may take a moment for large files."));
|
|
320
|
+
const wait = el("div", "fs-up-wait");
|
|
321
|
+
wait.append(el("div", "fs-up-spin is-lg"), el("div", "fs-up-wait-text", `Processing ${count} contacts...`));
|
|
322
|
+
card.append(head, wait);
|
|
323
|
+
mount(card);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const showResults = (outcome: ImportOutcome) => {
|
|
327
|
+
const created = outcome.rows.filter((row) => !row.reason);
|
|
328
|
+
const skipped = outcome.rows.filter((row) => row.reason);
|
|
329
|
+
const card = el("div", "fs-up");
|
|
330
|
+
|
|
331
|
+
if (outcome.created > 0) {
|
|
332
|
+
const noun = outcome.created === 1 ? "contact" : "contacts";
|
|
333
|
+
card.append(
|
|
334
|
+
heading(
|
|
335
|
+
"Import Complete",
|
|
336
|
+
`Successfully imported ${outcome.created} ${noun}.`,
|
|
337
|
+
icon("fs-up-icon is-ok", DONE_PATH),
|
|
338
|
+
),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const summary = el("div", "fs-up-sum");
|
|
343
|
+
summary.append(stat("Imported", String(outcome.created), "ok"));
|
|
344
|
+
summary.append(stat("Skipped", String(outcome.skipped), "warn"));
|
|
345
|
+
card.append(summary);
|
|
346
|
+
|
|
347
|
+
if (created.length > 0) {
|
|
348
|
+
const section = el("div", "fs-up-section");
|
|
349
|
+
section.append(el("div", "fs-up-section-title", `Imported Contacts (${created.length})`));
|
|
350
|
+
const list = el("div", "fs-up-list");
|
|
351
|
+
for (const row of created) {
|
|
352
|
+
const item = el("div", "fs-up-item");
|
|
353
|
+
const who = el("div", "");
|
|
354
|
+
who.append(el("span", "fs-up-name", row.name), el("span", "fs-up-email", ` - ${dash(row.email)}`));
|
|
355
|
+
item.append(who);
|
|
356
|
+
const account = parsed.find((source) => source.email === row.email)?.accountName;
|
|
357
|
+
if (account) item.append(el("span", "fs-up-tag", account));
|
|
358
|
+
list.append(item);
|
|
359
|
+
}
|
|
360
|
+
section.append(list);
|
|
361
|
+
card.append(section);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (skipped.length > 0) {
|
|
365
|
+
card.append(el("div", "fs-up-rule"));
|
|
366
|
+
const section = el("div", "fs-up-section");
|
|
367
|
+
section.append(el("div", "fs-up-section-title", `Skipped Contacts (${skipped.length})`));
|
|
368
|
+
const list = el("div", "fs-up-list");
|
|
369
|
+
for (const row of skipped) {
|
|
370
|
+
const item = el("div", "fs-up-item");
|
|
371
|
+
const who = el("div", "");
|
|
372
|
+
who.append(el("span", "fs-up-name", row.name), el("span", "fs-up-email", ` - ${dash(row.email)}`));
|
|
373
|
+
item.append(who, el("span", "fs-up-reason", row.reason ?? ""));
|
|
374
|
+
list.append(item);
|
|
375
|
+
}
|
|
376
|
+
section.append(list);
|
|
377
|
+
card.append(section);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const finish = () => report(() => {}, () => handlers.onDone(outcome));
|
|
381
|
+
const view = button("fs-up-btn fs-up-secondary", "View Contacts");
|
|
382
|
+
view.addEventListener("click", () => {
|
|
383
|
+
// callback first: a navigation that unloads the page must not lose it.
|
|
384
|
+
finish();
|
|
385
|
+
go("/__observe_me/contacts");
|
|
386
|
+
});
|
|
387
|
+
const done = button("fs-up-btn fs-up-primary", "Done");
|
|
388
|
+
done.addEventListener("click", finish);
|
|
389
|
+
card.append(actions(view, done));
|
|
390
|
+
mount(card);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
showUpload();
|
|
394
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Upload contacts",
|
|
3
|
+
"level": "L4",
|
|
4
|
+
"family": "upload",
|
|
5
|
+
"kind": "action",
|
|
6
|
+
"action": "upload_contacts",
|
|
7
|
+
"entry": "agent/actions/upload_contacts.ts",
|
|
8
|
+
"outcome": "A CSV is previewed, then written only after the user confirms",
|
|
9
|
+
"provenBy": 1
|
|
10
|
+
}
|
package/src/attach-preflight.mjs
CHANGED
|
@@ -39,6 +39,15 @@ export function guardAttachMode({
|
|
|
39
39
|
if (characterization.status === "no-sdk") {
|
|
40
40
|
return { ok: true, mode };
|
|
41
41
|
}
|
|
42
|
+
// The bootstrap script is added on every new document, so after the tab
|
|
43
|
+
// navigates within the app the SDK on the page is OUR bootstrap, carrying
|
|
44
|
+
// the configured agent. That is the expected state, not a foreign SDK:
|
|
45
|
+
// refusing it made the agent vanish on every in-app navigation
|
|
46
|
+
// (Buildium, 2026-09-22). Only a page whose SDK belongs to someone else
|
|
47
|
+
// is a real mismatch.
|
|
48
|
+
if (characterization.status === "same-agent") {
|
|
49
|
+
return { ok: true, mode, reattached: true };
|
|
50
|
+
}
|
|
42
51
|
return {
|
|
43
52
|
ok: false,
|
|
44
53
|
mode,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The test Chrome must never steal the keyboard. Chrome raises its window when
|
|
2
|
+
// a page navigates or reloads over CDP, and a customer typing somewhere else
|
|
3
|
+
// then types into their live product (seen 2026-09-22). Nothing in CDP stops
|
|
4
|
+
// that; on macOS the fix is to hand focus straight back to whatever app the
|
|
5
|
+
// human was using. Elsewhere this is a no-op.
|
|
6
|
+
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const FRONT_APP_SCRIPT =
|
|
10
|
+
'tell application "System Events" to get bundle identifier of first application process whose frontmost is true';
|
|
11
|
+
|
|
12
|
+
function osascript(script) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
// 4s, not less: the first call wakes System Events, which alone can take
|
|
15
|
+
// over a second, and a timeout here silently leaves focus on Chrome.
|
|
16
|
+
execFile("osascript", ["-e", script], { timeout: 4000 }, (error, stdout) => {
|
|
17
|
+
resolve(error ? null : String(stdout).trim());
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Bundle id of the app in front right now, or null (not macOS, or unknown). */
|
|
23
|
+
export async function frontApp() {
|
|
24
|
+
if (process.platform !== "darwin") return null;
|
|
25
|
+
return osascript(FRONT_APP_SCRIPT);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run `action` (which will make Chrome raise its window), then give focus back
|
|
30
|
+
* to the app that had it. If Chrome itself was in front, nothing to restore.
|
|
31
|
+
*/
|
|
32
|
+
export async function withoutStealingFocus(action, { front = frontApp, restore = restoreFront } = {}) {
|
|
33
|
+
const before = await front();
|
|
34
|
+
try {
|
|
35
|
+
return await action();
|
|
36
|
+
} finally {
|
|
37
|
+
if (before && !/google\.chrome|chromium/i.test(before)) await restore(before);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function restoreFront(bundleId) {
|
|
42
|
+
if (process.platform !== "darwin" || !bundleId) return;
|
|
43
|
+
// Twice, a beat apart: Chrome's activation lands after the navigation
|
|
44
|
+
// settles, so one immediate restore can be overtaken.
|
|
45
|
+
const script = `tell application id "${bundleId.replace(/"/g, "")}" to activate`;
|
|
46
|
+
await osascript(script);
|
|
47
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
48
|
+
await osascript(script);
|
|
49
|
+
}
|