@sanity/assist 4.2.0 → 4.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/assist",
3
- "version": "4.2.0",
3
+ "version": "4.3.1",
4
4
  "description": "You create the instructions; Sanity AI Assist does the rest.",
5
5
  "keywords": [
6
6
  "sanity",
@@ -51,7 +51,7 @@
51
51
  "dependencies": {
52
52
  "@sanity/icons": "^3.5.2",
53
53
  "@sanity/incompatible-plugin": "^1.0.4",
54
- "@sanity/ui": "^2.10.9",
54
+ "@sanity/ui": "^2.15.18",
55
55
  "date-fns": "^3.6.0",
56
56
  "lodash": "^4.17.21",
57
57
  "lodash-es": "^4.17.21",
@@ -63,9 +63,9 @@
63
63
  "@commitlint/cli": "^19.2.1",
64
64
  "@commitlint/config-conventional": "^19.1.0",
65
65
  "@rollup/plugin-image": "^3.0.3",
66
- "@sanity/pkg-utils": "^6.1.0",
66
+ "@sanity/pkg-utils": "^6.13.4",
67
67
  "@sanity/plugin-kit": "^3.1.10",
68
- "@sanity/schema": "^3.81.0",
68
+ "@sanity/schema": "^3.91.0",
69
69
  "@sanity/semantic-release-preset": "^4.1.7",
70
70
  "@types/lodash": "^4.17.0",
71
71
  "@types/lodash-es": "^4.17.12",
@@ -83,11 +83,11 @@
83
83
  "react": "^18.2.0",
84
84
  "react-dom": "^18.2.0",
85
85
  "rimraf": "^5.0.5",
86
- "sanity": "^3.81.0",
86
+ "sanity": "^3.91.0",
87
87
  "semantic-release": "^23.0.8",
88
88
  "styled-components": "^6.1.16",
89
89
  "typescript": "^5.7.2",
90
- "vitest": "^1.4.0"
90
+ "vitest": "^3.1.4"
91
91
  },
92
92
  "peerDependencies": {
93
93
  "@sanity/mutator": "^3.36.4",
@@ -96,9 +96,10 @@
96
96
  "styled-components": "^6.1"
97
97
  },
98
98
  "engines": {
99
- "node": ">=14"
99
+ "node": ">=20"
100
100
  },
101
101
  "publishConfig": {
102
102
  "access": "public"
103
- }
103
+ },
104
+ "browserslist": "extends @sanity/browserslist-config"
104
105
  }
@@ -1,7 +1,7 @@
1
1
  import {createContext, useContext} from 'react'
2
2
  import {DocumentInspector, ObjectSchemaType, PatchEvent} from 'sanity'
3
3
 
4
- import {StudioAssistDocument} from '../types'
4
+ import {InstructionTask, StudioAssistDocument} from '../types'
5
5
 
6
6
  export type AssistDocumentContextValue = (
7
7
  | {assistDocument: StudioAssistDocument; loading: false}
@@ -19,6 +19,19 @@ export type AssistDocumentContextValue = (
19
19
  inspector: DocumentInspector | null
20
20
  selectedPath?: string
21
21
  documentOnChange: (event: PatchEvent) => void
22
+
23
+ /**
24
+ * Synthetic task is used to display AI presence at the document level for the user who started the action.
25
+ * These are not persisted, so other users will not see them.
26
+ * It is mostly a helper to give _some_ visual feedback to the user while a custom action is running.
27
+ * This also means that reloading the page will remove the icon.
28
+ *
29
+ * Agent Actions add their own "real" tasks, so if a custom action calls an Agent action, _those_ tasks
30
+ * are visible across sessions.
31
+ */
32
+ syntheticTasks?: InstructionTask[]
33
+ addSyntheticTask: (syntheticTask: InstructionTask) => void
34
+ removeSyntheticTask: (syntheticTask: InstructionTask) => void
22
35
  }
23
36
 
24
37
  export const AssistDocumentContext = createContext<AssistDocumentContextValue | undefined>(
@@ -1,9 +1,9 @@
1
- import {useMemo} from 'react'
1
+ import {useCallback, useEffect, useMemo, useState} from 'react'
2
2
  import {getDraftId, getVersionId, type ObjectSchemaType, useSchema} from 'sanity'
3
3
  import {useDocumentPane} from 'sanity/structure'
4
4
 
5
5
  import {useAiPaneRouter} from '../../assistInspector/helpers'
6
- import {fieldPathParam} from '../../types'
6
+ import {fieldPathParam, InstructionTask} from '../../types'
7
7
  import type {AssistDocumentContextValue} from '../AssistDocumentContext'
8
8
  import {isDocAssistable} from '../RequestRunInstructionProvider'
9
9
  import {useStudioAssistDocument} from './useStudioAssistDocument'
@@ -51,7 +51,8 @@ export function useAssistDocumentContextValue(documentId: string, documentType:
51
51
  documentId: assistableDocumentId,
52
52
  schemaType: documentSchemaType,
53
53
  })
54
-
54
+ const {syntheticTasks, addSyntheticTask, removeSyntheticTask} =
55
+ useSyntheticTasks(assistableDocumentId)
55
56
  const value: AssistDocumentContextValue = useMemo(() => {
56
57
  const base = {
57
58
  assistableDocumentId,
@@ -63,6 +64,9 @@ export function useAssistDocumentContextValue(documentId: string, documentType:
63
64
  inspector,
64
65
  documentOnChange,
65
66
  selectedPath,
67
+ syntheticTasks,
68
+ addSyntheticTask,
69
+ removeSyntheticTask,
66
70
  }
67
71
  if (!assistDocument) {
68
72
  return {...base, loading: true, assistDocument: undefined}
@@ -83,7 +87,30 @@ export function useAssistDocumentContextValue(documentId: string, documentType:
83
87
  inspector,
84
88
  documentOnChange,
85
89
  selectedPath,
90
+ syntheticTasks,
91
+ addSyntheticTask,
92
+ removeSyntheticTask,
86
93
  ])
87
94
 
88
95
  return value
89
96
  }
97
+
98
+ function useSyntheticTasks(assistableDocumentId: string) {
99
+ const [syntheticTasks, setSyntheticTasks] = useState<InstructionTask[]>(() => [])
100
+ const addSyntheticTask = useCallback((task: InstructionTask) => {
101
+ setSyntheticTasks((current) => [...current, task])
102
+ }, [])
103
+ const removeSyntheticTask = useCallback((task: InstructionTask) => {
104
+ setSyntheticTasks((current) => current.filter((t) => task._key !== t._key))
105
+ }, [])
106
+
107
+ useEffect(() => {
108
+ setSyntheticTasks([])
109
+ }, [assistableDocumentId])
110
+
111
+ return {
112
+ syntheticTasks,
113
+ addSyntheticTask,
114
+ removeSyntheticTask,
115
+ }
116
+ }
@@ -1,5 +1,13 @@
1
1
  import {PlayIcon} from '@sanity/icons'
2
2
  import {Button, Dialog, Flex, Stack, Text, TextArea, Tooltip} from '@sanity/ui'
3
+ import {FormFieldHeaderText} from 'sanity'
4
+
5
+ import {getInstructionTitle} from '../helpers/misc'
6
+ import {type UserInputBlock, userInputTypeName} from '../types'
7
+ import {useApiClient, useRunInstructionApi} from '../useApiClient'
8
+ import {useAiAssistanceConfig} from './AiAssistanceConfigContext'
9
+ import type {RunInstructionArgs} from './AssistLayout'
10
+ import {CustomInputResult, GetUserInput} from '../fieldActions/useUserInput'
3
11
  import {
4
12
  createContext,
5
13
  type Dispatch,
@@ -14,24 +22,19 @@ import {
14
22
  useRef,
15
23
  useState,
16
24
  } from 'react'
17
- import {FormFieldHeaderText} from 'sanity'
18
-
19
- import {getInstructionTitle} from '../helpers/misc'
20
- import {type UserInputBlock, userInputTypeName} from '../types'
21
- import {useApiClient, useRunInstructionApi} from '../useApiClient'
22
- import {useAiAssistanceConfig} from './AiAssistanceConfigContext'
23
- import type {RunInstructionArgs} from './AssistLayout'
24
25
 
25
26
  type BlockInputs = Record<string, string>
26
27
  const NO_INPUT: BlockInputs = {}
27
28
 
28
29
  export interface RunInstructionContextValue {
29
30
  runInstruction: (req: RunInstructionArgs) => void
31
+ getUserInput: GetUserInput
30
32
  instructionLoading: boolean
31
33
  }
32
34
 
33
35
  export const RunInstructionContext = createContext<RunInstructionContextValue>({
34
36
  runInstruction: () => {},
37
+ getUserInput: async () => undefined,
35
38
  instructionLoading: false,
36
39
  })
37
40
 
@@ -53,9 +56,34 @@ export function RunInstructionProvider(props: PropsWithChildren<{}>) {
53
56
 
54
57
  const [inputs, setInputs] = useState(NO_INPUT)
55
58
  const [runRequest, setRunRequest] = useState<
56
- (RunInstructionArgs & {userInputBlocks: UserInputBlock[]}) | undefined
59
+ | (RunInstructionArgs & {userInputBlocks: UserInputBlock[]})
60
+ | {dialogTitle: string; userInputBlocks: UserInputBlock[]}
61
+ | undefined
57
62
  >()
58
63
 
64
+ const [resolveUserInput, setResolveUserInput] =
65
+ useState<
66
+ (
67
+ value: CustomInputResult[] | PromiseLike<CustomInputResult[] | undefined> | undefined,
68
+ ) => void
69
+ >()
70
+
71
+ const getUserInput: GetUserInput = useCallback(async ({title, inputs}) => {
72
+ const userInputBlocks: UserInputBlock[] = inputs.map((input, i) => ({
73
+ _type: userInputTypeName,
74
+ _key: input.id ?? `${i}`,
75
+ message: input.title,
76
+ description: input.description,
77
+ }))
78
+ if (!userInputBlocks.length) {
79
+ return undefined
80
+ }
81
+ setRunRequest({dialogTitle: title, userInputBlocks})
82
+ return new Promise<CustomInputResult[] | undefined>((resolve) => {
83
+ setResolveUserInput(() => resolve)
84
+ })
85
+ }, [])
86
+
59
87
  const runInstruction = useCallback(
60
88
  (req: RunInstructionArgs) => {
61
89
  if (loading) {
@@ -89,23 +117,43 @@ export function RunInstructionProvider(props: PropsWithChildren<{}>) {
89
117
  const close = useCallback(() => {
90
118
  setRunRequest(undefined)
91
119
  setInputs(NO_INPUT)
92
- }, [])
120
+ if (resolveUserInput) {
121
+ resolveUserInput(undefined)
122
+ }
123
+ setResolveUserInput(undefined)
124
+ }, [resolveUserInput])
93
125
 
94
126
  const runWithInput = useCallback(() => {
95
127
  if (runRequest) {
96
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
97
- const {instruction, userTexts, ...request} = runRequest
98
- runInstructionRequest({
99
- ...request,
100
- instructionKey: instruction._key,
101
- userTexts: Object.entries(inputs).map(([key, value]) => ({
102
- blockKey: key,
103
- userInput: value,
104
- })),
105
- })
128
+ if ('instruction' in runRequest) {
129
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
130
+ const {instruction, userTexts, ...request} = runRequest
131
+ runInstructionRequest({
132
+ ...request,
133
+ instructionKey: instruction._key,
134
+ userTexts: Object.entries(inputs).map(([key, value]) => ({
135
+ blockKey: key,
136
+ userInput: value,
137
+ })),
138
+ })
139
+ } else {
140
+ const userInputs = Object.values(inputs).map((input, i) => {
141
+ const userInputBlock = runRequest.userInputBlocks[i]
142
+ return {
143
+ input: {
144
+ id: userInputBlock._key,
145
+ title: userInputBlock.message ?? '',
146
+ description: userInputBlock.description,
147
+ },
148
+ result: input,
149
+ }
150
+ })
151
+ resolveUserInput?.(userInputs)
152
+ setResolveUserInput(undefined)
153
+ }
106
154
  }
107
155
  close()
108
- }, [close, runInstructionRequest, runRequest, inputs])
156
+ }, [close, runInstructionRequest, runRequest, inputs, resolveUserInput])
109
157
 
110
158
  const open = !!runRequest
111
159
 
@@ -128,7 +176,7 @@ export function RunInstructionProvider(props: PropsWithChildren<{}>) {
128
176
  )
129
177
 
130
178
  const contextValue: RunInstructionContextValue = useMemo(
131
- () => ({runInstruction, instructionLoading: loading}),
179
+ () => ({runInstruction, getUserInput, instructionLoading: loading}),
132
180
  [runInstruction, loading],
133
181
  )
134
182
 
@@ -140,7 +188,11 @@ export function RunInstructionProvider(props: PropsWithChildren<{}>) {
140
188
  open={open}
141
189
  onClose={close}
142
190
  width={1}
143
- header={getInstructionTitle(runRequest?.instruction)}
191
+ header={
192
+ 'dialogTitle' in runRequest
193
+ ? runRequest.dialogTitle
194
+ : getInstructionTitle(runRequest?.instruction)
195
+ }
144
196
  footer={
145
197
  <Flex justify="space-between" padding={2} flex={1}>
146
198
  {runDisabled ? (
@@ -178,7 +230,7 @@ export function RunInstructionProvider(props: PropsWithChildren<{}>) {
178
230
  )
179
231
  }
180
232
 
181
- function UserInput(props: {
233
+ export function UserInput(props: {
182
234
  block: UserInputBlock
183
235
  inputs: BlockInputs
184
236
  setInputs: Dispatch<SetStateAction<BlockInputs>>
@@ -4,6 +4,7 @@ import {
4
4
  type DocumentFieldAction,
5
5
  type DocumentFieldActionGroup,
6
6
  type DocumentFieldActionItem,
7
+ stringToPath,
7
8
  typed,
8
9
  useCurrentUser,
9
10
  } from 'sanity'
@@ -24,6 +25,8 @@ import {documentRootKey, fieldPathParam, instructionParam, type StudioInstructio
24
25
  import {generateCaptionsActions} from './generateCaptionActions'
25
26
  import {generateImagActions} from './generateImageActions'
26
27
  import {PrivateIcon} from './PrivateIcon'
28
+ import {AgentActionConditionalPath, useCustomFieldActions} from './customFieldActions'
29
+ import {AgentActionPath} from '@sanity/client/stega'
27
30
 
28
31
  function node(node: DocumentFieldActionItem | DocumentFieldActionGroup) {
29
32
  return node
@@ -48,6 +51,7 @@ export const assistFieldActions: DocumentFieldAction = {
48
51
  } = useAssistDocumentContext()
49
52
 
50
53
  const {value: docValue, formState} = useDocumentPane()
54
+ const docValueRef = useRef(docValue)
51
55
  const formStateRef = useRef(formState)
52
56
  formStateRef.current = formState
53
57
 
@@ -180,7 +184,35 @@ export const assistFieldActions: DocumentFieldAction = {
180
184
  imageGenAction,
181
185
  ])
182
186
 
183
- const instructionsLength = instructions?.length || 0
187
+ const getDocumentValue = useCallback(() => {
188
+ return docValueRef.current
189
+ }, [])
190
+
191
+ const getConditionalPaths: () => AgentActionConditionalPath[] = useCallback(() => {
192
+ return (formStateRef.current ? getConditionalMembers(formStateRef.current) : []).flatMap(
193
+ (cm) => {
194
+ const path = stringToPath(cm.path)
195
+ if (path.some((s) => typeof s === 'number')) {
196
+ //agent actions does not support indexed paths
197
+ return []
198
+ }
199
+ return {
200
+ ...cm,
201
+ path: path as AgentActionPath,
202
+ }
203
+ },
204
+ )
205
+ }, [])
206
+
207
+ const customActions = useCustomFieldActions({
208
+ actionType: props.path.length ? 'field' : 'document',
209
+ documentIdForAction: assistableDocumentId,
210
+ schemaType,
211
+ documentSchemaType,
212
+ path: props.path,
213
+ getDocumentValue,
214
+ getConditionalPaths,
215
+ })
184
216
 
185
217
  const manageInstructionsItem = useMemo(
186
218
  () =>
@@ -203,6 +235,7 @@ export const assistFieldActions: DocumentFieldAction = {
203
235
  children: [
204
236
  runInstructionsGroup,
205
237
  translateAction,
238
+ ...customActions,
206
239
  assistSupported && manageInstructionsItem,
207
240
  ]
208
241
  .filter((c): c is DocumentFieldActionItem | DocumentFieldActionGroup => !!c)
@@ -219,6 +252,7 @@ export const assistFieldActions: DocumentFieldAction = {
219
252
  imageCaptionAction,
220
253
  translateAction,
221
254
  imageGenAction,
255
+ customActions,
222
256
  ],
223
257
  )
224
258
 
@@ -237,7 +271,13 @@ export const assistFieldActions: DocumentFieldAction = {
237
271
  )
238
272
 
239
273
  // If there are no instructions, we don't want to render the group
240
- if (instructionsLength === 0 && !imageCaptionAction && !translateAction && !imageGenAction) {
274
+ if (
275
+ !instructions?.length &&
276
+ !imageCaptionAction &&
277
+ !translateAction &&
278
+ !imageGenAction &&
279
+ !customActions.length
280
+ ) {
241
281
  return emptyAction
242
282
  }
243
283
 
@@ -0,0 +1,304 @@
1
+ import {
2
+ DocumentFieldActionDivider,
3
+ DocumentFieldActionGroup,
4
+ DocumentFieldActionItem,
5
+ DocumentFieldActionNode,
6
+ ObjectSchemaType,
7
+ SanityDocumentLike,
8
+ useWorkspaceSchemaId,
9
+ } from 'sanity'
10
+ import {Path, SchemaType} from '@sanity/types'
11
+ import {useMemo} from 'react'
12
+ import {useAiAssistanceConfig} from '../assistLayout/AiAssistanceConfigContext'
13
+ import {ToastParams, useToast} from '@sanity/ui'
14
+ import {AgentActionPath} from '@sanity/client/stega'
15
+ import {useAssistDocumentContext} from '../assistDocument/AssistDocumentContext'
16
+ import {
17
+ documentRootKey,
18
+ fieldPresenceTypeName,
19
+ InstructionTask,
20
+ instructionTaskTypeName,
21
+ } from '../types'
22
+ import {randomKey} from '../_lib/randomKey'
23
+
24
+ export interface AgentActionConditionalPath {
25
+ path: AgentActionPath
26
+ readOnly: boolean
27
+ hidden: boolean
28
+ }
29
+
30
+ export interface AssistFieldActionProps {
31
+ /**
32
+ * `actionType` will be `document` for action invoked from the top right document action menu, and
33
+ * `field` when invoked from a field action menu.
34
+ */
35
+ actionType: 'document' | 'field'
36
+ /**
37
+ * This is the id of the current document pane; it contains `drafts.`or `versions. prefix` ect depending on context.
38
+ * Use this for `documentId` when calling any `client.agent.action`.
39
+ *
40
+ * It is generally recommended to call actions from the studio like this:
41
+ * ```ts
42
+ * await client.agent.action.generate({
43
+ * targetDocument: {
44
+ * operation: 'createIfNotExists',
45
+ * _id: props.documentIdForAction,
46
+ * _type: props.documentSchemaType.name,
47
+ * initialValues: props.getDocumentValue()
48
+ * },
49
+ * //...
50
+ * })
51
+ * ```
52
+ */
53
+ documentIdForAction: string
54
+
55
+ /**
56
+ * Schema type of the current document.
57
+ * @see documentIdForAction
58
+ */
59
+ documentSchemaType: ObjectSchemaType
60
+
61
+ /**
62
+ * Returns the current document value.
63
+ *
64
+ * Prefer passing this function to your hooks instead of passing the document value directly to avoid unnecessary re-renders.
65
+ * @see documentIdForAction
66
+ */
67
+ getDocumentValue: () => SanityDocumentLike
68
+
69
+ /**
70
+ * Returns the current readOnly and hidden state of all conditional members in the current document form.
71
+ *
72
+ * Intended to be passed to agent actions `conditionalPaths.paths`.
73
+ */
74
+ getConditionalPaths: () => AgentActionConditionalPath[]
75
+
76
+ /**
77
+ * `schemaId` for the current workspace.
78
+ *
79
+ * Note: the workspace schema has to be deployed using `sanity schema deploy` or `sanity deploy`.
80
+ *
81
+ * Use this for `schemaId` when calling any `client.agent.action`.
82
+ *
83
+ * It is generally recommended to call actions from the studio like this:
84
+ * ```ts
85
+ * await client.agent.action.generate({
86
+ * targetDocument: {
87
+ * operation: 'createIfNotExists',
88
+ * _id: props.documentIdForAction,
89
+ * _type: props.documentSchemaType.name,
90
+ * initialValues: props.getDocumentValue()
91
+ * },
92
+ * //...
93
+ * })
94
+ */
95
+ schemaId: string
96
+
97
+ /**
98
+ * This is the schema type of the field the actions will be attached to (ie, schemaType for `path`)
99
+ *
100
+ * It can be used with agent actions using `target.path`, to scope the action to a specific field.
101
+ *
102
+ * It is generally recommended to call actions from the studio like this:
103
+ * ```ts
104
+ * await client.agent.action.generate({
105
+ * targetDocument: {
106
+ * operation: 'createIfNotExists',
107
+ * _id: props.documentIdForAction,
108
+ * _type: props.documentSchemaType.name,
109
+ * initialValues: props.getDocumentValue()
110
+ * },
111
+ * target: {
112
+ * path: props.path
113
+ * },
114
+ * })
115
+ * ```
116
+ */
117
+ path: AgentActionPath
118
+
119
+ /**
120
+ * This is the schema type of the field the actions will be attached to (ie, schemaType for `path`).
121
+ *
122
+ * Typically useful to dynamically return different actions based on the schema type of the field.
123
+ *
124
+ * ```ts
125
+ * if(isObjectSchemaType(schemaType)) {
126
+ * return [
127
+ * defineAssistFieldAction({
128
+ * title: 'Fill the object fields',
129
+ * icon: RobotIcon,
130
+ * onAction: () => {
131
+ * //...
132
+ * }
133
+ * })
134
+ * ]
135
+ * }
136
+ * return useMemo(() => {
137
+ *
138
+ *
139
+ * }, [])
140
+ *
141
+ * ```
142
+ */
143
+ schemaType: SchemaType
144
+ }
145
+
146
+ export type AssistFieldActionNode =
147
+ | AssistFieldActionItem
148
+ | AssistFieldActionGroup
149
+ | DocumentFieldActionDivider
150
+
151
+ export type AssistFieldActionItem = Omit<
152
+ DocumentFieldActionItem,
153
+ 'renderAsButton' | 'selected' | 'onAction'
154
+ > & {
155
+ onAction: () => void | Promise<void>
156
+ }
157
+
158
+ export type AssistFieldActionGroup = Omit<
159
+ DocumentFieldActionGroup,
160
+ 'renderAsButton' | 'expanded' | 'children'
161
+ > & {
162
+ children: AssistFieldActionNode[]
163
+ }
164
+
165
+ type PushToast = (params: ToastParams) => string
166
+
167
+ export function defineAssistFieldAction(
168
+ action: Omit<AssistFieldActionItem, 'type'>,
169
+ ): AssistFieldActionItem {
170
+ return {
171
+ ...action,
172
+ type: 'action',
173
+ }
174
+ }
175
+
176
+ export function defineFieldActionDivider(): DocumentFieldActionDivider {
177
+ return {
178
+ type: 'divider',
179
+ }
180
+ }
181
+
182
+ export function defineAssistFieldActionGroup(
183
+ group: Omit<AssistFieldActionGroup, 'type'>,
184
+ ): AssistFieldActionGroup {
185
+ return {
186
+ ...group,
187
+ type: 'group',
188
+ }
189
+ }
190
+
191
+ export function useCustomFieldActions(
192
+ props: Omit<AssistFieldActionProps, 'schemaId' | 'path'> & {path: Path},
193
+ ) {
194
+ const {
195
+ config: {fieldActions},
196
+ } = useAiAssistanceConfig()
197
+ const {addSyntheticTask, removeSyntheticTask} = useAssistDocumentContext()
198
+
199
+ const schemaId = useWorkspaceSchemaId()
200
+ const {push: pushToast} = useToast()
201
+ const configActions = fieldActions?.useFieldActions?.({
202
+ ...props,
203
+ schemaId,
204
+ path: props.path as AgentActionPath,
205
+ })
206
+
207
+ return useMemo(() => {
208
+ const title = fieldActions?.title
209
+ const customActions = configActions?.map((node) => {
210
+ return createSafeNode({
211
+ node,
212
+ pushToast,
213
+ addSyntheticTask,
214
+ removeSyntheticTask,
215
+ })
216
+ })
217
+ const onlyGroups =
218
+ customActions?.length && customActions?.every((node) => node.type === 'group')
219
+ const groups = customActions?.length
220
+ ? onlyGroups
221
+ ? customActions
222
+ : [
223
+ {
224
+ type: 'group',
225
+ title: title || 'Custom actions',
226
+ children: customActions,
227
+ expanded: true,
228
+ } satisfies DocumentFieldActionGroup,
229
+ ]
230
+ : []
231
+ return groups ?? []
232
+ }, [configActions, fieldActions, pushToast])
233
+ }
234
+
235
+ function createSafeNode(args: {
236
+ node: AssistFieldActionNode
237
+ pushToast: PushToast
238
+ addSyntheticTask: (task: InstructionTask) => void
239
+ removeSyntheticTask: (task: InstructionTask) => void
240
+ }): DocumentFieldActionNode {
241
+ const {node} = args
242
+ switch (node.type) {
243
+ case 'action':
244
+ return createSafeAction({...args, action: node})
245
+ case 'group':
246
+ return {
247
+ ...node,
248
+ renderAsButton: false,
249
+ expanded: true,
250
+ children: node.children?.map((child) => createSafeNode({...args, node: child})),
251
+ }
252
+ case 'divider':
253
+ default:
254
+ return node
255
+ }
256
+ }
257
+
258
+ function createSafeAction(args: {
259
+ action: AssistFieldActionItem
260
+ pushToast: PushToast
261
+ addSyntheticTask: (task: InstructionTask) => void
262
+ removeSyntheticTask: (task: InstructionTask) => void
263
+ }) {
264
+ const {action, pushToast, addSyntheticTask, removeSyntheticTask} = args
265
+ return {
266
+ ...action,
267
+ onAction: () => {
268
+ async function runAction() {
269
+ const task: InstructionTask = {
270
+ _type: instructionTaskTypeName,
271
+ _key: randomKey(12),
272
+ started: new Date().toISOString(),
273
+ presence: [
274
+ {
275
+ _type: fieldPresenceTypeName,
276
+ _key: randomKey(12),
277
+ path: documentRootKey,
278
+ started: new Date().toISOString(),
279
+ },
280
+ ],
281
+ }
282
+ try {
283
+ addSyntheticTask(task)
284
+ const actionResult = action.onAction?.()
285
+ if (actionResult instanceof Promise) {
286
+ await actionResult
287
+ }
288
+ } catch (err: any) {
289
+ console.error('Failed to execute action', action, err)
290
+ pushToast({
291
+ title: 'Failed to execute action',
292
+ description: err?.message,
293
+ status: 'error',
294
+ })
295
+ } finally {
296
+ removeSyntheticTask(task)
297
+ }
298
+ }
299
+ runAction()
300
+ },
301
+ renderAsButton: false,
302
+ selected: false,
303
+ }
304
+ }