@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
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react'
2
- import { useEffect, useRef } from 'react'
2
+ import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
3
3
  import { styled } from 'styled-components'
4
4
 
5
5
  export interface ChatThreadProps {
@@ -17,21 +17,36 @@ export function ChatThread({
17
17
  scrollKey,
18
18
  className,
19
19
  }: ChatThreadProps): React.ReactElement {
20
+ const containerRef = useRef<HTMLDivElement>(null)
20
21
  const endRef = useRef<HTMLDivElement>(null)
21
22
  const rafRef = useRef<number>(0)
22
-
23
- useEffect(() => {
23
+ const scrollToEnd = useCallback(() => {
24
24
  if (rafRef.current) cancelAnimationFrame(rafRef.current)
25
25
  rafRef.current = requestAnimationFrame(() => {
26
- endRef.current?.scrollIntoView({ behavior: 'smooth' })
26
+ endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
27
27
  })
28
+ }, [])
29
+
30
+ useEffect(() => {
31
+ scrollToEnd()
28
32
  return () => {
29
33
  if (rafRef.current) cancelAnimationFrame(rafRef.current)
30
34
  }
31
- }, [children, scrollKey, footer])
35
+ }, [children, footer, scrollKey, scrollToEnd])
36
+
37
+ useLayoutEffect(() => {
38
+ const node = containerRef.current
39
+ if (!node || typeof ResizeObserver === 'undefined') return
40
+
41
+ const observer = new ResizeObserver(() => {
42
+ scrollToEnd()
43
+ })
44
+ observer.observe(node)
45
+ return () => observer.disconnect()
46
+ }, [scrollToEnd])
32
47
 
33
48
  return (
34
- <Container className={className}>
49
+ <Container ref={containerRef} className={className} data-agent-thread-scroll>
35
50
  {emptyMessage}
36
51
  {children}
37
52
  {footer}
@@ -48,7 +63,8 @@ const Container = styled.div`
48
63
  min-height: 0;
49
64
  overflow-y: auto;
50
65
  padding: calc(16px + var(--platform-agent-thread-header-height, 0px)) 24px
51
- 16px;
66
+ calc(16px + var(--platform-agent-composer-reserve-height, 0px));
67
+ scroll-padding-bottom: var(--platform-agent-composer-reserve-height, 0px);
52
68
  `
53
69
 
54
70
  export const ChatThreadEmpty = styled.div`
@@ -0,0 +1,208 @@
1
+ import { act } from 'react'
2
+ import { createRoot, type Root } from 'react-dom/client'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import type { OAuthCredentialStatus } from '../../../bridge/credentials.mjs'
5
+ import {
6
+ ProviderConnection,
7
+ type ProviderConnectionClient,
8
+ } from './ProviderConnection.js'
9
+
10
+ function statusOf(
11
+ overrides: Partial<OAuthCredentialStatus> = {},
12
+ ): OAuthCredentialStatus {
13
+ return {
14
+ id: 'google.oauth',
15
+ kind: 'oauth',
16
+ provider: 'google',
17
+ configured: false,
18
+ connected: false,
19
+ scopes: [],
20
+ needsReconnect: false,
21
+ ...overrides,
22
+ }
23
+ }
24
+
25
+ function mockClient(
26
+ status: OAuthCredentialStatus,
27
+ ): ProviderConnectionClient & {
28
+ setOAuthConfig: ReturnType<typeof vi.fn>
29
+ connectOAuth: ReturnType<typeof vi.fn>
30
+ disconnect: ReturnType<typeof vi.fn>
31
+ } {
32
+ let current = status
33
+ return {
34
+ status: vi.fn(async () => current),
35
+ setOAuthConfig: vi.fn(async () => {
36
+ current = statusOf({ configured: true })
37
+ return { ok: true }
38
+ }),
39
+ connectOAuth: vi.fn(async () => {
40
+ current = statusOf({
41
+ configured: true,
42
+ connected: true,
43
+ email: 'adam@pure.science',
44
+ })
45
+ return { email: 'adam@pure.science' }
46
+ }),
47
+ disconnect: vi.fn(async () => {
48
+ current = statusOf({ configured: true })
49
+ return { ok: true }
50
+ }),
51
+ }
52
+ }
53
+
54
+ let host: HTMLDivElement | null = null
55
+ let root: Root | null = null
56
+
57
+ async function render(element: React.ReactElement): Promise<void> {
58
+ host = document.createElement('div')
59
+ document.body.appendChild(host)
60
+ root = createRoot(host)
61
+ await act(async () => {
62
+ root?.render(element)
63
+ })
64
+ }
65
+
66
+ afterEach(async () => {
67
+ await act(async () => {
68
+ root?.unmount()
69
+ })
70
+ host?.remove()
71
+ host = null
72
+ root = null
73
+ })
74
+
75
+ describe('ProviderConnection', () => {
76
+ it('shows config fields when unconfigured and clears the secret after save', async () => {
77
+ const client = mockClient(statusOf())
78
+ await render(
79
+ <ProviderConnection
80
+ credentialId="google.oauth"
81
+ title="Google account"
82
+ client={client}
83
+ />,
84
+ )
85
+
86
+ const clientIdInput = host?.querySelector<HTMLInputElement>(
87
+ '#google\\.oauth-client-id',
88
+ )
89
+ const secretInput = host?.querySelector<HTMLInputElement>(
90
+ '#google\\.oauth-client-secret',
91
+ )
92
+ expect(clientIdInput).toBeTruthy()
93
+ expect(secretInput?.type).toBe('password')
94
+
95
+ await act(async () => {
96
+ clientIdInput?.focus()
97
+ })
98
+ // Drive the controlled inputs through React's onChange.
99
+ const setNativeValue = (input: HTMLInputElement, value: string) => {
100
+ const setter = Object.getOwnPropertyDescriptor(
101
+ HTMLInputElement.prototype,
102
+ 'value',
103
+ )?.set
104
+ setter?.call(input, value)
105
+ input.dispatchEvent(new Event('input', { bubbles: true }))
106
+ }
107
+ await act(async () => {
108
+ if (clientIdInput) setNativeValue(clientIdInput, 'client-id')
109
+ if (secretInput) setNativeValue(secretInput, 'client-secret')
110
+ })
111
+
112
+ const save = Array.from(host?.querySelectorAll('button') ?? []).find(button =>
113
+ button.textContent?.includes('Save'),
114
+ )
115
+ expect(save?.disabled).toBe(false)
116
+ await act(async () => {
117
+ save?.click()
118
+ })
119
+
120
+ expect(client.setOAuthConfig).toHaveBeenCalledWith({
121
+ id: 'google.oauth',
122
+ clientId: 'client-id',
123
+ clientSecret: 'client-secret',
124
+ })
125
+ // Write-only: after save the secret is gone from the DOM entirely.
126
+ expect(host?.innerHTML).not.toContain('client-secret')
127
+ // Now configured: the sign-in button is offered.
128
+ expect(host?.textContent).toContain('Sign in with Google')
129
+ })
130
+
131
+ it('shows the connected identity and never any secret material', async () => {
132
+ const client = mockClient(
133
+ statusOf({
134
+ configured: true,
135
+ connected: true,
136
+ email: 'adam@pure.science',
137
+ }),
138
+ )
139
+ await render(
140
+ <ProviderConnection
141
+ credentialId="google.oauth"
142
+ title="Google account"
143
+ client={client}
144
+ />,
145
+ )
146
+
147
+ expect(host?.textContent).toContain('Connected as adam@pure.science')
148
+ expect(host?.textContent).toContain('Disconnect')
149
+ expect(host?.querySelector('input')).toBeNull()
150
+ })
151
+
152
+ it('surfaces reconnect prominently when the grant is revoked', async () => {
153
+ const client = mockClient(
154
+ statusOf({
155
+ configured: true,
156
+ connected: false,
157
+ needsReconnect: true,
158
+ email: 'adam@pure.science',
159
+ }),
160
+ )
161
+ await render(
162
+ <ProviderConnection
163
+ credentialId="google.oauth"
164
+ title="Google account"
165
+ client={client}
166
+ />,
167
+ )
168
+
169
+ expect(host?.textContent).toContain('Reconnect required')
170
+ const reconnect = Array.from(host?.querySelectorAll('button') ?? []).find(
171
+ button => button.textContent === 'Reconnect',
172
+ )
173
+ expect(reconnect).toBeTruthy()
174
+
175
+ await act(async () => {
176
+ reconnect?.click()
177
+ })
178
+ expect(client.connectOAuth).toHaveBeenCalledWith({ id: 'google.oauth' })
179
+ expect(host?.textContent).toContain('Connected as adam@pure.science')
180
+ })
181
+
182
+ it('notifies the host app on status changes', async () => {
183
+ const client = mockClient(statusOf({ configured: true }))
184
+ const onStatusChange = vi.fn()
185
+ await render(
186
+ <ProviderConnection
187
+ credentialId="google.oauth"
188
+ title="Google account"
189
+ client={client}
190
+ onStatusChange={onStatusChange}
191
+ />,
192
+ )
193
+
194
+ expect(onStatusChange).toHaveBeenCalledWith(
195
+ expect.objectContaining({ configured: true, connected: false }),
196
+ )
197
+
198
+ const signIn = Array.from(host?.querySelectorAll('button') ?? []).find(button =>
199
+ button.textContent?.includes('Sign in with Google'),
200
+ )
201
+ await act(async () => {
202
+ signIn?.click()
203
+ })
204
+ expect(onStatusChange).toHaveBeenLastCalledWith(
205
+ expect.objectContaining({ connected: true }),
206
+ )
207
+ })
208
+ })
@@ -0,0 +1,277 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+ import {
3
+ connectCredentialOAuth,
4
+ disconnectCredential,
5
+ readCredentialStatus,
6
+ setCredentialOAuthConfig,
7
+ type OAuthCredentialStatus,
8
+ } from '../../../bridge/credentials.mjs'
9
+ import { Button } from '../buttons/Button.js'
10
+ import { FormField } from '../inputs/FormField.js'
11
+ import { TextField } from '../inputs/TextField.js'
12
+ import {
13
+ ConnectionButtonRow,
14
+ ConnectionFieldGrid,
15
+ ConnectionPanel,
16
+ ConnectionPanelText,
17
+ ConnectionPanelTitle,
18
+ ConnectionTestResult,
19
+ } from './ConnectionSetup.js'
20
+
21
+ /**
22
+ * Bridge surface the component talks to. Overridable for tests; production
23
+ * defaults to the shell credentials bridge. The shell owns all state — this
24
+ * component never caches connection state and never sees secret material.
25
+ */
26
+ export interface ProviderConnectionClient {
27
+ status(request: { id: string }): Promise<OAuthCredentialStatus>
28
+ setOAuthConfig(request: {
29
+ id: string
30
+ clientId: string
31
+ clientSecret: string
32
+ scopes?: string[]
33
+ }): Promise<unknown>
34
+ connectOAuth(request: {
35
+ id: string
36
+ scopes?: string[]
37
+ }): Promise<{ email?: string }>
38
+ disconnect(request: { id: string; forget?: boolean }): Promise<unknown>
39
+ }
40
+
41
+ const bridgeClient: ProviderConnectionClient = {
42
+ status: readCredentialStatus,
43
+ setOAuthConfig: setCredentialOAuthConfig,
44
+ connectOAuth: connectCredentialOAuth,
45
+ disconnect: disconnectCredential,
46
+ }
47
+
48
+ export interface ProviderConnectionProps {
49
+ /** Shell credential id, e.g. `google.oauth`. */
50
+ credentialId: string
51
+ title: string
52
+ description?: string
53
+ /** Scopes this app needs; merged into the connection request. */
54
+ scopes?: string[]
55
+ /** "How to create an OAuth client" walkthrough for the unconfigured state. */
56
+ helpUrl?: string
57
+ /** Called after connect/disconnect so the host app can rebuild providers. */
58
+ onStatusChange?: (status: OAuthCredentialStatus) => void
59
+ client?: ProviderConnectionClient
60
+ }
61
+
62
+ type Busy = 'idle' | 'saving' | 'connecting' | 'disconnecting'
63
+
64
+ export function ProviderConnection({
65
+ credentialId,
66
+ title,
67
+ description,
68
+ scopes,
69
+ helpUrl,
70
+ onStatusChange,
71
+ client = bridgeClient,
72
+ }: ProviderConnectionProps): React.ReactElement {
73
+ const [status, setStatus] = useState<OAuthCredentialStatus | null>(null)
74
+ const [clientIdDraft, setClientIdDraft] = useState('')
75
+ const [clientSecretDraft, setClientSecretDraft] = useState('')
76
+ const [busy, setBusy] = useState<Busy>('idle')
77
+ const [error, setError] = useState<string | null>(null)
78
+
79
+ const refresh = useCallback(async () => {
80
+ const next = await client.status({ id: credentialId })
81
+ setStatus(next)
82
+ onStatusChange?.(next)
83
+ return next
84
+ }, [client, credentialId, onStatusChange])
85
+
86
+ useEffect(() => {
87
+ refresh().catch(cause =>
88
+ setError(cause instanceof Error ? cause.message : String(cause)),
89
+ )
90
+ }, [refresh])
91
+
92
+ const scopesRequest = useMemo(
93
+ () => (scopes?.length ? { scopes } : {}),
94
+ [scopes],
95
+ )
96
+
97
+ const saveConfig = useCallback(async () => {
98
+ setBusy('saving')
99
+ setError(null)
100
+ try {
101
+ await client.setOAuthConfig({
102
+ id: credentialId,
103
+ clientId: clientIdDraft,
104
+ clientSecret: clientSecretDraft,
105
+ ...scopesRequest,
106
+ })
107
+ // Write-only: the secret leaves the DOM the moment it is saved.
108
+ setClientSecretDraft('')
109
+ setClientIdDraft('')
110
+ await refresh()
111
+ } catch (cause) {
112
+ setError(cause instanceof Error ? cause.message : String(cause))
113
+ } finally {
114
+ setBusy('idle')
115
+ }
116
+ }, [client, credentialId, clientIdDraft, clientSecretDraft, refresh, scopesRequest])
117
+
118
+ const connect = useCallback(async () => {
119
+ setBusy('connecting')
120
+ setError(null)
121
+ try {
122
+ await client.connectOAuth({ id: credentialId, ...scopesRequest })
123
+ await refresh()
124
+ } catch (cause) {
125
+ setError(cause instanceof Error ? cause.message : String(cause))
126
+ } finally {
127
+ setBusy('idle')
128
+ }
129
+ }, [client, credentialId, refresh, scopesRequest])
130
+
131
+ const disconnect = useCallback(async () => {
132
+ setBusy('disconnecting')
133
+ setError(null)
134
+ try {
135
+ await client.disconnect({ id: credentialId })
136
+ await refresh()
137
+ } catch (cause) {
138
+ setError(cause instanceof Error ? cause.message : String(cause))
139
+ } finally {
140
+ setBusy('idle')
141
+ }
142
+ }, [client, credentialId, refresh])
143
+
144
+ const configured = status?.configured === true
145
+ const connected = status?.connected === true
146
+ const needsReconnect = status?.needsReconnect === true
147
+ const working = busy !== 'idle'
148
+
149
+ return (
150
+ <ConnectionPanel aria-busy={working}>
151
+ <ConnectionPanelTitle>{title}</ConnectionPanelTitle>
152
+ {description ? (
153
+ <ConnectionPanelText>{description}</ConnectionPanelText>
154
+ ) : null}
155
+
156
+ {!configured ? (
157
+ <>
158
+ <ConnectionPanelText>
159
+ Paste the OAuth client id and secret from your Google Cloud
160
+ console.{' '}
161
+ {helpUrl ? (
162
+ <a href={helpUrl} target="_blank" rel="noreferrer">
163
+ How to create an OAuth client
164
+ </a>
165
+ ) : null}
166
+ </ConnectionPanelText>
167
+ <ConnectionFieldGrid>
168
+ <FormField label="Client id" htmlFor={`${credentialId}-client-id`}>
169
+ <TextField
170
+ id={`${credentialId}-client-id`}
171
+ value={clientIdDraft}
172
+ autoComplete="off"
173
+ spellCheck={false}
174
+ disabled={working}
175
+ onChange={event => setClientIdDraft(event.target.value)}
176
+ />
177
+ </FormField>
178
+ <FormField
179
+ label="Client secret"
180
+ htmlFor={`${credentialId}-client-secret`}
181
+ hint="Stored in the encrypted vault; never shown again."
182
+ >
183
+ <TextField
184
+ id={`${credentialId}-client-secret`}
185
+ type="password"
186
+ value={clientSecretDraft}
187
+ autoComplete="off"
188
+ spellCheck={false}
189
+ disabled={working}
190
+ onChange={event => setClientSecretDraft(event.target.value)}
191
+ />
192
+ </FormField>
193
+ </ConnectionFieldGrid>
194
+ <ConnectionButtonRow>
195
+ <Button
196
+ type="button"
197
+ disabled={
198
+ working || !clientIdDraft.trim() || !clientSecretDraft.trim()
199
+ }
200
+ onClick={() => void saveConfig()}
201
+ >
202
+ {busy === 'saving' ? 'Saving…' : 'Save'}
203
+ </Button>
204
+ </ConnectionButtonRow>
205
+ </>
206
+ ) : null}
207
+
208
+ {configured && !connected && !needsReconnect ? (
209
+ <ConnectionButtonRow>
210
+ <Button
211
+ type="button"
212
+ variant="subtle"
213
+ disabled={working}
214
+ onClick={() => void disconnect()}
215
+ >
216
+ Remove
217
+ </Button>
218
+ <Button
219
+ type="button"
220
+ disabled={working}
221
+ onClick={() => void connect()}
222
+ >
223
+ {busy === 'connecting' ? 'Waiting for browser…' : 'Sign in with Google'}
224
+ </Button>
225
+ </ConnectionButtonRow>
226
+ ) : null}
227
+
228
+ {needsReconnect ? (
229
+ <ConnectionTestResult
230
+ tone="warning"
231
+ title="Reconnect required"
232
+ message={
233
+ status?.email
234
+ ? `Access for ${status.email} has expired or was revoked.`
235
+ : 'Access has expired or was revoked.'
236
+ }
237
+ />
238
+ ) : null}
239
+
240
+ {configured && (connected || needsReconnect) ? (
241
+ <ConnectionButtonRow>
242
+ <Button
243
+ type="button"
244
+ variant="subtle"
245
+ disabled={working}
246
+ onClick={() => void disconnect()}
247
+ >
248
+ Disconnect
249
+ </Button>
250
+ <Button
251
+ type="button"
252
+ disabled={working}
253
+ onClick={() => void connect()}
254
+ >
255
+ {busy === 'connecting'
256
+ ? 'Waiting for browser…'
257
+ : needsReconnect
258
+ ? 'Reconnect'
259
+ : 'Reconnect account'}
260
+ </Button>
261
+ </ConnectionButtonRow>
262
+ ) : null}
263
+
264
+ {connected ? (
265
+ <ConnectionTestResult
266
+ tone="success"
267
+ title={status?.email ? `Connected as ${status.email}` : 'Connected'}
268
+ message="Mail and Calendar share this connection."
269
+ />
270
+ ) : null}
271
+
272
+ {error ? (
273
+ <ConnectionTestResult tone="error" title="Something went wrong" message={error} />
274
+ ) : null}
275
+ </ConnectionPanel>
276
+ )
277
+ }
@@ -24,3 +24,8 @@ export {
24
24
  type ConnectionTestResultProps,
25
25
  type ConnectionTestTone,
26
26
  } from './ConnectionSetup.js'
27
+ export {
28
+ ProviderConnection,
29
+ type ProviderConnectionClient,
30
+ type ProviderConnectionProps,
31
+ } from './ProviderConnection.js'
@@ -62,10 +62,10 @@ describe('AppFrame', () => {
62
62
 
63
63
  expect(document.getElementById(MENU_PORTAL_ID)).not.toBeNull()
64
64
  const baseStyles = document.getElementById(PLATFORM_APP_FRAME_BASE_STYLE_ID)
65
- expect(baseStyles).not.toBeNull()
66
- expect(baseStyles?.textContent).toContain('box-sizing: border-box')
67
- expect(baseStyles?.textContent).toContain('scrollbar-color')
68
- expect(document.getElementById(PLATFORM_FONT_STYLESHEET_ID)).not.toBeNull()
65
+ expect(baseStyles).not.toBeNull()
66
+ expect(baseStyles?.textContent).toContain('box-sizing: border-box')
67
+ expect(baseStyles?.textContent).toContain('scrollbar-color')
68
+ expect(document.getElementById(PLATFORM_FONT_STYLESHEET_ID)).not.toBeNull()
69
69
  expect(container.querySelector('[data-app="tasks"]')).not.toBeNull()
70
70
  expect(document.documentElement.dataset.app).toBe('tasks')
71
71
  expect(container.textContent).toContain('App content')
@@ -15,12 +15,18 @@ export interface AppFrameProps
15
15
  themeFromBridge?: boolean
16
16
  /** Optional actions rendered in the app header's action slot. */
17
17
  headerActions?: ReactNode
18
- }
18
+ /** Open document's file name, shown beside the wordmark. */
19
+ headerDocumentName?: ReactNode
20
+ /** Hide the app wordmark when identity is already provided by host chrome. */
21
+ hideWordmark?: boolean
22
+ /** Optional app identity slug used before the shell bridge is ready. */
23
+ identityAppSlug?: string
24
+ }
19
25
 
20
26
  const StyledRoot = styled.div`
21
27
  display: flex;
22
28
  flex-direction: column;
23
- /* width: 100%; */
29
+ width: 100%;
24
30
  height: 100%;
25
31
  min-width: 0;
26
32
  min-height: 0;
@@ -36,9 +42,12 @@ export function AppFrame({
36
42
  children,
37
43
  className,
38
44
  themeFromBridge = true,
39
- headerActions,
40
- ...rest
41
- }: AppFrameProps): React.ReactElement {
45
+ headerActions,
46
+ headerDocumentName,
47
+ hideWordmark = false,
48
+ identityAppSlug,
49
+ ...rest
50
+ }: AppFrameProps): React.ReactElement {
42
51
  const { meta, ready } = usePlatformBridge()
43
52
  const { manifest } = usePlatformAppManifest(meta?.appSlug ?? null, ready)
44
53
 
@@ -48,17 +57,18 @@ export function AppFrame({
48
57
 
49
58
  useEffect(() => {
50
59
  const root = document.documentElement
51
- if (meta?.appSlug) {
52
- root.dataset.app = meta.appSlug
60
+ const appSlug = meta?.appSlug ?? identityAppSlug
61
+ if (appSlug) {
62
+ root.dataset.app = appSlug
53
63
  } else {
54
64
  delete root.dataset.app
55
65
  }
56
66
  return () => {
57
- if (root.dataset.app === meta?.appSlug) {
67
+ if (root.dataset.app === appSlug) {
58
68
  delete root.dataset.app
59
69
  }
60
70
  }
61
- }, [meta?.appSlug])
71
+ }, [identityAppSlug, meta?.appSlug])
62
72
 
63
73
  usePlatformThemeFromBridge(themeFromBridge)
64
74
  const themeAttrs = usePlatformThemeDocumentAttrs(themeFromBridge)
@@ -73,7 +83,13 @@ export function AppFrame({
73
83
  data-platform-surface-palette={themeAttrs.surfacePalette || undefined}
74
84
  {...rest}
75
85
  >
76
- {manifest && <AppHeader manifest={manifest} actions={headerActions} />}
86
+ {manifest && !hideWordmark && (
87
+ <AppHeader
88
+ manifest={manifest}
89
+ actions={headerActions}
90
+ documentName={headerDocumentName}
91
+ />
92
+ )}
77
93
  {children}
78
94
  </StyledRoot>
79
95
  </>