@foldspace_npm/harness 0.1.16 → 0.1.18
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 +55 -21
- package/README.md +1 -1
- package/bin/attach.mjs +73 -20
- package/bin/badge.mjs +50 -0
- package/bin/inject.mjs +2 -2
- package/bin/observe.mjs +4 -3
- package/package.json +1 -1
- package/recipes/INDEX.md +7 -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/bottom-bar/README.md +43 -0
- package/recipes/bottom-bar/agent/bottomBar.ts +94 -0
- package/recipes/bottom-bar/fixtures/configuration.sent.json +17 -0
- package/recipes/bottom-bar/recipe.json +9 -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/badge-core.mjs +22 -0
- package/src/cli-registry.mjs +32 -3
- package/src/keep-focus.mjs +49 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// View-only card. Do not set awaitUserInput: the model is told only that the
|
|
2
|
+
// information was displayed, so the card itself carries every fact. A failed
|
|
3
|
+
// or signed-out call is drawn with renderFailure, never as a row of zeros.
|
|
4
|
+
|
|
5
|
+
import { belongsToSignedInUser, toOverview, type AccountOverview } from "../accounts";
|
|
6
|
+
import { getAccountOverview } from "../api/accounts";
|
|
7
|
+
import { renderFailure, type FailureReason } from "../utils";
|
|
8
|
+
import { mountAccountOverview } from "../views/overview";
|
|
9
|
+
|
|
10
|
+
type ShowResult = {
|
|
11
|
+
success: boolean;
|
|
12
|
+
message?: string;
|
|
13
|
+
error?: string;
|
|
14
|
+
reason?: FailureReason;
|
|
15
|
+
data?: AccountOverview | { accountId: string };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function isOverview(data: NonNullable<ShowResult["data"]>): data is AccountOverview {
|
|
19
|
+
return "account" in data;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const show_account_overview = {
|
|
23
|
+
execute: async (params: { accountId?: string }): Promise<ShowResult> => {
|
|
24
|
+
const accountId = typeof params?.accountId === "string" ? params.accountId.trim() : "";
|
|
25
|
+
if (!accountId) {
|
|
26
|
+
return { success: false, error: "accountId was not provided.", reason: "config" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const res = await getAccountOverview(accountId);
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
console.warn("[show_account_overview]", res.status, res.reason, res.detail);
|
|
32
|
+
return { success: false, error: res.error, reason: res.reason, data: { accountId } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!belongsToSignedInUser(res.data)) {
|
|
36
|
+
return { success: false, error: "Not signed in.", reason: "signed_out", data: { accountId } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `message` never reaches the model for a view-only action; it is for the console.
|
|
40
|
+
return { success: true, message: "Displayed.", data: toOverview(res.data) };
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
render: (result: ShowResult | undefined, host: HTMLElement, header: HTMLElement) => {
|
|
44
|
+
if (!result?.success || !result.data || !isOverview(result.data)) {
|
|
45
|
+
const accountId = result?.data && "accountId" in result.data ? result.data.accountId : "";
|
|
46
|
+
renderFailure(
|
|
47
|
+
host,
|
|
48
|
+
{
|
|
49
|
+
ok: false,
|
|
50
|
+
status: 0,
|
|
51
|
+
error: result?.error ?? "The account could not be loaded.",
|
|
52
|
+
reason: result?.reason ?? "server",
|
|
53
|
+
},
|
|
54
|
+
{ notFound: accountId ? `No account ${accountId}.` : "No account." },
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
mountAccountOverview(host, header, result.data);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Mock API. These paths are not a real product. Find an API that returns a
|
|
2
|
+
// similar shape — one account, its opportunities, contacts, open cases, and
|
|
3
|
+
// recent meetings — and replace the path and the envelope with what you
|
|
4
|
+
// observed. `__observe_me` cannot succeed until you do.
|
|
5
|
+
|
|
6
|
+
import { apiFetch, type ApiResult } from "../utils";
|
|
7
|
+
|
|
8
|
+
export type AccountRecord = {
|
|
9
|
+
id?: string | null;
|
|
10
|
+
name?: string | null;
|
|
11
|
+
industry?: string | null;
|
|
12
|
+
website?: string | null;
|
|
13
|
+
phone?: string | null;
|
|
14
|
+
revenue?: number | null;
|
|
15
|
+
employeeCount?: number | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type OpportunityRecord = {
|
|
19
|
+
id?: string | null;
|
|
20
|
+
name?: string | null;
|
|
21
|
+
value?: number | null;
|
|
22
|
+
stage?: string | null;
|
|
23
|
+
closeDate?: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ContactRecord = {
|
|
27
|
+
id?: string | null;
|
|
28
|
+
name?: string | null;
|
|
29
|
+
email?: string | null;
|
|
30
|
+
phone?: string | null;
|
|
31
|
+
title?: string | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type CaseRecord = {
|
|
35
|
+
id?: string | null;
|
|
36
|
+
title?: string | null;
|
|
37
|
+
priority?: string | null;
|
|
38
|
+
status?: string | null;
|
|
39
|
+
createdAt?: string | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type MeetingRecord = {
|
|
43
|
+
id?: string | null;
|
|
44
|
+
title?: string | null;
|
|
45
|
+
startTime?: string | null;
|
|
46
|
+
attendees?: unknown[] | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type OverviewPayload = {
|
|
50
|
+
account?: AccountRecord | null;
|
|
51
|
+
opportunities?: OpportunityRecord[] | null;
|
|
52
|
+
contacts?: ContactRecord[] | null;
|
|
53
|
+
openCases?: CaseRecord[] | null;
|
|
54
|
+
recentMeetings?: MeetingRecord[] | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export async function getAccountOverview(accountId: string): Promise<ApiResult<OverviewPayload>> {
|
|
58
|
+
return apiFetch<OverviewPayload>(`/__observe_me/accounts/${encodeURIComponent(accountId)}/overview`);
|
|
59
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
headerStart: "#1a2332",
|
|
8
|
+
border: "#344256",
|
|
9
|
+
text: "#E0E0E0",
|
|
10
|
+
textStrong: "#FFFFFF",
|
|
11
|
+
muted: "#94A3B8",
|
|
12
|
+
faint: "#6B7280",
|
|
13
|
+
accent: "#60A5FA",
|
|
14
|
+
accentDeep: "#3B82F6",
|
|
15
|
+
money: "#34D399",
|
|
16
|
+
deals: "#60A5FA",
|
|
17
|
+
contacts: "#A78BFA",
|
|
18
|
+
cases: "#FBBF24",
|
|
19
|
+
row: "rgba(255, 255, 255, 0.03)",
|
|
20
|
+
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
21
|
+
radius: "8px",
|
|
22
|
+
} as const;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// The account card from the production build. textContent only — never innerHTML
|
|
2
|
+
// with API data. Styles go in `header`. Routes under `/__observe_me` are
|
|
3
|
+
// placeholders: replace them with the app's own paths.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
formatMoney,
|
|
7
|
+
formatPipeline,
|
|
8
|
+
formatWhen,
|
|
9
|
+
titleCase,
|
|
10
|
+
type AccountOverview,
|
|
11
|
+
type CaseSummary,
|
|
12
|
+
type ContactSummary,
|
|
13
|
+
type MeetingSummary,
|
|
14
|
+
type OpportunitySummary,
|
|
15
|
+
} from "../accounts";
|
|
16
|
+
import { brand } from "./brand";
|
|
17
|
+
|
|
18
|
+
const SHOWN = 5;
|
|
19
|
+
|
|
20
|
+
const CSS = `
|
|
21
|
+
.fs-ov{background:${brand.surface};border-radius:${brand.radius};font-family:${brand.font};color:${brand.text};
|
|
22
|
+
border:1px solid ${brand.border};overflow:hidden;width:100%;box-sizing:border-box}
|
|
23
|
+
.fs-ov *{box-sizing:border-box}
|
|
24
|
+
.fs-ov-head{padding:16px;background:linear-gradient(135deg, ${brand.headerStart} 0%, ${brand.surface} 100%);border-bottom:1px solid ${brand.border}}
|
|
25
|
+
.fs-ov-title{font-size:18px;font-weight:600;color:${brand.textStrong};margin-bottom:6px;display:flex;align-items:center;justify-content:space-between}
|
|
26
|
+
.fs-ov-name{display:flex;align-items:center}
|
|
27
|
+
.fs-ov-mark{width:28px;height:28px;background:linear-gradient(135deg, ${brand.accent} 0%, ${brand.accentDeep} 100%);border-radius:6px;
|
|
28
|
+
display:flex;align-items:center;justify-content:center;margin-right:10px;color:#fff;font-weight:600;font-size:11px;flex-shrink:0}
|
|
29
|
+
.fs-ov-open{background:${brand.accent};color:#fff;border:none;padding:6px 12px;border-radius:4px;font-size:11px;font-weight:500;
|
|
30
|
+
cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:4px}
|
|
31
|
+
.fs-ov-open svg{width:12px;height:12px}
|
|
32
|
+
.fs-ov-sub{font-size:12px;color:${brand.muted};margin-bottom:12px;line-height:1.3}
|
|
33
|
+
.fs-ov-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
|
|
34
|
+
.fs-ov-label{font-size:10px;color:${brand.muted};text-transform:uppercase;letter-spacing:.5px;margin-bottom:2px}
|
|
35
|
+
.fs-ov-value{font-size:12px;color:#E2E8F0;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
36
|
+
.fs-ov-value a{color:${brand.accent};text-decoration:none}
|
|
37
|
+
.fs-ov-metrics{display:grid;grid-template-columns:repeat(4, 1fr);gap:1px;background:${brand.border}}
|
|
38
|
+
.fs-ov-metric{padding:12px;background:${brand.surface};display:flex;flex-direction:column;gap:2px;text-align:center;align-items:center}
|
|
39
|
+
.fs-ov-metric b{font-size:20px;font-weight:600}
|
|
40
|
+
.fs-ov-deals{color:${brand.deals}}
|
|
41
|
+
.fs-ov-pipeline{color:${brand.money}}
|
|
42
|
+
.fs-ov-contacts{color:${brand.contacts}}
|
|
43
|
+
.fs-ov-cases{color:${brand.cases}}
|
|
44
|
+
.fs-ov-tabs{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid ${brand.border}}
|
|
45
|
+
.fs-ov-tab{padding:12px 16px;background:transparent;border:none;color:${brand.muted};font-size:13px;font-weight:500;cursor:pointer;
|
|
46
|
+
border-bottom:2px solid transparent;text-align:center}
|
|
47
|
+
.fs-ov-tab.is-active{color:${brand.accent};border-bottom-color:${brand.accent}}
|
|
48
|
+
.fs-ov-panel{padding:16px}
|
|
49
|
+
.fs-ov-list{display:flex;flex-direction:column;gap:10px}
|
|
50
|
+
.fs-ov-row{background:${brand.row};border-radius:6px;padding:12px;border-left:3px solid ${brand.accent};color:inherit;display:block;text-decoration:none}
|
|
51
|
+
.fs-ov-row-title{font-size:13px;font-weight:600;color:${brand.textStrong};margin-bottom:4px}
|
|
52
|
+
.fs-ov-row-meta{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}
|
|
53
|
+
.fs-ov-detail{font-size:11px;color:${brand.muted};line-height:1.3}
|
|
54
|
+
.fs-ov-money{font-weight:600;color:${brand.money};font-size:12px}
|
|
55
|
+
.fs-ov-badge{display:inline-block;padding:2px 8px;border-radius:8px;font-size:10px;font-weight:500}
|
|
56
|
+
.fs-ov-stage-proposal{background:#6B21A8;color:#D8B4FE}
|
|
57
|
+
.fs-ov-stage-qualified,.fs-ov-stage-qualification,.fs-ov-stage-discovery{background:#1E40AF;color:#DBEAFE}
|
|
58
|
+
.fs-ov-stage-negotiation{background:#B45309;color:#FED7AA}
|
|
59
|
+
.fs-ov-stage-closed-won,.fs-ov-stage-closed_won{background:#166534;color:#DCFCE7}
|
|
60
|
+
.fs-ov-stage-closed-lost,.fs-ov-stage-closed_lost{background:#374151;color:#9CA3AF}
|
|
61
|
+
.fs-ov-priority-critical{background:#DC2626;color:#FEE2E2}
|
|
62
|
+
.fs-ov-priority-high{background:#EA580C;color:#FFEDD5}
|
|
63
|
+
.fs-ov-priority-medium{background:#CA8A04;color:#FEF9C3}
|
|
64
|
+
.fs-ov-priority-low{background:#2563EB;color:#DBEAFE}
|
|
65
|
+
.fs-ov-empty{text-align:center;padding:20px 16px;color:${brand.faint};font-style:italic;font-size:12px}
|
|
66
|
+
.fs-ov-more{display:flex;justify-content:flex-end;margin-bottom:12px}
|
|
67
|
+
.fs-ov-more a{font-size:11px;color:${brand.accent};text-decoration:none}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
type Tab = "opportunities" | "meetings" | "cases" | "contacts";
|
|
71
|
+
|
|
72
|
+
function el(tag: string, className: string, text?: string): HTMLElement {
|
|
73
|
+
const node = document.createElement(tag);
|
|
74
|
+
node.className = className;
|
|
75
|
+
if (text !== undefined) node.textContent = text;
|
|
76
|
+
return node;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function attr(node: HTMLElement, name: string, value: string): void {
|
|
80
|
+
if (typeof node.setAttribute === "function") node.setAttribute(name, value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The production build's single-page app listened for this message and routed
|
|
84
|
+
// on it. No other app does — on any other product these links do nothing.
|
|
85
|
+
// Open a record with a navigation route instead (CLAUDE.md, "Product defaults").
|
|
86
|
+
function go(path: string): void {
|
|
87
|
+
const top = window.top;
|
|
88
|
+
if (top && typeof top.postMessage === "function") {
|
|
89
|
+
top.postMessage({ type: "NAVIGATE", path }, "*");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function link(className: string, path: string, text?: string): HTMLAnchorElement {
|
|
94
|
+
const node = el("a", className, text) as HTMLAnchorElement;
|
|
95
|
+
node.href = path;
|
|
96
|
+
node.addEventListener("click", (event) => {
|
|
97
|
+
if (typeof event.preventDefault === "function") event.preventDefault();
|
|
98
|
+
go(path);
|
|
99
|
+
});
|
|
100
|
+
return node;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function initials(name: string): string {
|
|
104
|
+
const letters = name
|
|
105
|
+
.split(" ")
|
|
106
|
+
.map((word) => word[0])
|
|
107
|
+
.join("")
|
|
108
|
+
.slice(0, 2)
|
|
109
|
+
.toUpperCase();
|
|
110
|
+
return letters || "AC";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function openIcon(): SVGElement | null {
|
|
114
|
+
if (typeof document.createElementNS !== "function") return null;
|
|
115
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
116
|
+
attr(svg as unknown as HTMLElement, "viewBox", "0 0 24 24");
|
|
117
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
118
|
+
attr(path as unknown as HTMLElement, "fill", "currentColor");
|
|
119
|
+
attr(
|
|
120
|
+
path as unknown as HTMLElement,
|
|
121
|
+
"d",
|
|
122
|
+
"M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z",
|
|
123
|
+
);
|
|
124
|
+
svg.appendChild(path);
|
|
125
|
+
return svg;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function field(label: string, value: HTMLElement | string): HTMLElement {
|
|
129
|
+
const item = el("div", "fs-ov-field");
|
|
130
|
+
item.append(el("div", "fs-ov-label", label));
|
|
131
|
+
item.append(typeof value === "string" ? el("div", "fs-ov-value", value) : value);
|
|
132
|
+
return item;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function opportunityRow(row: OpportunitySummary): HTMLElement {
|
|
136
|
+
const node = link("fs-ov-row", `/__observe_me/opportunities/${row.id}`);
|
|
137
|
+
node.append(el("div", "fs-ov-row-title", row.name));
|
|
138
|
+
const meta = el("div", "fs-ov-row-meta");
|
|
139
|
+
meta.append(el("span", "fs-ov-money", formatMoney(row.value)));
|
|
140
|
+
node.append(meta);
|
|
141
|
+
const stage = row.stage ? row.stage.replaceAll("_", " ") : "unknown";
|
|
142
|
+
const close = formatWhen(row.closeDate);
|
|
143
|
+
node.append(el("div", "fs-ov-detail", close ? `Stage: ${stage} • Close: ${close}` : `Stage: ${stage}`));
|
|
144
|
+
return node;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function contactRow(row: ContactSummary): HTMLElement {
|
|
148
|
+
const node = link("fs-ov-row", `/__observe_me/contacts/${row.id}`);
|
|
149
|
+
node.append(el("div", "fs-ov-row-title", row.name));
|
|
150
|
+
node.append(el("div", "fs-ov-detail", row.title || "No title"));
|
|
151
|
+
const phone = row.phone ? ` • ${row.phone}` : "";
|
|
152
|
+
node.append(el("div", "fs-ov-detail", `${row.email ?? ""}${phone}`.trim()));
|
|
153
|
+
return node;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function caseRow(row: CaseSummary): HTMLElement {
|
|
157
|
+
const node = link("fs-ov-row", `/__observe_me/cases/${row.id}`);
|
|
158
|
+
node.append(el("div", "fs-ov-row-title", row.title));
|
|
159
|
+
if (row.priority) {
|
|
160
|
+
const meta = el("div", "fs-ov-row-meta");
|
|
161
|
+
const badge = el("span", `fs-ov-badge fs-ov-priority-${row.priority.toLowerCase()}`, titleCase(row.priority));
|
|
162
|
+
meta.append(badge);
|
|
163
|
+
node.append(meta);
|
|
164
|
+
}
|
|
165
|
+
const created = formatWhen(row.createdAt) ?? "Unknown";
|
|
166
|
+
const status = row.status ? titleCase(row.status) : "Open";
|
|
167
|
+
node.append(el("div", "fs-ov-detail", `Created: ${created} • ${status}`));
|
|
168
|
+
return node;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function meetingRow(row: MeetingSummary): HTMLElement {
|
|
172
|
+
const node = link("fs-ov-row", `/__observe_me/meetings/${row.id}`);
|
|
173
|
+
node.append(el("div", "fs-ov-row-title", row.title));
|
|
174
|
+
const when = formatWhen(row.startTime, true);
|
|
175
|
+
if (when) node.append(el("div", "fs-ov-detail", when));
|
|
176
|
+
const count = row.attendees;
|
|
177
|
+
node.append(el("div", "fs-ov-detail", `${count} attendee${count === 1 ? "" : "s"}`));
|
|
178
|
+
return node;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function mountAccountOverview(host: HTMLElement, header: HTMLElement, overview: AccountOverview): void {
|
|
182
|
+
const style = el("style", "");
|
|
183
|
+
style.textContent = CSS;
|
|
184
|
+
header.append(style);
|
|
185
|
+
|
|
186
|
+
const { account, metrics } = overview;
|
|
187
|
+
const card = el("div", "fs-ov");
|
|
188
|
+
|
|
189
|
+
const head = el("div", "fs-ov-head");
|
|
190
|
+
const title = el("div", "fs-ov-title");
|
|
191
|
+
const name = el("div", "fs-ov-name");
|
|
192
|
+
name.append(el("div", "fs-ov-mark", initials(account.name)), el("span", "fs-ov-account", account.name));
|
|
193
|
+
const open = link("fs-ov-open", `/__observe_me/accounts/${account.id}`);
|
|
194
|
+
const icon = openIcon();
|
|
195
|
+
if (icon) open.append(icon);
|
|
196
|
+
open.append(el("span", "fs-ov-open-label", "Open Account"));
|
|
197
|
+
title.append(name, open);
|
|
198
|
+
head.append(title);
|
|
199
|
+
|
|
200
|
+
const size = account.employeeCount === null ? "Size unknown" : `${account.employeeCount.toLocaleString()} employees`;
|
|
201
|
+
head.append(el("div", "fs-ov-sub", `${account.industry || "Industry not specified"} • ${size}`));
|
|
202
|
+
|
|
203
|
+
const grid = el("div", "fs-ov-grid");
|
|
204
|
+
grid.append(field("Phone", account.phone || "Not provided"));
|
|
205
|
+
const website = el("div", "fs-ov-value");
|
|
206
|
+
if (account.website) {
|
|
207
|
+
const site = el("a", "", account.website) as HTMLAnchorElement;
|
|
208
|
+
site.href = account.website;
|
|
209
|
+
attr(site, "target", "_blank");
|
|
210
|
+
website.append(site);
|
|
211
|
+
} else {
|
|
212
|
+
website.textContent = "Not provided";
|
|
213
|
+
}
|
|
214
|
+
grid.append(field("Website", website));
|
|
215
|
+
grid.append(field("Annual Revenue", account.revenue === null ? "Not disclosed" : formatMoney(account.revenue)));
|
|
216
|
+
grid.append(field("Employees", account.employeeCount === null ? "Not specified" : account.employeeCount.toLocaleString()));
|
|
217
|
+
head.append(grid);
|
|
218
|
+
card.append(head);
|
|
219
|
+
|
|
220
|
+
const metricsBar = el("div", "fs-ov-metrics");
|
|
221
|
+
const metric = (label: string, value: string, tone: string) => {
|
|
222
|
+
const item = el("div", "fs-ov-metric");
|
|
223
|
+
item.append(el("div", "fs-ov-label", label), el("b", tone, value));
|
|
224
|
+
return item;
|
|
225
|
+
};
|
|
226
|
+
metricsBar.append(
|
|
227
|
+
metric("Open Deals", String(metrics.openOpportunities), "fs-ov-deals"),
|
|
228
|
+
metric("Pipeline", formatPipeline(metrics.pipelineValue), "fs-ov-pipeline"),
|
|
229
|
+
metric("Contacts", String(metrics.contacts), "fs-ov-contacts"),
|
|
230
|
+
metric("Open Cases", String(metrics.openCases), "fs-ov-cases"),
|
|
231
|
+
);
|
|
232
|
+
card.append(metricsBar);
|
|
233
|
+
|
|
234
|
+
const tabs: { id: Tab; label: string; count: number; empty: string; rows: HTMLElement[] }[] = [
|
|
235
|
+
{
|
|
236
|
+
id: "opportunities",
|
|
237
|
+
label: "Opportunities",
|
|
238
|
+
count: overview.opportunities.length,
|
|
239
|
+
empty: "No open opportunities",
|
|
240
|
+
rows: overview.opportunities.slice(0, SHOWN).map(opportunityRow),
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: "meetings",
|
|
244
|
+
label: "Meetings",
|
|
245
|
+
count: overview.recentMeetings.length,
|
|
246
|
+
empty: "No recent meetings",
|
|
247
|
+
rows: overview.recentMeetings.slice(0, SHOWN).map(meetingRow),
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: "cases",
|
|
251
|
+
label: "Cases",
|
|
252
|
+
count: overview.openCases.length,
|
|
253
|
+
empty: "No open cases",
|
|
254
|
+
rows: overview.openCases.slice(0, SHOWN).map(caseRow),
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: "contacts",
|
|
258
|
+
label: "Contacts",
|
|
259
|
+
count: overview.contacts.length,
|
|
260
|
+
empty: "No contacts",
|
|
261
|
+
rows: overview.contacts.slice(0, SHOWN).map(contactRow),
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
const tabBar = el("div", "fs-ov-tabs");
|
|
266
|
+
const panel = el("div", "fs-ov-panel");
|
|
267
|
+
const buttons = new Map<Tab, HTMLButtonElement>();
|
|
268
|
+
|
|
269
|
+
const show = (id: Tab) => {
|
|
270
|
+
const tab = tabs.find((item) => item.id === id);
|
|
271
|
+
if (!tab) return;
|
|
272
|
+
for (const item of tabs) {
|
|
273
|
+
const button = buttons.get(item.id);
|
|
274
|
+
if (button) button.className = item.id === id ? "fs-ov-tab is-active" : "fs-ov-tab";
|
|
275
|
+
}
|
|
276
|
+
panel.textContent = "";
|
|
277
|
+
if (tab.count > SHOWN) {
|
|
278
|
+
const more = el("div", "fs-ov-more");
|
|
279
|
+
more.append(link("", `/__observe_me/${tab.id}?accountId=${account.id}`, "View All"));
|
|
280
|
+
panel.append(more);
|
|
281
|
+
}
|
|
282
|
+
if (tab.rows.length === 0) {
|
|
283
|
+
panel.append(el("div", "fs-ov-empty", tab.empty));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const list = el("div", "fs-ov-list");
|
|
287
|
+
for (const row of tab.rows) list.append(row);
|
|
288
|
+
panel.append(list);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
for (const tab of tabs) {
|
|
292
|
+
const button = el("button", "fs-ov-tab", `${tab.label} (${tab.count})`) as HTMLButtonElement;
|
|
293
|
+
button.type = "button";
|
|
294
|
+
button.addEventListener("click", () => show(tab.id));
|
|
295
|
+
buttons.set(tab.id, button);
|
|
296
|
+
tabBar.append(button);
|
|
297
|
+
}
|
|
298
|
+
card.append(tabBar, panel);
|
|
299
|
+
show("opportunities");
|
|
300
|
+
|
|
301
|
+
host.textContent = "";
|
|
302
|
+
host.append(card);
|
|
303
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"account": {
|
|
3
|
+
"id": "acct_1",
|
|
4
|
+
"name": "Northwind",
|
|
5
|
+
"industry": "Logistics",
|
|
6
|
+
"website": "https://northwind.example",
|
|
7
|
+
"phone": "(415) 555-0148",
|
|
8
|
+
"revenue": 2400000,
|
|
9
|
+
"employeeCount": 180
|
|
10
|
+
},
|
|
11
|
+
"opportunities": [
|
|
12
|
+
{ "id": "opp_1", "name": "Renewal", "value": 12000, "stage": "proposal", "closeDate": "2026-10-15" },
|
|
13
|
+
{ "id": "opp_2", "name": "Expansion", "value": 6000, "stage": "qualified", "closeDate": "2026-11-02" }
|
|
14
|
+
],
|
|
15
|
+
"contacts": [
|
|
16
|
+
{ "id": "ct_1", "name": "Dana Reyes", "email": "dana@example.com", "phone": "(415) 555-0199", "title": "Buyer" },
|
|
17
|
+
{ "id": "ct_2", "name": "Sam Lee", "email": "sam@example.com", "phone": "", "title": "Ops" }
|
|
18
|
+
],
|
|
19
|
+
"openCases": [
|
|
20
|
+
{ "id": "case_1", "title": "Billing question", "priority": "high", "status": "open", "createdAt": "2026-09-12" }
|
|
21
|
+
],
|
|
22
|
+
"recentMeetings": [
|
|
23
|
+
{
|
|
24
|
+
"id": "mtg_1",
|
|
25
|
+
"title": "Quarterly review",
|
|
26
|
+
"startTime": "2026-09-22T15:00:00Z",
|
|
27
|
+
"attendees": ["Dana Reyes", "Sam Lee", "Pat"]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Account overview",
|
|
3
|
+
"level": "L2",
|
|
4
|
+
"family": "show-a-record",
|
|
5
|
+
"kind": "action",
|
|
6
|
+
"action": "show_account_overview",
|
|
7
|
+
"entry": "agent/actions/show_account_overview.ts",
|
|
8
|
+
"outcome": "One account drawn as a card: identity, pipeline, contacts, cases, meetings",
|
|
9
|
+
"provenBy": 1
|
|
10
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Bottom bar with page starters — L0
|
|
2
|
+
|
|
3
|
+
The bottom bar is the agent's resting entry point: a bar at the foot of the
|
|
4
|
+
page that shows a few starters and opens the agent when one is clicked. Its
|
|
5
|
+
starters change with the screen the user is on, so an agent with two actions
|
|
6
|
+
still looks useful everywhere.
|
|
7
|
+
|
|
8
|
+
It is configured **from code** — Agent Studio has no UI for it today, and this
|
|
9
|
+
is how customers set it. **Proven by 2 production builds** (one in the
|
|
10
|
+
product's own source, one from the agent project behind a feature flag).
|
|
11
|
+
|
|
12
|
+
## Adapt it
|
|
13
|
+
|
|
14
|
+
| In `agent/bottomBar.ts` | Change |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `PAGE_STARTERS` | This product's screens (path prefixes) and, for each, two or three questions **its actions can answer**. A starter for something the agent cannot do is worse than none |
|
|
17
|
+
| `DEFAULT_STARTERS` | What to show on any other page |
|
|
18
|
+
| `BOTTOM_BAR_SETTINGS` | Leave as the docs' defaults unless the product asks |
|
|
19
|
+
|
|
20
|
+
Then call it from the bundle's entry point:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// agent/actions/index.ts — last line
|
|
24
|
+
import { installBottomBar } from "../bottomBar";
|
|
25
|
+
installBottomBar();
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## What those builds learned
|
|
29
|
+
|
|
30
|
+
- **`setConversationStarters` replaces the whole set for the page** — both
|
|
31
|
+
groups. `null` restores Agent Studio's. So always pass the full list you
|
|
32
|
+
want shown.
|
|
33
|
+
- **In embedded mode the bar needs `openEmbeddedCallback`**, or nothing opens
|
|
34
|
+
when a starter is clicked. This recipe is for the overlay agent; see the
|
|
35
|
+
docs for embedded.
|
|
36
|
+
- **Route changes in a single-page app don't reload**, so the starters must be
|
|
37
|
+
re-applied on navigation — this recipe wraps `pushState` / `replaceState`
|
|
38
|
+
and listens to `popstate`.
|
|
39
|
+
- **Dark mode**: the SDK's default colours are being fixed (PLG-5891). Until
|
|
40
|
+
then a product in dark mode may need `theme` / `darkModeSettings` set.
|
|
41
|
+
|
|
42
|
+
Docs: [Bottom Bar](https://docs.foldspace.ai/customize/bottom-bar/) ·
|
|
43
|
+
[Conversation starters](https://docs.foldspace.ai/customize/conversation-starters/)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The bottom bar: the agent's resting entry point at the foot of the page,
|
|
2
|
+
// cycling starters that fit the screen the user is on. Configured from code -
|
|
3
|
+
// Agent Studio has no UI for it today, and this is how customers set it.
|
|
4
|
+
// https://docs.foldspace.ai/customize/bottom-bar/
|
|
5
|
+
// https://docs.foldspace.ai/customize/conversation-starters/
|
|
6
|
+
//
|
|
7
|
+
// Product-neutral: the paths and starters below are placeholders. Replace them
|
|
8
|
+
// with this product's own screens and with questions its actions can answer.
|
|
9
|
+
|
|
10
|
+
import { getAgent } from "./utils";
|
|
11
|
+
|
|
12
|
+
/** Starters per screen. Paths are matched as prefixes, first match wins. */
|
|
13
|
+
export const PAGE_STARTERS: Array<{ match: RegExp; starters: string[] }> = [
|
|
14
|
+
{ match: /^\/dashboard/, starters: ["<A question about what this screen shows>", "<Another one>"] },
|
|
15
|
+
{ match: /^\/<records>/, starters: ["Find <a record> by name", "Show me <a record>'s details"] },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** Shown on any page not listed above. */
|
|
19
|
+
export const DEFAULT_STARTERS: string[] = ["What can you do?", "Show my account"];
|
|
20
|
+
|
|
21
|
+
// The docs' own settings, unchanged. Change nothing here unless the product
|
|
22
|
+
// asks for it: every field is documented on the Bottom Bar page.
|
|
23
|
+
export const BOTTOM_BAR_SETTINGS = {
|
|
24
|
+
enabled: true,
|
|
25
|
+
maxVisibleStarters: 3,
|
|
26
|
+
reopenFrequency: "EVERY_LOAD",
|
|
27
|
+
starterClickBehavior: "OPEN_AGENT",
|
|
28
|
+
idleTimeoutMs: 5000,
|
|
29
|
+
initialBehaviorMode: "FULL",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export function startersForPath(pathname: string): string[] {
|
|
33
|
+
const hit = PAGE_STARTERS.find((entry) => entry.match.test(pathname));
|
|
34
|
+
return hit ? hit.starters : DEFAULT_STARTERS;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// On window, not in module scope: a bundle evaluated twice on one page must
|
|
38
|
+
// not install two route listeners.
|
|
39
|
+
const INSTALLED_FLAG = "__foldspace_bottom_bar__";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Turn the bar on and keep its starters matched to the page. Call once from
|
|
43
|
+
* the bundle's entry point; safe to call again.
|
|
44
|
+
*
|
|
45
|
+
* `setConversationStarters` REPLACES the whole set for this page view (both
|
|
46
|
+
* groups); passing `null` restores what Agent Studio has. Starters go in the
|
|
47
|
+
* KNOWLEDGE group so the bar shows them as plain questions.
|
|
48
|
+
*/
|
|
49
|
+
export function installBottomBar(): void {
|
|
50
|
+
const foldspace = (window as any).foldspace;
|
|
51
|
+
if (typeof foldspace !== "function") return;
|
|
52
|
+
if ((window as any)[INSTALLED_FLAG]) return;
|
|
53
|
+
(window as any)[INSTALLED_FLAG] = true;
|
|
54
|
+
|
|
55
|
+
foldspace("when", "ready", () => {
|
|
56
|
+
const agent = getAgent();
|
|
57
|
+
if (!agent) return;
|
|
58
|
+
let current: string[] = [];
|
|
59
|
+
|
|
60
|
+
const apply = () => {
|
|
61
|
+
const next = startersForPath(window.location.pathname);
|
|
62
|
+
if (next === current) return;
|
|
63
|
+
current = next;
|
|
64
|
+
agent.setConversationStarters?.(
|
|
65
|
+
{ KNOWLEDGE: next.map((title) => ({ title })), ACTION: [] },
|
|
66
|
+
"KNOWLEDGE",
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// One whole settings group per call, as the docs do.
|
|
71
|
+
agent.setConfiguration?.({ bottomBarSettings: BOTTOM_BAR_SETTINGS });
|
|
72
|
+
apply();
|
|
73
|
+
|
|
74
|
+
// Single-page apps change the URL without a load: re-apply on navigation
|
|
75
|
+
// and bring the bar back so the new starters are seen.
|
|
76
|
+
const onRouteChange = () => {
|
|
77
|
+
apply();
|
|
78
|
+
agent.openBottomBar?.({ mode: "FULL" });
|
|
79
|
+
};
|
|
80
|
+
window.addEventListener("popstate", onRouteChange);
|
|
81
|
+
const history = window.history as any;
|
|
82
|
+
for (const method of ["pushState", "replaceState"] as const) {
|
|
83
|
+
const original = history[method];
|
|
84
|
+
if (typeof original !== "function" || original.__foldspaceWrapped) continue;
|
|
85
|
+
const wrapped = function (this: any, ...args: unknown[]) {
|
|
86
|
+
const result = original.apply(this, args);
|
|
87
|
+
onRouteChange();
|
|
88
|
+
return result;
|
|
89
|
+
};
|
|
90
|
+
(wrapped as any).__foldspaceWrapped = true;
|
|
91
|
+
history[method] = wrapped;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "This recipe makes no network calls. What it sends to the SDK on /dashboard:",
|
|
3
|
+
"setConfiguration": {
|
|
4
|
+
"bottomBarSettings": {
|
|
5
|
+
"enabled": true,
|
|
6
|
+
"maxVisibleStarters": 3,
|
|
7
|
+
"reopenFrequency": "EVERY_LOAD",
|
|
8
|
+
"starterClickBehavior": "OPEN_AGENT",
|
|
9
|
+
"idleTimeoutMs": 5000,
|
|
10
|
+
"initialBehaviorMode": "FULL"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"setConversationStarters": {
|
|
14
|
+
"starters": { "KNOWLEDGE": [{ "title": "<A question about what this screen shows>" }, { "title": "<Another one>" }], "ACTION": [] },
|
|
15
|
+
"defaultStarterType": "KNOWLEDGE"
|
|
16
|
+
}
|
|
17
|
+
}
|