Complete frontend migration to unified Pi harness (Phase 7 + Phase 9)
- Delete legacy hooks: useClaude.ts, useOpenCode.ts, usePiMono.ts, App.backup.tsx - Update all components to use usePi instead of legacy hooks - Replace useVisiblePiMonoModels/useClaudeModels/useOpenCodeModels with useVisiblePiModels - Migrate from LegacyChatMessage to ChatMessage type throughout - Update SessionBar to remove provider and archive props - Simplify ChatDetailPanel to Pi-only (remove Claude/OpenCode components) - Fix useChatSessions calls (remove provider parameter) - Update user-settings types: provider now only 'pi' instead of legacy values - Update PI_HARNESS_REBUILD.md to mark phases complete
This commit is contained in:
@@ -8,9 +8,9 @@ import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search, ChevronRight } from
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useVisiblePiMonoModels } from '@/state/useModels';
|
||||
import { useVisiblePiModels } from '@/state/useModels';
|
||||
import { Card } from '@/components/Card';
|
||||
import { usePiMono } from '@/Screens/Dashboard/Chat/usePiMono';
|
||||
import { usePi } from '@/Screens/Dashboard/Chat/usePi';
|
||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
type CapabilitySummary = {
|
||||
dirName: string;
|
||||
@@ -64,30 +64,30 @@ export const CapabilityChat = ({
|
||||
description,
|
||||
onResponseEnd,
|
||||
}: CapabilityChatProps) => {
|
||||
const piMonoModels = useVisiblePiMonoModels();
|
||||
const piModels = useVisiblePiModels();
|
||||
const seedFile = `${kind.toUpperCase()}.md`;
|
||||
const promptFrontmatter = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
|
||||
const defaultInput = isNew
|
||||
? description ?? `Help me create the content for this new ${kind} file`
|
||||
: `Help me understand and improve this ${kind} file`;
|
||||
|
||||
const piMono = usePiMono(undefined, undefined, { replaceUrl: false });
|
||||
const pi = usePi(undefined, undefined, { replaceUrl: false });
|
||||
|
||||
const onResponseEndRef = useRef(onResponseEnd);
|
||||
onResponseEndRef.current = onResponseEnd;
|
||||
|
||||
const wasGenerating = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wasGenerating.current && !piMono.isGenerating) {
|
||||
if (wasGenerating.current && !pi.isGenerating) {
|
||||
onResponseEndRef.current?.();
|
||||
}
|
||||
wasGenerating.current = piMono.isGenerating;
|
||||
}, [piMono.isGenerating]);
|
||||
wasGenerating.current = pi.isGenerating;
|
||||
}, [pi.isGenerating]);
|
||||
|
||||
return (
|
||||
<EmbeddableChat
|
||||
chat={piMono}
|
||||
availableModels={piMonoModels}
|
||||
chat={pi}
|
||||
availableModels={piModels}
|
||||
defaultInput={defaultInput}
|
||||
promptPrefix={promptFrontmatter}
|
||||
className="h-full"
|
||||
|
||||
@@ -4,19 +4,18 @@ import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { useSlashCommands } from '@/state/useSlashCommands';
|
||||
import { SessionBar } from 'apps/ChatHistory';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { useClaude } from './useClaude';
|
||||
import type { usePi } from './usePi';
|
||||
import { EmbeddableChat, type Attachment } from './EmbeddableChat';
|
||||
import { Card } from '@/components/Card';
|
||||
|
||||
export type { Attachment };
|
||||
|
||||
type ChatPanelProps = {
|
||||
chat: ReturnType<typeof useClaude>;
|
||||
provider?: 'claude' | 'opencode' | 'pi-mono';
|
||||
chat: ReturnType<typeof usePi>;
|
||||
availableModels?: ModelOption[];
|
||||
};
|
||||
|
||||
export const ChatPanel = ({ chat, provider = 'claude', availableModels = [] }: ChatPanelProps) => {
|
||||
export const ChatPanel = ({ chat, availableModels = [] }: ChatPanelProps) => {
|
||||
const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat;
|
||||
|
||||
const location = useLocation();
|
||||
@@ -25,7 +24,7 @@ export const ChatPanel = ({ chat, provider = 'claude', availableModels = [] }: C
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const initialSentRef = useRef(false);
|
||||
|
||||
const { sessions, archiveSession, deleteSession } = useChatSessions();
|
||||
const { sessions, deleteSession } = useChatSessions();
|
||||
const slashCommands = useSlashCommands({ sessionId });
|
||||
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
|
||||
const listPath = '/chat';
|
||||
@@ -78,22 +77,13 @@ export const ChatPanel = ({ chat, provider = 'claude', availableModels = [] }: C
|
||||
>
|
||||
<SessionBar
|
||||
listPath={listPath}
|
||||
provider={provider}
|
||||
sessionTitle={sessionTitle}
|
||||
isConnected={isConnected}
|
||||
isGenerating={isGenerating}
|
||||
fullscreen={fullscreen}
|
||||
onArchive={
|
||||
provider === 'claude' && sessionId
|
||||
? async () => {
|
||||
await archiveSession(provider, sessionId);
|
||||
navigate(listPath);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDelete={async () => {
|
||||
if (!sessionId) return;
|
||||
await deleteSession(provider, sessionId);
|
||||
await deleteSession(sessionId);
|
||||
navigate(listPath);
|
||||
}}
|
||||
onToggleFullscreen={() => setFullscreen((f) => !f)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRef, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { useClaude } from './useClaude';
|
||||
import type { usePi } from './usePi';
|
||||
import { MessageList } from 'apps/Chat';
|
||||
import { InputArea } from './InputArea';
|
||||
|
||||
@@ -12,7 +12,7 @@ export type Attachment =
|
||||
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean };
|
||||
|
||||
type EmbeddableChatProps = {
|
||||
chat: ReturnType<typeof useClaude>;
|
||||
chat: ReturnType<typeof usePi>;
|
||||
availableModels?: ModelOption[];
|
||||
onBeforeSend?: (text: string) => boolean | Promise<boolean>;
|
||||
commandFeedback?: string | null;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { LegacyChatMessage } from 'apps/Chat';
|
||||
import type { ChatMessage } 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: LegacyChatMessage[];
|
||||
messages: ChatMessage[];
|
||||
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 { LegacyChatMessage } from 'apps/Chat';
|
||||
import type { ChatMessage } from 'apps/Chat';
|
||||
|
||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
@@ -21,7 +21,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
};
|
||||
|
||||
type SettingsProps = {
|
||||
messages: LegacyChatMessage[];
|
||||
messages: ChatMessage[];
|
||||
availableModels: ModelOption[];
|
||||
selectedModel: string | null;
|
||||
onModelChange: (modelId: string) => void;
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import type { LegacyChatMessage, LegacyServerMessage, TaskInfo } from 'apps/Chat';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 1000;
|
||||
|
||||
type ResourceChatStorage = {
|
||||
load: () => Promise<{ sessionId: string | null; messages: LegacyChatMessage[] }>;
|
||||
save: (sessionId: string, messages: LegacyChatMessage[]) => Promise<void>;
|
||||
};
|
||||
|
||||
type UseClaudeOptions = {
|
||||
replaceUrl?: boolean;
|
||||
storage?: ResourceChatStorage;
|
||||
resourceChatDir?: string;
|
||||
taskInfo?: TaskInfo;
|
||||
};
|
||||
|
||||
export const useClaude = (initialSessionId?: string, initialModel?: string | null, options?: UseClaudeOptions) => {
|
||||
const { replaceUrl = true, storage, resourceChatDir, taskInfo } = options ?? {};
|
||||
const [messages, setMessages] = useState<LegacyChatMessage[]>([]);
|
||||
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 streamingRef = useRef('');
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
|
||||
const { getMessages, saveMessages } = useChatSessions();
|
||||
|
||||
const token = localStorage.getItem('BEARER_TOKEN');
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/harness/claudecode/ws?token=${token}`;
|
||||
|
||||
const flushStreaming = () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setStreamingText(streamingRef.current);
|
||||
rafRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const commitStreaming = () => {
|
||||
if (!streamingRef.current) return;
|
||||
setMessages((prev) => [...prev, { role: 'assistant', text: streamingRef.current }]);
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
};
|
||||
|
||||
const handleMessage = (data: unknown) => {
|
||||
const msg = data as LegacyServerMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'session:init':
|
||||
sessionIdRef.current = msg.sessionId;
|
||||
setSessionId(msg.sessionId);
|
||||
setModel(msg.model);
|
||||
if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`);
|
||||
break;
|
||||
|
||||
case 'system:prompt':
|
||||
setMessages((prev) => [...prev, { role: 'system', text: msg.text }]);
|
||||
break;
|
||||
|
||||
case 'assistant:partial':
|
||||
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:use':
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'tool', toolName: msg.toolName, toolInput: msg.toolInput, toolUseId: msg.toolUseId },
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'tool:result':
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === 'tool' && m.toolUseId === msg.toolUseId ? { ...m, output: msg.output, isError: msg.isError } : m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
commitStreaming();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'result',
|
||||
costUsd: msg.costUsd,
|
||||
durationMs: msg.durationMs,
|
||||
numTurns: msg.numTurns,
|
||||
isError: msg.isError,
|
||||
},
|
||||
]);
|
||||
setIsGenerating(false);
|
||||
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;
|
||||
getMessages('claude', initialSessionId)
|
||||
.then((data) => {
|
||||
if (Array.isArray(data) && data.length > 0) setMessages(data);
|
||||
})
|
||||
.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('claude', 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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendPrompt = (
|
||||
text: string,
|
||||
attachmentIds?: string[],
|
||||
images?: { filename: string; dataUrl: string }[],
|
||||
cwd?: { root?: string; path: string },
|
||||
) => {
|
||||
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 } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(attachmentIds?.length ? { attachmentIds } : {}),
|
||||
...(imageData?.length ? { images: imageData } : {}),
|
||||
...(resourceChatDir ? { resourceChatDir } : {}),
|
||||
...(taskInfo ? { taskInfo } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const stopGeneration = () => {
|
||||
send({ type: 'stop' });
|
||||
};
|
||||
|
||||
return {
|
||||
messages,
|
||||
streamingText,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
sessionId,
|
||||
model,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
};
|
||||
};
|
||||
@@ -1,249 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import type { LegacyChatMessage, LegacyServerMessage, TaskInfo } from 'apps/Chat';
|
||||
|
||||
const SYSTEM_RE = /^<system>([\s\S]*?)<\/system>\s*/;
|
||||
|
||||
const splitSystemBlocks = (messages: LegacyChatMessage[]): LegacyChatMessage[] => {
|
||||
const result: LegacyChatMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== 'user') {
|
||||
result.push(msg);
|
||||
continue;
|
||||
}
|
||||
const match = msg.text.match(SYSTEM_RE);
|
||||
if (!match) {
|
||||
result.push(msg);
|
||||
continue;
|
||||
}
|
||||
result.push({ role: 'user', text: msg.text.slice(match[0]!.length), images: msg.images });
|
||||
result.push({ role: 'system', text: match[1]!.trim() });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
type UseOpenCodeOptions = {
|
||||
replaceUrl?: boolean;
|
||||
taskInfo?: TaskInfo;
|
||||
};
|
||||
|
||||
export const useOpenCode = (initialSessionId?: string, initialModel?: string | null, options?: UseOpenCodeOptions) => {
|
||||
const { replaceUrl = true, taskInfo } = options ?? {};
|
||||
const [messages, setMessages] = useState<LegacyChatMessage[]>([]);
|
||||
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 streamingRef = useRef('');
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
|
||||
const selectedModelRef = useRef<string | null>(initialModel ?? null);
|
||||
|
||||
const updateSelectedModel = (value: string | null) => {
|
||||
selectedModelRef.current = value;
|
||||
setSelectedModel(value);
|
||||
};
|
||||
|
||||
const { getMessages } = useChatSessions();
|
||||
const { settings } = useSettings();
|
||||
const openCodeModels = useVisibleOpenCodeModels();
|
||||
|
||||
const token = localStorage.getItem('BEARER_TOKEN');
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/harness/opencode/ws?token=${token}`;
|
||||
|
||||
const flushStreaming = () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setStreamingText(streamingRef.current);
|
||||
rafRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const commitStreaming = () => {
|
||||
// Cancel any pending RAF to prevent stale reads of cleared streamingRef
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
if (!streamingRef.current) return;
|
||||
setMessages((prev) => [...prev, { role: 'assistant', text: streamingRef.current }]);
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
};
|
||||
|
||||
const handleMessage = (data: unknown) => {
|
||||
const msg = data as LegacyServerMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'session:init':
|
||||
sessionIdRef.current = msg.sessionId;
|
||||
setSessionId(msg.sessionId);
|
||||
setModel(msg.model);
|
||||
if (replaceUrl) window.history.replaceState(null, '', `/chat/opencode/${msg.sessionId}`);
|
||||
break;
|
||||
|
||||
case 'system:prompt':
|
||||
setMessages((prev) => [...prev, { role: 'system', text: msg.text }]);
|
||||
break;
|
||||
|
||||
case 'assistant:partial':
|
||||
streamingRef.current += msg.text;
|
||||
flushStreaming();
|
||||
break;
|
||||
|
||||
case 'assistant:text':
|
||||
// Server sends the final complete text — discard streaming and use this instead
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
setMessages((prev) => [...prev, { role: 'assistant', text: msg.text }]);
|
||||
break;
|
||||
|
||||
case 'tool:use':
|
||||
commitStreaming();
|
||||
setMessages((prev) => {
|
||||
const existing = prev.find((m) => m.role === 'tool' && m.toolUseId === msg.toolUseId);
|
||||
if (existing) {
|
||||
// Update input (running event sends actual input after pending)
|
||||
return prev.map((m) =>
|
||||
m.role === 'tool' && m.toolUseId === msg.toolUseId
|
||||
? { ...m, toolName: msg.toolName, toolInput: msg.toolInput }
|
||||
: m,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ role: 'tool', toolName: msg.toolName, toolInput: msg.toolInput, toolUseId: msg.toolUseId },
|
||||
];
|
||||
});
|
||||
break;
|
||||
|
||||
case 'tool:result':
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === 'tool' && m.toolUseId === msg.toolUseId ? { ...m, output: msg.output, isError: msg.isError } : m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
commitStreaming();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'result',
|
||||
costUsd: msg.costUsd,
|
||||
durationMs: msg.durationMs,
|
||||
numTurns: msg.numTurns,
|
||||
isError: msg.isError,
|
||||
},
|
||||
]);
|
||||
setIsGenerating(false);
|
||||
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 OpenCode on mount when resuming a session
|
||||
useEffect(() => {
|
||||
if (!initialSessionId) return;
|
||||
getMessages('opencode', initialSessionId)
|
||||
.then((data) => {
|
||||
if (Array.isArray(data) && data.length > 0) setMessages(splitSystemBlocks(data));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [initialSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedModelRef.current = selectedModel;
|
||||
}, [selectedModel]);
|
||||
|
||||
// Seed default model for OpenCode if none selected
|
||||
useEffect(() => {
|
||||
if (selectedModel) return;
|
||||
if (settings.chat.defaultProvider !== 'opencode' || !settings.chat.defaultModel) return;
|
||||
if (!openCodeModels.some((m) => m.id === settings.chat.defaultModel)) return;
|
||||
updateSelectedModel(settings.chat.defaultModel);
|
||||
}, [openCodeModels, selectedModel, settings.chat.defaultModel, settings.chat.defaultProvider]);
|
||||
|
||||
// Clean up RAF on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendPrompt = (text: string, attachmentIds?: string[], images?: { filename: string; dataUrl: string }[]) => {
|
||||
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);
|
||||
|
||||
const modelId = selectedModelRef.current;
|
||||
const selectedOption = modelId ? openCodeModels.find((m) => m.id === modelId) : undefined;
|
||||
const payload = {
|
||||
type: 'chat',
|
||||
prompt: text,
|
||||
sessionId: sessionIdRef.current,
|
||||
...(modelId
|
||||
? {
|
||||
model: {
|
||||
modelID: modelId,
|
||||
...(selectedOption?.providerId ? { providerID: selectedOption.providerId } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(attachmentIds?.length ? { attachmentIds } : {}),
|
||||
...(imageData?.length ? { images: imageData } : {}),
|
||||
...(taskInfo ? { taskInfo } : {}),
|
||||
};
|
||||
console.log('[opencode-ui] ws send', payload);
|
||||
send(payload);
|
||||
};
|
||||
|
||||
const stopGeneration = () => {
|
||||
send({ type: 'stop' });
|
||||
};
|
||||
|
||||
return {
|
||||
messages,
|
||||
streamingText,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
sessionId,
|
||||
model,
|
||||
selectedModel,
|
||||
setSelectedModel: updateSelectedModel,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
};
|
||||
};
|
||||
@@ -1,178 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import type { LegacyChatMessage, LegacyServerMessage, TaskInfo } from 'apps/Chat';
|
||||
|
||||
type UsePiMonoOptions = {
|
||||
replaceUrl?: boolean;
|
||||
taskInfo?: TaskInfo;
|
||||
};
|
||||
|
||||
export const usePiMono = (initialSessionId?: string, initialModel?: string | null, options?: UsePiMonoOptions) => {
|
||||
const { replaceUrl = true, taskInfo } = options ?? {};
|
||||
const [messages, setMessages] = useState<LegacyChatMessage[]>([]);
|
||||
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 streamingRef = useRef('');
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
|
||||
|
||||
const token = localStorage.getItem('BEARER_TOKEN');
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/harness/pi-mono/ws?token=${token}`;
|
||||
|
||||
const flushStreaming = () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setStreamingText(streamingRef.current);
|
||||
rafRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const commitStreaming = () => {
|
||||
if (!streamingRef.current) return;
|
||||
setMessages((prev) => [...prev, { role: 'assistant', text: streamingRef.current }]);
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
};
|
||||
|
||||
const handleMessage = (data: unknown) => {
|
||||
const msg = data as LegacyServerMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'session:init':
|
||||
sessionIdRef.current = msg.sessionId;
|
||||
setSessionId(msg.sessionId);
|
||||
setModel(msg.model);
|
||||
if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`);
|
||||
break;
|
||||
|
||||
case 'messages:sync':
|
||||
setMessages(msg.messages);
|
||||
streamingRef.current = msg.streamingText;
|
||||
setStreamingText(msg.streamingText);
|
||||
setIsGenerating(msg.isGenerating);
|
||||
break;
|
||||
|
||||
case 'assistant:partial':
|
||||
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:use':
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'tool', toolName: msg.toolName, toolInput: msg.toolInput, toolUseId: msg.toolUseId },
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'tool:result':
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === 'tool' && m.toolUseId === msg.toolUseId ? { ...m, output: msg.output, isError: msg.isError } : m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
commitStreaming();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'result',
|
||||
costUsd: msg.costUsd,
|
||||
durationMs: msg.durationMs,
|
||||
numTurns: msg.numTurns,
|
||||
isError: msg.isError,
|
||||
},
|
||||
]);
|
||||
setIsGenerating(false);
|
||||
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 });
|
||||
|
||||
// Attach to an existing server-side session on reconnect
|
||||
useEffect(() => {
|
||||
if (isConnected && initialSessionId) {
|
||||
send({ type: 'resume', sessionId: initialSessionId });
|
||||
}
|
||||
}, [isConnected, initialSessionId]);
|
||||
|
||||
// Clean up RAF on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendPrompt = (
|
||||
text: string,
|
||||
attachmentIds?: string[],
|
||||
images?: { filename: string; dataUrl: string }[],
|
||||
cwd?: { root?: string; path: string },
|
||||
) => {
|
||||
setMessages((prev) => [...prev, { role: 'user', text, ...(images?.length ? { images } : {}) }]);
|
||||
setIsGenerating(true);
|
||||
streamingRef.current = '';
|
||||
setStreamingText('');
|
||||
|
||||
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,
|
||||
...(sessionIdRef.current ? { sessionId: sessionIdRef.current } : {}),
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(attachmentIds?.length ? { attachmentIds } : {}),
|
||||
...(imageData?.length ? { images: imageData } : {}),
|
||||
...(taskInfo ? { taskInfo } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const stopGeneration = () => {
|
||||
send({ type: 'stop' });
|
||||
};
|
||||
|
||||
return {
|
||||
messages,
|
||||
streamingText,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
sessionId,
|
||||
model,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
sendPrompt,
|
||||
stopGeneration,
|
||||
};
|
||||
};
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Trash2, Archive } from 'lucide-react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels';
|
||||
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
|
||||
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
|
||||
import { usePiMono } from '@/Screens/Dashboard/Chat/usePiMono';
|
||||
import { useVisiblePiModels } from '@/state/useModels';
|
||||
import { usePi } from '@/Screens/Dashboard/Chat/usePi';
|
||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
|
||||
export type SelectedSession = {
|
||||
id: string;
|
||||
provider: 'claude' | 'opencode' | 'pi-mono';
|
||||
model?: string | null;
|
||||
} | null;
|
||||
|
||||
@@ -27,95 +24,50 @@ type ChatLocationState = {
|
||||
} | null;
|
||||
|
||||
type DetailBarProps = {
|
||||
provider: 'claude' | 'opencode' | 'pi-mono';
|
||||
sessionTitle: string | undefined;
|
||||
isConnected: boolean;
|
||||
isGenerating: boolean;
|
||||
onArchive: (() => void) | undefined;
|
||||
onDelete: (() => void) | undefined;
|
||||
};
|
||||
|
||||
const DetailBar = ({
|
||||
provider,
|
||||
sessionTitle,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
onArchive,
|
||||
onDelete,
|
||||
}: DetailBarProps) => (
|
||||
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
|
||||
<div className="flex items-center gap-1">
|
||||
{provider === 'claude' && onArchive && (
|
||||
<button
|
||||
onClick={onArchive}
|
||||
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal transition-colors cursor-pointer"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
function DetailBar({ sessionTitle, isConnected, isGenerating, onDelete }: DetailBarProps) {
|
||||
return (
|
||||
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
|
||||
<div className="flex items-center gap-1">
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3">
|
||||
{sessionTitle ?? 'New chat'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
{!isConnected ? (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
|
||||
) : isGenerating ? (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
|
||||
) : (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
|
||||
)}
|
||||
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3">
|
||||
{sessionTitle ?? 'New chat'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
{!isConnected ? (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
|
||||
) : isGenerating ? (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
|
||||
) : (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
|
||||
)}
|
||||
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
type InnerProps = {
|
||||
type SessionChatProps = {
|
||||
sessionId: string;
|
||||
model?: string | null;
|
||||
};
|
||||
|
||||
const ClaudeInner = ({ sessionId, model }: InnerProps) => {
|
||||
const chat = useClaude(sessionId, model, { replaceUrl: false });
|
||||
const models = useVisibleClaudeModels();
|
||||
const { sessions, archiveSession, deleteSession } = useChatSessions();
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<DetailBar
|
||||
provider="claude"
|
||||
sessionTitle={sessionTitle}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
onArchive={async () => {
|
||||
await archiveSession('claude', sessionId);
|
||||
setSelected(null);
|
||||
window.history.replaceState(null, '', '/chat');
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await deleteSession('claude', sessionId);
|
||||
setSelected(null);
|
||||
window.history.replaceState(null, '', '/chat');
|
||||
}}
|
||||
/>
|
||||
<EmbeddableChat chat={chat} availableModels={models} className="flex-1 min-h-0" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OpenCodeInner = ({ sessionId, model }: InnerProps) => {
|
||||
const chat = useOpenCode(sessionId, model, { replaceUrl: false });
|
||||
const models = useVisibleOpenCodeModels();
|
||||
function SessionChat({ sessionId, model }: SessionChatProps) {
|
||||
const chat = usePi(sessionId, model, { replaceUrl: false });
|
||||
const models = useVisiblePiModels();
|
||||
const { sessions, deleteSession } = useChatSessions();
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
||||
@@ -123,13 +75,11 @@ const OpenCodeInner = ({ sessionId, model }: InnerProps) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<DetailBar
|
||||
provider="opencode"
|
||||
sessionTitle={sessionTitle}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
onArchive={undefined}
|
||||
onDelete={async () => {
|
||||
await deleteSession('opencode', sessionId);
|
||||
await deleteSession(sessionId);
|
||||
setSelected(null);
|
||||
window.history.replaceState(null, '', '/chat');
|
||||
}}
|
||||
@@ -137,45 +87,19 @@ const OpenCodeInner = ({ sessionId, model }: InnerProps) => {
|
||||
<EmbeddableChat chat={chat} availableModels={models} className="flex-1 min-h-0" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const PiMonoInner = ({ sessionId, model }: InnerProps) => {
|
||||
const chat = usePiMono(sessionId, model, { replaceUrl: false });
|
||||
const models = useVisiblePiMonoModels();
|
||||
const { sessions, deleteSession } = useChatSessions();
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<DetailBar
|
||||
provider="pi-mono"
|
||||
sessionTitle={sessionTitle}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
onArchive={undefined}
|
||||
onDelete={async () => {
|
||||
await deleteSession('pi-mono', sessionId);
|
||||
setSelected(null);
|
||||
window.history.replaceState(null, '', '/chat');
|
||||
}}
|
||||
/>
|
||||
<EmbeddableChat chat={chat} availableModels={models} className="flex-1 min-h-0" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NewClaudeInner = () => {
|
||||
function NewChat() {
|
||||
const location = useLocation();
|
||||
const locationState = location.state as ChatLocationState;
|
||||
const initialSentRef = useRef(false);
|
||||
const chat = useClaude();
|
||||
const models = useVisibleClaudeModels();
|
||||
const chat = usePi();
|
||||
const models = useVisiblePiModels();
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.sessionId) {
|
||||
setSelected({ id: chat.sessionId, provider: 'claude', model: chat.model });
|
||||
setSelected({ id: chat.sessionId, model: chat.model });
|
||||
}
|
||||
}, [chat.sessionId]);
|
||||
|
||||
@@ -196,11 +120,9 @@ const NewClaudeInner = () => {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<DetailBar
|
||||
provider="claude"
|
||||
sessionTitle={undefined}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
onArchive={undefined}
|
||||
onDelete={undefined}
|
||||
/>
|
||||
<EmbeddableChat
|
||||
@@ -212,120 +134,19 @@ const NewClaudeInner = () => {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const NewOpenCodeInner = () => {
|
||||
const location = useLocation();
|
||||
const locationState = location.state as ChatLocationState;
|
||||
const initialSentRef = useRef(false);
|
||||
const chat = useOpenCode();
|
||||
const models = useVisibleOpenCodeModels();
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.sessionId) {
|
||||
setSelected({ id: chat.sessionId, provider: 'opencode', model: chat.model });
|
||||
}
|
||||
}, [chat.sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locationState || initialSentRef.current || !chat.isConnected) return;
|
||||
if (locationState.prefillInput) {
|
||||
initialSentRef.current = true;
|
||||
window.history.replaceState({}, '', location.pathname);
|
||||
return;
|
||||
}
|
||||
if (!locationState.initialMessage) return;
|
||||
initialSentRef.current = true;
|
||||
if (locationState.model) chat.setSelectedModel(locationState.model);
|
||||
chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images);
|
||||
window.history.replaceState({}, '', location.pathname);
|
||||
}, [chat.isConnected, location.state]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<DetailBar
|
||||
provider="opencode"
|
||||
sessionTitle={undefined}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
onArchive={undefined}
|
||||
onDelete={undefined}
|
||||
/>
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
availableModels={models}
|
||||
defaultInput={locationState?.prefillInput ?? ''}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NewPiMonoInner = () => {
|
||||
const location = useLocation();
|
||||
const locationState = location.state as ChatLocationState;
|
||||
const initialSentRef = useRef(false);
|
||||
const chat = usePiMono();
|
||||
const models = useVisiblePiMonoModels();
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.sessionId) {
|
||||
setSelected({ id: chat.sessionId, provider: 'pi-mono', model: chat.model });
|
||||
}
|
||||
}, [chat.sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locationState || initialSentRef.current || !chat.isConnected) return;
|
||||
if (locationState.prefillInput) {
|
||||
initialSentRef.current = true;
|
||||
window.history.replaceState({}, '', location.pathname);
|
||||
return;
|
||||
}
|
||||
if (!locationState.initialMessage) return;
|
||||
initialSentRef.current = true;
|
||||
if (locationState.model) chat.setSelectedModel(locationState.model);
|
||||
chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images);
|
||||
window.history.replaceState({}, '', location.pathname);
|
||||
}, [chat.isConnected, location.state]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<DetailBar
|
||||
provider="pi-mono"
|
||||
sessionTitle={undefined}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
onArchive={undefined}
|
||||
onDelete={undefined}
|
||||
/>
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
availableModels={models}
|
||||
defaultInput={locationState?.prefillInput ?? ''}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type NewChatPanelProps = {
|
||||
initialProvider?: 'claude' | 'opencode' | 'pi-mono';
|
||||
};
|
||||
|
||||
const NewChatPanel = ({ initialProvider = 'pi-mono' }: NewChatPanelProps) => {
|
||||
function NewChatPanel() {
|
||||
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
|
||||
// Once a session is created, the inner component updates selected via the channel
|
||||
if (selected && selected.id !== 'new') {
|
||||
return <PiMonoInner key={selected.id} sessionId={selected.id} model={selected.model} />;
|
||||
return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />;
|
||||
}
|
||||
|
||||
return <NewPiMonoInner key="new-pi-mono" />;
|
||||
};
|
||||
return <NewChat key="new" />;
|
||||
}
|
||||
|
||||
export const ChatDetailPanel = () => {
|
||||
export function ChatDetailPanel() {
|
||||
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
|
||||
if (!selected) {
|
||||
@@ -337,8 +158,8 @@ export const ChatDetailPanel = () => {
|
||||
}
|
||||
|
||||
if (selected.id === 'new') {
|
||||
return <NewChatPanel key="new" initialProvider={selected.provider} />;
|
||||
return <NewChatPanel key="new" />;
|
||||
}
|
||||
|
||||
return <PiMonoInner key={selected.id} sessionId={selected.id} model={selected.model} />;
|
||||
};
|
||||
return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />;
|
||||
}
|
||||
|
||||
@@ -20,19 +20,19 @@ export const SessionList = () => {
|
||||
|
||||
useEffect(() => {
|
||||
scrolledRef.current = false;
|
||||
}, [selected?.id, selected?.provider]);
|
||||
}, [selected?.id]);
|
||||
|
||||
const handleSelect = (session: (typeof sessions)[number]) => {
|
||||
setSelected({ id: session.id, provider: session.provider, model: session.model ?? null });
|
||||
setSelected({ id: session.id, model: session.model ?? null });
|
||||
window.history.replaceState(null, '', `/chat/${session.id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (provider: 'claude' | 'opencode' | 'pi-mono', id: string) => {
|
||||
if (selected?.id === id && selected?.provider === provider) {
|
||||
const handleDelete = async (id: string) => {
|
||||
if (selected?.id === id) {
|
||||
setSelected(null);
|
||||
window.history.replaceState(null, '', '/chat');
|
||||
}
|
||||
await deleteSession(provider, id);
|
||||
await deleteSession(id);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -42,7 +42,7 @@ export const SessionList = () => {
|
||||
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelected({ id: 'new', provider: 'pi-mono' });
|
||||
setSelected({ id: 'new' });
|
||||
window.history.replaceState(null, '', '/chat/new');
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
|
||||
@@ -61,10 +61,10 @@ export const SessionList = () => {
|
||||
)}
|
||||
|
||||
{sessions.map((session) => {
|
||||
const isSelected = selected?.id === session.id && selected?.provider === session.provider;
|
||||
const isSelected = selected?.id === session.id;
|
||||
return (
|
||||
<div
|
||||
key={`${session.provider}-${session.id}`}
|
||||
key={session.id}
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
className={`group flex items-center rounded-lg border transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
@@ -100,7 +100,7 @@ export const SessionList = () => {
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(session.provider, session.id)}
|
||||
onClick={() => handleDelete(session.id)}
|
||||
className="shrink-0 p-2 mr-2 text-duck-dark/20 dark:text-foreground/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ChatHistory = () => {
|
||||
<ul className="space-y-0.5">
|
||||
{sessions.map((session) => (
|
||||
<li
|
||||
key={`${session.provider}-${session.id}`}
|
||||
key={session.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group"
|
||||
>
|
||||
<Link
|
||||
@@ -46,7 +46,7 @@ export const ChatHistory = () => {
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => deleteSession(session.provider, session.id)}
|
||||
onClick={() => deleteSession(session.id)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -32,12 +32,12 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
setSelected({ id: 'new', provider: 'pi-mono' });
|
||||
setSelected({ id: 'new' });
|
||||
return;
|
||||
}
|
||||
if (!sessionId) return;
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
setSelected({ id: sessionId, provider: session?.provider ?? 'pi-mono', model: session?.model ?? null });
|
||||
setSelected({ id: sessionId, model: session?.model ?? null });
|
||||
}, [sessionId, isNew]);
|
||||
|
||||
const panelComponents: PanelComponents = useMemo(
|
||||
|
||||
@@ -4,9 +4,9 @@ import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
import type { TaskInfo } from 'apps/Chat';
|
||||
import { usePiMono } from '@/Screens/Dashboard/Chat/usePiMono';
|
||||
import { usePi } from '@/Screens/Dashboard/Chat/usePi';
|
||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
import { useVisiblePiMonoModels } from '@/state/useModels';
|
||||
import { useVisiblePiModels } from '@/state/useModels';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import type { TaskSummary } from 'apps/FileBrowser';
|
||||
|
||||
@@ -47,8 +47,8 @@ const PiMonoInner = ({
|
||||
initialModel,
|
||||
taskInfo,
|
||||
}: PiMonoInnerProps) => {
|
||||
const chat = usePiMono(undefined, initialModel, { replaceUrl: false, taskInfo });
|
||||
const models = useVisiblePiMonoModels();
|
||||
const chat = usePi(undefined, initialModel, { replaceUrl: false, taskInfo });
|
||||
const models = useVisiblePiModels();
|
||||
|
||||
const wasGenerating = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -108,10 +108,10 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType
|
||||
|
||||
{/* Chat */}
|
||||
<PiMonoInner
|
||||
key="pi-mono"
|
||||
key="pi"
|
||||
defaultInput={defaultInput}
|
||||
cwd={cwd}
|
||||
initialModel={taskSettings.defaultProvider === 'pi-mono' ? taskSettings.defaultModel : null}
|
||||
initialModel={taskSettings.defaultProvider === 'pi' ? taskSettings.defaultModel : null}
|
||||
taskInfo={taskInfo}
|
||||
/>
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useVisiblePiMonoModels } from '@/state/useModels';
|
||||
import { useVisiblePiModels, type ModelOption } from '@/state/useModels';
|
||||
import type { Attachment } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
|
||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
@@ -46,7 +46,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
export const ChatLauncher = () => {
|
||||
const navigate = useNavigate();
|
||||
const { settings } = useSettings();
|
||||
const piMonoModels = useVisiblePiMonoModels();
|
||||
const piModels = useVisiblePiModels();
|
||||
|
||||
const client = useClient();
|
||||
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
||||
@@ -62,17 +62,17 @@ export const ChatLauncher = () => {
|
||||
}, [settings.chat.defaultModel]);
|
||||
|
||||
const providers = useMemo(
|
||||
() => [...new Set(piMonoModels.map((m) => m.provider).filter(Boolean))] as string[],
|
||||
[piMonoModels],
|
||||
() => [...new Set(piModels.map((m: ModelOption) => m.provider).filter(Boolean))] as string[],
|
||||
[piModels],
|
||||
);
|
||||
|
||||
const activeProvider = piMonoModels.find((m) => m.id === model)?.provider ?? providers[0];
|
||||
const providerModels = piMonoModels.filter((m) => m.provider === activeProvider);
|
||||
const activeProvider = piModels.find((m: ModelOption) => m.id === model)?.provider ?? providers[0];
|
||||
const providerModels = piModels.filter((m: ModelOption) => m.provider === activeProvider);
|
||||
|
||||
const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider;
|
||||
|
||||
const handleProviderClick = (provider: string) => {
|
||||
const firstModel = piMonoModels.find((m) => m.provider === provider);
|
||||
const firstModel = piModels.find((m: ModelOption) => m.provider === provider);
|
||||
if (firstModel) setModel(firstModel.id);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,13 +12,11 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels';
|
||||
import { useVisiblePiModels, type ModelOption } from '@/state/useModels';
|
||||
|
||||
export const TaskDefaults = () => {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const claudeModels = useVisibleClaudeModels();
|
||||
const openCodeModels = useVisibleOpenCodeModels();
|
||||
const piMonoModels = useVisiblePiMonoModels();
|
||||
const piModels = useVisiblePiModels();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [model, setModel] = useState<string | null>(settings.tasks.defaultModel);
|
||||
@@ -27,7 +25,7 @@ export const TaskDefaults = () => {
|
||||
setModel(settings.tasks.defaultModel);
|
||||
}, [settings]);
|
||||
|
||||
const buildGroups = (models: { id: string; name: string; provider?: string }[], fallback: string) => {
|
||||
const buildGroups = (models: ModelOption[], fallback: string) => {
|
||||
const groups: Record<string, { id: string; name: string }[]> = {};
|
||||
for (const m of models) {
|
||||
const provider = m.provider ?? fallback;
|
||||
@@ -39,17 +37,13 @@ export const TaskDefaults = () => {
|
||||
.map(([provider, models]) => ({ provider, models: models.sort((a, b) => a.name.localeCompare(b.name)) }));
|
||||
};
|
||||
|
||||
const openCodeGroups = useMemo(() => buildGroups(openCodeModels, 'OpenCode'), [openCodeModels]);
|
||||
const piMonoGroups = useMemo(() => buildGroups(piMonoModels, 'Pi'), [piMonoModels]);
|
||||
const piGroups = useMemo(() => buildGroups(piModels, 'Pi'), [piModels]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const isPiMono = piMonoModels.some((m) => m.id === model);
|
||||
const isOpenCode = openCodeModels.some((m) => m.id === model);
|
||||
const defaultProvider = isPiMono ? ('pi-mono' as const) : isOpenCode ? ('opencode' as const) : ('claude' as const);
|
||||
await saveSettings({ ...settings, tasks: { defaultProvider, defaultModel: model } });
|
||||
await saveSettings({ ...settings, tasks: { defaultProvider: 'pi', defaultModel: model } });
|
||||
toast.success('Task defaults saved');
|
||||
} catch {
|
||||
toast.error('Failed to save settings');
|
||||
@@ -67,29 +61,9 @@ export const TaskDefaults = () => {
|
||||
<SelectValue placeholder="Same as chat default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600] max-h-[300px]">
|
||||
{claudeModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Claude</SelectLabel>
|
||||
{claudeModels.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{openCodeGroups.map(({ provider, models }) => (
|
||||
<SelectGroup key={provider}>
|
||||
<SelectLabel>{provider} (OpenCode)</SelectLabel>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={`${provider}:${m.id}`} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
{piMonoGroups.map(({ provider, models }) => (
|
||||
{piGroups.map(({ provider, models }) => (
|
||||
<SelectGroup key={`pi-${provider}`}>
|
||||
<SelectLabel>{provider} (Pi)</SelectLabel>
|
||||
<SelectLabel>{provider}</SelectLabel>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={`pi:${provider}:${m.id}`} value={m.id}>
|
||||
{m.name}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { appRegistry } from '../Workspaces/app-registry';
|
||||
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { usePiMonoModels, useVisiblePiMonoModels } from '@/state/useModels';
|
||||
import { usePiModels, useVisiblePiModels, type ModelOption } from '@/state/useModels';
|
||||
import type { UserSettings } from '@/state/types/user-settings';
|
||||
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
|
||||
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel';
|
||||
@@ -146,7 +146,7 @@ export const SystemSettings = () => {
|
||||
|
||||
function ChatDefaultsSection() {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const piMonoModels = useVisiblePiMonoModels();
|
||||
const piModels = useVisiblePiModels();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
||||
@@ -167,7 +167,7 @@ function ChatDefaultsSection() {
|
||||
try {
|
||||
const updated: UserSettings = {
|
||||
...settings,
|
||||
chat: { defaultProvider: 'pi-mono', defaultModel: model, systemPrompt, temperature, defaultPwd },
|
||||
chat: { defaultProvider: 'pi', defaultModel: model, systemPrompt, temperature, defaultPwd },
|
||||
};
|
||||
await saveSettings(updated);
|
||||
toast.success('Chat defaults saved');
|
||||
@@ -187,7 +187,7 @@ function ChatDefaultsSection() {
|
||||
<SelectValue placeholder="Default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600]">
|
||||
{piMonoModels.map((m) => (
|
||||
{piModels.map((m: ModelOption) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
<span className="font-bold">{m.name}</span>
|
||||
{m.provider && <span className="text-duck-dark/50 ml-1">({m.provider})</span>}
|
||||
@@ -304,14 +304,14 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
|
||||
function ModelVisibilitySection() {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const piMonoModels = usePiMonoModels();
|
||||
const piModels = usePiModels();
|
||||
const [activeProvider, setActiveProvider] = useUserState<string>('model-visibility-provider', '');
|
||||
|
||||
const enabledModels = settings.ai?.enabledModels ?? [];
|
||||
|
||||
const providerGroups = useMemo(() => {
|
||||
const groups: Record<string, { id: string; name: string }[]> = {};
|
||||
for (const m of piMonoModels) {
|
||||
for (const m of piModels) {
|
||||
const provider = m.provider ?? 'Other';
|
||||
if (!groups[provider]) groups[provider] = [];
|
||||
groups[provider].push({ id: m.id, name: m.name });
|
||||
@@ -319,7 +319,7 @@ function ModelVisibilitySection() {
|
||||
return Object.entries(groups)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, models]) => ({ provider, models: models.sort((a, b) => a.name.localeCompare(b.name)) }));
|
||||
}, [piMonoModels]);
|
||||
}, [piModels]);
|
||||
|
||||
const providers = useMemo(() => providerGroups.map((g) => g.provider), [providerGroups]);
|
||||
|
||||
|
||||
@@ -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 LegacyChatMessage } from 'apps/Chat';
|
||||
import { MessageBubble, type ChatMessage } from 'apps/Chat';
|
||||
|
||||
type LogMetadata = {
|
||||
filename: string;
|
||||
@@ -18,7 +18,7 @@ type LogMetadata = {
|
||||
};
|
||||
|
||||
type FullLog = LogMetadata & {
|
||||
messages: LegacyChatMessage[];
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
|
||||
@@ -9,9 +9,9 @@ import { TerminalView } from 'apps/Terminal';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
|
||||
import { usePiMono } from '../Chat/usePiMono';
|
||||
import { usePi } from '../Chat/usePi';
|
||||
import { ChatPanel } from '../Chat/ChatPanel';
|
||||
import { useVisiblePiMonoModels } from '@/state/useModels';
|
||||
import { useVisiblePiModels } from '@/state/useModels';
|
||||
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
||||
import { Files } from '../Files';
|
||||
import { Catalog } from 'sounds';
|
||||
@@ -22,9 +22,9 @@ import { widgetRegistry } from 'widgets/widget-registry';
|
||||
import { WidgetPanel } from 'widgets/WidgetPanel';
|
||||
|
||||
const ChatWidget = () => {
|
||||
const piMono = usePiMono();
|
||||
const models = useVisiblePiMonoModels();
|
||||
return <ChatPanel chat={piMono} provider="pi-mono" availableModels={models} />;
|
||||
const pi = usePi();
|
||||
const models = useVisiblePiModels();
|
||||
return <ChatPanel chat={pi} availableModels={models} />;
|
||||
};
|
||||
|
||||
const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
|
||||
|
||||
Reference in New Issue
Block a user