feat: add syncing update for graph building on the UI

This commit is contained in:
tusharmagar 2026-02-05 16:05:10 +05:30
parent 6425dbcf28
commit eefc6a9700
13 changed files with 1093 additions and 163 deletions

View file

@ -12,10 +12,12 @@ import { workspace as workspaceShared } from '@x/shared';
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
import * as runsCore from '@x/core/dist/runs/runs.js';
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 z from 'zod';
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 } from '@x/core/dist/models/models.js';
@ -218,6 +220,15 @@ function emitRunEvent(event: z.infer<typeof RunEvent>): void {
}
}
function emitServiceEvent(event: z.infer<typeof ServiceEvent>): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('services:events', event);
}
}
}
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
@ -237,6 +248,16 @@ export async function startRunsWatcher(): Promise<void> {
});
}
let servicesWatcher: (() => void) | null = null;
export async function startServicesWatcher(): Promise<void> {
if (servicesWatcher) {
return;
}
servicesWatcher = await serviceBus.subscribe(async (event) => {
emitServiceEvent(event);
});
}
// ============================================================================
// Handler Implementations
// ============================================================================

View file

@ -1,6 +1,6 @@
import { app, BrowserWindow, protocol, net, shell } from "electron";
import path from "node:path";
import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js";
import { setupIpcHandlers, startRunsWatcher, startServicesWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname } from "node:path";
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
@ -143,6 +143,9 @@ app.whenReady().then(async () => {
// start runs watcher
startRunsWatcher();
// start services watcher
startServicesWatcher();
// start gmail sync
initGmailSync();
@ -180,4 +183,4 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => {
// Clean up watcher on app quit
stopWorkspaceWatcher();
});
});

View file

@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { useEffect, useRef, useState } from "react"
import {
Bot,
ChevronRight,
@ -15,6 +15,7 @@ import {
Mic,
Network,
Pencil,
LoaderIcon,
Square,
SquarePen,
Trash2,
@ -28,6 +29,7 @@ import {
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
@ -36,6 +38,7 @@ import {
SidebarMenuItem,
SidebarMenuSub,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar"
import {
Tooltip,
@ -52,6 +55,8 @@ import {
import { Input } from "@/components/ui/input"
import { useSidebarSection } from "@/contexts/sidebar-context"
import { toast } from "@/lib/toast"
import { ServiceEvent } from "@x/shared/src/service-events.js"
import z from "zod"
interface TreeNode {
path: string
@ -96,6 +101,11 @@ type BackgroundTaskItem = {
lastRunAt?: string | null
}
type ServiceEventType = z.infer<typeof ServiceEvent>
const MAX_SYNC_EVENTS = 30
const RUN_STALE_MS = 2 * 60 * 60 * 1000
type TasksActions = {
onNewChat: () => void
onSelectRun: (runId: string) => void
@ -121,6 +131,117 @@ const sectionTitles = {
tasks: "Chats",
}
function formatEventTime(ts: string): string {
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return ""
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
}
function SyncStatusBar() {
const { state, isMobile } = useSidebar()
const [events, setEvents] = useState<ServiceEventType[]>([])
const [activeRuns, setActiveRuns] = useState<Set<string>>(new Set())
const [isExpanded, setIsExpanded] = useState(false)
const runTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
useEffect(() => {
const cleanup = window.ipc.on('services:events', (event) => {
const nextEvent = event as ServiceEventType
setEvents((prev) => [nextEvent, ...prev].slice(0, MAX_SYNC_EVENTS))
if (nextEvent.type === 'run_start') {
setActiveRuns((prev) => {
const next = new Set(prev)
next.add(nextEvent.runId)
return next
})
const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId)
if (existingTimeout) {
clearTimeout(existingTimeout)
}
const timeout = setTimeout(() => {
setActiveRuns((prev) => {
if (!prev.has(nextEvent.runId)) return prev
const next = new Set(prev)
next.delete(nextEvent.runId)
return next
})
runTimeoutsRef.current.delete(nextEvent.runId)
}, RUN_STALE_MS)
runTimeoutsRef.current.set(nextEvent.runId, timeout)
} else if (nextEvent.type === 'run_complete') {
setActiveRuns((prev) => {
const next = new Set(prev)
next.delete(nextEvent.runId)
return next
})
const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId)
if (existingTimeout) {
clearTimeout(existingTimeout)
runTimeoutsRef.current.delete(nextEvent.runId)
}
}
})
return cleanup
}, [])
useEffect(() => {
return () => {
runTimeoutsRef.current.forEach((timeout) => clearTimeout(timeout))
runTimeoutsRef.current.clear()
}
}, [])
const isSyncing = activeRuns.size > 0
const isCollapsed = state === "collapsed"
return (
<>
{!isMobile && isCollapsed && isSyncing && (
<div
className="fixed bottom-4 z-40 flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background shadow-sm"
style={{ left: "calc(var(--sidebar-offset) + 0.5rem)" }}
aria-label="Syncing"
>
<LoaderIcon className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
<SidebarFooter className="border-t border-sidebar-border px-2 py-2">
<button
type="button"
onClick={() => setIsExpanded((prev) => !prev)}
className="flex w-full items-center justify-between rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-sidebar-accent"
>
<span className="flex items-center gap-2">
{isSyncing ? (
<LoaderIcon className="h-3 w-3 animate-spin" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/60" />
)}
{isSyncing ? "Syncing" : "All caught up"}
</span>
<ChevronRight className={`h-3 w-3 transition-transform ${isExpanded ? "rotate-90" : ""}`} />
</button>
{isExpanded && (
<div className="mt-2 max-h-40 space-y-1 overflow-auto rounded-md border border-border bg-background p-2 text-xs text-muted-foreground">
{events.length === 0 ? (
<div>No recent activity.</div>
) : (
events.map((event, idx) => (
<div key={`${event.runId}-${event.ts}-${idx}`} className="flex items-start gap-2">
<span className="shrink-0 text-[10px] text-muted-foreground/70">
{formatEventTime(event.ts)}
</span>
<span className="leading-4">{event.message}</span>
</div>
))
)}
</div>
)}
</SidebarFooter>
</>
)
}
export function SidebarContentPanel({
tree,
selectedPath,
@ -165,6 +286,7 @@ export function SidebarContentPanel({
/>
)}
</SidebarContent>
<SyncStatusBar />
<SidebarRail />
</Sidebar>
)
@ -779,4 +901,3 @@ function TasksSection({
</SidebarGroup>
)
}