Compare commits

...

2 Commits

Author SHA1 Message Date
e9347c5e5a Unify timeline tags into bottom ProjectTimeline block 2026-03-13 00:38:51 +01:00
3bc8ad32cd remove stream_block — parse entity and chart tags inline from text
- Remove WsStreamBlock schema, type, and server frame entry
- Remove stream_block from V3StreamEvent unions (preload, ipcLink)
- Remove onBlock from StreamListener and all WS/IPC wiring
- Remove StreamBlock type, streamingBlocks state from useAIChat
- Convert parseMutationsToBlocks → parseMutationsToEntityTags (inline tags)
- Add inline tag parser: <type>[ids]</type> for entities, <chart>{JSON}</chart>
- Add MessageContent component to render mixed text + entity/chart segments
- Replace BlockRenderer with direct ChatEntityBlock/ChatChartBlock rendering
- Simplify blocks/index.tsx to re-exports only
2026-03-12 00:40:53 +01:00
13 changed files with 252 additions and 157 deletions

View File

@@ -83,7 +83,6 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
const { requestId, promise } = client.sendHomeRequest(message, conversationHistory, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId, chunk }),
onBlock: (blockType, data) => sendFrame(sender, { type: 'stream_block', requestId, blockType, data }),
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId, mutations: mutations as unknown[] | undefined }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
});
@@ -120,7 +119,6 @@ export async function orchestrateFloating(input: OrchestrateFloatingInput): Prom
const { requestId, promise } = client.sendFloatingRequest(message, scope, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId, chunk }),
onBlock: (blockType, data) => sendFrame(sender, { type: 'stream_block', requestId, blockType, data }),
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 }),
@@ -187,7 +185,6 @@ export async function generateAndCacheBrief(): Promise<void> {
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, {
onStart: () => {},
onText: (chunk) => { content += chunk; },
onBlock: () => {},
onEnd: () => {},
onError: () => {},
});
@@ -223,7 +220,6 @@ export async function dailyBrief(sender?: Electron.WebContents): Promise<Orchest
content += chunk;
sendFrame(sender, { type: 'stream_text', requestId, chunk });
},
onBlock: () => {},
onEnd: () => sendFrame(sender, { type: 'stream_end', requestId }),
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
});

View File

@@ -122,7 +122,6 @@ export class ServerError extends Error {
interface StreamListener {
onStart: () => void;
onText: (chunk: string) => void;
onBlock: (blockType: string, data: Record<string, unknown>) => void;
onEnd: (mutations?: unknown) => void;
onDomain: (domain: string) => void;
onError: (err: Error) => void;
@@ -241,7 +240,6 @@ export class BackendClient {
this.streamListeners.set(requestId, {
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
onBlock: callbacks?.onBlock ?? ((): void => { /* no-op */ }),
onEnd: (mutations) => {
callbacks?.onEnd?.(mutations);
this.streamListeners.delete(requestId);
@@ -286,7 +284,6 @@ export class BackendClient {
this.streamListeners.set(requestId, {
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
onBlock: callbacks?.onBlock ?? ((): void => { /* no-op */ }),
onEnd: (mutations) => {
callbacks?.onEnd?.(mutations);
this.streamListeners.delete(requestId);
@@ -674,12 +671,6 @@ export class BackendClient {
break;
}
case 'stream_block': {
const listener = this.streamListeners.get(frame.data.requestId);
listener?.onBlock(frame.data.blockType, frame.data.data);
break;
}
case 'stream_end': {
const listener = this.streamListeners.get(frame.data.requestId);
listener?.onEnd(frame.data.mutations as unknown);

View File

@@ -28,6 +28,8 @@ const TABLE_REGISTRY = {
notes,
taskComments,
timelineEvents,
// Alias: the backend sends "timelines" as the table name
timelines: timelineEvents,
} as const;
type TableName = keyof typeof TABLE_REGISTRY;

View File

@@ -601,7 +601,7 @@ const aiRouter = router({
})).optional(),
mode: z.enum(['home', 'floating']).optional(),
scope: z.object({
type: z.enum(['task', 'project', 'note', 'timelineEvent']),
type: z.enum(['task', 'project', 'note', 'timeline']),
id: z.string().optional(),
}).optional(),
}))

View File

@@ -25,9 +25,8 @@ const AI_STREAM_CHANNEL = 'ai:stream';
type V3StreamEvent =
| { type: 'stream_start'; requestId: string }
| { type: 'stream_text'; requestId: string; chunk: string }
| { type: 'stream_block'; requestId: string; blockType: 'chart' | 'entity_ref' | 'table' | 'timeline'; data: Record<string, unknown> }
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'timelineEvents' | 'projects' };
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'timelines' | 'projects' };
contextBridge.exposeInMainWorld('electronAI', {
/** Subscribe to v3 AI stream events. Returns an unsubscribe function. */

View File

@@ -10,10 +10,101 @@ import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { GradualBlur } from '@/components/ui/gradual-blur';
import { BlockRenderer } from './blocks';
import { ChatEntityBlock } from './blocks/ChatEntityBlock';
import { ChatChartBlock } from './blocks/ChatChartBlock';
import type { EntityRefBlockData, ChartBlockData } from '../../../shared/api-types';
/** Fluid font size for chat messages — scales with viewport width */
const CHAT_FONT = 'clamp(0.9rem, 1.2vw, 1.20rem)';
const CHAT_FONT = '1rem';
// ---------------------------------------------------------------------------
// Inline tag parsing (entities + charts)
// ---------------------------------------------------------------------------
/**
* Matches entity tags in both formats:
* - <task>[id1,id2]</task>
* - <timeline>id1,id2</timeline>
*/
const ENTITY_TAG_RE = /<(?<entity>task|project|note|timeline|timelineEvent)>(?:\[(?<bracketIds>[^\]]+)\]|(?<plainIds>[^<]+))<\/\k<entity>>/;
/** Matches chart tags: <chart>{...JSON...}</chart> */
const CHART_TAG_RE = /<chart>(?<chartJson>\{[\s\S]*?\})<\/chart>/;
/** Combined: matches the first occurrence of either tag */
const INLINE_TAG_RE = new RegExp(`${ENTITY_TAG_RE.source}|${CHART_TAG_RE.source}`);
type ContentSegment =
| { type: 'text'; content: string }
| { type: 'entity'; entity: EntityRefBlockData['entity']; ids: string[] }
| { type: 'chart'; data: ChartBlockData };
function parseInlineTags(content: string): ContentSegment[] {
const segments: ContentSegment[] = [];
let remaining = content;
while (remaining) {
const match = INLINE_TAG_RE.exec(remaining);
if (!match) {
segments.push({ type: 'text', content: remaining });
break;
}
const before = remaining.slice(0, match.index);
if (before) segments.push({ type: 'text', content: before });
const groups = match.groups ?? {};
if (groups.entity) {
const entity = groups.entity as EntityRefBlockData['entity'];
const rawIds = groups.bracketIds ?? groups.plainIds ?? '';
const ids = rawIds.split(',').map((id) => id.trim()).filter(Boolean);
segments.push({ type: 'entity', entity, ids });
} else if (groups.chartJson) {
try {
const chartData = JSON.parse(groups.chartJson) as ChartBlockData;
segments.push({ type: 'chart', data: chartData });
} catch {
// Malformed JSON — keep as text
segments.push({ type: 'text', content: match[0] });
}
}
const matchIndex = typeof match.index === 'number' ? match.index : 0;
remaining = remaining.slice(matchIndex + match[0].length);
}
return segments;
}
function hasInlineTags(content: string): boolean {
return INLINE_TAG_RE.test(content);
}
function mergeTimelineSegments(segments: ContentSegment[]): ContentSegment[] {
const allTimelineIds: string[] = [];
for (const seg of segments) {
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
allTimelineIds.push(...seg.ids);
}
}
const uniqueTimelineIds = [...new Set(allTimelineIds)];
if (!uniqueTimelineIds.length) return segments;
const merged: ContentSegment[] = [];
for (const seg of segments) {
if (seg.type === 'entity' && (seg.entity === 'timeline' || seg.entity === 'timelineEvent')) {
continue;
}
merged.push(seg);
}
// Keep prose flow untouched, then append a single consolidated timeline block.
merged.push({ type: 'entity', entity: 'timeline', ids: uniqueTimelineIds });
return merged;
}
const SUGGESTION_CHIPS = [
{ icon: ListTodo, label: "What's on my plate today?" },
@@ -78,7 +169,6 @@ export function AIChatPanel({
setInput,
isStreaming,
streamingContent,
streamingBlocks,
handleSend: chatHandleSend,
} = useAIChat(chatContext);
@@ -425,16 +515,13 @@ export function AIChatPanel({
}
return (
<div key={msg.id} className={`mr-auto ${msg.blocks.length ? 'w-full' : 'max-w-[75%]'}`}>
<div key={msg.id} className={`mr-auto ${hasInlineTags(msg.content) ? 'w-full' : 'max-w-[75%]'}`}>
<div className="flex items-center gap-1.5 mb-1">
<Sparkles size={16} className="text-foreground" />
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
</div>
<div className="pl-[22px] flex flex-col gap-3">
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
{msg.blocks.map((block) => (
<BlockRenderer key={block.id} block={block} />
))}
<MessageContent content={msg.content} fontSize={CHAT_FONT} />
</div>
</div>
);
@@ -442,17 +529,14 @@ export function AIChatPanel({
{/* Streaming AI response */}
{isStreaming && (
<div ref={setStreamingEl} className={`mr-auto ${streamingBlocks.length ? 'w-full' : 'max-w-[75%]'}`}>
<div ref={setStreamingEl} className={`mr-auto ${hasInlineTags(streamingContent) ? 'w-full' : 'max-w-[75%]'}`}>
<div className="flex items-center gap-1.5 mb-1">
<Sparkles size={16} className="text-foreground" />
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
</div>
{streamingContent ? (
<div className="pl-[22px] flex flex-col gap-3">
<ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
{streamingBlocks.map((block) => (
<BlockRenderer key={block.id} block={block} />
))}
<MessageContent content={streamingContent} fontSize={CHAT_FONT} />
</div>
) : (
<div className="space-y-2 pl-[22px]">
@@ -500,6 +584,48 @@ export function AIChatPanel({
);
}
/* ---------- MessageContent: text with inline entity blocks ---------- */
function MessageContent({ content, fontSize }: { content: string; fontSize?: string }) {
const segments = mergeTimelineSegments(parseInlineTags(content));
// Fast path: no inline tags, just render markdown
if (segments.length === 1 && segments[0]?.type === 'text') {
return <ChatMarkdown content={content} fontSize={fontSize} />;
}
// No content at all
if (segments.length === 0) return null;
return (
<div className="flex flex-col gap-3">
{segments.map((seg, i) => {
if (seg.type === 'text') {
return <ChatMarkdown key={i} content={seg.content} fontSize={fontSize} />;
}
if (seg.type === 'chart') {
return (
<motion.div key={i} {...blockAnimation}>
<ChatChartBlock data={seg.data} />
</motion.div>
);
}
return (
<motion.div key={i} {...blockAnimation}>
<ChatEntityBlock data={{ entity: seg.entity, ids: seg.ids }} />
</motion.div>
);
})}
</div>
);
}
const blockAnimation = {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
};
/* ---------- ChatInput: Floating glass card ---------- */
interface ChatInputProps {

View File

@@ -18,7 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton';
const DOMAIN_ROUTES: Record<string, string> = {
tasks: '/tasks',
notes: '/notes',
timelineEvents: '/timeline',
timelines: '/timeline',
projects: '/projects',
};
@@ -40,8 +40,8 @@ function FloatingChatInner() {
: activeSection.label?.toLowerCase().includes('note')
? 'note'
: activeSection.label?.toLowerCase().includes('timeline')
? 'timelineEvent'
: 'project') as 'task' | 'project' | 'note' | 'timelineEvent',
? 'timeline'
: 'project') as 'task' | 'project' | 'note' | 'timeline',
id: activeSection.projectId,
}
: undefined;
@@ -55,7 +55,7 @@ function FloatingChatInner() {
// Handle floating_domain signals — navigate in background
const handleDomainSignal = useCallback(
(domain: 'tasks' | 'notes' | 'timelineEvents' | 'projects') => {
(domain: 'tasks' | 'notes' | 'timelines' | 'projects') => {
const route = DOMAIN_ROUTES[domain];
if (!route) return;

View File

@@ -6,6 +6,7 @@ import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
import { ChatTimelineBlock } from './ChatTimelineBlock';
import type { EntityRefBlockData } from '../../../../../shared/api-types';
export function ChatEntityBlock({ data }: { data: EntityRefBlockData }) {
@@ -18,6 +19,8 @@ export function ChatEntityBlock({ data }: { data: EntityRefBlockData }) {
return <ProjectEntityBlock ids={ids} />;
case 'note':
return <NoteEntityBlock ids={ids} />;
case 'timeline':
return <TimelineEntityBlock ids={ids} />;
case 'timelineEvent':
return <TimelineEventEntityBlock ids={ids} />;
default:
@@ -169,6 +172,36 @@ function NoteEntityBlock({ ids }: { ids: string[] }) {
// Timeline Events
// ---------------------------------------------------------------------------
function TimelineEntityBlock({ ids }: { ids: string[] }) {
const { data: allEvents } = trpc.timelineEvents.list.useQuery();
const timelineData = useMemo(() => {
const filtered = allEvents?.filter((e) => ids.includes(e.id)) ?? [];
return {
events: filtered
.map((e) => {
const date = new Date(e.date).getTime();
const endDate = e.endDate ? new Date(e.endDate).getTime() : undefined;
return {
id: e.id,
title: e.title,
date,
endDate,
projectId: e.projectId,
isCompleted: e.isCompleted,
isAiSuggested: e.isAiSuggested,
};
})
.filter((e) => Number.isFinite(e.date)),
};
}, [allEvents, ids]);
if (!timelineData.events.length) return null;
return <ChatTimelineBlock data={timelineData} />;
}
function TimelineEventEntityBlock({ ids }: { ids: string[] }) {
const { data: allEvents } = trpc.timelineEvents.list.useQuery();

View File

@@ -1,27 +1,55 @@
import { format } from 'date-fns';
import { useMemo } from 'react';
import { trpc } from '@/lib/trpc';
import { ProjectTimelineBox, type ProjectGroup } from '@/components/timeline/ProjectTimelineBox';
import type { TimelineEvent } from '@/components/timeline/ProjectTimeline';
import type { TimelineBlockData } from '../../../../../shared/api-types';
export function ChatTimelineBlock({ data }: { data: TimelineBlockData }) {
const { events } = data;
const { events: rawEvents } = data;
const { data: allProjects } = trpc.projects.list.useQuery({ includeArchived: true });
const events = useMemo<TimelineEvent[]>(() => {
return rawEvents
.map((event) => ({
id: event.id,
title: event.title,
date: event.date,
endDate: event.endDate ?? null,
projectId: event.projectId ?? null,
isCompleted: event.isCompleted ?? 0,
isAiSuggested: event.isAiSuggested ?? 0,
}))
.filter((event) => Number.isFinite(event.date));
}, [rawEvents]);
if (!events.length) return null;
const sorted = [...events].sort((a, b) => a.date - b.date);
const projectIds = [...new Set(events.map((event) => event.projectId).filter((id): id is string => !!id))];
const singleProject = projectIds.length === 1
? allProjects?.find((project) => project.id === projectIds[0])
: undefined;
const dates = events.flatMap((event) => (event.endDate ? [event.date, event.endDate] : [event.date]));
const now = Date.now();
const minDate = Math.min(...dates, now);
const maxDate = Math.max(...dates, now);
const PAD_MS = 3 * 24 * 60 * 60 * 1000;
const group: ProjectGroup = {
projectId: singleProject?.id ?? null,
projectName: singleProject?.name ?? 'Timeline',
projectStatus: singleProject?.status ?? 'active',
breadcrumb: [],
events,
startDate: new Date(minDate - PAD_MS),
endDate: new Date(maxDate + PAD_MS),
};
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="relative ml-3 border-l border-border pl-6">
{sorted.map((ev, i) => (
<div key={ev.id} className={i < sorted.length - 1 ? 'pb-5' : ''}>
<div className="absolute -left-[5px] mt-1.5 h-2.5 w-2.5 rounded-full bg-primary" />
<p className="text-sm font-medium leading-tight">{ev.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{format(new Date(ev.date), 'MMM d, yyyy')}
{ev.endDate && `${format(new Date(ev.endDate), 'MMM d, yyyy')}`}
</p>
</div>
))}
</div>
<div className="w-full">
<ProjectTimelineBox group={group} />
</div>
);
}

View File

@@ -1,41 +1 @@
import { motion } from 'framer-motion';
import type { StreamBlock } from '@/hooks/useAIChat';
import type {
ChartBlockData,
EntityRefBlockData,
TableBlockData,
TimelineBlockData,
} from '../../../../../shared/api-types';
import { ChatChartBlock } from './ChatChartBlock';
import { ChatEntityBlock } from './ChatEntityBlock';
import { ChatTableBlock } from './ChatTableBlock';
import { ChatTimelineBlock } from './ChatTimelineBlock';
const blockAnimation = {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
};
export function BlockRenderer({ block }: { block: StreamBlock }) {
return (
<motion.div {...blockAnimation}>
{renderBlock(block)}
</motion.div>
);
}
function renderBlock(block: StreamBlock) {
switch (block.blockType) {
case 'chart':
return <ChatChartBlock data={block.data as unknown as ChartBlockData} />;
case 'entity_ref':
return <ChatEntityBlock data={block.data as unknown as EntityRefBlockData} />;
case 'table':
return <ChatTableBlock data={block.data as unknown as TableBlockData} />;
case 'timeline':
return <ChatTimelineBlock data={block.data as unknown as TimelineBlockData} />;
default:
return null;
}
}
/**\n * Block components are now rendered inline by the MessageContent parser\n * in AIChatPanel.tsx. Import individual block components directly:\n * - ChatEntityBlock\n * - ChatChartBlock\n * - ChatTableBlock\n * - ChatTimelineBlock\n */\nexport { ChatEntityBlock } from './ChatEntityBlock';\nexport { ChatChartBlock } from './ChatChartBlock';\nexport { ChatTableBlock } from './ChatTableBlock';\nexport { ChatTimelineBlock } from './ChatTimelineBlock';

View File

@@ -14,22 +14,15 @@ export interface UIChatContext {
projectId?: string;
/** For floating mode — the entity scope to pass to the backend. */
scope?: {
type: 'task' | 'project' | 'note' | 'timelineEvent';
type: 'task' | 'project' | 'note' | 'timeline';
id?: string;
};
}
export interface StreamBlock {
id: string;
blockType: 'chart' | 'entity_ref' | 'table' | 'timeline';
data: Record<string, unknown>;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
blocks: StreamBlock[];
error?: boolean;
}
@@ -39,29 +32,29 @@ interface UseAIChatReturn {
setInput: (v: string) => void;
isStreaming: boolean;
streamingContent: string;
streamingBlocks: StreamBlock[];
handleSend: (overrideMessage?: string, overrideContext?: UIChatContext) => void;
clearMessages: () => void;
}
interface UseAIChatOptions {
onDomainSignal?: (domain: 'tasks' | 'notes' | 'timelineEvents' | 'projects') => void;
onDomainSignal?: (domain: 'tasks' | 'notes' | 'timelines' | 'projects') => void;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TABLE_TO_ENTITY: Record<string, 'task' | 'project' | 'note' | 'timelineEvent'> = {
const TABLE_TO_ENTITY: Record<string, 'task' | 'project' | 'note' | 'timeline'> = {
tasks: 'task',
projects: 'project',
notes: 'note',
timelineEvents: 'timelineEvent',
timelines: 'timeline',
timelineEvents: 'timeline',
};
function parseMutationsToBlocks(mutations: unknown[] | undefined): StreamBlock[] {
if (!Array.isArray(mutations)) return [];
const blocks: StreamBlock[] = [];
function parseMutationsToEntityTags(mutations: unknown[] | undefined): string {
if (!Array.isArray(mutations)) return '';
const tags: string[] = [];
for (const m of mutations) {
if (!m || typeof m !== 'object') continue;
const mut = m as Record<string, unknown>;
@@ -73,9 +66,9 @@ function parseMutationsToBlocks(mutations: unknown[] | undefined): StreamBlock[]
if (!rows.length) continue;
const ids = rows.map((r) => String(r.id ?? '')).filter(Boolean);
if (!ids.length) continue;
blocks.push({ id: crypto.randomUUID(), blockType: 'entity_ref', data: { entity, ids } });
tags.push(`<${entity}>[${ids.join(',')}]</${entity}>`);
}
return blocks;
return tags.length ? '\n' + tags.join('\n') : '';
}
// ---------------------------------------------------------------------------
@@ -87,18 +80,14 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [streamingBlocks, setStreamingBlocks] = useState<StreamBlock[]>([]);
const streamingContentRef = useRef('');
const streamingBlocksRef = useRef<StreamBlock[]>([]);
const chatMutation = trpc.ai.chat.useMutation();
const clearMessages = useCallback(() => {
setMessages([]);
setStreamingContent('');
setStreamingBlocks([]);
streamingContentRef.current = '';
streamingBlocksRef.current = [];
}, []);
const handleSend = useCallback(
@@ -112,16 +101,13 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
blocks: [],
};
setMessages((prev) => [...prev, userMsg]);
if (!overrideMessage) setInput('');
setIsStreaming(true);
setStreamingContent('');
setStreamingBlocks([]);
streamingContentRef.current = '';
streamingBlocksRef.current = [];
// Capture the requestId from stream_start so we only handle events
// for this specific chat request (avoids cross-contamination with
@@ -146,21 +132,9 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
setStreamingContent(streamingContentRef.current);
break;
case 'stream_block': {
const block: StreamBlock = {
id: crypto.randomUUID(),
blockType: event.blockType,
data: event.data,
};
streamingBlocksRef.current = [...streamingBlocksRef.current, block];
setStreamingBlocks([...streamingBlocksRef.current]);
break;
}
case 'stream_end': {
const finalContent = streamingContentRef.current;
const mutationBlocks = parseMutationsToBlocks(event.mutations);
const finalBlocks = [...streamingBlocksRef.current, ...mutationBlocks];
const mutationTags = parseMutationsToEntityTags(event.mutations);
const finalContent = streamingContentRef.current + mutationTags;
setMessages((prev) => [
...prev,
@@ -168,13 +142,10 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
id: crypto.randomUUID(),
role: 'assistant',
content: finalContent,
blocks: finalBlocks,
},
]);
setStreamingContent('');
setStreamingBlocks([]);
streamingContentRef.current = '';
streamingBlocksRef.current = [];
setIsStreaming(false);
unsubscribe();
break;
@@ -208,12 +179,10 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, blocks: [], error: true },
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true },
]);
setStreamingContent('');
setStreamingBlocks([]);
streamingContentRef.current = '';
streamingBlocksRef.current = [];
setIsStreaming(false);
} else {
// Safety fallback: the tRPC response always arrives after the
@@ -222,21 +191,18 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
// React StrictMode, or requestId mismatch), this ensures
// isStreaming is reset.
unsubscribe();
if (streamingContentRef.current || streamingBlocksRef.current.length) {
if (streamingContentRef.current) {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: streamingContentRef.current,
blocks: streamingBlocksRef.current,
},
]);
}
setStreamingContent('');
setStreamingBlocks([]);
streamingContentRef.current = '';
streamingBlocksRef.current = [];
setIsStreaming(false);
}
},
@@ -248,14 +214,11 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
id: crypto.randomUUID(),
role: 'assistant',
content: err.message || 'An unexpected error occurred.',
blocks: [],
error: true,
},
]);
setStreamingContent('');
setStreamingBlocks([]);
streamingContentRef.current = '';
streamingBlocksRef.current = [];
setIsStreaming(false);
},
},
@@ -270,7 +233,6 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
setInput,
isStreaming,
streamingContent,
streamingBlocks,
handleSend,
clearMessages,
};

View File

@@ -16,9 +16,8 @@ interface ElectronTRPC {
type V3StreamEvent =
| { type: 'stream_start'; requestId: string }
| { type: 'stream_text'; requestId: string; chunk: string }
| { type: 'stream_block'; requestId: string; blockType: 'chart' | 'entity_ref' | 'table' | 'timeline'; data: Record<string, unknown> }
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'timelineEvents' | 'projects' };
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'timelines' | 'projects' };
interface ElectronAI {
onStreamEvent: (cb: (data: V3StreamEvent) => void) => () => void;

View File

@@ -116,7 +116,7 @@ export const WsFloatingRequestSchema = z.object({
type: z.literal('floating_request'),
message: z.string(),
scope: z.object({
type: z.enum(['task', 'project', 'note', 'timelineEvent']),
type: z.enum(['task', 'project', 'note', 'timeline']),
id: z.string().optional(),
}),
});
@@ -186,14 +186,6 @@ export const WsStreamTextSchema = z.object({
});
export type WsStreamText = z.infer<typeof WsStreamTextSchema>;
export const WsStreamBlockSchema = z.object({
type: z.literal('stream_block'),
requestId: z.string(),
blockType: z.enum(['chart', 'entity_ref', 'table', 'timeline']),
data: z.record(z.string(), z.unknown()),
});
export type WsStreamBlock = z.infer<typeof WsStreamBlockSchema>;
export const WsStreamEndSchema = z.object({
type: z.literal('stream_end'),
requestId: z.string(),
@@ -207,7 +199,7 @@ 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', 'timelineEvents', 'projects']),
domain: z.enum(['tasks', 'notes', 'timelines', 'projects']),
});
export type WsFloatingDomain = z.infer<typeof WsFloatingDomainSchema>;
@@ -221,7 +213,7 @@ export interface ChartBlockData {
}
export interface EntityRefBlockData {
entity: 'task' | 'project' | 'note' | 'timelineEvent';
entity: 'task' | 'project' | 'note' | 'timeline' | 'timelineEvent';
/** Live IDs — component fetches real data from local DB. */
ids: string[];
/** @deprecated Legacy inline items — prefer `ids` for live data. */
@@ -234,7 +226,15 @@ export interface TableBlockData {
}
export interface TimelineBlockData {
events: { id: string; title: string; date: number; endDate?: number }[];
events: {
id: string;
title: string;
date: number;
endDate?: number;
projectId?: string | null;
isCompleted?: number;
isAiSuggested?: number;
}[];
}
export const WsServerFrameSchema = z.discriminatedUnion('type', [
@@ -243,7 +243,6 @@ export const WsServerFrameSchema = z.discriminatedUnion('type', [
WsAgentRunSchema,
WsStreamStartSchema,
WsStreamTextSchema,
WsStreamBlockSchema,
WsStreamEndSchema,
WsFloatingDomainSchema,
]);