@foldspace_npm/harness 0.1.26 → 0.1.28
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 +402 -204
- package/README.md +84 -36
- package/bin/ask.mjs +19 -9
- package/bin/attach.mjs +91 -56
- package/bin/badge.mjs +28 -8
- package/bin/cli.mjs +23 -6
- package/bin/inject.mjs +3 -4
- package/bin/observe.mjs +22 -6
- package/bin/published.mjs +141 -0
- package/package.json +6 -2
- package/recipes/INDEX.md +13 -8
- package/recipes/README.md +30 -7
- package/recipes/account-overview/README.md +6 -6
- package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
- package/recipes/account-overview/agent/views/brand.ts +23 -14
- package/recipes/account-overview/agent/views/overview.ts +13 -17
- package/recipes/bottom-bar/README.md +12 -9
- package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
- package/recipes/create-a-record/README.md +97 -0
- package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
- package/recipes/create-a-record/agent/api/projects.ts +62 -0
- package/recipes/create-a-record/agent/projects.ts +145 -0
- package/recipes/create-a-record/agent/views/approval.ts +181 -0
- package/recipes/create-a-record/agent/views/brand.ts +35 -0
- package/recipes/create-a-record/agent/views/fields.ts +252 -0
- package/recipes/create-a-record/agent/views/project-card.ts +253 -0
- package/recipes/create-a-record/agent/views/steps.ts +92 -0
- package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
- package/recipes/create-a-record/fixtures/project.created.json +1 -0
- package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
- package/recipes/create-a-record/recipe.json +13 -0
- package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
- package/recipes/find-by-name/README.md +3 -1
- package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
- package/recipes/lookup-then-view/README.md +73 -0
- package/recipes/lookup-then-view/agent/accounts.ts +174 -0
- package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
- package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
- package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
- package/recipes/lookup-then-view/agent/views/account.ts +183 -0
- package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
- package/recipes/lookup-then-view/agent/views/format.ts +73 -0
- package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
- package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
- package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
- package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
- package/recipes/lookup-then-view/recipe.json +11 -0
- package/recipes/lookup-then-view/studio/find_account.json +10 -0
- package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
- package/recipes/opportunities-at-risk/README.md +5 -5
- package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
- package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
- package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
- package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
- package/recipes/pick-from-a-list/README.md +7 -6
- package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
- package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
- package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
- package/recipes/prepare-for-a-meeting/README.md +4 -4
- package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
- package/recipes/records-list/README.md +6 -6
- package/recipes/records-list/agent/accounts.ts +3 -1
- package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
- package/recipes/records-list/agent/views/brand.ts +26 -14
- package/recipes/server-rendered-forms/README.md +35 -0
- package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
- package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
- package/recipes/server-rendered-forms/agent/forms.ts +192 -0
- package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
- package/recipes/server-rendered-forms/fixtures/form.html +17 -0
- package/recipes/server-rendered-forms/fixtures/list.html +9 -0
- package/recipes/server-rendered-forms/fixtures/record.html +4 -0
- package/recipes/server-rendered-forms/recipe.json +10 -0
- package/recipes/several-hosts/README.md +10 -13
- package/recipes/several-hosts/agent/hosts.ts +19 -136
- package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
- package/recipes/several-hosts/recipe.json +3 -3
- package/recipes/swap-the-login-method/README.md +16 -5
- package/recipes/swap-the-login-method/agent/utils.ts +52 -3
- package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
- package/recipes/update-meeting-notes/README.md +10 -4
- package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
- package/recipes/upload-contacts/README.md +5 -5
- package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
- package/recipes/upload-contacts/agent/views/brand.ts +24 -14
- package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
- package/recipes/who-is-the-user/README.md +7 -3
- package/src/ask-core.mjs +16 -4
- package/src/attach-daemon.mjs +9 -2
- package/src/attach-helpers.mjs +105 -0
- package/src/attach-preflight.mjs +34 -11
- package/src/badge-core.mjs +1 -1
- package/src/cdp-client.mjs +15 -19
- package/src/cdp-ownership.mjs +41 -20
- package/src/cli-help.mjs +7 -5
- package/src/cli-registry.mjs +102 -47
- package/src/diagnostics.mjs +1 -1
- package/src/init.mjs +5 -3
- package/src/keep-focus.mjs +17 -13
- package/src/lint/index.mjs +8 -1
- package/src/lint/rules.mjs +240 -43
- package/src/observe-core.mjs +163 -34
- package/src/published-core.mjs +392 -0
- package/src/region.mjs +1 -1
- package/src/runtime/agent.ts +16 -12
- package/src/runtime/http.ts +25 -15
- package/src/runtime/match.ts +1 -1
- package/src/runtime/render.ts +4 -4
- package/src/transports/README.md +2 -2
- package/src/upgrade.mjs +33 -10
- package/templates/agent-starter/CLAUDE.md +8 -5
- package/templates/agent-starter/README.md +32 -33
- package/templates/agent-starter/agent/actions/_example.ts +4 -4
- package/templates/agent-starter/agent/actions/index.ts +2 -2
- package/templates/agent-starter/agent/constants.ts +4 -3
- package/templates/agent-starter/agent/utils.ts +15 -18
- package/templates/agent-starter/claude-settings.json +2 -1
- package/templates/agent-starter/docs/app-profile.md +19 -14
- package/templates/agent-starter/foldspace.dev.json +1 -1
- package/recipes/several-hosts/fixtures/nav-record.json +0 -5
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* foldspace published <dev|prod>
|
|
4
|
+
*
|
|
5
|
+
* Points the test window's agent at the code published to dev or prod, so
|
|
6
|
+
* `foldspace ask` checks what the `deploy` tool published instead of the
|
|
7
|
+
* local build. Only this page's agent changes, in this test window; a reload
|
|
8
|
+
* puts the page back on what the agent loads normally. Nothing is published
|
|
9
|
+
* and nothing changes in Foldspace.
|
|
10
|
+
*
|
|
11
|
+
* Refuses while attach runs (the page then serves the local build), and on a
|
|
12
|
+
* page whose own Foldspace agent is not this project's agent - attach's swap
|
|
13
|
+
* preflight decides that, so both read the page the same way.
|
|
14
|
+
*/
|
|
15
|
+
import { liveDaemon } from "../src/attach-daemon.mjs";
|
|
16
|
+
import { CdpPage, parseArgs, printJson, sleep } from "../src/cdp-client.mjs";
|
|
17
|
+
import { withoutStealingFocus } from "../src/keep-focus.mjs";
|
|
18
|
+
import { readProjectConfig, resolveConfiguredTarget } from "../src/project-config.mjs";
|
|
19
|
+
import {
|
|
20
|
+
ATTACH_RUNNING,
|
|
21
|
+
FOREGROUND_ATTACH,
|
|
22
|
+
buildPageReadScript,
|
|
23
|
+
buildPublishedScript,
|
|
24
|
+
checkPublishedPage,
|
|
25
|
+
foregroundAttachNext,
|
|
26
|
+
LOAD_TIMEOUT_MS,
|
|
27
|
+
pageSettled,
|
|
28
|
+
projectAgent,
|
|
29
|
+
publishedEnvironment,
|
|
30
|
+
publishedReport,
|
|
31
|
+
} from "../src/published-core.mjs";
|
|
32
|
+
import { recordEvent } from "../src/session-events.mjs";
|
|
33
|
+
import { resolveProjectDir } from "../src/upgrade.mjs";
|
|
34
|
+
|
|
35
|
+
const PAGE_WAIT_MS = 20_000;
|
|
36
|
+
const POLL_MS = 250;
|
|
37
|
+
// A page younger than this may not have started loading what its agent loads
|
|
38
|
+
// normally; once that load is in flight, pageSettled waits for it to land.
|
|
39
|
+
const SETTLE_AGE_MS = 5_000;
|
|
40
|
+
|
|
41
|
+
const projectDir = resolveProjectDir();
|
|
42
|
+
const { flags, rest } = parseArgs(process.argv.slice(2));
|
|
43
|
+
|
|
44
|
+
let environment;
|
|
45
|
+
try {
|
|
46
|
+
const unknown = Object.keys(flags);
|
|
47
|
+
if (unknown.length) throw new Error(`unknown option '--${unknown[0]}'`);
|
|
48
|
+
if (rest.length > 1) throw new Error(`unexpected argument '${rest[1]}'`);
|
|
49
|
+
environment = publishedEnvironment(rest[0]);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(`published: ${error instanceof Error ? error.message : String(error)}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let page = null;
|
|
56
|
+
|
|
57
|
+
function finish(result, code) {
|
|
58
|
+
recordEvent(projectDir, "published", { ok: result.ok, environment: environment.name });
|
|
59
|
+
printJson(result);
|
|
60
|
+
page?.close();
|
|
61
|
+
process.exit(code);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function configTarget(targetName) {
|
|
65
|
+
try {
|
|
66
|
+
return resolveConfiguredTarget(readProjectConfig(projectDir), targetName).target;
|
|
67
|
+
} catch {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read the page until it has settled, or the wait runs out. */
|
|
73
|
+
async function readPage(agent) {
|
|
74
|
+
const deadline = Date.now() + PAGE_WAIT_MS;
|
|
75
|
+
let last = null;
|
|
76
|
+
while (Date.now() < deadline) {
|
|
77
|
+
// A reload briefly destroys the page's context; read again.
|
|
78
|
+
last = await page.evaluate(buildPageReadScript(agent)).catch(() => last);
|
|
79
|
+
if (pageSettled(last) && (last.attachPrepared || last.ageMs >= SETTLE_AGE_MS)) break;
|
|
80
|
+
await sleep(POLL_MS);
|
|
81
|
+
}
|
|
82
|
+
return last;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function main() {
|
|
86
|
+
// While the attach daemon runs it serves the local build to every page load,
|
|
87
|
+
// so whatever this command loads would be replaced on the next one.
|
|
88
|
+
if (liveDaemon(projectDir)) {
|
|
89
|
+
finish({ ok: false, environment: environment.name, reason: ATTACH_RUNNING }, 1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
page = await CdpPage.open({ projectDir, command: "published" });
|
|
93
|
+
const agent = projectAgent(page.info.target, configTarget(page.info.targetName));
|
|
94
|
+
|
|
95
|
+
let seen = await readPage(agent);
|
|
96
|
+
if (seen?.attachPrepared) {
|
|
97
|
+
// No daemon, yet this document still carries the local build: an attach
|
|
98
|
+
// that ended without cleaning up. A reload is what attach does to clean up.
|
|
99
|
+
await withoutStealingFocus(() => page.send("Page.reload", { ignoreCache: true }));
|
|
100
|
+
await sleep(1000);
|
|
101
|
+
seen = await readPage(agent);
|
|
102
|
+
if (seen?.attachPrepared) {
|
|
103
|
+
finish(
|
|
104
|
+
{
|
|
105
|
+
ok: false,
|
|
106
|
+
environment: environment.name,
|
|
107
|
+
reason: FOREGROUND_ATTACH,
|
|
108
|
+
next: foregroundAttachNext(environment.name),
|
|
109
|
+
},
|
|
110
|
+
1,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const check = checkPublishedPage({ state: seen?.state, agent });
|
|
116
|
+
if (!check.ok) {
|
|
117
|
+
finish(
|
|
118
|
+
{
|
|
119
|
+
ok: false,
|
|
120
|
+
environment: environment.name,
|
|
121
|
+
agent: agent.apiName,
|
|
122
|
+
reason: check.reason,
|
|
123
|
+
next: check.next,
|
|
124
|
+
page: check.page,
|
|
125
|
+
url: page.info.url,
|
|
126
|
+
},
|
|
127
|
+
1,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const outcome = await page.evaluate(
|
|
132
|
+
buildPublishedScript({ apiName: agent.apiName, mode: check.mode, environment }),
|
|
133
|
+
{ timeoutMs: LOAD_TIMEOUT_MS + 15_000 },
|
|
134
|
+
);
|
|
135
|
+
const report = publishedReport({ outcome, environment, agent });
|
|
136
|
+
finish(report, report.ok ? 0 : 1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
main().catch((error) => {
|
|
140
|
+
finish({ ok: false, environment: environment.name, error: String(error?.message || error) }, 1);
|
|
141
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldspace_npm/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "Build and verify portable Foldspace action artifacts against a live app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"./package.json": "./package.json"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
-
"test": "node --test test/*.test.mjs"
|
|
18
|
+
"test": "node --test test/*.test.mjs",
|
|
19
|
+
"prepublishOnly": "npm test"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"bin",
|
|
@@ -35,5 +36,8 @@
|
|
|
35
36
|
},
|
|
36
37
|
"publishConfig": {
|
|
37
38
|
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"linkedom": "^0.18.13"
|
|
38
42
|
}
|
|
39
43
|
}
|
package/recipes/INDEX.md
CHANGED
|
@@ -6,16 +6,21 @@ Read this whole table, then open the recipe closest to the outcome.
|
|
|
6
6
|
|---|---|---|---|---|
|
|
7
7
|
| [`who-is-the-user`](who-is-the-user/) | **L0** | Foldspace knows who is signed in — id, email, name, role, subscription — so conversations and analytics are not anonymous | Runs once at start-up. No action, no card | 4 production builds |
|
|
8
8
|
| [`find-by-name`](find-by-name/) | **L2** | The agent turns a name the user said into the id the next action needs; a count when no name is given; every tie when the name is ambiguous; near-misses when nothing matches | A data action. **No card** | 3 production builds |
|
|
9
|
-
| [`
|
|
10
|
-
| [`
|
|
11
|
-
| [`
|
|
12
|
-
| [`
|
|
13
|
-
| [`
|
|
9
|
+
| [`lookup-then-view`](lookup-then-view/) | **L2** | Show one account the user names - **start here**. One clear match: its card, no question. Several or none: the **pick-one** list - search, pages, one click - then the card: who it is, the money, open deals, people, open cases. The card action's **required** `accountId`, described as coming from the lookup, makes the agent call the lookup first. The app already hands over the id → `account-overview` | Two actions - an input card (`find_account`) and a view card (`show_account_overview`) - with their Agent Studio definitions word for word | 1 production build |
|
|
10
|
+
| [`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 input card: the agent waits for the user's answer | 1 production build |
|
|
11
|
+
| [`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 production builds |
|
|
12
|
+
| [`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 (a third uses the default bearer token) |
|
|
13
|
+
| [`account-overview`](account-overview/) | **L2** | One account on a card, when the app already hands over its id: identity, pipeline total, contacts, cases, meetings. The agent gets what the card shows. An account the user names → `lookup-then-view` | A view card: no `callback`, no `awaitUserInput` | 1 production build |
|
|
14
|
+
| [`upload-contacts`](upload-contacts/) | **L4** | A CSV is previewed, then written only after the user chooses Import | An input card: the agent waits for the user's answer | 1 production build |
|
|
15
|
+
| [`create-a-record`](create-a-record/) | **L4** | One record created from what the user said: an editable card, pre-filled, with dropdowns read from the app, written only after they approve. The new id comes back. **Two versions**: everything on one card (default), or one question per card and then a final review, for when an answer changes what is asked next - the README says which | An action with a card that waits for the user | 2 production builds |
|
|
14
16
|
| [`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
17
|
| [`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
|
-
| [`
|
|
17
|
-
| [`
|
|
18
|
-
| [`
|
|
18
|
+
| [`server-rendered-forms`](server-rendered-forms/) | **L4** | The product renders its pages on the server and has no JSON API: records are read off its pages, and a record is created through the same form a person fills in - fields found by label, the app's own validation messages back as data | A write. **No card of its own** - always behind approval: the approve button of a card, or `confirmation_required: true` | 1 production build |
|
|
19
|
+
| [`several-hosts`](several-hosts/) | any | The product lives on several hosts (`www.`, `app.`, `reports.`, `admin.` ...): an action asked from the wrong host says which page it needs. After the agent moves the user there, the SDK carries the user, their conversations and the agent's turn to that host when it shares the domain (PLG-5962) | A guard each action calls | 1 production build |
|
|
20
|
+
| [`records-list`](records-list/) | **L2** | The user's records on one card: a row each with name, two facts and a number, matched to the agent's white panel. The agent gets what the card shows | A view card: no `callback`, no `awaitUserInput` | 1 production build |
|
|
21
|
+
| [`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 agent gets what the card shows | A view card: no `callback`, no `awaitUserInput` | 1 production build |
|
|
22
|
+
|
|
23
|
+
Levels: L0 the agent is live · L1 the user's own settings · L2 look up records · L3 navigation · L4 a write.
|
|
19
24
|
|
|
20
25
|
**Not here yet — no production build proves it:** the user's own plan or
|
|
21
26
|
settings drawn as a card (L1). In production that data is sent to `identify`
|
package/recipes/README.md
CHANGED
|
@@ -13,11 +13,21 @@ of a customer session.
|
|
|
13
13
|
## Use one
|
|
14
14
|
|
|
15
15
|
1. Read `INDEX.md` and pick the closest outcome.
|
|
16
|
-
2. Copy its `agent/` files into your project's `agent/`
|
|
17
|
-
same, so the imports
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
`
|
|
16
|
+
2. Copy its `agent/` files into your project's `agent/` - the layout is the
|
|
17
|
+
same, so the imports resolve. Never copy its `agent/views/brand.ts`: keep
|
|
18
|
+
the one `npx foldspace observe styles --write` wrote, and if the project
|
|
19
|
+
has none yet, run that command first. Do not overwrite any other file that
|
|
20
|
+
already exists: `agent/accounts.ts`, `agent/api/accounts.ts` and
|
|
21
|
+
`agent/api/meetings.ts` differ between recipes - add what the new recipe
|
|
22
|
+
imports to the file you have. Where the two files define the same name
|
|
23
|
+
(`AccountRecord`, `formatMoney`), keep one: one type with the fields of
|
|
24
|
+
both, one copy of the helper. The one file a recipe replaces is
|
|
25
|
+
`agent/utils.ts`, from `swap-the-login-method`.
|
|
26
|
+
3. Replace every placeholder with what you **observed** in the live app - the
|
|
27
|
+
`__observe_me` paths and keys, and each default the recipe's **Adapt it**
|
|
28
|
+
table names (claim names, starters) - and record how you know in
|
|
29
|
+
`docs/app-profile.md`. The `__observe_me` ones cannot succeed; the defaults
|
|
30
|
+
can, so check each.
|
|
21
31
|
4. Register actions in `agent/actions/index.ts` under the **same key** as in
|
|
22
32
|
Agent Studio, then run the six gates.
|
|
23
33
|
|
|
@@ -29,10 +39,13 @@ Each recipe's README says how many production builds carry the pattern and what
|
|
|
29
39
|
those builds learned the hard way. **What is taken is the shape** — the order of
|
|
30
40
|
calls, what is returned to the agent, where the traps are. **What is not taken**
|
|
31
41
|
is any customer's name, endpoint, identifier, styling or code. Contracts are the
|
|
32
|
-
|
|
33
|
-
`render(data, host, header, callback, cancel)`
|
|
42
|
+
SDK's: `execute(params)` and
|
|
43
|
+
`render(data, host, header, callback, cancel, readyState)`
|
|
34
44
|
(<https://docs.foldspace.ai/guides/in-chat-ui/>), `foldspace.identify(context)`
|
|
35
45
|
(<https://docs.foldspace.ai/start/user-context/>), and the harness runtime.
|
|
46
|
+
Where the in-chat UI page says to set `awaitUserInput`, do not - the SDK waits
|
|
47
|
+
when `render` uses `callback` or `cancel` (see the card bullet below). That
|
|
48
|
+
page does not list `readyState`; CLAUDE.md describes it, and no recipe uses it.
|
|
36
49
|
|
|
37
50
|
Error handling is **not** taken from those builds. They predate `reason`: one
|
|
38
51
|
turns every failure into "unexpected error", another reports a missing session
|
|
@@ -42,5 +55,15 @@ as "no projects found". Recipes use the runtime's failure reasons instead.
|
|
|
42
55
|
|
|
43
56
|
- `execute` returns **data and facts** — never `detail`, never instructions.
|
|
44
57
|
- A missing or expired session is `signed_out`, not an empty result.
|
|
58
|
+
- A **view card** shows something - an account, a list. Its
|
|
59
|
+
`render(data, host, header)` uses no `callback` and no `cancel`, and the
|
|
60
|
+
agent gets at once "Shown to the user. The user has not acted on it." with
|
|
61
|
+
exactly what `execute` returned. So `execute` returns the facts the card
|
|
62
|
+
shows: to change what the agent gets, change `execute`, not the card. An
|
|
63
|
+
**input card** asks the user: it calls `callback` (or `cancel`) on every
|
|
64
|
+
path - choosing, "none of these", closing, failing - and the agent waits
|
|
65
|
+
until it does. Never set `awaitUserInput`: an input card does not need it,
|
|
66
|
+
and on a view card in headless mode the agent waits for a callback that
|
|
67
|
+
never comes.
|
|
45
68
|
- DOM is built with `textContent`, never `innerHTML` with API data, and styles
|
|
46
69
|
go in `header` — `host` lives inside the component's frame.
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
**Proven by 1 production build.**
|
|
6
6
|
|
|
7
|
-
This card is the base layout. Copy it, then replace the `/__observe_me` routes with the app's own paths.
|
|
7
|
+
This card is the base layout. Copy it, then replace the `/__observe_me` routes with the app's own paths. `brand.ts` here is a placeholder: keep the `agent/views/brand.ts` that `npx foldspace observe styles --write` wrote - it has every key the recipe cards read. If the project has none yet, run that command first; do not copy this one.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
A view card: `render` takes no `callback` and the action sets no `awaitUserInput`. As soon as the card is drawn, the agent gets "Shown to the user. The user has not acted on it." plus exactly what `execute` returned - the account, the totals and every row behind the tabs, or the failure (`success: false`, `error` and `reason`) - and it answers questions about the account from that. So `execute` returns the overview the card draws and nothing else. 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 is signed out unless the account has both an id and a name.
|
|
10
10
|
|
|
11
|
-
In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId
|
|
11
|
+
In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId`, described as "The account's id. Never taken from the user's words: get it from `<lookup action key>`." Replace `<lookup action key>` with the key of this agent's action that returns account ids; if the agent has none, build that action first - `find-by-name` has the shape. Its `instructions`: "Answer follow-up questions about this account from the data you receive: quote money and counts exactly as given, and never add them up yourself. Right after the card, one short sentence at most - do not repeat what it shows. On a failure, the card already says why: say so in one short sentence and stop. No emoji."
|
|
12
12
|
|
|
13
13
|
## Adapt it
|
|
14
14
|
|
|
@@ -16,11 +16,11 @@ In Agent Studio: an action with key `show_account_overview` and one string param
|
|
|
16
16
|
|---|---|
|
|
17
17
|
| `agent/api/accounts.ts` | Mock path and envelope. Find a real API that returns a similar shape |
|
|
18
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
|
|
19
|
+
| `agent/views/overview.ts` | The card. Routes under `/__observe_me` are placeholders for the app's own paths |
|
|
20
20
|
|
|
21
21
|
## What that build learned the hard way
|
|
22
22
|
|
|
23
|
-
- **The
|
|
23
|
+
- **The agent answers from what `execute` returns.** A view card hands the agent everything `execute` returned, so `execute` returns the card's facts as fields, totals included. The build that proved this card ran before the SDK did that: its agent was told only that something was displayed, and could not answer a question about the account.
|
|
24
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.
|
|
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. The account's website is plain text, never a link out of the product.
|
|
26
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.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// A view card: render takes no callback and the action sets no
|
|
2
|
+
// awaitUserInput. As soon as the card is drawn, the agent is told it was shown
|
|
3
|
+
// and gets everything execute returned - so execute returns the overview the
|
|
4
|
+
// card draws and nothing else, or the failure the card draws instead. A failed
|
|
3
5
|
// or signed-out call is drawn with renderFailure, never as a row of zeros.
|
|
4
6
|
|
|
5
7
|
import { belongsToSignedInUser, toOverview, type AccountOverview } from "../accounts";
|
|
@@ -9,40 +11,33 @@ import { mountAccountOverview } from "../views/overview";
|
|
|
9
11
|
|
|
10
12
|
type ShowResult = {
|
|
11
13
|
success: boolean;
|
|
12
|
-
message?: string;
|
|
13
14
|
error?: string;
|
|
14
15
|
reason?: FailureReason;
|
|
15
|
-
data?: AccountOverview
|
|
16
|
+
data?: AccountOverview;
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
function isOverview(data: NonNullable<ShowResult["data"]>): data is AccountOverview {
|
|
19
|
-
return "account" in data;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
19
|
export const show_account_overview = {
|
|
23
20
|
execute: async (params: { accountId?: string }): Promise<ShowResult> => {
|
|
24
21
|
const accountId = typeof params?.accountId === "string" ? params.accountId.trim() : "";
|
|
25
22
|
if (!accountId) {
|
|
26
|
-
return { success: false, error: "
|
|
23
|
+
return { success: false, error: "No account was given.", reason: "config" };
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
const res = await getAccountOverview(accountId);
|
|
30
27
|
if (!res.ok) {
|
|
31
28
|
console.warn("[show_account_overview]", res.status, res.reason, res.detail);
|
|
32
|
-
return { success: false, error: res.error, reason: res.reason
|
|
29
|
+
return { success: false, error: res.error, reason: res.reason };
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
if (!belongsToSignedInUser(res.data)) {
|
|
36
|
-
return { success: false, error: "Not signed in.", reason: "signed_out"
|
|
33
|
+
return { success: false, error: "Not signed in.", reason: "signed_out" };
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
|
|
40
|
-
return { success: true, message: "Displayed.", data: toOverview(res.data) };
|
|
36
|
+
return { success: true, data: toOverview(res.data) };
|
|
41
37
|
},
|
|
42
38
|
|
|
43
39
|
render: (result: ShowResult | undefined, host: HTMLElement, header: HTMLElement) => {
|
|
44
|
-
if (!result?.success || !result.data
|
|
45
|
-
const accountId = result?.data && "accountId" in result.data ? result.data.accountId : "";
|
|
40
|
+
if (!result?.success || !result.data) {
|
|
46
41
|
renderFailure(
|
|
47
42
|
host,
|
|
48
43
|
{
|
|
@@ -51,7 +46,7 @@ export const show_account_overview = {
|
|
|
51
46
|
error: result?.error ?? "The account could not be loaded.",
|
|
52
47
|
reason: result?.reason ?? "server",
|
|
53
48
|
},
|
|
54
|
-
{ notFound:
|
|
49
|
+
{ notFound: "That account could not be found." },
|
|
55
50
|
);
|
|
56
51
|
return;
|
|
57
52
|
}
|
|
@@ -1,26 +1,35 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// card to history as an image drawn with html2canvas, which cannot read
|
|
5
|
-
// oklch()/lab()/color-mix(). Take `accent` from the agent's own primaryColor
|
|
6
|
-
// (get_agent_settings); re-sample font and radius from the page if you want
|
|
7
|
-
// them to match; swap for a dark set only when the panel itself is dark.
|
|
1
|
+
// Placeholder with the keys of the agent/views/brand.ts that
|
|
2
|
+
// `npx foldspace observe styles --write` writes. When you copy this recipe,
|
|
3
|
+
// keep the project's own brand.ts; if it has none yet, run that command.
|
|
8
4
|
|
|
9
5
|
export const brand = {
|
|
10
6
|
surface: "#FFFFFF",
|
|
11
|
-
|
|
12
|
-
border: "#E5E7EB",
|
|
7
|
+
surface2: "#F5F6FA",
|
|
13
8
|
text: "#374151",
|
|
14
|
-
textStrong: "#111827",
|
|
15
9
|
muted: "#6B7280",
|
|
16
|
-
|
|
10
|
+
border: "#E5E7EB",
|
|
17
11
|
accent: "#3248F2",
|
|
12
|
+
accentText: "#FFFFFF",
|
|
13
|
+
font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
14
|
+
radius: "8px",
|
|
15
|
+
size: { base: "14px", small: "12px" },
|
|
16
|
+
textStrong: "#111827",
|
|
17
|
+
faint: "#9CA3AF",
|
|
18
|
+
row: "#F9FAFB",
|
|
19
|
+
tableHead: "#F3F4F6",
|
|
18
20
|
accentDeep: "#2538D6",
|
|
21
|
+
accentSoft: "#EEF0FE",
|
|
22
|
+
headerStart: "#F0F1FE",
|
|
23
|
+
good: "#047857",
|
|
24
|
+
warn: "#B45309",
|
|
25
|
+
warnInk: "#FEF3C7",
|
|
26
|
+
bad: "#B91C1C",
|
|
27
|
+
primary: "#3248F2",
|
|
28
|
+
tint: "#F3F4F6",
|
|
29
|
+
disabled: "#9CA3AF",
|
|
19
30
|
money: "#047857",
|
|
31
|
+
ok: "#047857",
|
|
20
32
|
deals: "#2563EB",
|
|
21
33
|
contacts: "#7C3AED",
|
|
22
34
|
cases: "#B45309",
|
|
23
|
-
row: "#F9FAFB",
|
|
24
|
-
font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
25
|
-
radius: "8px",
|
|
26
35
|
} as const;
|
|
@@ -34,7 +34,6 @@ const CSS = `
|
|
|
34
34
|
.fs-ov-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
|
|
35
35
|
.fs-ov-label{font-size:10px;color:${brand.muted};text-transform:uppercase;letter-spacing:.5px;margin-bottom:2px}
|
|
36
36
|
.fs-ov-value{font-size:12px;color:${brand.textStrong};font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
37
|
-
.fs-ov-value a{color:${brand.accent};text-decoration:none}
|
|
38
37
|
.fs-ov-metrics{display:grid;grid-template-columns:repeat(4, 1fr);gap:1px;background:${brand.border}}
|
|
39
38
|
.fs-ov-metric{padding:12px;background:${brand.surface};display:flex;flex-direction:column;gap:2px;text-align:center;align-items:center}
|
|
40
39
|
.fs-ov-metric b{font-size:20px;font-weight:600}
|
|
@@ -198,23 +197,20 @@ export function mountAccountOverview(host: HTMLElement, header: HTMLElement, ove
|
|
|
198
197
|
title.append(name, open);
|
|
199
198
|
head.append(title);
|
|
200
199
|
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
// What is known, and nothing else: a missing value reads "Not provided" in
|
|
201
|
+
// the grid below.
|
|
202
|
+
const known = [
|
|
203
|
+
account.industry,
|
|
204
|
+
account.employeeCount === null ? null : `${account.employeeCount.toLocaleString()} employees`,
|
|
205
|
+
].filter((fact): fact is string => Boolean(fact));
|
|
206
|
+
if (known.length) head.append(el("div", "fs-ov-sub", known.join(" • ")));
|
|
203
207
|
|
|
204
208
|
const grid = el("div", "fs-ov-grid");
|
|
205
209
|
grid.append(field("Phone", account.phone || "Not provided"));
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
attr(site, "target", "_blank");
|
|
211
|
-
website.append(site);
|
|
212
|
-
} else {
|
|
213
|
-
website.textContent = "Not provided";
|
|
214
|
-
}
|
|
215
|
-
grid.append(field("Website", website));
|
|
216
|
-
grid.append(field("Annual Revenue", account.revenue === null ? "Not disclosed" : formatMoney(account.revenue)));
|
|
217
|
-
grid.append(field("Employees", account.employeeCount === null ? "Not specified" : account.employeeCount.toLocaleString()));
|
|
210
|
+
// Plain text, never a link out of the product.
|
|
211
|
+
grid.append(field("Website", account.website || "Not provided"));
|
|
212
|
+
grid.append(field("Annual Revenue", account.revenue === null ? "Not provided" : formatMoney(account.revenue)));
|
|
213
|
+
grid.append(field("Employees", account.employeeCount === null ? "Not provided" : account.employeeCount.toLocaleString()));
|
|
218
214
|
head.append(grid);
|
|
219
215
|
card.append(head);
|
|
220
216
|
|
|
@@ -225,10 +221,10 @@ export function mountAccountOverview(host: HTMLElement, header: HTMLElement, ove
|
|
|
225
221
|
return item;
|
|
226
222
|
};
|
|
227
223
|
metricsBar.append(
|
|
228
|
-
metric("
|
|
224
|
+
metric("Opportunities", String(metrics.openOpportunities), "fs-ov-deals"),
|
|
229
225
|
metric("Pipeline", formatPipeline(metrics.pipelineValue), "fs-ov-pipeline"),
|
|
230
226
|
metric("Contacts", String(metrics.contacts), "fs-ov-contacts"),
|
|
231
|
-
metric("
|
|
227
|
+
metric("Cases", String(metrics.openCases), "fs-ov-cases"),
|
|
232
228
|
);
|
|
233
229
|
card.append(metricsBar);
|
|
234
230
|
|
|
@@ -14,8 +14,8 @@ product's own source, one from the agent project behind a feature flag).
|
|
|
14
14
|
| In `agent/bottomBar.ts` | Change |
|
|
15
15
|
|---|---|
|
|
16
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` |
|
|
17
|
+
| `DEFAULT_STARTERS` | What to show on any other page. It replaces the agent-wide starters there |
|
|
18
|
+
| `BOTTOM_BAR_SETTINGS` | The docs' settings, except two: `enabled: true` (the docs default it to `false`, and the bar then never renders) and `maxVisibleStarters: 3` (the docs default 5). Change any of them only if the human asks |
|
|
19
19
|
|
|
20
20
|
Then call it from the bundle's entry point:
|
|
21
21
|
|
|
@@ -30,14 +30,17 @@ installBottomBar();
|
|
|
30
30
|
- **`setConversationStarters` replaces the whole set for the page** — both
|
|
31
31
|
groups. `null` restores Agent Studio's. So always pass the full list you
|
|
32
32
|
want shown.
|
|
33
|
-
- **In embedded mode the bar needs `openEmbeddedCallback`**, or
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
- **In embedded mode the bar needs `openEmbeddedCallback`**, or the bar does
|
|
34
|
+
not render at all. This recipe is for the overlay agent; see the docs for
|
|
35
|
+
embedded.
|
|
36
36
|
- **Route changes in a single-page app don't reload**, so the starters must be
|
|
37
|
-
re-applied on navigation
|
|
38
|
-
and listens to `popstate`.
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
re-applied on navigation - this recipe wraps `pushState` / `replaceState`
|
|
38
|
+
and listens to `popstate`. It brings the bar back to full only when the
|
|
39
|
+
starters change; a navigation that keeps the same starters leaves a bar
|
|
40
|
+
that has folded to compact (after `idleTimeoutMs`) as it is.
|
|
41
|
+
- **Dark mode**: the SDK's default colours do not follow a dark theme yet
|
|
42
|
+
(PLG-5891, in the backlog); a product in dark mode may need `theme` /
|
|
43
|
+
`darkModeSettings` set.
|
|
41
44
|
|
|
42
45
|
Docs: [Bottom Bar](https://docs.foldspace.ai/customize/bottom-bar/) ·
|
|
43
46
|
[Conversation starters](https://docs.foldspace.ai/customize/conversation-starters/)
|
|
@@ -16,10 +16,12 @@ export const PAGE_STARTERS: Array<{ match: RegExp; starters: string[] }> = [
|
|
|
16
16
|
];
|
|
17
17
|
|
|
18
18
|
/** Shown on any page not listed above. */
|
|
19
|
-
export const DEFAULT_STARTERS: string[] = ["What can you do?", "
|
|
19
|
+
export const DEFAULT_STARTERS: string[] = ["What can you do?", "<A question one of its actions can answer>"];
|
|
20
20
|
|
|
21
|
-
// The docs'
|
|
22
|
-
//
|
|
21
|
+
// The docs' settings, except two: `enabled: true` (the docs default it to
|
|
22
|
+
// `false`, and the bar then never renders) and `maxVisibleStarters: 3` (the
|
|
23
|
+
// docs default 5). Change any of them only if the human asks: every field is
|
|
24
|
+
// documented on the Bottom Bar page.
|
|
23
25
|
export const BOTTOM_BAR_SETTINGS = {
|
|
24
26
|
enabled: true,
|
|
25
27
|
maxVisibleStarters: 3,
|
|
@@ -39,7 +41,7 @@ export function startersForPath(pathname: string): string[] {
|
|
|
39
41
|
const INSTALLED_FLAG = "__foldspace_bottom_bar__";
|
|
40
42
|
|
|
41
43
|
/**
|
|
42
|
-
*
|
|
44
|
+
* Set up the bar and keep its starters matched to the page. Call once from
|
|
43
45
|
* the bundle's entry point; safe to call again.
|
|
44
46
|
*
|
|
45
47
|
* `setConversationStarters` REPLACES the whole set for this page view (both
|
|
@@ -57,7 +59,10 @@ export function installBottomBar(): void {
|
|
|
57
59
|
if (!agent) return;
|
|
58
60
|
let current: string[] = [];
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
// On a navigation, the bar is brought back to full only when its starters
|
|
63
|
+
// changed, so the new ones are seen. Same starters: nothing is sent, and a
|
|
64
|
+
// bar that has folded to compact stays compact.
|
|
65
|
+
const apply = (reopen: boolean) => {
|
|
61
66
|
const next = startersForPath(window.location.pathname);
|
|
62
67
|
if (next === current) return;
|
|
63
68
|
current = next;
|
|
@@ -65,18 +70,15 @@ export function installBottomBar(): void {
|
|
|
65
70
|
{ KNOWLEDGE: next.map((title) => ({ title })), ACTION: [] },
|
|
66
71
|
"KNOWLEDGE",
|
|
67
72
|
);
|
|
73
|
+
if (reopen) agent.openBottomBar?.({ mode: "FULL" });
|
|
68
74
|
};
|
|
69
75
|
|
|
70
76
|
// One whole settings group per call, as the docs do.
|
|
71
77
|
agent.setConfiguration?.({ bottomBarSettings: BOTTOM_BAR_SETTINGS });
|
|
72
|
-
apply();
|
|
78
|
+
apply(false);
|
|
73
79
|
|
|
74
|
-
// Single-page apps change the URL without a load: re-apply on navigation
|
|
75
|
-
|
|
76
|
-
const onRouteChange = () => {
|
|
77
|
-
apply();
|
|
78
|
-
agent.openBottomBar?.({ mode: "FULL" });
|
|
79
|
-
};
|
|
80
|
+
// Single-page apps change the URL without a load: re-apply on navigation.
|
|
81
|
+
const onRouteChange = () => apply(true);
|
|
80
82
|
window.addEventListener("popstate", onRouteChange);
|
|
81
83
|
const history = window.history as any;
|
|
82
84
|
for (const method of ["pushState", "replaceState"] as const) {
|