@foldspace_npm/harness 0.1.27 → 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 +301 -188
- package/README.md +54 -36
- package/bin/ask.mjs +9 -7
- package/bin/attach.mjs +80 -51
- 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 +1 -1
- 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 +3 -3
- 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 +14 -19
- package/src/cdp-ownership.mjs +41 -20
- package/src/cli-help.mjs +7 -5
- package/src/cli-registry.mjs +65 -46
- package/src/diagnostics.mjs +1 -1
- package/src/init.mjs +5 -3
- package/src/lint/index.mjs +8 -1
- package/src/lint/rules.mjs +240 -43
- package/src/observe-core.mjs +163 -34
- 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 +1 -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,97 @@
|
|
|
1
|
+
# Create a record — L4
|
|
2
|
+
|
|
3
|
+
**The user says** "create a project called Website relaunch, Dana owns it". **The user sees** a card pre-filled from what they said: name, a key made from the name, a dropdown of owners read from the app with Dana already chosen, status, description, and visibility. They can change anything. Nothing is written until they press Create project. **The agent gets** whether it was created, the new id, the written name, key, owner, status and visibility (in the step-by-step version, kind and client too) and which fields the user changed.
|
|
4
|
+
|
|
5
|
+
**Proven by 2 production builds.** One product has four create actions, each with the all-at-once card. The other creates projects one step at a time, with the first answer deciding what is asked next.
|
|
6
|
+
|
|
7
|
+
`execute` writes nothing. It loads what the dropdowns need and returns a proposal. `render` draws the card. Create project runs the write, then draws the outcome and calls `callback`. Cancel, a failed load, a failed or refused write, a write the app did not confirm, and a card that could not be drawn each call `callback` too, exactly once. This is an input card: because `render` uses `callback`, the agent waits until `callback` runs. Never set `awaitUserInput` - it is not needed.
|
|
8
|
+
|
|
9
|
+
## Which version
|
|
10
|
+
|
|
11
|
+
Two versions of the same action, with the same key. Both pre-fill from what the user said, both end on the same review card, and both write only on Create project. They differ in how the values are confirmed before that card.
|
|
12
|
+
|
|
13
|
+
| Version | File | What the user sees |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| **All at once** (the default) | `agent/actions/create_project.ts` | One card with every field, pre-filled. They check it and press Create project |
|
|
16
|
+
| **Step by step** | `variants/actions/create_project.steps.ts` | One question per card, each pre-filled with what they said: they confirm or change it and press Continue. Then a final review card with every value: all but the first answer can still be edited |
|
|
17
|
+
|
|
18
|
+
**Use all at once unless one of these is true.** Decide once, when you build the action, by the record and the people who create it. Build one version per action - never both - and never let the agent choose.
|
|
19
|
+
|
|
20
|
+
| Use **all at once** when | Use **step by step** when |
|
|
21
|
+
|---|---|
|
|
22
|
+
| Every record of this kind has the same fields | One answer decides which questions come next. A client project asks for a client and a rate; an internal one does not |
|
|
23
|
+
| The user usually says the details in their request | The user usually starts with nothing ("create a new project") and fills it in as they go |
|
|
24
|
+
| The card is short enough to check at a glance | A choice needs explaining before anyone can pick it: two or three options, each with a sentence on what it means |
|
|
25
|
+
| Speed matters: one click to create | Each value deserves its own look: an address, a client, a rate that is costly to get wrong |
|
|
26
|
+
|
|
27
|
+
**How the step-by-step version runs:**
|
|
28
|
+
|
|
29
|
+
1. **The deciding question first**, as option cards with a caption each. If the user already said the answer it is pre-chosen; if not, nothing is, so they pick rather than approve a default they may not notice.
|
|
30
|
+
2. **Every field that applies, one per card**, in the same order as the review card. Each starts with what the user said, so a step they already answered is one Continue.
|
|
31
|
+
3. **The final review card**: every value together; all but the first answer can still be edited. Only Create project writes, and the agent gets one callback with what was written and every field changed on any step.
|
|
32
|
+
|
|
33
|
+
A step is skipped only when there is nothing to ask. A field with one possible value is kept and shown on the review card. A field that does not apply to the answers so far is left out, like the client fields for an internal project, or the email question unless the project is private. Every step has Cancel, and Cancel on any step answers the agent as `cancelled`.
|
|
34
|
+
|
|
35
|
+
To use the step-by-step version, copy it over `agent/actions/create_project.ts`. It imports the same `../projects` and `../views/*`, so no other file changes. In Agent Studio its parameters and its instructions differ: **In Agent Studio** gives both. Replace `KINDS`, `QUESTIONS` and `fieldsFor` with your record's deciding question, the question each field asks, and the fields each answer calls for.
|
|
36
|
+
|
|
37
|
+
## In Agent Studio
|
|
38
|
+
|
|
39
|
+
An action with key `create_project`. Each parameter is something the user said, as they said it; the action matches it to what the app takes, and the card shows the result for the user to confirm or change. Describe each one in Agent Studio by where it comes from:
|
|
40
|
+
|
|
41
|
+
| Parameter | Where it comes from |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `name` (required) | The project's name as the user said it |
|
|
44
|
+
| `owner` | The owner's name as the user said it - the action matches it to an id; with no single match, the user picks on the card |
|
|
45
|
+
| `status` | The status as the user said it ("on hold") - the action matches it to one of the app's statuses, else Planned |
|
|
46
|
+
| `description` | What the user said the project is for |
|
|
47
|
+
| `key` | Only a key the user gave - otherwise the action makes one from the name |
|
|
48
|
+
| `private` | `true` only when the user said only members should see it |
|
|
49
|
+
| `notify_members` | `true` only when the user asked for members to be emailed. It counts only for a private project |
|
|
50
|
+
|
|
51
|
+
The step-by-step version adds three optional parameters - `kind` (`client` or `internal`, only when the user said which), `client` (the client's name as the user said it) and `hourly_rate` (a number, only when the user gave one) - and makes `name` optional too: its own step asks for it. Leave **`confirmation_required`** off (the default): the card is the confirmation, and turning it on asks the user twice.
|
|
52
|
+
|
|
53
|
+
The action's instructions must keep two questions apart:
|
|
54
|
+
|
|
55
|
+
- *"What should it be called?"* is a missing required parameter, and the agent must ask it.
|
|
56
|
+
- *"Shall I go ahead?"* is the card's job, and the agent must not ask it.
|
|
57
|
+
|
|
58
|
+
Something like:
|
|
59
|
+
|
|
60
|
+
> Creates a project. If the user has not given a name, ask for it in one sentence and do not call this action. Once you have a name, call this action; do not ask for confirmation in chat, because the card shows every value and has its own Create project button. After it runs, use the names in the result, not the ones you passed, because the user may have edited them on the card. If status is `created`, say in one sentence that it was created, with its name. If status is `not_created`, say in one sentence that it was not created: give `error` as the reason when the result has one, and otherwise say that the card shows why. If status is `unconfirmed`, say in one sentence that it is not known whether it was created. If status is `cancelled`, acknowledge it in one short sentence. No emoji.
|
|
61
|
+
|
|
62
|
+
For the step-by-step version, the card asks every question, the deciding one and the name included, so the agent asks nothing in chat. Replace the sample up to "After it runs" with:
|
|
63
|
+
|
|
64
|
+
> Creates a project. Call this action as soon as the user wants a project, and pass whatever they already said, `kind` included; do not ask for anything in chat first, because the card asks each question and has its own Create project button.
|
|
65
|
+
|
|
66
|
+
Keep the rest of the sample, from "After it runs" on.
|
|
67
|
+
|
|
68
|
+
The result carries the new project's `id`. Pair it with a navigation route to the project's page, so the agent can offer to open what was just created.
|
|
69
|
+
|
|
70
|
+
## Adapt it
|
|
71
|
+
|
|
72
|
+
| File | Change |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `agent/api/projects.ts` | Mock paths. Replace them with the app's own calls: the one that lists what a dropdown offers, and the create call. ⚠️ No harness command records the create call: `observe screen` reloads the page (or moves to another) as it starts listening, which clears a half-filled form, and stops once the network goes quiet, so it never sees a create the human makes by hand. You submit nothing in a signed-in account unless the human named it a test environment. Finding the create call is a platform gap: handle it as rule 9 of the manual's **Rules that are not negotiable** says. Keep the signed-in check: a value that must be there, such as the workspace id |
|
|
75
|
+
| `agent/projects.ts` | The fields the create call takes (`projectFields`), and what `proposeProject` pre-fills. Each field's kind follows the API: free text becomes a text box, a limited set becomes a dropdown, a value with only one possibility becomes read-only, and a choice that decides what comes next becomes option cards |
|
|
76
|
+
| `agent/actions/create_project.ts` | Rename the action to your entity. Or copy the step-by-step version over it (**Which version**) |
|
|
77
|
+
| `agent/views/project-card.ts` | The review card, the write, and what goes back to the agent. The facts and labels on the outcome card |
|
|
78
|
+
| `agent/views/fields.ts`, `approval.ts`, `steps.ts` | Copy them unchanged. Every create card in the project shares them; `steps.ts` is used only by the step-by-step version |
|
|
79
|
+
| `agent/views/brand.ts` | 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 |
|
|
80
|
+
|
|
81
|
+
## What those builds learned the hard way
|
|
82
|
+
|
|
83
|
+
- **A missing required value is not a failure.** The all-at-once build drew a red "Not created: a title is required" card when the user had only said "let's create one". Nothing had been attempted. The instructions above have the agent ask for it in chat first. If the agent calls anyway, the card draws the field empty and keeps Create project disabled.
|
|
84
|
+
- **The field kind follows the API, not convenience.** A dropdown for every limited set, read from the app when the app defines it.
|
|
85
|
+
- **One possible value is shown, not asked.** When the app offers exactly one owner, office or workspace, the card shows it as plain text instead of a dropdown with one item. A one-option dropdown is still sent and is not counted as an edit; the workspace here is a fact on the card and is not sent.
|
|
86
|
+
- **Dropdown values are ids, not names.** Two records can share a name. The label adds something distinguishing only where names repeat.
|
|
87
|
+
- **Pre-select only on a single confident match.** `matchOption` tries an exact match, then a single "contains" match, then a single fuzzy hit. Anything else leaves "Choose an owner", so the user picks rather than approving a guess they may not notice. The deciding question in the step-by-step version starts with nothing chosen unless the user said which, for the same reason.
|
|
88
|
+
- **Start each step with what the user said.** The step-by-step build's action took no parameters, so the user typed again the address and name they had just given.
|
|
89
|
+
- **End on a review.** In the step-by-step build the last answer wrote the record and earlier answers were locked. Here the review card shows everything, and all but the first answer can still be edited.
|
|
90
|
+
- **Send back what was written, and what was edited.** The user can change the name on the card, so the agent's own arguments are stale. The result carries the written values and `edited`.
|
|
91
|
+
- **Return the new id.** Without it the agent cannot offer to open the record it just created.
|
|
92
|
+
- **What goes back is state, never a next step.** The step-by-step build sent `directive: "The action is complete."` and messages such as "Let the user know..." with its result. Those are instructions inside a tool response (the manual's **Rules that are not negotiable**, 1), and lint rejects the `directive` key. The same holds for the runtime's failure sentences ("Your session has expired.", "Too many requests right now."): the card draws them, and the agent gets only `reason`.
|
|
93
|
+
- **No status pill on the proposal.** The buttons already say what is being asked, and a coloured "Needs approval" pill made a routine create look risky.
|
|
94
|
+
- **Each button resolves once.** A double click is not two records. Create project swaps the buttons for a working line.
|
|
95
|
+
- **The app's refusal message stays in the console.** `detail` is the app's own message from the response body. It is never drawn on the card and never returned. A 400 or 422 is drawn as "The app did not accept these values.", and the same words go back as `error`. No other failure returns `error`.
|
|
96
|
+
- ⚠️ **Closing the panel cancelled the open card** in the step-by-step build, so a half-finished create never held the conversation. That build ran the agent in its own embedded panel and wired this itself. In the standard panel, Cancel on the card is the way out (without it the agent waits until the user presses stop), so never draw a step without one.
|
|
97
|
+
- ⚠️ **The all-at-once build wrote by replaying the app's own HTML form,** reading the nonce and posting the fields by label. The app had no JSON create call. Use the app's JSON create call when it has one. When it has none, post the app's own form behind Create project: load the form, take every hidden field from it, and post it back filled by label.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Create one record from what the user said, behind an editable approval card.
|
|
2
|
+
// This is the all-at-once version: every field on one card, pre-filled. For a create
|
|
3
|
+
// where one answer decides which fields come next, see
|
|
4
|
+
// variants/actions/create_project.steps.ts - the README says which to use.
|
|
5
|
+
//
|
|
6
|
+
// execute writes nothing: it reads what the card's dropdowns need and proposes.
|
|
7
|
+
// The project is created only when the user presses Create project. An input card:
|
|
8
|
+
// render uses callback, so the agent waits until callback runs. Every path calls it,
|
|
9
|
+
// exactly once. Never set awaitUserInput - it is not needed.
|
|
10
|
+
|
|
11
|
+
import { proposeProject, type Params, type Proposal } from "../projects";
|
|
12
|
+
import { drawFailed, mountLoadFailure, mountProjectCard, type Callback } from "../views/project-card";
|
|
13
|
+
|
|
14
|
+
export const create_project = {
|
|
15
|
+
// Reads and proposes. Writes nothing.
|
|
16
|
+
execute: async (params: Params = {}): Promise<Proposal> => proposeProject(params),
|
|
17
|
+
|
|
18
|
+
render: (proposal: Proposal | undefined, host: HTMLElement, header: HTMLElement, callback: Callback) => {
|
|
19
|
+
try {
|
|
20
|
+
if (!proposal?.success) return mountLoadFailure(host, proposal, callback);
|
|
21
|
+
mountProjectCard(host, header, proposal, callback);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
drawFailed(host, callback, error);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Mock API. These paths are not a real product. Find the app's own call that
|
|
2
|
+
// lists who can own a project (for the card's dropdown) and the call that
|
|
3
|
+
// creates one, then replace the paths and the envelopes with the app's own.
|
|
4
|
+
// `__observe_me` cannot succeed until you do.
|
|
5
|
+
//
|
|
6
|
+
// ⚠️ No harness command records the create call, and you submit nothing in a
|
|
7
|
+
// signed-in account unless the human named it a test environment: finding the
|
|
8
|
+
// create call is a platform gap (rule 9 of the manual's "Rules that are not
|
|
9
|
+
// negotiable").
|
|
10
|
+
|
|
11
|
+
import { apiFetch, type ApiResult } from "../utils";
|
|
12
|
+
|
|
13
|
+
export type Owner = { id: string; name: string; email?: string | null };
|
|
14
|
+
|
|
15
|
+
export type Directory = {
|
|
16
|
+
/** The one workspace the signed-in user is in: a fact on the card, not a field. */
|
|
17
|
+
workspace: { id: string; name: string };
|
|
18
|
+
owners: Owner[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ProjectDraft = {
|
|
22
|
+
name: string;
|
|
23
|
+
key: string;
|
|
24
|
+
ownerId: string;
|
|
25
|
+
status: string;
|
|
26
|
+
description: string | null;
|
|
27
|
+
private: boolean;
|
|
28
|
+
notifyMembers: boolean;
|
|
29
|
+
/** Set by the step-by-step variant: the answer that decided which fields were asked. */
|
|
30
|
+
kind?: string;
|
|
31
|
+
clientName?: string | null;
|
|
32
|
+
hourlyRate?: number | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type CreatedProject = { id?: string | null; name?: string | null; key?: string | null };
|
|
36
|
+
|
|
37
|
+
export async function listOwners(): Promise<ApiResult<Directory>> {
|
|
38
|
+
const res = await apiFetch<{ workspace?: { id?: string; name?: string } | null; items?: Owner[] | null }>(
|
|
39
|
+
"/__observe_me/users?role=project_owner",
|
|
40
|
+
);
|
|
41
|
+
if (!res.ok) return res;
|
|
42
|
+
|
|
43
|
+
// A signed-out response can be a 200 with nothing in it. The workspace has to be
|
|
44
|
+
// there for a signed-in user; without it, this is not an empty list.
|
|
45
|
+
const workspace = res.data?.workspace;
|
|
46
|
+
if (!workspace?.id) {
|
|
47
|
+
return { ok: false, status: res.status, reason: "signed_out", error: "Not signed in." };
|
|
48
|
+
}
|
|
49
|
+
const owners = (Array.isArray(res.data?.items) ? res.data.items : []).filter((owner) => owner?.id && owner?.name);
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
status: res.status,
|
|
53
|
+
data: { workspace: { id: workspace.id, name: workspace.name ?? "" }, owners },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function createProject(draft: ProjectDraft): Promise<ApiResult<CreatedProject>> {
|
|
58
|
+
return apiFetch<CreatedProject>("/__observe_me/projects", {
|
|
59
|
+
method: "POST",
|
|
60
|
+
body: JSON.stringify(draft),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// What a new project is made of, between the agent's parameters, the card and
|
|
2
|
+
// the create call. Replace the fields with the ones the app's create call takes.
|
|
3
|
+
|
|
4
|
+
import { listOwners, type Owner, type ProjectDraft } from "./api/projects";
|
|
5
|
+
import type { FailureReason } from "./utils";
|
|
6
|
+
import { matchOption, type Field, type Option, type Values } from "./views/fields";
|
|
7
|
+
|
|
8
|
+
/** What the agent passes: whatever the user said, all of it optional. */
|
|
9
|
+
export type Params = {
|
|
10
|
+
name?: string;
|
|
11
|
+
owner?: string;
|
|
12
|
+
status?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
key?: string;
|
|
15
|
+
private?: boolean;
|
|
16
|
+
notify_members?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** A limited set the app defines: a dropdown, not a text box. */
|
|
20
|
+
export const STATUSES: Option[] = [
|
|
21
|
+
{ value: "planned", label: "Planned" },
|
|
22
|
+
{ value: "active", label: "Active" },
|
|
23
|
+
{ value: "on_hold", label: "On hold" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** The app's rule for a project key. Placeholder: 2 to 6 capital letters - use the rule the app enforces. */
|
|
27
|
+
export const KEY_PATTERN = /^[A-Z]{2,6}$/;
|
|
28
|
+
|
|
29
|
+
export function text(value: unknown, max: number): string {
|
|
30
|
+
return typeof value === "string" ? value.trim().slice(0, max) : "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A key from the name: initials of several words, else the first letters of one. */
|
|
34
|
+
export function toKey(name: string): string {
|
|
35
|
+
const words = name.toUpperCase().replace(/[^A-Z\s]/g, " ").split(/\s+/).filter(Boolean);
|
|
36
|
+
const initials = words.length > 1 ? words.map((word) => word[0]).join("") : (words[0] ?? "").slice(0, 4);
|
|
37
|
+
return initials.slice(0, 6).length >= 2 ? initials.slice(0, 6) : "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Owners as dropdown options. The value is the id, never the name: two people can
|
|
42
|
+
* share a name, and the label adds the email only where they do.
|
|
43
|
+
*/
|
|
44
|
+
export function ownerOptions(owners: Owner[]): Option[] {
|
|
45
|
+
const counts = new Map<string, number>();
|
|
46
|
+
for (const owner of owners) counts.set(owner.name, (counts.get(owner.name) ?? 0) + 1);
|
|
47
|
+
return owners.map((owner) => ({
|
|
48
|
+
value: owner.id,
|
|
49
|
+
label: (counts.get(owner.name) ?? 0) > 1 && owner.email ? `${owner.name} (${owner.email})` : owner.name,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The card's values as the create call's body. */
|
|
54
|
+
export function draftFrom(values: Values): ProjectDraft {
|
|
55
|
+
const isPrivate = values.private === true;
|
|
56
|
+
return {
|
|
57
|
+
name: String(values.name ?? "").trim(),
|
|
58
|
+
key: String(values.key ?? "").trim(),
|
|
59
|
+
ownerId: String(values.owner ?? ""),
|
|
60
|
+
status: String(values.status ?? "planned"),
|
|
61
|
+
description: String(values.description ?? "").trim() || null,
|
|
62
|
+
private: isPrivate,
|
|
63
|
+
notifyMembers: isPrivate && values.notify_members === true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What execute hands to render: the user's words, normalised, plus what the dropdowns need. */
|
|
68
|
+
export type Proposal = {
|
|
69
|
+
success: boolean;
|
|
70
|
+
error?: string;
|
|
71
|
+
reason?: FailureReason;
|
|
72
|
+
workspace?: string;
|
|
73
|
+
/** Empty when the user has not said one yet: the card asks for it, nothing has failed. */
|
|
74
|
+
name?: string;
|
|
75
|
+
key?: string;
|
|
76
|
+
owner_options?: Option[];
|
|
77
|
+
/** The option the user's words point at, or "" when none or several do. */
|
|
78
|
+
owner_value?: string;
|
|
79
|
+
status?: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
private?: boolean;
|
|
82
|
+
notify_members?: boolean;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Reads what the card's dropdowns need and normalises what the user said. Writes nothing. */
|
|
86
|
+
export async function proposeProject(params: Params = {}): Promise<Proposal> {
|
|
87
|
+
const directory = await listOwners();
|
|
88
|
+
if (!directory.ok) return { success: false, error: directory.error, reason: directory.reason ?? "server" };
|
|
89
|
+
|
|
90
|
+
const name = text(params.name, 120);
|
|
91
|
+
const options = ownerOptions(directory.data.owners);
|
|
92
|
+
const key = text(params.key, 6).toUpperCase();
|
|
93
|
+
return {
|
|
94
|
+
success: true,
|
|
95
|
+
workspace: directory.data.workspace.name,
|
|
96
|
+
name,
|
|
97
|
+
key: KEY_PATTERN.test(key) ? key : toKey(name),
|
|
98
|
+
owner_options: options,
|
|
99
|
+
owner_value: matchOption(text(params.owner, 120), options),
|
|
100
|
+
status: matchOption(text(params.status, 40), STATUSES) || "planned",
|
|
101
|
+
description: text(params.description, 2000),
|
|
102
|
+
private: params.private === true,
|
|
103
|
+
notify_members: params.notify_members === true,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Every project's fields, pre-filled from the proposal. `more` goes after the owner:
|
|
109
|
+
* fields only some projects have (the step-by-step variant adds a client's).
|
|
110
|
+
*/
|
|
111
|
+
export function projectFields(proposal: Proposal, more: Field[] = []): Field[] {
|
|
112
|
+
return [
|
|
113
|
+
{ key: "name", label: "Name", kind: "text", value: proposal.name ?? "", maxLength: 120, required: true },
|
|
114
|
+
{
|
|
115
|
+
key: "key",
|
|
116
|
+
label: "Key",
|
|
117
|
+
kind: "text",
|
|
118
|
+
value: proposal.key ?? "",
|
|
119
|
+
maxLength: 6,
|
|
120
|
+
required: true,
|
|
121
|
+
hint: "2 to 6 capital letters, e.g. WEB.",
|
|
122
|
+
check: (value) => (KEY_PATTERN.test(value) ? null : "2 to 6 capital letters only."),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
key: "owner",
|
|
126
|
+
label: "Owner",
|
|
127
|
+
kind: "select",
|
|
128
|
+
value: proposal.owner_value ?? "",
|
|
129
|
+
options: proposal.owner_options ?? [],
|
|
130
|
+
required: true,
|
|
131
|
+
placeholder: "Choose an owner",
|
|
132
|
+
},
|
|
133
|
+
...more,
|
|
134
|
+
{ key: "status", label: "Status", kind: "select", value: proposal.status ?? "planned", options: STATUSES },
|
|
135
|
+
{ key: "description", label: "Description", kind: "text", multiline: true, value: proposal.description ?? "", maxLength: 2000 },
|
|
136
|
+
{ key: "private", label: "Private - only members can see it", kind: "checkbox", value: proposal.private === true },
|
|
137
|
+
{
|
|
138
|
+
key: "notify_members",
|
|
139
|
+
label: "Email members when it is created",
|
|
140
|
+
kind: "checkbox",
|
|
141
|
+
value: proposal.notify_members === true,
|
|
142
|
+
showWhen: (values) => values.private === true,
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// One card for a create: first the proposal (editable fields, Cancel and the approve
|
|
2
|
+
// button - "Create project" on the review, "Continue" or "Review" on a step), then the
|
|
3
|
+
// outcome (created, not created, not confirmed, cancelled) drawn in the same slot.
|
|
4
|
+
// The card keeps references to its own parts instead of querying the DOM: the
|
|
5
|
+
// host belongs to the agent panel's frame. textContent only - never innerHTML
|
|
6
|
+
// with API data. Styles go in `header`.
|
|
7
|
+
|
|
8
|
+
import { brand } from "./brand";
|
|
9
|
+
import { buildForm, type Field, type Values } from "./fields";
|
|
10
|
+
|
|
11
|
+
export type Tone = "success" | "warning" | "danger";
|
|
12
|
+
|
|
13
|
+
export type Outcome = {
|
|
14
|
+
tone: Tone;
|
|
15
|
+
/** "Project created", "Not created", "Not confirmed", "Cancelled". "" draws no pill. */
|
|
16
|
+
status: string;
|
|
17
|
+
/** "New project" */
|
|
18
|
+
kicker: string;
|
|
19
|
+
/** The record's name. */
|
|
20
|
+
headline: string;
|
|
21
|
+
/** Read-only lines: a value with only one possibility, or what was written. Empty values are skipped. */
|
|
22
|
+
facts: Array<[string, string]>;
|
|
23
|
+
/** One line under the facts - the card's or the runtime's sentence, never the app's response body (`detail`). */
|
|
24
|
+
note?: string | null;
|
|
25
|
+
footer: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const TONES: Record<Tone, { bg: string; fg: string; dot: string }> = {
|
|
29
|
+
success: { bg: "#ECFDF5", fg: "#047857", dot: "#10B981" },
|
|
30
|
+
warning: { bg: "#FFFBEB", fg: "#B45309", dot: "#F59E0B" },
|
|
31
|
+
danger: { bg: "#FEF2F2", fg: "#B91C1C", dot: "#EF4444" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const CSS = `
|
|
35
|
+
.fs-rec{background:${brand.surface};border-radius:${brand.radius};font-family:${brand.font};color:${brand.text};
|
|
36
|
+
border:1px solid ${brand.border};overflow:hidden;width:100%;box-sizing:border-box;font-size:13px;line-height:1.4}
|
|
37
|
+
.fs-rec *{box-sizing:border-box;margin:0}
|
|
38
|
+
.fs-rec-top{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px 0}
|
|
39
|
+
.fs-rec-pill{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:3px 10px;font-size:12px;font-weight:600}
|
|
40
|
+
.fs-rec-dot{width:7px;height:7px;border-radius:50%}
|
|
41
|
+
.fs-rec-kicker{font-size:11px;color:${brand.muted};text-transform:uppercase;letter-spacing:.04em;white-space:nowrap}
|
|
42
|
+
.fs-rec-head{padding:10px 14px 12px;border-bottom:1px solid ${brand.border}}
|
|
43
|
+
.fs-rec-title{font-size:17px;font-weight:600;word-break:break-word}
|
|
44
|
+
.fs-rec-facts{display:grid;grid-template-columns:auto 1fr;gap:6px 14px;padding:12px 14px}
|
|
45
|
+
.fs-rec-k{color:${brand.muted};font-size:12px;white-space:nowrap}
|
|
46
|
+
.fs-rec-v{font-size:12px;font-weight:500;overflow-wrap:anywhere}
|
|
47
|
+
.fs-rec-note{margin:0 14px 12px;padding:8px 10px;border-radius:6px;font-size:12px}
|
|
48
|
+
.fs-rec-actions{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;border-top:1px solid ${brand.border}}
|
|
49
|
+
.fs-rec-btn{font-family:${brand.font};font-size:13px;font-weight:600;border-radius:6px;padding:7px 14px;cursor:pointer;
|
|
50
|
+
border:1px solid ${brand.border};background:${brand.surface};color:${brand.text}}
|
|
51
|
+
.fs-rec-btn.primary{background:${brand.accent};border-color:${brand.accent};color:${brand.accentText}}
|
|
52
|
+
.fs-rec-btn:disabled{opacity:.55;cursor:default}
|
|
53
|
+
.fs-rec-working{font-size:12px;color:${brand.muted}}
|
|
54
|
+
.fs-rec-foot{padding:8px 14px;background:${brand.surface2};border-top:1px solid ${brand.border};font-size:11px;color:${brand.muted}}
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
function el(tag: string, className: string, text?: string): HTMLElement {
|
|
58
|
+
const node = document.createElement(tag);
|
|
59
|
+
node.className = className;
|
|
60
|
+
if (text !== undefined) node.textContent = text;
|
|
61
|
+
return node;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function addStyle(host: HTMLElement, header: HTMLElement | undefined, css: string): void {
|
|
65
|
+
const style = el("style", "");
|
|
66
|
+
style.textContent = css;
|
|
67
|
+
(header ?? host).append(style);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Draws a card. `form` and `actions` go where a proposal needs them; an outcome has neither. */
|
|
71
|
+
function draw(outcome: Outcome, form?: HTMLElement, actions?: HTMLElement): { card: HTMLElement; title: HTMLElement } {
|
|
72
|
+
const tone = TONES[outcome.tone];
|
|
73
|
+
const card = el("div", "fs-rec");
|
|
74
|
+
|
|
75
|
+
const top = el("div", "fs-rec-top");
|
|
76
|
+
if (outcome.status) {
|
|
77
|
+
const pill = el("span", "fs-rec-pill");
|
|
78
|
+
pill.style.background = tone.bg;
|
|
79
|
+
pill.style.color = tone.fg;
|
|
80
|
+
const dot = el("span", "fs-rec-dot");
|
|
81
|
+
dot.style.background = tone.dot;
|
|
82
|
+
pill.append(dot, document.createTextNode(outcome.status));
|
|
83
|
+
top.append(pill);
|
|
84
|
+
}
|
|
85
|
+
top.append(el("span", "fs-rec-kicker", outcome.kicker));
|
|
86
|
+
card.append(top);
|
|
87
|
+
|
|
88
|
+
const head = el("div", "fs-rec-head");
|
|
89
|
+
const title = el("div", "fs-rec-title", outcome.headline || "Untitled");
|
|
90
|
+
head.append(title);
|
|
91
|
+
card.append(head);
|
|
92
|
+
if (form) card.append(form);
|
|
93
|
+
|
|
94
|
+
const facts = outcome.facts.filter(([, value]) => value);
|
|
95
|
+
if (facts.length) {
|
|
96
|
+
const grid = el("div", "fs-rec-facts");
|
|
97
|
+
for (const [key, value] of facts) grid.append(el("div", "fs-rec-k", key), el("div", "fs-rec-v", value));
|
|
98
|
+
card.append(grid);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (outcome.note) {
|
|
102
|
+
const note = el("div", "fs-rec-note", outcome.note);
|
|
103
|
+
note.style.background = tone.bg;
|
|
104
|
+
note.style.color = tone.fg;
|
|
105
|
+
card.append(note);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (actions) card.append(actions);
|
|
109
|
+
card.append(el("div", "fs-rec-foot", outcome.footer));
|
|
110
|
+
return { card, title };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The outcome drawn in the card's place: the result of the write, or of Cancel. No buttons. */
|
|
114
|
+
export function mountOutcome(host: HTMLElement, header: HTMLElement | undefined, outcome: Outcome): void {
|
|
115
|
+
addStyle(host, header, CSS);
|
|
116
|
+
host.textContent = "";
|
|
117
|
+
host.append(draw(outcome).card);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A proposal the user must approve: the card, plus editable fields, Cancel and the approve
|
|
122
|
+
* button (`labels.approve`). The inputs start pre-filled, the approve button stays disabled
|
|
123
|
+
* until every shown field passes its check, and it hands over what the card shows. Facts
|
|
124
|
+
* stay read-only. Nothing is written before it is pressed. Both buttons resolve exactly once.
|
|
125
|
+
*/
|
|
126
|
+
export function mountApproval(
|
|
127
|
+
host: HTMLElement,
|
|
128
|
+
header: HTMLElement | undefined,
|
|
129
|
+
proposal: Outcome,
|
|
130
|
+
labels: { approve: string; working: string },
|
|
131
|
+
fields: Field[],
|
|
132
|
+
handlers: { onApprove: (values: Values, edited: string[]) => void; onCancel: () => void },
|
|
133
|
+
/** The field whose value is the card's headline, kept in step as it is edited. */
|
|
134
|
+
headlineKey?: string,
|
|
135
|
+
): void {
|
|
136
|
+
addStyle(host, header, CSS);
|
|
137
|
+
const form = buildForm(fields);
|
|
138
|
+
(header ?? host).append(form.style);
|
|
139
|
+
|
|
140
|
+
// The button bar is a slot: the approve button swaps its contents for a "working" line.
|
|
141
|
+
const actions = el("div", "fs-rec-actions");
|
|
142
|
+
const cancel = el("button", "fs-rec-btn", "Cancel") as HTMLButtonElement;
|
|
143
|
+
const approve = el("button", "fs-rec-btn primary", labels.approve) as HTMLButtonElement;
|
|
144
|
+
cancel.type = "button";
|
|
145
|
+
approve.type = "button";
|
|
146
|
+
actions.append(cancel, approve);
|
|
147
|
+
|
|
148
|
+
const { card, title } = draw(proposal, form.node, actions);
|
|
149
|
+
if (headlineKey) {
|
|
150
|
+
form.onChange(() => {
|
|
151
|
+
title.textContent = String(form.values()[headlineKey] ?? "").trim() || proposal.headline || "Untitled";
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let done = false;
|
|
156
|
+
const sync = () => {
|
|
157
|
+
if (!done) approve.disabled = !form.valid();
|
|
158
|
+
};
|
|
159
|
+
form.onChange(sync);
|
|
160
|
+
sync();
|
|
161
|
+
|
|
162
|
+
cancel.addEventListener("click", () => {
|
|
163
|
+
if (done) return;
|
|
164
|
+
done = true;
|
|
165
|
+
handlers.onCancel();
|
|
166
|
+
});
|
|
167
|
+
approve.addEventListener("click", () => {
|
|
168
|
+
if (done || !form.valid()) return;
|
|
169
|
+
done = true;
|
|
170
|
+
approve.disabled = true;
|
|
171
|
+
cancel.disabled = true;
|
|
172
|
+
const values = form.values();
|
|
173
|
+
const edited = form.edited();
|
|
174
|
+
actions.textContent = "";
|
|
175
|
+
actions.append(el("div", "fs-rec-working", labels.working));
|
|
176
|
+
handlers.onApprove(values, edited);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
host.textContent = "";
|
|
180
|
+
host.append(card);
|
|
181
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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.
|
|
4
|
+
|
|
5
|
+
export const brand = {
|
|
6
|
+
surface: "#FFFFFF",
|
|
7
|
+
surface2: "#F5F6FA",
|
|
8
|
+
text: "#374151",
|
|
9
|
+
muted: "#6B7280",
|
|
10
|
+
border: "#E5E7EB",
|
|
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",
|
|
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",
|
|
30
|
+
money: "#047857",
|
|
31
|
+
ok: "#047857",
|
|
32
|
+
deals: "#3248F2",
|
|
33
|
+
contacts: "#2538D6",
|
|
34
|
+
cases: "#B45309",
|
|
35
|
+
} as const;
|