mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-27 17:36:25 +02:00
feat: add syncing update for graph building on the UI
This commit is contained in:
parent
6425dbcf28
commit
eefc6a9700
13 changed files with 1093 additions and 163 deletions
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue