select workspace

This commit is contained in:
Arjun 2026-02-25 16:29:09 +05:30
parent 36f700cc77
commit 6cd5abce48
7 changed files with 303 additions and 53 deletions

View file

@ -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<ISlackConfigRepo>('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<ISlackConfigRepo>('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();

View file

@ -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<Array<{ url: string; name: string }>>([])
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(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
</div>
{/* Slack */}
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
<div className="rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{slackEnabled && slackWorkspaces.length > 0 ? (
<span className="text-xs text-muted-foreground truncate">
{slackWorkspaces.map(w => w.name).join(', ')}
</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
<div className="shrink-0 flex items-center gap-2">
{(slackLoading || slackDiscovering) && (
<Loader2 className="size-3 animate-spin" />
)}
{slackEnabled ? (
<Switch
checked={true}
onCheckedChange={() => handleSlackDisable()}
disabled={slackLoading}
/>
) : (
<Button
variant="default"
size="sm"
onClick={handleSlackEnable}
disabled={slackLoading || slackDiscovering}
className="h-7 px-2 text-xs"
>
Enable
</Button>
)}
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{slackLoading && (
<Loader2 className="size-3 animate-spin" />
)}
<Switch
checked={slackEnabled}
onCheckedChange={handleSlackToggle}
disabled={slackLoading}
/>
</div>
{slackPickerOpen && (
<div className="mt-2 ml-11 space-y-2">
{slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
) : (
<>
{slackAvailableWorkspaces.map(w => (
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={slackSelectedUrls.has(w.url)}
onChange={(e) => {
setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(w.url)
else next.delete(w.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{w.name}</span>
</label>
))}
<Button
size="sm"
onClick={handleSlackSaveWorkspaces}
disabled={slackSelectedUrls.size === 0 || slackLoading}
className="h-7 px-3 text-xs"
>
Save
</Button>
</>
)}
</div>
)}
</div>
</>
)}

View file

@ -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<Array<{ url: string; name: string }>>([])
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(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 = () => (
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-5" />
<div className="rounded-md px-3 py-3 hover:bg-accent">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-5" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{slackEnabled && slackWorkspaces.length > 0 ? (
<span className="text-xs text-muted-foreground truncate">
{slackWorkspaces.map(w => w.name).join(', ')}
</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
<div className="shrink-0 flex items-center gap-2">
{(slackLoading || slackDiscovering) && (
<Loader2 className="size-3 animate-spin" />
)}
{slackEnabled ? (
<Switch
checked={true}
onCheckedChange={() => handleSlackDisable()}
disabled={slackLoading}
/>
) : (
<Button
variant="default"
size="sm"
onClick={handleSlackEnable}
disabled={slackLoading || slackDiscovering}
>
Enable
</Button>
)}
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{slackLoading && (
<Loader2 className="size-3 animate-spin" />
)}
<Switch
checked={slackEnabled}
onCheckedChange={handleSlackToggle}
disabled={slackLoading}
/>
</div>
{slackPickerOpen && (
<div className="mt-2 ml-13 space-y-2">
{slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
) : (
<>
{slackAvailableWorkspaces.map(w => (
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={slackSelectedUrls.has(w.url)}
onChange={(e) => {
setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(w.url)
else next.delete(w.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{w.name}</span>
</label>
))}
<Button
size="sm"
onClick={handleSlackSaveWorkspaces}
disabled={slackSelectedUrls.size === 0 || slackLoading}
>
Save
</Button>
</>
)}
</div>
)}
</div>
)

View file

@ -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 <url>\` 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 <url>\` 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.
---

View file

@ -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();

View file

@ -1,6 +1,13 @@
import z from "zod";
export const SlackWorkspace = z.object({
url: z.string(),
name: z.string(),
});
export type SlackWorkspace = z.infer<typeof SlackWorkspace>;
export const SlackConfig = z.object({
enabled: z.boolean(),
workspaces: z.array(SlackWorkspace).default([]),
});
export type SlackConfig = z.infer<typeof SlackConfig>;

View file

@ -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({