Skip to content

Commit 1602097

Browse files
khaliqgantclaudeagent-relay-code[bot]
authored
Add Slack DM recipient picker (user-message mount model) (#156)
* Add Slack DM recipient picker (user-message mount model) Make 1:1 Slack DMs a first-class, selectable Pear source using the canonical user-recipient model `/slack/users/<U>/messages` (bare Slack user ids). This is the Pear/UI-mount layer of the comprehensive DM build; it pairs with the relayfile adapter D→U materialization and Cloud token/options wiring. - New `SlackDmRecipientPicker` (reuses `GenericScopePicker`): consumes Cloud `users` options and emits concrete `/slack/users/<U>/messages` mount paths. - `GenericScopePicker` gains `defaultSelectAll` (default true; preserves channel behavior). DM picker sets it false so a fresh picker mounts NO one's DMs by default — readability requires explicitly selecting recipients. - `ProjectSettings`: separate channel vs DM pending scope state, merged into one `mountPaths` via `mergeSlackScopeMountPaths` (each half preserved when the other changes; discovery paths kept). `listenDms` stays watch-only with copy clarifying it observes events; recipients must be chosen to read/send DMs. - Demote the vestigial `/slack/dms/*` event watch glob — no adapter resource or record ever materialized there. Canonical DM watch is `/slack/users/*/messages/**`; `/slack/channels/D*` stays diagnostic-only. - Bare `U…`/`W…` ids only; never suffixed `U…__slug` (adapter writeback and Cloud scope aliasing only bridge channel suffixes today). Tests: `slack-scope.test.ts` (helpers + mount-path merge) and an `integration-event-bridge` glob test asserting the user-message watch is canonical and `/slack/dms/*` is dropped. Source-only; no rebuild of the running instance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Clear pendingDmScopeValue on scope-editor toggle (review fix) The scope-editor open/close toggle reset pendingScopeValue + pendingSlackListenDms but not the new pendingDmScopeValue, so an unsaved DM-recipient selection could survive closing the editor and be applied on a later Save for the same or another Slack integration. Clear it in the toggle handler alongside the other pending state (Cancel and save-success paths already cleared it). Reported by event-path-codex on PR #156. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: apply pr-reviewer fixes for #156 * chore: apply pr-reviewer fixes for #156 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: agent-relay-code[bot] <agent-relay-code[bot]@users.noreply.github.com>
1 parent c660556 commit 1602097

8 files changed

Lines changed: 376 additions & 9 deletions

File tree

src/main/__tests__/integration-event-bridge.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getIntegrationEventTelemetrySnapshot,
1010
IntegrationEventBridge,
1111
createWorkspaceScopedEventClient,
12+
eventPathGlobsForIntegration,
1213
integrationSubscriptionSummaries,
1314
integrationRelayFileSyncOptions,
1415
localWatchEventPathsForFilename,
@@ -306,6 +307,29 @@ test('relayfile sdk path filters broaden partial-segment Slack DM globs', () =>
306307
])
307308
})
308309

310+
test('slack DM watch globs use the user-message model and drop vestigial /slack/dms', () => {
311+
const slackDm = integration({
312+
provider: 'slack',
313+
integrationId: 'slack-1',
314+
mountPaths: ['/slack/channels/C123ABC__proj-cloud'],
315+
scope: { listenDms: true }
316+
})
317+
const globs = eventPathGlobsForIntegration(slackDm)
318+
assert.ok(globs.includes('/slack/users/*/messages/**'), 'canonical user-message DM watch present')
319+
assert.ok(globs.includes('/slack/channels/D*/**'), 'raw-D diagnostic alias retained')
320+
assert.ok(!globs.includes('/slack/dms/*/**'), 'vestigial /slack/dms watch glob dropped')
321+
322+
const noDm = integration({
323+
provider: 'slack',
324+
integrationId: 'slack-2',
325+
mountPaths: ['/slack/channels/C123ABC__proj-cloud'],
326+
scope: { listenDms: false }
327+
})
328+
const noDmGlobs = eventPathGlobsForIntegration(noDm)
329+
assert.ok(!noDmGlobs.includes('/slack/users/*/messages/**'), 'no DM watch when listenDms is off')
330+
assert.ok(!noDmGlobs.includes('/slack/channels/D*/**'), 'no raw-D diagnostic watch when listenDms is off')
331+
})
332+
309333
test('integration event remote stream keeps a refreshable relayfile token provider', () => {
310334
const tokenProvider = async () => 'workspace-token'
311335
const options = integrationRelayFileSyncOptions({

src/main/integration-event-bridge.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,15 @@ const INTEGRATION_EVENT_LOG_PATH = join(homedir(), '.agentworkforce', 'pear', 'i
3838
const AGGREGATED_WARNING_REPEAT_EVERY = 25
3939
const MAX_AGGREGATED_WARNING_KEYS = 256
4040
const SLACK_LIVE_EVENT_WINDOW_MS = 30 * 60 * 1_000
41+
// DM event watch globs. Canonical 1:1 DM surface is the user-recipient model
42+
// `/slack/users/<U>/messages` (where the adapter materializes message.im once
43+
// D→U mapping lands). `/slack/channels/D*` is retained only as a diagnostic
44+
// alias for raw Slack IM conversation ids the adapter still materializes
45+
// channel-style today. `/slack/dms/*` was vestigial residue — no adapter
46+
// resource or record ever materialized there — so it is intentionally dropped;
47+
// it must not imply mounted/readable DM content.
4148
const SLACK_DM_EVENT_GLOBS = [
4249
'/slack/channels/D*/**',
43-
'/slack/dms/*/**',
4450
'/slack/users/*/messages/**'
4551
]
4652
const MAX_EVENT_CONTEXT_PREVIEW_BYTES = 32 * 1024
@@ -480,7 +486,7 @@ function watchGlobForPath(path: string): string {
480486
return root.endsWith('/**') ? root : `${root || '/'}/**`
481487
}
482488

483-
function eventPathGlobsForIntegration(integration: ConnectedIntegration): string[] {
489+
export function eventPathGlobsForIntegration(integration: ConnectedIntegration): string[] {
484490
return dedupeStrings([
485491
...canonicalMountPaths(integration).map(watchGlobForPath),
486492
...(slackListenDms(integration) ? SLACK_DM_EVENT_GLOBS : [])

src/renderer/src/components/settings/ProjectSettings.tsx

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@ import { ProactiveAgentsSection } from '@/components/proactive/ProactiveAgentsSe
2424
import { pear, type ConnectedIntegration } from '@/lib/ipc'
2525
import {
2626
SlackChannelPicker,
27+
SlackDmRecipientPicker,
2728
type IntegrationAccessibleResource,
2829
type ScopePickerValue
2930
} from '@/components/settings/scope-pickers'
31+
import {
32+
isSlackUserMessagesMountPath,
33+
mergeSlackScopeMountPaths,
34+
slackUserResourceFromOption
35+
} from '@/components/settings/slack-scope'
3036
import { useAgentStore } from '@/stores/agent-store'
3137
import {
3238
normalizeChannelName,
@@ -385,6 +391,7 @@ function IntegrationVisibilitySection({
385391
const [error, setError] = useState<string | null>(null)
386392
const [scopeEditorIntegrationId, setScopeEditorIntegrationId] = useState<string | null>(null)
387393
const [pendingScopeValue, setPendingScopeValue] = useState<ScopePickerValue | null>(null)
394+
const [pendingDmScopeValue, setPendingDmScopeValue] = useState<ScopePickerValue | null>(null)
388395
const [pendingSlackListenDms, setPendingSlackListenDms] = useState<boolean | null>(null)
389396
const resourceCacheRef = useRef(new Map<string, ResourceCacheEntry>())
390397

@@ -634,6 +641,23 @@ function IntegrationVisibilitySection({
634641
})
635642
}, [cachedResources, projectId])
636643

644+
const listSlackDmRecipients = useCallback(async (integration: ConnectedIntegration): Promise<IntegrationAccessibleResource[]> => {
645+
const cacheKey = `slack-users:${projectId}:${integration.integrationId}`
646+
return cachedResources(cacheKey, async () => {
647+
const listOptions = (pear.integrations as typeof pear.integrations & {
648+
listOptions?: typeof pear.integrations.listOptions
649+
}).listOptions
650+
if (typeof listOptions !== 'function') {
651+
throw new Error('Slack DM recipient options are not available yet.')
652+
}
653+
const options = await listOptions(projectId, integration.provider, 'users')
654+
if (!Array.isArray(options)) {
655+
throw new Error('Slack DM recipient options returned an unexpected response.')
656+
}
657+
return options.map(slackUserResourceFromOption)
658+
})
659+
}, [cachedResources, projectId])
660+
637661
const saveSlackSourceChannels = useCallback(async (integration: ConnectedIntegration) => {
638662
const listenDms = pendingSlackListenDms ?? slackListenDmsFromScope(integration.scope)
639663
const nextScope = {
@@ -642,8 +666,18 @@ function IntegrationVisibilitySection({
642666
selection: pendingScopeValue?.scope.selection ?? integration.scope.selection ?? 'selected',
643667
channels: pendingScopeValue?.scope.channels ?? integration.scope.channels ?? [],
644668
resources: pendingScopeValue?.scope.resources ?? integration.scope.resources ?? [],
669+
dmUsers: pendingDmScopeValue?.scope.dmUsers ?? integration.scope.dmUsers ?? [],
645670
listenDms
646671
}
672+
const nextMountPaths = mergeSlackScopeMountPaths({
673+
existing: integration.mountPaths ?? [],
674+
channelPaths: pendingScopeValue?.mountPaths ?? null,
675+
// Only mount concrete `/slack/users/<U>/messages` recipients — never a bare
676+
// `/slack/users` root (emitted when no users are available to select).
677+
dmPaths: pendingDmScopeValue
678+
? (pendingDmScopeValue.mountPaths ?? []).filter(isSlackUserMessagesMountPath)
679+
: null
680+
})
647681

648682
setBusyIntegrationId(integration.integrationId)
649683
setError(null)
@@ -652,7 +686,7 @@ function IntegrationVisibilitySection({
652686
projectId,
653687
integration.integrationId,
654688
nextScope,
655-
pendingScopeValue?.mountPaths ?? integration.mountPaths
689+
nextMountPaths
656690
)
657691
setIntegrations((current) =>
658692
current.map((entry) =>
@@ -661,13 +695,14 @@ function IntegrationVisibilitySection({
661695
)
662696
setScopeEditorIntegrationId(null)
663697
setPendingScopeValue(null)
698+
setPendingDmScopeValue(null)
664699
setPendingSlackListenDms(null)
665700
} catch (err) {
666701
setError(err instanceof Error ? err.message : String(err))
667702
} finally {
668703
setBusyIntegrationId(null)
669704
}
670-
}, [pendingScopeValue, pendingSlackListenDms, projectId])
705+
}, [pendingScopeValue, pendingDmScopeValue, pendingSlackListenDms, projectId])
671706

672707
return (
673708
<Section
@@ -729,11 +764,19 @@ function IntegrationVisibilitySection({
729764
const scopeEditorOpen = scopeEditorIntegrationId === integration.integrationId
730765
const savedSlackListenDms = slackListenDmsFromScope(integration.scope)
731766
const slackListenDms = pendingSlackListenDms ?? savedSlackListenDms
767+
const integrationMountPaths = integration.mountPaths ?? []
732768
const selectedSlackSourceIds = Array.from(new Set([
733-
...integration.mountPaths,
769+
...integrationMountPaths,
734770
...scopeStringList(integration.scope, 'channels')
735771
]))
736-
const slackScopeDirty = !!pendingScopeValue || (
772+
const selectedSlackDmRecipientIds = Array.from(new Set([
773+
...integrationMountPaths
774+
.filter(isSlackUserMessagesMountPath)
775+
.map((path) => path.split('/')[3])
776+
.filter(Boolean),
777+
...scopeStringList(integration.scope, 'dmUsers')
778+
]))
779+
const slackScopeDirty = !!pendingScopeValue || !!pendingDmScopeValue || (
737780
pendingSlackListenDms !== null && pendingSlackListenDms !== savedSlackListenDms
738781
)
739782
const knownTargetValues = new Set([
@@ -773,6 +816,7 @@ function IntegrationVisibilitySection({
773816
disabled={busy}
774817
onClick={() => {
775818
setPendingScopeValue(null)
819+
setPendingDmScopeValue(null)
776820
setPendingSlackListenDms(scopeEditorOpen ? null : savedSlackListenDms)
777821
setScopeEditorIntegrationId(scopeEditorOpen ? null : integration.integrationId)
778822
}}
@@ -879,16 +923,28 @@ function IntegrationVisibilitySection({
879923
/>
880924
<span className="min-w-0">
881925
<span className="block text-sm text-[var(--pear-text)]">Direct messages</span>
882-
<span className="block text-xs text-[var(--pear-text-faint)]">Listen for Slack DMs</span>
926+
<span className="block text-xs text-[var(--pear-text-faint)]">
927+
Observe DM events (watch-only). Choose recipients below to read &amp; send their DMs.
928+
</span>
883929
</span>
884930
</label>
931+
<div className="mt-3">
932+
<SlackDmRecipientPicker
933+
provider="slack"
934+
disabled={busy}
935+
initialSelectedIds={selectedSlackDmRecipientIds}
936+
listAccessibleResources={() => listSlackDmRecipients(integration)}
937+
onChange={setPendingDmScopeValue}
938+
/>
939+
</div>
885940
<div className="mt-3 flex justify-end gap-2">
886941
<button
887942
type="button"
888943
disabled={busy}
889944
onClick={() => {
890945
setScopeEditorIntegrationId(null)
891946
setPendingScopeValue(null)
947+
setPendingDmScopeValue(null)
892948
setPendingSlackListenDms(null)
893949
}}
894950
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"

src/renderer/src/components/settings/scope-pickers/GenericScopePicker.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ type GenericScopePickerProps = ScopePickerProps & {
3232
resourceNoun?: string
3333
baseMountPath?: string
3434
scopeKey?: string
35+
// When no prior selection exists, select every resource by default (true) or
36+
// none (false). Bounded "watch everything" surfaces like channels default to
37+
// true; DM recipients default to false so we never mount every user's DMs.
38+
defaultSelectAll?: boolean
3539
getResourceLabel?: (resource: IntegrationAccessibleResource) => string
3640
getResourceDescription?: (resource: IntegrationAccessibleResource) => string
3741
getResourceMountSegment?: (resource: IntegrationAccessibleResource) => string
@@ -89,6 +93,7 @@ export function GenericScopePicker({
8993
resourceNoun = 'resources',
9094
baseMountPath = `/integrations/${provider}`,
9195
scopeKey = 'resources',
96+
defaultSelectAll = true,
9297
getResourceLabel = (resource) => resourceText(resource, 'displayName', 'title', 'name', 'slug', 'key', 'path', 'id'),
9398
getResourceDescription = (resource) => resourceText(resource, 'path', 'type', 'kind'),
9499
getResourceMountSegment = (resource) => resourceText(resource, 'path', 'slug', 'key', 'name', 'id'),
@@ -103,13 +108,15 @@ export function GenericScopePicker({
103108
const getResourceScopeIdRef = useRef(getResourceScopeId)
104109
const listAccessibleResourcesRef = useRef(listAccessibleResources)
105110
const initialSelectedIdsRef = useRef(initialSelectedIds)
111+
const defaultSelectAllRef = useRef(defaultSelectAll)
106112

107113
useEffect(() => {
108114
onChangeRef.current = onChange
109115
getResourceMountSegmentRef.current = getResourceMountSegment
110116
getResourceScopeIdRef.current = getResourceScopeId
111117
listAccessibleResourcesRef.current = listAccessibleResources
112118
initialSelectedIdsRef.current = initialSelectedIds
119+
defaultSelectAllRef.current = defaultSelectAll
113120
})
114121

115122
useEffect(() => {
@@ -126,7 +133,9 @@ export function GenericScopePicker({
126133
new Set(
127134
seeds && seeds.length > 0
128135
? seeds
129-
: nextResources.map(resourceId)
136+
: defaultSelectAllRef.current
137+
? nextResources.map(resourceId)
138+
: []
130139
)
131140
)
132141
})
@@ -153,9 +162,10 @@ export function GenericScopePicker({
153162
)
154163

155164
useEffect(() => {
156-
if (loading) return
165+
if (loading || error) return
157166

158167
if (resources.length === 0) {
168+
if (!defaultSelectAll) return
159169
onChangeRef.current({
160170
scope: { provider, selection: 'all', [scopeKey]: [] },
161171
mountPaths: [baseMountPath]
@@ -181,6 +191,8 @@ export function GenericScopePicker({
181191
})
182192
}, [
183193
baseMountPath,
194+
defaultSelectAll,
195+
error,
184196
loading,
185197
provider,
186198
resources.length,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type React from 'react'
2+
import { GenericScopePicker, metadataText, resourceText, type ScopePickerProps } from './GenericScopePicker'
3+
import { slackDmMountSegment, slackDmUserId } from '../slack-scope'
4+
5+
export function SlackDmRecipientPicker(props: ScopePickerProps): React.ReactNode {
6+
return (
7+
<GenericScopePicker
8+
{...props}
9+
title="Choose DM recipients"
10+
resourceNoun="people"
11+
baseMountPath="/slack/users"
12+
scopeKey="dmUsers"
13+
defaultSelectAll={false}
14+
getResourceLabel={(resource) => {
15+
const name = resourceText(resource, 'displayName', 'name', 'title')
16+
if (!name) return slackDmUserId(resource)
17+
return name.startsWith('@') ? name : `@${name}`
18+
}}
19+
getResourceDescription={(resource) =>
20+
metadataText(resource, 'realName', 'email', 'workspace', 'team') || slackDmUserId(resource)
21+
}
22+
getResourceMountSegment={slackDmMountSegment}
23+
getResourceScopeId={slackDmUserId}
24+
/>
25+
)
26+
}

src/renderer/src/components/settings/scope-pickers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export { GitHubRepoPicker } from './GitHubRepoPicker'
1111
export { LinearTeamPicker } from './LinearTeamPicker'
1212
export { NotionDatabasePicker } from './NotionDatabasePicker'
1313
export { SlackChannelPicker } from './SlackChannelPicker'
14+
export { SlackDmRecipientPicker } from './SlackDmRecipientPicker'

0 commit comments

Comments
 (0)