Compare commits

...

3 Commits

10 changed files with 421 additions and 96 deletions

View File

@@ -21,18 +21,24 @@ import type { WsFloatingRequest } from '../../shared/api-types';
const AI_STREAM_CHANNEL = 'ai:stream';
const BRIEF_SCHEDULER_INTERVAL_MS = 60_000;
const BRIEF_REGEN_DEBOUNCE_MS = 1_500;
const NOOP = () => undefined;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface OrchestrateInput {
message: string;
requestId?: string;
conversationHistory?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>;
sender?: Electron.WebContents;
}
interface OrchestrateFloatingInput {
message: string;
requestId?: string;
scope: WsFloatingRequest['scope'];
sender?: Electron.WebContents;
}
@@ -42,6 +48,10 @@ interface OrchestrateResult {
error?: string;
}
let briefSchedulerTimer: ReturnType<typeof setInterval> | null = null;
let briefRegenerationTimer: ReturnType<typeof setTimeout> | null = null;
let lastGeneratedSlotKey: string | null = null;
// ---------------------------------------------------------------------------
// IPC helper
// ---------------------------------------------------------------------------
@@ -73,18 +83,18 @@ async function checkConnectivity(): Promise<{ ok: true } | { ok: false; error: s
// ---------------------------------------------------------------------------
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
const { message, conversationHistory, sender } = input;
const { message, requestId, conversationHistory, sender } = input;
const check = await checkConnectivity();
if (!check.ok) return { response: '', error: check.error };
try {
const client = getBackendClient();
const { requestId, promise } = client.sendHomeRequest(message, conversationHistory, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId, chunk }),
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId, mutations: mutations as unknown[] | undefined }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
const { requestId: activeRequestId, promise } = client.sendHomeRequest(message, conversationHistory, requestId, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId: activeRequestId }),
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId: activeRequestId, chunk }),
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId, mutations: mutations as unknown[] | undefined }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId }),
});
await promise;
@@ -109,19 +119,19 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
// ---------------------------------------------------------------------------
export async function orchestrateFloating(input: OrchestrateFloatingInput): Promise<OrchestrateResult> {
const { message, scope, sender } = input;
const { message, requestId, scope, sender } = input;
const check = await checkConnectivity();
if (!check.ok) return { response: '', error: check.error };
try {
const client = getBackendClient();
const { requestId, promise } = client.sendFloatingRequest(message, scope, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId, chunk }),
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId, mutations: mutations as unknown[] | undefined }),
onDomain: (domain) => sendFrame(sender, { type: 'floating_domain', requestId, domain }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
const { requestId: activeRequestId, promise } = client.sendFloatingRequest(message, scope, requestId, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId: activeRequestId }),
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId: activeRequestId, chunk }),
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId, mutations: mutations as unknown[] | undefined }),
onDomain: (domain) => sendFrame(sender, { type: 'floating_domain', requestId: activeRequestId, domain }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId }),
});
await promise;
@@ -162,6 +172,25 @@ function todayString(): string {
return new Date().toISOString().slice(0, 10);
}
function getBriefTimeSlot(now: Date): string {
const minutes = now.getHours() * 60 + now.getMinutes();
if (minutes < 6 * 60) return 'night';
if (minutes < 9 * 60) return 'morning';
if (minutes < 11 * 60 + 30) return 'mid_morning';
if (minutes < 14 * 60) return 'lunch';
if (minutes < 17 * 60) return 'afternoon';
if (minutes < 21 * 60) return 'evening';
return 'night';
}
function getCurrentSlotKey(now = new Date()): string {
return `${todayString()}:${getBriefTimeSlot(now)}`;
}
function markCurrentSlotAsGenerated(): void {
lastGeneratedSlotKey = getCurrentSlotKey();
}
/** Returns cached brief content if it was generated today, otherwise null. */
export function getCachedBrief(): string | null {
const cache = getStore().get('dailyBriefCache');
@@ -172,40 +201,85 @@ export function getCachedBrief(): string | null {
/** Invalidate the cache so the next home visit triggers a fresh generation. */
export function invalidateBriefCache(): void {
getStore().set('dailyBriefCache', null);
scheduleBriefRegeneration();
}
function scheduleBriefRegeneration(delayMs = BRIEF_REGEN_DEBOUNCE_MS): void {
if (briefRegenerationTimer) {
clearTimeout(briefRegenerationTimer);
}
briefRegenerationTimer = setTimeout(() => {
briefRegenerationTimer = null;
void generateAndCacheBrief();
}, delayMs);
}
/** Regenerate the brief silently in background, cache it, then push to all windows. */
export async function generateAndCacheBrief(): Promise<void> {
export async function generateAndCacheBrief(): Promise<boolean> {
const check = await checkConnectivity();
if (!check.ok) return;
if (!check.ok) return false;
let content = '';
try {
const client = getBackendClient();
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, {
onStart: () => {},
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, undefined, {
onStart: NOOP,
onText: (chunk) => { content += chunk; },
onEnd: () => {},
onError: () => {},
onEnd: NOOP,
onError: NOOP,
});
await promise;
} catch {
return;
return false;
}
if (!content) return;
if (!content) return false;
getStore().set('dailyBriefCache', { content, date: todayString() });
markCurrentSlotAsGenerated();
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('ai:brief-updated', content);
}
}
return true;
}
export function startBriefScheduler(): void {
if (briefSchedulerTimer) return;
const tick = async () => {
if (BrowserWindow.getAllWindows().length === 0) return;
const slotKey = getCurrentSlotKey();
if (slotKey === lastGeneratedSlotKey) return;
const generated = await generateAndCacheBrief();
if (!generated) return;
};
briefSchedulerTimer = setInterval(() => {
void tick();
}, BRIEF_SCHEDULER_INTERVAL_MS);
// Run once on startup so the current slot has a fresh brief immediately.
void tick();
}
export function stopBriefScheduler(): void {
if (briefSchedulerTimer) {
clearInterval(briefSchedulerTimer);
briefSchedulerTimer = null;
}
if (briefRegenerationTimer) {
clearTimeout(briefRegenerationTimer);
briefRegenerationTimer = null;
}
}
/** Stream the daily brief to the renderer and cache the result. */
export async function dailyBrief(sender?: Electron.WebContents): Promise<OrchestrateResult> {
export async function dailyBrief(sender?: Electron.WebContents, requestId?: string): Promise<OrchestrateResult> {
let content = '';
const check = await checkConnectivity();
@@ -213,15 +287,15 @@ export async function dailyBrief(sender?: Electron.WebContents): Promise<Orchest
try {
const client = getBackendClient();
const requestId = crypto.randomUUID();
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
const activeRequestId = requestId ?? crypto.randomUUID();
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, activeRequestId, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId: activeRequestId }),
onText: (chunk) => {
content += chunk;
sendFrame(sender, { type: 'stream_text', requestId, chunk });
sendFrame(sender, { type: 'stream_text', requestId: activeRequestId, chunk });
},
onEnd: () => sendFrame(sender, { type: 'stream_end', requestId }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
onEnd: () => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId }),
});
await promise;
} catch (err) {
@@ -233,6 +307,7 @@ export async function dailyBrief(sender?: Electron.WebContents): Promise<Orchest
if (content) {
getStore().set('dailyBriefCache', { content, date: todayString() });
markCurrentSlotAsGenerated();
}
return { response: 'ok' };

View File

@@ -30,6 +30,7 @@ import type {
WsAgentData,
LocalAgentConfig,
WsFloatingRequest,
WsFloatingDomain,
} from '../../shared/api-types';
import { DrizzleExecutor } from './drizzle-executor';
import { readAgentFiles } from '../agents/file-reader';
@@ -123,7 +124,7 @@ interface StreamListener {
onStart: () => void;
onText: (chunk: string) => void;
onEnd: (mutations?: unknown) => void;
onDomain: (domain: string) => void;
onDomain: (domain: WsFloatingDomain['domain']) => void;
onError: (err: Error) => void;
resolve: () => void;
reject: (err: Error) => void;
@@ -233,22 +234,23 @@ export class BackendClient {
sendHomeRequest(
message: string,
conversationHistory?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
requestId?: string,
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
): { requestId: string; promise: Promise<void> } {
const requestId = crypto.randomUUID();
const activeRequestId = requestId ?? crypto.randomUUID();
const promise = new Promise<void>((resolve, reject) => {
this.streamListeners.set(requestId, {
this.streamListeners.set(activeRequestId, {
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
onEnd: (mutations) => {
callbacks?.onEnd?.(mutations);
this.streamListeners.delete(requestId);
this.streamListeners.delete(activeRequestId);
resolve();
},
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
onError: (err) => {
callbacks?.onError?.(err);
this.streamListeners.delete(requestId);
this.streamListeners.delete(activeRequestId);
reject(err);
},
resolve,
@@ -257,17 +259,17 @@ export class BackendClient {
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.streamListeners.delete(requestId);
this.streamListeners.delete(activeRequestId);
reject(new OfflineError('Persistent WS not connected'));
return;
}
const homePayload = toSnakeCase({ type: 'home_request', requestId, message, conversationHistory });
const homePayload = toSnakeCase({ type: 'home_request', requestId: activeRequestId, message, conversationHistory });
logWsSend(homePayload);
ws.send(JSON.stringify(homePayload));
});
return { requestId, promise };
return { requestId: activeRequestId, promise };
}
/**
@@ -277,22 +279,23 @@ export class BackendClient {
sendFloatingRequest(
message: string,
scope: WsFloatingRequest['scope'],
requestId?: string,
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
): { requestId: string; promise: Promise<void> } {
const requestId = crypto.randomUUID();
const activeRequestId = requestId ?? crypto.randomUUID();
const promise = new Promise<void>((resolve, reject) => {
this.streamListeners.set(requestId, {
this.streamListeners.set(activeRequestId, {
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
onEnd: (mutations) => {
callbacks?.onEnd?.(mutations);
this.streamListeners.delete(requestId);
this.streamListeners.delete(activeRequestId);
resolve();
},
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
onError: (err) => {
callbacks?.onError?.(err);
this.streamListeners.delete(requestId);
this.streamListeners.delete(activeRequestId);
reject(err);
},
resolve,
@@ -301,17 +304,17 @@ export class BackendClient {
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.streamListeners.delete(requestId);
this.streamListeners.delete(activeRequestId);
reject(new OfflineError('Persistent WS not connected'));
return;
}
const floatingPayload = toSnakeCase({ type: 'floating_request', requestId, message, scope });
const floatingPayload = toSnakeCase({ type: 'floating_request', requestId: activeRequestId, message, scope });
logWsSend(floatingPayload);
ws.send(JSON.stringify(floatingPayload));
});
return { requestId, promise };
return { requestId: activeRequestId, promise };
}
// -------------------------------------------------------------------------

View File

@@ -10,6 +10,7 @@ import { getBackendClient } from './api/backend-client';
import { getBackupManager } from './backup/backup-manager';
import { getSyncQueue } from './backup/sync-queue';
import { getStore } from './store';
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
@@ -87,10 +88,13 @@ app.on('ready', () => {
}
})
.catch((err) => console.error('[DeviceWS] Startup connect failed:', err));
startBriefScheduler();
});
// Clean up the persistent WS and backup timers before the app exits
app.on('will-quit', () => {
stopBriefScheduler();
getBackupManager().stopPeriodicBackup();
getBackendClient().disconnectPersistent();
});

View File

@@ -15,12 +15,29 @@ import type { TRPCContext } from '../ipc';
const t = initTRPC.context<TRPCContext>().create();
/** Returns true if the given Unix-ms timestamp falls on today's calendar date. */
function isDueToday(ts: number | null | undefined): boolean {
function isInCurrentWeek(ts: number | null | undefined): boolean {
if (!ts) return false;
const d = new Date(ts);
const now = new Date();
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
const start = new Date(now);
const day = start.getDay();
const daysSinceMonday = (day + 6) % 7;
start.setDate(start.getDate() - daysSinceMonday);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 7);
end.setMilliseconds(end.getMilliseconds() - 1);
return ts >= start.getTime() && ts <= end.getTime();
}
function isBriefRelevantTask(dueDate: number | null | undefined, status: string | null | undefined): boolean {
if (!isInCurrentWeek(dueDate)) return false;
return status !== 'done';
}
function isBriefRelevantTimeline(date: number | null | undefined): boolean {
return isInCurrentWeek(date);
}
const router = t.router;
@@ -331,7 +348,8 @@ const tasksRouter = router({
isApproved: input.isApproved ?? 1,
createdAt: now,
}).run();
if (isDueToday(input.dueDate)) invalidateBriefCache();
const nextStatus = input.status ?? 'todo';
if (isBriefRelevantTask(input.dueDate, nextStatus)) invalidateBriefCache();
return { id };
}),
@@ -348,6 +366,12 @@ const tasksRouter = router({
isApproved: z.number().optional(),
}))
.mutation(({ input }) => {
const prev = getDb()
.select({ dueDate: tasks.dueDate, status: tasks.status })
.from(tasks)
.where(eq(tasks.id, input.id))
.all()[0];
const set: Partial<{
title: string;
description: string | null;
@@ -369,14 +393,27 @@ const tasksRouter = router({
if (Object.keys(set).length > 0) {
getDb().update(tasks).set(set).where(eq(tasks.id, input.id)).run();
}
if (isDueToday(input.dueDate)) invalidateBriefCache();
const prevRelevant = isBriefRelevantTask(prev?.dueDate, prev?.status);
const nextDueDate = input.dueDate !== undefined ? input.dueDate : prev?.dueDate;
const nextStatus = input.status !== undefined ? input.status : prev?.status;
const nextRelevant = isBriefRelevantTask(nextDueDate, nextStatus);
if (prevRelevant || nextRelevant) invalidateBriefCache();
return null;
}),
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(({ input }) => {
const prev = getDb()
.select({ dueDate: tasks.dueDate, status: tasks.status })
.from(tasks)
.where(eq(tasks.id, input.id))
.all()[0];
getDb().delete(tasks).where(eq(tasks.id, input.id)).run();
if (isBriefRelevantTask(prev?.dueDate, prev?.status)) invalidateBriefCache();
return { success: true as const };
}),
@@ -436,7 +473,7 @@ const timelineEventsRouter = router({
isAiSuggested: input.isAiSuggested ?? 0,
createdAt: now,
}).run();
if (isDueToday(input.date)) invalidateBriefCache();
if (isBriefRelevantTimeline(input.date)) invalidateBriefCache();
return { id };
}),
@@ -449,6 +486,12 @@ const timelineEventsRouter = router({
isCompleted: z.number().optional(),
}))
.mutation(({ input }) => {
const prev = getDb()
.select({ date: timelineEvents.date })
.from(timelineEvents)
.where(eq(timelineEvents.id, input.id))
.all()[0];
const set: Partial<{ title: string; date: number; endDate: number | null; isCompleted: number }> = {};
if (input.title !== undefined) set.title = input.title;
if (input.date !== undefined) set.date = input.date;
@@ -457,14 +500,26 @@ const timelineEventsRouter = router({
if (Object.keys(set).length > 0) {
getDb().update(timelineEvents).set(set).where(eq(timelineEvents.id, input.id)).run();
}
if (isDueToday(input.date)) invalidateBriefCache();
const prevRelevant = isBriefRelevantTimeline(prev?.date);
const nextDate = input.date !== undefined ? input.date : prev?.date;
const nextRelevant = isBriefRelevantTimeline(nextDate);
if (prevRelevant || nextRelevant) invalidateBriefCache();
return null;
}),
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(({ input }) => {
const prev = getDb()
.select({ date: timelineEvents.date })
.from(timelineEvents)
.where(eq(timelineEvents.id, input.id))
.all()[0];
getDb().delete(timelineEvents).where(eq(timelineEvents.id, input.id)).run();
if (isBriefRelevantTimeline(prev?.date)) invalidateBriefCache();
return { success: true as const };
}),
});
@@ -594,6 +649,7 @@ const settingsRouter = router({
const aiRouter = router({
chat: publicProcedure
.input(z.object({
requestId: z.string().optional(),
message: z.string(),
conversationHistory: z.array(z.object({
role: z.enum(['user', 'assistant', 'system']),
@@ -610,12 +666,14 @@ const aiRouter = router({
if (input.mode === 'floating' && input.scope) {
return await orchestrateFloating({
message: input.message,
requestId: input.requestId,
scope: input.scope,
sender: ctx.sender,
});
}
return await orchestrate({
message: input.message,
requestId: input.requestId,
conversationHistory: input.conversationHistory,
sender: ctx.sender,
});
@@ -628,9 +686,10 @@ const aiRouter = router({
.query(() => getCachedBrief()),
dailyBrief: publicProcedure
.mutation(async ({ ctx }) => {
.input(z.object({ requestId: z.string().optional() }).optional())
.mutation(async ({ ctx, input }) => {
try {
return await dailyBrief(ctx.sender);
return await dailyBrief(ctx.sender, input?.requestId);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
return { response: '', error: msg };

View File

@@ -26,7 +26,20 @@ type V3StreamEvent =
| { type: 'stream_start'; requestId: string }
| { type: 'stream_text'; requestId: string; chunk: string }
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'timelines' | 'projects' };
| {
type: 'floating_domain';
requestId: string;
domain:
| 'tasks'
| 'notes'
| 'timelines'
| 'projects'
| {
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
id?: string | null;
section?: 'task' | 'timeline' | 'note' | null;
};
};
contextBridge.exposeInMainWorld('electronAI', {
/** Subscribe to v3 AI stream events. Returns an unsubscribe function. */

View File

@@ -91,17 +91,19 @@ function mergeTimelineSegments(segments: ContentSegment[]): ContentSegment[] {
if (!uniqueTimelineIds.length) return segments;
const merged: ContentSegment[] = [];
let lastTimelineInsertIndex = 0;
for (const seg of segments) {
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
lastTimelineInsertIndex = merged.length;
continue;
}
merged.push(seg);
}
// Keep prose flow untouched, then append a single consolidated timeline block.
merged.push({ type: 'entity', entity: 'timeline', ids: uniqueTimelineIds });
// Keep prose flow untouched and place consolidated timeline at the last timeline tag position.
merged.splice(lastTimelineInsertIndex, 0, { type: 'entity', entity: 'timeline', ids: uniqueTimelineIds });
return merged;
}
@@ -238,6 +240,22 @@ export function AIChatPanel({
setDailyBrief(cached);
}, [cachedBriefQuery.data]);
// Listen for background brief refreshes pushed by the main process.
useEffect(() => {
if (!isHomePage) return;
const unsubscribe = window.electronAI.onBriefUpdated((content) => {
briefModule.content = content;
briefModule.streamFired = true;
setDailyBrief(content);
void utils.ai.getBrief.invalidate();
});
return () => {
unsubscribe?.();
};
}, [isHomePage, utils]);
// Stream a fresh brief when cache is empty (once per app session).
useEffect(() => {
if (!isHomePage || !authStatusQuery.data?.authenticated) return;
@@ -251,14 +269,10 @@ export function AIChatPanel({
if (briefModule.streamFired) return;
briefModule.streamFired = true;
briefContentRef.current = '';
const requestId = crypto.randomUUID();
let briefRequestId: string | null = null;
let unsubscribe: (() => void) | null = window.electronAI.onStreamEvent((event) => {
if (event.type === 'stream_start') {
briefRequestId = event.requestId;
return;
}
if (briefRequestId !== null && event.requestId !== briefRequestId) return;
if (event.requestId !== requestId) return;
if (event.type === 'stream_text') {
briefContentRef.current += event.chunk;
@@ -275,7 +289,7 @@ export function AIChatPanel({
}
});
briefMutation.mutate(undefined, {
briefMutation.mutate({ requestId }, {
onError: () => {
unsubscribe?.();
unsubscribe = null;
@@ -359,7 +373,7 @@ export function AIChatPanel({
className="overflow-hidden"
>
<div className="px-4 pb-3 max-h-64 overflow-y-auto">
<ChatMarkdown content={dailyBrief} />
<MessageContent content={dailyBrief} />
</div>
</motion.div>
)}
@@ -447,7 +461,7 @@ export function AIChatPanel({
<Skeleton className="h-5 w-2/3" />
</div>
) : dailyBrief ? (
<ChatMarkdown content={dailyBrief} size="lg" />
<MessageContent content={dailyBrief} fontSize={CHAT_FONT} />
) : null}
</motion.div>

View File

@@ -10,7 +10,7 @@ import {
CHAT_HEIGHT,
PADDING,
} from '@/context/FloatingChatContext';
import { useAIChat, type UIChatContext } from '@/hooks/useAIChat';
import { useAIChat, type UIChatContext, type FloatingDomainSignal } from '@/hooks/useAIChat';
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
import { Skeleton } from '@/components/ui/skeleton';
@@ -22,11 +22,63 @@ const DOMAIN_ROUTES: Record<string, string> = {
projects: '/projects',
};
const DOMAIN_SECTION_IDS: Partial<Record<'tasks' | 'notes' | 'timelines' | 'projects', string>> = {
tasks: 'tasks-list',
timelines: 'timeline-chart',
};
interface DomainNavigationTarget {
route: '/tasks' | '/timeline' | '/projects' | '/notes/$noteId';
sectionId?: string;
projectId?: string;
noteId?: string;
nodeId?: string;
}
function normalizeDomainSignal(domain: FloatingDomainSignal): DomainNavigationTarget | null {
if (typeof domain === 'string') {
const route = DOMAIN_ROUTES[domain];
if (!route) return null;
return {
route: route as DomainNavigationTarget['route'],
sectionId: DOMAIN_SECTION_IDS[domain as keyof typeof DOMAIN_SECTION_IDS],
};
}
switch (domain.type) {
case 'task':
return { route: '/tasks', sectionId: 'tasks-list' };
case 'timeline':
return { route: '/timeline', sectionId: 'timeline-chart' };
case 'note':
if (!domain.id) return { route: '/projects' };
return { route: '/notes/$noteId', noteId: domain.id };
case 'project': {
if (domain.section === 'task') {
return { route: '/projects', sectionId: 'project-tasks', projectId: domain.id ?? undefined };
}
if (domain.section === 'timeline') {
return { route: '/projects', sectionId: 'project-timeline', projectId: domain.id ?? undefined };
}
if (domain.section === 'note') {
return { route: '/projects', sectionId: 'project-notes', projectId: domain.id ?? undefined };
}
return { route: '/projects', projectId: domain.id ?? undefined };
}
case 'node':
if (!domain.id) return null;
return { route: '/projects', sectionId: domain.id, nodeId: domain.id };
default:
return null;
}
}
function FloatingChatInner() {
const { state, sections, close, updatePosition, setPendingSection } = useFloatingChat();
const { state, sections, close, updatePosition, setPendingSection, moveToSection } = useFloatingChat();
const navigate = useNavigate();
const routerState = useRouterState();
const prevPathRef = useRef(routerState.location.pathname);
const domainNavigationInFlightRef = useRef(false);
// Active section lookup
const activeSection = sections.get(state.activeSectionId ?? '');
@@ -55,17 +107,50 @@ function FloatingChatInner() {
// Handle floating_domain signals — navigate in background
const handleDomainSignal = useCallback(
(domain: 'tasks' | 'notes' | 'timelines' | 'projects') => {
const route = DOMAIN_ROUTES[domain];
if (!route) return;
(domainSignal: FloatingDomainSignal) => {
const target = normalizeDomainSignal(domainSignal);
if (!target) return;
// If backend points to a currently registered node/section, move there immediately.
if (target.sectionId && sections.has(target.sectionId)) {
moveToSection(target.sectionId);
return;
}
const currentPath = routerState.location.pathname;
if (currentPath === route) return;
const isCurrentRoute =
(target.route === '/projects' && currentPath === '/projects') ||
(target.route === '/tasks' && currentPath === '/tasks') ||
(target.route === '/timeline' && currentPath === '/timeline') ||
(target.route === '/notes/$noteId' && currentPath.startsWith('/notes/'));
setPendingSection({ sectionId: domain });
void navigate({ to: route });
if (isCurrentRoute && target.sectionId) {
setPendingSection({ sectionId: target.sectionId });
return;
}
if (isCurrentRoute) return;
domainNavigationInFlightRef.current = true;
const pendingSectionId = target.sectionId;
if (pendingSectionId) {
setPendingSection({ sectionId: pendingSectionId });
} else {
setPendingSection(undefined);
}
if (target.route === '/projects') {
void navigate({ to: '/projects', search: target.projectId ? { projectId: target.projectId } : {} });
} else if (target.route === '/notes/$noteId' && target.noteId) {
void navigate({ to: '/notes/$noteId', params: { noteId: target.noteId } });
} else if (target.route === '/tasks') {
void navigate({ to: '/tasks' });
} else if (target.route === '/timeline') {
void navigate({ to: '/timeline' });
}
},
[routerState.location.pathname, navigate, setPendingSection],
[routerState.location.pathname, navigate, setPendingSection, sections, moveToSection],
);
const {
@@ -98,8 +183,13 @@ function FloatingChatInner() {
useEffect(() => {
const currentPath = routerState.location.pathname;
if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
close();
if (prevPathRef.current !== currentPath && state.isOpen) {
// Keep floating chat alive when navigation is AI-domain driven.
if (domainNavigationInFlightRef.current) {
domainNavigationInFlightRef.current = false;
} else if (!state.pendingSection) {
close();
}
}
prevPathRef.current = currentPath;
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);

View File

@@ -1,6 +1,17 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { trpc } from '@/lib/trpc';
export type FloatingDomainSignal =
| 'tasks'
| 'notes'
| 'timelines'
| 'projects'
| {
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
id?: string | null;
section?: 'task' | 'timeline' | 'note' | null;
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -37,7 +48,23 @@ interface UseAIChatReturn {
}
interface UseAIChatOptions {
onDomainSignal?: (domain: 'tasks' | 'notes' | 'timelines' | 'projects') => void;
onDomainSignal?: (domain: FloatingDomainSignal) => void;
}
interface CachedChatState {
messages: ChatMessage[];
input: string;
}
const chatSessionCache = new Map<string, CachedChatState>();
function getContextCacheKey(ctx: UIChatContext): string {
if (ctx.type === 'global') return 'global';
if (ctx.type === 'project') return `project:${ctx.projectId ?? ''}`;
// Floating chat should keep a single continuous session while the panel is open,
// even when route/section context changes due floating-domain navigation.
return 'floating';
}
// ---------------------------------------------------------------------------
@@ -76,19 +103,45 @@ function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
// ---------------------------------------------------------------------------
export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOptions): UseAIChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const contextCacheKey = useMemo(
() => getContextCacheKey(defaultContext),
[defaultContext.type, defaultContext.projectId, defaultContext.scope?.type, defaultContext.scope?.id],
);
const [messages, setMessages] = useState<ChatMessage[]>(
() => chatSessionCache.get(contextCacheKey)?.messages ?? [],
);
const [input, setInput] = useState(
() => chatSessionCache.get(contextCacheKey)?.input ?? '',
);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const streamingContentRef = useRef('');
const chatMutation = trpc.ai.chat.useMutation();
// Keep local state aligned when the chat context changes in-place.
useEffect(() => {
const cached = chatSessionCache.get(contextCacheKey);
setMessages(cached?.messages ?? []);
setInput(cached?.input ?? '');
setIsStreaming(false);
setStreamingContent('');
streamingContentRef.current = '';
}, [contextCacheKey]);
// Persist the chat session so remounting the panel restores the conversation.
useEffect(() => {
chatSessionCache.set(contextCacheKey, { messages, input });
}, [contextCacheKey, messages, input]);
const clearMessages = useCallback(() => {
setMessages([]);
setStreamingContent('');
streamingContentRef.current = '';
}, []);
chatSessionCache.set(contextCacheKey, { messages: [], input: '' });
setInput('');
}, [contextCacheKey]);
const handleSend = useCallback(
(overrideMessage?: string, overrideContext?: UIChatContext) => {
@@ -102,6 +155,7 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
role: 'user',
content: trimmed,
};
const requestId = crypto.randomUUID();
setMessages((prev) => [...prev, userMsg]);
if (!overrideMessage) setInput('');
@@ -109,24 +163,16 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
setStreamingContent('');
streamingContentRef.current = '';
// Capture the requestId from stream_start so we only handle events
// for this specific chat request (avoids cross-contamination with
// other concurrent streams like the daily brief).
let activeRequestId: string | null = null;
const unsubscribe = window.electronAI.onStreamEvent((event) => {
// Latch the requestId on first stream_start
if (event.type === 'stream_start') {
activeRequestId = event.requestId;
return;
}
// Once we have a requestId, ignore events from other streams
if (activeRequestId !== null && event.requestId !== activeRequestId) {
// Strictly process only frames for this chat request.
if (event.requestId !== requestId) {
return;
}
switch (event.type) {
case 'stream_start':
break;
case 'stream_text':
streamingContentRef.current += event.chunk;
setStreamingContent(streamingContentRef.current);
@@ -167,6 +213,7 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
chatMutation.mutate(
{
requestId,
message: trimmed,
conversationHistory,
...(isFloating && ctx.scope

View File

@@ -17,7 +17,20 @@ type V3StreamEvent =
| { type: 'stream_start'; requestId: string }
| { type: 'stream_text'; requestId: string; chunk: string }
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'timelines' | 'projects' };
| {
type: 'floating_domain';
requestId: string;
domain:
| 'tasks'
| 'notes'
| 'timelines'
| 'projects'
| {
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
id?: string | null;
section?: 'task' | 'timeline' | 'note' | null;
};
};
interface ElectronAI {
onStreamEvent: (cb: (data: V3StreamEvent) => void) => () => void;

View File

@@ -199,7 +199,14 @@ export type WsStreamEnd = z.infer<typeof WsStreamEndSchema>;
export const WsFloatingDomainSchema = z.object({
type: z.literal('floating_domain'),
requestId: z.string(),
domain: z.enum(['tasks', 'notes', 'timelines', 'projects']),
domain: z.union([
z.enum(['tasks', 'notes', 'timelines', 'projects']),
z.object({
type: z.enum(['task', 'timeline', 'project', 'note', 'node']),
id: z.string().nullable().optional(),
section: z.enum(['task', 'timeline', 'note']).nullable().optional(),
}),
]),
});
export type WsFloatingDomain = z.infer<typeof WsFloatingDomainSchema>;