@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,104 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import {
3
+ getPlatformAppSettings,
4
+ updatePlatformAppSettings,
5
+ } from '../appSettings.mjs'
6
+
7
+ export interface UsePlatformAppSettingsOptions<
8
+ TSettings extends Record<string, unknown> = Record<string, unknown>,
9
+ > {
10
+ appSlug: string | null
11
+ /** Wait for bridge ready before loading (default: true when appSlug exists). */
12
+ enabled?: boolean
13
+ /** Normalize stored settings into the app-owned settings shape. */
14
+ parse?: (value: Record<string, unknown>) => TSettings
15
+ initialSettings?: TSettings
16
+ }
17
+
18
+ export interface UsePlatformAppSettingsResult<
19
+ TSettings extends Record<string, unknown> = Record<string, unknown>,
20
+ > {
21
+ settings: TSettings
22
+ loading: boolean
23
+ error: Error | null
24
+ /** Shallow-merge a patch through shell `settings.app.update`. */
25
+ patchSettings: (patch: Record<string, unknown>) => Promise<TSettings>
26
+ reload: () => void
27
+ }
28
+
29
+ function defaultParse<TSettings extends Record<string, unknown>>(
30
+ value: Record<string, unknown>,
31
+ ): TSettings {
32
+ return value as TSettings
33
+ }
34
+
35
+ /**
36
+ * Load and update the current app's settings through `settings.app.*`.
37
+ * App settings are small per-app preferences, not user-visible documents.
38
+ */
39
+ export function usePlatformAppSettings<
40
+ TSettings extends Record<string, unknown> = Record<string, unknown>,
41
+ >({
42
+ appSlug,
43
+ enabled = Boolean(appSlug),
44
+ parse = defaultParse<TSettings>,
45
+ initialSettings = {} as TSettings,
46
+ }: UsePlatformAppSettingsOptions<TSettings>): UsePlatformAppSettingsResult<TSettings> {
47
+ const [settings, setSettings] = useState<TSettings>(initialSettings)
48
+ const [loading, setLoading] = useState(false)
49
+ const [error, setError] = useState<Error | null>(null)
50
+ const [reloadToken, setReloadToken] = useState(0)
51
+
52
+ const reload = useCallback(() => {
53
+ setReloadToken(token => token + 1)
54
+ }, [])
55
+
56
+ useEffect(() => {
57
+ if (!enabled || !appSlug) return
58
+
59
+ let cancelled = false
60
+ setLoading(true)
61
+ setError(null)
62
+
63
+ void getPlatformAppSettings(appSlug)
64
+ .then(raw => {
65
+ if (!cancelled) setSettings(parse(raw))
66
+ })
67
+ .catch((cause: unknown) => {
68
+ if (!cancelled) {
69
+ setError(cause instanceof Error ? cause : new Error(String(cause)))
70
+ }
71
+ })
72
+ .finally(() => {
73
+ if (!cancelled) setLoading(false)
74
+ })
75
+
76
+ return () => {
77
+ cancelled = true
78
+ }
79
+ }, [appSlug, enabled, parse, reloadToken])
80
+
81
+ const patchSettings = useCallback(
82
+ async (patch: Record<string, unknown>): Promise<TSettings> => {
83
+ if (!appSlug) throw new Error('appSlug is required to update app settings')
84
+ try {
85
+ const next = parse(
86
+ await updatePlatformAppSettings({
87
+ appSlug,
88
+ patch,
89
+ }),
90
+ )
91
+ setSettings(next)
92
+ setError(null)
93
+ return next
94
+ } catch (cause) {
95
+ const error = cause instanceof Error ? cause : new Error(String(cause))
96
+ setError(error)
97
+ throw error
98
+ }
99
+ },
100
+ [appSlug, parse],
101
+ )
102
+
103
+ return { settings, loading, error, patchSettings, reload }
104
+ }
@@ -0,0 +1,152 @@
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 { usePlatformJsonStore } from './usePlatformJsonStore.js'
14
+
15
+ type ReactActGlobal = typeof globalThis & {
16
+ IS_REACT_ACT_ENVIRONMENT?: boolean
17
+ }
18
+
19
+ interface Store {
20
+ items: string[]
21
+ }
22
+
23
+ const storageMock = vi.hoisted(() => ({
24
+ readPlatformStorageJson: vi.fn(),
25
+ writePlatformStorageJson: vi.fn(),
26
+ }))
27
+
28
+ vi.mock('../storage.mjs', () => storageMock)
29
+
30
+ let latest: ReturnType<typeof usePlatformJsonStore<Store>> | null = null
31
+
32
+ const EMPTY_STORE: Store = { items: [] }
33
+
34
+ function parseStore(value: unknown): Store {
35
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
36
+ return EMPTY_STORE
37
+ }
38
+ const record = value as Record<string, unknown>
39
+ return {
40
+ items: Array.isArray(record.items)
41
+ ? record.items.filter((item): item is string => typeof item === 'string')
42
+ : [],
43
+ }
44
+ }
45
+
46
+ function Probe(): React.ReactElement {
47
+ latest = usePlatformJsonStore<Store>({
48
+ appSlug: 'tasks',
49
+ fileName: 'tasks-items.json',
50
+ initialValue: EMPTY_STORE,
51
+ parse: parseStore,
52
+ })
53
+ return <div />
54
+ }
55
+
56
+ async function flushEffects(): Promise<void> {
57
+ await act(async () => {
58
+ await Promise.resolve()
59
+ await Promise.resolve()
60
+ })
61
+ }
62
+
63
+ describe('usePlatformJsonStore', () => {
64
+ let host: HTMLDivElement
65
+ let root: Root
66
+
67
+ beforeAll(() => {
68
+ ;(globalThis as ReactActGlobal).IS_REACT_ACT_ENVIRONMENT = true
69
+ })
70
+
71
+ beforeEach(() => {
72
+ latest = null
73
+ host = document.createElement('div')
74
+ document.body.appendChild(host)
75
+ root = createRoot(host)
76
+ storageMock.readPlatformStorageJson.mockResolvedValue({
77
+ path: '/data/tasks-items.json',
78
+ value: { items: ['one', 2, 'two'] },
79
+ })
80
+ storageMock.writePlatformStorageJson.mockResolvedValue({
81
+ path: '/data/tasks-items.json',
82
+ ok: true,
83
+ })
84
+ })
85
+
86
+ afterEach(() => {
87
+ act(() => root.unmount())
88
+ host.remove()
89
+ vi.clearAllMocks()
90
+ })
91
+
92
+ it('loads and normalizes a JSON store file', async () => {
93
+ act(() => {
94
+ root.render(<Probe />)
95
+ })
96
+ await flushEffects()
97
+
98
+ expect(storageMock.readPlatformStorageJson).toHaveBeenCalledWith({
99
+ appSlug: 'tasks',
100
+ fileName: 'tasks-items.json',
101
+ })
102
+ expect(latest?.value).toEqual({ items: ['one', 'two'] })
103
+ expect(latest?.path).toBe('/data/tasks-items.json')
104
+ })
105
+
106
+ it('saves the complete next JSON store value', async () => {
107
+ act(() => {
108
+ root.render(<Probe />)
109
+ })
110
+ await flushEffects()
111
+
112
+ await act(async () => {
113
+ await latest?.saveValue({ items: ['next'] })
114
+ })
115
+
116
+ expect(storageMock.writePlatformStorageJson).toHaveBeenCalledWith({
117
+ appSlug: 'tasks',
118
+ fileName: 'tasks-items.json',
119
+ value: { items: ['next'] },
120
+ })
121
+ expect(latest?.value).toEqual({ items: ['next'] })
122
+ })
123
+
124
+ it('applies queued updates to the latest saved value', async () => {
125
+ act(() => {
126
+ root.render(<Probe />)
127
+ })
128
+ await flushEffects()
129
+
130
+ await act(async () => {
131
+ const first = latest?.updateValue(current => ({
132
+ items: [...current.items, 'three'],
133
+ }))
134
+ const second = latest?.updateValue(current => ({
135
+ items: [...current.items, 'four'],
136
+ }))
137
+ await Promise.all([first, second])
138
+ })
139
+
140
+ expect(storageMock.writePlatformStorageJson).toHaveBeenNthCalledWith(1, {
141
+ appSlug: 'tasks',
142
+ fileName: 'tasks-items.json',
143
+ value: { items: ['one', 'two', 'three'] },
144
+ })
145
+ expect(storageMock.writePlatformStorageJson).toHaveBeenNthCalledWith(2, {
146
+ appSlug: 'tasks',
147
+ fileName: 'tasks-items.json',
148
+ value: { items: ['one', 'two', 'three', 'four'] },
149
+ })
150
+ expect(latest?.value).toEqual({ items: ['one', 'two', 'three', 'four'] })
151
+ })
152
+ })
@@ -0,0 +1,149 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import {
3
+ readPlatformStorageJson,
4
+ writePlatformStorageJson,
5
+ } from '../storage.mjs'
6
+
7
+ export type PlatformJsonStoreFileName = `${string}.json`
8
+
9
+ export interface UsePlatformJsonStoreOptions<TValue> {
10
+ appSlug: string | null
11
+ fileName: PlatformJsonStoreFileName | null
12
+ /** Default value when the JSON file does not exist yet. */
13
+ initialValue: TValue
14
+ /** Wait for bridge ready before loading (default: true when appSlug/fileName exist). */
15
+ enabled?: boolean
16
+ /** Normalize parsed JSON into the app-owned store shape. */
17
+ parse?: (value: unknown) => TValue
18
+ }
19
+
20
+ export interface UsePlatformJsonStoreResult<TValue> {
21
+ value: TValue
22
+ path: string | null
23
+ loading: boolean
24
+ saving: boolean
25
+ error: Error | null
26
+ saveValue: (next: TValue) => Promise<TValue>
27
+ updateValue: (updater: (current: TValue) => TValue) => Promise<TValue>
28
+ reload: () => void
29
+ }
30
+
31
+ function defaultParse<TValue>(value: unknown): TValue {
32
+ return value as TValue
33
+ }
34
+
35
+ /**
36
+ * Load and replace one shell-managed JSON store file through `storage.*`.
37
+ * Use this for app-owned internal records, not for user-visible documents.
38
+ */
39
+ export function usePlatformJsonStore<TValue>({
40
+ appSlug,
41
+ fileName,
42
+ initialValue,
43
+ enabled = Boolean(appSlug && fileName),
44
+ parse = defaultParse<TValue>,
45
+ }: UsePlatformJsonStoreOptions<TValue>): UsePlatformJsonStoreResult<TValue> {
46
+ const [value, setValue] = useState<TValue>(initialValue)
47
+ const [path, setPath] = useState<string | null>(null)
48
+ const [loading, setLoading] = useState(false)
49
+ const [saving, setSaving] = useState(false)
50
+ const [error, setError] = useState<Error | null>(null)
51
+ const [reloadToken, setReloadToken] = useState(0)
52
+ const valueRef = useRef(value)
53
+ const writeChainRef = useRef<Promise<TValue>>(Promise.resolve(value))
54
+ valueRef.current = value
55
+
56
+ const reload = useCallback(() => {
57
+ setReloadToken(token => token + 1)
58
+ }, [])
59
+
60
+ useEffect(() => {
61
+ if (!enabled || !appSlug || !fileName) return
62
+
63
+ let cancelled = false
64
+ setLoading(true)
65
+ setError(null)
66
+
67
+ void readPlatformStorageJson({ appSlug, fileName })
68
+ .then(result => {
69
+ if (cancelled) return
70
+ const next =
71
+ result.value === null ? initialValue : parse(result.value)
72
+ valueRef.current = next
73
+ setValue(next)
74
+ setPath(result.path)
75
+ })
76
+ .catch((cause: unknown) => {
77
+ if (!cancelled) {
78
+ setError(cause instanceof Error ? cause : new Error(String(cause)))
79
+ }
80
+ })
81
+ .finally(() => {
82
+ if (!cancelled) setLoading(false)
83
+ })
84
+
85
+ return () => {
86
+ cancelled = true
87
+ }
88
+ }, [appSlug, enabled, fileName, initialValue, parse, reloadToken])
89
+
90
+ const queueSave = useCallback(
91
+ (resolveNext: (current: TValue) => TValue): Promise<TValue> => {
92
+ if (!appSlug || !fileName) {
93
+ throw new Error('appSlug and fileName are required to save JSON storage')
94
+ }
95
+
96
+ const write = async (): Promise<TValue> => {
97
+ const next = resolveNext(valueRef.current)
98
+ setSaving(true)
99
+ try {
100
+ const result = await writePlatformStorageJson({
101
+ appSlug,
102
+ fileName,
103
+ value: next,
104
+ })
105
+ valueRef.current = next
106
+ setValue(next)
107
+ setPath(result.path)
108
+ setError(null)
109
+ return next
110
+ } catch (cause) {
111
+ const error =
112
+ cause instanceof Error ? cause : new Error(String(cause))
113
+ setError(error)
114
+ throw error
115
+ } finally {
116
+ setSaving(false)
117
+ }
118
+ }
119
+
120
+ const queued = writeChainRef.current.then(write, write)
121
+ writeChainRef.current = queued.catch(() => valueRef.current)
122
+ return queued
123
+ },
124
+ [appSlug, fileName],
125
+ )
126
+
127
+ const saveValue = useCallback(
128
+ (next: TValue): Promise<TValue> => queueSave(() => next),
129
+ [queueSave],
130
+ )
131
+
132
+ const updateValue = useCallback(
133
+ (updater: (current: TValue) => TValue): Promise<TValue> => {
134
+ return queueSave(updater)
135
+ },
136
+ [queueSave],
137
+ )
138
+
139
+ return {
140
+ value,
141
+ path,
142
+ loading,
143
+ saving,
144
+ error,
145
+ saveValue,
146
+ updateValue,
147
+ reload,
148
+ }
149
+ }
@@ -0,0 +1,75 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import {
3
+ listPlatformOperations,
4
+ onPlatformOperationRecorded,
5
+ } from '../operations.mjs'
6
+ import type {
7
+ PlatformOperation,
8
+ PlatformOperationsListQuery,
9
+ } from '../operations.d.mts'
10
+
11
+ export interface UsePlatformOperationsResult {
12
+ /** Newest first; live — appends arrive via the operations.changed event. */
13
+ operations: PlatformOperation[]
14
+ /** True until the first list round-trip settles. */
15
+ loading: boolean
16
+ /** Re-fetch from the shell (the live stream normally makes this unnecessary). */
17
+ refresh: () => Promise<void>
18
+ }
19
+
20
+ /**
21
+ * The suite-wide operations feed for app UIs: one initial list, then live
22
+ * appends. Pass `lane` to follow a single lane ('user' | 'agent'); omit for
23
+ * both. Outside the desktop iframe (standalone dev) it settles to an empty
24
+ * feed instead of throwing.
25
+ */
26
+ export function usePlatformOperations(
27
+ query: PlatformOperationsListQuery = {},
28
+ ): UsePlatformOperationsResult {
29
+ const [operations, setOperations] = useState<PlatformOperation[]>([])
30
+ const [loading, setLoading] = useState(true)
31
+ const laneRef = useRef(query.lane)
32
+ laneRef.current = query.lane
33
+ const limitRef = useRef(query.limit)
34
+ limitRef.current = query.limit
35
+
36
+ const refresh = useCallback(async (): Promise<void> => {
37
+ try {
38
+ const result = await listPlatformOperations({
39
+ ...(laneRef.current ? { lane: laneRef.current } : {}),
40
+ ...(limitRef.current !== undefined ? { limit: limitRef.current } : {}),
41
+ })
42
+ setOperations(result.operations)
43
+ } catch {
44
+ // Standalone dev / bridge unavailable: an empty feed, not a crash.
45
+ setOperations([])
46
+ } finally {
47
+ setLoading(false)
48
+ }
49
+ }, [])
50
+
51
+ useEffect(() => {
52
+ void refresh()
53
+ }, [refresh, query.lane, query.limit])
54
+
55
+ useEffect(() => {
56
+ let unsubscribe: (() => void) | undefined
57
+ try {
58
+ unsubscribe = onPlatformOperationRecorded(operation => {
59
+ if (laneRef.current && operation.lane !== laneRef.current) return
60
+ setOperations(current => {
61
+ const next = [operation, ...current]
62
+ const limit = limitRef.current
63
+ return typeof limit === 'number' && limit >= 0
64
+ ? next.slice(0, limit)
65
+ : next
66
+ })
67
+ })
68
+ } catch {
69
+ // No bridge (standalone dev) — the initial refresh already settled.
70
+ }
71
+ return () => unsubscribe?.()
72
+ }, [])
73
+
74
+ return { operations, loading, refresh }
75
+ }
@@ -1,24 +1,37 @@
1
- export type PlatformStorageJsonFileName = `${string}.json`
2
-
3
- export interface PlatformStorageJsonRequest {
4
- appSlug: string
5
- fileName: PlatformStorageJsonFileName
6
- }
1
+ export type PlatformStorageJsonFileName = `${string}.json`
2
+
3
+ export interface PlatformStorageJsonRequest {
4
+ appSlug: string
5
+ fileName: PlatformStorageJsonFileName
6
+ }
7
7
 
8
8
  export interface PlatformStorageJsonReadResult {
9
9
  path: string
10
10
  value: unknown | null
11
+ /** Content hash for optimistic concurrency (null when the file is absent). */
12
+ version: string | null
11
13
  }
12
14
 
13
15
  export interface PlatformStorageJsonWriteRequest
14
16
  extends PlatformStorageJsonRequest {
15
17
  value: unknown
18
+ /**
19
+ * The version this write was derived from (null = file must not exist).
20
+ * Omit for an unconditional write. On mismatch nothing is written and the
21
+ * result carries conflict: true — re-read, re-apply, retry.
22
+ */
23
+ ifMatch?: string | null
16
24
  }
17
25
 
18
- export interface PlatformStorageJsonWriteResult {
19
- path: string
20
- ok: true
21
- }
26
+ export type PlatformStorageJsonWriteResult =
27
+ | { path: string; ok: true; version: string }
28
+ | {
29
+ path: string
30
+ ok: false
31
+ conflict: true
32
+ value: unknown | null
33
+ version: string | null
34
+ }
22
35
 
23
36
  export declare function readPlatformStorageJson(
24
37
  request: PlatformStorageJsonRequest,
@@ -3,15 +3,18 @@ import { PLATFORM_BRIDGE_METHODS } from './methods.mjs'
3
3
 
4
4
  /**
5
5
  * @param {{ appSlug: string, fileName: string }} request
6
- * @returns {Promise<{ path: string, value: unknown | null }>}
6
+ * @returns {Promise<{ path: string, value: unknown | null, version: string | null }>}
7
7
  */
8
8
  export async function readPlatformStorageJson(request) {
9
9
  return bridge.call(PLATFORM_BRIDGE_METHODS.STORAGE_READ_JSON, [request])
10
10
  }
11
11
 
12
12
  /**
13
- * @param {{ appSlug: string, fileName: string, value: unknown }} request
14
- * @returns {Promise<{ path: string, ok: true }>}
13
+ * Optional `ifMatch` (version from readPlatformStorageJson) makes the write
14
+ * conditional: on conflict nothing is written and the result has
15
+ * `conflict: true` with the current value — re-read, re-apply, retry.
16
+ * @param {{ appSlug: string, fileName: string, value: unknown, ifMatch?: string | null }} request
17
+ * @returns {Promise<{ path: string, ok: boolean, version: string | null, conflict?: true, value?: unknown | null }>}
15
18
  */
16
19
  export async function writePlatformStorageJson(request) {
17
20
  return bridge.call(PLATFORM_BRIDGE_METHODS.STORAGE_WRITE_JSON, [request])
@@ -20,7 +20,7 @@ beforeEach(() => {
20
20
 
21
21
  describe('storage bridge helpers', () => {
22
22
  it('reads JSON storage using the shell request object shape', async () => {
23
- const request = { appSlug: 'puretasks', fileName: 'tasks.json' } as const
23
+ const request = { appSlug: 'puretasks', fileName: 'tasks.json' } as const
24
24
  bridgeCall.mockResolvedValue({ path: '/tmp/tasks.json', value: null })
25
25
 
26
26
  await readPlatformStorageJson(request)
@@ -32,11 +32,11 @@ describe('storage bridge helpers', () => {
32
32
  })
33
33
 
34
34
  it('writes JSON storage using the shell request object shape', async () => {
35
- const request = {
36
- appSlug: 'puretasks',
37
- fileName: 'tasks.json',
38
- value: { tasks: [] },
39
- } as const
35
+ const request = {
36
+ appSlug: 'puretasks',
37
+ fileName: 'tasks.json',
38
+ value: { tasks: [] },
39
+ } as const
40
40
  bridgeCall.mockResolvedValue({ path: '/tmp/tasks.json', ok: true })
41
41
 
42
42
  await writePlatformStorageJson(request)
@@ -2,6 +2,7 @@ export type PlatformBridgeEventName =
2
2
  import('./events.mjs').PlatformBridgeEventName
3
3
 
4
4
  import type { PlatformThemeMode } from '../theme/tokens.js'
5
+ import type { PlatformAppContextManifest } from '../context/contextConfig.js'
5
6
 
6
7
  /** Shell prefs exposed via `settings.prefs.get` / `settings.prefs.set`. */
7
8
  export type PlatformThemePreference = PlatformThemeMode
@@ -80,6 +81,8 @@ export interface PlatformAppManifest {
80
81
  productName: string
81
82
  kind: string
82
83
  description: string
84
+ /** Declarative context-file config for this app's package objects. */
85
+ context?: PlatformAppContextManifest
83
86
  peopleProviders?: Array<{
84
87
  id: string
85
88
  label: string
@@ -205,6 +208,10 @@ export interface RenderPagedSnapshotMetrics {
205
208
  captionVisible?: boolean
206
209
  captionOrphaned?: boolean
207
210
  strandedHeading?: boolean | null
211
+ /** Blocks on the target page that collide with each other. */
212
+ overlappingBlocks?: number
213
+ /** Blocks escaping the page content box. */
214
+ overflowingBlocks?: number
208
215
  }
209
216
 
210
217
  export interface RenderPagedPreviewResult {
@@ -20,6 +20,7 @@ export interface AgentComposerProps {
20
20
  leading?: React.ReactNode
21
21
  followUps?: AgentUiFollowUp[]
22
22
  questionToolCalls?: AgentUiToolCall[]
23
+ questionPromptList?: React.ReactNode
23
24
  usage?: AgentUiRunUsage | null
24
25
  liveTurn?: AgentLiveTurn | null
25
26
  onChange: (value: string) => void
@@ -76,6 +77,7 @@ export function AgentComposer({
76
77
  leading,
77
78
  followUps = [],
78
79
  questionToolCalls = [],
80
+ questionPromptList = null,
79
81
  usage = null,
80
82
  liveTurn = null,
81
83
  onChange,
@@ -89,7 +91,7 @@ export function AgentComposer({
89
91
  const showUsage =
90
92
  liveTurn?.contextCompaction === 'summarizing' ||
91
93
  Boolean(usage && (usage.totalTokens > 0 || usage.contextFillTokens))
92
- const hasQuestions = questionToolCalls.length > 0
94
+ const hasQuestions = questionPromptList !== null || questionToolCalls.length > 0
93
95
 
94
96
  return (
95
97
  <StyledRoot>
@@ -105,11 +107,13 @@ export function AgentComposer({
105
107
  preserveChildrenOnExit
106
108
  aria-hidden={!hasQuestions}
107
109
  >
108
- <AgentQuestionPromptList
109
- toolCalls={questionToolCalls}
110
- onAnswer={onAnswerQuestionTool}
111
- onDecline={onDeclineQuestionTool}
112
- />
110
+ {questionPromptList ?? (
111
+ <AgentQuestionPromptList
112
+ toolCalls={questionToolCalls}
113
+ onAnswer={onAnswerQuestionTool}
114
+ onDecline={onDeclineQuestionTool}
115
+ />
116
+ )}
113
117
  </StyledQuestionPresence>
114
118
  <StyledComposerSurfaceAnchor>
115
119
  {showUsage ? (