@puredesktop/puredesktop-ui-bridge 2.1.8 → 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.
- package/package.json +35 -5
- package/src/agents/mcpServerPresets.test.ts +105 -0
- package/src/agents/mcpServerPresets.ts +380 -0
- package/src/agents/runtime/index.ts +7 -0
- package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
- package/src/agents/runtime/pendingAsks.test.ts +117 -0
- package/src/agents/runtime/pendingAsks.ts +110 -0
- package/src/bridge/agentScopeDefaults.ts +65 -0
- package/src/bridge/agentTypes.ts +36 -2
- package/src/bridge/agents.d.mts +6 -0
- package/src/bridge/agents.mjs +10 -0
- package/src/bridge/agents.ts +4 -0
- package/src/bridge/credentials.d.mts +62 -0
- package/src/bridge/credentials.mjs +53 -0
- package/src/bridge/events.d.mts +1 -0
- package/src/bridge/events.mjs +1 -0
- package/src/bridge/googleAuth.ts +7 -152
- package/src/bridge/mcpClient.d.mts +74 -0
- package/src/bridge/mcpClient.mjs +79 -0
- package/src/bridge/methods.d.mts +22 -0
- package/src/bridge/methods.mjs +23 -0
- package/src/bridge/notifications.d.mts +12 -0
- package/src/bridge/notifications.mjs +36 -0
- package/src/bridge/operations.d.mts +44 -0
- package/src/bridge/operations.mjs +40 -0
- package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
- package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
- package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -105
- package/src/bridge/react/usePlatformAppSettings.tsx +104 -104
- package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -152
- package/src/bridge/react/usePlatformJsonStore.tsx +149 -149
- package/src/bridge/react/usePlatformOperations.tsx +75 -0
- package/src/bridge/storage.d.mts +17 -4
- package/src/bridge/storage.mjs +6 -3
- package/src/components/agents/AgentComposer.tsx +10 -6
- package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
- package/src/components/agents/AgentDrawerPanel.tsx +23 -64
- package/src/components/agents/AgentMessageBubble.tsx +59 -33
- package/src/components/agents/AgentMessageList.tsx +4 -9
- package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
- package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
- package/src/components/agents/AgentToolPendingList.tsx +6 -6
- package/src/components/agents/AgentToolPendingReview.tsx +6 -6
- package/src/components/agents/QuestionRequestList.tsx +278 -0
- package/src/components/agents/agentPanelStyles.ts +9 -33
- package/src/components/agents/agentTypes.ts +25 -43
- package/src/components/agents/index.ts +4 -0
- package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
- package/src/components/chrome/WorkspaceTabStrip.tsx +4 -3
- package/src/components/common/chat/ChatBox.tsx +9 -9
- package/src/components/common/chat/ChatModelMenu.tsx +34 -4
- package/src/components/common/chat/ChatThread.tsx +23 -7
- package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
- package/src/components/common/connections/ProviderConnection.tsx +277 -0
- package/src/components/common/connections/index.ts +5 -0
- package/src/components/common/containers/AppFrame.tsx +26 -19
- package/src/components/common/containers/AppHeader.tsx +20 -20
- package/src/components/common/documents/DocumentSwitcher.tsx +63 -58
- package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
- package/src/components/common/dropdown/MenuPortal.tsx +3 -1
- package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
- package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
- package/src/components/common/overlays/Modal.tsx +13 -2
- package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
- package/src/ics/generateIcs.test.ts +153 -0
- package/src/ics/generateIcs.ts +197 -0
- package/src/index.ts +3 -0
- package/src/net/httpRetry.test.ts +117 -0
- package/src/net/httpRetry.ts +111 -0
- package/src/theme/appAccents.ts +6 -0
- package/src/theme/appIdentityCss.mjs +11 -0
- package/src/theme/appIdentityCss.ts +11 -0
- package/src/theme/themes/dark.ts +3 -1
- package/src/theme/themes/light.ts +3 -1
- package/src/bridge/googleProviderConfig.ts +0 -73
|
@@ -1,152 +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
|
-
})
|
|
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
|
+
})
|
|
@@ -1,149 +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
|
-
}
|
|
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
|
+
}
|