-
Notifications
You must be signed in to change notification settings - Fork 0
Add Slack DM recipient picker (user-message mount model) #156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cc3d84f
efe2737
36e10c3
0526295
e745219
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -24,9 +24,15 @@ import { ProactiveAgentsSection } from '@/components/proactive/ProactiveAgentsSe | |||||||||||||||||||||||||||||
| import { pear, type ConnectedIntegration } from '@/lib/ipc' | ||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||
| SlackChannelPicker, | ||||||||||||||||||||||||||||||
| SlackDmRecipientPicker, | ||||||||||||||||||||||||||||||
| type IntegrationAccessibleResource, | ||||||||||||||||||||||||||||||
| type ScopePickerValue | ||||||||||||||||||||||||||||||
| } from '@/components/settings/scope-pickers' | ||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||
| isSlackUserMessagesMountPath, | ||||||||||||||||||||||||||||||
| mergeSlackScopeMountPaths, | ||||||||||||||||||||||||||||||
| slackUserResourceFromOption | ||||||||||||||||||||||||||||||
| } from '@/components/settings/slack-scope' | ||||||||||||||||||||||||||||||
| import { useAgentStore } from '@/stores/agent-store' | ||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||
| normalizeChannelName, | ||||||||||||||||||||||||||||||
|
|
@@ -385,6 +391,7 @@ function IntegrationVisibilitySection({ | |||||||||||||||||||||||||||||
| const [error, setError] = useState<string | null>(null) | ||||||||||||||||||||||||||||||
| const [scopeEditorIntegrationId, setScopeEditorIntegrationId] = useState<string | null>(null) | ||||||||||||||||||||||||||||||
| const [pendingScopeValue, setPendingScopeValue] = useState<ScopePickerValue | null>(null) | ||||||||||||||||||||||||||||||
| const [pendingDmScopeValue, setPendingDmScopeValue] = useState<ScopePickerValue | null>(null) | ||||||||||||||||||||||||||||||
| const [pendingSlackListenDms, setPendingSlackListenDms] = useState<boolean | null>(null) | ||||||||||||||||||||||||||||||
| const resourceCacheRef = useRef(new Map<string, ResourceCacheEntry>()) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -634,6 +641,23 @@ function IntegrationVisibilitySection({ | |||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||
| }, [cachedResources, projectId]) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const listSlackDmRecipients = useCallback(async (integration: ConnectedIntegration): Promise<IntegrationAccessibleResource[]> => { | ||||||||||||||||||||||||||||||
| const cacheKey = `slack-users:${projectId}:${integration.integrationId}` | ||||||||||||||||||||||||||||||
| return cachedResources(cacheKey, async () => { | ||||||||||||||||||||||||||||||
| const listOptions = (pear.integrations as typeof pear.integrations & { | ||||||||||||||||||||||||||||||
| listOptions?: typeof pear.integrations.listOptions | ||||||||||||||||||||||||||||||
| }).listOptions | ||||||||||||||||||||||||||||||
| if (typeof listOptions !== 'function') { | ||||||||||||||||||||||||||||||
| throw new Error('Slack DM recipient options are not available yet.') | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const options = await listOptions(projectId, integration.provider, 'users') | ||||||||||||||||||||||||||||||
| if (!Array.isArray(options)) { | ||||||||||||||||||||||||||||||
| throw new Error('Slack DM recipient options returned an unexpected response.') | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| return options.map(slackUserResourceFromOption) | ||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||
| }, [cachedResources, projectId]) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const saveSlackSourceChannels = useCallback(async (integration: ConnectedIntegration) => { | ||||||||||||||||||||||||||||||
| const listenDms = pendingSlackListenDms ?? slackListenDmsFromScope(integration.scope) | ||||||||||||||||||||||||||||||
| const nextScope = { | ||||||||||||||||||||||||||||||
|
|
@@ -642,8 +666,18 @@ function IntegrationVisibilitySection({ | |||||||||||||||||||||||||||||
| selection: pendingScopeValue?.scope.selection ?? integration.scope.selection ?? 'selected', | ||||||||||||||||||||||||||||||
| channels: pendingScopeValue?.scope.channels ?? integration.scope.channels ?? [], | ||||||||||||||||||||||||||||||
| resources: pendingScopeValue?.scope.resources ?? integration.scope.resources ?? [], | ||||||||||||||||||||||||||||||
| dmUsers: pendingDmScopeValue?.scope.dmUsers ?? integration.scope.dmUsers ?? [], | ||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: DM recipient state can be unintentionally cleared when recipient options are empty/unavailable, because pending picker output overwrites existing Prompt for AI agents |
||||||||||||||||||||||||||||||
| listenDms | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const nextMountPaths = mergeSlackScopeMountPaths({ | ||||||||||||||||||||||||||||||
| existing: integration.mountPaths ?? [], | ||||||||||||||||||||||||||||||
| channelPaths: pendingScopeValue?.mountPaths ?? null, | ||||||||||||||||||||||||||||||
| // Only mount concrete `/slack/users/<U>/messages` recipients — never a bare | ||||||||||||||||||||||||||||||
| // `/slack/users` root (emitted when no users are available to select). | ||||||||||||||||||||||||||||||
| dmPaths: pendingDmScopeValue | ||||||||||||||||||||||||||||||
| ? (pendingDmScopeValue.mountPaths ?? []).filter(isSlackUserMessagesMountPath) | ||||||||||||||||||||||||||||||
| : null | ||||||||||||||||||||||||||||||
|
Comment on lines
+677
to
+679
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard against cases where
Suggested change
|
||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| setBusyIntegrationId(integration.integrationId) | ||||||||||||||||||||||||||||||
| setError(null) | ||||||||||||||||||||||||||||||
|
|
@@ -652,7 +686,7 @@ function IntegrationVisibilitySection({ | |||||||||||||||||||||||||||||
| projectId, | ||||||||||||||||||||||||||||||
| integration.integrationId, | ||||||||||||||||||||||||||||||
| nextScope, | ||||||||||||||||||||||||||||||
| pendingScopeValue?.mountPaths ?? integration.mountPaths | ||||||||||||||||||||||||||||||
| nextMountPaths | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| setIntegrations((current) => | ||||||||||||||||||||||||||||||
| current.map((entry) => | ||||||||||||||||||||||||||||||
|
|
@@ -661,13 +695,14 @@ function IntegrationVisibilitySection({ | |||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| setScopeEditorIntegrationId(null) | ||||||||||||||||||||||||||||||
| setPendingScopeValue(null) | ||||||||||||||||||||||||||||||
| setPendingDmScopeValue(null) | ||||||||||||||||||||||||||||||
| setPendingSlackListenDms(null) | ||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||
| setError(err instanceof Error ? err.message : String(err)) | ||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||
| setBusyIntegrationId(null) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| }, [pendingScopeValue, pendingSlackListenDms, projectId]) | ||||||||||||||||||||||||||||||
| }, [pendingScopeValue, pendingDmScopeValue, pendingSlackListenDms, projectId]) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <Section | ||||||||||||||||||||||||||||||
|
|
@@ -729,11 +764,19 @@ function IntegrationVisibilitySection({ | |||||||||||||||||||||||||||||
| const scopeEditorOpen = scopeEditorIntegrationId === integration.integrationId | ||||||||||||||||||||||||||||||
| const savedSlackListenDms = slackListenDmsFromScope(integration.scope) | ||||||||||||||||||||||||||||||
| const slackListenDms = pendingSlackListenDms ?? savedSlackListenDms | ||||||||||||||||||||||||||||||
| const integrationMountPaths = integration.mountPaths ?? [] | ||||||||||||||||||||||||||||||
| const selectedSlackSourceIds = Array.from(new Set([ | ||||||||||||||||||||||||||||||
| ...integration.mountPaths, | ||||||||||||||||||||||||||||||
| ...integrationMountPaths, | ||||||||||||||||||||||||||||||
| ...scopeStringList(integration.scope, 'channels') | ||||||||||||||||||||||||||||||
| ])) | ||||||||||||||||||||||||||||||
|
Comment on lines
+767
to
771
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Filter DM mount paths out of the channel picker seed state. Lines 767-771 now feed every Suggested fix📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| const slackScopeDirty = !!pendingScopeValue || ( | ||||||||||||||||||||||||||||||
| const selectedSlackDmRecipientIds = Array.from(new Set([ | ||||||||||||||||||||||||||||||
| ...integrationMountPaths | ||||||||||||||||||||||||||||||
| .filter(isSlackUserMessagesMountPath) | ||||||||||||||||||||||||||||||
| .map((path) => path.split('/')[3]) | ||||||||||||||||||||||||||||||
| .filter(Boolean), | ||||||||||||||||||||||||||||||
| ...scopeStringList(integration.scope, 'dmUsers') | ||||||||||||||||||||||||||||||
| ])) | ||||||||||||||||||||||||||||||
|
Comment on lines
+772
to
+778
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Suggested change
|
||||||||||||||||||||||||||||||
| const slackScopeDirty = !!pendingScopeValue || !!pendingDmScopeValue || ( | ||||||||||||||||||||||||||||||
| pendingSlackListenDms !== null && pendingSlackListenDms !== savedSlackListenDms | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| const knownTargetValues = new Set([ | ||||||||||||||||||||||||||||||
|
|
@@ -773,6 +816,7 @@ function IntegrationVisibilitySection({ | |||||||||||||||||||||||||||||
| disabled={busy} | ||||||||||||||||||||||||||||||
| onClick={() => { | ||||||||||||||||||||||||||||||
| setPendingScopeValue(null) | ||||||||||||||||||||||||||||||
| setPendingDmScopeValue(null) | ||||||||||||||||||||||||||||||
| setPendingSlackListenDms(scopeEditorOpen ? null : savedSlackListenDms) | ||||||||||||||||||||||||||||||
| setScopeEditorIntegrationId(scopeEditorOpen ? null : integration.integrationId) | ||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||
|
|
@@ -879,16 +923,28 @@ function IntegrationVisibilitySection({ | |||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||
| <span className="min-w-0"> | ||||||||||||||||||||||||||||||
| <span className="block text-sm text-[var(--pear-text)]">Direct messages</span> | ||||||||||||||||||||||||||||||
| <span className="block text-xs text-[var(--pear-text-faint)]">Listen for Slack DMs</span> | ||||||||||||||||||||||||||||||
| <span className="block text-xs text-[var(--pear-text-faint)]"> | ||||||||||||||||||||||||||||||
| Observe DM events (watch-only). Choose recipients below to read & send their DMs. | ||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||
| </label> | ||||||||||||||||||||||||||||||
| <div className="mt-3"> | ||||||||||||||||||||||||||||||
| <SlackDmRecipientPicker | ||||||||||||||||||||||||||||||
| provider="slack" | ||||||||||||||||||||||||||||||
| disabled={busy} | ||||||||||||||||||||||||||||||
| initialSelectedIds={selectedSlackDmRecipientIds} | ||||||||||||||||||||||||||||||
| listAccessibleResources={() => listSlackDmRecipients(integration)} | ||||||||||||||||||||||||||||||
| onChange={setPendingDmScopeValue} | ||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| <div className="mt-3 flex justify-end gap-2"> | ||||||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||||||
| disabled={busy} | ||||||||||||||||||||||||||||||
| onClick={() => { | ||||||||||||||||||||||||||||||
| setScopeEditorIntegrationId(null) | ||||||||||||||||||||||||||||||
| setPendingScopeValue(null) | ||||||||||||||||||||||||||||||
| setPendingDmScopeValue(null) | ||||||||||||||||||||||||||||||
| setPendingSlackListenDms(null) | ||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||
| className="h-8 rounded-md border border-[var(--pear-border)] px-3 text-xs text-[var(--pear-text-dim)] hover:text-[var(--pear-text)] disabled:opacity-40" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import type React from 'react' | ||
| import { GenericScopePicker, metadataText, resourceText, type ScopePickerProps } from './GenericScopePicker' | ||
| import { slackDmMountSegment, slackDmUserId } from '../slack-scope' | ||
|
|
||
| export function SlackDmRecipientPicker(props: ScopePickerProps): React.ReactNode { | ||
| return ( | ||
| <GenericScopePicker | ||
| {...props} | ||
| title="Choose DM recipients" | ||
| resourceNoun="people" | ||
| baseMountPath="/slack/users" | ||
| scopeKey="dmUsers" | ||
| defaultSelectAll={false} | ||
| getResourceLabel={(resource) => { | ||
| const name = resourceText(resource, 'displayName', 'name', 'title') | ||
| if (!name) return slackDmUserId(resource) | ||
| return name.startsWith('@') ? name : `@${name}` | ||
| }} | ||
| getResourceDescription={(resource) => | ||
| metadataText(resource, 'realName', 'email', 'workspace', 'team') || slackDmUserId(resource) | ||
| } | ||
| getResourceMountSegment={slackDmMountSegment} | ||
| getResourceScopeId={slackDmUserId} | ||
| /> | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
listOptionsreturns a non-array value (e.g., due to an unexpected API response or error state), callingoptions.mapwill throw aTypeError. Adding anArray.isArray(options)check ensures the application handles unexpected API payloads gracefully.