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:
2026-02-20 21:42:26 +00:00
parent 92f4b2be8e
commit ba51ee0320
31 changed files with 225 additions and 1176 deletions
@@ -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));