diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index bbeb3164..79645f8f 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -15,7 +15,11 @@ import { bus } from '@x/core/dist/runs/bus.js'; import { serviceBus } from '@x/core/dist/services/service_bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; import z from 'zod'; + +const execAsync = promisify(exec); import { RunEvent } from '@x/shared/dist/runs.js'; import { ServiceEvent } from '@x/shared/dist/service-events.js'; import container from '@x/core/dist/di/container.js'; @@ -397,13 +401,27 @@ export function setupIpcHandlers() { 'slack:getConfig': async () => { const repo = container.resolve('slackConfigRepo'); const config = await repo.getConfig(); - return { enabled: config.enabled }; + return { enabled: config.enabled, workspaces: config.workspaces }; }, 'slack:setConfig': async (_event, args) => { const repo = container.resolve('slackConfigRepo'); - await repo.setConfig({ enabled: args.enabled }); + await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces }); return { success: true }; }, + 'slack:listWorkspaces': async () => { + try { + const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 }); + const parsed = JSON.parse(stdout); + const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({ + url: w.workspace_url || '', + name: w.workspace_name || '', + })); + return { workspaces }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces'; + return { workspaces: [], error: message }; + } + }, 'onboarding:getStatus': async () => { // Show onboarding if it hasn't been completed yet const complete = isOnboardingComplete(); diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 26fb3c04..78aee6e4 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -57,6 +57,12 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha // Slack state (agent-slack CLI) const [slackEnabled, setSlackEnabled] = useState(false) const [slackLoading, setSlackLoading] = useState(true) + const [slackWorkspaces, setSlackWorkspaces] = useState>([]) + const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) + const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) + const [slackPickerOpen, setSlackPickerOpen] = useState(false) + const [slackDiscovering, setSlackDiscovering] = useState(false) + const [slackDiscoverError, setSlackDiscoverError] = useState(null) // Load available providers on mount useEffect(() => { @@ -110,21 +116,67 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha setSlackLoading(true) const result = await window.ipc.invoke('slack:getConfig', null) setSlackEnabled(result.enabled) + setSlackWorkspaces(result.workspaces || []) } catch (error) { console.error('Failed to load Slack config:', error) setSlackEnabled(false) + setSlackWorkspaces([]) } finally { setSlackLoading(false) } }, []) - // Update Slack config - const handleSlackToggle = useCallback(async (enabled: boolean) => { + // Enable Slack: discover workspaces + const handleSlackEnable = useCallback(async () => { + setSlackDiscovering(true) + setSlackDiscoverError(null) + try { + const result = await window.ipc.invoke('slack:listWorkspaces', null) + if (result.error || result.workspaces.length === 0) { + setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') + setSlackAvailableWorkspaces([]) + setSlackPickerOpen(true) + } else { + setSlackAvailableWorkspaces(result.workspaces) + setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) + setSlackPickerOpen(true) + } + } catch (error) { + console.error('Failed to discover Slack workspaces:', error) + setSlackDiscoverError('Failed to discover Slack workspaces') + setSlackPickerOpen(true) + } finally { + setSlackDiscovering(false) + } + }, []) + + // Save selected Slack workspaces + const handleSlackSaveWorkspaces = useCallback(async () => { + const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) try { setSlackLoading(true) - await window.ipc.invoke('slack:setConfig', { enabled }) - setSlackEnabled(enabled) - toast.success(enabled ? 'Slack enabled' : 'Slack disabled') + await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) + setSlackEnabled(true) + setSlackWorkspaces(selected) + setSlackPickerOpen(false) + toast.success('Slack enabled') + } catch (error) { + console.error('Failed to save Slack config:', error) + toast.error('Failed to save Slack settings') + } finally { + setSlackLoading(false) + } + }, [slackAvailableWorkspaces, slackSelectedUrls]) + + // Disable Slack + const handleSlackDisable = useCallback(async () => { + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) + setSlackEnabled(false) + setSlackWorkspaces([]) + setSlackPickerOpen(false) + toast.success('Slack disabled') } catch (error) { console.error('Failed to update Slack config:', error) toast.error('Failed to update Slack settings') @@ -505,28 +557,84 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha {/* Slack */} -
-
-
- +
+
+
+
+ +
+
+ Slack + {slackEnabled && slackWorkspaces.length > 0 ? ( + + {slackWorkspaces.map(w => w.name).join(', ')} + + ) : ( + + Send messages and view channels + + )} +
-
- Slack - - Send messages and view channels - +
+ {(slackLoading || slackDiscovering) && ( + + )} + {slackEnabled ? ( + handleSlackDisable()} + disabled={slackLoading} + /> + ) : ( + + )}
-
- {slackLoading && ( - - )} - -
+ {slackPickerOpen && ( +
+ {slackDiscoverError ? ( +

{slackDiscoverError}

+ ) : ( + <> + {slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
+ )}
)} diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index ff3d18d1..464d8b96 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -81,6 +81,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Slack state (agent-slack CLI) const [slackEnabled, setSlackEnabled] = useState(false) const [slackLoading, setSlackLoading] = useState(true) + const [slackWorkspaces, setSlackWorkspaces] = useState>([]) + const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) + const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) + const [slackPickerOpen, setSlackPickerOpen] = useState(false) + const [slackDiscovering, setSlackDiscovering] = useState(false) + const [slackDiscoverError, setSlackDiscoverError] = useState(null) const updateProviderConfig = useCallback( (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => { @@ -214,21 +220,67 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { setSlackLoading(true) const result = await window.ipc.invoke('slack:getConfig', null) setSlackEnabled(result.enabled) + setSlackWorkspaces(result.workspaces || []) } catch (error) { console.error('Failed to load Slack config:', error) setSlackEnabled(false) + setSlackWorkspaces([]) } finally { setSlackLoading(false) } }, []) - // Update Slack config - const handleSlackToggle = useCallback(async (enabled: boolean) => { + // Enable Slack: discover workspaces + const handleSlackEnable = useCallback(async () => { + setSlackDiscovering(true) + setSlackDiscoverError(null) + try { + const result = await window.ipc.invoke('slack:listWorkspaces', null) + if (result.error || result.workspaces.length === 0) { + setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') + setSlackAvailableWorkspaces([]) + setSlackPickerOpen(true) + } else { + setSlackAvailableWorkspaces(result.workspaces) + setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) + setSlackPickerOpen(true) + } + } catch (error) { + console.error('Failed to discover Slack workspaces:', error) + setSlackDiscoverError('Failed to discover Slack workspaces') + setSlackPickerOpen(true) + } finally { + setSlackDiscovering(false) + } + }, []) + + // Save selected Slack workspaces + const handleSlackSaveWorkspaces = useCallback(async () => { + const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) try { setSlackLoading(true) - await window.ipc.invoke('slack:setConfig', { enabled }) - setSlackEnabled(enabled) - toast.success(enabled ? 'Slack enabled' : 'Slack disabled') + await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) + setSlackEnabled(true) + setSlackWorkspaces(selected) + setSlackPickerOpen(false) + toast.success('Slack enabled') + } catch (error) { + console.error('Failed to save Slack config:', error) + toast.error('Failed to save Slack settings') + } finally { + setSlackLoading(false) + } + }, [slackAvailableWorkspaces, slackSelectedUrls]) + + // Disable Slack + const handleSlackDisable = useCallback(async () => { + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) + setSlackEnabled(false) + setSlackWorkspaces([]) + setSlackPickerOpen(false) + toast.success('Slack disabled') } catch (error) { console.error('Failed to update Slack config:', error) toast.error('Failed to update Slack settings') @@ -492,28 +544,82 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Render Slack row const renderSlackRow = () => ( -
-
-
- +
+
+
+
+ +
+
+ Slack + {slackEnabled && slackWorkspaces.length > 0 ? ( + + {slackWorkspaces.map(w => w.name).join(', ')} + + ) : ( + + Send messages and view channels + + )} +
-
- Slack - - Send messages and view channels - +
+ {(slackLoading || slackDiscovering) && ( + + )} + {slackEnabled ? ( + handleSlackDisable()} + disabled={slackLoading} + /> + ) : ( + + )}
-
- {slackLoading && ( - - )} - -
+ {slackPickerOpen && ( +
+ {slackDiscoverError ? ( +

{slackDiscoverError}

+ ) : ( + <> + {slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
+ )}
) diff --git a/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts b/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts index 8b6b09d6..afb3ce0d 100644 --- a/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts @@ -83,13 +83,15 @@ agent-slack canvas get F01234567 --workspace https://team.slack.com ## 3. Multi-Workspace -If the user has multiple workspaces configured, use \`--workspace \` to disambiguate: +**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`~/.rowboat/config/slack.json\` to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces. + +If the selected workspace list contains multiple entries, use \`--workspace \` to disambiguate: \`\`\` agent-slack message list "#general" --workspace https://team.slack.com \`\`\` -Use \`agent-slack auth whoami\` to see all configured workspaces. +If only one workspace is selected, always use \`--workspace\` with its URL to avoid ambiguity with other authenticated workspaces. --- diff --git a/apps/x/packages/core/src/slack/repo.ts b/apps/x/packages/core/src/slack/repo.ts index 3cb9ce99..2e29b57a 100644 --- a/apps/x/packages/core/src/slack/repo.ts +++ b/apps/x/packages/core/src/slack/repo.ts @@ -10,7 +10,7 @@ export interface ISlackConfigRepo { export class FSSlackConfigRepo implements ISlackConfigRepo { private readonly configPath = path.join(WorkDir, 'config', 'slack.json'); - private readonly defaultConfig: SlackConfig = { enabled: false }; + private readonly defaultConfig: SlackConfig = { enabled: false, workspaces: [] }; constructor() { this.ensureConfigFile(); diff --git a/apps/x/packages/core/src/slack/types.ts b/apps/x/packages/core/src/slack/types.ts index bfd1fe07..64917084 100644 --- a/apps/x/packages/core/src/slack/types.ts +++ b/apps/x/packages/core/src/slack/types.ts @@ -1,6 +1,13 @@ import z from "zod"; +export const SlackWorkspace = z.object({ + url: z.string(), + name: z.string(), +}); +export type SlackWorkspace = z.infer; + export const SlackConfig = z.object({ enabled: z.boolean(), + workspaces: z.array(SlackWorkspace).default([]), }); export type SlackConfig = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 04791f9c..8c018438 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -274,16 +274,25 @@ const ipcSchemas = { req: z.null(), res: z.object({ enabled: z.boolean(), + workspaces: z.array(z.object({ url: z.string(), name: z.string() })), }), }, 'slack:setConfig': { req: z.object({ enabled: z.boolean(), + workspaces: z.array(z.object({ url: z.string(), name: z.string() })), }), res: z.object({ success: z.literal(true), }), }, + 'slack:listWorkspaces': { + req: z.null(), + res: z.object({ + workspaces: z.array(z.object({ url: z.string(), name: z.string() })), + error: z.string().optional(), + }), + }, 'onboarding:getStatus': { req: z.null(), res: z.object({