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:
256
src/renderer/context/ContextualChatContext.tsx
Normal file
256
src/renderer/context/ContextualChatContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user