Skip to content

Commit 6a2ab0a

Browse files
authored
feat: add support for the upcoming move_page, delete_page and rotate_page in Copilot + fixes (#32)
## Background Adding support for the upcoming move, delete and rotate page. In addition, some QoL improvements: stretching the input when longer text is entered + better affordance for demo mode ## Changes **Page actions** - Register `move_page`, `delete_page`, `rotate_page` (and `remove_fields`) with the LLM tool registry on both `/api/chat` and BYOK transports. - Gerund tool-invocation labels for the new actions, split from the legacy `submit_download`. **QoL** - Auto-resize chat input upward as a textarea (Enter sends, Shift+Enter newline). - Demo header hides the underlying provider — shows the BYOK upsell CTA instead. BYOK active still shows the actual model name. - Chat history keyed on `form` (URL-stable) instead of `document_id`, so in-editor merges preserve history. - Field-detection rewrite: id-based tracking, baseline persisted across cursor gate flips, mixed-type icon row, CLDR-pluralized hint. - Tighter `remove_fields` validation (typed `bad_input` for malformed `field_ids`) and self-pruning seen-id set.
1 parent df970ef commit 6a2ab0a

32 files changed

Lines changed: 525 additions & 165 deletions

copilot/src/components/chat/chat_pane.tsx

Lines changed: 154 additions & 77 deletions
Large diffs are not rendered by default.

copilot/src/components/chat/chat_pane_header.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next'
44
import { LanguagePicker } from './language_picker'
55

66
type ChatPaneHeaderProps = {
7-
// Resolved label of whichever model will run the next turn (BYOK model
8-
// name, or the demo model name in demo mode). Null means no model in
9-
// scope at all, in which case the header shows a status message instead
10-
// of the clickable model affordance. `hasActiveModel` derives from this
11-
// (label !== null) and lives at the consumer.
7+
// Display label for the clickable model affordance. BYOK active surfaces
8+
// the actual model name (so the user can see which model will run and
9+
// click to swap). The shared-demo path passes the BYOK upsell CTA copy
10+
// instead — same affordance, opens the picker — so the demo never reveals
11+
// which provider it is paying for. Null means no clickable affordance at
12+
// all and the header falls back to a status message.
1213
activeModelLabel: string | null
1314
// True when BYOK is active AND the user provided custom system-prompt
1415
// instructions. Renders a small pill next to the model name.

copilot/src/components/chat/hooks/use_detect_user_added_field.ts

Lines changed: 57 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,32 @@ import type { IframeBridge, SupportedFieldType } from '../../../lib/embed-bridge
88
// so the chat_pane call site stays readable, and so the workaround can be
99
// deleted in one place the day the editor ships a real outbound event.
1010
//
11-
// The poll is gated aggressively to minimise iframe round-trips:
11+
// The polling LOOP is gated aggressively to minimise iframe round-trips:
1212
// - bridge ready AND isReady AND toolbarTool is a placement tool AND the
1313
// user's cursor is over the editor iframe.
14-
// When any of those flip off, the effect tears down and polling stops.
14+
// When any of those flip off, the loop pauses; when they flip back on,
15+
// it resumes. The SET OF SEEN FIELD IDS persists across gate flips —
16+
// only a bridge change resets it. This is what gives the post-LLM-turn
17+
// reconciliation for free: while the LLM is streaming, the user's cursor
18+
// often moves to the chat panel and back. Without persistence, each
19+
// cursor re-entry would re-seed the seen set with the (now-larger) field
20+
// set and the fields the user dropped during the stream would never
21+
// surface to the LLM. With persistence, the next post-stream poll diffs
22+
// the current set against the seen set and fires once.
1523
//
1624
// Stream safety: if the assistant is mid-response, the poll skips the
1725
// iframe call entirely and `onFieldAdded` is not called. The first tick
1826
// after streaming ends catches whatever was dropped mid-stream.
1927
//
20-
// One-shot: after firing `onFieldAdded` once, the loop cancels; a fresh
21-
// cycle arms when any of the gates re-enters the "all true" state (e.g.
22-
// user moves their cursor off the iframe and back on, or flips placement
23-
// tools).
28+
// Per-type tracking: the hook diffs by field id, not by count. The fire
29+
// payload carries the list of tool types of the newly-added fields so
30+
// the UI can show one icon per unique type when the user mixed (e.g.
31+
// TEXT + SIGNATURE in the same batch).
32+
//
33+
// LLM-created fields bypass this nudge via `markFieldAsKnown(fieldId)`,
34+
// called from the create_field middleware once the iframe has confirmed
35+
// the new field id. The id goes straight into the seen set; the next
36+
// poll's diff sees no user-added fields.
2437
//
2538
// Refs-not-props for the streaming flag and the fire callback let the
2639
// hook be called BEFORE useChat in the consumer (useChat produces the
@@ -29,7 +42,7 @@ import type { IframeBridge, SupportedFieldType } from '../../../lib/embed-bridge
2942

3043
const POLL_INTERVAL_MS = 200
3144

32-
export type FieldAddedEvent = { tool: SupportedFieldType; delta: number }
45+
export type FieldAddedEvent = { tools: SupportedFieldType[]; delta: number }
3346

3447
type UseDetectUserAddedFieldArgs = {
3548
bridge: IframeBridge | null
@@ -42,10 +55,10 @@ type UseDetectUserAddedFieldArgs = {
4255

4356
type UseDetectUserAddedFieldReturn = {
4457
// Consumers call this when they know a field was added by something
45-
// other than the user (e.g. the LLM's `create_field` tool). It advances
46-
// the baseline so the next poll tick does NOT attribute that field to
47-
// the user.
48-
advanceBaseline: (delta: number) => void
58+
// other than the user (e.g. the LLM's `create_field` tool returned a
59+
// field id). The id is added to the seen set so the next poll does
60+
// NOT attribute that field to the user.
61+
markFieldAsKnown: (fieldId: string) => void
4962
}
5063

5164
export const useDetectUserAddedField = ({
@@ -56,55 +69,64 @@ export const useDetectUserAddedField = ({
5669
isStreamingRef,
5770
onFieldAddedRef,
5871
}: UseDetectUserAddedFieldArgs): UseDetectUserAddedFieldReturn => {
59-
const baselineRef = useRef<number | null>(null)
72+
const seenIdsRef = useRef<Set<string> | null>(null)
73+
const lastBridgeRef = useRef<IframeBridge | null>(null)
6074

61-
const advanceBaseline = useCallback((delta: number): void => {
62-
if (baselineRef.current !== null) {
63-
baselineRef.current += delta
75+
const markFieldAsKnown = useCallback((fieldId: string): void => {
76+
if (seenIdsRef.current !== null) {
77+
seenIdsRef.current.add(fieldId)
6478
}
6579
}, [])
6680

6781
useEffect(() => {
82+
// Bridge swap is the only event that invalidates the seen set; the
83+
// ids belong to a different document context. Tool changes, cursor
84+
// re-entry, and isReady transitions do NOT reset — see the file header
85+
// for why persistence drives the post-stream reconciliation.
86+
if (lastBridgeRef.current !== bridge) {
87+
seenIdsRef.current = null
88+
lastBridgeRef.current = bridge
89+
}
90+
6891
const gatesOpen = bridge !== null && isReady && toolbarTool !== null && isCursorOverEditor
6992
if (!gatesOpen) {
70-
baselineRef.current = null
7193
return
7294
}
73-
// Reset on every re-entry (tool change, cursor re-entry, bridge swap).
74-
// Prevents a stale baseline from a previous session from mis-attributing
75-
// a held delta to the current tool.
76-
baselineRef.current = null
7795
let cancelled = false
7896
let timeoutId: ReturnType<typeof setTimeout> | null = null
7997
const poll = async (): Promise<void> => {
8098
if (isStreamingRef.current) {
8199
// No iframe traffic during a stream; the first post-stream tick
82-
// will see whatever count is current and re-baseline / fire.
100+
// will see whatever's currently in the editor and diff against
101+
// the seen set.
83102
return
84103
}
85104
const result = await bridge.getFields()
86105
if (cancelled || !result.success) {
87106
return
88107
}
89-
const count = result.data.fields.length
90-
if (baselineRef.current === null) {
91-
baselineRef.current = count
92-
return
93-
}
94-
if (count <= baselineRef.current) {
108+
const currentFields = result.data.fields
109+
const currentIds = new Set(currentFields.map((field) => field.field_id))
110+
if (seenIdsRef.current === null) {
111+
seenIdsRef.current = currentIds
95112
return
96113
}
114+
const seen = seenIdsRef.current
115+
const addedFields = currentFields.filter((field) => !seen.has(field.field_id))
97116
if (isStreamingRef.current) {
98117
// Race-safety: if the stream started between the top of poll and
99-
// the getFields resolve, hold the baseline for the next tick.
118+
// the getFields resolve, hold the seen set for the next tick.
100119
return
101120
}
102-
const delta = count - baselineRef.current
103-
baselineRef.current = count
104-
// One-shot: the loop cancels after firing. A new cycle arms when a
105-
// gate flips (cursor out / in, tool switch, etc.).
106-
cancelled = true
107-
onFieldAddedRef.current({ tool: toolbarTool, delta })
121+
// Always refresh the seen set to the live id set. Adds get reported,
122+
// deletes get pruned automatically — no stale ids accumulating over
123+
// a long session, no drift from "what's currently in the editor".
124+
seenIdsRef.current = currentIds
125+
if (addedFields.length === 0) {
126+
return
127+
}
128+
const tools = addedFields.map((field) => field.type)
129+
onFieldAddedRef.current({ tools, delta: tools.length })
108130
}
109131
const pollLoop = async (): Promise<void> => {
110132
await poll()
@@ -124,5 +146,5 @@ export const useDetectUserAddedField = ({
124146
}
125147
}, [bridge, isReady, toolbarTool, isCursorOverEditor, isStreamingRef, onFieldAddedRef])
126148

127-
return { advanceBaseline }
149+
return { markFieldAsKnown }
128150
}

copilot/src/lib/byok/transport.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { convertToModelMessages, streamText, type UIMessage } from 'ai'
22
import { buildSystemPrompt } from '../../server/tools'
33
import {
4+
DeletePageInput,
45
DetectFieldsInput,
56
FINALISATION_ACTION,
67
FocusFieldInput,
78
GetDocumentContentInput,
89
GetFieldsInput,
910
GoToPageInput,
11+
MovePageInput,
12+
RemoveFieldsInput,
13+
RotatePageInput,
1014
SelectToolInput,
1115
SetFieldValueInput,
1216
withFinalisationTool,
@@ -103,6 +107,11 @@ export const runByokStream = async ({ config, init }: RunByokStreamArgs): Promis
103107
'Asks the editor to auto-detect and create missing fields. Call this when get_fields returned 0 fields.',
104108
inputSchema: DetectFieldsInput,
105109
},
110+
remove_fields: {
111+
description:
112+
'Removes fields from the document. field_ids targets specific fields by id; page targets a single page (1-indexed); both omitted clears all fields. Destructive — only call when the user explicitly asks to remove fields.',
113+
inputSchema: RemoveFieldsInput,
114+
},
106115
select_tool: {
107116
description:
108117
'Switches the editor tool (TEXT, BOXED_TEXT, CHECKBOX, SIGNATURE, PICTURE, or null for cursor).',
@@ -120,6 +129,21 @@ export const runByokStream = async ({ config, init }: RunByokStreamArgs): Promis
120129
description: 'Scrolls the editor to a given 1-based page.',
121130
inputSchema: GoToPageInput,
122131
},
132+
move_page: {
133+
description:
134+
'Reorders pages: from_page and to_page are 1-indexed visible page positions. Destructive — only call when the user explicitly asks to reorder a page.',
135+
inputSchema: MovePageInput,
136+
},
137+
delete_page: {
138+
description:
139+
'Permanently removes a visible page (1-indexed) and any fields placed on it. The last remaining page cannot be deleted. Destructive — only call when the user explicitly asks to delete a page.',
140+
inputSchema: DeletePageInput,
141+
},
142+
rotate_page: {
143+
description:
144+
'Rotates a visible page (1-indexed) 90° clockwise per call (repeat for 180° / 270°). Destructive — only call when the user explicitly asks to rotate a page.',
145+
inputSchema: RotatePageInput,
146+
},
123147
}),
124148
onError: ({ error }) => {
125149
monitoring.error('byok.stream_error', { detail: normalizeError(error) })

copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,26 @@ export const dispatch = async (
3131
}
3232
case 'detect_fields':
3333
return bridge.detectFields()
34+
case 'remove_fields': {
35+
const rawIds = input.field_ids
36+
const fieldIds = ((): string[] | null | 'invalid' => {
37+
if (rawIds === undefined || rawIds === null) {
38+
return null
39+
}
40+
if (Array.isArray(rawIds) && rawIds.every((id): id is string => typeof id === 'string')) {
41+
return rawIds
42+
}
43+
return 'invalid'
44+
})()
45+
if (fieldIds === 'invalid') {
46+
return {
47+
success: false,
48+
error: { code: 'bad_input', message: 'field_ids must be an array of strings' },
49+
}
50+
}
51+
const page = typeof input.page === 'number' ? input.page : null
52+
return bridge.removeFields({ fieldIds, page })
53+
}
3454
case 'select_tool': {
3555
const rawTool = input.tool
3656
if (rawTool !== undefined && !isSelectableTool(rawTool)) {

copilot/src/lib/embed-bridge-adapters/client-tools/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ export { composeMiddleware } from './middleware'
99
export type { ClientToolName } from './schemas'
1010
export {
1111
CLIENT_TOOL_SCHEMAS,
12+
DeletePageInput,
1213
DetectFieldsInput,
1314
DownloadInput,
1415
FocusFieldInput,
1516
GetDocumentContentInput,
1617
GetFieldsInput,
1718
GoToPageInput,
1819
isClientToolName,
20+
MovePageInput,
21+
RemoveFieldsInput,
22+
RotatePageInput,
1923
SelectToolInput,
2024
SetFieldValueInput,
2125
SubmitInput,

copilot/src/lib/embed-bridge-adapters/client-tools/schemas.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ export const DetectFieldsInput = z
1818
'Asks the editor to auto-detect and create fields on the document. Use when get_fields returned 0 fields before asking the user to add fields manually.',
1919
)
2020

21+
export const RemoveFieldsInput = z
22+
.object({
23+
field_ids: z.array(z.string()).optional().describe('Specific field identifiers to remove (omit to target by page or all)'),
24+
page: z.number().int().positive().optional().describe('1-indexed visible page to clear (omit to target specific ids or all)'),
25+
})
26+
.describe(
27+
'Removes fields from the document. Pass field_ids to remove specific fields, page to clear a single page, or both omitted to remove every field. Destructive: only call when the user explicitly asks.',
28+
)
29+
2130
// Aligned with the bridge's SupportedFieldType. The LLM may pick any of
2231
// the five tool variants + null (cursor); the host UI mirrors the same
2332
// five in its toolbar.
@@ -89,6 +98,7 @@ export const CLIENT_TOOL_NAMES = [
8998
'get_fields',
9099
'get_document_content',
91100
'detect_fields',
101+
'remove_fields',
92102
'select_tool',
93103
'set_field_value',
94104
'focus_field',
@@ -112,6 +122,7 @@ export const CLIENT_TOOL_SCHEMAS = {
112122
get_fields: GetFieldsInput,
113123
get_document_content: GetDocumentContentInput,
114124
detect_fields: DetectFieldsInput,
125+
remove_fields: RemoveFieldsInput,
115126
select_tool: SelectToolInput,
116127
set_field_value: SetFieldValueInput,
117128
focus_field: FocusFieldInput,

copilot/src/locales/ar.json

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,18 @@
112112
"stop": "إيقاف",
113113
"thinking": "جارٍ التفكير…",
114114
"piiWarning": "عرض توضيحي عام. استخدم بيانات تجريبية فقط. تُعالج الرسائل بواسطة مزود الذكاء الاصطناعي المحدد.",
115-
"newFieldHint": "حقل {{field}} جديد أضافه المستخدم",
115+
"newFieldHint_zero": "لم يضف المستخدم أي حقل {{field}}",
116+
"newFieldHint_one": "حقل {{field}} جديد أضافه المستخدم",
117+
"newFieldHint_two": "حقلا {{field}} جديدان أضافهما المستخدم",
118+
"newFieldHint_few": "{{count}} حقول {{field}} جديدة أضافها المستخدم",
119+
"newFieldHint_many": "{{count}} حقلًا {{field}} جديدًا أضافها المستخدم",
120+
"newFieldHint_other": "{{count}} حقل {{field}} جديد أضافها المستخدم",
121+
"newFieldsHint_zero": "لم يضف المستخدم أي حقول جديدة",
122+
"newFieldsHint_one": "حقل جديد أضافه المستخدم",
123+
"newFieldsHint_two": "حقلان جديدان أضافهما المستخدم",
124+
"newFieldsHint_few": "{{count}} حقول جديدة أضافها المستخدم",
125+
"newFieldsHint_many": "{{count}} حقلًا جديدًا أضافها المستخدم",
126+
"newFieldsHint_other": "{{count}} حقل جديد أضافها المستخدم",
116127
"errorAuthTitle": "فشل التحقق من الهوية.",
117128
"errorAuthBody": "رفض مزود الذكاء الاصطناعي مفتاح API الخاص بك. <switchModel>بدّل نموذج الذكاء الاصطناعي</switchModel> للتحقق من المفتاح، ثم حاول مرة أخرى.",
118129
"errorRateLimitedTitle": "لقد وصلت إلى حد العرض التوضيحي",
@@ -198,7 +209,11 @@
198209
"select_tool": "جارٍ تبديل الأداة",
199210
"create_field": "جارٍ إنشاء حقل",
200211
"remove_fields": "جارٍ إزالة الحقول",
201-
"submit_download": "جارٍ تحضير التنزيل"
212+
"move_page": "جارٍ نقل الصفحة",
213+
"delete_page": "جارٍ حذف الصفحة",
214+
"rotate_page": "جارٍ تدوير الصفحة",
215+
"submit": "جارٍ إرسال النموذج",
216+
"download": "جارٍ تحضير التنزيل"
202217
}
203218
},
204219
"forms": {

copilot/src/locales/cs.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,12 @@
112112
"stop": "Zastavit",
113113
"thinking": "Přemýšlím…",
114114
"piiWarning": "Veřejné demo. Používejte pouze ukázková data. Zprávy zpracovává zvolený AI poskytovatel.",
115-
"newFieldHint": "Nové pole typu {{field}} přidané uživatelem",
115+
"newFieldHint_one": "Nové pole typu {{field}} přidané uživatelem",
116+
"newFieldHint_few": "{{count}} nová pole typu {{field}} přidaná uživatelem",
117+
"newFieldHint_other": "{{count}} nových polí typu {{field}} přidaných uživatelem",
118+
"newFieldsHint_one": "Nové pole přidané uživatelem",
119+
"newFieldsHint_few": "{{count}} nová pole přidaná uživatelem",
120+
"newFieldsHint_other": "{{count}} nových polí přidaných uživatelem",
116121
"errorAuthTitle": "Ověření selhalo.",
117122
"errorAuthBody": "AI poskytovatel odmítl váš API klíč. <switchModel>Přepněte AI model</switchModel>, ověřte klíč a zkuste to znovu.",
118123
"errorRateLimitedTitle": "Dosáhli jste limitu dema",
@@ -195,7 +200,11 @@
195200
"select_tool": "Přepínám nástroj",
196201
"create_field": "Vytvářím pole",
197202
"remove_fields": "Odstraňuji pole",
198-
"submit_download": "Připravuji ke stažení"
203+
"move_page": "Přesouvám stránku",
204+
"delete_page": "Mažu stránku",
205+
"rotate_page": "Otáčím stránku",
206+
"submit": "Odesílám formulář",
207+
"download": "Připravuji ke stažení"
199208
}
200209
},
201210
"forms": {

copilot/src/locales/da.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@
112112
"stop": "Stop",
113113
"thinking": "Tænker…",
114114
"piiWarning": "Offentlig demo. Brug kun eksempeldata. Beskeder behandles af den valgte AI-udbyder.",
115-
"newFieldHint": "Nyt {{field}}-felt tilføjet af brugeren",
115+
"newFieldHint_one": "Nyt {{field}}-felt tilføjet af brugeren",
116+
"newFieldHint_other": "{{count}} nye {{field}}-felter tilføjet af brugeren",
117+
"newFieldsHint_one": "Nyt felt tilføjet af brugeren",
118+
"newFieldsHint_other": "{{count}} nye felter tilføjet af brugeren",
116119
"errorAuthTitle": "Godkendelse mislykkedes.",
117120
"errorAuthBody": "AI-udbyderen afviste din API-nøgle. <switchModel>Skift AI-model</switchModel> for at verificere nøglen, og prøv igen.",
118121
"errorRateLimitedTitle": "Du har nået demo-grænsen",
@@ -194,7 +197,11 @@
194197
"select_tool": "Skifter værktøj",
195198
"create_field": "Opretter et felt",
196199
"remove_fields": "Fjerner felter",
197-
"submit_download": "Forbereder download"
200+
"move_page": "Flytter siden",
201+
"delete_page": "Sletter siden",
202+
"rotate_page": "Roterer siden",
203+
"submit": "Indsender formularen",
204+
"download": "Forbereder download"
198205
}
199206
},
200207
"forms": {

0 commit comments

Comments
 (0)