@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
|
@@ -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
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
140
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
31
|
+
background: var(--platform-colors-surface);
|
|
26
32
|
position: sticky;
|
|
27
33
|
top: 0;
|
|
28
|
-
z-index:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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,
|
|
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
|
+
})
|