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 23040c67..e063bb4d 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -16,16 +16,18 @@ 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'; import { listOnboardingModels } from '@x/core/dist/models/models-dev.js'; -import { testModelConnection, listModelsForProvider } from '@x/core/dist/models/models.js'; +import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js'; +import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js'; import { isSignedIn } from '@x/core/dist/account/account.js'; import { listGatewayModels } from '@x/core/dist/models/gateway.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; @@ -46,6 +48,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'; @@ -62,7 +68,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; -import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js'; import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; @@ -85,6 +91,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'; /** @@ -553,6 +743,9 @@ export function setupIpcHandlers() { 'gmail:getAccountEmail': async () => { return { email: await getAccountEmail() }; }, + 'gmail:getAccountName': async () => { + return { name: await getAccountName() }; + }, 'gmail:archiveThread': async (_event, args) => { return archiveThread(args.threadId); }, @@ -668,6 +861,15 @@ export function setupIpcHandlers() { return { success: false, error: message }; } }, + 'llm:getDefaultModel': async () => { + return await getDefaultModelAndProvider(); + }, + 'llm:generate': async (_event, args) => { + console.log(`[llm:generate] requested provider=${args.provider ?? '(default)'} model=${args.model ?? '(default)'}`); + const result = await generateOneShot(args); + console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`); + return result; + }, 'models:saveConfig': async (_event, args) => { const repo = container.resolve('modelConfigRepo'); await repo.setConfig(args); @@ -861,21 +1063,191 @@ export function setupIpcHandlers() { 'slack:setConfig': async (_event, args) => { const repo = container.resolve('slackConfigRepo'); await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces }); + // Connecting/disconnecting Slack changes the Copilot's routing (native + // `slack` skill vs. Composio), so rebuild its cached instructions. + invalidateCopilotInstructionsCache(); 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') { + // The Copilot prompt lists the selected Slack channels, so refresh it + // whenever the channel selection changes. + invalidateCopilotInstructionsCache(); + 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/App.css b/apps/x/apps/renderer/src/App.css index 02cfd7bd..09cf87bf 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -160,6 +160,13 @@ border-bottom: 1px solid var(--gm-border); } +.gmail-topbar-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + .gmail-search { display: flex; align-items: center; @@ -707,6 +714,112 @@ border-color: var(--gm-border-strong); } +/* Standalone "new email" composer — centered modal popup */ +.gmail-compose-overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: rgba(0, 0, 0, 0.32); +} + +.gmail-compose-modal { + display: flex; + flex-direction: column; + width: min(840px, 100%); + height: min(720px, calc(100vh - 64px)); + max-height: calc(100vh - 64px); + border: 1px solid var(--gm-border-strong); + border-radius: 10px; + overflow: hidden; + background: var(--gm-bg-card); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35); +} + +.gmail-compose-modal-header { + display: flex; + align-items: center; + gap: 10px; + height: 40px; + padding: 0 8px 0 14px; + background: var(--gm-bg-input); + color: var(--gm-text-body); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.01em; + text-transform: uppercase; +} + +.gmail-compose-modal-header > span { + flex: 1; +} + +.gmail-compose-modal .gmail-compose-editor { + flex: 1; + min-height: 160px; + max-height: none; + padding: 0 14px; +} + +.gmail-compose-ai-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--gm-border); +} + +.gmail-compose-ai-input { + flex: 1; + min-width: 0; + height: 30px; + padding: 0 10px; + border: 1px solid var(--gm-border-strong); + border-radius: 6px; + outline: none; + background: var(--gm-bg-input); + color: var(--gm-text); + font: inherit; + font-size: 12px; +} + +.gmail-compose-ai-presets { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 12px 10px; + border-bottom: 1px solid var(--gm-border); +} + +.gmail-compose-ai-presets button { + height: 24px; + padding: 0 10px; + border: 1px solid var(--gm-border-strong); + border-radius: 999px; + background: var(--gm-bg-pill); + color: var(--gm-text-muted); + font: inherit; + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.gmail-compose-ai-presets button:hover:not(:disabled) { + background: var(--gm-bg-pill-hover); + border-color: var(--gm-accent); + color: var(--gm-accent); +} + +.gmail-compose-ai-presets button:disabled, +.gmail-compose-ai-input:disabled { + opacity: 0.5; + cursor: default; +} + .gmail-compose-card { max-width: 720px; margin-left: 40px; @@ -987,7 +1100,10 @@ gap: 2px; flex: 1; min-width: 0; - justify-content: center; + justify-content: flex-start; + padding-left: 10px; + margin-left: 2px; + border-left: 1px solid var(--gm-border-strong); } .gmail-compose-link-popover { @@ -1059,11 +1175,16 @@ transition: background 120ms ease, color 120ms ease; } -.gmail-compose-tool:hover { +.gmail-compose-tool:hover:not(:disabled) { background: var(--gm-bg-pill-hover); color: var(--gm-text); } +.gmail-compose-tool:disabled { + opacity: 0.4; + cursor: default; +} + .gmail-compose-tool.is-active { background: var(--gm-bg-pill-hover); color: var(--gm-accent); @@ -1154,6 +1275,52 @@ pointer-events: none; } +.gmail-compose-attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 12px 0; +} + +.gmail-compose-attachment { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 240px; + padding: 4px 8px; + border: 1px solid var(--gm-border); + border-radius: 6px; + background: var(--gm-bg-pill); + font-size: 12px; + color: var(--gm-text); +} + +.gmail-compose-attachment-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gmail-compose-attachment-size { + color: var(--gm-text-muted); + flex-shrink: 0; +} + +.gmail-compose-attachment-remove { + border: none; + background: transparent; + color: var(--gm-text-muted); + cursor: pointer; + font-size: 15px; + line-height: 1; + padding: 0 0 0 2px; + flex-shrink: 0; +} + +.gmail-compose-attachment-remove:hover { + color: var(--gm-text); +} + .gmail-compose-actions { display: flex; align-items: center; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 7d5c5f1d..b8b2e537 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, Strikethrough, Trash2 } from 'lucide-react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Strikethrough, Trash2, Undo2 } from 'lucide-react' import { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' @@ -258,6 +258,15 @@ function escapeHtml(text: string): string { .replace(/'/g, ''') } +// Convert AI-generated plain text into the simple paragraph HTML the Tiptap +// editor expects (blank lines → paragraphs, single newlines →
). +function plainTextToHtml(text: string): string { + return text + .split(/\n{2,}/) + .map((para) => `

${escapeHtml(para.trim()).replace(/\n/g, '
')}

`) + .join('') +} + function splitPlainTextQuote(text: string): { visible: string; quoted: string | null } { const re = /(?:^|\n)On\s+.+?\swrote:\s*(?:\n|$)/ const match = re.exec(text) @@ -514,7 +523,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable void }) { return (
+ + + editor.chain().focus().toggleBold().run()} @@ -866,20 +898,76 @@ function RecipientField({ ) } -function ComposeBox({ +const AI_GENERATE_SYSTEM = + 'You write complete emails. Given an instruction, produce a subject line and a body. ' + + 'Respond in EXACTLY this format and nothing else:\n' + + 'Subject: \n' + + '\n' + + '\n' + + 'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' + + 'When recipient names are provided, address them naturally (e.g. "Hi ,"). ' + + 'When the sender\'s name is provided, sign off with it; otherwise omit the sign-off name ' + + '(never write a placeholder like "[Your Name]").' + +const AI_REWRITE_SYSTEM = + 'You rewrite emails. Given the current subject and body plus an edit instruction, ' + + 'produce the revised subject line and body. Keep the subject if it still fits, or ' + + 'refine it so it matches the rewritten body. Respond in EXACTLY this format and nothing else:\n' + + 'Subject: \n' + + '\n' + + '\n' + + 'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' + + 'Preserve the existing sign-off; do not invent placeholder names like "[Your Name]".' + +// Split AI output of the form "Subject: …\n\n" into its parts. If no +// subject line is present, the whole text is treated as the body. +function parseGeneratedEmail(text: string): { subject: string | null; body: string } { + const match = text.match(/^\s*Subject:\s*(.+?)(?:\r?\n|$)/i) + if (match) { + const subject = match[1].trim() + const body = text.slice(match.index! + match[0].length).replace(/^\s+/, '') + return { subject, body } + } + return { subject: null, body: text } +} + +// Guarantee the sender's name signs off the email. If the model already ended +// with the name (e.g. "Best,\nHarsh"), leave it; otherwise append it. +function ensureSignature(body: string, name: string): string { + const signer = name.trim() + if (!signer) return body + const trimmed = body.replace(/\s+$/, '') + // Check the last couple of lines so we don't double up an existing sign-off. + const tail = trimmed.split('\n').slice(-2).join('\n').toLowerCase() + if (tail.includes(signer.toLowerCase())) return trimmed + return `${trimmed}\n\n${signer}` +} + +const TONE_PRESETS: Array<{ key: string; label: string; instruction: string }> = [ + { key: 'formal', label: 'Formal', instruction: 'Rewrite this email to be more formal and professional.' }, + { key: 'casual', label: 'Casual', instruction: 'Rewrite this email to be more casual and friendly.' }, + { key: 'shorter', label: 'Shorter', instruction: 'Rewrite this email to be more concise, keeping the key points.' }, + { key: 'longer', label: 'Longer', instruction: 'Rewrite this email to be more detailed and thorough.' }, +] + +// Composer for replies, forwards, and (mode 'new') from-scratch emails. With a +// thread it renders as an inline card under the thread; in 'new' mode it has no +// thread and renders as a centered modal with the AI writing bar. +const ComposeBox = memo(function ComposeBox({ mode, thread, - selfEmail, + selfEmail = '', onClose, }: { mode: ComposeMode - thread: GmailThread - selfEmail: string + thread?: GmailThread + selfEmail?: string onClose: () => void }) { - const latest = latestMessage(thread) + const isNew = mode === 'new' + const latest = thread ? latestMessage(thread) : undefined const initialRecipients = useMemo( - () => buildRecipients(mode, thread, selfEmail), + () => (thread ? buildRecipients(mode, thread, selfEmail) : { to: [], cc: [] }), [mode, thread, selfEmail], ) @@ -888,10 +976,11 @@ function ComposeBox({ const [bccList, setBccList] = useState([]) const [showCc, setShowCc] = useState(initialRecipients.cc.length > 0) const [showBcc, setShowBcc] = useState(false) - const [subject, setSubject] = useState(() => composeSubject(mode, thread.subject)) - const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply' + const [subject, setSubject] = useState(() => (thread ? composeSubject(mode, thread.subject) : '')) + const modeLabel = isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply' const initialContent = useMemo(() => { + if (!thread) return '' if (mode === 'forward') return buildForwardedContent(thread) // Gmail-side draft (user's own work) wins over the AI-generated draft. const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '') @@ -907,7 +996,7 @@ function ComposeBox({ StarterKit.configure({ link: false }), Link.configure({ openOnClick: false, autolink: true }), Placeholder.configure({ - placeholder: mode === 'forward' ? 'Write a message…' : 'Write your reply…', + placeholder: isNew || mode === 'forward' ? 'Write a message…' : 'Write your reply…', }), ], editorProps: { @@ -959,13 +1048,176 @@ function ComposeBox({ if (editor && sel) editor.chain().focus().setTextSelection(sel).run() } + // The signed-in account's display name, used to sign off AI-generated emails. + const [selfName, setSelfName] = useState('') + useEffect(() => { + if (!isNew) return + let cancelled = false + window.ipc.invoke('gmail:getAccountName', {}) + .then((res) => { if (!cancelled && res?.name) setSelfName(res.name) }) + .catch(() => {}) + return () => { cancelled = true } + }, [isNew]) + + const [aiPrompt, setAiPrompt] = useState('') + const [generating, setGenerating] = useState(false) + // Once a draft has been generated, show a follow-up bar for iterative edits + // ("add a line about…", "remove the last paragraph", etc.). It hides again if + // the draft is emptied (e.g. undone), tracked via hasContent below. + const [hasGenerated, setHasGenerated] = useState(false) + const [hasContent, setHasContent] = useState(false) + + // Keep hasContent in sync with the editor across typing, undo/redo, and clears. + useEffect(() => { + if (!editor) return + const sync = () => setHasContent(!editor.isEmpty) + sync() + editor.on('update', sync) + return () => { editor.off('update', sync) } + }, [editor]) + + // Clearing the body reverts the AI control to its "Write" state and drops the + // generated subject, so an emptied composer behaves like a fresh one. The + // hasGenerated guard avoids wiping a subject typed before any generation. + useEffect(() => { + if (hasGenerated && !hasContent) { + setHasGenerated(false) + setSubject('') + } + }, [hasGenerated, hasContent]) + + const runAi = async (instruction: string, aiMode: 'generate' | 'rewrite') => { + if (!editor || generating) return + const current = editor.getText().trim() + let prompt: string + let system: string + if (aiMode === 'generate') { + if (!instruction.trim()) { toast('Describe what to write.', 'error'); return } + system = AI_GENERATE_SYSTEM + const ctx: string[] = [] + // Use the recipients' names (from the contacts picker) so the AI can + // address them naturally; fall back to the address when there's no name. + const recipientNames = toList + .map((token) => { + const name = extractName(token) + return name && name !== 'Unknown' ? name : extractAddress(token) + }) + .filter(Boolean) + if (recipientNames.length) ctx.push(`Recipient(s): ${recipientNames.join(', ')}`) + if (selfName) ctx.push(`Sender's name (sign off as this): ${selfName}`) + if (subject.trim()) ctx.push(`Desired subject hint: ${subject.trim()}`) + if (current) ctx.push(`Existing draft (revise or build on it):\n${current}`) + prompt = `${ctx.length ? ctx.join('\n') + '\n\n' : ''}Instruction: ${instruction.trim()}` + } else { + if (!instruction.trim()) { toast('Describe the edit to make.', 'error'); return } + if (!current) { toast('Write something first.', 'error'); return } + system = AI_REWRITE_SYSTEM + const subjectLine = subject.trim() ? `Subject: ${subject.trim()}\n\n` : '' + prompt = `Instruction: ${instruction}\n\n---\n${subjectLine}${current}` + } + + setGenerating(true) + try { + // Draft through Copilot: no model override, so the backend resolves the + // same default model/provider the Copilot chat uses (models.json). + const res = await window.ipc.invoke('llm:generate', { prompt, system }) + if (res.error || !res.text) { + toast(res.error || 'No text was generated.', 'error') + return + } + // Replace via a tracked transaction (selectAll + insertContent) so the AI + // draft lands in the editor's undo history and the toolbar's Undo reverts it. + if (aiMode === 'generate') { + const { subject: generatedSubject, body } = parseGeneratedEmail(res.text) + if (generatedSubject) setSubject(generatedSubject) + // Always sign off with the account name, even if the model omitted it. + const signed = ensureSignature(body, selfName) + editor.chain().focus().selectAll().insertContent(plainTextToHtml(signed)).run() + setHasGenerated(true) + } else { + // Rewrites also regenerate the subject so it stays in sync with the body. + const { subject: rewrittenSubject, body } = parseGeneratedEmail(res.text) + if (rewrittenSubject) setSubject(rewrittenSubject) + editor.chain().focus().selectAll().insertContent(plainTextToHtml(body)).run() + } + } catch (err) { + toast(`Generation failed: ${err instanceof Error ? err.message : String(err)}`, 'error') + } finally { + setGenerating(false) + } + } + + // The single Write/Edit bar: generate a fresh draft until one exists, then + // switch to rewriting it. Clears the prompt after a run kicks off. + const runAiBar = async () => { + await runAi(aiPrompt, hasGenerated ? 'rewrite' : 'generate') + setAiPrompt('') + } + + // Attachments staged for this message. contentBase64 is the raw file bytes, + // read in the renderer; the main process wraps them into the MIME on send. + const [attachments, setAttachments] = useState< + Array<{ id: string; filename: string; mimeType: string; size: number; contentBase64: string }> + >([]) + const fileInputRef = useRef(null) + + // Gmail rejects messages over ~25MB; base64 inflates bytes by ~33%. + const MAX_TOTAL_BYTES = 25 * 1024 * 1024 + + // Read a file's bytes as raw base64 (the part after the data: URL prefix). + const readAsBase64 = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error ?? new Error('read failed')) + reader.onload = () => { + const result = String(reader.result) + const comma = result.indexOf(',') + resolve(comma >= 0 ? result.slice(comma + 1) : result) + } + reader.readAsDataURL(file) + }) + + const addFiles = async (files: FileList | null) => { + if (!files || files.length === 0) return + const staged: typeof attachments = [] + for (const file of Array.from(files)) { + try { + staged.push({ + id: `${file.name}-${file.size}-${file.lastModified}`, + filename: file.name, + mimeType: file.type || 'application/octet-stream', + size: file.size, + contentBase64: await readAsBase64(file), + }) + } catch { + toast(`Could not read ${file.name}.`, 'error') + } + } + setAttachments((prev) => { + const merged = [...prev] + for (const item of staged) { + if (!merged.some((a) => a.id === item.id)) merged.push(item) + } + const total = merged.reduce((sum, a) => sum + a.size, 0) + if (total > MAX_TOTAL_BYTES) { + toast('Attachments exceed the 25MB limit.', 'error') + return prev + } + return merged + }) + } + + const removeAttachment = (id: string) => { + setAttachments((prev) => prev.filter((a) => a.id !== id)) + } + const [sending, setSending] = useState(false) const sendInGmail = async () => { if (!editor || sending) return const html = editor.getHTML() const text = editor.getText().trim() if (!text) { - toast('Draft is empty.', 'error') + toast(isNew ? 'Message is empty.' : 'Draft is empty.', 'error') return } @@ -975,25 +1227,29 @@ function ComposeBox({ } // Build References chain from all known message ids (newest last). - const messageIds = thread.messages + const messageIds = (thread?.messages ?? []) .map((m) => m.messageIdHeader) .filter((v): v is string => Boolean(v)) const references = messageIds.join(' ') const inReplyTo = latest?.messageIdHeader - const isForward = mode === 'forward' + // Only replies stay on the thread; forwards and new emails start fresh. + const isThreaded = Boolean(thread) && mode !== 'forward' && !isNew setSending(true) try { const result = await window.ipc.invoke('gmail:sendReply', { - threadId: isForward ? undefined : thread.threadId, + threadId: isThreaded ? thread?.threadId : undefined, to: toList.join(', '), cc: ccList.length ? ccList.join(', ') : undefined, bcc: bccList.length ? bccList.join(', ') : undefined, - subject: subject.trim() || composeSubject(mode, thread.subject), + subject: subject.trim() || (thread ? composeSubject(mode, thread.subject) : '(No subject)'), bodyHtml: html, bodyText: text, - inReplyTo: isForward ? undefined : inReplyTo, - references: isForward ? undefined : references || undefined, + inReplyTo: isThreaded ? inReplyTo : undefined, + references: isThreaded ? references || undefined : undefined, + attachments: attachments.length + ? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 })) + : undefined, }) if (result.error) { toast(`Send failed: ${result.error}`, 'error') @@ -1009,7 +1265,7 @@ function ComposeBox({ } const refineWithCopilot = () => { - if (!editor) return + if (!editor || !thread) return const currentDraft = editor.getText().trim() const threadSubject = thread.subject || '(No subject)' @@ -1039,17 +1295,25 @@ function ComposeBox({ window.dispatchEvent(new Event('email-block:draft-with-assistant')) } - return ( -
-
+ const card = ( +
event.stopPropagation() : undefined} + > +
{modeLabel} - +
{!showCc && } @@ -1059,18 +1323,83 @@ function ComposeBox({ /> {showCc && } {showBcc && } - {mode === 'forward' && ( + {isNew && ( + <> +
+ setAiPrompt(event.target.value)} + placeholder={hasGenerated + ? 'Edit the draft (e.g. add a line about…, remove the last paragraph)…' + : 'Describe the email and let AI write it…'} + disabled={generating} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + void runAiBar() + } + }} + /> + +
+
+ + {TONE_PRESETS.map((preset) => ( + + ))} +
+ + )} + {(isNew || mode === 'forward') && (
Subject setSubject(event.target.value)} - placeholder="Subject" />
)} + { + void addFiles(event.target.value ? event.currentTarget.files : null) + event.currentTarget.value = '' + }} + /> + {attachments.length > 0 && ( +
+ {attachments.map((att) => ( +
+ + {att.filename} + {formatAttachmentSize(att.size)} + +
+ ))} +
+ )} {linkOpen && (
event.preventDefault()}> { void sendInGmail() }} disabled={sending} - title="Send this reply via Gmail" + title={isNew ? 'Send this email via Gmail' : 'Send this reply via Gmail'} > {sending ? : } {sending ? 'Sending…' : 'Send'} @@ -1107,19 +1436,40 @@ function ComposeBox({ + {thread && ( + + )}
{editor && }
) -} + + if (isNew) { + return ( +
+ {card} +
+ ) + } + return card +}) function ThreadDetail({ thread, @@ -1301,6 +1651,9 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current) const [error, setError] = useState(null) const [query, setQuery] = useState('') + const [composeOpen, setComposeOpen] = useState(false) + // Stable so the open composer isn't re-rendered on every inbox sync tick. + const closeCompose = useCallback(() => setComposeOpen(false), []) // Gmail sync uses the native Google OAuth connection. const [emailConnection, setEmailConnection] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) @@ -1526,12 +1879,18 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = // when files change. Throttled to at most one reload per ~3s so a burst of // backend writes (sync processing many threads sequentially) coalesces into // a small number of in-place updates rather than a flicker storm. - // Suppressed while a thread is open (composing/reading); deferred until close. + // Suppressed while a thread is open (reading/replying) or the compose-new + // modal is open; deferred until whichever is open closes. A reload replaces + // the threads array and re-renders the whole inbox list (and any mounted + // ThreadDetail iframes) on the main thread — that re-render janks an open + // composer even though ComposeBox itself is memoized, so we pause it. const pendingReloadRef = useRef(false) const reloadDebounceRef = useRef | null>(null) const lastReloadAtRef = useRef(0) const isSelectedRef = useRef(null) isSelectedRef.current = selectedThreadId + const composeOpenRef = useRef(false) + composeOpenRef.current = composeOpen const isRefreshingRef = useRef(false) isRefreshingRef.current = refreshing const otherHasThreadsRef = useRef(false) @@ -1541,7 +1900,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const doReload = useCallback(() => { if (isRefreshingRef.current) return - if (isSelectedRef.current !== null) { + if (isSelectedRef.current !== null || composeOpenRef.current) { pendingReloadRef.current = true return } @@ -1596,9 +1955,10 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = } }, [triggerLiveReload]) - // When user closes a thread, if updates arrived while they were reading, flush now. + // When the user closes the open thread or the compose-new modal, if updates + // arrived while it was open, flush them now. useEffect(() => { - if (selectedThreadId !== null) return + if (selectedThreadId !== null || composeOpen) return if (!pendingReloadRef.current) return pendingReloadRef.current = false lastReloadAtRef.current = Date.now() @@ -1606,7 +1966,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = if (otherHasThreadsRef.current) { void reloadFirstPage('other', { silent: true }) } - }, [selectedThreadId, reloadFirstPage]) + }, [selectedThreadId, composeOpen, reloadFirstPage]) // Manual refresh: wake the background sync loop. It updates inbox_lists/, // the watcher fires, and triggerLiveReload picks up the changes. The @@ -1745,9 +2105,14 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = placeholder="Search loaded mail" />
- +
+ + +
{error && !hasAny ? ( @@ -1814,6 +2179,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = )} + {composeOpen && } ) 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. +

+