feat(contextual): ContextualChatProvider

Holds open/size/sessionId/scope/messages/streaming state. Creates
or hydrates a contextual aiChatSessions row on mount, persists
messages, and fires scope updates through window.electronAI when
the renderer scope changes. Not yet mounted into AppShell (M4.5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Roberto
2026-05-14 21:50:30 +02:00
parent 49c0ae2413
commit 869e0d82ee
2 changed files with 258 additions and 0 deletions

View File

@@ -0,0 +1,256 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { trpc } from '@/lib/trpc';
import type { ChatMessage } from '@/hooks/useAIChat';
export interface ContextualScope {
page: 'timeline' | 'tasks' | 'projects-list' | 'project' | 'note';
entityType?: 'project' | 'note' | null;
entityId?: string;
entityName?: string;
projectId?: string | null;
counts?: { tasks?: number; notes?: number; milestones?: number };
charCount?: number;
filters?: unknown;
}
interface ContextualChatState {
open: boolean;
size: number;
sessionId: string | null;
scope: ContextualScope | null;
messages: ChatMessage[];
isStreaming: boolean;
streamingContent: string;
toggle: () => void;
close: () => void;
newChat: () => Promise<void>;
setSize: (s: number) => void;
setScope: (s: ContextualScope) => void;
send: (text: string) => void;
}
const Ctx = createContext<ContextualChatState | null>(null);
const SESSION_KEY = 'chat.contextual.lastSessionId';
const SIZE_KEY = 'chat.sidebar.size';
const OPEN_KEY = 'chat.contextual.open';
function readNumber(k: string, fallback: number): number {
if (typeof window === 'undefined') return fallback;
const v = window.localStorage.getItem(k);
if (!v) return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
export function ContextualChatProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState<boolean>(() =>
typeof window !== 'undefined' && window.localStorage.getItem(OPEN_KEY) === '1',
);
const [size, setSizeState] = useState<number>(() => readNumber(SIZE_KEY, 32));
const [sessionId, setSessionId] = useState<string | null>(() =>
typeof window !== 'undefined' ? window.localStorage.getItem(SESSION_KEY) : null,
);
const [scope, setScopeState] = useState<ContextualScope | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const streamRef = useRef('');
const utils = trpc.useUtils();
const createSession = trpc.aiChat.createSession.useMutation();
const appendMessage = trpc.aiChat.appendMessage.useMutation();
const chatMutation = trpc.ai.chat.useMutation();
// Hydrate or create session on mount. One-shot effect.
const hydratedRef = useRef(false);
useEffect(() => {
if (hydratedRef.current) return;
hydratedRef.current = true;
let cancelled = false;
(async () => {
if (!sessionId) {
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
if (cancelled) return;
window.localStorage.setItem(SESSION_KEY, id);
setSessionId(id);
} else {
const res = await utils.aiChat.getSession.fetch({ id: sessionId });
if (cancelled) return;
if (!res) {
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
if (cancelled) return;
window.localStorage.setItem(SESSION_KEY, id);
setSessionId(id);
} else if (res.messages) {
setMessages(
res.messages
.filter((m) => m.role !== 'system')
.map((m) => ({
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content,
})),
);
}
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setSize = useCallback((s: number) => {
setSizeState(s);
window.localStorage.setItem(SIZE_KEY, String(s));
}, []);
const toggle = useCallback(() => {
setOpen((o) => {
const next = !o;
window.localStorage.setItem(OPEN_KEY, next ? '1' : '0');
return next;
});
}, []);
const close = useCallback(() => {
setOpen(false);
window.localStorage.setItem(OPEN_KEY, '0');
}, []);
const newChat = useCallback(async () => {
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
window.localStorage.setItem(SESSION_KEY, id);
setSessionId(id);
setMessages([]);
}, [createSession]);
const lastScopeKeyRef = useRef<string>('');
const setScope = useCallback(
(s: ContextualScope) => {
const key = JSON.stringify(s);
if (key === lastScopeKeyRef.current) return;
lastScopeKeyRef.current = key;
setScopeState(s);
if (sessionId && (window as any).electronAI?.sendContextualScopeUpdate) {
// Best-effort fire — exposed by preload in M4.7.
(window as any).electronAI.sendContextualScopeUpdate({ sessionId, scope: s });
}
},
[sessionId],
);
const send = useCallback(
(text: string) => {
if (!sessionId || !scope) return;
const trimmed = text.trim();
if (!trimmed || isStreaming) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
appendMessage.mutate({
sessionId,
role: 'user',
content: trimmed,
scope: JSON.stringify(scope),
});
const requestId = crypto.randomUUID();
setIsStreaming(true);
setStreamingContent('');
streamRef.current = '';
const unsub = window.electronAI.onStreamEvent((event) => {
if (event.requestId !== requestId) return;
switch (event.type) {
case 'stream_text':
streamRef.current += event.chunk;
setStreamingContent(streamRef.current);
break;
case 'stream_end': {
const final = streamRef.current;
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: final },
]);
appendMessage.mutate({
sessionId,
role: 'assistant',
content: final,
scope: JSON.stringify(scope),
});
setStreamingContent('');
streamRef.current = '';
setIsStreaming(false);
unsub();
break;
}
}
});
chatMutation.mutate(
{
requestId,
message: trimmed,
conversationHistory: messages.slice(-20).map((m) => ({
role: m.role,
content: m.content,
})),
sessionId,
mode: 'contextual',
scope,
} as never,
{
onError: (err) => {
unsub();
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: err.message || 'An unexpected error occurred.',
error: true,
},
]);
setStreamingContent('');
streamRef.current = '';
setIsStreaming(false);
},
},
);
},
[sessionId, scope, isStreaming, messages, appendMessage, chatMutation],
);
const value = useMemo<ContextualChatState>(
() => ({
open,
size,
sessionId,
scope,
messages,
isStreaming,
streamingContent,
toggle,
close,
newChat,
setSize,
setScope,
send,
}),
[open, size, sessionId, scope, messages, isStreaming, streamingContent, toggle, close, newChat, setSize, setScope, send],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useContextualChat() {
const v = useContext(Ctx);
if (!v) throw new Error('useContextualChat must be used within ContextualChatProvider');
return v;
}

View File

@@ -35,6 +35,8 @@ type V3StreamEvent =
interface ElectronAI {
onStreamEvent: (cb: (data: V3StreamEvent) => void) => () => void;
onBriefUpdated: (cb: (content: string) => void) => () => void;
/** Exposed by preload in M4.7. Best-effort fire when renderer scope changes. */
sendContextualScopeUpdate?: (args: { sessionId: string; scope: unknown }) => void;
}
interface ElectronDialog {