From 79162ebc696eb16fa41eb642c1569ed5cedddc29 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:22:27 +0530 Subject: [PATCH 1/4] 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 --- apps/x/apps/main/bundle.mjs | 30 +- apps/x/apps/main/package.json | 1 + apps/x/apps/main/src/ipc.ts | 379 +++++++++++++- apps/x/apps/main/src/main.ts | 25 +- .../renderer/src/components/home-view.tsx | 104 +++- .../settings/connected-accounts-settings.tsx | 240 ++++++++- .../apps/renderer/src/hooks/useConnectors.ts | 295 ++++++++++- .../core/src/application/lib/builtin-tools.ts | 7 +- .../core/src/knowledge/build_graph.ts | 45 +- .../core/src/knowledge/note_creation.ts | 40 +- .../knowledge/sources/rank_slack_home.test.ts | 126 +++++ .../src/knowledge/sources/rank_slack_home.ts | 92 ++++ .../core/src/knowledge/sources/repo.ts | 113 +++++ .../src/knowledge/sources/sync_slack.test.ts | 182 +++++++ .../core/src/knowledge/sources/sync_slack.ts | 479 ++++++++++++++++++ .../core/src/knowledge/sources/types.ts | 49 ++ .../core/src/slack/agent-slack-exec.test.ts | 190 +++++++ .../core/src/slack/agent-slack-exec.ts | 315 ++++++++++++ apps/x/packages/shared/src/ipc.ts | 128 +++++ apps/x/packages/shared/src/service-events.ts | 1 + apps/x/pnpm-lock.yaml | 204 ++++++++ 21 files changed, 2979 insertions(+), 66 deletions(-) create mode 100644 apps/x/packages/core/src/knowledge/sources/rank_slack_home.test.ts create mode 100644 apps/x/packages/core/src/knowledge/sources/rank_slack_home.ts create mode 100644 apps/x/packages/core/src/knowledge/sources/repo.ts create mode 100644 apps/x/packages/core/src/knowledge/sources/sync_slack.test.ts create mode 100644 apps/x/packages/core/src/knowledge/sources/sync_slack.ts create mode 100644 apps/x/packages/core/src/knowledge/sources/types.ts create mode 100644 apps/x/packages/core/src/slack/agent-slack-exec.test.ts create mode 100644 apps/x/packages/core/src/slack/agent-slack-exec.ts diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 69bb2e1b..fa62d0db 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -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)`); diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 4b55ab2a..a7eca38e 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -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", diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 71e9a66e..88c0fdc1 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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 { + 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 { + 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; + 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 { + const value = message.text ?? message.body ?? message.content; + return typeof value === 'string' ? value.trim() : ''; +} + +function slackMessageAuthor(message: Record): 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; + const profile = obj.profile && typeof obj.profile === 'object' ? obj.profile as Record : undefined; + const user = obj.user && typeof obj.user === 'object' ? obj.user as Record : undefined; + const userProfile = user?.profile && typeof user.profile === 'object' ? user.profile as Record : 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, +): Promise { + 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, +): Promise { + 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, +): Promise { + 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, 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('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(); + + 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; + 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; + 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 diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 8c70f610..0c7bc9b9 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -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(); diff --git a/apps/x/apps/renderer/src/components/home-view.tsx b/apps/x/apps/renderer/src/components/home-view.tsx index bfcc0a33..1f1e6e6f 100644 --- a/apps/x/apps/renderer/src/components/home-view.tsx +++ b/apps/x/apps/renderer/src/components/home-view.tsx @@ -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([]) const [emails, setEmails] = useState([]) + const [slackEnabled, setSlackEnabled] = useState(false) + const [slackMessages, setSlackMessages] = useState([]) + const [slackError, setSlackError] = useState(null) + const [slackErrorKind, setSlackErrorKind] = useState(null) const [toolkitPreviews, setToolkitPreviews] = useState(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({ + {/* Slack */} + {slackEnabled && ( +
+
+ + Slack + + Latest messages +
+ {slackError ? ( +
{homeSlackErrorCopy(slackErrorKind)}
+ ) : slackMessages.length === 0 ? ( +
No messages worth surfacing right now.
+ ) : slackMessages.map((message, i) => ( +
+
+
+ {message.channelName ?? 'Slack'} + {message.author && ( + <> + · + {message.author} + + )} + · + {relativeSlackTs(message.ts)} +
+
{message.text}
+
+ {message.url && ( + + )} +
+ ))} +
+ )} + {/* Today's schedule */}
diff --git a/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx b/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx index e0c0b900..4b9eb802 100644 --- a/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx +++ b/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx @@ -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', , 'AI meeting transcripts')} )} + + {/* Team Communication Section */} + <> + +
+ + Team Communication + +
+
+
+
+
+ +
+
+ Slack + {c.slackLoading ? ( + Checking... + ) : c.slackEnabled && c.slackWorkspaces.length > 0 ? ( + + {c.slackWorkspaces.map(workspace => workspace.name).join(', ')} + + ) : ( + Send messages and view channels + )} +
+
+
+ {c.slackLoading || c.slackDiscovering ? ( + + ) : c.slackEnabled ? ( + + ) : ( + + )} +
+
+ {c.slackPickerOpen && ( +
+ {c.slackNeedsAuth ? ( + <> +

+ {c.slackDiscoverError ?? 'Connect your signed-in Slack desktop app to continue.'} +

+
+ + {isWindows && ( + + )} + +
+ {c.slackCurlOpen && ( +
+

+ In a browser signed in to Slack, open DevTools → Network, click any + request to app.slack.com, right-click → Copy → Copy as cURL, + then paste it below. +

+