Skip to content

Commit c47ab63

Browse files
authored
Handle integrations toggle on editor sidebar (#1787)
1 parent eb654d0 commit c47ab63

File tree

46 files changed

+2927
-162
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+2927
-162
lines changed

apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/Editor/SidebarArea/Section/index.tsx

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,6 @@ export function SectionLoader({ items = 3 }: { items: number }) {
2323
)
2424
}
2525

26-
const SidebarTitle = ({ children }: { children: ReactNode }) => {
27-
return <Text.H5M>{children}</Text.H5M>
28-
}
29-
3026
type SectionAction = {
3127
iconProps?: IconProps
3228
onClick: () => void
@@ -37,18 +33,14 @@ const SidebarSection = ({
3733
title,
3834
actions,
3935
}: {
40-
title: string | ReactNode
36+
title: string
4137
children?: ReactNode
4238
actions?: SectionAction[]
4339
}) => {
4440
return (
4541
<div className='flex flex-col gap-y-2'>
4642
<div className='flex justify-between gap-x-3'>
47-
{typeof title === 'string' ? (
48-
<SidebarTitle>{title}</SidebarTitle>
49-
) : (
50-
title
51-
)}
43+
<Text.H5M>{title}</Text.H5M>
5244

5345
{actions ? (
5446
<div className='flex flex-row gap-x-2'>
@@ -70,6 +62,4 @@ const SidebarSection = ({
7062
)
7163
}
7264

73-
SidebarSection.Title = SidebarTitle
74-
7565
export { SidebarSection }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { useCallback, use, useMemo, useEffect, useState, useRef } from 'react'
2+
import { IndentationBar } from '$/components/Sidebar/Files/IndentationBar'
3+
import useIntegrationTools from '$/stores/integrationTools'
4+
import { Text } from '@latitude-data/web-ui/atoms/Text'
5+
import { Skeleton } from '@latitude-data/web-ui/atoms/Skeleton'
6+
import { SwitchToggle } from '@latitude-data/web-ui/atoms/Switch'
7+
import { useCurrentCommit } from '$/app/providers/CommitProvider'
8+
import { Button } from '@latitude-data/web-ui/atoms/Button'
9+
import { Input } from '@latitude-data/web-ui/atoms/Input'
10+
import { ActiveIntegration } from '../../../toolsHelpers/types'
11+
import { ToolsContext } from '../../ToolsProvider'
12+
import { useActiveIntegrationsStore } from '../../hooks/useActiveIntegrationsStore'
13+
import { useAnimatedItems } from './useAnimatedItems'
14+
15+
const MAX_VISIBLE_TOOLS = 10
16+
17+
function ToolListLoader({ numberOfItems }: { numberOfItems: number }) {
18+
const toolItems = useMemo(
19+
() => Array.from({ length: numberOfItems }),
20+
[numberOfItems],
21+
)
22+
return (
23+
<div className='flex flex-col gap-1'>
24+
{toolItems.map((_, index) => (
25+
<div key={index} className='flex items-center justify-between h-6'>
26+
<div className='flex items-center gap-2'>
27+
<IndentationBar
28+
startOnIndex={0}
29+
hasChildren={false}
30+
indentation={[{ isLast: index === toolItems.length - 1 }]}
31+
/>
32+
<div className='flex items-center gap-2'>
33+
<Skeleton height='h6' className='w-32' />
34+
</div>
35+
</div>
36+
</div>
37+
))}
38+
</div>
39+
)
40+
}
41+
42+
function checkIsActive(
43+
activeTools: ActiveIntegration['tools'],
44+
toolName: string,
45+
) {
46+
if (typeof activeTools === 'boolean') {
47+
return activeTools === true
48+
}
49+
return activeTools.includes(toolName)
50+
}
51+
52+
const TOOL_EMPTY: [] = []
53+
export function ToolList({ integration }: { integration: ActiveIntegration }) {
54+
const {
55+
data: tools = TOOL_EMPTY,
56+
isLoading,
57+
error,
58+
} = useIntegrationTools(integration)
59+
const { addIntegrationTool, removeIntegrationTool } = use(ToolsContext)
60+
const setIntegrationToolNames = useActiveIntegrationsStore(
61+
(state) => state.setIntegrationToolNames,
62+
)
63+
const { commit } = useCurrentCommit()
64+
const isLive = !!commit.mergedAt
65+
const [showAll, setShowAll] = useState(false)
66+
const [searchQuery, setSearchQuery] = useState('')
67+
const containerRef = useRef<HTMLDivElement>(null)
68+
69+
const toggleTool = useCallback(
70+
(toolName: string) => () => {
71+
if (isLive) return
72+
if (!tools) return
73+
74+
const isActive = checkIsActive(integration.tools, toolName)
75+
76+
if (isActive) {
77+
removeIntegrationTool({
78+
integrationName: integration.name,
79+
toolName,
80+
allToolNames: tools.map((t) => t.name),
81+
})
82+
} else {
83+
addIntegrationTool({
84+
integrationName: integration.name,
85+
toolName,
86+
})
87+
}
88+
},
89+
[tools, isLive, addIntegrationTool, removeIntegrationTool, integration],
90+
)
91+
// Sort tools to show active ones first (in document order), then inactive ones
92+
const sortedTools = useMemo(() => {
93+
if (!tools || tools.length === 0) return []
94+
95+
const activeToolNames = Array.isArray(integration.tools)
96+
? integration.tools
97+
: []
98+
99+
// Split into active and inactive
100+
const activeTools: typeof tools = []
101+
const inactiveTools: typeof tools = []
102+
103+
tools.forEach((tool) => {
104+
if (integration.tools === true || activeToolNames.includes(tool.name)) {
105+
activeTools.push(tool)
106+
} else {
107+
inactiveTools.push(tool)
108+
}
109+
})
110+
111+
// Sort active tools by their position in the config
112+
if (Array.isArray(integration.tools)) {
113+
activeTools.sort((a, b) => {
114+
const indexA = activeToolNames.indexOf(a.name)
115+
const indexB = activeToolNames.indexOf(b.name)
116+
return indexA - indexB
117+
})
118+
}
119+
120+
return [...activeTools, ...inactiveTools]
121+
}, [tools, integration.tools])
122+
123+
// Filter tools based on search query
124+
const filteredTools = useMemo(() => {
125+
if (!searchQuery.trim()) return sortedTools
126+
127+
const query = searchQuery.toLowerCase()
128+
return sortedTools.filter((tool) => {
129+
const name = tool.name.toLowerCase()
130+
const displayName = (tool.displayName ?? '').toLowerCase()
131+
return name.includes(query) || displayName.includes(query)
132+
})
133+
}, [sortedTools, searchQuery])
134+
135+
useEffect(() => {
136+
if (tools && tools.length > 0) {
137+
setIntegrationToolNames({
138+
integrationName: integration.name,
139+
toolNames: tools.map((t) => t.name),
140+
})
141+
}
142+
}, [tools, integration.name, setIntegrationToolNames])
143+
144+
const hasMoreTools = sortedTools.length > MAX_VISIBLE_TOOLS
145+
const shouldShowSearch = sortedTools.length > MAX_VISIBLE_TOOLS
146+
const visibleTools = showAll
147+
? filteredTools
148+
: filteredTools.slice(0, MAX_VISIBLE_TOOLS)
149+
const shouldShowMoreButton = hasMoreTools && !showAll && !searchQuery.trim()
150+
useAnimatedItems({
151+
containerRef,
152+
isLoading,
153+
sortedTools,
154+
error,
155+
})
156+
157+
if (isLoading) return <ToolListLoader numberOfItems={5} />
158+
159+
if (error) {
160+
return (
161+
<div className='w-full h-full flex flex-col gap-2 bg-destructive-muted p-4 rounded-xl'>
162+
<Text.H5B color='destructiveMutedForeground'>
163+
Error loading tools
164+
</Text.H5B>
165+
<Text.H6 color='destructiveMutedForeground'>{error.message}</Text.H6>
166+
{integration.tools !== undefined && (
167+
<Button
168+
variant='outline'
169+
className='border-destructive-muted-foreground'
170+
onClick={() => {
171+
removeIntegrationTool({
172+
integrationName: integration.name,
173+
toolName: '*',
174+
allToolNames: [],
175+
})
176+
}}
177+
>
178+
<Text.H6 color='destructiveMutedForeground'>
179+
Remove from prompt
180+
</Text.H6>
181+
</Button>
182+
)}
183+
</div>
184+
)
185+
}
186+
187+
return (
188+
<div
189+
ref={containerRef}
190+
className='flex flex-col gap-1 min-w-0'
191+
style={{ position: 'relative' }}
192+
>
193+
{shouldShowSearch && (
194+
<div className='mb-2 mx-2'>
195+
<Input
196+
type='text'
197+
size='small'
198+
placeholder='Search tools...'
199+
value={searchQuery}
200+
onChange={(e) => setSearchQuery(e.target.value)}
201+
className='w-full'
202+
/>
203+
</div>
204+
)}
205+
{visibleTools.map((tool, index) => {
206+
const isActive = checkIsActive(integration.tools, tool.name)
207+
const isLastTool = index === visibleTools.length - 1
208+
return (
209+
<div
210+
role='button'
211+
tabIndex={0}
212+
aria-disabled={isLive}
213+
key={tool.name}
214+
data-tool-id={tool.name}
215+
onClick={toggleTool(tool.name)}
216+
className='w-full flex items-center justify-between'
217+
>
218+
<div className='w-full flex items-center gap-2 min-w-0'>
219+
<IndentationBar
220+
startOnIndex={0}
221+
hasChildren={false}
222+
indentation={[{ isLast: isLastTool && !shouldShowMoreButton }]}
223+
/>
224+
<div className='w-full flex justify-between items-center min-w-0 gap-x-2'>
225+
<div className='flex-1 flex items-center gap-2 min-w-0'>
226+
<Text.H5 ellipsis noWrap color='foreground'>
227+
{tool.displayName ?? tool.name}
228+
</Text.H5>
229+
</div>
230+
<SwitchToggle
231+
checked={isActive}
232+
onClick={toggleTool(tool.name)}
233+
disabled={isLive}
234+
/>
235+
</div>
236+
</div>
237+
</div>
238+
)
239+
})}
240+
{shouldShowMoreButton && (
241+
<button
242+
onClick={() => setShowAll(true)}
243+
className='w-full flex items-center gap-2 min-w-0 cursor-pointer hover:opacity-70'
244+
>
245+
<IndentationBar
246+
startOnIndex={0}
247+
hasChildren={false}
248+
indentation={[{ isLast: true }]}
249+
/>
250+
<Text.H5 color='accentForeground'>
251+
+ Show {filteredTools.length - MAX_VISIBLE_TOOLS} more
252+
</Text.H5>
253+
</button>
254+
)}
255+
</div>
256+
)
257+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { McpToolDto } from '$/stores/integrationTools'
2+
import { RefObject, useLayoutEffect, useRef } from 'react'
3+
4+
export function useAnimatedItems({
5+
error,
6+
isLoading,
7+
sortedTools,
8+
containerRef,
9+
}: {
10+
containerRef: RefObject<HTMLElement | null>
11+
error: unknown
12+
isLoading: boolean
13+
sortedTools: McpToolDto[]
14+
}) {
15+
const prevPositionsRef = useRef<Map<string, DOMRect>>(new Map())
16+
useLayoutEffect(() => {
17+
if (isLoading || error) return
18+
19+
const container = containerRef.current
20+
if (!container) return
21+
22+
const children = Array.from(
23+
container.querySelectorAll('[data-tool-id]'),
24+
) as HTMLElement[]
25+
26+
children.forEach((child) => {
27+
const id = child.dataset.toolId
28+
if (!id) return
29+
30+
const prevRect = prevPositionsRef.current.get(id)
31+
const currentRect = child.getBoundingClientRect()
32+
33+
if (prevRect) {
34+
const deltaY = prevRect.top - currentRect.top
35+
36+
if (deltaY !== 0) {
37+
// Invert: Move element to its previous position
38+
child.style.transform = `translateY(${deltaY}px)`
39+
child.style.transition = 'none'
40+
41+
// Play: Animate to natural position
42+
requestAnimationFrame(() => {
43+
child.style.transition = 'transform 0.3s ease-in-out'
44+
child.style.transform = ''
45+
})
46+
}
47+
}
48+
49+
// Store current position for next time
50+
prevPositionsRef.current.set(id, currentRect)
51+
})
52+
}, [sortedTools, isLoading, error, containerRef])
53+
}

0 commit comments

Comments
 (0)