mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-14 01:18:15 -05:00
Compare commits
2 Commits
fix/copilo
...
fix/add-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42fa5b2198 | ||
|
|
8ccf4548a6 |
@@ -1855,25 +1855,17 @@ export function LinearIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function LemlistIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 181' fill='none'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M32.0524 0.919922H147.948C165.65 0.919922 180 15.2703 180 32.9723V148.867C180 166.57 165.65 180.92 147.948 180.92H32.0524C14.3504 180.92 0 166.57 0 148.867V32.9723C0 15.2703 14.3504 0.919922 32.0524 0.919922ZM119.562 82.8879H85.0826C82.4732 82.8879 80.3579 85.0032 80.3579 87.6126V94.2348C80.3579 96.8442 82.4732 98.9595 85.0826 98.9595H119.562C122.171 98.9595 124.286 96.8442 124.286 94.2348V87.6126C124.286 85.0032 122.171 82.8879 119.562 82.8879ZM85.0826 49.1346H127.061C129.67 49.1346 131.785 51.2499 131.785 53.8593V60.4815C131.785 63.0909 129.67 65.2062 127.061 65.2062H85.0826C82.4732 65.2062 80.3579 63.0909 80.3579 60.4815V53.8593C80.3579 51.2499 82.4732 49.1346 85.0826 49.1346ZM131.785 127.981V121.358C131.785 118.75 129.669 116.634 127.061 116.634H76.5706C69.7821 116.634 64.2863 111.138 64.2863 104.349V53.8593C64.2863 51.2513 62.1697 49.1346 59.5616 49.1346H52.9395C50.3314 49.1346 48.2147 51.2513 48.2147 53.8593V114.199C48.8497 124.133 56.7873 132.07 66.7205 132.705H127.061C129.669 132.705 131.785 130.589 131.785 127.981Z'
|
||||
fill='#316BFF'
|
||||
/>
|
||||
<path
|
||||
d='M85.0826 49.1346H127.061C129.67 49.1346 131.785 51.2499 131.785 53.8593V60.4815C131.785 63.0909 129.67 65.2062 127.061 65.2062H85.0826C82.4732 65.2062 80.3579 63.0909 80.3579 60.4815V53.8593C80.3579 51.2499 82.4732 49.1346 85.0826 49.1346Z'
|
||||
fill='white'
|
||||
/>
|
||||
<path
|
||||
d='M85.0826 82.8879H119.562C122.171 82.8879 124.286 85.0032 124.286 87.6126V94.2348C124.286 96.8442 122.171 98.9595 119.562 98.9595H85.0826C82.4732 98.9595 80.3579 96.8442 80.3579 94.2348V87.6126C80.3579 85.0032 82.4732 82.8879 85.0826 82.8879Z'
|
||||
fill='white'
|
||||
/>
|
||||
<path
|
||||
d='M131.785 121.358V127.981C131.785 130.589 129.669 132.705 127.061 132.705H66.7205C56.7873 132.07 48.8497 124.133 48.2147 114.199V53.8593C48.2147 51.2513 50.3314 49.1346 52.9395 49.1346H59.5616C62.1697 49.1346 64.2863 51.2513 64.2863 53.8593V104.349C64.2863 111.138 69.7821 116.634 76.5706 116.634H127.061C129.669 116.634 131.785 118.75 131.785 121.358Z'
|
||||
fill='white'
|
||||
/>
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
width='24'
|
||||
height='24'
|
||||
fill='none'
|
||||
>
|
||||
<rect width='24' height='24' rx='4' fill='#316BFF' />
|
||||
<path d='M7 6h2v9h5v2H7V6Z' fill='white' />
|
||||
<circle cx='17' cy='8' r='2' fill='white' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -208,3 +208,8 @@ Delete the push notification webhook configuration for a task.
|
||||
| `success` | boolean | Whether deletion was successful |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `a2a`
|
||||
|
||||
@@ -49,7 +49,8 @@ Retrieves lead information by email address or lead ID.
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Lemlist API key |
|
||||
| `leadIdentifier` | string | Yes | Lead email address or lead ID |
|
||||
| `email` | string | No | Lead email address \(use either email or id\) |
|
||||
| `id` | string | No | Lead ID \(use either email or id\) |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -124,45 +124,6 @@ Read the latest messages from Slack channels. Retrieve conversation history with
|
||||
| --------- | ---- | ----------- |
|
||||
| `messages` | array | Array of message objects from the channel |
|
||||
|
||||
### `slack_get_message`
|
||||
|
||||
Retrieve a specific message by its timestamp. Useful for getting a thread parent message.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | Yes | Slack channel ID \(e.g., C1234567890\) |
|
||||
| `timestamp` | string | Yes | Message timestamp to retrieve \(e.g., 1405894322.002768\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | object | The retrieved message object |
|
||||
|
||||
### `slack_get_thread`
|
||||
|
||||
Retrieve an entire thread including the parent message and all replies. Useful for getting full conversation context.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | Yes | Slack channel ID \(e.g., C1234567890\) |
|
||||
| `threadTs` | string | Yes | Thread timestamp \(thread_ts\) to retrieve \(e.g., 1405894322.002768\) |
|
||||
| `limit` | number | No | Maximum number of messages to return \(default: 100, max: 200\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `parentMessage` | object | The thread parent message |
|
||||
|
||||
### `slack_list_channels`
|
||||
|
||||
List all channels in a Slack workspace. Returns public and private channels the bot has access to.
|
||||
|
||||
@@ -2,6 +2,13 @@ import { createSession, createWorkspaceRecord, loggerMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Tests for workspace invitation by ID API route
|
||||
* Tests GET (details + token acceptance), DELETE (cancellation)
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
const mockGetSession = vi.fn()
|
||||
const mockHasWorkspaceAdminAccess = vi.fn()
|
||||
|
||||
@@ -220,7 +227,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
expect(response.headers.get('location')).toBe('https://test.sim.ai/workspace/workspace-456/w')
|
||||
})
|
||||
|
||||
it('should redirect to error page with token preserved when invitation expired', async () => {
|
||||
it('should redirect to error page when invitation expired', async () => {
|
||||
const session = createSession({
|
||||
userId: mockUser.id,
|
||||
email: 'invited@example.com',
|
||||
@@ -243,13 +250,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = response.headers.get('location')
|
||||
expect(location).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=expired&token=token-abc123'
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=expired'
|
||||
)
|
||||
})
|
||||
|
||||
it('should redirect to error page with token preserved when email mismatch', async () => {
|
||||
it('should redirect to error page when email mismatch', async () => {
|
||||
const session = createSession({
|
||||
userId: mockUser.id,
|
||||
email: 'wrong@example.com',
|
||||
@@ -271,13 +277,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = response.headers.get('location')
|
||||
expect(location).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=email-mismatch&token=token-abc123'
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=email-mismatch'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return 404 when invitation not found (without token)', async () => {
|
||||
it('should return 404 when invitation not found', async () => {
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
dbSelectResults = [[]]
|
||||
@@ -291,189 +296,6 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
expect(response.status).toBe(404)
|
||||
expect(data).toEqual({ error: 'Invitation not found or has expired' })
|
||||
})
|
||||
|
||||
it('should redirect to error page with token preserved when invitation not found (with token)', async () => {
|
||||
const session = createSession({ userId: mockUser.id, email: mockUser.email })
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
dbSelectResults = [[]]
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/non-existent?token=some-invalid-token'
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'non-existent' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = response.headers.get('location')
|
||||
expect(location).toBe(
|
||||
'https://test.sim.ai/invite/non-existent?error=invalid-token&token=some-invalid-token'
|
||||
)
|
||||
})
|
||||
|
||||
it('should redirect to error page with token preserved when invitation already processed', async () => {
|
||||
const session = createSession({
|
||||
userId: mockUser.id,
|
||||
email: 'invited@example.com',
|
||||
name: mockUser.name,
|
||||
})
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
|
||||
const acceptedInvitation = {
|
||||
...mockInvitation,
|
||||
status: 'accepted',
|
||||
}
|
||||
|
||||
dbSelectResults = [[acceptedInvitation], [mockWorkspace]]
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = response.headers.get('location')
|
||||
expect(location).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=already-processed&token=token-abc123'
|
||||
)
|
||||
})
|
||||
|
||||
it('should redirect to error page with token preserved when workspace not found', async () => {
|
||||
const session = createSession({
|
||||
userId: mockUser.id,
|
||||
email: 'invited@example.com',
|
||||
name: mockUser.name,
|
||||
})
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
|
||||
dbSelectResults = [[mockInvitation], []]
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = response.headers.get('location')
|
||||
expect(location).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=workspace-not-found&token=token-abc123'
|
||||
)
|
||||
})
|
||||
|
||||
it('should redirect to error page with token preserved when user not found', async () => {
|
||||
const session = createSession({
|
||||
userId: mockUser.id,
|
||||
email: 'invited@example.com',
|
||||
name: mockUser.name,
|
||||
})
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
|
||||
dbSelectResults = [[mockInvitation], [mockWorkspace], []]
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = response.headers.get('location')
|
||||
expect(location).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=user-not-found&token=token-abc123'
|
||||
)
|
||||
})
|
||||
|
||||
it('should URL encode special characters in token when preserving in error redirects', async () => {
|
||||
const session = createSession({
|
||||
userId: mockUser.id,
|
||||
email: 'wrong@example.com',
|
||||
name: mockUser.name,
|
||||
})
|
||||
mockGetSession.mockResolvedValue(session)
|
||||
|
||||
dbSelectResults = [
|
||||
[mockInvitation],
|
||||
[mockWorkspace],
|
||||
[{ ...mockUser, email: 'wrong@example.com' }],
|
||||
]
|
||||
|
||||
const specialToken = 'token+with/special=chars&more'
|
||||
const request = new NextRequest(
|
||||
`http://localhost/api/workspaces/invitations/token-abc123?token=${encodeURIComponent(specialToken)}`
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = response.headers.get('location')
|
||||
expect(location).toContain('error=email-mismatch')
|
||||
expect(location).toContain(`token=${encodeURIComponent(specialToken)}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Token Preservation - Full Flow Scenario', () => {
|
||||
it('should preserve token through email mismatch so user can retry with correct account', async () => {
|
||||
const wrongSession = createSession({
|
||||
userId: 'wrong-user',
|
||||
email: 'wrong@example.com',
|
||||
name: 'Wrong User',
|
||||
})
|
||||
mockGetSession.mockResolvedValue(wrongSession)
|
||||
|
||||
dbSelectResults = [
|
||||
[mockInvitation],
|
||||
[mockWorkspace],
|
||||
[{ id: 'wrong-user', email: 'wrong@example.com' }],
|
||||
]
|
||||
|
||||
const request1 = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params1 = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response1 = await GET(request1, { params: params1 })
|
||||
|
||||
expect(response1.status).toBe(307)
|
||||
const location1 = response1.headers.get('location')
|
||||
expect(location1).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=email-mismatch&token=token-abc123'
|
||||
)
|
||||
|
||||
vi.clearAllMocks()
|
||||
dbSelectCallIndex = 0
|
||||
|
||||
const correctSession = createSession({
|
||||
userId: mockUser.id,
|
||||
email: 'invited@example.com',
|
||||
name: mockUser.name,
|
||||
})
|
||||
mockGetSession.mockResolvedValue(correctSession)
|
||||
|
||||
dbSelectResults = [
|
||||
[mockInvitation],
|
||||
[mockWorkspace],
|
||||
[{ ...mockUser, email: 'invited@example.com' }],
|
||||
[],
|
||||
]
|
||||
|
||||
const request2 = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params2 = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response2 = await GET(request2, { params: params2 })
|
||||
|
||||
expect(response2.status).toBe(307)
|
||||
expect(response2.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/workspace/workspace-456/w'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/workspaces/invitations/[invitationId]', () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ export async function GET(
|
||||
const isAcceptFlow = !!token // If token is provided, this is an acceptance flow
|
||||
|
||||
if (!session?.user?.id) {
|
||||
// For token-based acceptance flows, redirect to login
|
||||
if (isAcceptFlow) {
|
||||
return NextResponse.redirect(new URL(`/invite/${invitationId}?token=${token}`, getBaseUrl()))
|
||||
}
|
||||
@@ -50,9 +51,8 @@ export async function GET(
|
||||
|
||||
if (!invitation) {
|
||||
if (isAcceptFlow) {
|
||||
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
|
||||
return NextResponse.redirect(
|
||||
new URL(`/invite/${invitationId}?error=invalid-token${tokenParam}`, getBaseUrl())
|
||||
new URL(`/invite/${invitationId}?error=invalid-token`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
|
||||
@@ -60,9 +60,8 @@ export async function GET(
|
||||
|
||||
if (new Date() > new Date(invitation.expiresAt)) {
|
||||
if (isAcceptFlow) {
|
||||
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
|
||||
return NextResponse.redirect(
|
||||
new URL(`/invite/${invitation.id}?error=expired${tokenParam}`, getBaseUrl())
|
||||
new URL(`/invite/${invitation.id}?error=expired`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
|
||||
@@ -76,20 +75,17 @@ export async function GET(
|
||||
|
||||
if (!workspaceDetails) {
|
||||
if (isAcceptFlow) {
|
||||
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
|
||||
return NextResponse.redirect(
|
||||
new URL(`/invite/${invitation.id}?error=workspace-not-found${tokenParam}`, getBaseUrl())
|
||||
new URL(`/invite/${invitation.id}?error=workspace-not-found`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (isAcceptFlow) {
|
||||
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''
|
||||
|
||||
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/invite/${invitation.id}?error=already-processed${tokenParam}`, getBaseUrl())
|
||||
new URL(`/invite/${invitation.id}?error=already-processed`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,7 +100,7 @@ export async function GET(
|
||||
|
||||
if (!userData) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/invite/${invitation.id}?error=user-not-found${tokenParam}`, getBaseUrl())
|
||||
new URL(`/invite/${invitation.id}?error=user-not-found`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -112,7 +108,7 @@ export async function GET(
|
||||
|
||||
if (!isValidMatch) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/invite/${invitation.id}?error=email-mismatch${tokenParam}`, getBaseUrl())
|
||||
new URL(`/invite/${invitation.id}?error=email-mismatch`, getBaseUrl())
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -178,25 +178,23 @@ export default function Invite() {
|
||||
|
||||
useEffect(() => {
|
||||
const errorReason = searchParams.get('error')
|
||||
const isNew = searchParams.get('new') === 'true'
|
||||
setIsNewUser(isNew)
|
||||
|
||||
const tokenFromQuery = searchParams.get('token')
|
||||
if (tokenFromQuery) {
|
||||
setToken(tokenFromQuery)
|
||||
sessionStorage.setItem('inviteToken', tokenFromQuery)
|
||||
} else {
|
||||
const storedToken = sessionStorage.getItem('inviteToken')
|
||||
if (storedToken && storedToken !== inviteId) {
|
||||
setToken(storedToken)
|
||||
}
|
||||
}
|
||||
|
||||
if (errorReason) {
|
||||
setError(getInviteError(errorReason))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const isNew = searchParams.get('new') === 'true'
|
||||
setIsNewUser(isNew)
|
||||
|
||||
const tokenFromQuery = searchParams.get('token')
|
||||
const effectiveToken = tokenFromQuery || inviteId
|
||||
|
||||
if (effectiveToken) {
|
||||
setToken(effectiveToken)
|
||||
sessionStorage.setItem('inviteToken', effectiveToken)
|
||||
}
|
||||
}, [searchParams, inviteId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -205,6 +203,7 @@ export default function Invite() {
|
||||
async function fetchInvitationDetails() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Fetch invitation details using the invitation ID from the URL path
|
||||
const workspaceInviteResponse = await fetch(`/api/workspaces/invitations/${inviteId}`, {
|
||||
method: 'GET',
|
||||
})
|
||||
@@ -221,6 +220,7 @@ export default function Invite() {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle workspace invitation errors with specific status codes
|
||||
if (!workspaceInviteResponse.ok && workspaceInviteResponse.status !== 404) {
|
||||
const errorCode = parseApiError(null, workspaceInviteResponse.status)
|
||||
const errorData = await workspaceInviteResponse.json().catch(() => ({}))
|
||||
@@ -229,6 +229,7 @@ export default function Invite() {
|
||||
error: errorData,
|
||||
})
|
||||
|
||||
// Refine error code based on response body if available
|
||||
if (errorData.error) {
|
||||
const refinedCode = parseApiError(errorData.error, workspaceInviteResponse.status)
|
||||
setError(getInviteError(refinedCode))
|
||||
@@ -253,11 +254,13 @@ export default function Invite() {
|
||||
if (data) {
|
||||
setInvitationType('organization')
|
||||
|
||||
// Check if user is already in an organization BEFORE showing the invitation
|
||||
const activeOrgResponse = await client.organization
|
||||
.getFullOrganization()
|
||||
.catch(() => ({ data: null }))
|
||||
|
||||
if (activeOrgResponse?.data) {
|
||||
// User is already in an organization
|
||||
setCurrentOrgName(activeOrgResponse.data.name)
|
||||
setError(getInviteError('already-in-organization'))
|
||||
setIsLoading(false)
|
||||
@@ -286,6 +289,7 @@ export default function Invite() {
|
||||
throw { code: 'invalid-invitation' }
|
||||
}
|
||||
} catch (orgErr: any) {
|
||||
// If this is our structured error, use it directly
|
||||
if (orgErr.code) {
|
||||
throw orgErr
|
||||
}
|
||||
@@ -312,6 +316,7 @@ export default function Invite() {
|
||||
window.location.href = `/api/workspaces/invitations/${encodeURIComponent(inviteId)}?token=${encodeURIComponent(token || '')}`
|
||||
} else {
|
||||
try {
|
||||
// Get the organizationId from invitation details
|
||||
const orgId = invitationDetails?.data?.organizationId
|
||||
|
||||
if (!orgId) {
|
||||
@@ -320,6 +325,7 @@ export default function Invite() {
|
||||
return
|
||||
}
|
||||
|
||||
// Use our custom API endpoint that handles Pro usage snapshot
|
||||
const response = await fetch(`/api/organizations/${orgId}/invitations/${inviteId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -341,6 +347,7 @@ export default function Invite() {
|
||||
return
|
||||
}
|
||||
|
||||
// Set the organization as active
|
||||
await client.organization.setActive({
|
||||
organizationId: orgId,
|
||||
})
|
||||
@@ -353,6 +360,7 @@ export default function Invite() {
|
||||
} catch (err: any) {
|
||||
logger.error('Error accepting invitation:', err)
|
||||
|
||||
// Reset accepted state on error
|
||||
setAccepted(false)
|
||||
|
||||
const errorCode = parseApiError(err)
|
||||
@@ -363,9 +371,7 @@ export default function Invite() {
|
||||
}
|
||||
|
||||
const getCallbackUrl = () => {
|
||||
const effectiveToken =
|
||||
token || sessionStorage.getItem('inviteToken') || searchParams.get('token')
|
||||
return `/invite/${inviteId}${effectiveToken && effectiveToken !== inviteId ? `?token=${effectiveToken}` : ''}`
|
||||
return `/invite/${inviteId}${token && token !== inviteId ? `?token=${token}` : ''}`
|
||||
}
|
||||
|
||||
if (!session?.user && !isPending) {
|
||||
@@ -429,6 +435,7 @@ export default function Invite() {
|
||||
if (error) {
|
||||
const callbackUrl = encodeURIComponent(getCallbackUrl())
|
||||
|
||||
// Special handling for already in organization
|
||||
if (error.code === 'already-in-organization') {
|
||||
return (
|
||||
<InviteLayout>
|
||||
@@ -456,6 +463,7 @@ export default function Invite() {
|
||||
)
|
||||
}
|
||||
|
||||
// Handle email mismatch - user needs to sign in with a different account
|
||||
if (error.code === 'email-mismatch') {
|
||||
return (
|
||||
<InviteLayout>
|
||||
@@ -482,6 +490,7 @@ export default function Invite() {
|
||||
)
|
||||
}
|
||||
|
||||
// Handle auth-related errors - prompt user to sign in
|
||||
if (error.requiresAuth) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
@@ -509,6 +518,7 @@ export default function Invite() {
|
||||
)
|
||||
}
|
||||
|
||||
// Handle retryable errors
|
||||
const actions: Array<{
|
||||
label: string
|
||||
onClick: () => void
|
||||
@@ -540,6 +550,7 @@ export default function Invite() {
|
||||
)
|
||||
}
|
||||
|
||||
// Show success only if accepted AND no error
|
||||
if (accepted && !error) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
|
||||
@@ -531,6 +531,35 @@ export function Chat() {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
selectedOutputs.length > 0 &&
|
||||
'logs' in result &&
|
||||
Array.isArray(result.logs) &&
|
||||
activeWorkflowId
|
||||
) {
|
||||
const additionalOutputs: string[] = []
|
||||
|
||||
for (const outputId of selectedOutputs) {
|
||||
const blockId = extractBlockIdFromOutputId(outputId)
|
||||
const path = extractPathFromOutputId(outputId, blockId)
|
||||
|
||||
if (path === 'content') continue
|
||||
|
||||
const outputValue = extractOutputFromLogs(result.logs as BlockLog[], outputId)
|
||||
if (outputValue !== undefined) {
|
||||
const formattedValue =
|
||||
typeof outputValue === 'string' ? outputValue : JSON.stringify(outputValue)
|
||||
if (formattedValue) {
|
||||
additionalOutputs.push(`**${path}:** ${formattedValue}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (additionalOutputs.length > 0) {
|
||||
appendMessageContent(responseMessageId, `\n\n${additionalOutputs.join('\n\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
finalizeMessageStream(responseMessageId)
|
||||
} else if (contentChunk) {
|
||||
accumulatedContent += contentChunk
|
||||
|
||||
@@ -26,14 +26,26 @@ function formatTimestamp(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common text styling for loading and empty states
|
||||
*/
|
||||
const STATE_TEXT_CLASSES = 'px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'
|
||||
|
||||
/**
|
||||
* Loading state component for mention folders
|
||||
*/
|
||||
const LoadingState = () => <div className={STATE_TEXT_CLASSES}>Loading...</div>
|
||||
|
||||
/**
|
||||
* Empty state component for mention folders
|
||||
*/
|
||||
const EmptyState = ({ message }: { message: string }) => (
|
||||
<div className={STATE_TEXT_CLASSES}>{message}</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* Aggregated item type for filtered results
|
||||
*/
|
||||
interface AggregatedItem {
|
||||
id: string
|
||||
label: string
|
||||
@@ -66,6 +78,14 @@ interface MentionMenuProps {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MentionMenu component for mention menu dropdown.
|
||||
* Handles rendering of mention options, submenus, and aggregated search results.
|
||||
* Manages keyboard navigation and selection of mentions.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Rendered mention menu
|
||||
*/
|
||||
export function MentionMenu({
|
||||
mentionMenu,
|
||||
mentionData,
|
||||
@@ -80,7 +100,6 @@ export function MentionMenu({
|
||||
submenuActiveIndex,
|
||||
mentionActiveIndex,
|
||||
openSubmenuFor,
|
||||
setOpenSubmenuFor,
|
||||
} = mentionMenu
|
||||
|
||||
const {
|
||||
@@ -289,55 +308,72 @@ export function MentionMenu({
|
||||
'Docs', // 7
|
||||
] as const
|
||||
|
||||
// Get active folder based on navigation when not in submenu and no query
|
||||
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
|
||||
|
||||
// Compute caret viewport position via mirror technique for precise anchoring
|
||||
const textareaEl = mentionMenu.textareaRef.current
|
||||
if (!textareaEl) return null
|
||||
|
||||
const caretPos = getCaretPos()
|
||||
const textareaRect = textareaEl.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(textareaEl)
|
||||
const getCaretViewport = (textarea: HTMLTextAreaElement, caretPosition: number, text: string) => {
|
||||
const textareaRect = textarea.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(textarea)
|
||||
|
||||
const mirrorDiv = document.createElement('div')
|
||||
mirrorDiv.style.position = 'absolute'
|
||||
mirrorDiv.style.visibility = 'hidden'
|
||||
mirrorDiv.style.whiteSpace = 'pre-wrap'
|
||||
mirrorDiv.style.wordWrap = 'break-word'
|
||||
mirrorDiv.style.font = style.font
|
||||
mirrorDiv.style.padding = style.padding
|
||||
mirrorDiv.style.border = style.border
|
||||
mirrorDiv.style.width = style.width
|
||||
mirrorDiv.style.lineHeight = style.lineHeight
|
||||
mirrorDiv.style.boxSizing = style.boxSizing
|
||||
mirrorDiv.style.letterSpacing = style.letterSpacing
|
||||
mirrorDiv.style.textTransform = style.textTransform
|
||||
mirrorDiv.style.textIndent = style.textIndent
|
||||
mirrorDiv.style.textAlign = style.textAlign
|
||||
mirrorDiv.textContent = message.substring(0, caretPos)
|
||||
const mirrorDiv = document.createElement('div')
|
||||
mirrorDiv.style.position = 'absolute'
|
||||
mirrorDiv.style.visibility = 'hidden'
|
||||
mirrorDiv.style.whiteSpace = 'pre-wrap'
|
||||
mirrorDiv.style.wordWrap = 'break-word'
|
||||
mirrorDiv.style.font = style.font
|
||||
mirrorDiv.style.padding = style.padding
|
||||
mirrorDiv.style.border = style.border
|
||||
mirrorDiv.style.width = style.width
|
||||
mirrorDiv.style.lineHeight = style.lineHeight
|
||||
mirrorDiv.style.boxSizing = style.boxSizing
|
||||
mirrorDiv.style.letterSpacing = style.letterSpacing
|
||||
mirrorDiv.style.textTransform = style.textTransform
|
||||
mirrorDiv.style.textIndent = style.textIndent
|
||||
mirrorDiv.style.textAlign = style.textAlign
|
||||
|
||||
const caretMarker = document.createElement('span')
|
||||
caretMarker.style.display = 'inline-block'
|
||||
caretMarker.style.width = '0px'
|
||||
caretMarker.style.padding = '0'
|
||||
caretMarker.style.border = '0'
|
||||
mirrorDiv.appendChild(caretMarker)
|
||||
mirrorDiv.textContent = text.substring(0, caretPosition)
|
||||
|
||||
document.body.appendChild(mirrorDiv)
|
||||
const markerRect = caretMarker.getBoundingClientRect()
|
||||
const mirrorRect = mirrorDiv.getBoundingClientRect()
|
||||
document.body.removeChild(mirrorDiv)
|
||||
const caretMarker = document.createElement('span')
|
||||
caretMarker.style.display = 'inline-block'
|
||||
caretMarker.style.width = '0px'
|
||||
caretMarker.style.padding = '0'
|
||||
caretMarker.style.border = '0'
|
||||
mirrorDiv.appendChild(caretMarker)
|
||||
|
||||
const caretViewport = {
|
||||
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft,
|
||||
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop,
|
||||
document.body.appendChild(mirrorDiv)
|
||||
const markerRect = caretMarker.getBoundingClientRect()
|
||||
const mirrorRect = mirrorDiv.getBoundingClientRect()
|
||||
document.body.removeChild(mirrorDiv)
|
||||
|
||||
const leftOffset = markerRect.left - mirrorRect.left - textarea.scrollLeft
|
||||
const topOffset = markerRect.top - mirrorRect.top - textarea.scrollTop
|
||||
|
||||
return {
|
||||
left: textareaRect.left + leftOffset,
|
||||
top: textareaRect.top + topOffset,
|
||||
}
|
||||
}
|
||||
|
||||
const caretPos = getCaretPos()
|
||||
const caretViewport = getCaretViewport(textareaEl, caretPos, message)
|
||||
|
||||
// Decide preferred side based on available space
|
||||
const margin = 8
|
||||
const spaceAbove = caretViewport.top - margin
|
||||
const spaceBelow = window.innerHeight - caretViewport.top - margin
|
||||
const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top'
|
||||
const side: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top'
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={() => {}}>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={() => {
|
||||
/* controlled by mentionMenu */
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
style={{
|
||||
@@ -363,7 +399,7 @@ export function MentionMenu({
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<PopoverBackButton onClick={() => setOpenSubmenuFor(null)} />
|
||||
<PopoverBackButton />
|
||||
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
|
||||
{openSubmenuFor ? (
|
||||
// Submenu view - showing contents of a specific folder
|
||||
|
||||
@@ -12,19 +12,31 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import type { useMentionMenu } from '../../hooks/use-mention-menu'
|
||||
|
||||
/**
|
||||
* Top-level slash command options
|
||||
*/
|
||||
const TOP_LEVEL_COMMANDS = [
|
||||
{ id: 'fast', label: 'Fast' },
|
||||
{ id: 'research', label: 'Research' },
|
||||
{ id: 'superagent', label: 'Actions' },
|
||||
{ id: 'fast', label: 'fast' },
|
||||
{ id: 'plan', label: 'plan' },
|
||||
{ id: 'debug', label: 'debug' },
|
||||
{ id: 'research', label: 'research' },
|
||||
{ id: 'deploy', label: 'deploy' },
|
||||
{ id: 'superagent', label: 'superagent' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Web submenu commands
|
||||
*/
|
||||
const WEB_COMMANDS = [
|
||||
{ id: 'search', label: 'Search' },
|
||||
{ id: 'read', label: 'Read' },
|
||||
{ id: 'scrape', label: 'Scrape' },
|
||||
{ id: 'crawl', label: 'Crawl' },
|
||||
{ id: 'search', label: 'search' },
|
||||
{ id: 'read', label: 'read' },
|
||||
{ id: 'scrape', label: 'scrape' },
|
||||
{ id: 'crawl', label: 'crawl' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* All command labels for filtering
|
||||
*/
|
||||
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
|
||||
|
||||
interface SlashMenuProps {
|
||||
@@ -33,6 +45,13 @@ interface SlashMenuProps {
|
||||
onSelectCommand: (command: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* SlashMenu component for slash command dropdown.
|
||||
* Shows command options when user types '/'.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Rendered slash menu
|
||||
*/
|
||||
export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) {
|
||||
const {
|
||||
mentionMenuRef,
|
||||
@@ -45,71 +64,92 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
|
||||
setOpenSubmenuFor,
|
||||
} = mentionMenu
|
||||
|
||||
/**
|
||||
* Get the current query string after /
|
||||
*/
|
||||
const currentQuery = useMemo(() => {
|
||||
const caretPos = getCaretPos()
|
||||
const active = getActiveSlashQueryAtPosition(caretPos, message)
|
||||
return active?.query.trim().toLowerCase() || ''
|
||||
}, [message, getCaretPos, getActiveSlashQueryAtPosition])
|
||||
|
||||
/**
|
||||
* Filter commands based on query (search across all commands when there's a query)
|
||||
*/
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!currentQuery) return null
|
||||
return ALL_COMMANDS.filter(
|
||||
(cmd) =>
|
||||
cmd.id.toLowerCase().includes(currentQuery) ||
|
||||
cmd.label.toLowerCase().includes(currentQuery)
|
||||
)
|
||||
if (!currentQuery) return null // Show folder view when no query
|
||||
return ALL_COMMANDS.filter((cmd) => cmd.label.toLowerCase().includes(currentQuery))
|
||||
}, [currentQuery])
|
||||
|
||||
// Show aggregated view when there's a query
|
||||
const showAggregatedView = currentQuery.length > 0
|
||||
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
|
||||
|
||||
// Compute caret viewport position via mirror technique for precise anchoring
|
||||
const textareaEl = mentionMenu.textareaRef.current
|
||||
if (!textareaEl) return null
|
||||
|
||||
const caretPos = getCaretPos()
|
||||
const textareaRect = textareaEl.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(textareaEl)
|
||||
const getCaretViewport = (textarea: HTMLTextAreaElement, caretPosition: number, text: string) => {
|
||||
const textareaRect = textarea.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(textarea)
|
||||
|
||||
const mirrorDiv = document.createElement('div')
|
||||
mirrorDiv.style.position = 'absolute'
|
||||
mirrorDiv.style.visibility = 'hidden'
|
||||
mirrorDiv.style.whiteSpace = 'pre-wrap'
|
||||
mirrorDiv.style.wordWrap = 'break-word'
|
||||
mirrorDiv.style.font = style.font
|
||||
mirrorDiv.style.padding = style.padding
|
||||
mirrorDiv.style.border = style.border
|
||||
mirrorDiv.style.width = style.width
|
||||
mirrorDiv.style.lineHeight = style.lineHeight
|
||||
mirrorDiv.style.boxSizing = style.boxSizing
|
||||
mirrorDiv.style.letterSpacing = style.letterSpacing
|
||||
mirrorDiv.style.textTransform = style.textTransform
|
||||
mirrorDiv.style.textIndent = style.textIndent
|
||||
mirrorDiv.style.textAlign = style.textAlign
|
||||
mirrorDiv.textContent = message.substring(0, caretPos)
|
||||
const mirrorDiv = document.createElement('div')
|
||||
mirrorDiv.style.position = 'absolute'
|
||||
mirrorDiv.style.visibility = 'hidden'
|
||||
mirrorDiv.style.whiteSpace = 'pre-wrap'
|
||||
mirrorDiv.style.wordWrap = 'break-word'
|
||||
mirrorDiv.style.font = style.font
|
||||
mirrorDiv.style.padding = style.padding
|
||||
mirrorDiv.style.border = style.border
|
||||
mirrorDiv.style.width = style.width
|
||||
mirrorDiv.style.lineHeight = style.lineHeight
|
||||
mirrorDiv.style.boxSizing = style.boxSizing
|
||||
mirrorDiv.style.letterSpacing = style.letterSpacing
|
||||
mirrorDiv.style.textTransform = style.textTransform
|
||||
mirrorDiv.style.textIndent = style.textIndent
|
||||
mirrorDiv.style.textAlign = style.textAlign
|
||||
|
||||
const caretMarker = document.createElement('span')
|
||||
caretMarker.style.display = 'inline-block'
|
||||
caretMarker.style.width = '0px'
|
||||
caretMarker.style.padding = '0'
|
||||
caretMarker.style.border = '0'
|
||||
mirrorDiv.appendChild(caretMarker)
|
||||
mirrorDiv.textContent = text.substring(0, caretPosition)
|
||||
|
||||
document.body.appendChild(mirrorDiv)
|
||||
const markerRect = caretMarker.getBoundingClientRect()
|
||||
const mirrorRect = mirrorDiv.getBoundingClientRect()
|
||||
document.body.removeChild(mirrorDiv)
|
||||
const caretMarker = document.createElement('span')
|
||||
caretMarker.style.display = 'inline-block'
|
||||
caretMarker.style.width = '0px'
|
||||
caretMarker.style.padding = '0'
|
||||
caretMarker.style.border = '0'
|
||||
mirrorDiv.appendChild(caretMarker)
|
||||
|
||||
const caretViewport = {
|
||||
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft,
|
||||
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop,
|
||||
document.body.appendChild(mirrorDiv)
|
||||
const markerRect = caretMarker.getBoundingClientRect()
|
||||
const mirrorRect = mirrorDiv.getBoundingClientRect()
|
||||
document.body.removeChild(mirrorDiv)
|
||||
|
||||
const leftOffset = markerRect.left - mirrorRect.left - textarea.scrollLeft
|
||||
const topOffset = markerRect.top - mirrorRect.top - textarea.scrollTop
|
||||
|
||||
return {
|
||||
left: textareaRect.left + leftOffset,
|
||||
top: textareaRect.top + topOffset,
|
||||
}
|
||||
}
|
||||
|
||||
const caretPos = getCaretPos()
|
||||
const caretViewport = getCaretViewport(textareaEl, caretPos, message)
|
||||
|
||||
// Decide preferred side based on available space
|
||||
const margin = 8
|
||||
const spaceAbove = caretViewport.top - margin
|
||||
const spaceBelow = window.innerHeight - caretViewport.top - margin
|
||||
const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top'
|
||||
const side: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top'
|
||||
|
||||
// Check if we're in folder navigation mode (no query, not in submenu)
|
||||
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
|
||||
|
||||
return (
|
||||
<Popover open={true} onOpenChange={() => {}}>
|
||||
<Popover
|
||||
open={true}
|
||||
onOpenChange={() => {
|
||||
/* controlled externally */
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
style={{
|
||||
@@ -135,22 +175,24 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<PopoverBackButton onClick={() => setOpenSubmenuFor(null)} />
|
||||
<PopoverBackButton />
|
||||
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
|
||||
{openSubmenuFor === 'Web' ? (
|
||||
// Web submenu view
|
||||
<>
|
||||
{WEB_COMMANDS.map((cmd, index) => (
|
||||
<PopoverItem
|
||||
key={cmd.id}
|
||||
onClick={() => onSelectCommand(cmd.id)}
|
||||
onClick={() => onSelectCommand(cmd.label)}
|
||||
data-idx={index}
|
||||
active={index === submenuActiveIndex}
|
||||
>
|
||||
<span className='truncate'>{cmd.label}</span>
|
||||
<span className='truncate capitalize'>{cmd.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</>
|
||||
) : showAggregatedView ? (
|
||||
// Aggregated filtered view
|
||||
<>
|
||||
{filteredCommands && filteredCommands.length === 0 ? (
|
||||
<div className='px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'>
|
||||
@@ -160,25 +202,26 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
|
||||
filteredCommands?.map((cmd, index) => (
|
||||
<PopoverItem
|
||||
key={cmd.id}
|
||||
onClick={() => onSelectCommand(cmd.id)}
|
||||
onClick={() => onSelectCommand(cmd.label)}
|
||||
data-idx={index}
|
||||
active={index === submenuActiveIndex}
|
||||
>
|
||||
<span className='truncate'>{cmd.label}</span>
|
||||
<span className='truncate capitalize'>{cmd.label}</span>
|
||||
</PopoverItem>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Folder navigation view
|
||||
<>
|
||||
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
|
||||
<PopoverItem
|
||||
key={cmd.id}
|
||||
onClick={() => onSelectCommand(cmd.id)}
|
||||
onClick={() => onSelectCommand(cmd.label)}
|
||||
data-idx={index}
|
||||
active={isInFolderNavigationMode && index === mentionActiveIndex}
|
||||
>
|
||||
<span className='truncate'>{cmd.label}</span>
|
||||
<span className='truncate capitalize'>{cmd.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
|
||||
@@ -192,8 +235,8 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
|
||||
data-idx={TOP_LEVEL_COMMANDS.length}
|
||||
>
|
||||
{WEB_COMMANDS.map((cmd) => (
|
||||
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.id)}>
|
||||
<span className='truncate'>{cmd.label}</span>
|
||||
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.label)}>
|
||||
<span className='truncate capitalize'>{cmd.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverFolder>
|
||||
|
||||
@@ -40,24 +40,6 @@ import { useCopilotStore } from '@/stores/panel'
|
||||
|
||||
const logger = createLogger('CopilotUserInput')
|
||||
|
||||
const TOP_LEVEL_COMMANDS = ['fast', 'research', 'superagent'] as const
|
||||
const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl'] as const
|
||||
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
|
||||
|
||||
const COMMAND_DISPLAY_LABELS: Record<string, string> = {
|
||||
superagent: 'Actions',
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the next index for circular navigation (wraps around at bounds)
|
||||
*/
|
||||
function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number {
|
||||
if (direction === 'down') {
|
||||
return current >= maxIndex ? 0 : current + 1
|
||||
}
|
||||
return current <= 0 ? maxIndex : current - 1
|
||||
}
|
||||
|
||||
interface UserInputProps {
|
||||
onSubmit: (
|
||||
message: string,
|
||||
@@ -128,6 +110,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// Refs and external hooks
|
||||
const { data: session } = useSession()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -139,16 +122,19 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel
|
||||
const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel
|
||||
|
||||
// Internal state
|
||||
const [internalMessage, setInternalMessage] = useState('')
|
||||
const [isNearTop, setIsNearTop] = useState(false)
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||
const [inputContainerRef, setInputContainerRef] = useState<HTMLDivElement | null>(null)
|
||||
const [showSlashMenu, setShowSlashMenu] = useState(false)
|
||||
|
||||
// Controlled vs uncontrolled message state
|
||||
const message = controlledValue !== undefined ? controlledValue : internalMessage
|
||||
const setMessage =
|
||||
controlledValue !== undefined ? onControlledChange || (() => {}) : setInternalMessage
|
||||
|
||||
// Effective placeholder
|
||||
const effectivePlaceholder =
|
||||
placeholder ||
|
||||
(mode === 'ask'
|
||||
@@ -157,8 +143,11 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
? 'Plan your workflow'
|
||||
: 'Plan, search, build anything')
|
||||
|
||||
// Custom hooks - order matters for ref sharing
|
||||
// Context management (manages selectedContexts state)
|
||||
const contextManagement = useContextManagement({ message, initialContexts })
|
||||
|
||||
// Mention menu
|
||||
const mentionMenu = useMentionMenu({
|
||||
message,
|
||||
selectedContexts: contextManagement.selectedContexts,
|
||||
@@ -166,6 +155,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
onMessageChange: setMessage,
|
||||
})
|
||||
|
||||
// Mention token utilities
|
||||
const mentionTokensWithContext = useMentionTokens({
|
||||
message,
|
||||
selectedContexts: contextManagement.selectedContexts,
|
||||
@@ -193,6 +183,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
isLoading,
|
||||
})
|
||||
|
||||
// Insert mention handlers
|
||||
const insertHandlers = useMentionInsertHandlers({
|
||||
mentionMenu,
|
||||
workflowId: workflowId || null,
|
||||
@@ -200,12 +191,14 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
onContextAdd: contextManagement.addContext,
|
||||
})
|
||||
|
||||
// Keyboard navigation hook
|
||||
const mentionKeyboard = useMentionKeyboard({
|
||||
mentionMenu,
|
||||
mentionData,
|
||||
insertHandlers,
|
||||
})
|
||||
|
||||
// Expose focus method to parent
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -222,6 +215,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
[mentionMenu.textareaRef]
|
||||
)
|
||||
|
||||
// Note: textarea auto-resize is handled by the useTextareaAutoResize hook
|
||||
|
||||
// Load workflows on mount if we have a workflowId
|
||||
useEffect(() => {
|
||||
if (workflowId) {
|
||||
void mentionData.ensureWorkflowsLoaded()
|
||||
@@ -229,6 +225,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workflowId])
|
||||
|
||||
// Detect if input is near top of screen
|
||||
useEffect(() => {
|
||||
const checkPosition = () => {
|
||||
if (containerRef) {
|
||||
@@ -256,6 +253,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
}, [containerRef])
|
||||
|
||||
// Also check position when mention menu opens
|
||||
useEffect(() => {
|
||||
if (mentionMenu.showMentionMenu && containerRef) {
|
||||
const rect = containerRef.getBoundingClientRect()
|
||||
@@ -263,6 +261,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
}, [mentionMenu.showMentionMenu, containerRef])
|
||||
|
||||
// Preload mention data when query is active
|
||||
useEffect(() => {
|
||||
if (!mentionMenu.showMentionMenu || mentionMenu.openSubmenuFor) {
|
||||
return
|
||||
@@ -274,6 +273,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
.toLowerCase()
|
||||
|
||||
if (q && q.length > 0) {
|
||||
// Prefetch all lists when there's any query for instant filtering
|
||||
void mentionData.ensurePastChatsLoaded()
|
||||
void mentionData.ensureWorkflowsLoaded()
|
||||
void mentionData.ensureWorkflowBlocksLoaded()
|
||||
@@ -282,12 +282,15 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
void mentionData.ensureTemplatesLoaded()
|
||||
void mentionData.ensureLogsLoaded()
|
||||
|
||||
// Reset to first item when query changes
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
|
||||
}
|
||||
// Only depend on values that trigger data loading, not the entire objects
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mentionMenu.showMentionMenu, mentionMenu.openSubmenuFor, message])
|
||||
|
||||
// When switching into a submenu, select the first item and scroll to it
|
||||
useEffect(() => {
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
@@ -296,10 +299,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mentionMenu.openSubmenuFor])
|
||||
|
||||
// Handlers
|
||||
const handleSubmit = useCallback(
|
||||
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
|
||||
const targetMessage = overrideMessage ?? message
|
||||
const trimmedMessage = targetMessage.trim()
|
||||
// Allow submission even when isLoading - store will queue the message
|
||||
if (!trimmedMessage || disabled) return
|
||||
|
||||
const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key)
|
||||
@@ -372,13 +377,17 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const handleSlashCommandSelect = useCallback(
|
||||
(command: string) => {
|
||||
const displayLabel =
|
||||
COMMAND_DISPLAY_LABELS[command] || command.charAt(0).toUpperCase() + command.slice(1)
|
||||
mentionMenu.replaceActiveSlashWith(displayLabel)
|
||||
// Capitalize the command for display
|
||||
const capitalizedCommand = command.charAt(0).toUpperCase() + command.slice(1)
|
||||
|
||||
// Replace the active slash query with the capitalized command
|
||||
mentionMenu.replaceActiveSlashWith(capitalizedCommand)
|
||||
|
||||
// Add as a context so it gets highlighted
|
||||
contextManagement.addContext({
|
||||
kind: 'slash_command',
|
||||
command,
|
||||
label: displayLabel,
|
||||
label: capitalizedCommand,
|
||||
})
|
||||
|
||||
setShowSlashMenu(false)
|
||||
@@ -389,6 +398,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Escape key handling
|
||||
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) {
|
||||
e.preventDefault()
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
@@ -401,33 +411,65 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
return
|
||||
}
|
||||
|
||||
// Arrow navigation in slash menu
|
||||
if (showSlashMenu) {
|
||||
const TOP_LEVEL_COMMANDS = ['fast', 'plan', 'debug', 'research', 'deploy', 'superagent']
|
||||
const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl']
|
||||
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
|
||||
|
||||
const caretPos = mentionMenu.getCaretPos()
|
||||
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
|
||||
const query = activeSlash?.query.trim().toLowerCase() || ''
|
||||
const showAggregatedView = query.length > 0
|
||||
const direction = e.key === 'ArrowDown' ? 'down' : 'up'
|
||||
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
|
||||
if (mentionMenu.openSubmenuFor === 'Web') {
|
||||
// Navigate in Web submenu
|
||||
const last = WEB_COMMANDS.length - 1
|
||||
mentionMenu.setSubmenuActiveIndex((prev) => {
|
||||
const next = getNextIndex(prev, direction, WEB_COMMANDS.length - 1)
|
||||
const next =
|
||||
e.key === 'ArrowDown'
|
||||
? prev >= last
|
||||
? 0
|
||||
: prev + 1
|
||||
: prev <= 0
|
||||
? last
|
||||
: prev - 1
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
|
||||
return next
|
||||
})
|
||||
} else if (showAggregatedView) {
|
||||
// Navigate in filtered view
|
||||
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
|
||||
const last = Math.max(0, filtered.length - 1)
|
||||
mentionMenu.setSubmenuActiveIndex((prev) => {
|
||||
if (filtered.length === 0) return 0
|
||||
const next = getNextIndex(prev, direction, filtered.length - 1)
|
||||
const next =
|
||||
e.key === 'ArrowDown'
|
||||
? prev >= last
|
||||
? 0
|
||||
: prev + 1
|
||||
: prev <= 0
|
||||
? last
|
||||
: prev - 1
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
// Navigate in folder view (top-level + Web folder)
|
||||
const totalItems = TOP_LEVEL_COMMANDS.length + 1 // +1 for Web folder
|
||||
const last = totalItems - 1
|
||||
mentionMenu.setMentionActiveIndex((prev) => {
|
||||
const next = getNextIndex(prev, direction, TOP_LEVEL_COMMANDS.length)
|
||||
const next =
|
||||
e.key === 'ArrowDown'
|
||||
? prev >= last
|
||||
? 0
|
||||
: prev + 1
|
||||
: prev <= 0
|
||||
? last
|
||||
: prev - 1
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
|
||||
return next
|
||||
})
|
||||
@@ -435,9 +477,11 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
return
|
||||
}
|
||||
|
||||
// Arrow right to enter Web submenu
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
if (!showAggregatedView && !mentionMenu.openSubmenuFor) {
|
||||
// Check if Web folder is selected (it's after all top-level commands)
|
||||
if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) {
|
||||
mentionMenu.setOpenSubmenuFor('Web')
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
@@ -446,6 +490,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
return
|
||||
}
|
||||
|
||||
// Arrow left to exit submenu
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
@@ -455,33 +500,44 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow navigation in mention menu
|
||||
if (mentionKeyboard.handleArrowNavigation(e)) return
|
||||
if (mentionKeyboard.handleArrowRight(e)) return
|
||||
if (mentionKeyboard.handleArrowLeft(e)) return
|
||||
|
||||
// Enter key handling
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
if (showSlashMenu) {
|
||||
const TOP_LEVEL_COMMANDS = ['fast', 'plan', 'debug', 'research', 'deploy', 'superagent']
|
||||
const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl']
|
||||
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
|
||||
|
||||
const caretPos = mentionMenu.getCaretPos()
|
||||
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
|
||||
const query = activeSlash?.query.trim().toLowerCase() || ''
|
||||
const showAggregatedView = query.length > 0
|
||||
|
||||
if (mentionMenu.openSubmenuFor === 'Web') {
|
||||
// Select from Web submenu
|
||||
const selectedCommand =
|
||||
WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0]
|
||||
handleSlashCommandSelect(selectedCommand)
|
||||
} else if (showAggregatedView) {
|
||||
// Select from filtered view
|
||||
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
|
||||
if (filtered.length > 0) {
|
||||
const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0]
|
||||
handleSlashCommandSelect(selectedCommand)
|
||||
}
|
||||
} else {
|
||||
// Folder navigation view
|
||||
const selectedIndex = mentionMenu.mentionActiveIndex
|
||||
if (selectedIndex < TOP_LEVEL_COMMANDS.length) {
|
||||
// Top-level command selected
|
||||
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex])
|
||||
} else if (selectedIndex === TOP_LEVEL_COMMANDS.length) {
|
||||
// Web folder selected - open it
|
||||
mentionMenu.setOpenSubmenuFor('Web')
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
}
|
||||
@@ -496,6 +552,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
return
|
||||
}
|
||||
|
||||
// Handle mention token behavior (backspace, delete, arrow keys) when menu is closed
|
||||
if (!mentionMenu.showMentionMenu) {
|
||||
const textarea = mentionMenu.textareaRef.current
|
||||
const selStart = textarea?.selectionStart ?? 0
|
||||
@@ -504,8 +561,11 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
if (selectionLength > 0) {
|
||||
// Multi-character selection: Clean up contexts for any overlapping mentions
|
||||
// but let the default behavior handle the actual text deletion
|
||||
mentionTokensWithContext.removeContextsInSelection(selStart, selEnd)
|
||||
} else {
|
||||
// Single character delete - check if cursor is inside/at a mention token
|
||||
const ranges = mentionTokensWithContext.computeMentionRanges()
|
||||
const target =
|
||||
e.key === 'Backspace'
|
||||
@@ -544,6 +604,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent typing inside token
|
||||
if (e.key.length === 1 || e.key === 'Space') {
|
||||
const blocked =
|
||||
selectionLength === 0 && !!mentionTokensWithContext.findRangeContaining(selStart)
|
||||
@@ -576,10 +637,14 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
const newValue = e.target.value
|
||||
setMessage(newValue)
|
||||
|
||||
// Skip mention menu logic if mentions are disabled
|
||||
if (disableMentions) return
|
||||
|
||||
const caret = e.target.selectionStart ?? newValue.length
|
||||
|
||||
// Check for @ mention trigger
|
||||
const activeMention = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
|
||||
// Check for / slash command trigger
|
||||
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caret, newValue)
|
||||
|
||||
if (activeMention) {
|
||||
@@ -621,66 +686,84 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
}, [mentionMenu.textareaRef, mentionTokensWithContext])
|
||||
|
||||
const insertTriggerAndOpenMenu = useCallback(
|
||||
(trigger: '@' | '/') => {
|
||||
if (disabled || isLoading) return
|
||||
const textarea = mentionMenu.textareaRef.current
|
||||
if (!textarea) return
|
||||
const handleOpenMentionMenuWithAt = useCallback(() => {
|
||||
if (disabled || isLoading) return
|
||||
const textarea = mentionMenu.textareaRef.current
|
||||
if (!textarea) return
|
||||
textarea.focus()
|
||||
const pos = textarea.selectionStart ?? message.length
|
||||
const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1))
|
||||
|
||||
const insertText = needsSpaceBefore ? ' @' : '@'
|
||||
const start = textarea.selectionStart ?? message.length
|
||||
const end = textarea.selectionEnd ?? message.length
|
||||
const before = message.slice(0, start)
|
||||
const after = message.slice(end)
|
||||
const next = `${before}${insertText}${after}`
|
||||
setMessage(next)
|
||||
|
||||
setTimeout(() => {
|
||||
const newPos = before.length + insertText.length
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
textarea.focus()
|
||||
const start = textarea.selectionStart ?? message.length
|
||||
const end = textarea.selectionEnd ?? message.length
|
||||
const needsSpaceBefore = start > 0 && !/\s/.test(message.charAt(start - 1))
|
||||
}, 0)
|
||||
|
||||
const insertText = needsSpaceBefore ? ` ${trigger}` : trigger
|
||||
const before = message.slice(0, start)
|
||||
const after = message.slice(end)
|
||||
setMessage(`${before}${insertText}${after}`)
|
||||
mentionMenu.setShowMentionMenu(true)
|
||||
mentionMenu.setOpenSubmenuFor(null)
|
||||
mentionMenu.setMentionActiveIndex(0)
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
}, [disabled, isLoading, mentionMenu, message, setMessage])
|
||||
|
||||
setTimeout(() => {
|
||||
const newPos = before.length + insertText.length
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
const handleOpenSlashMenu = useCallback(() => {
|
||||
if (disabled || isLoading) return
|
||||
const textarea = mentionMenu.textareaRef.current
|
||||
if (!textarea) return
|
||||
textarea.focus()
|
||||
const pos = textarea.selectionStart ?? message.length
|
||||
const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1))
|
||||
|
||||
if (trigger === '@') {
|
||||
mentionMenu.setShowMentionMenu(true)
|
||||
mentionMenu.setOpenSubmenuFor(null)
|
||||
mentionMenu.setMentionActiveIndex(0)
|
||||
} else {
|
||||
setShowSlashMenu(true)
|
||||
}
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
},
|
||||
[disabled, isLoading, mentionMenu, message, setMessage]
|
||||
)
|
||||
const insertText = needsSpaceBefore ? ' /' : '/'
|
||||
const start = textarea.selectionStart ?? message.length
|
||||
const end = textarea.selectionEnd ?? message.length
|
||||
const before = message.slice(0, start)
|
||||
const after = message.slice(end)
|
||||
const next = `${before}${insertText}${after}`
|
||||
setMessage(next)
|
||||
|
||||
const handleOpenMentionMenuWithAt = useCallback(
|
||||
() => insertTriggerAndOpenMenu('@'),
|
||||
[insertTriggerAndOpenMenu]
|
||||
)
|
||||
setTimeout(() => {
|
||||
const newPos = before.length + insertText.length
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
|
||||
const handleOpenSlashMenu = useCallback(
|
||||
() => insertTriggerAndOpenMenu('/'),
|
||||
[insertTriggerAndOpenMenu]
|
||||
)
|
||||
setShowSlashMenu(true)
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
}, [disabled, isLoading, mentionMenu, message, setMessage])
|
||||
|
||||
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
|
||||
const showAbortButton = isLoading && onAbort
|
||||
|
||||
// Render overlay content with highlighted mentions
|
||||
const renderOverlayContent = useCallback(() => {
|
||||
const contexts = contextManagement.selectedContexts
|
||||
|
||||
// Handle empty message
|
||||
if (!message) {
|
||||
return <span>{'\u00A0'}</span>
|
||||
}
|
||||
|
||||
// If no contexts, render the message directly with proper newline handling
|
||||
if (contexts.length === 0) {
|
||||
// Add a zero-width space at the end if message ends with newline
|
||||
// This ensures the newline is rendered and height is calculated correctly
|
||||
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
|
||||
return <span>{displayText}</span>
|
||||
}
|
||||
|
||||
const elements: React.ReactNode[] = []
|
||||
const labels = contexts.map((c) => c.label).filter(Boolean)
|
||||
|
||||
// Build ranges for all mentions to highlight them including spaces
|
||||
const ranges = mentionTokensWithContext.computeMentionRanges()
|
||||
|
||||
if (ranges.length === 0) {
|
||||
@@ -692,11 +775,14 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const range = ranges[i]
|
||||
|
||||
// Add text before mention
|
||||
if (range.start > lastIndex) {
|
||||
const before = message.slice(lastIndex, range.start)
|
||||
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
|
||||
}
|
||||
|
||||
// Add highlighted mention (including spaces)
|
||||
// Use index + start + end to ensure unique keys even with duplicate contexts
|
||||
const mentionText = message.slice(range.start, range.end)
|
||||
elements.push(
|
||||
<span
|
||||
@@ -711,10 +797,12 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const tail = message.slice(lastIndex)
|
||||
if (tail) {
|
||||
// Add a zero-width space at the end if tail ends with newline
|
||||
const displayTail = tail.endsWith('\n') ? `${tail}\u200B` : tail
|
||||
elements.push(<span key={`tail-${lastIndex}`}>{displayTail}</span>)
|
||||
}
|
||||
|
||||
// Ensure there's always something to render for height calculation
|
||||
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
|
||||
}, [message, contextManagement.selectedContexts, mentionTokensWithContext])
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
{ label: 'Send Message', id: 'send' },
|
||||
{ label: 'Create Canvas', id: 'canvas' },
|
||||
{ label: 'Read Messages', id: 'read' },
|
||||
{ label: 'Get Message', id: 'get_message' },
|
||||
{ label: 'Get Thread', id: 'get_thread' },
|
||||
{ label: 'List Channels', id: 'list_channels' },
|
||||
{ label: 'List Channel Members', id: 'list_members' },
|
||||
{ label: 'List Users', id: 'list_users' },
|
||||
@@ -318,68 +316,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
// Get Message specific fields
|
||||
{
|
||||
id: 'getMessageTimestamp',
|
||||
title: 'Message Timestamp',
|
||||
type: 'short-input',
|
||||
placeholder: 'Message timestamp (e.g., 1405894322.002768)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_message',
|
||||
},
|
||||
required: true,
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Extract or generate a Slack message timestamp from the user's input.
|
||||
Slack message timestamps are in the format: XXXXXXXXXX.XXXXXX (seconds.microseconds since Unix epoch).
|
||||
Examples:
|
||||
- "1405894322.002768" -> 1405894322.002768 (already a valid timestamp)
|
||||
- "thread_ts from the trigger" -> The user wants to reference a variable, output the original text
|
||||
- A URL like "https://slack.com/archives/C123/p1405894322002768" -> Extract 1405894322.002768 (remove 'p' prefix, add decimal after 10th digit)
|
||||
|
||||
If the input looks like a reference to another block's output (contains < and >) or a variable, return it as-is.
|
||||
Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'Paste a Slack message URL or timestamp...',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
// Get Thread specific fields
|
||||
{
|
||||
id: 'getThreadTimestamp',
|
||||
title: 'Thread Timestamp',
|
||||
type: 'short-input',
|
||||
placeholder: 'Thread timestamp (thread_ts, e.g., 1405894322.002768)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_thread',
|
||||
},
|
||||
required: true,
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Extract or generate a Slack thread timestamp from the user's input.
|
||||
Slack thread timestamps (thread_ts) are in the format: XXXXXXXXXX.XXXXXX (seconds.microseconds since Unix epoch).
|
||||
Examples:
|
||||
- "1405894322.002768" -> 1405894322.002768 (already a valid timestamp)
|
||||
- "thread_ts from the trigger" -> The user wants to reference a variable, output the original text
|
||||
- A URL like "https://slack.com/archives/C123/p1405894322002768" -> Extract 1405894322.002768 (remove 'p' prefix, add decimal after 10th digit)
|
||||
|
||||
If the input looks like a reference to another block's output (contains < and >) or a variable, return it as-is.
|
||||
Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'Paste a Slack thread URL or thread_ts...',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'threadLimit',
|
||||
title: 'Message Limit',
|
||||
type: 'short-input',
|
||||
placeholder: '100',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_thread',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'oldest',
|
||||
title: 'Oldest Timestamp',
|
||||
@@ -494,8 +430,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
'slack_message',
|
||||
'slack_canvas',
|
||||
'slack_message_reader',
|
||||
'slack_get_message',
|
||||
'slack_get_thread',
|
||||
'slack_list_channels',
|
||||
'slack_list_members',
|
||||
'slack_list_users',
|
||||
@@ -514,10 +448,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
return 'slack_canvas'
|
||||
case 'read':
|
||||
return 'slack_message_reader'
|
||||
case 'get_message':
|
||||
return 'slack_get_message'
|
||||
case 'get_thread':
|
||||
return 'slack_get_thread'
|
||||
case 'list_channels':
|
||||
return 'slack_list_channels'
|
||||
case 'list_members':
|
||||
@@ -568,9 +498,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
includeDeleted,
|
||||
userLimit,
|
||||
userId,
|
||||
getMessageTimestamp,
|
||||
getThreadTimestamp,
|
||||
threadLimit,
|
||||
...rest
|
||||
} = params
|
||||
|
||||
@@ -647,27 +574,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
break
|
||||
}
|
||||
|
||||
case 'get_message':
|
||||
if (!getMessageTimestamp) {
|
||||
throw new Error('Message timestamp is required for get message operation')
|
||||
}
|
||||
baseParams.timestamp = getMessageTimestamp
|
||||
break
|
||||
|
||||
case 'get_thread': {
|
||||
if (!getThreadTimestamp) {
|
||||
throw new Error('Thread timestamp is required for get thread operation')
|
||||
}
|
||||
baseParams.threadTs = getThreadTimestamp
|
||||
if (threadLimit) {
|
||||
const parsedLimit = Number.parseInt(threadLimit, 10)
|
||||
if (!Number.isNaN(parsedLimit) && parsedLimit > 0) {
|
||||
baseParams.limit = Math.min(parsedLimit, 200)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'list_channels': {
|
||||
baseParams.includePrivate = includePrivate !== 'false'
|
||||
baseParams.excludeArchived = true
|
||||
@@ -773,14 +679,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
userLimit: { type: 'string', description: 'Maximum number of users to return' },
|
||||
// Get User inputs
|
||||
userId: { type: 'string', description: 'User ID to look up' },
|
||||
// Get Message inputs
|
||||
getMessageTimestamp: { type: 'string', description: 'Message timestamp to retrieve' },
|
||||
// Get Thread inputs
|
||||
getThreadTimestamp: { type: 'string', description: 'Thread timestamp to retrieve' },
|
||||
threadLimit: {
|
||||
type: 'string',
|
||||
description: 'Maximum number of messages to return from thread',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
// slack_message outputs (send operation)
|
||||
@@ -808,24 +706,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
'Array of message objects with comprehensive properties: text, user, timestamp, reactions, threads, files, attachments, blocks, stars, pins, and edit history',
|
||||
},
|
||||
|
||||
// slack_get_thread outputs (get_thread operation)
|
||||
parentMessage: {
|
||||
type: 'json',
|
||||
description: 'The thread parent message with all properties',
|
||||
},
|
||||
replies: {
|
||||
type: 'json',
|
||||
description: 'Array of reply messages in the thread (excluding the parent)',
|
||||
},
|
||||
replyCount: {
|
||||
type: 'number',
|
||||
description: 'Number of replies returned in this response',
|
||||
},
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether there are more messages in the thread',
|
||||
},
|
||||
|
||||
// slack_list_channels outputs (list_channels operation)
|
||||
channels: {
|
||||
type: 'json',
|
||||
|
||||
@@ -1180,8 +1180,6 @@ import {
|
||||
slackCanvasTool,
|
||||
slackDeleteMessageTool,
|
||||
slackDownloadTool,
|
||||
slackGetMessageTool,
|
||||
slackGetThreadTool,
|
||||
slackGetUserTool,
|
||||
slackListChannelsTool,
|
||||
slackListMembersTool,
|
||||
@@ -1733,8 +1731,6 @@ export const tools: Record<string, ToolConfig> = {
|
||||
slack_list_members: slackListMembersTool,
|
||||
slack_list_users: slackListUsersTool,
|
||||
slack_get_user: slackGetUserTool,
|
||||
slack_get_message: slackGetMessageTool,
|
||||
slack_get_thread: slackGetThreadTool,
|
||||
slack_canvas: slackCanvasTool,
|
||||
slack_download: slackDownloadTool,
|
||||
slack_update_message: slackUpdateMessageTool,
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import type { SlackGetMessageParams, SlackGetMessageResponse } from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackGetMessageTool: ToolConfig<SlackGetMessageParams, SlackGetMessageResponse> = {
|
||||
id: 'slack_get_message',
|
||||
name: 'Slack Get Message',
|
||||
description:
|
||||
'Retrieve a specific message by its timestamp. Useful for getting a thread parent message.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
|
||||
params: {
|
||||
authMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Authentication method: oauth or bot_token',
|
||||
},
|
||||
botToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Bot token for Custom Bot',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token or bot token for Slack API',
|
||||
},
|
||||
channel: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Slack channel ID (e.g., C1234567890)',
|
||||
},
|
||||
timestamp: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Message timestamp to retrieve (e.g., 1405894322.002768)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: SlackGetMessageParams) => {
|
||||
const url = new URL('https://slack.com/api/conversations.history')
|
||||
url.searchParams.append('channel', params.channel?.trim() ?? '')
|
||||
url.searchParams.append('oldest', params.timestamp?.trim() ?? '')
|
||||
url.searchParams.append('limit', '1')
|
||||
url.searchParams.append('inclusive', 'true')
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: SlackGetMessageParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken || params.botToken}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
if (data.error === 'missing_scope') {
|
||||
throw new Error(
|
||||
'Missing required permissions. Please reconnect your Slack account with the necessary scopes (channels:history, groups:history).'
|
||||
)
|
||||
}
|
||||
if (data.error === 'invalid_auth') {
|
||||
throw new Error('Invalid authentication. Please check your Slack credentials.')
|
||||
}
|
||||
if (data.error === 'channel_not_found') {
|
||||
throw new Error('Channel not found. Please check the channel ID.')
|
||||
}
|
||||
throw new Error(data.error || 'Failed to get message from Slack')
|
||||
}
|
||||
|
||||
const messages = data.messages || []
|
||||
if (messages.length === 0) {
|
||||
throw new Error('Message not found')
|
||||
}
|
||||
|
||||
const msg = messages[0]
|
||||
const message = {
|
||||
type: msg.type ?? 'message',
|
||||
ts: msg.ts,
|
||||
text: msg.text ?? '',
|
||||
user: msg.user ?? null,
|
||||
bot_id: msg.bot_id ?? null,
|
||||
username: msg.username ?? null,
|
||||
channel: msg.channel ?? null,
|
||||
team: msg.team ?? null,
|
||||
thread_ts: msg.thread_ts ?? null,
|
||||
parent_user_id: msg.parent_user_id ?? null,
|
||||
reply_count: msg.reply_count ?? null,
|
||||
reply_users_count: msg.reply_users_count ?? null,
|
||||
latest_reply: msg.latest_reply ?? null,
|
||||
subscribed: msg.subscribed ?? null,
|
||||
last_read: msg.last_read ?? null,
|
||||
unread_count: msg.unread_count ?? null,
|
||||
subtype: msg.subtype ?? null,
|
||||
reactions: msg.reactions ?? [],
|
||||
is_starred: msg.is_starred ?? false,
|
||||
pinned_to: msg.pinned_to ?? [],
|
||||
files: (msg.files ?? []).map((f: any) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
mimetype: f.mimetype,
|
||||
size: f.size,
|
||||
url_private: f.url_private ?? null,
|
||||
permalink: f.permalink ?? null,
|
||||
mode: f.mode ?? null,
|
||||
})),
|
||||
attachments: msg.attachments ?? [],
|
||||
blocks: msg.blocks ?? [],
|
||||
edited: msg.edited ?? null,
|
||||
permalink: msg.permalink ?? null,
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: {
|
||||
type: 'object',
|
||||
description: 'The retrieved message object',
|
||||
properties: {
|
||||
type: { type: 'string', description: 'Message type' },
|
||||
ts: { type: 'string', description: 'Message timestamp' },
|
||||
text: { type: 'string', description: 'Message text content' },
|
||||
user: { type: 'string', description: 'User ID who sent the message' },
|
||||
bot_id: { type: 'string', description: 'Bot ID if sent by a bot', optional: true },
|
||||
username: { type: 'string', description: 'Display username', optional: true },
|
||||
channel: { type: 'string', description: 'Channel ID', optional: true },
|
||||
team: { type: 'string', description: 'Team ID', optional: true },
|
||||
thread_ts: { type: 'string', description: 'Thread parent timestamp', optional: true },
|
||||
parent_user_id: { type: 'string', description: 'User ID of thread parent', optional: true },
|
||||
reply_count: { type: 'number', description: 'Number of thread replies', optional: true },
|
||||
reply_users_count: {
|
||||
type: 'number',
|
||||
description: 'Number of users who replied',
|
||||
optional: true,
|
||||
},
|
||||
latest_reply: { type: 'string', description: 'Timestamp of latest reply', optional: true },
|
||||
subtype: { type: 'string', description: 'Message subtype', optional: true },
|
||||
reactions: {
|
||||
type: 'array',
|
||||
description: 'Array of reactions on this message',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Emoji name' },
|
||||
count: { type: 'number', description: 'Number of reactions' },
|
||||
users: {
|
||||
type: 'array',
|
||||
description: 'User IDs who reacted',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
is_starred: { type: 'boolean', description: 'Whether message is starred', optional: true },
|
||||
pinned_to: {
|
||||
type: 'array',
|
||||
description: 'Channel IDs where message is pinned',
|
||||
items: { type: 'string' },
|
||||
optional: true,
|
||||
},
|
||||
files: {
|
||||
type: 'array',
|
||||
description: 'Files attached to message',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'File ID' },
|
||||
name: { type: 'string', description: 'File name' },
|
||||
mimetype: { type: 'string', description: 'MIME type' },
|
||||
size: { type: 'number', description: 'File size in bytes' },
|
||||
url_private: { type: 'string', description: 'Private download URL' },
|
||||
permalink: { type: 'string', description: 'Permanent link to file' },
|
||||
},
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
type: 'array',
|
||||
description: 'Legacy attachments',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
blocks: { type: 'array', description: 'Block Kit blocks', items: { type: 'object' } },
|
||||
edited: {
|
||||
type: 'object',
|
||||
description: 'Edit information if message was edited',
|
||||
properties: {
|
||||
user: { type: 'string', description: 'User ID who edited' },
|
||||
ts: { type: 'string', description: 'Edit timestamp' },
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
permalink: { type: 'string', description: 'Permanent link to message', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import type { SlackGetThreadParams, SlackGetThreadResponse } from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackGetThreadTool: ToolConfig<SlackGetThreadParams, SlackGetThreadResponse> = {
|
||||
id: 'slack_get_thread',
|
||||
name: 'Slack Get Thread',
|
||||
description:
|
||||
'Retrieve an entire thread including the parent message and all replies. Useful for getting full conversation context.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
|
||||
params: {
|
||||
authMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Authentication method: oauth or bot_token',
|
||||
},
|
||||
botToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Bot token for Custom Bot',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token or bot token for Slack API',
|
||||
},
|
||||
channel: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Slack channel ID (e.g., C1234567890)',
|
||||
},
|
||||
threadTs: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Thread timestamp (thread_ts) to retrieve (e.g., 1405894322.002768)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of messages to return (default: 100, max: 200)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: SlackGetThreadParams) => {
|
||||
const url = new URL('https://slack.com/api/conversations.replies')
|
||||
url.searchParams.append('channel', params.channel?.trim() ?? '')
|
||||
url.searchParams.append('ts', params.threadTs?.trim() ?? '')
|
||||
url.searchParams.append('inclusive', 'true')
|
||||
const limit = params.limit ? Math.min(Number(params.limit), 200) : 100
|
||||
url.searchParams.append('limit', String(limit))
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: SlackGetThreadParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken || params.botToken}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
if (data.error === 'missing_scope') {
|
||||
throw new Error(
|
||||
'Missing required permissions. Please reconnect your Slack account with the necessary scopes (channels:history, groups:history).'
|
||||
)
|
||||
}
|
||||
if (data.error === 'invalid_auth') {
|
||||
throw new Error('Invalid authentication. Please check your Slack credentials.')
|
||||
}
|
||||
if (data.error === 'channel_not_found') {
|
||||
throw new Error('Channel not found. Please check the channel ID.')
|
||||
}
|
||||
if (data.error === 'thread_not_found') {
|
||||
throw new Error('Thread not found. Please check the thread timestamp.')
|
||||
}
|
||||
throw new Error(data.error || 'Failed to get thread from Slack')
|
||||
}
|
||||
|
||||
const rawMessages = data.messages || []
|
||||
if (rawMessages.length === 0) {
|
||||
throw new Error('Thread not found')
|
||||
}
|
||||
|
||||
const messages = rawMessages.map((msg: any) => ({
|
||||
type: msg.type ?? 'message',
|
||||
ts: msg.ts,
|
||||
text: msg.text ?? '',
|
||||
user: msg.user ?? null,
|
||||
bot_id: msg.bot_id ?? null,
|
||||
username: msg.username ?? null,
|
||||
channel: msg.channel ?? null,
|
||||
team: msg.team ?? null,
|
||||
thread_ts: msg.thread_ts ?? null,
|
||||
parent_user_id: msg.parent_user_id ?? null,
|
||||
reply_count: msg.reply_count ?? null,
|
||||
reply_users_count: msg.reply_users_count ?? null,
|
||||
latest_reply: msg.latest_reply ?? null,
|
||||
subscribed: msg.subscribed ?? null,
|
||||
last_read: msg.last_read ?? null,
|
||||
unread_count: msg.unread_count ?? null,
|
||||
subtype: msg.subtype ?? null,
|
||||
reactions: msg.reactions ?? [],
|
||||
is_starred: msg.is_starred ?? false,
|
||||
pinned_to: msg.pinned_to ?? [],
|
||||
files: (msg.files ?? []).map((f: any) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
mimetype: f.mimetype,
|
||||
size: f.size,
|
||||
url_private: f.url_private ?? null,
|
||||
permalink: f.permalink ?? null,
|
||||
mode: f.mode ?? null,
|
||||
})),
|
||||
attachments: msg.attachments ?? [],
|
||||
blocks: msg.blocks ?? [],
|
||||
edited: msg.edited ?? null,
|
||||
permalink: msg.permalink ?? null,
|
||||
}))
|
||||
|
||||
// First message is always the parent
|
||||
const parentMessage = messages[0]
|
||||
// Remaining messages are replies
|
||||
const replies = messages.slice(1)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
parentMessage,
|
||||
replies,
|
||||
messages,
|
||||
replyCount: replies.length,
|
||||
hasMore: data.has_more ?? false,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
parentMessage: {
|
||||
type: 'object',
|
||||
description: 'The thread parent message',
|
||||
properties: {
|
||||
type: { type: 'string', description: 'Message type' },
|
||||
ts: { type: 'string', description: 'Message timestamp' },
|
||||
text: { type: 'string', description: 'Message text content' },
|
||||
user: { type: 'string', description: 'User ID who sent the message' },
|
||||
bot_id: { type: 'string', description: 'Bot ID if sent by a bot', optional: true },
|
||||
username: { type: 'string', description: 'Display username', optional: true },
|
||||
reply_count: { type: 'number', description: 'Total number of thread replies' },
|
||||
reply_users_count: { type: 'number', description: 'Number of users who replied' },
|
||||
latest_reply: { type: 'string', description: 'Timestamp of latest reply' },
|
||||
reactions: {
|
||||
type: 'array',
|
||||
description: 'Array of reactions on the parent message',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Emoji name' },
|
||||
count: { type: 'number', description: 'Number of reactions' },
|
||||
users: {
|
||||
type: 'array',
|
||||
description: 'User IDs who reacted',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
files: {
|
||||
type: 'array',
|
||||
description: 'Files attached to the parent message',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'File ID' },
|
||||
name: { type: 'string', description: 'File name' },
|
||||
mimetype: { type: 'string', description: 'MIME type' },
|
||||
size: { type: 'number', description: 'File size in bytes' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
replies: {
|
||||
type: 'array',
|
||||
description: 'Array of reply messages in the thread (excluding the parent)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ts: { type: 'string', description: 'Message timestamp' },
|
||||
text: { type: 'string', description: 'Message text content' },
|
||||
user: { type: 'string', description: 'User ID who sent the reply' },
|
||||
reactions: { type: 'array', description: 'Reactions on the reply' },
|
||||
files: { type: 'array', description: 'Files attached to the reply' },
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
type: 'array',
|
||||
description: 'All messages in the thread (parent + replies) in chronological order',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
replyCount: {
|
||||
type: 'number',
|
||||
description: 'Number of replies returned in this response',
|
||||
},
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether there are more messages in the thread (pagination needed)',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import { slackAddReactionTool } from '@/tools/slack/add_reaction'
|
||||
import { slackCanvasTool } from '@/tools/slack/canvas'
|
||||
import { slackDeleteMessageTool } from '@/tools/slack/delete_message'
|
||||
import { slackDownloadTool } from '@/tools/slack/download'
|
||||
import { slackGetMessageTool } from '@/tools/slack/get_message'
|
||||
import { slackGetThreadTool } from '@/tools/slack/get_thread'
|
||||
import { slackGetUserTool } from '@/tools/slack/get_user'
|
||||
import { slackListChannelsTool } from '@/tools/slack/list_channels'
|
||||
import { slackListMembersTool } from '@/tools/slack/list_members'
|
||||
@@ -24,6 +22,4 @@ export {
|
||||
slackListMembersTool,
|
||||
slackListUsersTool,
|
||||
slackGetUserTool,
|
||||
slackGetMessageTool,
|
||||
slackGetThreadTool,
|
||||
}
|
||||
|
||||
@@ -71,17 +71,6 @@ export interface SlackGetUserParams extends SlackBaseParams {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface SlackGetMessageParams extends SlackBaseParams {
|
||||
channel: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface SlackGetThreadParams extends SlackBaseParams {
|
||||
channel: string
|
||||
threadTs: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface SlackMessageResponse extends ToolResponse {
|
||||
output: {
|
||||
// Legacy properties for backward compatibility
|
||||
@@ -316,22 +305,6 @@ export interface SlackGetUserResponse extends ToolResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackGetMessageResponse extends ToolResponse {
|
||||
output: {
|
||||
message: SlackMessage
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackGetThreadResponse extends ToolResponse {
|
||||
output: {
|
||||
parentMessage: SlackMessage
|
||||
replies: SlackMessage[]
|
||||
messages: SlackMessage[]
|
||||
replyCount: number
|
||||
hasMore: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type SlackResponse =
|
||||
| SlackCanvasResponse
|
||||
| SlackMessageReaderResponse
|
||||
@@ -344,5 +317,3 @@ export type SlackResponse =
|
||||
| SlackListMembersResponse
|
||||
| SlackListUsersResponse
|
||||
| SlackGetUserResponse
|
||||
| SlackGetMessageResponse
|
||||
| SlackGetThreadResponse
|
||||
|
||||
Reference in New Issue
Block a user