@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
package/CLAUDE.md
CHANGED
|
@@ -77,8 +77,8 @@ at the app or testing in it, run `npx foldspace badge working` (a bar across
|
|
|
77
77
|
the top of the window says Foldspace is building here, leave it alone) and say
|
|
78
78
|
so in chat: *"I'm going to work in that Chrome window for a few minutes -
|
|
79
79
|
please leave it alone until I tell you it's ready."* When it is their turn,
|
|
80
|
-
`npx foldspace badge ready --text "
|
|
81
|
-
|
|
80
|
+
`npx foldspace badge ready --text "<the question this experience answers>"` -
|
|
81
|
+
the bar adds "Try asking:" and a Copy button itself - and say the same in chat. `foldspace badge label` puts the small
|
|
82
82
|
corner tag back; `off` removes it, for a screenshot they will share.
|
|
83
83
|
|
|
84
84
|
Navigation, uploads, extraction and anything that writes are a later session.
|
|
@@ -438,10 +438,14 @@ placeholder path and fields with what you observed. They match the harness
|
|
|
438
438
|
version you have installed, and every one is built and run on each release.
|
|
439
439
|
|
|
440
440
|
L0 → `who-is-the-user`. L2 → `find-by-name`, and `pick-from-a-list` when the
|
|
441
|
-
user has to choose.
|
|
441
|
+
user has to choose. Show one account on a card → `account-overview`. Upload a
|
|
442
|
+
CSV of contacts → `upload-contacts`. Save a note on a meeting →
|
|
443
|
+
`update-meeting-notes`. Prepare for a meeting → `prepare-for-a-meeting`. The records that need
|
|
444
|
+
attention, with reasons → `opportunities-at-risk`.
|
|
445
|
+
App does not use a bearer token → `swap-the-login-method`.
|
|
442
446
|
Each says how many production builds carry the pattern and what they learned.
|
|
443
|
-
If nothing fits — there is no L1 card recipe yet
|
|
444
|
-
`agent/actions/_example.ts`.
|
|
447
|
+
If nothing fits — there is no L1 card recipe yet, and no navigation recipe —
|
|
448
|
+
say so and build from `agent/actions/_example.ts`.
|
|
445
449
|
|
|
446
450
|
## What you already have
|
|
447
451
|
|
package/bin/attach.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from "../src/attach-preflight.mjs";
|
|
45
45
|
import { serializeDiagnostic } from "../src/diagnostics.mjs";
|
|
46
46
|
import { recordEvent } from "../src/session-events.mjs";
|
|
47
|
+
import { withoutStealingFocus } from "../src/keep-focus.mjs";
|
|
47
48
|
import {
|
|
48
49
|
ERROR_CODES,
|
|
49
50
|
createLifecycleResult,
|
|
@@ -222,9 +223,12 @@ const SDK_PATTERNS = [
|
|
|
222
223
|
|
|
223
224
|
// The badge has three states, set from outside with \`foldspace badge\`:
|
|
224
225
|
// label - the small corner tag (default): who is attached, which mode
|
|
225
|
-
// working - a
|
|
226
|
-
// please leave it alone
|
|
227
|
-
// ready -
|
|
226
|
+
// working - a pill at the foot of the window: the agent is building in this
|
|
227
|
+
// window, please leave it alone
|
|
228
|
+
// ready - the same pill: it is the human's turn, with the question to try
|
|
229
|
+
// The pill sits bottom-left, never across the top: a bar there covered the
|
|
230
|
+
// app's own header and menu (seen on a CRM, 2026-09-22). The agent's panel
|
|
231
|
+
// is on the right, so bottom-left is the corner nothing else uses.
|
|
228
232
|
// The state lives in sessionStorage so it survives navigation inside the
|
|
229
233
|
// test window; \`foldspace badge\` writes it and the script redraws.
|
|
230
234
|
const badgeSrc = `(() => {
|
|
@@ -246,11 +250,35 @@ const badgeSrc = `(() => {
|
|
|
246
250
|
b.dataset.fsState = want;
|
|
247
251
|
const base = "position:fixed;top:0;left:0;z-index:2147483647;color:#fff;font:700 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.12em;pointer-events:none;box-shadow:0 1px 6px rgba(0,0,0,.35);";
|
|
248
252
|
if (s.state === "working") {
|
|
249
|
-
|
|
250
|
-
|
|
253
|
+
// Attention, not decoration: a pulsing marker, the ask in bold, and an
|
|
254
|
+
// animated line underneath so it reads as "in progress" at a glance.
|
|
255
|
+
b.style.cssText = base + "top:auto;bottom:16px;left:16px;right:auto;max-width:min(560px,calc(100vw - 32px));border-radius:8px;overflow:hidden;background:#1F2A8A;padding:10px 14px 13px;font-size:13px;letter-spacing:.02em;text-align:left;line-height:1.3;";
|
|
256
|
+
b.innerHTML =
|
|
257
|
+
'<style>@keyframes fsPulse{0%,100%{opacity:1}50%{opacity:.25}}@keyframes fsSweep{0%{left:-30%}100%{left:100%}}' +
|
|
258
|
+
'#foldspace-dev-badge .fs-dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#FFB300;margin-right:10px;vertical-align:middle;animation:fsPulse 1s ease-in-out infinite}' +
|
|
259
|
+
'#foldspace-dev-badge .fs-line{position:absolute;left:0;right:0;bottom:0;height:3px;background:rgba(255,255,255,.15);overflow:hidden}' +
|
|
260
|
+
'#foldspace-dev-badge .fs-line::after{content:"";position:absolute;top:0;width:30%;height:100%;background:#FFB300;animation:fsSweep 1.4s linear infinite}</style>' +
|
|
261
|
+
'<span class="fs-dot"></span>Foldspace is building your agent on this page. <b style="color:#FFD54F">Please leave this window alone</b> until Claude says it is ready.' +
|
|
262
|
+
(s.text ? " " + s.text.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c]) : "") +
|
|
263
|
+
'<span class="fs-line"></span>';
|
|
251
264
|
} else if (s.state === "ready") {
|
|
252
|
-
|
|
253
|
-
|
|
265
|
+
// Calm, and useful: the question to try, with a Copy button so nobody
|
|
266
|
+
// retypes it into the panel. The bar accepts clicks only on that button.
|
|
267
|
+
const esc = (t) => String(t).replace(/[<>&"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """ })[c]);
|
|
268
|
+
const question = s.text ? esc(s.text) : "";
|
|
269
|
+
b.style.cssText = base + "top:auto;bottom:16px;left:16px;right:auto;max-width:min(560px,calc(100vw - 32px));border-radius:8px;overflow:hidden;background:#0F9D58;padding:10px 14px;font-size:13px;letter-spacing:.02em;text-align:left;line-height:1.3;pointer-events:none;";
|
|
270
|
+
b.innerHTML =
|
|
271
|
+
'<style>#foldspace-dev-badge .fs-copy{pointer-events:auto;margin-left:12px;padding:4px 10px;border:1px solid rgba(255,255,255,.7);border-radius:4px;background:transparent;color:#fff;font:inherit;font-size:12px;cursor:pointer;letter-spacing:0}' +
|
|
272
|
+
'#foldspace-dev-badge .fs-copy:hover{background:rgba(255,255,255,.15)}</style>' +
|
|
273
|
+
'<b>Ready - your turn.</b> ' +
|
|
274
|
+
(question ? 'Try asking: <span class="fs-q">' + question + '</span><button class="fs-copy" type="button">Copy</button>' : "Open your agent and try it.");
|
|
275
|
+
const btn = b.querySelector(".fs-copy");
|
|
276
|
+
if (btn) btn.addEventListener("click", () => {
|
|
277
|
+
const q = b.querySelector(".fs-q")?.textContent || "";
|
|
278
|
+
const done = () => { btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy"; }, 1500); };
|
|
279
|
+
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(q).then(done, done);
|
|
280
|
+
else { const ta = document.createElement("textarea"); ta.value = q; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); } catch (e) {} ta.remove(); done(); }
|
|
281
|
+
});
|
|
254
282
|
} else {
|
|
255
283
|
b.style.cssText = base + "background:#3247F2;padding:5px 10px;border-bottom-right-radius:4px;";
|
|
256
284
|
b.textContent = LABEL;
|
|
@@ -724,7 +752,7 @@ async function prepare(ws, sessionId, url) {
|
|
|
724
752
|
),
|
|
725
753
|
});
|
|
726
754
|
console.log(` attached -> ${url}`);
|
|
727
|
-
await call(ws, "Page.reload", { ignoreCache: true }, sessionId);
|
|
755
|
+
await withoutStealingFocus(() => call(ws, "Page.reload", { ignoreCache: true }, sessionId));
|
|
728
756
|
|
|
729
757
|
{
|
|
730
758
|
const verification = await verifyPreparedPage(ws, sessionId);
|
|
@@ -1039,7 +1067,7 @@ async function cleanupPreparedSession(
|
|
|
1039
1067
|
// identity aliases alike.
|
|
1040
1068
|
if (reload) {
|
|
1041
1069
|
try {
|
|
1042
|
-
await call(socket, "Page.reload", { ignoreCache: true }, sessionId);
|
|
1070
|
+
await withoutStealingFocus(() => call(socket, "Page.reload", { ignoreCache: true }, sessionId));
|
|
1043
1071
|
} catch (error) {
|
|
1044
1072
|
failures.push(`restore reload: ${error instanceof Error ? error.message : String(error)}`);
|
|
1045
1073
|
}
|
package/bin/observe.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import fs from "node:fs";
|
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
|
|
24
24
|
import { CdpPage, parseArgs, printJson, readLaunchState, sleep } from "../src/cdp-client.mjs";
|
|
25
|
+
import { withoutStealingFocus } from "../src/keep-focus.mjs";
|
|
25
26
|
import {
|
|
26
27
|
NAV_ITEM_SELECTOR,
|
|
27
28
|
NAV_SCOPE_SELECTOR,
|
|
@@ -330,7 +331,7 @@ async function runScreen(page, flags) {
|
|
|
330
331
|
let reached;
|
|
331
332
|
|
|
332
333
|
if (destination) {
|
|
333
|
-
await page.send("Page.navigate", { url: destination.url });
|
|
334
|
+
await withoutStealingFocus(() => page.send("Page.navigate", { url: destination.url }));
|
|
334
335
|
reached = { how: "goto", path: destination.path };
|
|
335
336
|
if (flags.click !== undefined) await network.settle(Math.min(waitMs, 3000));
|
|
336
337
|
}
|
|
@@ -360,7 +361,7 @@ async function runScreen(page, flags) {
|
|
|
360
361
|
}
|
|
361
362
|
if (!reached) {
|
|
362
363
|
// Nothing to reach: reload so this screen's own requests are seen.
|
|
363
|
-
await page.send("Page.reload");
|
|
364
|
+
await withoutStealingFocus(() => page.send("Page.reload"));
|
|
364
365
|
reached = { how: "reload" };
|
|
365
366
|
}
|
|
366
367
|
await network.settle(waitMs);
|
|
@@ -456,7 +457,7 @@ async function runAuth(page, flags) {
|
|
|
456
457
|
const waitMs = numberFlag(flags.wait, 5000, "wait");
|
|
457
458
|
await page.send("Network.enable");
|
|
458
459
|
const network = recordNetwork(page);
|
|
459
|
-
await page.send("Page.reload");
|
|
460
|
+
await withoutStealingFocus(() => page.send("Page.reload"));
|
|
460
461
|
await network.settle(waitMs);
|
|
461
462
|
|
|
462
463
|
const url = await page.evaluate("return location.href;");
|
package/package.json
CHANGED
package/recipes/INDEX.md
CHANGED
|
@@ -9,8 +9,12 @@ Read this whole table, then open the recipe closest to the outcome.
|
|
|
9
9
|
| [`pick-from-a-list`](pick-from-a-list/) | **L2** | A clickable list, shown **only** when the user has to choose — with "none of these" and cancel | An action with a card that waits for the user | 1 production build |
|
|
10
10
|
| [`bottom-bar`](bottom-bar/) | **L0** | The agent's resting entry point is a bar at the foot of the page whose starters change with the screen the user is on — configured from code, the way customers do it | Page set-up, runs once at start-up | 2 |
|
|
11
11
|
| [`swap-the-login-method`](swap-the-login-method/) | any | `apiFetch` for an app that authenticates with a custom header, or with cookies, instead of a bearer token | A replacement `agent/utils.ts` | 2 production builds (3 builds, 3 different schemes) |
|
|
12
|
+
| [`account-overview`](account-overview/) | **L2** | One account on a card: identity, pipeline total, contacts, cases, meetings. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
|
|
13
|
+
| [`upload-contacts`](upload-contacts/) | **L4** | A CSV is previewed, then written only after the user chooses Import | An action with a card that waits for the user | 1 production build |
|
|
14
|
+
| [`update-meeting-notes`](update-meeting-notes/) | **L4** | A note is saved on a meeting and the new note id comes back | A write. **No card** | 1 production build |
|
|
15
|
+
| [`prepare-for-a-meeting`](prepare-for-a-meeting/) | **L2** | Meeting facts are gathered in code, then a task agent writes the summary | A data action plus a task agent. **No card** | 1 production build |
|
|
16
|
+
| [`opportunities-at-risk`](opportunities-at-risk/) | **L2** | Every open opportunity the app flags as at risk, on one card: count, total, and why each one is at risk. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
|
|
12
17
|
|
|
13
18
|
**Not here yet — no production build proves it:** the user's own plan or
|
|
14
19
|
settings drawn as a card (L1). In production that data is sent to `identify`
|
|
15
|
-
(`who-is-the-user`), not rendered. Navigation (L3)
|
|
16
|
-
task agents are also still to come.
|
|
20
|
+
(`who-is-the-user`), not rendered. Navigation (L3) is also still to come.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Account overview — L2
|
|
2
|
+
|
|
3
|
+
**The user says** "show me this account". **The user sees** the account card: identity, phone, revenue, a metrics bar, and tabs for opportunities, meetings, cases, and contacts.
|
|
4
|
+
|
|
5
|
+
**Proven by 1 production build.**
|
|
6
|
+
|
|
7
|
+
This card is the base layout. Copy it, then replace the `/__observe_me` routes with the app's own paths and re-sample `brand.ts` if the product is not this dark theme.
|
|
8
|
+
|
|
9
|
+
The card is view-only. `awaitUserInput` stays off. For a component that only shows something, the model is told that the information was displayed — nothing `execute` returned and nothing the card drew. One tab is on screen at a time; an empty tab says so. A signed-out or failed call is drawn with `renderFailure`, never as zeros. A 200 full of nulls is signed out when the account has no id and no name.
|
|
10
|
+
|
|
11
|
+
In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId`.
|
|
12
|
+
|
|
13
|
+
## Adapt it
|
|
14
|
+
|
|
15
|
+
| File | Change |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `agent/api/accounts.ts` | Mock path and envelope. Find a real API that returns a similar shape |
|
|
18
|
+
| `agent/accounts.ts` | Which fields a summary carries. The pipeline total is summed here |
|
|
19
|
+
| `agent/views/overview.ts` | The card. Routes under `/__observe_me` are placeholders for the app's own paths. Re-sample `brand.ts` when the host product is not this dark theme |
|
|
20
|
+
|
|
21
|
+
## What that build learned the hard way
|
|
22
|
+
|
|
23
|
+
- **The card is the experience.** A rich object returned from `execute` does not reach the model when the action only draws.
|
|
24
|
+
- **Add the money in code.** Line items summed by the model eventually come out wrong. The card shows the total the code computed.
|
|
25
|
+
- **The links only work where the app listens.** "Open Account", the rows and "View All" post a message the build's single-page app routed on; other apps do not. ⚠️ Wire that message in the host app, or replace the links with a navigation route.
|
|
26
|
+
- **A signed-out response often looks empty.** Zeros and nulls on a 200 are not an account with no pipeline. Check for an id and a name before drawing numbers.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Totals are computed here, from the API's own numbers. The model does not add them.
|
|
2
|
+
|
|
3
|
+
import type { OverviewPayload } from "./api/accounts";
|
|
4
|
+
|
|
5
|
+
export type AccountSummary = {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
industry: string | null;
|
|
9
|
+
website: string | null;
|
|
10
|
+
phone: string | null;
|
|
11
|
+
revenue: number | null;
|
|
12
|
+
employeeCount: number | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type OpportunitySummary = {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
value: number;
|
|
19
|
+
stage: string | null;
|
|
20
|
+
closeDate: string | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ContactSummary = {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
email: string | null;
|
|
27
|
+
phone: string | null;
|
|
28
|
+
title: string | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type CaseSummary = {
|
|
32
|
+
id: string;
|
|
33
|
+
title: string;
|
|
34
|
+
priority: string | null;
|
|
35
|
+
status: string | null;
|
|
36
|
+
createdAt: string | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type MeetingSummary = {
|
|
40
|
+
id: string;
|
|
41
|
+
title: string;
|
|
42
|
+
startTime: string | null;
|
|
43
|
+
attendees: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type AccountMetrics = {
|
|
47
|
+
openOpportunities: number;
|
|
48
|
+
pipelineValue: number;
|
|
49
|
+
contacts: number;
|
|
50
|
+
openCases: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type AccountOverview = {
|
|
54
|
+
account: AccountSummary;
|
|
55
|
+
metrics: AccountMetrics;
|
|
56
|
+
opportunities: OpportunitySummary[];
|
|
57
|
+
contacts: ContactSummary[];
|
|
58
|
+
openCases: CaseSummary[];
|
|
59
|
+
recentMeetings: MeetingSummary[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** A 200 full of nulls is not an account. Zeros are only real once an id and a name are present. */
|
|
63
|
+
export function belongsToSignedInUser(payload: OverviewPayload): boolean {
|
|
64
|
+
const id = payload.account?.id;
|
|
65
|
+
const name = payload.account?.name;
|
|
66
|
+
return typeof id === "string" && id.trim() !== "" && typeof name === "string" && name.trim() !== "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatMoney(value: number): string {
|
|
70
|
+
const rounded = Math.round(value);
|
|
71
|
+
const sign = rounded < 0 ? "-" : "";
|
|
72
|
+
const digits = String(Math.abs(rounded));
|
|
73
|
+
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
74
|
+
return `${sign}$${grouped}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The metrics bar shortens the pipeline: $18K, $2.4M. Row amounts stay full. */
|
|
78
|
+
export function formatPipeline(value: number): string {
|
|
79
|
+
const amount = Math.abs(value);
|
|
80
|
+
const sign = value < 0 ? "-" : "";
|
|
81
|
+
if (amount >= 1_000_000) return `${sign}$${(amount / 1_000_000).toFixed(1)}M`;
|
|
82
|
+
if (amount >= 1000) return `${sign}$${(amount / 1000).toFixed(0)}K`;
|
|
83
|
+
return `${sign}$${amount.toFixed(0)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatWhen(iso: string | null, withTime = false): string | null {
|
|
87
|
+
if (!iso) return null;
|
|
88
|
+
const date = new Date(iso);
|
|
89
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
90
|
+
const day = date.toLocaleDateString();
|
|
91
|
+
if (!withTime) return day;
|
|
92
|
+
const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
93
|
+
return `${day} at ${time}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function titleCase(value: string): string {
|
|
97
|
+
return value
|
|
98
|
+
.split(/[_\s]+/)
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
101
|
+
.join(" ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const text = (value: string | null | undefined, fallback: string) => value?.trim() || fallback;
|
|
105
|
+
|
|
106
|
+
export function toOverview(payload: OverviewPayload): AccountOverview {
|
|
107
|
+
const account = payload.account ?? {};
|
|
108
|
+
const opportunities = (payload.opportunities ?? []).map((row) => ({
|
|
109
|
+
id: row.id?.trim() || "",
|
|
110
|
+
name: text(row.name, "Untitled"),
|
|
111
|
+
value: typeof row.value === "number" && Number.isFinite(row.value) ? row.value : 0,
|
|
112
|
+
stage: row.stage?.trim() || null,
|
|
113
|
+
closeDate: row.closeDate?.trim() || null,
|
|
114
|
+
}));
|
|
115
|
+
const contacts = (payload.contacts ?? []).map((row) => ({
|
|
116
|
+
id: row.id?.trim() || "",
|
|
117
|
+
name: text(row.name, "Untitled"),
|
|
118
|
+
email: row.email?.trim() || null,
|
|
119
|
+
phone: row.phone?.trim() || null,
|
|
120
|
+
title: row.title?.trim() || null,
|
|
121
|
+
}));
|
|
122
|
+
const openCases = (payload.openCases ?? []).map((row) => ({
|
|
123
|
+
id: row.id?.trim() || "",
|
|
124
|
+
title: text(row.title, "Untitled"),
|
|
125
|
+
priority: row.priority?.trim() || null,
|
|
126
|
+
status: row.status?.trim() || null,
|
|
127
|
+
createdAt: row.createdAt?.trim() || null,
|
|
128
|
+
}));
|
|
129
|
+
const recentMeetings = (payload.recentMeetings ?? []).map((row) => ({
|
|
130
|
+
id: row.id?.trim() || "",
|
|
131
|
+
title: text(row.title, "Untitled"),
|
|
132
|
+
startTime: row.startTime?.trim() || null,
|
|
133
|
+
attendees: Array.isArray(row.attendees) ? row.attendees.length : 0,
|
|
134
|
+
}));
|
|
135
|
+
const revenue = account.revenue;
|
|
136
|
+
const employees = account.employeeCount;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
account: {
|
|
140
|
+
id: String(account.id),
|
|
141
|
+
name: String(account.name).trim(),
|
|
142
|
+
industry: account.industry?.trim() || null,
|
|
143
|
+
website: account.website?.trim() || null,
|
|
144
|
+
phone: account.phone?.trim() || null,
|
|
145
|
+
revenue: typeof revenue === "number" && Number.isFinite(revenue) ? revenue : null,
|
|
146
|
+
employeeCount: typeof employees === "number" && Number.isFinite(employees) ? employees : null,
|
|
147
|
+
},
|
|
148
|
+
metrics: {
|
|
149
|
+
openOpportunities: opportunities.length,
|
|
150
|
+
pipelineValue: opportunities.reduce((sum, row) => sum + row.value, 0),
|
|
151
|
+
contacts: contacts.length,
|
|
152
|
+
openCases: openCases.length,
|
|
153
|
+
},
|
|
154
|
+
opportunities,
|
|
155
|
+
contacts,
|
|
156
|
+
openCases,
|
|
157
|
+
recentMeetings,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -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;
|