From cc3d84f9f0b693f03ad786fae2d981cc3306582d Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 8 Jun 2026 06:52:57 +0200 Subject: [PATCH 1/4] Add Slack DM recipient picker (user-message mount model) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make 1:1 Slack DMs a first-class, selectable Pear source using the canonical user-recipient model `/slack/users//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//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 --- .../integration-event-bridge.test.ts | 24 +++++ src/main/integration-event-bridge.ts | 10 +- .../components/settings/ProjectSettings.tsx | 59 ++++++++++- .../scope-pickers/GenericScopePicker.tsx | 11 +- .../scope-pickers/SlackDmRecipientPicker.tsx | 26 +++++ .../settings/scope-pickers/index.ts | 1 + .../components/settings/slack-scope.test.ts | 100 ++++++++++++++++++ .../src/components/settings/slack-scope.ts | 96 +++++++++++++++++ 8 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/settings/scope-pickers/SlackDmRecipientPicker.tsx create mode 100644 src/renderer/src/components/settings/slack-scope.test.ts create mode 100644 src/renderer/src/components/settings/slack-scope.ts diff --git a/src/main/__tests__/integration-event-bridge.test.ts b/src/main/__tests__/integration-event-bridge.test.ts index 38d36277..1de1ac66 100644 --- a/src/main/__tests__/integration-event-bridge.test.ts +++ b/src/main/__tests__/integration-event-bridge.test.ts @@ -7,6 +7,7 @@ import { getIntegrationEventTelemetrySnapshot, IntegrationEventBridge, createWorkspaceScopedEventClient, + eventPathGlobsForIntegration, integrationSubscriptionSummaries, integrationRelayFileSyncOptions, localWatchEventPathsForFilename, @@ -304,6 +305,29 @@ test('relayfile sdk path filters broaden partial-segment Slack DM globs', () => ]) }) +test('slack DM watch globs use the user-message model and drop vestigial /slack/dms', () => { + const slackDm = integration({ + provider: 'slack', + integrationId: 'slack-1', + mountPaths: ['/slack/channels/C123ABC__proj-cloud'], + scope: { listenDms: true } + }) + const globs = eventPathGlobsForIntegration(slackDm) + assert.ok(globs.includes('/slack/users/*/messages/**'), 'canonical user-message DM watch present') + assert.ok(globs.includes('/slack/channels/D*/**'), 'raw-D diagnostic alias retained') + assert.ok(!globs.includes('/slack/dms/*/**'), 'vestigial /slack/dms watch glob dropped') + + const noDm = integration({ + provider: 'slack', + integrationId: 'slack-2', + mountPaths: ['/slack/channels/C123ABC__proj-cloud'], + scope: { listenDms: false } + }) + const noDmGlobs = eventPathGlobsForIntegration(noDm) + assert.ok(!noDmGlobs.includes('/slack/users/*/messages/**'), 'no DM watch when listenDms is off') + assert.ok(!noDmGlobs.includes('/slack/channels/D*/**'), 'no raw-D diagnostic watch when listenDms is off') +}) + test('integration event remote stream keeps a refreshable relayfile token provider', () => { const tokenProvider = async () => 'workspace-token' const options = integrationRelayFileSyncOptions({ diff --git a/src/main/integration-event-bridge.ts b/src/main/integration-event-bridge.ts index 7f96e3c2..7f7a6989 100644 --- a/src/main/integration-event-bridge.ts +++ b/src/main/integration-event-bridge.ts @@ -38,9 +38,15 @@ const INTEGRATION_EVENT_LOG_PATH = join(homedir(), '.agentworkforce', 'pear', 'i const AGGREGATED_WARNING_REPEAT_EVERY = 25 const MAX_AGGREGATED_WARNING_KEYS = 256 const SLACK_LIVE_EVENT_WINDOW_MS = 30 * 60 * 1_000 +// DM event watch globs. Canonical 1:1 DM surface is the user-recipient model +// `/slack/users//messages` (where the adapter materializes message.im once +// D→U mapping lands). `/slack/channels/D*` is retained only as a diagnostic +// alias for raw Slack IM conversation ids the adapter still materializes +// channel-style today. `/slack/dms/*` was vestigial residue — no adapter +// resource or record ever materialized there — so it is intentionally dropped; +// it must not imply mounted/readable DM content. const SLACK_DM_EVENT_GLOBS = [ '/slack/channels/D*/**', - '/slack/dms/*/**', '/slack/users/*/messages/**' ] const MAX_EVENT_CONTEXT_PREVIEW_BYTES = 32 * 1024 @@ -474,7 +480,7 @@ function watchGlobForPath(path: string): string { return root.endsWith('/**') ? root : `${root || '/'}/**` } -function eventPathGlobsForIntegration(integration: ConnectedIntegration): string[] { +export function eventPathGlobsForIntegration(integration: ConnectedIntegration): string[] { return dedupeStrings([ ...canonicalMountPaths(integration).map(watchGlobForPath), ...(slackListenDms(integration) ? SLACK_DM_EVENT_GLOBS : []) diff --git a/src/renderer/src/components/settings/ProjectSettings.tsx b/src/renderer/src/components/settings/ProjectSettings.tsx index c9762635..47e4e622 100644 --- a/src/renderer/src/components/settings/ProjectSettings.tsx +++ b/src/renderer/src/components/settings/ProjectSettings.tsx @@ -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(null) const [scopeEditorIntegrationId, setScopeEditorIntegrationId] = useState(null) const [pendingScopeValue, setPendingScopeValue] = useState(null) + const [pendingDmScopeValue, setPendingDmScopeValue] = useState(null) const [pendingSlackListenDms, setPendingSlackListenDms] = useState(null) const resourceCacheRef = useRef(new Map()) @@ -634,6 +641,20 @@ function IntegrationVisibilitySection({ }) }, [cachedResources, projectId]) + const listSlackDmRecipients = useCallback(async (integration: ConnectedIntegration): Promise => { + 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') + return options.map(slackUserResourceFromOption) + }) + }, [cachedResources, projectId]) + const saveSlackSourceChannels = useCallback(async (integration: ConnectedIntegration) => { const listenDms = pendingSlackListenDms ?? slackListenDmsFromScope(integration.scope) const nextScope = { @@ -642,8 +663,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 ?? [], listenDms } + const nextMountPaths = mergeSlackScopeMountPaths({ + existing: integration.mountPaths, + channelPaths: pendingScopeValue?.mountPaths ?? null, + // Only mount concrete `/slack/users//messages` recipients — never a bare + // `/slack/users` root (emitted when no users are available to select). + dmPaths: pendingDmScopeValue + ? pendingDmScopeValue.mountPaths.filter(isSlackUserMessagesMountPath) + : null + }) setBusyIntegrationId(integration.integrationId) setError(null) @@ -652,7 +683,7 @@ function IntegrationVisibilitySection({ projectId, integration.integrationId, nextScope, - pendingScopeValue?.mountPaths ?? integration.mountPaths + nextMountPaths ) setIntegrations((current) => current.map((entry) => @@ -661,13 +692,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 (
path.split('/')[3]) + .filter(Boolean), + ...scopeStringList(integration.scope, 'dmUsers') + ])) + const slackScopeDirty = !!pendingScopeValue || !!pendingDmScopeValue || ( pendingSlackListenDms !== null && pendingSlackListenDms !== savedSlackListenDms ) const knownTargetValues = new Set([ @@ -879,9 +918,20 @@ function IntegrationVisibilitySection({ /> Direct messages - Listen for Slack DMs + + Observe DM events (watch-only). Choose recipients below to read & send their DMs. + +
+ listSlackDmRecipients(integration)} + onChange={setPendingDmScopeValue} + /> +