Skip to content

Commit bb61872

Browse files
feat(app-shell,components): Open the full form carries an AI chat draft in router state, marked AI suggestion until edited (#31445)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01GsBogozeZG5sdpAARrN7HP?thread=cmsg_01GsBogozeZG5sdpAARrN7HP4Tej82cK86WWZKKTHrPtsN)_ Closes #28104 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:AI 聊天里新建记录卡片上的「打开完整表单」,现在把卡片的字段值放在路由 state 里带到新建表单,不再拼进网址;表单里这些字段标「AI 建议」,用户一改就消失,表单上方有一句提示「保存前不会写入任何内容」,绝不自动提交。 - 为什么改:之前姓名、电话、邮箱等联系人信息直接出现在地址栏、浏览器历史和复制出去的链接里;卡片要求的「AI 建议」标记也没有。原卡写的 `record_draft` 卡片已随 cloud#3565 退役,这里做的是它在现行 `create_record` 卡片上仍然缺的那一半。 - 风险与回滚:只动界面三包(app-shell、components、i18n),没有 spec 或元数据改动;回滚即撤销本 PR,按钮回到网址预填。 - 先看:`ui/packages/app-shell/src/views/RecordFormPage.tsx`(state 与网址预填的优先级)、`ui/packages/components/src/renderers/form/form.tsx`(标记何时出现、何时消失)、`ui/packages/app-shell/src/utils/recordFormAiDraft.ts`。 - 两分钟验证:在 AI 聊天里说「新建一个线索:张伟,电话 13900001111」,在确认卡片上点「打开完整表单」,应看到地址是不带 `?` 的 `/new`,姓名和电话已填好并标「AI 建议」,改一下电话标记消失,不点保存就没有记录。 - 需要你做的:无,席位落地。 ## What changes - **Premise, checked on `main` first.** The card asked for a `status:'record_draft'` card. That producer is gone: cloud#3565 replaced `draft_record` with the `create_record` / `update_record` proposals (`packages/spec/src/system/constants/platform-tool-names.ts:57-69` on this repo; on cloud main `service-ai-studio/src/__tests__/ask-tool-surface.test.ts:305` asserts `draft_record` is no longer a tool, and `plugin.ts:80` retires the `record_drafting` skill). `plugin-chatbot` already renders the replacement record-write card with its dropped reasons (`ChatbotEnhanced.tsx:3615-3740`, `recordWrite-3565.test.ts:94` pins that a `record_draft` result is not a card). So work item 1 is dropped as done. What was still missing is the rest of the card on that live path: the card's "Open the full form" (`AiChatPage.tsx` `openRecordForm`) put every value in the New page's **query string** (`recordFormPrefill.ts`), and `RecordFormPage` only read the query string (`RecordFormPage.tsx:100-108` on main). No field was marked. - **`@object-ui/app-shell`.** `utils/recordFormAiDraft.ts` builds the navigation: a create goes to the bare `/apps/APP/OBJECT/new` with the values in router state (types kept, empty values left out); an update still opens the edit page on the stored record. `RecordFormPage` reads that state in create mode only, merges it under any query-string prefill (which still wins per field, so a related-list "+ New" keeps naming its parent) and over the form view's `defaults`, names the fields the draft really fills through `SuggestedFieldsContext`, and shows one notice above the form. `recordFormPrefill.ts` and its test are removed; nothing else used them. - **`@object-ui/components`.** New host slot `SuggestedFieldsContext` (a set of field names). The `form` renderer marks each named field "AI suggestion" inside its description, so the control's `aria-describedby` announces it, while the field still holds its opening value (the same empty-ish comparison the dirty check uses, so a widget normalizing on mount does not read as an edit). The first edit removes the mark. It is not a schema key; with no provider nothing changes. - **`@object-ui/i18n`.** `form.aiSuggested` and `form.aiDraftNotice` in all eleven packs. The `de` notice adds one correctly paired „…“ span, so `de-quote-pairing-3876.test.ts`'s census moves 109 to 110. - **Never auto-submit.** Nothing on the page or in the form submits on mount; only Save writes. The card cancels its pending write before navigating (unchanged), so a record saved here is written once. - **Reload.** The card said a reload drops the draft. With the console's `BrowserRouter`, router state lives in the tab's history entry, so reloading or going Back to that same entry reopens the draft; a copied link, a new tab or another device opens an empty form, because nothing is in the URL. The values never leave the browser tab. ## Why The draft carries contact details pasted from a chat. A query string is kept in the address bar, the browser's history list, server and proxy logs, and every link copied from the page. The marker tells the person which values are the AI's guess until they have looked at them. ## Tests - `ui/packages/app-shell/src/utils/__tests__/recordFormAiDraft-28104.test.ts`: the create target has no `?` and no value in the URL, the state round-trips typed values, empty values are dropped, an update carries no draft, malformed state reads as none. - `ui/packages/app-shell/src/views/RecordFormPage.aiDraft-28104.test.tsx`: the draft seeds the create over the form defaults and names every drafted field; a query-string prefill wins and is not named; one notice; the draft arrives through a real `navigate(to, { state })` from another route with the URL left bare; CONTROL: no draft or an edit names nothing. - `ui/packages/components/src/renderers/form/__tests__/form-aiSuggestedMark-28104.test.tsx`: the mark sits in the description the control names; the first edit removes it; unnamed fields and a form with no provider carry none; mounting submits nothing and Save submits the values. Suites at head `05c19b1e` (run under `os-verify-lock`, shared 4-core box): - `@object-ui/i18n`: Test Files 99 passed (99), Tests 1804 passed, 13 skipped. - `@object-ui/components`: Test Files 478 passed, 1 skipped (479), Tests 4578 passed, 24 skipped. - `@object-ui/app-shell`: Test Files 2217 passed, 1 skipped (2218), Tests 20191 passed, 9 skipped. - `type-check` on all three packages: exit 0 (app-shell runs its `tsconfig.test.json` program too, so the new tests are compiled). <details><summary>Ablation, one mutation per tested behaviour, implementation committed first, restored by blob hash</summary> Each leg via `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed, restore blob equals HEAD, `git diff HEAD` empty). The tests resolve these packages through the vitest `src` aliases, so no `dist/` leg applies. - A. `form.tsx`: `suggestedFields?.has(name) &&` to `false &&` (the renderer ignores the slot). RED as predicted: "marks a named field…" and "the first edit takes the mark away…"; the three CONTROL / no-auto-submit cells stay green. 2 failed, 3 passed. - B. `RecordFormPage.tsx`: `{ ...aiDraftValues, ...prefillValues }` to `{ ...prefillValues, ...aiDraftValues }` (the draft beats the query string). RED: "a query-string prefill wins per field…". 1 failed, 5 passed. - C. `RecordFormPage.tsx`: `readRecordFormAiDraft(location.state)` to `undefined` (the page reads no router state). RED: the seed, precedence, notice and real-navigate cells; both CONTROL cells green. 4 failed, 2 passed. </details> ## Acceptance notes - An **update** card's "Open the full form" still opens the edit page without the proposed values. That is objectstack#28106 and is not touched here. - `SuggestedFieldsContext` is an additive export from `@object-ui/components` (hence `Clause-②: yes (widening)`); no spec, Zod or authorable key changes. ## Verification <details><summary>Derived gates (`node scripts/pm/dispatch-gates.mjs --commands`, 39 families) and reconciliation</summary> All 39 derived commands ran locally, each with its exit code captured before any pipe, plus `pnpm check:changeset-fixed` (its roster sits under `.changeset/`). All exit 0. One was red on the first head and fixed in `05c19b1e`: `pnpm check:test-source-alias` asked the new form test to pay the `@object-ui/react-runtime` load at collection (module-top import added), re-run exit 0. The `de` quote census in `de-quote-pairing-3876.test.ts` moved 109 to 110 for the one new, correctly paired „…“ span. `dispatch-gates --ran`: "39 derived famil(ies) accounted for — 39 run, 0 NOT-MEASURED (a DERIVED zero — all 39 recorded an exit code and none of them is 3)." NOT MEASURED locally, owned by CI: the type-check lanes (`Type Check UI`, workspace), repo `pnpm lint`, `test:repo`, the artifact-roster and wide-population families. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI owns the repo lint) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the seat-required Chinese summary). --- _Generated by [Claude Code](https://claude.ai/code/session_01QUs3BtDrV2BdBwtFsUGswG)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QUs3BtDrV2BdBwtFsUGswG --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1ff9944 commit bb61872

24 files changed

Lines changed: 645 additions & 113 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/components': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
feat(app-shell,components): "Open the full form" on an AI chat record card opens the New form with the values in router state, marked 「AI 建议」 until edited
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **`@object-ui/app-shell`:** on a create card from the AI chat (cloud#3565), "Open the full form" used to put every card value in the New page's query string, so a lead's name, phone number and e-mail address sat in the address bar, the browser history and any link copied from it. The values now travel in the router state of that one navigation (`utils/recordFormAiDraft.ts`), with their types kept, and the URL is the bare `/new` path. `RecordFormPage` reads them as create values in create mode only, under any query-string prefill, which still wins per field (a related-list "+ New" names its parent on purpose), and over the form view's `defaults`. When the page opens on such a draft it shows one notice above the form saying the record is not saved until the person checks it and saves. Nothing submits on its own. Router state belongs to the tab's history entry: reloading or going Back to that entry reopens the draft, while a copied link, a new tab or another device opens an empty form. The query-string helper `recordFormPrefill.ts`, which only this button used, is removed.
13+
- **`@object-ui/components`:** new host slot `SuggestedFieldsContext` (a set of field names, default `undefined`). A `form` renderer under it marks each named field "AI suggestion" inside the field's description, so the control's `aria-describedby` announces it, while the field still holds its opening value. The person's first edit takes the mark away. It is not metadata: no form or field key sets it, and with no provider nothing changes.
14+
- **`@object-ui/i18n`:** `form.aiSuggested` and `form.aiDraftNotice` in all eleven locale packs.

‎ui/packages/app-shell/src/console/ai/AiChatPage.tsx‎

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ import { isConversationZh } from './conversationLanguage.js';
146146
import { resolveOutboundAgentText, type OutboundAgentTextKey } from './outboundAgentText.js';
147147
import { resolveApiBase } from './resolveApiBase.js';
148148
import { AiUnavailable } from './AiUnavailable.js';
149-
import { recordFormPrefillQuery } from './recordFormPrefill.js';
149+
import { recordWriteFormTarget } from '../../utils/recordFormAiDraft.js';
150150

151151
/**
152152
* The app segment a record of `objectName` lives under: the object's owning
@@ -1747,9 +1747,11 @@ export function ChatPane({
17471747
);
17481748

17491749
// cloud#3565 — 打开完整表单 on a record-write card. A create opens the New
1750-
// form with the card's values through the query-string prefill
1751-
// `RecordFormPage` already reads for related-list "+ New" (one `field=value`
1752-
// pair per card field; the form's own defaults fill the rest). An update
1750+
// form with the card's values in the ROUTER STATE of this navigation, never
1751+
// the query string: a chat draft carries contact details, and a URL is
1752+
// copied, shared and logged (objectstack#28104, `recordFormAiDraft.ts`).
1753+
// The form marks each of those fields 「AI 建议」 until the person edits
1754+
// it, and opens unsaved; the form's own defaults fill the rest. An update
17531755
// opens the record's edit form: the card has shown old → new, and the form
17541756
// opens on the stored record. The card cancelled the pending write before
17551757
// calling this, so a record saved here is never written a second time.
@@ -1760,14 +1762,8 @@ export function ChatPane({
17601762
recordId?: string;
17611763
fields: Array<{ name: string; value: unknown }>;
17621764
}) => {
1763-
const app = await appOfObject(write.objectName);
1764-
const base = `/apps/${encodeURIComponent(app)}/${encodeURIComponent(write.objectName)}`;
1765-
if (write.operation === 'update' && write.recordId) {
1766-
navigate(`${base}/record/${encodeURIComponent(write.recordId)}/edit`);
1767-
return;
1768-
}
1769-
const query = recordFormPrefillQuery(write.fields);
1770-
navigate(`${base}/new${query ? `?${query}` : ''}`);
1765+
const target = recordWriteFormTarget(write, await appOfObject(write.objectName));
1766+
navigate(target.to, target.state ? { state: target.state } : undefined);
17711767
},
17721768
[navigate],
17731769
);

‎ui/packages/app-shell/src/console/ai/__tests__/recordFormPrefill-3565.test.ts‎

Lines changed: 0 additions & 36 deletions
This file was deleted.

‎ui/packages/app-shell/src/console/ai/recordFormPrefill.ts‎

Lines changed: 0 additions & 34 deletions
This file was deleted.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#28104 — "Open the full form" on a record-write card carries the
11+
* card's values in router state, never in the query string, and the New page
12+
* reads back exactly what was sent.
13+
*/
14+
import { describe, expect, it } from 'vitest';
15+
import { readRecordFormAiDraft, recordFormAiDraftState, recordWriteFormTarget } from '../recordFormAiDraft';
16+
17+
const CREATE = {
18+
operation: 'create' as const,
19+
objectName: 'crm_lead',
20+
fields: [
21+
{ name: 'name', value: '张伟' },
22+
{ name: 'phone', value: '13900001111' },
23+
{ name: 'estimated_amount', value: 120000 },
24+
{ name: 'is_vip', value: false },
25+
{ name: 'tags', value: ['打印机', '网络'] },
26+
{ name: 'note', value: '' },
27+
{ name: 'email', value: null },
28+
{ name: 'labels', value: [] },
29+
],
30+
};
31+
32+
describe('recordWriteFormTarget (objectstack#28104)', () => {
33+
it('a create opens /new with no query string and the values in state, types kept', () => {
34+
const target = recordWriteFormTarget(CREATE, 'crm');
35+
expect(target.to).toBe('/apps/crm/crm_lead/new');
36+
expect(target.to).not.toContain('?');
37+
expect(target.to).not.toContain('13900001111');
38+
expect(readRecordFormAiDraft(target.state)).toStrictEqual({
39+
name: '张伟',
40+
phone: '13900001111',
41+
estimated_amount: 120000,
42+
is_vip: false,
43+
tags: ['打印机', '网络'],
44+
});
45+
});
46+
47+
it('a create whose fields are all empty opens a plain /new with no state', () => {
48+
const target = recordWriteFormTarget({ ...CREATE, fields: [{ name: 'note', value: '' }] }, 'crm');
49+
expect(target).toStrictEqual({ to: '/apps/crm/crm_lead/new' });
50+
});
51+
52+
it('an update opens the edit page on the stored record and carries no draft', () => {
53+
const target = recordWriteFormTarget(
54+
{ operation: 'update', objectName: 'crm_lead', recordId: 'lead 1', fields: [{ name: 'phone', value: '1' }] },
55+
'crm',
56+
);
57+
expect(target).toStrictEqual({ to: '/apps/crm/crm_lead/record/lead%201/edit' });
58+
});
59+
});
60+
61+
describe('readRecordFormAiDraft (objectstack#28104)', () => {
62+
it('reads nothing from a state that is not a draft', () => {
63+
for (const state of [undefined, null, 'x', 1, {}, { from: { pathname: '/a' } }, { recordFormAiDraft: 'x' },
64+
{ recordFormAiDraft: { values: ['a'] } }, { recordFormAiDraft: { values: { a: '' } } }]) {
65+
expect(readRecordFormAiDraft(state)).toBeUndefined();
66+
}
67+
});
68+
69+
it('round-trips the state recordFormAiDraftState builds', () => {
70+
expect(readRecordFormAiDraft(recordFormAiDraftState([{ name: 'a', value: 1 }]))).toStrictEqual({ a: 1 });
71+
expect(recordFormAiDraftState([])).toBeUndefined();
72+
});
73+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* objectstack#28104 — how an AI chat draft reaches the New form.
9+
*
10+
* "Open the full form" on a record-write card (cloud#3565) opens the object's
11+
* New page on the card's values. They travel in the ROUTER STATE of that one
12+
* navigation, never in the query string: a draft pasted from a chat carries
13+
* names, phone numbers and e-mail addresses, and a URL is copied, shared,
14+
* logged by proxies and kept in the browser history's address list.
15+
*
16+
* Router state lives in the tab's history entry (`history.state`), so it is
17+
* read again when that same entry is reloaded or revisited with Back, and is
18+
* absent everywhere else: a copied link, a new tab, another device all open
19+
* an empty form. Nothing here saves anything: the page opens unsaved and the
20+
* person presses Save.
21+
*/
22+
23+
/** The key the draft rides under in `location.state`. */
24+
const AI_DRAFT_KEY = 'recordFormAiDraft';
25+
26+
/** The `state` "Open the full form" navigates with. */
27+
export interface RecordFormAiDraftState {
28+
[AI_DRAFT_KEY]: { values: Record<string, unknown> };
29+
}
30+
31+
/** An empty value is left out: the form's own default fills that field. */
32+
function isEmptyValue(value: unknown): boolean {
33+
return (
34+
value === null ||
35+
value === undefined ||
36+
value === '' ||
37+
(Array.isArray(value) && value.length === 0)
38+
);
39+
}
40+
41+
/**
42+
* The router state for a card's fields, or `undefined` when no field carries
43+
* a value. Values keep their type (a number stays a number, a multi-select
44+
* stays a list): unlike a query string, router state is not flattened.
45+
*/
46+
export function recordFormAiDraftState(
47+
fields: ReadonlyArray<{ name: string; value: unknown }>,
48+
): RecordFormAiDraftState | undefined {
49+
const values: Record<string, unknown> = {};
50+
for (const f of fields) {
51+
if (f.name && !isEmptyValue(f.value)) values[f.name] = f.value;
52+
}
53+
return Object.keys(values).length > 0 ? { [AI_DRAFT_KEY]: { values } } : undefined;
54+
}
55+
56+
/**
57+
* The draft values a New page was opened with, or `undefined` when its
58+
* `location.state` carries none (or anything that is not a draft).
59+
*/
60+
export function readRecordFormAiDraft(state: unknown): Record<string, unknown> | undefined {
61+
if (!state || typeof state !== 'object') return undefined;
62+
const draft = (state as Record<string, unknown>)[AI_DRAFT_KEY];
63+
if (!draft || typeof draft !== 'object') return undefined;
64+
const values = (draft as { values?: unknown }).values;
65+
if (!values || typeof values !== 'object' || Array.isArray(values)) return undefined;
66+
const out: Record<string, unknown> = {};
67+
for (const [name, value] of Object.entries(values as Record<string, unknown>)) {
68+
if (name && !isEmptyValue(value)) out[name] = value;
69+
}
70+
return Object.keys(out).length > 0 ? out : undefined;
71+
}
72+
73+
/**
74+
* Where "Open the full form" on a record-write card goes, given the app the
75+
* object belongs to. A create opens the New page with the card's values in
76+
* `state` and nothing after the path; an update opens the record's edit page
77+
* on the stored record (the card already showed old → new).
78+
*/
79+
export function recordWriteFormTarget(
80+
write: {
81+
operation: 'create' | 'update';
82+
objectName: string;
83+
recordId?: string;
84+
fields: ReadonlyArray<{ name: string; value: unknown }>;
85+
},
86+
app: string,
87+
): { to: string; state?: RecordFormAiDraftState } {
88+
const base = `/apps/${encodeURIComponent(app)}/${encodeURIComponent(write.objectName)}`;
89+
if (write.operation === 'update' && write.recordId) {
90+
return { to: `${base}/record/${encodeURIComponent(write.recordId)}/edit` };
91+
}
92+
const state = recordFormAiDraftState(write.fields);
93+
return state ? { to: `${base}/new`, state } : { to: `${base}/new` };
94+
}

0 commit comments

Comments
 (0)