feat: Complete Pi Harness Rebuild - Phases 1 till 7.2

This commit is contained in:
2026-02-20 20:51:10 +00:00
parent 25f5f74b1b
commit 92f4b2be8e
39 changed files with 5176 additions and 1657 deletions
@@ -11,7 +11,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { ModelOption } from '@/state/useModels';
import type { ChatMessage } from 'apps/Chat';
import type { LegacyChatMessage } from 'apps/Chat';
import type { Attachment } from './EmbeddableChat';
import { Settings } from './Settings';
@@ -61,7 +61,7 @@ type InputAreaProps = {
isConnected: boolean;
commandFeedback: string | null;
textareaRef: RefObject<HTMLTextAreaElement | null>;
messages: ChatMessage[];
messages: LegacyChatMessage[];
availableModels: ModelOption[];
selectedModel: string | null;
onModelChange: (modelId: string) => void;
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { ModelOption } from '@/state/useModels';
import type { ChatMessage } from 'apps/Chat';
import type { LegacyChatMessage } from 'apps/Chat';
const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic',
@@ -21,7 +21,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
};
type SettingsProps = {
messages: ChatMessage[];
messages: LegacyChatMessage[];
availableModels: ModelOption[];
selectedModel: string | null;
onModelChange: (modelId: string) => void;
@@ -1,13 +1,13 @@
import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useChatSessions } from '@/state/useChatSessions';
import type { ChatMessage, ServerMessage, TaskInfo } from 'apps/Chat';
import type { LegacyChatMessage, LegacyServerMessage, TaskInfo } from 'apps/Chat';
const SAVE_DEBOUNCE_MS = 1000;
type ResourceChatStorage = {
load: () => Promise<{ sessionId: string | null; messages: ChatMessage[] }>;
save: (sessionId: string, messages: ChatMessage[]) => Promise<void>;
load: () => Promise<{ sessionId: string | null; messages: LegacyChatMessage[] }>;
save: (sessionId: string, messages: LegacyChatMessage[]) => Promise<void>;
};
type UseClaudeOptions = {
@@ -19,7 +19,7 @@ type UseClaudeOptions = {
export const useClaude = (initialSessionId?: string, initialModel?: string | null, options?: UseClaudeOptions) => {
const { replaceUrl = true, storage, resourceChatDir, taskInfo } = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [messages, setMessages] = useState<LegacyChatMessage[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
@@ -53,7 +53,7 @@ export const useClaude = (initialSessionId?: string, initialModel?: string | nul
};
const handleMessage = (data: unknown) => {
const msg = data as ServerMessage;
const msg = data as LegacyServerMessage;
switch (msg.type) {
case 'session:init':
@@ -3,12 +3,12 @@ import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useSettings } from '@/state/useSettings';
import { useVisibleOpenCodeModels } from '@/state/useModels';
import { useChatSessions } from '@/state/useChatSessions';
import type { ChatMessage, ServerMessage, TaskInfo } from 'apps/Chat';
import type { LegacyChatMessage, LegacyServerMessage, TaskInfo } from 'apps/Chat';
const SYSTEM_RE = /^<system>([\s\S]*?)<\/system>\s*/;
const splitSystemBlocks = (messages: ChatMessage[]): ChatMessage[] => {
const result: ChatMessage[] = [];
const splitSystemBlocks = (messages: LegacyChatMessage[]): LegacyChatMessage[] => {
const result: LegacyChatMessage[] = [];
for (const msg of messages) {
if (msg.role !== 'user') {
result.push(msg);
@@ -32,7 +32,7 @@ type UseOpenCodeOptions = {
export const useOpenCode = (initialSessionId?: string, initialModel?: string | null, options?: UseOpenCodeOptions) => {
const { replaceUrl = true, taskInfo } = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [messages, setMessages] = useState<LegacyChatMessage[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
@@ -78,7 +78,7 @@ export const useOpenCode = (initialSessionId?: string, initialModel?: string | n
};
const handleMessage = (data: unknown) => {
const msg = data as ServerMessage;
const msg = data as LegacyServerMessage;
switch (msg.type) {
case 'session:init':
@@ -0,0 +1,285 @@
import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useChatSessions } from '@/state/useChatSessions';
import type { ChatMessage, ServerMessage, TaskInfo, Message } from 'apps/Chat';
const SAVE_DEBOUNCE_MS = 1000;
type ResourceChatStorage = {
load: () => Promise<{ sessionId: string | null; messages: ChatMessage[] }>;
save: (sessionId: string, messages: ChatMessage[]) => Promise<void>;
};
type UsePiOptions = {
replaceUrl?: boolean;
storage?: ResourceChatStorage;
resourceChatDir?: string;
taskInfo?: TaskInfo;
};
export function usePi(initialSessionId?: string, initialModel?: string | null, options?: UsePiOptions) {
const { replaceUrl = true, storage, resourceChatDir, taskInfo } = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
const [model, setModel] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useState<string | null>(initialModel ?? null);
const [cwd, setCwd] = useState<string | null>(null);
const streamingRef = useRef('');
const rafRef = useRef<number | null>(null);
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
const saveTimerRef = useRef<number | null>(null);
const { getSession, saveMessages } = useChatSessions();
const token = localStorage.getItem('BEARER_TOKEN');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/pi/chat/ws?token=${token}`;
function flushStreaming() {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setStreamingText(streamingRef.current);
rafRef.current = null;
});
}
function commitStreaming() {
if (!streamingRef.current) return;
setMessages((prev) => [...prev, { role: 'assistant', text: streamingRef.current }]);
streamingRef.current = '';
setStreamingText('');
}
function handleMessage(data: unknown) {
const msg = data as ServerMessage;
switch (msg.type) {
case 'session:init':
sessionIdRef.current = msg.sessionId;
setSessionId(msg.sessionId);
setModel(msg.model);
setCwd(msg.cwd);
if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`);
break;
case 'assistant:delta':
streamingRef.current += msg.text;
flushStreaming();
break;
case 'assistant:text':
if (streamingRef.current) {
commitStreaming();
} else {
setMessages((prev) => [...prev, { role: 'assistant', text: msg.text }]);
}
break;
case 'tool:start':
setMessages((prev) => [
...prev,
{
role: 'tool',
toolName: msg.toolName,
toolInput: msg.toolInput,
toolCallId: msg.toolCallId,
},
]);
break;
case 'tool:result':
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && m.toolCallId === msg.toolCallId
? { ...m, output: msg.output, isError: msg.isError }
: m,
),
);
break;
case 'result':
commitStreaming();
setMessages((prev) => [
...prev,
{
role: 'result',
cost: msg.cost,
},
]);
setIsGenerating(false);
break;
case 'sync:messages':
sessionIdRef.current = msg.sessionId;
setSessionId(msg.sessionId);
// Convert Message[] to ChatMessage[]
const chatMessages = msg.messages.map((m): ChatMessage => {
if (m.role === 'user') {
return { role: 'user', text: m.text || '' };
} else if (m.role === 'assistant') {
return { role: 'assistant', text: m.text || '' };
} else if (m.role === 'tool') {
return {
role: 'tool',
toolName: m.toolName || '',
toolInput: m.toolInput || {},
toolCallId: m.toolCallId || '',
output: m.output,
isError: m.isError,
};
}
return { role: 'assistant', text: '' }; // Fallback
});
setMessages(chatMessages);
setIsGenerating(msg.isGenerating);
if (msg.streamingText) {
streamingRef.current = msg.streamingText;
flushStreaming();
}
break;
case 'error':
commitStreaming();
setMessages((prev) => [...prev, { role: 'error', text: msg.message }]);
setIsGenerating(false);
break;
case 'stopped':
commitStreaming();
setIsGenerating(false);
break;
}
}
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage });
// Load messages from server on mount when resuming a session
useEffect(() => {
if (storage) {
storage
.load()
.then(({ sessionId: sid, messages: msgs }) => {
if (sid) {
sessionIdRef.current = sid;
setSessionId(sid);
}
if (msgs.length > 0) setMessages(msgs);
})
.catch(() => {});
return;
}
if (!initialSessionId) return;
getSession(initialSessionId)
.then((data) => {
if (data.session?.messages && data.session.messages.length > 0) {
// Convert backend Message[] to ChatMessage[]
const chatMessages = data.session.messages.map((m: Message): ChatMessage => {
if (m.role === 'user') {
return { role: 'user', text: m.text || '' };
} else if (m.role === 'assistant') {
return { role: 'assistant', text: m.text || '' };
} else if (m.role === 'tool') {
return {
role: 'tool',
toolName: m.toolName || '',
toolInput: m.toolInput || {},
toolCallId: m.toolCallId || '',
output: m.output,
isError: m.isError,
};
}
return { role: 'assistant', text: '' }; // Fallback
});
setMessages(chatMessages);
}
})
.catch(() => {});
}, [initialSessionId]);
// Debounced save messages to server
useEffect(() => {
if (!sessionIdRef.current || messages.length === 0) return;
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
const sid = sessionIdRef.current;
const snapshot = messages;
saveTimerRef.current = window.setTimeout(() => {
if (storage) {
storage.save(sid, snapshot).catch(() => {});
} else {
saveMessages(sid, snapshot).catch(() => {});
}
saveTimerRef.current = null;
}, SAVE_DEBOUNCE_MS);
return () => {
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, [messages]);
// Clean up RAF on unmount
useEffect(() => {
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, []);
function sendPrompt(
text: string,
attachmentIds?: string[],
images?: { filename: string; dataUrl: string }[],
cwdParam?: { root?: string; path: string },
groupSlug?: string | null,
) {
setMessages((prev) => [...prev, { role: 'user', text, ...(images?.length ? { images } : {}) }]);
setIsGenerating(true);
streamingRef.current = '';
setStreamingText('');
// Parse dataUrls into { mediaType, data } for the server
const imageData = images
?.map((img) => {
const match = img.dataUrl.match(/^data:([^;]+);base64,(.+)$/);
return match ? { mediaType: match[1], data: match[2] } : null;
})
.filter((x): x is { mediaType: string; data: string } => x !== null);
send({
type: 'chat',
prompt: text,
sessionId: sessionIdRef.current,
...(selectedModel ? { model: selectedModel } : {}),
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
...(groupSlug !== undefined ? { groupSlug } : {}),
...(attachmentIds?.length ? { attachmentIds } : {}),
...(imageData?.length ? { images: imageData } : {}),
...(resourceChatDir ? { resourceChatDir } : {}),
...(taskInfo ? { taskInfo } : {}),
});
}
function stopGeneration() {
send({ type: 'stop' });
}
return {
messages,
streamingText,
isConnected,
isGenerating,
sessionId,
model,
selectedModel,
cwd,
setSelectedModel,
sendPrompt,
stopGeneration,
};
}
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import type { ChatMessage, ServerMessage, TaskInfo } from 'apps/Chat';
import type { LegacyChatMessage, LegacyServerMessage, TaskInfo } from 'apps/Chat';
type UsePiMonoOptions = {
replaceUrl?: boolean;
@@ -9,7 +9,7 @@ type UsePiMonoOptions = {
export const usePiMono = (initialSessionId?: string, initialModel?: string | null, options?: UsePiMonoOptions) => {
const { replaceUrl = true, taskInfo } = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [messages, setMessages] = useState<LegacyChatMessage[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
@@ -40,7 +40,7 @@ export const usePiMono = (initialSessionId?: string, initialModel?: string | nul
};
const handleMessage = (data: unknown) => {
const msg = data as ServerMessage;
const msg = data as LegacyServerMessage;
switch (msg.type) {
case 'session:init':
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
import { MessageBubble, type ChatMessage } from 'apps/Chat';
import { MessageBubble, type LegacyChatMessage } from 'apps/Chat';
type LogMetadata = {
filename: string;
@@ -18,7 +18,7 @@ type LogMetadata = {
};
type FullLog = LogMetadata & {
messages: ChatMessage[];
messages: LegacyChatMessage[];
};
const formatDate = (iso: string) => {