@puredesktop/puredesktop-ui-bridge 2.1.7 → 2.1.9

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.
Files changed (119) hide show
  1. package/package.json +89 -5
  2. package/src/agents/mcpServerPresets.test.ts +105 -0
  3. package/src/agents/mcpServerPresets.ts +380 -0
  4. package/src/agents/runtime/index.ts +7 -0
  5. package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
  6. package/src/agents/runtime/pendingAsks.test.ts +117 -0
  7. package/src/agents/runtime/pendingAsks.ts +110 -0
  8. package/src/bridge/agentScopeDefaults.ts +65 -0
  9. package/src/bridge/agentTypes.ts +57 -3
  10. package/src/bridge/agents.d.mts +6 -0
  11. package/src/bridge/agents.mjs +10 -0
  12. package/src/bridge/agents.ts +4 -0
  13. package/src/bridge/collectionImagePaste.ts +19 -1
  14. package/src/bridge/context.mjs +32 -0
  15. package/src/bridge/credentials.d.mts +62 -0
  16. package/src/bridge/credentials.mjs +53 -0
  17. package/src/bridge/documents.d.mts +76 -0
  18. package/src/bridge/documents.mjs +40 -0
  19. package/src/bridge/events.d.mts +1 -0
  20. package/src/bridge/events.mjs +1 -0
  21. package/src/bridge/googleAuth.ts +7 -152
  22. package/src/bridge/mcpClient.d.mts +74 -0
  23. package/src/bridge/mcpClient.mjs +79 -0
  24. package/src/bridge/methods.d.mts +45 -0
  25. package/src/bridge/methods.mjs +46 -0
  26. package/src/bridge/notifications.d.mts +12 -0
  27. package/src/bridge/notifications.mjs +36 -0
  28. package/src/bridge/operations.d.mts +44 -0
  29. package/src/bridge/operations.mjs +40 -0
  30. package/src/bridge/pureRender/base.css +44 -0
  31. package/src/bridge/pureRender/document.ts +29 -2
  32. package/src/bridge/pureRender/index.ts +3 -0
  33. package/src/bridge/pureRender/normalize.ts +167 -0
  34. package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
  35. package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
  36. package/src/bridge/pureRender/styles.ts +125 -5
  37. package/src/bridge/pureRender/theme.test.ts +259 -0
  38. package/src/bridge/pureRender/theme.ts +19 -1
  39. package/src/bridge/pureRender/types.ts +33 -0
  40. package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
  41. package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
  42. package/src/bridge/react/useDocumentHotkeys.ts +41 -0
  43. package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
  44. package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
  45. package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
  46. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
  47. package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
  48. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
  49. package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
  50. package/src/bridge/react/usePlatformOperations.tsx +75 -0
  51. package/src/bridge/storage.d.mts +23 -10
  52. package/src/bridge/storage.mjs +6 -3
  53. package/src/bridge/storage.test.ts +6 -6
  54. package/src/bridge/types.ts +7 -0
  55. package/src/components/agents/AgentComposer.tsx +10 -6
  56. package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
  57. package/src/components/agents/AgentDrawerPanel.tsx +23 -62
  58. package/src/components/agents/AgentMessageBubble.tsx +59 -33
  59. package/src/components/agents/AgentMessageList.tsx +4 -4
  60. package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
  61. package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
  62. package/src/components/agents/AgentToolCallCard.tsx +20 -20
  63. package/src/components/agents/AgentToolPendingList.tsx +6 -6
  64. package/src/components/agents/AgentToolPendingReview.tsx +6 -6
  65. package/src/components/agents/ContextPicker.tsx +239 -0
  66. package/src/components/agents/QuestionRequestList.tsx +278 -0
  67. package/src/components/agents/agentPanelStyles.ts +8 -31
  68. package/src/components/agents/agentTypes.ts +25 -41
  69. package/src/components/agents/index.ts +4 -0
  70. package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
  71. package/src/components/chrome/WorkspaceTabStrip.tsx +22 -4
  72. package/src/components/chrome/workspaceTabTypes.ts +2 -0
  73. package/src/components/common/chat/ChatBox.tsx +9 -9
  74. package/src/components/common/chat/ChatModelMenu.tsx +34 -4
  75. package/src/components/common/chat/ChatThread.tsx +23 -7
  76. package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
  77. package/src/components/common/connections/ProviderConnection.tsx +277 -0
  78. package/src/components/common/connections/index.ts +5 -0
  79. package/src/components/common/containers/AppFrame.test.tsx +4 -4
  80. package/src/components/common/containers/AppFrame.tsx +26 -10
  81. package/src/components/common/containers/AppHeader.tsx +37 -15
  82. package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
  83. package/src/components/common/documents/DocumentSwitcher.tsx +1181 -0
  84. package/src/components/common/documents/index.ts +8 -0
  85. package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
  86. package/src/components/common/dropdown/MenuPortal.tsx +3 -1
  87. package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
  88. package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
  89. package/src/components/common/overlays/Modal.tsx +13 -2
  90. package/src/components/common/research/EvidenceDossier.tsx +1232 -150
  91. package/src/components/common/research/index.ts +2 -0
  92. package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
  93. package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
  94. package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
  95. package/src/components/print-design/index.ts +2 -0
  96. package/src/components/print-design/previewContent.test.ts +32 -0
  97. package/src/components/print-design/previewContent.ts +31 -0
  98. package/src/context/buildContextDocument.test.ts +240 -0
  99. package/src/context/buildContextDocument.ts +309 -0
  100. package/src/context/contextAccess.ts +66 -0
  101. package/src/context/contextConfig.ts +72 -0
  102. package/src/context/contextDocument.test.ts +155 -0
  103. package/src/context/contextDocument.ts +300 -0
  104. package/src/context/contextSections.test.ts +88 -0
  105. package/src/context/contextSections.ts +84 -0
  106. package/src/context/htmlToContextMarkdown.test.ts +134 -0
  107. package/src/context/htmlToContextMarkdown.ts +369 -0
  108. package/src/ics/generateIcs.test.ts +153 -0
  109. package/src/ics/generateIcs.ts +197 -0
  110. package/src/index.ts +3 -0
  111. package/src/net/httpRetry.test.ts +117 -0
  112. package/src/net/httpRetry.ts +111 -0
  113. package/src/theme/appAccents.test.ts +36 -35
  114. package/src/theme/appAccents.ts +93 -104
  115. package/src/theme/appIdentityCss.mjs +178 -229
  116. package/src/theme/appIdentityCss.ts +222 -231
  117. package/src/theme/themes/dark.ts +3 -1
  118. package/src/theme/themes/light.ts +3 -1
  119. package/src/bridge/googleProviderConfig.ts +0 -73
@@ -0,0 +1,359 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import {
3
+ autosavePlatformDocument,
4
+ createPlatformDraft,
5
+ duplicatePlatformDocument,
6
+ promotePlatformDocument,
7
+ renamePlatformDocument,
8
+ touchPlatformRecentDocument,
9
+ type PlatformDocumentFilePayload,
10
+ } from '../documents.mjs'
11
+ import { bridge } from '../client.mjs'
12
+ import { PLATFORM_BRIDGE_METHODS } from '../methods.mjs'
13
+
14
+ export type DocumentLifecycleStatus = 'none' | 'draft' | 'filed'
15
+
16
+ export interface DocumentLifecycleDoc {
17
+ path: string | null
18
+ title: string
19
+ status: DocumentLifecycleStatus
20
+ savedAt: string | null
21
+ saving: boolean
22
+ error: string | null
23
+ }
24
+
25
+ export interface UseDocumentLifecycleOptions {
26
+ appSlug: string
27
+ /** Document suffix, e.g. `.research`. */
28
+ suffix: string
29
+ kind: 'package' | 'file'
30
+ /** Called at flush time; returns the document's current file contents. */
31
+ serialize: () => PlatformDocumentFilePayload[]
32
+ /** Prefill for the naming dialog (e.g. an AI-generated title). */
33
+ suggestedTitle?: string
34
+ debounceMs?: number
35
+ }
36
+
37
+ export interface DocumentLifecycle {
38
+ doc: DocumentLifecycleDoc
39
+ /** Create the durable draft if none exists yet; returns the path. */
40
+ ensureDraft: () => Promise<string | null>
41
+ /** Note a content change: lazily creates the draft, debounces an autosave. */
42
+ markDirty: () => void
43
+ /** Immediate save of the current serialize() output. */
44
+ flush: () => Promise<void>
45
+ /** Name + file the document (move out of drafts). */
46
+ promote: (title: string, targetDir?: string) => Promise<void>
47
+ rename: (title: string) => Promise<void>
48
+ duplicate: () => Promise<string | null>
49
+ revealInFinder: () => Promise<void>
50
+ openInFiles: () => Promise<void>
51
+ /** Bind an existing on-disk document (opened from PureFiles/recents). */
52
+ adopt: (path: string, opts?: { title?: string }) => void
53
+ /** Detach — back to `none` (⌘N / new document). */
54
+ reset: () => void
55
+ }
56
+
57
+ function titleFromPath(path: string, suffix: string): string {
58
+ const name = path.split('/').pop() ?? path
59
+ return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name
60
+ }
61
+
62
+ function statusForPath(path: string): DocumentLifecycleStatus {
63
+ return path.includes('/PureDrafts/') ? 'draft' : 'filed'
64
+ }
65
+
66
+ export function useDocumentLifecycle(
67
+ options: UseDocumentLifecycleOptions,
68
+ ): DocumentLifecycle {
69
+ const { appSlug, suffix, kind, suggestedTitle } = options
70
+ const debounceMs = options.debounceMs ?? 1500
71
+ const serializeRef = useRef(options.serialize)
72
+ serializeRef.current = options.serialize
73
+
74
+ const [doc, setDoc] = useState<DocumentLifecycleDoc>({
75
+ path: null,
76
+ title: suggestedTitle ?? '',
77
+ status: 'none',
78
+ savedAt: null,
79
+ saving: false,
80
+ error: null,
81
+ })
82
+ const pathRef = useRef<string | null>(null)
83
+ const timerRef = useRef<number | null>(null)
84
+ // Serialize all writes for one document through a promise chain — never two
85
+ // concurrent autosaves, and flush() awaits everything queued before it.
86
+ const writeChainRef = useRef<Promise<void>>(Promise.resolve())
87
+ const creatingRef = useRef<Promise<string | null> | null>(null)
88
+
89
+ useEffect(
90
+ () => () => {
91
+ if (timerRef.current !== null) window.clearTimeout(timerRef.current)
92
+ },
93
+ [],
94
+ )
95
+
96
+ const queueWrite = useCallback(
97
+ (work: () => Promise<void>): Promise<void> => {
98
+ const next = writeChainRef.current.then(work, work)
99
+ writeChainRef.current = next.catch(() => undefined)
100
+ return next
101
+ },
102
+ [],
103
+ )
104
+
105
+ const saveNow = useCallback(async (): Promise<void> => {
106
+ const path = pathRef.current
107
+ if (!path) return
108
+ setDoc(current => ({ ...current, saving: true }))
109
+ try {
110
+ const { savedAt } = await autosavePlatformDocument({
111
+ path,
112
+ files: serializeRef.current(),
113
+ })
114
+ setDoc(current => ({ ...current, saving: false, savedAt, error: null }))
115
+ } catch (error) {
116
+ setDoc(current => ({
117
+ ...current,
118
+ saving: false,
119
+ error: error instanceof Error ? error.message : 'Could not save.',
120
+ }))
121
+ }
122
+ }, [])
123
+
124
+ const ensureDraft = useCallback(async (): Promise<string | null> => {
125
+ if (pathRef.current) return pathRef.current
126
+ if (creatingRef.current) return creatingRef.current
127
+ creatingRef.current = (async () => {
128
+ try {
129
+ const { path } = await createPlatformDraft({
130
+ appSlug,
131
+ suffix,
132
+ kind,
133
+ files: serializeRef.current(),
134
+ })
135
+ pathRef.current = path
136
+ setDoc(current => ({
137
+ ...current,
138
+ path,
139
+ title: current.title || titleFromPath(path, suffix),
140
+ status: 'draft',
141
+ savedAt: new Date().toISOString(),
142
+ error: null,
143
+ }))
144
+ void touchPlatformRecentDocument({
145
+ appSlug,
146
+ path,
147
+ title: titleFromPath(path, suffix),
148
+ }).catch(() => undefined)
149
+ return path
150
+ } catch (error) {
151
+ setDoc(current => ({
152
+ ...current,
153
+ error:
154
+ error instanceof Error ? error.message : 'Could not create draft.',
155
+ }))
156
+ return null
157
+ } finally {
158
+ creatingRef.current = null
159
+ }
160
+ })()
161
+ return creatingRef.current
162
+ }, [appSlug, kind, suffix])
163
+
164
+ const markDirty = useCallback((): void => {
165
+ void (async () => {
166
+ if (!pathRef.current) {
167
+ const created = await ensureDraft()
168
+ if (!created) return
169
+ }
170
+ if (timerRef.current !== null) window.clearTimeout(timerRef.current)
171
+ timerRef.current = window.setTimeout(() => {
172
+ timerRef.current = null
173
+ void queueWrite(saveNow)
174
+ }, debounceMs)
175
+ })()
176
+ }, [debounceMs, ensureDraft, queueWrite, saveNow])
177
+
178
+ const flush = useCallback(async (): Promise<void> => {
179
+ if (timerRef.current !== null) {
180
+ window.clearTimeout(timerRef.current)
181
+ timerRef.current = null
182
+ }
183
+ if (!pathRef.current) return
184
+ await queueWrite(saveNow)
185
+ }, [queueWrite, saveNow])
186
+
187
+ // Last-chance flush when the app tab hides or unloads.
188
+ useEffect(() => {
189
+ const onHidden = (): void => {
190
+ if (document.visibilityState === 'hidden') void flush()
191
+ }
192
+ window.addEventListener('beforeunload', onHidden)
193
+ document.addEventListener('visibilitychange', onHidden)
194
+ return () => {
195
+ window.removeEventListener('beforeunload', onHidden)
196
+ document.removeEventListener('visibilitychange', onHidden)
197
+ }
198
+ }, [flush])
199
+
200
+ const promote = useCallback(
201
+ async (title: string, targetDir?: string): Promise<void> => {
202
+ const path = pathRef.current ?? (await ensureDraft())
203
+ if (!path) return
204
+ await flush()
205
+ try {
206
+ const result = await promotePlatformDocument({
207
+ path,
208
+ title,
209
+ targetDir,
210
+ suffix,
211
+ appSlug,
212
+ })
213
+ pathRef.current = result.path
214
+ setDoc(current => ({
215
+ ...current,
216
+ path: result.path,
217
+ title: titleFromPath(result.path, suffix),
218
+ status: statusForPath(result.path),
219
+ error: null,
220
+ }))
221
+ void touchPlatformRecentDocument({
222
+ appSlug,
223
+ path: result.path,
224
+ title: titleFromPath(result.path, suffix),
225
+ }).catch(() => undefined)
226
+ } catch (error) {
227
+ setDoc(current => ({
228
+ ...current,
229
+ error: error instanceof Error ? error.message : 'Could not file.',
230
+ }))
231
+ }
232
+ },
233
+ [appSlug, ensureDraft, flush, suffix],
234
+ )
235
+
236
+ const rename = useCallback(
237
+ async (title: string): Promise<void> => {
238
+ const path = pathRef.current
239
+ if (!path) {
240
+ setDoc(current => ({ ...current, title }))
241
+ return
242
+ }
243
+ await flush()
244
+ try {
245
+ const result = await renamePlatformDocument({
246
+ path,
247
+ title,
248
+ suffix,
249
+ appSlug,
250
+ })
251
+ pathRef.current = result.path
252
+ setDoc(current => ({
253
+ ...current,
254
+ path: result.path,
255
+ title: titleFromPath(result.path, suffix),
256
+ status: statusForPath(result.path),
257
+ error: null,
258
+ }))
259
+ } catch (error) {
260
+ setDoc(current => ({
261
+ ...current,
262
+ error: error instanceof Error ? error.message : 'Could not rename.',
263
+ }))
264
+ }
265
+ },
266
+ [appSlug, flush, suffix],
267
+ )
268
+
269
+ const duplicate = useCallback(async (): Promise<string | null> => {
270
+ const path = pathRef.current
271
+ if (!path) return null
272
+ await flush()
273
+ try {
274
+ const result = await duplicatePlatformDocument({ path, suffix })
275
+ return result.path
276
+ } catch {
277
+ return null
278
+ }
279
+ }, [flush, suffix])
280
+
281
+ const revealInFinder = useCallback(async (): Promise<void> => {
282
+ const path = pathRef.current
283
+ if (!path) return
284
+ await bridge.call(PLATFORM_BRIDGE_METHODS.OS_REVEAL, [path])
285
+ }, [])
286
+
287
+ const openInFiles = useCallback(async (): Promise<void> => {
288
+ const path = pathRef.current
289
+ if (!path) return
290
+ await bridge.call(PLATFORM_BRIDGE_METHODS.CATALOG_OPEN, [
291
+ { path, appSlug: 'purefiles' },
292
+ ])
293
+ }, [])
294
+
295
+ const adopt = useCallback(
296
+ (path: string, opts: { title?: string } = {}): void => {
297
+ pathRef.current = path
298
+ setDoc({
299
+ path,
300
+ title: opts.title ?? titleFromPath(path, suffix),
301
+ status: statusForPath(path),
302
+ savedAt: null,
303
+ saving: false,
304
+ error: null,
305
+ })
306
+ void touchPlatformRecentDocument({
307
+ appSlug,
308
+ path,
309
+ title: opts.title ?? titleFromPath(path, suffix),
310
+ }).catch(() => undefined)
311
+ },
312
+ [appSlug, suffix],
313
+ )
314
+
315
+ const reset = useCallback((): void => {
316
+ if (timerRef.current !== null) {
317
+ window.clearTimeout(timerRef.current)
318
+ timerRef.current = null
319
+ }
320
+ pathRef.current = null
321
+ setDoc({
322
+ path: null,
323
+ title: '',
324
+ status: 'none',
325
+ savedAt: null,
326
+ saving: false,
327
+ error: null,
328
+ })
329
+ }, [])
330
+
331
+ return useMemo(
332
+ () => ({
333
+ doc,
334
+ ensureDraft,
335
+ markDirty,
336
+ flush,
337
+ promote,
338
+ rename,
339
+ duplicate,
340
+ revealInFinder,
341
+ openInFiles,
342
+ adopt,
343
+ reset,
344
+ }),
345
+ [
346
+ doc,
347
+ ensureDraft,
348
+ markDirty,
349
+ flush,
350
+ promote,
351
+ rename,
352
+ duplicate,
353
+ revealInFinder,
354
+ openInFiles,
355
+ adopt,
356
+ reset,
357
+ ],
358
+ )
359
+ }
@@ -104,16 +104,35 @@ describe('usePlatformAgentSessionDrive', () => {
104
104
  ;(globalThis as ReactActGlobal).IS_REACT_ACT_ENVIRONMENT = true
105
105
  })
106
106
 
107
- beforeEach(() => {
108
- latestDrive = null
109
- host = document.createElement('div')
110
- document.body.appendChild(host)
111
- root = createRoot(host)
112
- agentsMock.readAgentSession.mockResolvedValue(sessionRecord)
113
- agentsMock.advanceAgentSessionPending.mockResolvedValue(null)
114
- agentsMock.readAgentLlmMenuOptions.mockResolvedValue([])
115
- agentsMock.onAgentRunEvent.mockReturnValue(vi.fn())
116
- })
107
+ beforeEach(() => {
108
+ latestDrive = null
109
+ host = document.createElement('div')
110
+ document.body.appendChild(host)
111
+ root = createRoot(host)
112
+ agentsMock.readAgentSession.mockResolvedValue(sessionRecord)
113
+ agentsMock.advanceAgentSessionPending.mockResolvedValue({
114
+ session: sessionRecord,
115
+ snapshot: {
116
+ runId: sessionRecord.runId,
117
+ state: sessionRecord.snapshot.state,
118
+ turn: sessionRecord.snapshot.turn,
119
+ messages: sessionRecord.snapshot.messages,
120
+ },
121
+ effectiveToolPolicy: {
122
+ allowedTargetApps: ['book'],
123
+ defaultRequiresApproval: false,
124
+ approvalOverrides: {
125
+ 'book.write': true,
126
+ 'book.review': true,
127
+ },
128
+ },
129
+ manualPendingToolCalls: [
130
+ { id: 'call-manual', name: 'book.review', arguments: {} },
131
+ ],
132
+ })
133
+ agentsMock.readAgentLlmMenuOptions.mockResolvedValue([])
134
+ agentsMock.onAgentRunEvent.mockReturnValue(vi.fn())
135
+ })
117
136
 
118
137
  afterEach(() => {
119
138
  act(() => root.unmount())
@@ -31,10 +31,7 @@ import {
31
31
  isAgentAutoModelId,
32
32
  DEFAULT_AGENT_HIGH_LEVEL_MODEL_ID,
33
33
  } from '../agentModelTiers.js'
34
- import {
35
- getPlatformPreferences,
36
- patchPlatformPreferences,
37
- } from '../preferences.mjs'
34
+ import { patchAgentSessionScopeDefaults } from '../agentScopeDefaults.js'
38
35
  import {
39
36
  advanceAgentSessionPending,
40
37
  approveAgentSessionTools,
@@ -95,10 +92,6 @@ export interface UsePlatformAgentSessionDrive {
95
92
  /** Send the composer value, or an explicit message (programmatic). Returns the final snapshot. */
96
93
  sendMessage: (message?: string) => Promise<PlatformAgentRunSnapshot | null>
97
94
  approveTools: (toolCallIds?: readonly string[]) => Promise<void>
98
- approveToolAlwaysForSession: (
99
- qualifiedName: string,
100
- toolCallId: string,
101
- ) => Promise<void>
102
95
  rejectTools: (toolCallIds?: readonly string[]) => Promise<void>
103
96
  selectSession: (sessionId: string) => Promise<void>
104
97
  createNewSession: () => Promise<void>
@@ -121,9 +114,9 @@ interface DriveResult {
121
114
  }
122
115
 
123
116
  /** Display snapshot from a persisted session record (before any live run this turn). */
124
- function recordSnapshot(
125
- record: PlatformAgentSessionRecord | null,
126
- ): PlatformAgentRunSnapshot | null {
117
+ function recordSnapshot(
118
+ record: PlatformAgentSessionRecord | null,
119
+ ): PlatformAgentRunSnapshot | null {
127
120
  if (!record) return null
128
121
  return {
129
122
  runId: record.runId ?? '',
@@ -132,28 +125,10 @@ function recordSnapshot(
132
125
  messages: record.snapshot.messages,
133
126
  error: record.snapshot.error,
134
127
  interruptedReason: record.snapshot.interruptedReason,
135
- }
136
- }
137
-
138
- function mergeScopeDefaultPatch(
139
- base: PlatformAgentSessionConfigPatch | undefined,
140
- patch: PlatformAgentSessionConfigPatch,
141
- ): PlatformAgentSessionConfigPatch {
142
- const toolPolicy =
143
- patch.toolPolicy || base?.toolPolicy
144
- ? {
145
- ...base?.toolPolicy,
146
- ...patch.toolPolicy,
147
- approvalOverrides: {
148
- ...base?.toolPolicy?.approvalOverrides,
149
- ...patch.toolPolicy?.approvalOverrides,
150
- },
151
- }
152
- : undefined
153
- return { ...base, ...patch, ...(toolPolicy ? { toolPolicy } : {}) }
154
- }
155
-
156
- export function usePlatformAgentSessionDrive({
128
+ }
129
+ }
130
+
131
+ export function usePlatformAgentSessionDrive({
157
132
  appSlug,
158
133
  scope: initialScope = 'app',
159
134
  enabled = true,
@@ -404,7 +379,7 @@ export function usePlatformAgentSessionDrive({
404
379
  : 'This model is not available. Check API keys or local runtime in Settings.'
405
380
  }, [modelOptions, selectedModelId])
406
381
 
407
- const toolPolicy = policy ?? sessionRecord?.config?.toolPolicy ?? undefined
382
+ const toolPolicy = policy
408
383
  const acceptedPendingToolCallIds = useMemo(
409
384
  () =>
410
385
  sessionRecord?.runId && sessionRecord.runId === threadSnapshot?.runId
@@ -607,28 +582,7 @@ export function usePlatformAgentSessionDrive({
607
582
  const persistScopeDefault = useCallback(
608
583
  async (patch: PlatformAgentSessionConfigPatch) => {
609
584
  try {
610
- const prefs = (await getPlatformPreferences()) as {
611
- agentSessionDefaults?: {
612
- global?: PlatformAgentSessionConfigPatch
613
- apps?: Record<string, PlatformAgentSessionConfigPatch>
614
- }
615
- }
616
- const stored = prefs.agentSessionDefaults ?? {}
617
- if (scope === 'global') {
618
- await patchPlatformPreferences({
619
- agentSessionDefaults: {
620
- ...stored,
621
- global: mergeScopeDefaultPatch(stored.global, patch),
622
- },
623
- })
624
- return
625
- }
626
- if (!scopeAppSlug) return
627
- const apps = { ...(stored.apps ?? {}) }
628
- apps[scopeAppSlug] = mergeScopeDefaultPatch(apps[scopeAppSlug], patch)
629
- await patchPlatformPreferences({
630
- agentSessionDefaults: { ...stored, apps },
631
- })
585
+ await patchAgentSessionScopeDefaults(scope, scopeAppSlug, patch)
632
586
  } catch {
633
587
  /* scope defaults are best-effort */
634
588
  }
@@ -696,30 +650,6 @@ export function usePlatformAgentSessionDrive({
696
650
  [persistScopeDefault, sessionQuery, sessionRecord],
697
651
  )
698
652
 
699
- const approveToolAlwaysForSession = useCallback(
700
- async (qualifiedName: string, toolCallId: string) => {
701
- if (!sessionRecord) return
702
- await drive(async () => {
703
- const overrides = {
704
- ...sessionRecord.config.toolPolicy?.approvalOverrides,
705
- [qualifiedName]: false,
706
- }
707
- const updated = await updateAgentSession({
708
- query: sessionQuery(sessionRecord.sessionId),
709
- patch: { config: { toolPolicy: { approvalOverrides: overrides } } },
710
- })
711
- await persistScopeDefault({
712
- toolPolicy: { approvalOverrides: overrides },
713
- })
714
- return approveAgentSessionTools({
715
- session: sessionQuery(updated.sessionId),
716
- toolCallIds: [toolCallId],
717
- })
718
- })
719
- },
720
- [drive, persistScopeDefault, sessionQuery, sessionRecord],
721
- )
722
-
723
653
  return {
724
654
  scope,
725
655
  setScope,
@@ -744,7 +674,6 @@ export function usePlatformAgentSessionDrive({
744
674
  sendMessage,
745
675
  interruptRun,
746
676
  approveTools,
747
- approveToolAlwaysForSession,
748
677
  rejectTools,
749
678
  selectSession,
750
679
  createNewSession,
@@ -0,0 +1,105 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import {
5
+ afterEach,
6
+ beforeAll,
7
+ beforeEach,
8
+ describe,
9
+ expect,
10
+ it,
11
+ vi,
12
+ } from 'vitest'
13
+ import { usePlatformAppSettings } from './usePlatformAppSettings.js'
14
+
15
+ type ReactActGlobal = typeof globalThis & {
16
+ IS_REACT_ACT_ENVIRONMENT?: boolean
17
+ }
18
+
19
+ type Settings = Record<string, unknown> & {
20
+ view: 'list' | 'board'
21
+ }
22
+
23
+ const appSettingsMock = vi.hoisted(() => ({
24
+ getPlatformAppSettings: vi.fn(),
25
+ updatePlatformAppSettings: vi.fn(),
26
+ }))
27
+
28
+ vi.mock('../appSettings.mjs', () => appSettingsMock)
29
+
30
+ let latest:
31
+ | ReturnType<typeof usePlatformAppSettings<Settings>>
32
+ | null = null
33
+
34
+ function parseSettings(raw: Record<string, unknown>): Settings {
35
+ return {
36
+ view: raw.view === 'board' ? 'board' : 'list',
37
+ }
38
+ }
39
+
40
+ function Probe(): React.ReactElement {
41
+ latest = usePlatformAppSettings<Settings>({
42
+ appSlug: 'tasks',
43
+ parse: parseSettings,
44
+ initialSettings: { view: 'list' },
45
+ })
46
+ return <div />
47
+ }
48
+
49
+ async function flushEffects(): Promise<void> {
50
+ await act(async () => {
51
+ await Promise.resolve()
52
+ await Promise.resolve()
53
+ })
54
+ }
55
+
56
+ describe('usePlatformAppSettings', () => {
57
+ let host: HTMLDivElement
58
+ let root: Root
59
+
60
+ beforeAll(() => {
61
+ ;(globalThis as ReactActGlobal).IS_REACT_ACT_ENVIRONMENT = true
62
+ })
63
+
64
+ beforeEach(() => {
65
+ latest = null
66
+ host = document.createElement('div')
67
+ document.body.appendChild(host)
68
+ root = createRoot(host)
69
+ appSettingsMock.getPlatformAppSettings.mockResolvedValue({ view: 'board' })
70
+ appSettingsMock.updatePlatformAppSettings.mockResolvedValue({ view: 'list' })
71
+ })
72
+
73
+ afterEach(() => {
74
+ act(() => root.unmount())
75
+ host.remove()
76
+ vi.clearAllMocks()
77
+ })
78
+
79
+ it('loads and normalizes app settings for one app slug', async () => {
80
+ act(() => {
81
+ root.render(<Probe />)
82
+ })
83
+ await flushEffects()
84
+
85
+ expect(appSettingsMock.getPlatformAppSettings).toHaveBeenCalledWith('tasks')
86
+ expect(latest?.settings).toEqual({ view: 'board' })
87
+ })
88
+
89
+ it('patches app settings through settings.app.update', async () => {
90
+ act(() => {
91
+ root.render(<Probe />)
92
+ })
93
+ await flushEffects()
94
+
95
+ await act(async () => {
96
+ await latest?.patchSettings({ view: 'list' })
97
+ })
98
+
99
+ expect(appSettingsMock.updatePlatformAppSettings).toHaveBeenCalledWith({
100
+ appSlug: 'tasks',
101
+ patch: { view: 'list' },
102
+ })
103
+ expect(latest?.settings).toEqual({ view: 'list' })
104
+ })
105
+ })