From 4db42d17cf93f270b5f883c2860d03ba94553abe Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 22 May 2026 17:25:45 +0530 Subject: [PATCH 01/16] fix gmail sync --- .../packages/core/src/knowledge/sync_gmail.ts | 119 ++++++++++++++++-- 1 file changed, 109 insertions(+), 10 deletions(-) diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 6402d097..8057df86 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -28,6 +28,7 @@ const CACHE_DIR = path.join(WorkDir, 'inbox_lists'); const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; const MAX_THREADS_IN_DIGEST = 10; +const RECENT_BACKFILL_INTERVAL_MS = 15 * 60 * 1000; const nhm = new NodeHtmlMarkdown(); interface SnapshotCacheEntry { @@ -713,7 +714,9 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri } catch (error) { console.error(`Error processing thread ${threadId}:`, error); - return null; + const status = getErrorStatus(error); + if (status === 404) return null; + throw error; } } @@ -757,20 +760,102 @@ async function pruneInboxCache(auth: OAuth2Client): Promise { } } -function loadState(stateFile: string): { historyId?: string; last_sync?: string } { +function loadState(stateFile: string): { historyId?: string; last_sync?: string; last_recent_backfill?: string } { if (fs.existsSync(stateFile)) { return JSON.parse(fs.readFileSync(stateFile, 'utf-8')); } return {}; } -function saveState(historyId: string, stateFile: string) { +function saveState(historyId: string, stateFile: string, extra: { last_recent_backfill?: string } = {}) { + const previous = loadState(stateFile); fs.writeFileSync(stateFile, JSON.stringify({ historyId, - last_sync: new Date().toISOString() + last_sync: new Date().toISOString(), + last_recent_backfill: extra.last_recent_backfill ?? previous.last_recent_backfill, + ...extra, }, null, 2)); } +function getErrorStatus(error: unknown): number | undefined { + const status = (error as { response?: { status?: number } }).response?.status; + if (status) return status; + const code = Number((error as { code?: number | string }).code); + return Number.isFinite(code) ? code : undefined; +} + +function recentDateQuery(lookbackDays: number): string { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - lookbackDays); + return pastDate.toISOString().split('T')[0].replace(/-/g, '/'); +} + +async function listRecentNonDeletedThreadIds(gmailClient: gmail.Gmail, lookbackDays: number): Promise { + const dateQuery = recentDateQuery(lookbackDays); + const results: RecentThreadInfo[] = []; + const seen = new Set(); + let pageToken: string | undefined; + + do { + const res = await gmailClient.users.threads.list({ + userId: 'me', + q: `after:${dateQuery} -in:spam -in:trash`, + maxResults: 500, + pageToken, + }); + for (const thread of res.data.threads || []) { + if (!thread.id || seen.has(thread.id)) continue; + seen.add(thread.id); + results.push({ + threadId: thread.id, + historyId: thread.historyId || '', + snippet: thread.snippet || undefined, + }); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); + + return results; +} + +function shouldRunRecentBackfill(stateFile: string): boolean { + const state = loadState(stateFile); + if (!state.last_recent_backfill) return true; + const lastRunMs = new Date(state.last_recent_backfill).getTime(); + if (!Number.isFinite(lastRunMs)) return true; + return Date.now() - lastRunMs >= RECENT_BACKFILL_INTERVAL_MS; +} + +async function backfillMissingRecentThreads( + auth: OAuth2Client, + syncDir: string, + attachmentsDir: string, + stateFile: string, + lookbackDays: number, +): Promise { + if (!shouldRunRecentBackfill(stateFile)) return []; + + const gmailClient = google.gmail({ version: 'v1', auth }); + const recentThreads = await listRecentNonDeletedThreadIds(gmailClient, lookbackDays); + const missingThreadIds = recentThreads + .map((thread) => thread.threadId) + .filter((threadId) => !fs.existsSync(path.join(syncDir, `${threadId}.md`))); + + const synced: SyncedThread[] = []; + for (const threadId of missingThreadIds) { + const result = await processThread(auth, threadId, syncDir, attachmentsDir); + if (result) synced.push(result); + } + + const profile = await gmailClient.users.getProfile({ userId: 'me' }); + saveState(profile.data.historyId!, stateFile, { last_recent_backfill: new Date().toISOString() }); + + if (missingThreadIds.length > 0) { + console.log(`Recent Gmail backfill synced ${synced.length}/${missingThreadIds.length} missing thread(s).`); + } + return synced; +} + async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) { const gmail = google.gmail({ version: 'v1', auth }); @@ -814,6 +899,7 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str const res = await gmail.users.threads.list({ userId: 'me', q: `after:${dateQuery} -in:spam -in:trash`, + maxResults: 500, pageToken }); @@ -907,15 +993,24 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: }; try { - const res = await gmail.users.history.list({ - userId: 'me', - startHistoryId, - historyTypes: ['messageAdded'] - }); + const changes: gmail.Schema$History[] = []; + let pageToken: string | undefined; + do { + const res = await gmail.users.history.list({ + userId: 'me', + startHistoryId, + historyTypes: ['messageAdded'], + maxResults: 500, + pageToken, + }); + if (res.data.history) changes.push(...res.data.history); + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); - const changes = res.data.history; if (!changes || changes.length === 0) { console.log("No new changes."); + const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays); + await publishGmailSyncEvent(backfilled); const profile = await gmail.users.getProfile({ userId: 'me' }); saveState(profile.data.historyId!, stateFile); return; @@ -937,6 +1032,8 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: } if (threadIds.size === 0) { + const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays); + await publishGmailSyncEvent(backfilled); const profile = await gmail.users.getProfile({ userId: 'me' }); saveState(profile.data.historyId!, stateFile); return; @@ -961,6 +1058,8 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: const result = await processThread(auth, tid, syncDir, attachmentsDir); if (result) synced.push(result); } + const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays); + synced.push(...backfilled); await publishGmailSyncEvent(synced); From 6094ac508a3028f0bbd604e38ef3b2a806e36f1a Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 22 May 2026 17:31:13 +0530 Subject: [PATCH 02/16] fix build isues --- apps/x/apps/renderer/src/App.tsx | 3 - .../renderer/src/components/home-view.tsx | 71 +------------------ .../src/components/sidebar-content.tsx | 6 +- 3 files changed, 4 insertions(+), 76 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 4904a119..59a16b26 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5302,9 +5302,6 @@ function App() { onOpenNote={(path) => navigateToFile(path)} onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} onTakeMeetingNotes={() => { void handleToggleMeeting() }} - onVoiceNoteCreated={handleVoiceNoteCreated} - onRunBrowserTask={handleToggleBrowser} - onStartResearch={() => submitFromPalette('Do deep, extreme research on a topic and build me a local website that summarizes the findings. Ask me what topic to research.', null)} onOpenChat={handleNewChatTab} /> diff --git a/apps/x/apps/renderer/src/components/home-view.tsx b/apps/x/apps/renderer/src/components/home-view.tsx index 21034f56..3cc0899d 100644 --- a/apps/x/apps/renderer/src/components/home-view.tsx +++ b/apps/x/apps/renderer/src/components/home-view.tsx @@ -1,7 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { ArrowRight, Bot, Calendar, Clock, FileText, Globe, Mail, MessageSquare, Mic, Plus, Telescope, Video } from 'lucide-react' - -import { VoiceNoteButton } from '@/components/sidebar-content' +import { ArrowRight, Bot, Calendar, Clock, FileText, Mail, MessageSquare, Mic, Plus, Video } from 'lucide-react' import { extractConferenceLink } from '@/lib/calendar-event' interface TreeNode { @@ -26,9 +24,6 @@ type HomeViewProps = { onOpenNote: (path: string) => void onOpenRun: (runId: string) => void onTakeMeetingNotes: () => void - onVoiceNoteCreated?: (path: string) => void - onRunBrowserTask?: () => void - onStartResearch?: () => void onOpenChat?: () => void } @@ -169,9 +164,6 @@ export function HomeView({ onOpenNote, onOpenRun, onTakeMeetingNotes, - onVoiceNoteCreated, - onRunBrowserTask, - onStartResearch, onOpenChat, }: HomeViewProps) { const [events, setEvents] = useState([]) @@ -472,67 +464,6 @@ export function HomeView({ ) } -type DiscoveryTip = { - icon: typeof Mic - title: string - desc: string - /** Provide one of: an action node (e.g. VoiceNoteButton) or an onAction handler. */ - action?: React.ReactNode - onAction?: () => void -} - -function DiscoveryCarousel({ tips }: { tips: DiscoveryTip[] }) { - const [index, setIndex] = useState(0) - const [paused, setPaused] = useState(false) - - useEffect(() => { - if (paused || tips.length <= 1) return - const id = setInterval(() => setIndex((i) => (i + 1) % tips.length), 7000) - return () => clearInterval(id) - }, [paused, tips.length]) - - const safeIndex = index % tips.length - const tip = tips[safeIndex] - const Icon = tip.icon - - return ( -
setPaused(true)} - onMouseLeave={() => setPaused(false)} - > -
- -
-
- {tip.title} - — {tip.desc} -
- {tip.action ?? ( - - )} -
- {tips.map((_, i) => ( -
-
- ) -} - function formatFrom(from: string): string { const m = /^\s*"?([^"<]+?)"?\s*<.+>\s*$/.exec(from) return (m ? m[1] : from).trim() diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 822aa8f1..a679c4fd 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -59,6 +59,7 @@ import { cn } from "@/lib/utils" import { SettingsDialog } from "@/components/settings-dialog" import { extractConferenceLink } from "@/lib/calendar-event" import { useBilling } from "@/hooks/useBilling" +import { toast } from "@/lib/toast" import { ServiceEvent } from "@x/shared/src/service-events.js" import z from "zod" @@ -472,7 +473,7 @@ export function SidebarContentPanel({ const loadNext = async () => { try { const exists = await window.ipc.invoke('workspace:exists', { path: 'calendar_sync' }) - if (!exists.exists) { if (!cancelled) setNextMeetingLabel(null); return } + if (!exists.exists) { if (!cancelled) setMeetings([]); return } const entries = await window.ipc.invoke('workspace:readdir', { path: 'calendar_sync', opts: { recursive: false, includeHidden: false, includeStats: false }, @@ -998,7 +999,7 @@ export function SidebarContentPanel({ { - setOpenConnectorsAfterClose(false) + setOpenConnectionsAfterClose(false) setShowOauthAlert(false) }} > @@ -1410,4 +1411,3 @@ function formatEmailFrom(from: string): string { if (match) return match[1].trim() return from } - From eb4b11a5304b917e60ad69006398c55edd1f8864 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Sat, 23 May 2026 09:25:26 +0530 Subject: [PATCH 03/16] email and calendar empty states now check only native google oauth --- apps/x/apps/renderer/src/components/email-view.tsx | 13 ++++++++----- .../apps/renderer/src/components/meetings-view.tsx | 13 ++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index f2fc5ca3..69f9b91e 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -843,7 +843,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current) const [error, setError] = useState(null) const [query, setQuery] = useState('') - // Gmail connection status — null while checking, false shows the connect prompt. + // Gmail sync uses the native Google OAuth connection. const [emailConnected, setEmailConnected] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) @@ -851,15 +851,18 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = let cancelled = false const check = async () => { try { - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) - if (!cancelled) setEmailConnected(result.isConnected) + const oauthState = await window.ipc.invoke('oauth:getState', null) + if (!cancelled) setEmailConnected(oauthState.config?.google?.connected ?? false) } catch { if (!cancelled) setEmailConnected(false) } } void check() - const cleanup = window.ipc.on('oauth:didConnect', () => { void check() }) - return () => { cancelled = true; cleanup() } + const cleanupOAuthConnect = window.ipc.on('oauth:didConnect', () => { void check() }) + return () => { + cancelled = true + cleanupOAuthConnect() + } }, []) useEffect(() => { persistedImportant = important }, [important]) diff --git a/apps/x/apps/renderer/src/components/meetings-view.tsx b/apps/x/apps/renderer/src/components/meetings-view.tsx index 436fe42b..5f74688d 100644 --- a/apps/x/apps/renderer/src/components/meetings-view.tsx +++ b/apps/x/apps/renderer/src/components/meetings-view.tsx @@ -190,7 +190,7 @@ function UpcomingEvents() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [refreshTick, setRefreshTick] = useState(0) - // Calendar connection status — null while checking, false shows the connect prompt. + // Calendar sync uses the native Google OAuth connection. const [calendarConnected, setCalendarConnected] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) @@ -198,15 +198,18 @@ function UpcomingEvents() { let cancelled = false const check = async () => { try { - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) - if (!cancelled) setCalendarConnected(result.isConnected) + const oauthState = await window.ipc.invoke('oauth:getState', null) + if (!cancelled) setCalendarConnected(oauthState.config?.google?.connected ?? false) } catch { if (!cancelled) setCalendarConnected(false) } } void check() - const cleanup = window.ipc.on('oauth:didConnect', () => { void check() }) - return () => { cancelled = true; cleanup() } + const cleanupOAuthConnect = window.ipc.on('oauth:didConnect', () => { void check() }) + return () => { + cancelled = true + cleanupOAuthConnect() + } }, []) const loadEvents = useCallback(async () => { From 7cd661d726b8bda3e3f4259de3e5be4361359e1c Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Sun, 24 May 2026 12:46:54 +0530 Subject: [PATCH 04/16] Show split monthly and daily credits - update billing contract to consume split monthly/daily usage - show monthly and daily credit percentage bars in account settings - keep sidebar plan labels normalized - update out-of-credits copy for daily credits --- .../components/settings/account-settings.tsx | 46 +++++++++++++++++-- .../src/components/sidebar-content.tsx | 7 ++- apps/x/apps/renderer/src/lib/billing-error.ts | 2 +- apps/x/packages/core/src/billing/billing.ts | 17 +++++-- apps/x/packages/shared/src/billing.ts | 13 +++++- 5 files changed, 73 insertions(+), 12 deletions(-) diff --git a/apps/x/apps/renderer/src/components/settings/account-settings.tsx b/apps/x/apps/renderer/src/components/settings/account-settings.tsx index feee291b..c40f411e 100644 --- a/apps/x/apps/renderer/src/components/settings/account-settings.tsx +++ b/apps/x/apps/renderer/src/components/settings/account-settings.tsx @@ -17,11 +17,44 @@ import { import { Separator } from "@/components/ui/separator" import { useBilling } from "@/hooks/useBilling" import { toast } from "sonner" +import type { BillingUsageBucket } from "@x/shared/dist/billing.js" interface AccountSettingsProps { dialogOpen: boolean } +function formatPlanName(plan: string | null | undefined) { + if (!plan) return 'No Plan' + return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} Plan` +} + +function CreditUsageBar({ label, bucket, helper }: { + label: string + bucket: BillingUsageBucket + helper?: string +}) { + const pct = bucket.sanctionedCredits > 0 + ? Math.min(100, Math.max(0, Math.round((bucket.usedCredits / bucket.sanctionedCredits) * 100))) + : 0 + + return ( +
+
+
+

{label}

+ {helper ?

{helper}

: null} +
+

+ {pct}% +

+
+
+
+
+
+ ) +} + export function AccountSettings({ dialogOpen }: AccountSettingsProps) { const [isRowboatConnected, setIsRowboatConnected] = useState(false) const [connectionLoading, setConnectionLoading] = useState(true) @@ -164,7 +197,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {

- {billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'} + {formatPlanName(billing.subscriptionPlan)}

{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => { const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))) @@ -179,14 +212,19 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) { {!billing.subscriptionPlan && (

Subscribe to access AI features

)} - {billing.subscriptionPlan === 'free' && ( -

Free usage resets daily at 00:00 UTC.

- )}
+
+ + +
) : (

Unable to load plan details

diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index a679c4fd..dbb5edb5 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -96,6 +96,11 @@ function displayNoteName(node: TreeNode): string { return node.name } +function formatBillingPlanName(plan: string | null | undefined) { + if (!plan) return 'No plan' + return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} plan` +} + function formatAgo(ms: number): string { const diffMs = Math.max(0, Date.now() - ms) const min = Math.floor(diffMs / 60000) @@ -921,7 +926,7 @@ export function SidebarContentPanel({
- {billing.subscriptionPlan ? `${billing.subscriptionPlan} plan` : 'No plan'} + {formatBillingPlanName(billing.subscriptionPlan)} {billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => { const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))) diff --git a/apps/x/apps/renderer/src/lib/billing-error.ts b/apps/x/apps/renderer/src/lib/billing-error.ts index ffb77470..bfbb3266 100644 --- a/apps/x/apps/renderer/src/lib/billing-error.ts +++ b/apps/x/apps/renderer/src/lib/billing-error.ts @@ -8,7 +8,7 @@ export const BILLING_ERROR_PATTERNS = [ { pattern: /not enough credits/i, title: "You've run out of credits", - subtitle: 'Upgrade your plan for more credits. Free usage resets daily at 00:00 UTC.', + subtitle: 'Upgrade your plan for more monthly credits. Daily credits reset at 00:00 UTC.', cta: 'Upgrade plan', }, { diff --git a/apps/x/packages/core/src/billing/billing.ts b/apps/x/packages/core/src/billing/billing.ts index 22bc3d5a..0b088be4 100644 --- a/apps/x/packages/core/src/billing/billing.ts +++ b/apps/x/packages/core/src/billing/billing.ts @@ -20,8 +20,17 @@ export async function getBillingInfo(): Promise { status: string | null; trialExpiresAt: string | null; usage: { - sanctionedCredits: number; - availableCredits: number; + monthly: { + sanctionedCredits: number; + usedCredits: number; + availableCredits: number; + }; + daily: { + sanctionedCredits: number; + usedCredits: number; + availableCredits: number; + usageDay: string; + }; }; }; }; @@ -31,7 +40,7 @@ export async function getBillingInfo(): Promise { subscriptionPlan: body.billing.plan, subscriptionStatus: body.billing.status, trialExpiresAt: body.billing.trialExpiresAt ?? null, - sanctionedCredits: body.billing.usage.sanctionedCredits, - availableCredits: body.billing.usage.availableCredits, + monthly: body.billing.usage.monthly, + daily: body.billing.usage.daily, }; } diff --git a/apps/x/packages/shared/src/billing.ts b/apps/x/packages/shared/src/billing.ts index dca3343a..59fe68d3 100644 --- a/apps/x/packages/shared/src/billing.ts +++ b/apps/x/packages/shared/src/billing.ts @@ -3,13 +3,22 @@ import { z } from 'zod'; export const BillingPlanSchema = z.enum(['free', 'starter', 'pro']); export type BillingPlan = z.infer; +export const BillingUsageBucketSchema = z.object({ + sanctionedCredits: z.number(), + usedCredits: z.number(), + availableCredits: z.number(), +}); +export type BillingUsageBucket = z.infer; + export const BillingInfoSchema = z.object({ userEmail: z.string().nullable(), userId: z.string().nullable(), subscriptionPlan: BillingPlanSchema.nullable(), subscriptionStatus: z.string().nullable(), trialExpiresAt: z.string().nullable(), - sanctionedCredits: z.number(), - availableCredits: z.number(), + monthly: BillingUsageBucketSchema, + daily: BillingUsageBucketSchema.extend({ + usageDay: z.string(), + }), }); export type BillingInfo = z.infer; From 566f4553b44d1ab8442350dd5540f665ca83b7b9 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Sun, 24 May 2026 13:21:30 +0530 Subject: [PATCH 05/16] Clarify billing usage labels - rename monthly credits label to plan usage - rename daily credits label to daily use - clarify daily usage reset and out-of-credits copy --- .../renderer/src/components/settings/account-settings.tsx | 6 +++--- apps/x/apps/renderer/src/lib/billing-error.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/renderer/src/components/settings/account-settings.tsx b/apps/x/apps/renderer/src/components/settings/account-settings.tsx index c40f411e..66f2b682 100644 --- a/apps/x/apps/renderer/src/components/settings/account-settings.tsx +++ b/apps/x/apps/renderer/src/components/settings/account-settings.tsx @@ -218,11 +218,11 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
- +
diff --git a/apps/x/apps/renderer/src/lib/billing-error.ts b/apps/x/apps/renderer/src/lib/billing-error.ts index bfbb3266..2e6736ed 100644 --- a/apps/x/apps/renderer/src/lib/billing-error.ts +++ b/apps/x/apps/renderer/src/lib/billing-error.ts @@ -8,7 +8,7 @@ export const BILLING_ERROR_PATTERNS = [ { pattern: /not enough credits/i, title: "You've run out of credits", - subtitle: 'Upgrade your plan for more monthly credits. Daily credits reset at 00:00 UTC.', + subtitle: 'Upgrade your plan for more usage. Daily usage resets at 00:00 UTC.', cta: 'Upgrade plan', }, { From a59c42e22bb47175077d2fbef06456a693ee7f5b Mon Sep 17 00:00:00 2001 From: gagan Date: Sun, 24 May 2026 23:48:20 +0530 Subject: [PATCH 06/16] =?UTF-8?q?fix:=20notes=20=E2=80=94=20in-note=20sect?= =?UTF-8?q?ion=20links,=20deep-note=20wiki=20resolution,=20file=20links=20?= =?UTF-8?q?(#571)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix discord users feedback * fix: drop duplicate Link extension and scroll headings to viewport top * fix: collapse ../ segments in note-relative file links --------- Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com> --- apps/x/apps/renderer/src/App.tsx | 30 +++- .../renderer/src/components/email-view.tsx | 2 +- .../src/components/markdown-editor.tsx | 151 +++++++++++++++++- .../src/components/rich-markdown-viewer.tsx | 1 + .../src/components/settings-dialog.tsx | 15 +- .../apps/renderer/src/extensions/wiki-link.ts | 58 ++++--- apps/x/apps/renderer/src/lib/wiki-links.ts | 38 ++++- 7 files changed, 261 insertions(+), 34 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 59a16b26..33eda548 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -69,7 +69,7 @@ import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" import { BillingErrorDialog } from "@/components/billing-error-dialog" import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error" -import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' +import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { extractConferenceLink } from '@/lib/calendar-event' import { OnboardingModal } from '@/components/onboarding' @@ -4776,8 +4776,30 @@ function App() { return () => window.removeEventListener('email-block:draft-with-assistant', handler) }, []) + const resolveWikiFilePath = useCallback((wikiPath: string) => { + const normalized = normalizeWikiPath(wikiPath) + const { path: basePath } = splitWikiFragment(normalized) + if (!basePath) return null + + const targetPath = ensureMarkdownExtension(basePath) + const targetKey = targetPath.toLowerCase() + const exactMatch = knowledgeFiles.find((filePath) => normalizeWikiPath(filePath).toLowerCase() === targetKey) + if (exactMatch) return toKnowledgePath(exactMatch) + + if (!basePath.includes('/')) { + const targetBaseName = targetPath.split('/').pop()?.toLowerCase() + const basenameMatches = knowledgeFiles.filter((filePath) => { + const normalizedFile = normalizeWikiPath(filePath) + return normalizedFile.split('/').pop()?.toLowerCase() === targetBaseName + }) + if (basenameMatches.length === 1) return toKnowledgePath(basenameMatches[0]) + } + + return toKnowledgePath(basePath) + }, [knowledgeFiles]) + const ensureWikiFile = useCallback(async (wikiPath: string) => { - const resolvedPath = toKnowledgePath(wikiPath) + const resolvedPath = resolveWikiFilePath(wikiPath) if (!resolvedPath) return null try { const exists = await window.ipc.invoke('workspace:exists', { path: resolvedPath }) @@ -4794,9 +4816,11 @@ function App() { console.error('Failed to ensure wiki link target:', err) return null } - }, []) + }, [resolveWikiFilePath]) const openWikiLink = useCallback(async (wikiPath: string) => { + const { path: basePath } = splitWikiFragment(normalizeWikiPath(wikiPath)) + if (!basePath) return const resolvedPath = await ensureWikiFile(wikiPath) if (resolvedPath) { navigateToFile(resolvedPath) diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 69f9b91e..d64c3fc1 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -500,7 +500,7 @@ function ComposeBox({ const editor = useEditor({ extensions: [ - StarterKit, + StarterKit.configure({ link: false }), Link.configure({ openOnClick: false, autolink: true }), Placeholder.configure({ placeholder: mode === 'reply' ? 'Write your reply…' : 'Write a message…', diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 3666f446..6146d2e4 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -1,5 +1,5 @@ import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react' -import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' @@ -83,7 +83,8 @@ function nodeToText(node: JsonNode): string { return text } else if (child.type === 'wikiLink') { const path = (child.attrs?.path as string) || '' - return path ? `[[${path}]]` : '' + const label = (child.attrs?.label as string | null | undefined) || '' + return path ? `[[${path}${label ? `|${label}` : ''}]]` : '' } else if (child.type === 'hardBreak') { return '\n' } @@ -189,7 +190,8 @@ function blockToMarkdown(node: JsonNode): string { return '---' case 'wikiLink': { const path = (node.attrs?.path as string) || '' - return `[[${path}]]` + const label = (node.attrs?.label as string | null | undefined) || '' + return `[[${path}${label ? `|${label}` : ''}]]` } case 'image': { const src = (node.attrs?.src as string) || '' @@ -297,7 +299,7 @@ import { FrontmatterProperties } from './frontmatter-properties' import { WikiLink } from '@/extensions/wiki-link' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' -import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links' +import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, wikiLabel } from '@/lib/wiki-links' import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter' import { RowboatMentionPopover } from './rowboat-mention-popover' import '@/styles/editor.css' @@ -523,6 +525,106 @@ const TabIndentExtension = Extension.create({ }, }) +const slugifyHeading = (text: string) => + text + .trim() + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + +const decodeLinkTarget = (target: string) => { + try { + return decodeURIComponent(target) + } catch { + return target + } +} + +const scrollToHeading = (view: EditorView, rawTarget: string) => { + const target = decodeLinkTarget(rawTarget.replace(/^#/, '')).trim() + if (!target) return false + + const targetSlug = slugifyHeading(target) + let foundPos: number | null = null + view.state.doc.descendants((node, pos) => { + if (node.type.name !== 'heading') return true + const headingText = node.textContent.trim() + if ( + headingText.toLowerCase() === target.toLowerCase() + || slugifyHeading(headingText) === targetSlug + ) { + foundPos = pos + return false + } + return true + }) + + if (foundPos === null) return false + + const selectionPos = Math.min(foundPos + 1, view.state.doc.content.size) + view.dispatch( + view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(selectionPos))) + ) + view.focus() + + const domAtPos = view.domAtPos(foundPos + 1) + const node = domAtPos.node + const headingEl = node.nodeType === Node.ELEMENT_NODE + ? (node as HTMLElement) + : node.parentElement + headingEl?.scrollIntoView({ block: 'start', behavior: 'smooth' }) + return true +} + +const stripMarkdownExtension = (path: string) => + path.toLowerCase().endsWith('.md') ? path.slice(0, -3) : path + +const isSameNotePath = (linkPath: string, notePath?: string) => { + if (!notePath) return false + const normalizedLink = stripMarkdownExtension(normalizeWikiPath(linkPath)).toLowerCase() + const normalizedNote = stripMarkdownExtension(normalizeWikiPath(notePath)).toLowerCase() + return normalizedLink === normalizedNote +} + +const isExternalHref = (href: string) => + /^(https?:|mailto:|tel:)/i.test(href) + +const collapseRelativeSegments = (relPath: string) => { + const parts = relPath.split('/').filter((part) => part !== '' && part !== '.') + const stack: string[] = [] + for (const part of parts) { + if (part === '..') { + if (stack.length === 0) return null + stack.pop() + } else { + stack.push(part) + } + } + return stack.join('/') +} + +const resolveWorkspaceLinkPath = (href: string, notePath?: string) => { + const withoutHash = href.split('#')[0] + const withoutQuery = withoutHash.split('?')[0] + const decoded = decodeLinkTarget(withoutQuery) + if (!decoded) return null + + if (/^file:\/\//i.test(decoded)) { + try { + return decodeURIComponent(new URL(decoded).pathname) + } catch { + return decoded + } + } + + if (/^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/')) return decoded + if (decoded.startsWith('knowledge/') || !notePath) return collapseRelativeSegments(decoded.replace(/^\.\//, '')) + + const noteDir = notePath.split('/').slice(0, -1).join('/') + return collapseRelativeSegments(`${noteDir}/${decoded.replace(/^\.\//, '')}`) +} + export interface MarkdownEditorHandle { /** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */ getCursorContext: () => { path: string; lineNumber: number } | null @@ -644,6 +746,7 @@ export const MarkdownEditor = forwardRef { if (node.type.name === 'wikiLink') { event.preventDefault() + const wikiPath = String(node.attrs.path ?? '') + const { path: linkedNotePath, heading } = splitWikiFragment(wikiPath) + if (heading && (!linkedNotePath || isSameNotePath(linkedNotePath, notePath))) { + return scrollToHeading(_view, heading) + } wikiLinks?.onOpen?.(node.attrs.path) return true } return false }, + handleDOMEvents: { + click: (view, event) => { + const target = event.target as Element | null + const link = target?.closest('a[href]') as HTMLAnchorElement | null + if (!link) return false + if (link.dataset.type === 'wiki-link') return false + + const href = link.getAttribute('href') ?? '' + if (!href) return false + + if (href.startsWith('#')) { + event.preventDefault() + return scrollToHeading(view, href) + } + + if (isExternalHref(href)) { + event.preventDefault() + window.open(href, '_blank') + return true + } + + const workspacePath = resolveWorkspaceLinkPath(href, notePath) + if (!workspacePath) return false + + event.preventDefault() + void window.ipc.invoke('shell:openPath', { path: workspacePath }).then((result) => { + if (result.error) console.error('Failed to open linked file:', result.error) + }).catch((err) => { + console.error('Failed to open linked file:', err) + }) + return true + }, + }, }, }, [ editorSessionKey, maybeCommitPrimaryHeading, + notePath, preventTitleHeadingDemotion, promoteFirstParagraphToTitleHeading, + wikiLinks, ]) const orderedFiles = useMemo(() => { diff --git a/apps/x/apps/renderer/src/components/rich-markdown-viewer.tsx b/apps/x/apps/renderer/src/components/rich-markdown-viewer.tsx index da6342fe..6c3c79b5 100644 --- a/apps/x/apps/renderer/src/components/rich-markdown-viewer.tsx +++ b/apps/x/apps/renderer/src/components/rich-markdown-viewer.tsx @@ -42,6 +42,7 @@ export function RichMarkdownViewer({ content }: { content: string }) { heading: { levels: [1, 2, 3], }, + link: false, }), Link.configure({ openOnClick: true, diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 3a93e1c5..c7750d6c 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug } from "lucide-react" import { Dialog, @@ -110,6 +110,19 @@ function HelpSettings() {

Help & Support

Get help from our community

+ + + ))} + setDraft(event.target.value)} + onKeyDown={onKeyDown} + onBlur={() => { if (draft.trim()) commit(draft) }} + onPaste={(event) => { + const text = event.clipboardData.getData('text') + if (text && /[,;\n]/.test(text)) { + event.preventDefault() + commit(text) + } + }} + /> + + {trailing &&
{trailing}
} + + ) +} + function ComposeBox({ mode, thread, + selfEmail, onClose, }: { mode: ComposeMode thread: GmailThread + selfEmail: string onClose: () => void }) { const latest = latestMessage(thread) - const to = mode === 'reply' ? extractAddress(latest?.from) : '' + const initialRecipients = useMemo( + () => buildRecipients(mode, thread, selfEmail), + [mode, thread, selfEmail], + ) + + const [toList, setToList] = useState(initialRecipients.to) + const [ccList, setCcList] = useState(initialRecipients.cc) + 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 initialContent = useMemo(() => { - if (mode !== 'reply') return '' + if (mode === 'forward') return buildForwardedContent(thread) // Gmail-side draft (user's own work) wins over the AI-generated draft. const source = thread.gmail_draft || thread.draft_response if (!source) return '' @@ -496,14 +698,14 @@ function ComposeBox({ .split(/\n{2,}/) .map((para) => `

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

`) .join('') - }, [mode, thread.gmail_draft, thread.draft_response]) + }, [mode, thread]) const editor = useEditor({ extensions: [ StarterKit.configure({ link: false }), Link.configure({ openOnClick: false, autolink: true }), Placeholder.configure({ - placeholder: mode === 'reply' ? 'Write your reply…' : 'Write a message…', + placeholder: mode === 'forward' ? 'Write a message…' : 'Write your reply…', }), ], editorProps: { @@ -555,52 +757,65 @@ function ComposeBox({ if (editor && sel) editor.chain().focus().setTextSelection(sel).run() } + const [sending, setSending] = useState(false) const sendInGmail = async () => { - if (!editor) { - window.open(thread.threadUrl, '_blank') - return - } + if (!editor || sending) return const html = editor.getHTML() const text = editor.getText().trim() - - let copied = false - if (text) { - try { - if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) { - await navigator.clipboard.write([ - new ClipboardItem({ - 'text/html': new Blob([html], { type: 'text/html' }), - 'text/plain': new Blob([text], { type: 'text/plain' }), - }), - ]) - copied = true - } else if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text) - copied = true - } - } catch (err) { - console.warn('[Gmail] clipboard write failed:', err) - } + if (!text) { + toast('Draft is empty.', 'error') + return } - window.open(thread.threadUrl, '_blank') - if (copied) { - toast('Draft copied — open the reply in Gmail and paste.', 'info') - } else if (text) { - toast('Could not copy draft. Open Gmail and paste manually.', 'error') + if (toList.length === 0) { + toast('Add at least one recipient.', 'error') + return + } + + // Build References chain from all known message ids (newest last). + 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' + + setSending(true) + try { + const result = await window.ipc.invoke('gmail:sendReply', { + threadId: isForward ? undefined : thread.threadId, + to: toList.join(', '), + cc: ccList.length ? ccList.join(', ') : undefined, + bcc: bccList.length ? bccList.join(', ') : undefined, + subject: subject.trim() || composeSubject(mode, thread.subject), + bodyHtml: html, + bodyText: text, + inReplyTo: isForward ? undefined : inReplyTo, + references: isForward ? undefined : references || undefined, + }) + if (result.error) { + toast(`Send failed: ${result.error}`, 'error') + return + } + toast('Sent.', 'success') + onClose() + } catch (err) { + toast(`Send failed: ${err instanceof Error ? err.message : String(err)}`, 'error') + } finally { + setSending(false) } } const refineWithCopilot = () => { if (!editor) return const currentDraft = editor.getText().trim() - const subject = thread.subject || '(No subject)' + const threadSubject = thread.subject || '(No subject)' const lines: string[] = [] lines.push(`Help me refine this draft email response. **Please ask me how I want to refine it before making any changes** — wait for my answer, then apply the edits.`) lines.push('') - lines.push(`**Mode:** ${mode === 'reply' ? 'Reply' : 'Forward'}`) - lines.push(`**Subject:** ${subject}`) + lines.push(`**Mode:** ${modeLabel}`) + lines.push(`**Subject:** ${threadSubject}`) lines.push('') lines.push(`## Thread (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`) lines.push('') @@ -625,17 +840,32 @@ function ComposeBox({ return (
- {mode === 'reply' ? 'Reply' : 'Forward'} - -
-
- {mode === 'reply' ? 'To' : 'Recipients'} - + {modeLabel} +
+ + {!showCc && } + {!showBcc && } +
+ } + /> + {showCc && } + {showBcc && } {mode === 'forward' && (
- Subject - + Subject + setSubject(event.target.value)} + placeholder="Subject" + />
)} @@ -666,10 +896,11 @@ function ComposeBox({ type="button" className="gmail-send-button" onClick={() => { void sendInGmail() }} - title="Copy draft and open this thread in Gmail" + disabled={sending} + title="Send this reply via Gmail" > - - Send + {sending ? : } + {sending ? 'Sending…' : 'Send'} + {canReplyAll && ( + + )} + +
+ {isUnread && ( + + )} + + +
+ {isMounted && ( )} - ) : emailConnected === false ? ( + ) : needsEmailConnect || needsEmailReconnect ? (
-

Connect your email to see your inbox here.

+

+ {needsEmailReconnect + ? 'Reconnect your email to enable Gmail sync and actions.' + : 'Connect your email to see your inbox here.'} +

) : ( diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index 52bd0ab5..732d56ab 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -75,9 +75,8 @@ const providerConfigs: ProviderConfig = { mode: 'static', }, scopes: [ - 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/calendar.events.readonly', - 'https://www.googleapis.com/auth/drive.readonly', ], }, 'fireflies-ai': { @@ -119,4 +118,3 @@ export async function getProviderConfig(providerName: string): Promise { + const status = await this.getCredentialStatus(requiredScopes); + return status.hasRequiredScopes; + } + + static async getCredentialStatus(requiredScopes: string | string[]): Promise<{ + connected: boolean; + hasRequiredScopes: boolean; + missingScopes: string[]; + }> { const oauthRepo = container.resolve('oauthRepo'); const { tokens } = await oauthRepo.read(this.PROVIDER_NAME); if (!tokens) { - return false; + const scopesArray = Array.isArray(requiredScopes) ? requiredScopes : [requiredScopes]; + return { + connected: false, + hasRequiredScopes: false, + missingScopes: scopesArray, + }; } - // Check if required scope(s) are present const scopesArray = Array.isArray(requiredScopes) ? requiredScopes : [requiredScopes]; + const granted = new Set(tokens.scopes ?? []); + const missingScopes = scopesArray.filter(scope => !granted.has(scope)); if (!tokens.scopes || tokens.scopes.length === 0) { - return false; + return { + connected: true, + hasRequiredScopes: false, + missingScopes, + }; } - return scopesArray.every(scope => tokens.scopes!.includes(scope)); + return { + connected: true, + hasRequiredScopes: missingScopes.length === 0, + missingScopes, + }; } /** diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 8057df86..1f98736a 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -26,7 +26,7 @@ const CACHE_DIR = path.join(WorkDir, 'inbox_lists'); } })(); const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds -const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; +const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.modify'; const MAX_THREADS_IN_DIGEST = 10; const RECENT_BACKFILL_INTERVAL_MS = 15 * 60 * 1000; const nhm = new NodeHtmlMarkdown(); @@ -78,6 +78,76 @@ export function saveMessageBodyHeight(threadId: string, messageId: string, heigh } } +function deleteCachedSnapshot(threadId: string): void { + try { + fs.rmSync(cachePath(threadId), { force: true }); + } catch (err) { + console.warn(`[Gmail cache] delete failed for ${threadId}:`, err); + } +} + +async function getGmailClientOrThrow() { + const auth = await GoogleClientFactory.getClient(); + if (!auth) throw new Error('Gmail is not connected.'); + return google.gmail({ version: 'v1', auth }); +} + +export interface ThreadActionResult { + ok: boolean; + error?: string; +} + +export async function archiveThread(threadId: string): Promise { + try { + const gmailClient = await getGmailClientOrThrow(); + await gmailClient.users.threads.modify({ + userId: 'me', + id: threadId, + requestBody: { removeLabelIds: ['INBOX'] }, + }); + deleteCachedSnapshot(threadId); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function trashThread(threadId: string): Promise { + try { + const gmailClient = await getGmailClientOrThrow(); + await gmailClient.users.threads.trash({ userId: 'me', id: threadId }); + deleteCachedSnapshot(threadId); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function markThreadRead(threadId: string): Promise { + try { + const gmailClient = await getGmailClientOrThrow(); + await gmailClient.users.threads.modify({ + userId: 'me', + id: threadId, + requestBody: { removeLabelIds: ['UNREAD'] }, + }); + // Update local cache: clear unread on all messages in the thread. + const cached = readCachedSnapshot(threadId); + if (cached) { + for (const m of cached.snapshot.messages) m.unread = false; + cached.snapshot.unread = false; + try { + fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8'); + } catch (err) { + console.warn(`[Gmail cache] markRead write failed for ${threadId}:`, err); + } + } + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + interface SyncedThread { threadId: string; markdown: string; @@ -114,6 +184,7 @@ export interface GmailThreadSnapshot { sizeBytes?: number; savedPath: string; }>; + messageIdHeader?: string; }>; } @@ -1158,6 +1229,162 @@ async function performSync() { } } +// --- Send Reply --- + +export interface SendReplyOptions { + threadId?: string; + to: string; + cc?: string; + bcc?: string; + subject: string; + bodyHtml: string; + bodyText: string; + inReplyTo?: string; + references?: string; +} + +export interface SendReplyResult { + messageId?: string; + error?: string; +} + +export interface GmailConnectionStatus { + connected: boolean; + hasRequiredScope: boolean; + missingScopes: string[]; + email: string | null; +} + +/** The connected Gmail address (cached). Used by the composer to exclude "me" from reply-all. */ +export async function getAccountEmail(): Promise { + const auth = await GoogleClientFactory.getClient(); + if (!auth) return null; + return getUserEmail(auth); +} + +export async function getConnectionStatus(): Promise { + const status = await GoogleClientFactory.getCredentialStatus(REQUIRED_SCOPE); + let email: string | null = null; + if (status.connected) { + try { + email = await getAccountEmail(); + } catch { + email = null; + } + } + return { + connected: status.connected, + hasRequiredScope: status.hasRequiredScopes, + missingScopes: status.missingScopes, + email, + }; +} + +function requireSafeHeaderValue(name: string, value: string): string { + if (/[\r\n]/.test(value)) { + throw new Error(`${name} cannot contain line breaks.`); + } + return value.trim(); +} + +function encodeRfc2047(text: string): string { + requireSafeHeaderValue('Subject', text); + // Only encode if non-ASCII chars present. + // eslint-disable-next-line no-control-regex + if (/^[\x00-\x7F]*$/.test(text)) return text; + return `=?UTF-8?B?${Buffer.from(text).toString('base64')}?=`; +} + +function encodeMimeBase64(text: string): string { + return Buffer.from(text, 'utf8') + .toString('base64') + .match(/.{1,76}/g) + ?.join('\r\n') ?? ''; +} + +export async function sendThreadReply(opts: SendReplyOptions): Promise { + try { + const auth = await GoogleClientFactory.getClient(); + if (!auth) return { error: 'Gmail is not connected.' }; + + const gmailClient = google.gmail({ version: 'v1', auth }); + const userEmail = await getUserEmail(auth); + if (!userEmail) return { error: 'Could not determine your Gmail address.' }; + + const safeTo = requireSafeHeaderValue('To', opts.to); + const safeCc = opts.cc?.trim() ? requireSafeHeaderValue('Cc', opts.cc) : undefined; + const safeBcc = opts.bcc?.trim() ? requireSafeHeaderValue('Bcc', opts.bcc) : undefined; + const safeInReplyTo = opts.inReplyTo ? requireSafeHeaderValue('In-Reply-To', opts.inReplyTo) : undefined; + const safeReferences = opts.references ? requireSafeHeaderValue('References', opts.references) : undefined; + + const boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + const headers: string[] = []; + headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`); + headers.push(`To: ${safeTo}`); + if (safeCc) headers.push(`Cc: ${safeCc}`); + if (safeBcc) headers.push(`Bcc: ${safeBcc}`); + headers.push(`Subject: ${encodeRfc2047(opts.subject)}`); + if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`); + if (safeReferences) headers.push(`References: ${safeReferences}`); + headers.push('MIME-Version: 1.0'); + headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`); + + const parts: string[] = []; + parts.push(`--${boundary}`); + parts.push('Content-Type: text/plain; charset="UTF-8"'); + parts.push('Content-Transfer-Encoding: base64'); + parts.push(''); + parts.push(encodeMimeBase64(opts.bodyText)); + parts.push(''); + parts.push(`--${boundary}`); + parts.push('Content-Type: text/html; charset="UTF-8"'); + parts.push('Content-Transfer-Encoding: base64'); + parts.push(''); + parts.push(encodeMimeBase64(opts.bodyHtml)); + parts.push(''); + parts.push(`--${boundary}--`); + + const message = `${headers.join('\r\n')}\r\n\r\n${parts.join('\r\n')}`; + const raw = Buffer.from(message, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + const requestBody: gmail.Schema$Message = { raw }; + if (opts.threadId) requestBody.threadId = opts.threadId; + + const res = await gmailClient.users.messages.send({ + userId: 'me', + requestBody, + }); + + if (opts.threadId) { + // Clean up any Gmail-side drafts in this thread. + try { + const drafts = await gmailClient.users.drafts.list({ userId: 'me' }); + const matching = (drafts.data.drafts || []).filter( + (d) => d.message?.threadId === opts.threadId && d.id + ); + await Promise.all( + matching.map((d) => + gmailClient.users.drafts.delete({ userId: 'me', id: d.id! }) + ) + ); + } catch (cleanupErr) { + console.warn('[Gmail] Draft cleanup after send failed:', cleanupErr); + } + } + + // Wake the sync loop so the cache picks up the new message. + triggerSync(); + + return { messageId: res.data.id || undefined }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + export async function init() { console.log("Starting Gmail Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts index ce8ce43c..69b331c4 100644 --- a/apps/x/packages/shared/src/blocks.ts +++ b/apps/x/packages/shared/src/blocks.ts @@ -123,6 +123,7 @@ export const GmailThreadMessageSchema = z.object({ unread: z.boolean().optional(), bodyHeight: z.number().int().positive().optional(), attachments: z.array(GmailAttachmentSchema).optional(), + messageIdHeader: z.string().optional(), }); export type GmailThreadMessage = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 37cf41e7..230d384c 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -148,6 +148,50 @@ const ipcSchemas = { req: z.object({}), res: z.object({}), }, + 'gmail:sendReply': { + req: z.object({ + threadId: z.string().min(1).optional(), + to: z.string().min(1), + cc: z.string().optional(), + bcc: z.string().optional(), + subject: z.string(), + bodyHtml: z.string(), + bodyText: z.string(), + inReplyTo: z.string().optional(), + references: z.string().optional(), + }), + res: z.object({ + messageId: z.string().optional(), + error: z.string().optional(), + }), + }, + 'gmail:getConnectionStatus': { + req: z.object({}), + res: z.object({ + connected: z.boolean(), + hasRequiredScope: z.boolean(), + missingScopes: z.array(z.string()), + email: z.string().nullable(), + }), + }, + 'gmail:getAccountEmail': { + req: z.object({}), + res: z.object({ + email: z.string().nullable(), + }), + }, + 'gmail:archiveThread': { + req: z.object({ threadId: z.string().min(1) }), + res: z.object({ ok: z.boolean(), error: z.string().optional() }), + }, + 'gmail:trashThread': { + req: z.object({ threadId: z.string().min(1) }), + res: z.object({ ok: z.boolean(), error: z.string().optional() }), + }, + 'gmail:markThreadRead': { + req: z.object({ threadId: z.string().min(1) }), + res: z.object({ ok: z.boolean(), error: z.string().optional() }), + }, 'gmail:saveMessageHeight': { req: z.object({ threadId: z.string().min(1), From 8aa0c1763a1379a8c82f6304e2edbf24053ac7cc Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 25 May 2026 13:19:34 +0530 Subject: [PATCH 08/16] tools row in home --- .../renderer/src/components/home-view.tsx | 127 +++++++++++++++++- 1 file changed, 125 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/renderer/src/components/home-view.tsx b/apps/x/apps/renderer/src/components/home-view.tsx index 3cc0899d..bfcc0a33 100644 --- a/apps/x/apps/renderer/src/components/home-view.tsx +++ b/apps/x/apps/renderer/src/components/home-view.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { ArrowRight, Bot, Calendar, Clock, FileText, Mail, MessageSquare, Mic, Plus, Video } from 'lucide-react' +import { ArrowRight, Bot, Calendar, Clock, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react' import { extractConferenceLink } from '@/lib/calendar-event' +import { SettingsDialog } from '@/components/settings-dialog' interface TreeNode { path: string @@ -53,6 +54,7 @@ type RawCalEvent = { } type EmailThread = { threadId: string; subject: string; from: string } +type ToolkitPreview = { slug: string; logo: string; name: string; description: string } function greeting(): string { const h = new Date().getHours() @@ -152,6 +154,54 @@ function triggerMeetingCapture(event: CalEvent, openConference: boolean) { } const CARD = 'rounded-xl border border-border bg-card p-4' +const TOOLKIT_PREVIEW_LIMIT = 8 + +let cachedToolkitPreviews: ToolkitPreview[] | null = null +let cachedToolkitLogosLoaded = false + +function ToolkitPreviewIcon({ + toolkit, + onInvalid, +}: { + toolkit: ToolkitPreview + onInvalid: (slug: string) => void +}) { + const [loaded, setLoaded] = useState(false) + + if (!loaded) { + return ( + { + const img = event.currentTarget + if (img.naturalWidth > 1 && img.naturalHeight > 1) { + setLoaded(true) + } else { + onInvalid(toolkit.slug) + } + }} + onError={() => onInvalid(toolkit.slug)} + /> + ) + } + + return ( +
+ onInvalid(toolkit.slug)} + /> +
+ ) +} export function HomeView({ tree, @@ -168,6 +218,9 @@ export function HomeView({ }: HomeViewProps) { const [events, setEvents] = useState([]) const [emails, setEmails] = useState([]) + const [toolkitPreviews, setToolkitPreviews] = useState(cachedToolkitPreviews ?? []) + const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded) + const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false) const loadEvents = useCallback(async () => { try { @@ -207,7 +260,40 @@ export function HomeView({ } }, []) - useEffect(() => { void loadEvents(); void loadEmails() }, [loadEvents, loadEmails]) + const loadConnectorLogos = useCallback(async () => { + if (cachedToolkitLogosLoaded) return + try { + const configured = await window.ipc.invoke('composio:is-configured', null) + if (!configured.configured) return + const toolkits = await window.ipc.invoke('composio:list-toolkits', {}) + const previews = toolkits.items + .filter((toolkit) => Boolean(toolkit.meta.logo)) + .slice(0, TOOLKIT_PREVIEW_LIMIT) + .map((toolkit) => ({ + slug: toolkit.slug, + logo: toolkit.meta.logo, + name: toolkit.name, + description: toolkit.meta.description, + })) + cachedToolkitPreviews = previews + setToolkitPreviews(previews) + } catch { + cachedToolkitPreviews = [] + } finally { + cachedToolkitLogosLoaded = true + setToolkitLogosLoaded(true) + } + }, []) + + const removeToolkitPreview = useCallback((slug: string) => { + setToolkitPreviews((prev) => { + const next = prev.filter((toolkit) => toolkit.slug !== slug) + cachedToolkitPreviews = next + return next + }) + }, []) + + useEffect(() => { void loadEvents(); void loadEmails(); void loadConnectorLogos() }, [loadEvents, loadEmails, loadConnectorLogos]) // Upcoming (not-yet-ended) events, soonest first. const upcoming = useMemo(() => { @@ -437,6 +523,43 @@ export function HomeView({ )} + {/* Tool connections */} +
+
+
+ +
+
+
+ Connect your tools. + Bring context from the apps you already use. +
+
+ {toolkitLogosLoaded && toolkitPreviews.map((toolkit) => ( + + ))} + +
+
+
+
+ + {/* Open chat CTA */} {onOpenChat && ( + )} + + + + setOpen(false)} /> + + ) +} + +function EventDetailsPopover({ event, onClose }: { event: UpcomingEvent; onClose: () => void }) { + const organizer = personLabel(event.organizer) ?? personLabel(event.creator) + const attendees = event.attendees.map(attendeeLabel).filter((label): label is string => Boolean(label)) + const descriptionParts = event.description ? parseDescriptionParts(event.description) : [] + const handleMeetingCapture = (openConference: boolean) => { + onClose() + triggerMeetingCapture(event, openConference) + } + + return ( + +
+ {event.htmlLink ? ( - )} + ) : null} +
+
+
+ +
+

+ {event.summary} +

+
+
+ + } value={formatEventDetailTime(event)} /> + {event.location ? } value={event.location} /> : null} + {organizer ? } value={`Organizer: ${organizer}`} /> : null} + {attendees.length > 0 ? ( + } + value={attendees.slice(0, 8).join(', ') + (attendees.length > 8 ? `, +${attendees.length - 8} more` : '')} + /> + ) : null} + + {event.conferenceLink ? ( +
+
+ ) : ( +
+ + +
+ )} + + {descriptionParts.length > 0 ? ( +
+ + +
+ ) : null} +
+
+ ) +} + +function EventDetailRow({ icon, value }: { icon: React.ReactNode; value: string }) { + return ( +
+ {icon} + {value}
) } From 31e35e00b893922cf424c6ea979c988f9d4dfa72 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 25 May 2026 16:21:40 +0530 Subject: [PATCH 10/16] Refactor builtin file tools beyond workspace scope Replace workspace-scoped builtin file tools with general-purpose file-* tools that accept relative, absolute, and ~/ paths. Relative paths still resolve against the configured workdir. File operations within the workdir are auto-approved. File operations outside the workdir now emit file permission metadata and require user approval, with support for once, session, and persistent grants. Add a shared filesystem layer for text-focused read/write/edit/list/search operations, including binary-file safeguards for text reads. parseFile and LLMParse continue to read file buffers for document/image parsing. Update copilot prompts, background/live-note agents, knowledge workflows, and renderer labels/UI to use the new file-* tool surface and permission details. Add package-local Vitest setup for @x/core with colocated filesystem unit tests covering path resolution, canonical permission paths, binary detection, read/write/edit behavior, glob, and grep. --- apps/x/LIVE_NOTE.md | 12 +- apps/x/apps/renderer/src/App.tsx | 1 + .../ai-elements/permission-request.tsx | 48 +- .../renderer/src/lib/chat-conversation.ts | 26 +- apps/x/packages/core/package.json | 11 +- apps/x/packages/core/src/agents/runtime.ts | 171 ++++- .../src/application/assistant/instructions.ts | 68 +- .../assistant/skills/app-navigation/skill.ts | 2 +- .../assistant/skills/background-task/skill.ts | 4 +- .../assistant/skills/builtin-tools/skill.ts | 30 +- .../skills/create-presentations/skill.ts | 16 +- .../assistant/skills/doc-collab/skill.ts | 32 +- .../assistant/skills/draft-emails/skill.ts | 12 +- .../assistant/skills/live-note/skill.ts | 24 +- .../assistant/skills/mcp-integration/skill.ts | 2 +- .../assistant/skills/meeting-prep/skill.ts | 18 +- .../assistant/skills/notify-user/skill.ts | 2 +- .../core/src/application/lib/builtin-tools.ts | 409 +++-------- .../core/src/background-tasks/agent.ts | 4 +- .../core/src/background-tasks/runner.ts | 2 +- apps/x/packages/core/src/config/security.ts | 127 +++- .../core/src/filesystem/files.test.ts | 204 ++++++ apps/x/packages/core/src/filesystem/files.ts | 641 ++++++++++++++++++ .../core/src/knowledge/agent_notes_agent.ts | 20 +- .../core/src/knowledge/build_graph.ts | 8 +- .../core/src/knowledge/inline_task_agent.ts | 16 +- .../core/src/knowledge/label_emails.ts | 6 +- .../core/src/knowledge/labeling_agent.ts | 14 +- .../core/src/knowledge/live-note/agent.ts | 22 +- .../core/src/knowledge/live-note/runner.ts | 8 +- .../core/src/knowledge/note_creation.ts | 78 +-- .../core/src/knowledge/note_tagging_agent.ts | 14 +- .../packages/core/src/knowledge/tag_notes.ts | 6 +- .../core/src/pre_built/email-draft.md | 26 +- .../core/src/pre_built/meeting-prep.md | 26 +- apps/x/packages/core/src/runs/runs.ts | 9 +- apps/x/packages/core/tsconfig.build.json | 7 + apps/x/packages/core/vitest.config.ts | 11 + apps/x/packages/shared/src/runs.ts | 14 + apps/x/pnpm-lock.yaml | 238 +++++++ apps/x/pnpm-workspace.yaml | 3 + 41 files changed, 1777 insertions(+), 615 deletions(-) create mode 100644 apps/x/packages/core/src/filesystem/files.test.ts create mode 100644 apps/x/packages/core/src/filesystem/files.ts create mode 100644 apps/x/packages/core/tsconfig.build.json create mode 100644 apps/x/packages/core/vitest.config.ts diff --git a/apps/x/LIVE_NOTE.md b/apps/x/LIVE_NOTE.md index fe31d019..d8a157d7 100644 --- a/apps/x/LIVE_NOTE.md +++ b/apps/x/LIVE_NOTE.md @@ -70,7 +70,7 @@ The `once` trigger from the prior model has been **dropped** — it didn't fit t Two paths, both producing identical on-disk YAML: 1. **Hand-written** — type the `live:` block directly into the note's frontmatter. The scheduler picks it up on its next 15-second tick. -2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `workspace-edit`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. +2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `file-editText`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. When the note is **already live** and the user asks to track something new, Copilot extends the existing `live.objective` in natural-language prose. It does not create a second `live:` block. @@ -92,8 +92,8 @@ When a trigger fires, the live-note agent receives a short message: - For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it"). The agent's system prompt tells it to: -1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh). -2. Make small, **patch-style** edits with `workspace-edit` — change one region, re-read, change the next region — rather than one-shot rewrites. +1. Call `file-readText` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh). +2. Make small, **patch-style** edits with `file-editText` — change one region, re-read, change the next region — rather than one-shot rewrites. 3. Follow default body structure unless the objective overrides: H1 stays the title; a 1-3 sentence rolling summary at the top; H2 sub-topic sections below, freshest first. 4. Never modify YAML frontmatter — that's owned by the user and the runtime. 5. End with a 1-2 sentence summary stored as `lastRunSummary`. @@ -115,7 +115,7 @@ Backend (main process) ├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent └─ Builtin tool │ │ run-live-note-agent ────┘ ▼ - workspace-readFile / -edit + file-readText / -edit │ ▼ body region(s) rewritten on disk @@ -249,7 +249,7 @@ The contract (defined in the run-agent system prompt — `packages/core/src/know - Then content organized by sub-topic under H2 headings, freshest/most-important first. - Tightness over decoration. - **Override** — if the objective specifies a different layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly. -- **Patch-style updates** — make small, incremental `workspace-edit` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable. +- **Patch-style updates** — make small, incremental `file-editText` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable. - **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1. --- @@ -316,7 +316,7 @@ Every LLM-facing prompt in the feature, with file pointers. After any edit: `cd - **Purpose**: the user message seeded into each agent run. - **File**: `packages/core/src/knowledge/live-note/runner.ts` (`buildMessage`). - **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `live.objective`, `live.triggers?.eventMatchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`. -- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits. +- **Behavior**: tells the agent to call `file-readText` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits. Three branches by `trigger`: - **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` tool uses this path for both plain refreshes and context-biased backfills. diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 33eda548..080ddf58 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5677,6 +5677,7 @@ function App() { {rendered} handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} diff --git a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx index e9cef6dc..c0369a9a 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx @@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"; import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react"; import type { ComponentProps } from "react"; import { ToolCallPart } from "@x/shared/dist/message.js"; +import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; import z from "zod"; export type PermissionRequestProps = ComponentProps<"div"> & { @@ -22,6 +23,15 @@ export type PermissionRequestProps = ComponentProps<"div"> & { onDeny?: () => void; isProcessing?: boolean; response?: 'approve' | 'deny' | null; + permission?: z.infer; +}; + +const fileActionLabels: Record = { + read: "Read file", + list: "List folder", + search: "Search files", + write: "Write files", + delete: "Delete path", }; export const PermissionRequest = ({ @@ -33,14 +43,16 @@ export const PermissionRequest = ({ onDeny, isProcessing = false, response = null, + permission, ...props }: PermissionRequestProps) => { // Extract command from arguments if it's executeCommand - const command = toolCall.toolName === "executeCommand" + const command = permission?.kind === "command" || toolCall.toolName === "executeCommand" ? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments ? String(toolCall.arguments.command) : JSON.stringify(toolCall.arguments)) : null; + const filePermission = permission?.kind === "file" ? permission : null; const isResponded = response !== null; const isApproved = response === 'approve'; @@ -113,7 +125,35 @@ export const PermissionRequest = ({ )} - {!command && toolCall.arguments && ( + {filePermission && ( +
+
+

+ Action +

+

+ {fileActionLabels[filePermission.operation] ?? filePermission.operation} +

+
+
+

+ Path{filePermission.paths.length === 1 ? "" : "s"} +

+
+                    {filePermission.paths.join("\n")}
+                  
+
+
+

+ Approval Scope +

+
+                    {filePermission.pathPrefix}
+                  
+
+
+ )} + {!command && !filePermission && toolCall.arguments && (

Arguments @@ -133,12 +173,12 @@ export const PermissionRequest = ({ size="sm" onClick={onApprove} disabled={isProcessing} - className={cn("flex-1", command && "rounded-r-none")} + className={cn("flex-1", (command || filePermission) && "rounded-r-none")} > Approve - {command && ( + {(command || filePermission) && ( )} @@ -650,7 +666,7 @@ function EventDetailsPopover({ event, onClose }: { event: UpcomingEvent; onClose sideOffset={6} className="w-[min(380px,calc(100vw-32px))] rounded-lg p-0 shadow-xl" style={{ - backgroundColor: 'var(--popover, #fff)', + backgroundColor: 'var(--muted, #f4f4f5)', borderColor: 'var(--border, #e4e4e7)', color: 'var(--popover-foreground, #09090b)', }} @@ -664,7 +680,7 @@ function EventDetailsPopover({ event, onClose }: { event: UpcomingEvent; onClose style={{ color: 'var(--muted-foreground, #71717a)' }} aria-label="Open in Google Calendar" title="Open in Google Calendar" - onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--muted, #f4f4f5)' }} + onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--background, #ffffff)' }} onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }} > @@ -677,7 +693,7 @@ function EventDetailsPopover({ event, onClose }: { event: UpcomingEvent; onClose style={{ color: 'var(--muted-foreground, #71717a)' }} aria-label="Close event details" title="Close" - onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--muted, #f4f4f5)' }} + onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--background, #ffffff)' }} onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }} > @@ -771,41 +787,46 @@ function SplitJoinButton({ onJoinAndNotes, onNotesOnly }: { onNotesOnly: () => void }) { const [open, setOpen] = useState(false) - const ref = useRef(null) + const containerRef = useRef(null) + const menuRef = useRef(null) + // Fixed-position coords for the portaled menu so it isn't clipped by the + // calendar card's `overflow-hidden`. + const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null) + + const updatePos = useCallback(() => { + const rect = containerRef.current?.getBoundingClientRect() + if (!rect) return + setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right }) + }, []) useEffect(() => { if (!open) return + updatePos() const handler = (e: MouseEvent) => { const target = e.target - if (ref.current && target instanceof globalThis.Node && !ref.current.contains(target)) { - setOpen(false) - } + if (!(target instanceof globalThis.Node)) return + if (containerRef.current?.contains(target) || menuRef.current?.contains(target)) return + setOpen(false) } document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [open]) + window.addEventListener('resize', updatePos) + window.addEventListener('scroll', updatePos, true) + return () => { + document.removeEventListener('mousedown', handler) + window.removeEventListener('resize', updatePos) + window.removeEventListener('scroll', updatePos, true) + } + }, [open, updatePos]) return ( -

+
- {open && ( -
- -
- )} + {open && menuPos + ? createPortal( +
+ +
, + document.body, + ) + : null}
) } From 83389e93fc02d7fa56e1d37a8c791a3646020d5f Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 25 May 2026 20:04:52 +0530 Subject: [PATCH 12/16] fixed multiple recepients / attachments in email issue --- apps/x/apps/renderer/src/App.css | 1 + .../renderer/src/components/email-view.tsx | 5 +++- .../packages/core/src/knowledge/sync_gmail.ts | 30 ++++++++++++++----- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 64bd9779..46763d5c 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -536,6 +536,7 @@ .gmail-message-from span, .gmail-message-to, +.gmail-message-cc, .gmail-message-date { color: var(--gm-text-muted); font-size: 12px; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 32f022a3..deed545c 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -998,7 +998,10 @@ function ThreadDetail({
{isExpanded ? ( -
to {message.to || 'me'}
+ <> +
to {message.to || 'me'}
+ {message.cc &&
cc {message.cc}
} + ) : (
{snippet(message.body)}
)} diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 1f98736a..6b131a5d 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -31,9 +31,16 @@ const MAX_THREADS_IN_DIGEST = 10; const RECENT_BACKFILL_INTERVAL_MS = 15 * 60 * 1000; const nhm = new NodeHtmlMarkdown(); +// Bump whenever snapshot-building logic changes in a way that should invalidate +// previously cached snapshots (e.g. attachment / recipient parsing fixes). The +// short-circuit in buildAndCacheSnapshot only reuses a cache whose version matches, +// so stale entries are transparently rebuilt on the next sync. +const SNAPSHOT_PARSER_VERSION = 2; + interface SnapshotCacheEntry { historyId: string; fetchedAt: string; + parserVersion?: number; snapshot: GmailThreadSnapshot; } @@ -56,6 +63,7 @@ function writeCachedSnapshot(threadId: string, historyId: string, snapshot: Gmai const entry: SnapshotCacheEntry = { historyId, fetchedAt: new Date().toISOString(), + parserVersion: SNAPSHOT_PARSER_VERSION, snapshot, }; fs.writeFileSync(cachePath(threadId), JSON.stringify(entry), 'utf-8'); @@ -308,19 +316,24 @@ interface ExtractedAttachment { * saveAttachment / processThread, so the renderer can hand them to * shell.openPath via the existing IPC. */ -function extractAttachments(msgId: string, payload: gmail.Schema$MessagePart): ExtractedAttachment[] { +function extractAttachments(msgId: string, payload: gmail.Schema$MessagePart, html?: string): ExtractedAttachment[] { const out: ExtractedAttachment[] = []; const walk = (part: gmail.Schema$MessagePart): void => { const filename = part.filename; const attId = part.body?.attachmentId; if (filename && attId) { - // Exclude only true inline images (image/* with a Content-ID, which - // get baked into bodyHtml as data URLs by inlineCidImages). Other - // parts with Content-ID — PDFs, .log files, .ics, etc. — are real - // attachments; Gmail just stamps Content-ID on most parts. - const cid = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value; + // Exclude only images that are genuinely inline — i.e. their Content-ID + // is actually referenced via `cid:` in the HTML body, so inlineCidImages + // already baked them in as data URLs. Gmail stamps a Content-ID on most + // parts (including real, separately-attached images like screenshots or + // scanned docs), so a Content-ID alone must NOT exclude an attachment; + // otherwise attached images silently disappear from the thread view. + const cidRaw = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value; + const cid = cidRaw?.replace(/^<|>$/g, '').trim(); const mime = part.mimeType || ''; - const isInlineImage = !!cid && mime.startsWith('image/'); + const referencedInHtml = !!cid && !!html + && new RegExp(`cid:${cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i').test(html); + const isInlineImage = mime.startsWith('image/') && referencedInHtml; if (!isInlineImage) { const safeName = `${msgId}_${cleanFilename(filename)}`; out.push({ @@ -577,6 +590,7 @@ async function buildAndCacheSnapshot( threadData.historyId && cached && cached.historyId === threadData.historyId && + cached.parserVersion === SNAPSHOT_PARSER_VERSION && cached.snapshot.importance ) { return cached.snapshot; @@ -602,7 +616,7 @@ async function buildAndCacheSnapshot( } } const isDraft = msg.labelIds?.includes('DRAFT') ?? false; - const attachments = msg.payload && msg.id ? extractAttachments(msg.id, msg.payload) : []; + const attachments = msg.payload && msg.id ? extractAttachments(msg.id, msg.payload, parts.html) : []; return { id: msg.id || undefined, from: headerValue(headers, 'From') || 'Unknown', From a4697fc281e8a9dfc00fbaa1da93d77cd39eab89 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 25 May 2026 20:05:39 +0530 Subject: [PATCH 13/16] dont sync working location --- apps/x/packages/core/src/knowledge/sync_calendar.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index 84e25a00..a010295a 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -30,6 +30,10 @@ function formatEventTime(event: AnyEvent): string { return `${startStr} → ${endStr}`; } +function shouldSyncCalendarEvent(event: cal.Schema$Event): boolean { + return event.eventType !== 'workingLocation'; +} + function formatEventBlock(event: AnyEvent, label: 'NEW' | 'UPDATED'): string { const id = getStr(event, 'id') ?? '(unknown id)'; const title = getStr(event, 'summary') ?? '(no title)'; @@ -347,6 +351,9 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD console.log(`Found ${events.length} events.`); for (const event of events) { if (event.id) { + if (!shouldSyncCalendarEvent(event)) { + continue; + } const result = await saveEvent(event, syncDir); const attachmentsSaved = await processAttachments(drive, event, syncDir); currentEventIds.add(event.id); From a79263c5efde49b729f35b61aadcc4fe11da294b Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 25 May 2026 20:49:00 +0530 Subject: [PATCH 14/16] fix email drafts --- .../core/src/knowledge/classify_thread.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/x/packages/core/src/knowledge/classify_thread.ts b/apps/x/packages/core/src/knowledge/classify_thread.ts index 01fc7731..faf72322 100644 --- a/apps/x/packages/core/src/knowledge/classify_thread.ts +++ b/apps/x/packages/core/src/knowledge/classify_thread.ts @@ -109,7 +109,7 @@ export interface Classification { const ClassificationSchema = z.object({ importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'), summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'), - draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'), + draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'), }); const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user. @@ -128,7 +128,18 @@ When the thread is important, write a 1-2 sentence summary that captures the gis When the thread is important AND a reply is reasonably expected from the user, write a complete draft reply they could send as-is. -Apply the user's email-style guide (when provided below) — match their tone, sign-off, length, and phrasing patterns. If no style guide is provided, default to a brief, warm, professional voice. +Format it like a real email, not one run-on block. Use actual line breaks: put the greeting on its own line, separate distinct paragraphs with a blank line, and put the sign-off and the name on their own lines. The example below illustrates only the line-break structure — not the wording, tone, greeting, or sign-off to use: + +Hi Tyrone, + +Thanks for the follow-up — sorry I missed your earlier note. + +Could you resend it with a bit more context so I can get back to you properly? + +Best, +John + +When an email-style guide is provided below, it takes precedence: follow it for greeting, tone, sign-off, length, and phrasing patterns (while keeping the line-break structure shown above). If no style guide is provided, default to a brief, warm, professional voice. For scheduling-related threads (where the sender proposes meeting times, asks for the user's availability, or follows up on a meeting request), look at the user's upcoming calendar (provided below) and either: - Propose 2-3 specific time windows from genuinely free slots, or @@ -144,10 +155,12 @@ Omit the draft when: Be decisive — pick exactly one importance label. Do not hedge.`; -function userReplied(snapshot: GmailThreadSnapshot, userEmail: string | null): boolean { +function userSentLatest(snapshot: GmailThreadSnapshot, userEmail: string | null): boolean { if (!userEmail) return false; + const latest = snapshot.messages[snapshot.messages.length - 1]; + if (!latest) return false; const needle = userEmail.toLowerCase(); - return snapshot.messages.some(m => (m.from || '').toLowerCase().includes(needle)); + return (latest.from || '').toLowerCase().includes(needle); } function buildPrompt( @@ -206,7 +219,7 @@ export async function classifyThread( userEmail: string | null, options: ClassifyOptions = {}, ): Promise { - if (userReplied(snapshot, userEmail)) { + if (userSentLatest(snapshot, userEmail)) { return { importance: 'important' }; } From 23e1a89c9e49971349a7d068246ec55b3e6c5795 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 25 May 2026 21:11:55 +0530 Subject: [PATCH 15/16] add dismiss button for middle pane --- apps/x/apps/renderer/src/App.tsx | 50 ++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 080ddf58..04359de5 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon, X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -779,6 +779,14 @@ function App() { const [graphError, setGraphError] = useState(null) const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true) const [isRightPaneMaximized, setIsRightPaneMaximized] = useState(false) + // Middle-pane collapse animation. Animating its max-width from 100% is janky: + // 100% is relative to the parent (far wider than the pane's real width), so the + // transition spends its first frames non-binding (nothing moves) then snaps shut. + // Instead we snapshot the pane's real px width before it collapses and drive the + // transition from that value. + const [insetCollapseFromPx, setInsetCollapseFromPx] = useState(null) + const [insetMaxWidth, setInsetMaxWidth] = useState('100%') + const [insetAnimateMaxWidth, setInsetAnimateMaxWidth] = useState(true) // Live-note panel: bound to a single note path. Mounted as a sibling of the // markdown editor so it shares the layout (no overlap with chat) and // auto-closes when the active note changes. @@ -3299,7 +3307,15 @@ function App() { const toggleRightPaneMaximize = useCallback(() => { setIsChatSidebarOpen(true) - setIsRightPaneMaximized(prev => !prev) + setIsRightPaneMaximized(prev => { + if (!prev) { + // About to collapse the middle pane: capture its real width now, while it's + // still laid out, so the collapse can animate from a binding px value. + const px = document.querySelector('[data-slot="sidebar-inset"]')?.getBoundingClientRect().width + setInsetCollapseFromPx(px && px > 0 ? px : null) + } + return !prev + }) }, []) const handleOpenFullScreenChat = useCallback(() => { @@ -5115,6 +5131,27 @@ function App() { const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode + // Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's + // binding immediately (no flex jump), then animate to 0. Expanding goes back to 100% + // — its non-binding range lands at the end of the range, where it isn't visible. + useLayoutEffect(() => { + if (!shouldCollapseLeftPane) { + setInsetAnimateMaxWidth(true) + setInsetMaxWidth('100%') + return + } + if (insetCollapseFromPx == null) { + setInsetMaxWidth('0px') + return + } + setInsetAnimateMaxWidth(false) + setInsetMaxWidth(`${insetCollapseFromPx}px`) + const id = requestAnimationFrame(() => { + setInsetAnimateMaxWidth(true) + setInsetMaxWidth('0px') + }) + return () => cancelAnimationFrame(id) + }, [shouldCollapseLeftPane, insetCollapseFromPx]) const openMarkdownTabs = React.useMemo(() => { const markdownTabs = fileTabs.filter(tab => tab.path.endsWith('.md')) if (selectedPath?.endsWith('.md')) { @@ -5170,10 +5207,11 @@ function App() { /> setActiveShortcutPane('left')} onFocusCapture={() => setActiveShortcutPane('left')} @@ -5284,7 +5322,9 @@ function App() { ? { onClick: pushChatToSidePane, icon: , label: 'Dock chat to side pane' } : (viewOpen && !isChatSidebarOpen) ? { onClick: openChatSidePane, icon: , label: 'Open chat' } - : null + : (viewOpen && isChatSidebarOpen && !isRightPaneMaximized) + ? { onClick: toggleRightPaneMaximize, icon: , label: 'Expand chat' } + : null return ( From 31ec625bef2d4ca9b953264a10fdeb3248e2be86 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 25 May 2026 21:18:33 +0530 Subject: [PATCH 16/16] assistant welcome wording change --- apps/x/apps/renderer/src/components/chat-empty-state.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/x/apps/renderer/src/components/chat-empty-state.tsx b/apps/x/apps/renderer/src/components/chat-empty-state.tsx index 8febf651..bb9cea8d 100644 --- a/apps/x/apps/renderer/src/components/chat-empty-state.tsx +++ b/apps/x/apps/renderer/src/components/chat-empty-state.tsx @@ -43,7 +43,7 @@ export function ChatEmptyState({
-
How can I help?
+
What are we working on?
Ask anything, or pick up where you left off.