@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.
Files changed (75) hide show
  1. package/package.json +35 -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 +36 -2
  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/credentials.d.mts +62 -0
  14. package/src/bridge/credentials.mjs +53 -0
  15. package/src/bridge/events.d.mts +1 -0
  16. package/src/bridge/events.mjs +1 -0
  17. package/src/bridge/googleAuth.ts +7 -152
  18. package/src/bridge/mcpClient.d.mts +74 -0
  19. package/src/bridge/mcpClient.mjs +79 -0
  20. package/src/bridge/methods.d.mts +22 -0
  21. package/src/bridge/methods.mjs +23 -0
  22. package/src/bridge/notifications.d.mts +12 -0
  23. package/src/bridge/notifications.mjs +36 -0
  24. package/src/bridge/operations.d.mts +44 -0
  25. package/src/bridge/operations.mjs +40 -0
  26. package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
  27. package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
  28. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -105
  29. package/src/bridge/react/usePlatformAppSettings.tsx +104 -104
  30. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -152
  31. package/src/bridge/react/usePlatformJsonStore.tsx +149 -149
  32. package/src/bridge/react/usePlatformOperations.tsx +75 -0
  33. package/src/bridge/storage.d.mts +17 -4
  34. package/src/bridge/storage.mjs +6 -3
  35. package/src/components/agents/AgentComposer.tsx +10 -6
  36. package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
  37. package/src/components/agents/AgentDrawerPanel.tsx +23 -64
  38. package/src/components/agents/AgentMessageBubble.tsx +59 -33
  39. package/src/components/agents/AgentMessageList.tsx +4 -9
  40. package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
  41. package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
  42. package/src/components/agents/AgentToolPendingList.tsx +6 -6
  43. package/src/components/agents/AgentToolPendingReview.tsx +6 -6
  44. package/src/components/agents/QuestionRequestList.tsx +278 -0
  45. package/src/components/agents/agentPanelStyles.ts +9 -33
  46. package/src/components/agents/agentTypes.ts +25 -43
  47. package/src/components/agents/index.ts +4 -0
  48. package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
  49. package/src/components/chrome/WorkspaceTabStrip.tsx +4 -3
  50. package/src/components/common/chat/ChatBox.tsx +9 -9
  51. package/src/components/common/chat/ChatModelMenu.tsx +34 -4
  52. package/src/components/common/chat/ChatThread.tsx +23 -7
  53. package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
  54. package/src/components/common/connections/ProviderConnection.tsx +277 -0
  55. package/src/components/common/connections/index.ts +5 -0
  56. package/src/components/common/containers/AppFrame.tsx +26 -19
  57. package/src/components/common/containers/AppHeader.tsx +20 -20
  58. package/src/components/common/documents/DocumentSwitcher.tsx +63 -58
  59. package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
  60. package/src/components/common/dropdown/MenuPortal.tsx +3 -1
  61. package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
  62. package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
  63. package/src/components/common/overlays/Modal.tsx +13 -2
  64. package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
  65. package/src/ics/generateIcs.test.ts +153 -0
  66. package/src/ics/generateIcs.ts +197 -0
  67. package/src/index.ts +3 -0
  68. package/src/net/httpRetry.test.ts +117 -0
  69. package/src/net/httpRetry.ts +111 -0
  70. package/src/theme/appAccents.ts +6 -0
  71. package/src/theme/appIdentityCss.mjs +11 -0
  72. package/src/theme/appIdentityCss.ts +11 -0
  73. package/src/theme/themes/dark.ts +3 -1
  74. package/src/theme/themes/light.ts +3 -1
  75. package/src/bridge/googleProviderConfig.ts +0 -73
@@ -4,7 +4,10 @@ import { FlexCol, FlexRow } from '../common/containers/flex.js'
4
4
  export const StyledAgentDrawer = styled(FlexCol)`
5
5
  flex: 1 1 auto;
6
6
  min-height: 0;
7
+ min-width: 0;
7
8
  width: 100%;
9
+ box-sizing: border-box;
10
+ overflow: hidden;
8
11
  background: var(--platform-colors-app-viewport);
9
12
  margin: 0;
10
13
  `
@@ -23,17 +26,15 @@ export const StyledAgentThreadHost = styled.div`
23
26
  width: 100%;
24
27
  `
25
28
 
26
- export const StyledAgentScroll = styled.div<{ $threadHeaderHeight: number }>`
27
- position: relative;
29
+ export const StyledAgentScroll = styled.div`
30
+ flex: 1 1 auto;
28
31
  min-height: 0;
32
+ min-width: 0;
33
+ width: 100%;
34
+ box-sizing: border-box;
35
+ overflow: hidden;
29
36
  display: flex;
30
37
  flex-direction: column;
31
- --platform-agent-thread-header-height: 0px;
32
-
33
- &[data-thread-header] {
34
- --platform-agent-thread-header-height: ${({ $threadHeaderHeight }) =>
35
- $threadHeaderHeight}px;
36
- }
37
38
  `
38
39
 
39
40
  export const StyledAgentFooter = styled.div`
@@ -45,31 +46,6 @@ export const StyledAgentFooter = styled.div`
45
46
  background: var(--platform-colors-surface);
46
47
  `
47
48
 
48
- /** Delay footer reserve collapse so the portaled chatbox can lead the motion. */
49
- export const StyledComposerReserveFooter = styled(StyledAgentFooter)`
50
- overflow: hidden;
51
- gap: var(--platform-spacing-sm);
52
- padding-block: 8px 12px;
53
- border-top: 1px solid var(--platform-colors-border);
54
- transition: padding-block 0.5s linear, gap 0.5s linear,
55
- border-color 0.5s linear;
56
-
57
- [data-pure-assistant-tab-active='true'] & {
58
- gap: 0;
59
- padding-block: 0;
60
- border-top-color: transparent;
61
- }
62
- `
63
-
64
- export const StyledComposerFooterSpacer = styled.div`
65
- min-height: 112px;
66
- transition: min-height 0.5s linear;
67
-
68
- [data-pure-assistant-tab-active='true'] & {
69
- min-height: 0;
70
- }
71
- `
72
-
73
49
  export const StyledToolCard = styled.div`
74
50
  display: flex;
75
51
  flex-direction: column;
@@ -12,14 +12,22 @@ export type AgentUiRunState =
12
12
 
13
13
  export type AgentUiMessageRole = 'user' | 'assistant' | 'tool'
14
14
 
15
- export interface AgentUiToolCall {
16
- id: string
17
- name: string
18
- arguments: Record<string, unknown>
19
- result?: string
20
- status?: 'running' | 'done'
21
- delegatedActivity?: AgentDelegatedActivity
22
- }
15
+ export interface AgentUiToolCall {
16
+ id: string
17
+ name: string
18
+ arguments: Record<string, unknown>
19
+ result?: string
20
+ status?: 'running' | 'done'
21
+ delegatedActivity?: AgentDelegatedActivity
22
+ serviceActivity?: AgentToolActivity
23
+ }
24
+
25
+ export interface AgentToolActivity {
26
+ detail?: string
27
+ id: string
28
+ status: 'done' | 'failed' | 'info' | 'running'
29
+ title: string
30
+ }
23
31
 
24
32
  export const AgentLivePhase = {
25
33
  Idle: 'idle',
@@ -92,50 +100,24 @@ export interface AgentUiRunUsage {
92
100
  export interface AgentDrawerPanelProps {
93
101
  messages: AgentUiMessage[]
94
102
  assistantLabel?: string
95
- liveTurn?: AgentLiveTurn | null
96
103
  pendingToolCalls: AgentUiToolCall[]
97
- pendingQuestionToolCalls?: AgentUiToolCall[]
98
- runState: AgentUiRunState | null
99
- busy: boolean
100
104
  error: string | null
101
- composerValue: string
102
- composerDisabled?: boolean
103
- composerInputDisabled?: boolean
104
- composerSendDisabled?: boolean
105
- composerLeading?: React.ReactNode
106
- followUps?: AgentUiFollowUp[]
105
+ composerSlot?: React.ReactNode
107
106
  activityHint?: string | null
108
- onComposerChange: (value: string) => void
109
- onSend: () => void
110
- onStop?: () => void
111
- onRemoveFollowUp?: (followUpId: string) => void
112
- onSendFollowUpNow?: (followUpId: string) => void
113
107
  onApproveTools: () => void
114
108
  onRejectTools: () => void
115
109
  onApproveOne?: (toolCallId: string) => void
116
110
  onRejectOne?: (toolCallId: string) => void
117
- onApproveAlwaysForSession?: (
118
- qualifiedName: string,
119
- toolCallId: string,
120
- ) => void
121
- onAnswerQuestionTool?: (
122
- toolCallId: string,
123
- selectedOptionIds: readonly string[],
124
- ) => void | Promise<void>
125
- onDeclineQuestionTool?: (
126
- toolCallId: string,
127
- declineReason: string,
128
- ) => void | Promise<void>
111
+ /** Label for the standing-approval option ("Always approve for Writer"). */
112
+ approveAlwaysScopeLabel?: string
113
+ onApproveAlwaysForScope?: (qualifiedName: string, toolCallId: string) => void
129
114
  storageHint?: string | null
130
- usage?: AgentUiRunUsage | null
115
+ storageHintAction?: React.ReactNode
116
+ onStorageHintDismiss?: () => void
131
117
  /** Pure Assistant workspace tab is active — shell sets this; UI leaves layout to the host. */
132
118
  pureAssistantTabActive?: boolean
133
- /** Agent drawer panel is open (including forced-open on Pure Assistant). */
134
- drawerOpen?: boolean
135
- /** Composer is portaled by the shell — drawer keeps a footer spacer only. */
136
- composerPortaled?: boolean
137
- /** Shell thread chrome (scope, session actions) — anchored to the message column. */
119
+ /** Shell thread chrome rendered above the scrollable message column. */
138
120
  threadHeader?: React.ReactNode
139
- /** Reserved top space for an absolute thread header. */
140
- threadHeaderHeight?: number
121
+ /** Shell-owned content rendered between the message thread and interaction footer. */
122
+ bottomPanel?: React.ReactNode
141
123
  }
@@ -3,6 +3,10 @@ export { AgentMessageList, formatAssistantLabel } from './AgentMessageList.js'
3
3
  export { AgentMessageBubble } from './AgentMessageBubble.js'
4
4
  export { AgentToolPendingList } from './AgentToolPendingList.js'
5
5
  export { AgentComposer } from './AgentComposer.js'
6
+ export {
7
+ QuestionRequestList,
8
+ type QuestionRequestView,
9
+ } from './QuestionRequestList.js'
6
10
  export {
7
11
  AgentContextUsageBar,
8
12
  formatAgentTokenCount,
@@ -12,20 +12,26 @@ const StyledList = styled.div`
12
12
  flex: 1;
13
13
  min-height: 0;
14
14
  overflow: auto;
15
- padding: 14px 24px 24px;
15
+ /* No top padding: it's inside the scrollport, so a sticky header pinned at
16
+ top:0 would leave/scroll a gap above it (the "jumps down / white space"). */
17
+ padding: 0 24px 24px;
16
18
  `
17
19
 
18
20
  const StyledSectionHeading = styled.div`
19
- padding: var(--platform-spacing-sm) 2px var(--platform-spacing-xs);
21
+ /* Full-bleed opaque bar: negative inline margin + matching padding makes the
22
+ background span the whole sidebar so rows scroll fully under it (they used
23
+ to overrun/bleed through the label); text stays aligned with the rows. */
24
+ margin: 0 -24px;
25
+ padding: var(--platform-spacing-sm) 24px var(--platform-spacing-xs);
20
26
  font-size: var(--platform-typography-font-size-xs);
21
27
  font-weight: var(--platform-typography-font-weight-medium);
22
28
  letter-spacing: 0.04em;
23
29
  text-transform: uppercase;
24
30
  color: var(--platform-colors-text-secondary);
25
- background: transparent;
31
+ background: var(--platform-colors-surface);
26
32
  position: sticky;
27
33
  top: 0;
28
- z-index: 1;
34
+ z-index: 2;
29
35
  `
30
36
 
31
37
  const StyledEmptyList = styled.div`
@@ -28,7 +28,7 @@ const StyledScroll = styled.div`
28
28
  flex: 1;
29
29
  min-width: 0;
30
30
  gap: 2px;
31
- padding: 5px 8px 0;
31
+ padding: 5px 10px 0 12px;
32
32
  overflow-x: auto;
33
33
  overflow-y: hidden;
34
34
  scrollbar-width: thin;
@@ -46,7 +46,7 @@ const StyledTab = styled.button<{
46
46
  height: 31px;
47
47
  max-width: 260px;
48
48
  margin-bottom: -1px;
49
- padding: 0 8px 0 9px;
49
+ padding: 0 8px 0 12px;
50
50
  border: 1px solid transparent;
51
51
  border-bottom-color: transparent;
52
52
  border-radius: 11px 11px 0 0;
@@ -120,7 +120,8 @@ const StyledLabel = styled.span`
120
120
 
121
121
  const StyledTabWordmark = styled(PureAppWordmark)`
122
122
  min-width: 0;
123
- overflow: hidden;
123
+ padding-left: 1px;
124
+ overflow: visible;
124
125
 
125
126
  > span:last-child {
126
127
  overflow: hidden;
@@ -4,10 +4,10 @@ import { Send, Square } from 'lucide-react'
4
4
  import { FlexCol } from '../containers/flex.js'
5
5
  import { FlexRow } from '../containers/flex.js'
6
6
 
7
- export interface ChatBoxProps {
8
- value: string
9
- onChange: (value: string) => void
10
- onSend: () => void
7
+ export interface ChatBoxProps {
8
+ value: string
9
+ onChange: (value: string) => void
10
+ onSend: () => void
11
11
  onStop?: () => void
12
12
  sending?: boolean
13
13
  disabled?: boolean
@@ -22,10 +22,10 @@ export interface ChatBoxProps {
22
22
 
23
23
  export const ChatBox = forwardRef<HTMLTextAreaElement, ChatBoxProps>(
24
24
  function ChatBox(
25
- {
26
- value,
27
- onChange,
28
- onSend,
25
+ {
26
+ value,
27
+ onChange,
28
+ onSend,
29
29
  onStop,
30
30
  sending = false,
31
31
  disabled = false,
@@ -54,7 +54,7 @@ export const ChatBox = forwardRef<HTMLTextAreaElement, ChatBoxProps>(
54
54
  const showStop = sending && onStop
55
55
 
56
56
  return (
57
- <Root $gap="xs">
57
+ <Root $gap="xs">
58
58
  <EditorWrapper>
59
59
  {topContent ?? null}
60
60
  <Textarea
@@ -2,6 +2,7 @@ import { ChevronDown } from 'lucide-react'
2
2
  import { Fragment, useMemo } from 'react'
3
3
  import { styled } from 'styled-components'
4
4
  import { PlatformIcon } from '../../chrome/PlatformIcon.js'
5
+ import { CredentialProviderIcon } from '../../credentials/credentialProviderIcon.js'
5
6
  import { SelectMenuControl } from '../dropdown/SelectMenuControl.js'
6
7
  import type { SelectMenuPlacement } from '../dropdown/SelectMenu.js'
7
8
  import { useSelectMenuAnchor } from '../dropdown/useSelectMenuAnchor.js'
@@ -11,6 +12,7 @@ export interface ChatModelMenuOption {
11
12
  label: string
12
13
  groupLabel?: string
13
14
  meta?: string
15
+ provider?: string
14
16
  disabled?: boolean
15
17
  contextWindowTokens?: number
16
18
  supportsImageInput?: boolean
@@ -175,6 +177,12 @@ export function ChatModelMenu({
175
177
  title={triggerLabel ?? current?.label ?? ariaLabel}
176
178
  onClick={toggle}
177
179
  >
180
+ {current?.provider ? (
181
+ <CredentialProviderIcon
182
+ credentialId={`${current.provider}.apiKey`}
183
+ label={current.provider}
184
+ />
185
+ ) : null}
178
186
  <StyledLabel>{triggerLabel ?? current?.label ?? 'Model'}</StyledLabel>
179
187
  <PlatformIcon icon={ChevronDown} size={12} strokeWidth={1.75} />
180
188
  </StyledTrigger>
@@ -234,10 +242,20 @@ function ModelMenuOptions({
234
242
  onClose()
235
243
  }}
236
244
  >
237
- <StyledOptionLabel>{option.label}</StyledOptionLabel>
238
- {option.meta ? (
239
- <StyledOptionMeta>{option.meta}</StyledOptionMeta>
240
- ) : null}
245
+ <StyledOptionContent>
246
+ {option.provider ? (
247
+ <CredentialProviderIcon
248
+ credentialId={`${option.provider}.apiKey`}
249
+ label={option.provider}
250
+ />
251
+ ) : null}
252
+ <StyledOptionText>
253
+ <StyledOptionLabel>{option.label}</StyledOptionLabel>
254
+ {option.meta ? (
255
+ <StyledOptionMeta>{option.meta}</StyledOptionMeta>
256
+ ) : null}
257
+ </StyledOptionText>
258
+ </StyledOptionContent>
241
259
  </StyledOption>
242
260
  </Fragment>
243
261
  )
@@ -269,6 +287,18 @@ const StyledOption = styled.button<{ $selected?: boolean }>`
269
287
  }
270
288
  `
271
289
 
290
+ const StyledOptionContent = styled.span`
291
+ display: flex;
292
+ align-items: flex-start;
293
+ gap: 7px;
294
+ `
295
+
296
+ const StyledOptionText = styled.span`
297
+ display: flex;
298
+ flex-direction: column;
299
+ gap: 2px;
300
+ `
301
+
272
302
  const StyledOptionLabel = styled.span`
273
303
  font-size: var(--platform-typography-font-size-sm);
274
304
  line-height: 1.25;
@@ -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
+ })