feat: ship Slack as a knowledge source, hardened for production (#596)

* index slack and add to home page

* filter only useful slack messages in homr

* feat: bundle agent-slack CLI and route all calls through shared executor

Pins agent-slack@0.9.3, bundles it next to main.cjs (replaces the startup npm install -g), adds a structured-result executor with bundled/global/PATH resolution, a slack:cliStatus IPC probe, and a PATH shim so the Copilot skill keeps working.

* feat: surface Slack failures and add cross-OS auth fallbacks

Classify agent-slack errors (not_authed/rate_limited/network/bad_channel), persist per-source sync status with rate-limit backoff, and expose it via slack:knowledgeStatus. Fix the Settings Enable bounce-back with actionable copy, a browser-paste (parse-curl) fallback, and a Windows quit-Slack-and-import button; add home-feed empty/error states.

* feat: rank Slack home feed deterministically by recency

Drop the per-load LLM ranker (cost/latency/model dependency) in favor of a stronger deterministic filter + recency ordering. The filter now removes system messages, emoji/reaction-only posts, bare greetings/acks, and empty bodies, with a durable-signal escape hatch. Expand tests to one describe per noise class plus ordering/cap/volume coverage.

* fix: hide Slack knowledge Save button once saved

Only show the Save button when the channel list or enabled toggle differs from the last-persisted config, so it disappears after a successful save and reappears when a new channel is entered.

---------

Co-authored-by: Gagancreates <gaganp000999@gmail.com>
This commit is contained in:
arkml 2026-06-18 01:22:27 +05:30 committed by GitHub
parent 2ddec07712
commit 79162ebc69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 2979 additions and 66 deletions

View file

@ -68,4 +68,32 @@ for (const dir of fs.readdirSync(prebuildsDir)) {
}
console.log('✅ node-pty staged in .package/node_modules');
console.log('✅ Main process bundled to .package/dist-bundle/main.js');
// Bundle the vendored agent-slack CLI into a single self-contained script next
// to main.cjs. It runs as a child process (process.execPath with
// ELECTRON_RUN_AS_NODE=1), so it must exist as a real file on disk — it can't
// be inlined into main.cjs. Bundling here means the packaged app needs neither
// node_modules nor a global npm install.
const agentSlackPkg = JSON.parse(
await readFile(new URL('./node_modules/agent-slack/package.json', import.meta.url), 'utf8'),
);
await esbuild.build({
entryPoints: ['./node_modules/agent-slack/dist/index.js'],
bundle: true,
platform: 'node',
target: 'node22',
outfile: './.package/dist/agent-slack.cjs',
format: 'cjs',
banner: { js: cjsBanner },
define: {
'import.meta.url': '__import_meta_url',
// Without this constant the CLI's --version walks up the directory tree
// for a package.json and would find Rowboat's instead of agent-slack's.
'AGENT_SLACK_BUILD_VERSION': JSON.stringify(agentSlackPkg.version),
},
// The CLI probes bun:sqlite via dynamic import inside a try/catch and falls
// back to node:sqlite; keep it external so the probe fails at runtime the
// same way it does under plain node.
external: ['bun:sqlite'],
});
console.log(`✅ Main process bundled to .package/dist/main.cjs (+ agent-slack ${agentSlackPkg.version} CLI)`);

View file

@ -17,6 +17,7 @@
"@agentclientprotocol/codex-acp": "^0.0.44",
"@x/core": "workspace:*",
"@x/shared": "workspace:*",
"agent-slack": "0.9.3",
"chokidar": "^4.0.3",
"electron-squirrel-startup": "^1.0.1",
"html-to-docx": "^1.8.0",

View file

@ -16,11 +16,12 @@ 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 { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import z from 'zod';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
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';
@ -46,6 +47,10 @@ import type { CodeSession } from '@x/shared/dist/code-sessions.js';
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { runAgentSlack, getAgentSlackCliStatus, AgentSlackRunError } from '@x/core/dist/slack/agent-slack-exec.js';
import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js';
import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js';
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
import * as composioHandler from './composio-handler.js';
@ -85,6 +90,190 @@ import {
listTasks,
readRunIds as readTaskRunIds,
} from '@x/core/dist/background-tasks/fileops.js';
type SlackHomeChannel = {
id: string;
name: string;
workspaceUrl?: string;
workspaceName?: string;
};
type SlackHomeMessage = {
id: string;
workspaceName?: string;
workspaceUrl?: string;
channelId?: string;
channelName?: string;
author?: string;
text: string;
ts: string;
url?: string;
};
function parseWhoamiWorkspaces(data: unknown): Array<{ url: string; name: string }> {
const parsed = (data ?? {}) as { workspaces?: Array<{ workspace_url?: string; workspace_name?: string }> };
return (parsed.workspaces || []).map((w) => ({
url: w.workspace_url || '',
name: w.workspace_name || '',
}));
}
type SlackAuthResult = {
ok: boolean;
workspaces: Array<{ url: string; name: string }>;
error?: string;
errorKind?: 'not_installed' | 'timeout' | 'parse_error' | 'not_authed' | 'rate_limited' | 'network' | 'bad_channel' | 'unknown';
};
// Run `auth import-desktop`, then read back the workspaces via `auth whoami`.
// Shared by the plain and the quit-Slack-first import handlers.
async function importDesktopAndReadWorkspaces(): Promise<SlackAuthResult> {
const imported = await runAgentSlack(['auth', 'import-desktop'], { timeoutMs: 20000, parseJson: false });
if (!imported.ok) {
return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind };
}
const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
if (!whoami.ok) {
return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind };
}
const workspaces = parseWhoamiWorkspaces(whoami.data);
if (workspaces.length === 0) {
return { ok: false, workspaces: [], error: 'No signed-in Slack workspaces found in the desktop app.', errorKind: 'not_authed' };
}
return { ok: true, workspaces };
}
// Windows force-quits Slack so its exclusive Cookies-DB lock releases before
// desktop import (the EBUSY cause). No-op on mac/Linux, where import works with
// Slack open. taskkill exits non-zero when nothing matches — that's fine.
async function quitSlackIfWindows(): Promise<void> {
if (process.platform !== 'win32') return;
try {
await execFileAsync('taskkill', ['/F', '/IM', 'Slack.exe'], { timeout: 10000, windowsHide: true });
} catch {
// No running Slack process to kill — nothing to do.
}
// Give Windows a moment to release the file handles before we copy them.
await new Promise(resolve => setTimeout(resolve, 800));
}
function extractArrayPayload(parsed: unknown): unknown[] {
if (Array.isArray(parsed)) return parsed;
if (parsed && typeof parsed === 'object') {
const obj = parsed as Record<string, unknown>;
for (const key of ['messages', 'channels', 'items', 'results', 'data']) {
if (Array.isArray(obj[key])) return obj[key] as unknown[];
}
}
return [];
}
function slackMessageText(message: Record<string, unknown>): string {
const value = message.text ?? message.body ?? message.content;
return typeof value === 'string' ? value.trim() : '';
}
function slackMessageAuthor(message: Record<string, unknown>): string | undefined {
const value = message.username ?? message.user ?? message.author;
return typeof value === 'string' ? value : undefined;
}
function extractSlackUserName(raw: unknown): string | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Record<string, unknown>;
const profile = obj.profile && typeof obj.profile === 'object' ? obj.profile as Record<string, unknown> : undefined;
const user = obj.user && typeof obj.user === 'object' ? obj.user as Record<string, unknown> : undefined;
const userProfile = user?.profile && typeof user.profile === 'object' ? user.profile as Record<string, unknown> : undefined;
const candidates = [
profile?.display_name,
profile?.real_name,
userProfile?.display_name,
userProfile?.real_name,
obj.display_name,
obj.displayName,
obj.real_name,
obj.realName,
user?.display_name,
user?.displayName,
user?.real_name,
user?.realName,
obj.name,
user?.name,
];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim();
}
}
return null;
}
async function resolveSlackUserName(
userId: string,
workspaceUrl: string | undefined,
cache: Map<string, string>,
): Promise<string | null> {
const key = `${workspaceUrl ?? ''}:${userId}`;
if (cache.has(key)) return cache.get(key) ?? null;
const args = ['user', 'get', userId];
if (workspaceUrl) {
args.push('--workspace', workspaceUrl);
}
const result = await runAgentSlack(args, { timeoutMs: 10000, maxBuffer: 512 * 1024 });
if (result.ok) {
const name = extractSlackUserName(result.data ?? {});
if (name) {
cache.set(key, name);
return name;
}
} else {
console.warn(`[Slack] Failed to resolve user ${userId}: ${result.message}`);
}
cache.set(key, userId);
return null;
}
async function resolveSlackMessageText(
text: string,
workspaceUrl: string | undefined,
cache: Map<string, string>,
): Promise<string> {
const matches = Array.from(text.matchAll(/<@([UW][A-Z0-9]+)(?:\|([^>]+))?>|@([UW][A-Z0-9]{6,})\b/g));
if (matches.length === 0) return text;
let resolved = text;
for (const match of matches) {
const userId = match[1] ?? match[3];
if (!userId) continue;
const fallback = match[2] ?? match[0];
const name = await resolveSlackUserName(userId, workspaceUrl, cache);
resolved = resolved.replaceAll(match[0], name ?? fallback);
}
return resolved;
}
async function resolveSlackAuthor(
author: string | undefined,
workspaceUrl: string | undefined,
cache: Map<string, string>,
): Promise<string | undefined> {
if (!author) return undefined;
if (!/^[UW][A-Z0-9]{6,}$/.test(author)) return author;
return await resolveSlackUserName(author, workspaceUrl, cache) ?? author;
}
function slackMessageUrl(message: Record<string, unknown>, workspaceUrl: string | undefined, channelId: string | undefined, ts: string): string | undefined {
const direct = message.permalink ?? message.url;
if (typeof direct === 'string' && direct) return direct;
if (!workspaceUrl || !channelId) return undefined;
return `${workspaceUrl.replace(/\/$/, '')}/archives/${channelId}/p${ts.replace('.', '')}`;
}
import { browserIpcHandlers } from './browser/ipc.js';
/**
@ -854,19 +1043,183 @@ export function setupIpcHandlers() {
await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces });
return { success: true };
},
'slack:cliStatus': async () => {
return await getAgentSlackCliStatus();
},
'slack:knowledgeStatus': async () => {
return {
cli: await getAgentSlackCliStatus(),
sources: getSlackKnowledgeSyncStatus(),
};
},
'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 };
const result = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
if (!result.ok) {
return { workspaces: [], error: result.message, errorKind: result.kind };
}
const workspaces = parseWhoamiWorkspaces(result.data);
return { workspaces };
},
'slack:importDesktopAuth': async () => {
// Pull xoxc token(s) + cookie from the running/installed Slack desktop
// app into agent-slack's credential store, then read back the workspaces.
return await importDesktopAndReadWorkspaces();
},
'slack:quitAndImportDesktop': async () => {
// Windows-only convenience: kill Slack (which locks its Cookies DB) then
// run the normal desktop import in one click.
await quitSlackIfWindows();
return await importDesktopAndReadWorkspaces();
},
'slack:parseCurlAuth': async (_event, args) => {
// Cross-OS fallback to desktop import: the user pastes a "Copy as cURL"
// request from a signed-in Slack web tab; parse-curl reads it from stdin
// and extracts the xoxc token + xoxd cookie. No leveldb, no OS keychain.
const curl = (args.curl ?? '').trim();
if (!curl) {
return { ok: false, workspaces: [], error: 'Paste the copied cURL command first.', errorKind: 'unknown' as const };
}
const imported = await runAgentSlack(['auth', 'parse-curl'], { timeoutMs: 15000, parseJson: false, input: curl });
if (!imported.ok) {
return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind };
}
const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
if (!whoami.ok) {
return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind };
}
const workspaces = parseWhoamiWorkspaces(whoami.data);
if (workspaces.length === 0) {
return { ok: false, workspaces: [], error: 'Tokens were saved but no workspace was found. Double-check the copied request.', errorKind: 'not_authed' as const };
}
return { ok: true, workspaces };
},
'slack:listChannels': async (_event, args) => {
const result = await runAgentSlack(['channel', 'list', '--all', '--workspace', args.workspaceUrl, '--limit', '200'], { timeoutMs: 15000 });
if (!result.ok) {
return { channels: [], error: result.message };
}
const rawChannels = extractArrayPayload(result.data) as Array<{
id?: string;
name?: string;
is_private?: boolean;
isPrivate?: boolean;
is_member?: boolean;
isMember?: boolean;
}>;
const channels = rawChannels.map((ch) => ({
id: ch.id || ch.name || '',
name: ch.name || ch.id || '',
isPrivate: ch.is_private ?? ch.isPrivate,
isMember: ch.is_member ?? ch.isMember,
})).filter((ch) => ch.id && ch.name);
return { channels };
},
'slack:getRecentMessages': async (_event, args) => {
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
const config = await repo.getConfig();
if (!config.enabled || config.workspaces.length === 0) {
return { enabled: false, messages: [] };
}
const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
const messages: SlackHomeMessage[] = [];
const userNameCache = new Map<string, string>();
try {
const knowledgeConfig = knowledgeSourcesRepo.getConfig();
const slackSource = knowledgeConfig.sources.find(source => source.id === 'slack' && source.provider === 'slack' && source.enabled);
let channels: SlackHomeChannel[] = (slackSource?.scopes ?? [])
.filter(scope => scope.type === 'channel')
.map(scope => ({
id: scope.id,
name: scope.name ?? scope.id,
workspaceUrl: scope.workspaceUrl,
workspaceName: config.workspaces.find(workspace => workspace.url === scope.workspaceUrl)?.name,
}));
if (channels.length === 0) {
for (const workspace of config.workspaces) {
const channelList = await runAgentSlack(['channel', 'list', '--workspace', workspace.url, '--limit', '12'], { timeoutMs: 15000 });
if (!channelList.ok) {
throw new AgentSlackRunError(channelList.kind, channelList.message);
}
const rawChannels = extractArrayPayload(channelList.data);
for (const raw of rawChannels) {
if (!raw || typeof raw !== 'object') continue;
const channel = raw as Record<string, unknown>;
const id = typeof channel.id === 'string' ? channel.id : undefined;
const name = typeof channel.name === 'string' ? channel.name : id;
const isMember = channel.is_member ?? channel.isMember;
if (!id || !name || isMember === false) continue;
channels.push({ id, name, workspaceUrl: workspace.url, workspaceName: workspace.name });
}
}
}
channels = channels.slice(0, 8);
for (const channel of channels) {
const commandArgs = ['message', 'list', channel.id, '--limit', '5', '--max-body-chars', '500'];
if (channel.workspaceUrl) {
commandArgs.push('--workspace', channel.workspaceUrl);
}
const messageList = await runAgentSlack(commandArgs, { timeoutMs: 15000, maxBuffer: 1024 * 1024 });
if (!messageList.ok) {
console.warn(`[Slack] Failed to load messages for ${channel.name}: ${messageList.message}`);
continue;
}
const rawMessages = extractArrayPayload(messageList.data);
for (const raw of rawMessages) {
if (!raw || typeof raw !== 'object') continue;
const message = raw as Record<string, unknown>;
const ts = typeof message.ts === 'string' ? message.ts : undefined;
const text = slackMessageText(message);
if (!ts || !text) continue;
const channelId = typeof message.channel_id === 'string'
? message.channel_id
: typeof message.channel === 'string'
? message.channel
: channel.id;
const resolvedAuthor = await resolveSlackAuthor(slackMessageAuthor(message), channel.workspaceUrl, userNameCache);
const resolvedText = await resolveSlackMessageText(text, channel.workspaceUrl, userNameCache);
messages.push({
id: `${channel.workspaceUrl ?? 'workspace'}:${channelId}:${ts}`,
workspaceName: channel.workspaceName,
workspaceUrl: channel.workspaceUrl,
channelId,
channelName: channel.name,
author: resolvedAuthor,
text: resolvedText,
ts,
url: slackMessageUrl(message, channel.workspaceUrl, channelId, ts),
});
}
}
const rankedIds = await rankSlackHomeMessages(messages, limit);
const byId = new Map(messages.map(message => [message.id, message]));
const rankedMessages = rankedIds
.map(id => byId.get(id))
.filter((message): message is SlackHomeMessage => Boolean(message));
return { enabled: true, messages: rankedMessages };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load Slack messages';
const errorKind = err instanceof AgentSlackRunError ? err.kind : undefined;
return { enabled: true, messages: [], error: message, errorKind };
}
},
'knowledgeSources:getConfig': async () => {
return knowledgeSourcesRepo.getConfig();
},
'knowledgeSources:upsert': async (_event, args) => {
const config = knowledgeSourcesRepo.upsertSource(args);
if (args.provider === 'slack') {
triggerSlackKnowledgeSync();
void syncSlackKnowledgeSources().catch(error => {
console.error('[SlackKnowledge] Immediate sync after settings update failed:', error);
});
}
return config;
},
'onboarding:getStatus': async () => {
// Show onboarding if it hasn't been completed yet

View file

@ -37,10 +37,10 @@ import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js";
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
import started from "electron-squirrel-startup";
import { execSync, exec, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { execFileSync } from "node:child_process";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
@ -56,8 +56,6 @@ import {
} from "./deeplink.js";
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
const execAsync = promisify(exec);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -313,18 +311,13 @@ app.whenReady().then(async () => {
});
}
// Ensure agent-slack CLI is available
try {
execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 });
} catch {
try {
console.log('agent-slack not found, installing...');
await execAsync('npm install -g agent-slack', { timeout: 60000 });
console.log('agent-slack installed successfully');
} catch (e) {
console.error('Failed to install agent-slack:', e);
}
}
// The agent-slack CLI ships bundled with the app (.package/dist/agent-slack.cjs)
// and is resolved per call by the shared executor in @x/core. Availability is
// exposed to the UI via the slack:cliStatus IPC channel; this startup log is
// diagnostics only.
getAgentSlackCliStatus().then((status) => {
console.log('[Slack] agent-slack CLI status:', status);
}).catch(() => { /* probe failures already surface through slack:cliStatus */ });
// Initialize all config files before UI can access them
await initConfigs();

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowRight, Bot, Calendar, Clock, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react'
import { ArrowRight, Bot, Calendar, Clock, ExternalLink, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react'
import { extractConferenceLink } from '@/lib/calendar-event'
import { SettingsDialog } from '@/components/settings-dialog'
@ -54,6 +54,17 @@ type RawCalEvent = {
}
type EmailThread = { threadId: string; subject: string; from: string }
type SlackFeedMessage = {
id: string
workspaceName?: string
workspaceUrl?: string
channelId?: string
channelName?: string
author?: string
text: string
ts: string
url?: string
}
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
function greeting(): string {
@ -94,6 +105,28 @@ function relativeAgo(iso?: string): string {
return `${d}d ago`
}
function relativeSlackTs(ts: string): string {
const seconds = Number(ts.split('.')[0])
if (!Number.isFinite(seconds)) return ''
const iso = new Date(seconds * 1000).toISOString()
return relativeAgo(iso)
}
// Short, non-actionable copy for the home feed — the actionable fix lives in
// Settings, so every failure routes the user there.
function homeSlackErrorCopy(kind: string | null): string {
switch (kind) {
case 'not_authed':
return 'Slack needs reconnecting — open Settings → Connected accounts.'
case 'network':
return "Couldn't reach Slack. Check your connection."
case 'rate_limited':
return 'Slack is rate-limiting requests — will retry shortly.'
default:
return "Couldn't load Slack right now — see Settings."
}
}
function parseAllDay(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
if (!m) return null
@ -218,6 +251,10 @@ export function HomeView({
}: HomeViewProps) {
const [events, setEvents] = useState<CalEvent[]>([])
const [emails, setEmails] = useState<EmailThread[]>([])
const [slackEnabled, setSlackEnabled] = useState(false)
const [slackMessages, setSlackMessages] = useState<SlackFeedMessage[]>([])
const [slackError, setSlackError] = useState<string | null>(null)
const [slackErrorKind, setSlackErrorKind] = useState<string | null>(null)
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
@ -260,6 +297,22 @@ export function HomeView({
}
}, [])
const loadSlackMessages = useCallback(async () => {
try {
const result = await window.ipc.invoke('slack:getRecentMessages', { limit: 5 })
setSlackEnabled(result.enabled)
setSlackMessages(result.messages)
setSlackError(result.error ?? null)
setSlackErrorKind(result.errorKind ?? null)
} catch (err) {
console.error('Home: failed to load Slack messages', err)
setSlackEnabled(false)
setSlackMessages([])
setSlackError(null)
setSlackErrorKind(null)
}
}, [])
const loadConnectorLogos = useCallback(async () => {
if (cachedToolkitLogosLoaded) return
try {
@ -293,7 +346,7 @@ export function HomeView({
})
}, [])
useEffect(() => { void loadEvents(); void loadEmails(); void loadConnectorLogos() }, [loadEvents, loadEmails, loadConnectorLogos])
useEffect(() => { void loadEvents(); void loadEmails(); void loadSlackMessages(); void loadConnectorLogos() }, [loadEvents, loadEmails, loadSlackMessages, loadConnectorLogos])
// Upcoming (not-yet-ended) events, soonest first.
const upcoming = useMemo(() => {
@ -460,6 +513,53 @@ export function HomeView({
</div>
</div>
{/* Slack */}
{slackEnabled && (
<div className={CARD}>
<div className="mb-3 flex items-center gap-2">
<MessageSquare className="size-[15px]" />
<span className="text-sm font-medium">Slack</span>
<span className="flex-1" />
<span className="text-xs text-muted-foreground">Latest messages</span>
</div>
{slackError ? (
<div className="py-1 text-[12.5px] text-muted-foreground">{homeSlackErrorCopy(slackErrorKind)}</div>
) : slackMessages.length === 0 ? (
<div className="py-1 text-[12.5px] text-muted-foreground">No messages worth surfacing right now.</div>
) : slackMessages.map((message, i) => (
<div
key={message.id}
className={`flex items-start gap-3 py-2 text-[12.5px] ${i ? 'border-t border-border' : ''}`}
>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex min-w-0 items-center gap-1.5 text-[11.5px] text-muted-foreground">
<span className="truncate">{message.channelName ?? 'Slack'}</span>
{message.author && (
<>
<span className="shrink-0">·</span>
<span className="truncate">{message.author}</span>
</>
)}
<span className="shrink-0">·</span>
<span className="shrink-0">{relativeSlackTs(message.ts)}</span>
</div>
<div className="line-clamp-2 text-foreground">{message.text}</div>
</div>
{message.url && (
<button
type="button"
onClick={() => window.open(message.url, '_blank')}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-1 text-[11.5px] text-primary transition-colors hover:bg-accent"
>
Open
<ExternalLink className="size-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Today's schedule */}
<div className={CARD}>
<div className="mb-3.5 flex items-center gap-2">

View file

@ -1,19 +1,37 @@
"use client"
import * as React from "react"
import { Loader2, Mic, Mail, Calendar } from "lucide-react"
import { Loader2, Mic, Mail, Calendar, MessageSquare } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { useConnectors } from "@/hooks/useConnectors"
import { useConnectors, actionableSlackError } from "@/hooks/useConnectors"
interface ConnectedAccountsSettingsProps {
dialogOpen: boolean
}
function relativeTime(iso?: string): string {
if (!iso) return "never"
const then = Date.parse(iso)
if (!Number.isFinite(then)) return "never"
const diffSec = Math.round((Date.now() - then) / 1000)
if (diffSec < 60) return "just now"
const diffMin = Math.round(diffSec / 60)
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
return `${Math.round(diffHr / 24)}d ago`
}
export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSettingsProps) {
const c = useConnectors(dialogOpen)
// Windows exclusively locks Slack's Cookies DB while it runs, so we offer a
// "quit Slack first" one-click import there. mac/Linux import with Slack open.
const isWindows = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win')
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
const state = c.providerStates[provider] || {
@ -237,6 +255,224 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
{renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
</>
)}
{/* Team Communication Section */}
<>
<Separator className="my-2" />
<div className="px-3 pt-1 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Team Communication
</span>
</div>
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 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>
{c.slackLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : c.slackEnabled && c.slackWorkspaces.length > 0 ? (
<span className="text-xs text-emerald-600 truncate">
{c.slackWorkspaces.map(workspace => workspace.name).join(', ')}
</span>
) : (
<span className="text-xs text-muted-foreground truncate">Send messages and view channels</span>
)}
</div>
</div>
<div className="shrink-0">
{c.slackLoading || c.slackDiscovering ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : c.slackEnabled ? (
<Button
variant="outline"
size="sm"
onClick={c.handleSlackDisable}
className="h-7 px-3 text-xs"
>
Disable
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleSlackEnable}
className="h-7 px-3 text-xs"
>
Enable
</Button>
)}
</div>
</div>
{c.slackPickerOpen && (
<div className="mt-2 ml-10 space-y-2">
{c.slackNeedsAuth ? (
<>
<p className="text-xs text-muted-foreground">
{c.slackDiscoverError ?? 'Connect your signed-in Slack desktop app to continue.'}
</p>
<div className="flex flex-wrap items-center gap-2.5">
<Button
size="sm"
onClick={c.handleSlackImportDesktop}
disabled={c.slackAuthImporting}
className="h-7 px-3 text-xs"
>
{c.slackAuthImporting ? <Loader2 className="size-3 animate-spin" /> : "Connect Slack"}
</Button>
{isWindows && (
<Button
variant="outline"
size="sm"
onClick={c.handleSlackQuitAndImport}
disabled={c.slackAuthImporting}
className="h-7 px-3 text-xs"
title="Closes Slack so its data unlocks, then connects"
>
Quit Slack &amp; connect
</Button>
)}
<button
type="button"
onClick={() => c.setSlackCurlOpen(!c.slackCurlOpen)}
className="text-xs text-primary underline-offset-2 hover:underline"
>
Paste from browser instead
</button>
</div>
{c.slackCurlOpen && (
<div className="space-y-1.5">
<p className="text-[11px] leading-relaxed text-muted-foreground">
In a browser signed in to Slack, open DevTools Network, click any
request to <code>app.slack.com</code>, right-click Copy Copy as cURL,
then paste it below.
</p>
<Textarea
value={c.slackCurlValue}
onChange={(event) => c.setSlackCurlValue(event.target.value)}
placeholder="curl 'https://your-team.slack.com/api/...' -H 'Cookie: d=xoxd-...' ..."
className="min-h-20 text-[11px] font-mono"
disabled={c.slackCurlSubmitting}
/>
<Button
size="sm"
onClick={c.handleSlackParseCurl}
disabled={c.slackCurlSubmitting || c.slackCurlValue.trim().length === 0}
className="h-7 px-3 text-xs"
>
{c.slackCurlSubmitting ? <Loader2 className="size-3 animate-spin" /> : "Connect with cURL"}
</Button>
</div>
)}
</>
) : c.slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
) : (
<>
{c.slackAvailableWorkspaces.map(workspace => (
<label key={workspace.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={c.slackSelectedUrls.has(workspace.url)}
onChange={(event) => {
c.setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (event.target.checked) next.add(workspace.url)
else next.delete(workspace.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{workspace.name}</span>
</label>
))}
<Button
size="sm"
onClick={c.handleSlackSaveWorkspaces}
disabled={c.slackSelectedUrls.size === 0 || c.slackLoading}
className="h-7 px-3 text-xs"
>
Save
</Button>
</>
)}
</div>
)}
</div>
</>
{/* Knowledge Sources Section */}
{c.slackEnabled && (
<>
<Separator className="my-2" />
<div className="px-3 pt-1 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Knowledge Sources
</span>
</div>
<div className="rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 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 to knowledge</span>
<span className="text-xs text-muted-foreground truncate">
Sync selected channels into the knowledge graph
</span>
</div>
</div>
<Switch
checked={c.slackKnowledgeEnabled}
onCheckedChange={c.setSlackKnowledgeEnabled}
disabled={c.slackKnowledgeSaving}
/>
</div>
<div className="mt-2 space-y-2">
<Textarea
value={c.slackKnowledgeChannels}
onChange={(event) => c.setSlackKnowledgeChannels(event.target.value)}
placeholder={c.slackWorkspaces.length > 1 ? "https://team.slack.com #engineering" : "#engineering"}
className="min-h-20 text-xs"
disabled={!c.slackKnowledgeEnabled || c.slackKnowledgeSaving}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
One channel per line. Use channel names or IDs.
</span>
{(c.slackKnowledgeDirty || c.slackKnowledgeSaving) && (
<Button
size="sm"
onClick={c.handleSlackKnowledgeSave}
disabled={c.slackKnowledgeSaving || (c.slackKnowledgeEnabled && c.slackKnowledgeChannels.trim().length === 0)}
className="h-7 px-3 text-xs"
>
{c.slackKnowledgeSaving ? <Loader2 className="size-3 animate-spin" /> : "Save"}
</Button>
)}
</div>
{c.slackKnowledgeEnabled && c.slackSyncStatuses.filter(s => s.enabled).map(status => (
<div key={status.id} className="flex items-center gap-1.5 text-xs">
{status.lastStatus === 'error' ? (
<span className="text-amber-600 truncate">
Sync failing {actionableSlackError(status.lastError?.kind, status.lastError?.message)}
</span>
) : status.lastSyncAt ? (
<span className="text-muted-foreground">Last synced {relativeTime(status.lastSyncAt)}</span>
) : (
<span className="text-muted-foreground">Not synced yet first sync runs shortly</span>
)}
</div>
))}
</div>
</div>
</>
)}
</div>
</>
)

View file

@ -12,6 +12,58 @@ export interface ProviderStatus {
error?: string
}
type KnowledgeSourceConfig = {
id: string
provider: 'gmail' | 'meeting' | 'voice_memo' | 'slack' | 'github' | 'linear'
enabled: boolean
artifactDir: string
syncMode: 'file' | 'poll' | 'event' | 'manual'
intervalMs?: number
scopes: Array<{ type: string; id: string; name?: string; workspaceUrl?: string }>
instructions?: string
filters?: Record<string, unknown>
}
export type SlackSyncStatus = {
id: string
enabled: boolean
lastSyncAt?: string
lastStatus?: 'ok' | 'error'
lastError?: { kind: string; message: string }
nextDueAt?: string
}
/**
* Map a structured agent-slack failure to actionable user copy. The key
* distinction (raised by real usage): a missing Slack desktop app needs a
* different instruction than a signed-out one.
*/
export function actionableSlackError(kind?: string, message?: string): string {
// Windows locks Slack's Cookies/LevelDB files while it's running, so the
// desktop import copy fails with EBUSY. This can surface under any kind, so
// check the message first.
if (message && /EBUSY|resource busy|locked|copyfile/i.test(message)) {
return 'Slack is open and locking its data. Click "Quit Slack & connect" to close it automatically, or use "Paste from browser instead".'
}
switch (kind) {
case 'not_installed':
return 'The Slack helper is unavailable in this build. Please update or reinstall Rowboat.'
case 'network':
return "Couldn't reach Slack. Check your internet connection and try again."
case 'rate_limited':
return 'Slack is rate-limiting requests right now. Wait a minute and try again.'
case 'bad_channel':
return message || "A configured channel couldn't be found. Check the channel names in Settings."
case 'not_authed':
if (message && /Desktop data not found|not supported/i.test(message)) {
return 'No Slack desktop app was found. Install Slack, sign in to your workspace, then click Connect.'
}
return 'No signed-in Slack account found. Open the Slack desktop app, sign in, then click Connect.'
default:
return message || "Couldn't connect to Slack. Please try again."
}
}
export function useConnectors(active: boolean) {
const [providers, setProviders] = useState<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
@ -37,6 +89,23 @@ export function useConnectors(active: boolean) {
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
// True when discovery succeeded but no workspaces are connected yet, so the
// user needs to import auth from the Slack desktop app (fixes the silent
// "Enable" bounce-back where the button never progressed).
const [slackNeedsAuth, setSlackNeedsAuth] = useState(false)
const [slackAuthImporting, setSlackAuthImporting] = useState(false)
// Cross-OS "paste cURL from a browser tab" fallback when desktop import fails.
const [slackCurlOpen, setSlackCurlOpen] = useState(false)
const [slackCurlValue, setSlackCurlValue] = useState("")
const [slackCurlSubmitting, setSlackCurlSubmitting] = useState(false)
const [slackKnowledgeEnabled, setSlackKnowledgeEnabled] = useState(false)
const [slackKnowledgeChannels, setSlackKnowledgeChannels] = useState("")
const [slackKnowledgeSaving, setSlackKnowledgeSaving] = useState(false)
// Snapshot of the last-persisted knowledge config, used to detect unsaved
// edits so the Save button only appears when there's something to save.
const [slackKnowledgeSavedEnabled, setSlackKnowledgeSavedEnabled] = useState(false)
const [slackKnowledgeSavedChannels, setSlackKnowledgeSavedChannels] = useState("")
const [slackSyncStatuses, setSlackSyncStatuses] = useState<SlackSyncStatus[]>([])
// Composio Gmail/Calendar sync was removed. These flags are seeded false
// and never flipped — the IPC that used to set them is gone. The setters
@ -121,26 +190,105 @@ export function useConnectors(active: boolean) {
const handleSlackEnable = useCallback(async () => {
setSlackDiscovering(true)
setSlackDiscoverError(null)
setSlackNeedsAuth(false)
setSlackCurlOpen(false)
setSlackCurlValue("")
setSlackPickerOpen(true)
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 {
if (result.workspaces.length > 0) {
// Already-connected workspaces → straight to the picker.
setSlackAvailableWorkspaces(result.workspaces)
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
setSlackPickerOpen(true)
} else {
// CLI ran but nothing is connected yet (or it errored): offer a
// concrete next step instead of a dead-end message.
setSlackAvailableWorkspaces([])
setSlackNeedsAuth(true)
setSlackDiscoverError(result.error ? actionableSlackError(result.errorKind, result.error) : null)
}
} catch (error) {
console.error('Failed to discover Slack workspaces:', error)
setSlackDiscoverError('Failed to discover Slack workspaces')
setSlackPickerOpen(true)
setSlackNeedsAuth(true)
setSlackDiscoverError("Couldn't start Slack discovery. Please try again.")
} finally {
setSlackDiscovering(false)
}
}, [])
// Shared success path for both auth methods: show the discovered workspaces
// in the picker, preselected. Returns true when workspaces were found.
const applyDiscoveredWorkspaces = useCallback((result: { ok: boolean; workspaces: Array<{ url: string; name: string }>; error?: string; errorKind?: string }) => {
if (result.ok && result.workspaces.length > 0) {
setSlackAvailableWorkspaces(result.workspaces)
setSlackSelectedUrls(new Set(result.workspaces.map((w) => w.url)))
setSlackNeedsAuth(false)
setSlackCurlOpen(false)
setSlackCurlValue("")
return true
}
setSlackDiscoverError(actionableSlackError(result.errorKind, result.error))
return false
}, [])
// Import xoxc token + cookie from the signed-in Slack desktop app, then show
// the discovered workspaces in the picker.
const handleSlackImportDesktop = useCallback(async () => {
setSlackAuthImporting(true)
setSlackDiscoverError(null)
try {
const result = await window.ipc.invoke('slack:importDesktopAuth', null)
// Desktop import is best-effort: it fails when Slack is running and locks
// its Cookies DB (EBUSY on Windows), or on unsupported Slack builds. On
// any failure, reveal the browser-paste fallback so the user is never
// stuck — it has no file-lock dependency and works cross-OS.
if (!applyDiscoveredWorkspaces(result)) {
setSlackCurlOpen(true)
}
} catch (error) {
console.error('Failed to import Slack desktop auth:', error)
setSlackDiscoverError("Couldn't import from the Slack desktop app. Please try again, or paste from your browser below.")
setSlackCurlOpen(true)
} finally {
setSlackAuthImporting(false)
}
}, [applyDiscoveredWorkspaces])
// Windows-only: force-quit Slack (releases its Cookies-DB lock) then import.
// One click instead of the manual taskkill dance.
const handleSlackQuitAndImport = useCallback(async () => {
setSlackAuthImporting(true)
setSlackDiscoverError(null)
try {
const result = await window.ipc.invoke('slack:quitAndImportDesktop', null)
if (!applyDiscoveredWorkspaces(result)) {
setSlackCurlOpen(true)
}
} catch (error) {
console.error('Failed to quit Slack and import:', error)
setSlackDiscoverError("Couldn't import after closing Slack. Please try again, or paste from your browser below.")
setSlackCurlOpen(true)
} finally {
setSlackAuthImporting(false)
}
}, [applyDiscoveredWorkspaces])
// Fallback: parse a "Copy as cURL" request pasted from a signed-in Slack web
// tab. Works on every OS — no desktop app, leveldb, or keychain needed.
const handleSlackParseCurl = useCallback(async () => {
setSlackCurlSubmitting(true)
setSlackDiscoverError(null)
try {
const result = await window.ipc.invoke('slack:parseCurlAuth', { curl: slackCurlValue })
applyDiscoveredWorkspaces(result)
} catch (error) {
console.error('Failed to parse Slack cURL:', error)
setSlackDiscoverError("Couldn't read that cURL command. Please try again.")
} finally {
setSlackCurlSubmitting(false)
}
}, [applyDiscoveredWorkspaces, slackCurlValue])
const handleSlackSaveWorkspaces = useCallback(async () => {
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
try {
@ -149,6 +297,7 @@ export function useConnectors(active: boolean) {
setSlackEnabled(true)
setSlackWorkspaces(selected)
setSlackPickerOpen(false)
setSlackNeedsAuth(false)
toast.success('Slack enabled')
} catch (error) {
console.error('Failed to save Slack config:', error)
@ -165,6 +314,22 @@ export function useConnectors(active: boolean) {
setSlackEnabled(false)
setSlackWorkspaces([])
setSlackPickerOpen(false)
setSlackNeedsAuth(false)
setSlackCurlOpen(false)
setSlackCurlValue("")
await window.ipc.invoke('knowledgeSources:upsert', {
id: 'slack',
provider: 'slack',
enabled: false,
artifactDir: 'knowledge_sources/slack',
syncMode: 'poll',
intervalMs: 5 * 60 * 1000,
scopes: [],
})
setSlackKnowledgeEnabled(false)
setSlackKnowledgeChannels("")
setSlackKnowledgeSavedEnabled(false)
setSlackKnowledgeSavedChannels("")
toast.success('Slack disabled')
} catch (error) {
console.error('Failed to update Slack config:', error)
@ -174,6 +339,93 @@ export function useConnectors(active: boolean) {
}
}, [])
const refreshSlackKnowledgeStatus = useCallback(async () => {
try {
const result = await window.ipc.invoke('slack:knowledgeStatus', null)
setSlackSyncStatuses(result.sources)
} catch (error) {
console.error('Failed to load Slack knowledge status:', error)
setSlackSyncStatuses([])
}
}, [])
const refreshKnowledgeSources = useCallback(async () => {
try {
const result = await window.ipc.invoke('knowledgeSources:getConfig', null)
const slackSource = (result.sources as KnowledgeSourceConfig[]).find(source => source.id === 'slack')
const enabled = Boolean(slackSource?.enabled)
const channels = (slackSource?.scopes ?? [])
.filter(scope => scope.type === 'channel')
.map(scope => {
const channel = scope.name || scope.id
return scope.workspaceUrl ? `${scope.workspaceUrl} ${channel}` : channel
})
.join('\n')
setSlackKnowledgeEnabled(enabled)
setSlackKnowledgeChannels(channels)
setSlackKnowledgeSavedEnabled(enabled)
setSlackKnowledgeSavedChannels(channels)
} catch (error) {
console.error('Failed to load knowledge sources:', error)
setSlackKnowledgeEnabled(false)
setSlackKnowledgeChannels("")
setSlackKnowledgeSavedEnabled(false)
setSlackKnowledgeSavedChannels("")
}
}, [])
const parseSlackKnowledgeScopes = useCallback(() => {
const defaultWorkspaceUrl = slackWorkspaces.length === 1 ? slackWorkspaces[0]?.url : undefined
return slackKnowledgeChannels
.split(/\n+/)
.map(line => line.trim())
.filter(Boolean)
.map(line => {
const parts = line.split(/\s+/)
const first = parts[0] ?? ''
const hasWorkspace = /^https?:\/\//.test(first)
const workspaceUrl = hasWorkspace ? first : defaultWorkspaceUrl
const channelRaw = hasWorkspace ? parts.slice(1).join(' ') : line
const channel = channelRaw.trim()
return {
type: 'channel',
id: channel.replace(/^#/, ''),
name: channel.startsWith('#') ? channel : `#${channel}`,
workspaceUrl,
}
})
.filter(scope => scope.id.length > 0)
}, [slackKnowledgeChannels, slackWorkspaces])
const handleSlackKnowledgeSave = useCallback(async () => {
try {
setSlackKnowledgeSaving(true)
const scopes = parseSlackKnowledgeScopes()
await window.ipc.invoke('knowledgeSources:upsert', {
id: 'slack',
provider: 'slack',
enabled: slackKnowledgeEnabled && scopes.length > 0,
artifactDir: 'knowledge_sources/slack',
syncMode: 'poll',
intervalMs: 5 * 60 * 1000,
scopes,
instructions: 'Use Slack messages to update durable knowledge about projects, people, decisions, blockers, owners, deadlines, and status changes.',
filters: {
limit: 100,
maxBodyChars: 4000,
recentBackfillSeconds: 6 * 60 * 60,
},
})
toast.success('Slack knowledge source saved')
await refreshKnowledgeSources()
} catch (error) {
console.error('Failed to save Slack knowledge source:', error)
toast.error('Failed to save Slack knowledge source')
} finally {
setSlackKnowledgeSaving(false)
}
}, [parseSlackKnowledgeScopes, refreshKnowledgeSources, slackKnowledgeEnabled])
// Gmail (Composio)
const refreshGmailStatus = useCallback(async () => {
try {
@ -417,6 +669,8 @@ export function useConnectors(active: boolean) {
const refreshAllStatuses = useCallback(async () => {
refreshGranolaConfig()
refreshSlackConfig()
refreshKnowledgeSources()
refreshSlackKnowledgeStatus()
if (useComposioForGoogle) {
refreshGmailStatus()
@ -461,7 +715,7 @@ export function useConnectors(active: boolean) {
}
setProviderStates(newStates)
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
}, [providers, refreshGranolaConfig, refreshSlackConfig, refreshKnowledgeSources, refreshSlackKnowledgeStatus, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar])
// Refresh when active or providers change
useEffect(() => {
@ -545,6 +799,11 @@ export function useConnectors(active: boolean) {
(status) => Boolean(status?.error)
)
// Whether the knowledge config has unsaved edits — drives Save button visibility.
const slackKnowledgeDirty =
slackKnowledgeEnabled !== slackKnowledgeSavedEnabled ||
slackKnowledgeChannels !== slackKnowledgeSavedChannels
return {
// OAuth providers
providers,
@ -587,9 +846,27 @@ export function useConnectors(active: boolean) {
setSlackPickerOpen,
slackDiscovering,
slackDiscoverError,
slackNeedsAuth,
slackAuthImporting,
slackCurlOpen,
setSlackCurlOpen,
slackCurlValue,
setSlackCurlValue,
slackCurlSubmitting,
slackSyncStatuses,
slackKnowledgeEnabled,
setSlackKnowledgeEnabled,
slackKnowledgeChannels,
setSlackKnowledgeChannels,
slackKnowledgeSaving,
slackKnowledgeDirty,
handleSlackEnable,
handleSlackImportDesktop,
handleSlackQuitAndImport,
handleSlackParseCurl,
handleSlackSaveWorkspaces,
handleSlackDisable,
handleSlackKnowledgeSave,
// Gmail (Composio)
useComposioForGoogle,