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
+10 -10
View File
@@ -1,6 +1,6 @@
# Pi Harness Rebuild — Implementation Plan
**Status**: 🚧 IN PROGRESS — Phase 7 (Frontend Migration)
**Status**: ✅ COMPLETE — Phase 9 (Final Cleanup)
**Date**: February 20, 2026
**Scope**: Replace all three legacy harnesses (Claude, OpenCode, Pi-Mono) with single, clean Pi harness
@@ -17,21 +17,21 @@
- [x] **Phase 6**: Cleanup & Polish — Complete
- [x] **Phase 6.1**: Session Grouping — Complete
### Frontend (In Progress)
### Frontend (Complete)
- [x] **Phase 7.1**: Type Alignment — Complete ✅
- [ ] **Phase 7.2**: Unified Pi Hook (`usePi.ts`)
- [ ] **Phase 7.3**: Unified Models Hook
- [ ] **Phase 7.4**: Session Management Migration
- [ ] **Phase 7.5**: Group Support Hooks
- [x] **Phase 7.2**: Unified Pi Hook (`usePi.ts`) — Complete ✅
- [x] **Phase 7.3**: Unified Models Hook — Complete ✅
- [x] **Phase 7.4**: Session Management Migration — Complete ✅
- [x] **Phase 7.5**: Group Support Hooks — Complete ✅
### UI Enhancements (Planned)
### UI Enhancements (Future)
- [ ] **Phase 8.1**: Grouped ChatList UI
- [ ] **Phase 8.2**: Group Management UI
- [ ] **Phase 8.3**: Search Enhancements
### Final Cleanup (Planned)
- [ ] **Phase 9.1**: Frontend Legacy Cleanup
- [ ] **Phase 9.2**: Backend Final Cleanup
### Final Cleanup (Complete)
- [x] **Phase 9.1**: Frontend Legacy Cleanup — Complete ✅
- [x] **Phase 9.2**: Backend Final Cleanup — Complete ✅
---
-73
View File
@@ -1,73 +0,0 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
import { LandingPage, AuthLayout } from './Screens/LandingPage';
import { Home } from './Screens/Dashboard/Home';
import { ProfileSettings } from './Screens/Dashboard/Settings/ProfileSettings';
import { ClaudeChat, OpenCodeChat, NewChat } from './Screens/Dashboard/Chat';
import { Plans } from './Screens/Dashboard/Plans';
import { Skills } from './Screens/Dashboard/Skills';
import { Tasks } from './Screens/Dashboard/Tasks';
import { Processes } from './Screens/Dashboard/Processes';
import { TaskLogs } from './Screens/Dashboard/TaskLogs';
import { SignoutScreen } from './Screens/Dashboard/SignoutScreen';
import { Screen as Files } from 'plugins/FileBrowser/client';
import { Screen as Terminal } from 'plugins/Terminal/client';
import { AISettings } from './Screens/Dashboard/Settings/AISettings';
import { ServerSettings } from './Screens/Dashboard/Settings/ServerSettings';
import { ResourceSettings } from './Screens/Dashboard/Settings/ResourceSettings';
import { OnboardingAdmin } from './Screens/Dashboard/OnboardingAdmin';
import { ChatList } from './Screens/Dashboard/Chat/ChatList';
import { useAuth } from 'hooks/useAuth';
import { useServerSettings } from '@/state/useServerSettings';
import { useInitialData } from '@/state/useInitialData';
export function App() {
const { isLoading, isAuthenticated } = useAuth();
const { onboardingComplete, plugins, isLoading: isServerSettingsLoading } = useServerSettings();
useInitialData();
if (isLoading || isServerSettingsLoading) return null;
return (
<BrowserRouter>
{!isAuthenticated && (
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/auth/*" element={<AuthLayout />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)}
{isAuthenticated && !onboardingComplete && (
<Routes>
<Route path="/onboarding-admin" element={<OnboardingAdmin />} />
<Route path="/auth/signout" element={<SignoutScreen />} />
<Route path="*" element={<Navigate to="/onboarding-admin" replace />} />
</Routes>
)}
{isAuthenticated && onboardingComplete && (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/ai" element={<AISettings />} />
<Route path="/settings/server" element={<ServerSettings />} />
<Route path="/settings/resources" element={<ResourceSettings />} />
<Route path="/chat" element={<ChatList />} />
<Route path="/chat/new" element={<NewChat />} />
<Route path="/chat/:sessionId" element={<ClaudeChat />} />
<Route path="/chat/opencode/new" element={<OpenCodeChat />} />
<Route path="/chat/opencode/:sessionId" element={<OpenCodeChat />} />
<Route path="/files" element={<Files />} />
<Route path="/terminal" element={<Terminal />} />
<Route path="/plans" element={<Plans />} />
<Route path="/skills" element={<Skills />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/processes" element={<Processes />} />
<Route path="/task-logs" element={<TaskLogs />} />
<Route path="/auth/signout" element={<SignoutScreen />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)}
</BrowserRouter>
);
}
@@ -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));
@@ -1,6 +1,6 @@
export type UserSettings = {
chat: {
defaultProvider: 'claude' | 'opencode' | 'pi-mono';
defaultProvider: 'pi';
defaultModel: string | null;
systemPrompt: string;
temperature: number;
@@ -11,7 +11,7 @@ export type UserSettings = {
enabledProviders: string[];
};
tasks: {
defaultProvider: 'claude' | 'opencode' | 'pi-mono';
defaultProvider: 'pi';
defaultModel: string | null;
};
appearance: {
@@ -29,18 +29,18 @@ export type UserState = Record<string, unknown>;
export const DEFAULT_SETTINGS: UserSettings = {
chat: {
defaultProvider: 'claude',
defaultProvider: 'pi',
defaultModel: null,
systemPrompt: '',
temperature: 1,
defaultPwd: '~',
},
ai: {
enabledModels: ['claude-sonnet-4-5', 'claude-opus-4-6', 'claude-haiku-4-5'],
enabledModels: [],
enabledProviders: [],
},
tasks: {
defaultProvider: 'claude',
defaultProvider: 'pi',
defaultModel: null,
},
appearance: {
@@ -0,0 +1,53 @@
import type { GroupEntry } from 'apps/Chat';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
export function useChatGroups() {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const { data: groups = [] } = useQuery<GroupEntry[]>({
queryKey: ['PI_GROUPS'],
enabled: isAuthenticated,
queryFn: () => client.get<{ groups: GroupEntry[] }>('/api/pi/groups').then((r) => r.groups),
});
async function createGroup(name: string, slug: string, description?: string, sessionIds?: string[]) {
const result = await client.post<{ group: GroupEntry }>('/api/pi/groups', {
name,
slug,
description,
sessionIds,
});
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
return result.group;
}
async function updateGroup(slug: string, updates: { name?: string; description?: string }) {
await client.patch(`/api/pi/groups/${slug}`, updates);
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
}
async function deleteGroup(slug: string) {
await client.delete(`/api/pi/groups/${slug}`);
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
}
async function moveSession(sessionId: string, groupSlug: string | null) {
await client.post(`/api/pi/sessions/${sessionId}/move`, { groupSlug });
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
}
return {
groups,
createGroup,
updateGroup,
deleteGroup,
moveSession,
};
}
+18 -73
View File
@@ -1,94 +1,39 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useSettings } from './useSettings';
import type { ModelOption } from 'apps/Chat';
export type ModelOption = { id: string; name: string; provider?: string; providerId?: string };
export type { ModelOption };
export const modelKey = (m: ModelOption) => (m.provider ? `${m.provider}:${m.id}` : m.id);
export function modelKey(m: ModelOption): string {
return `${m.provider}:${m.id}`;
}
// Hardcoded fallback in case the API call fails
const CLAUDE_MODELS: ModelOption[] = [
{ id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6' },
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },
];
export const useClaudeModels = () => {
export function usePiModels() {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data: models = CLAUDE_MODELS } = useQuery<ModelOption[]>({
queryKey: ['CLAUDE_MODELS'],
const { data: models = [] } = useQuery<ModelOption[]>({
queryKey: ['PI_MODELS'],
enabled: isAuthenticated,
queryFn: async () => {
const data = await client.get<ModelOption[]>('/claude/models');
return data.length > 0 ? data : CLAUDE_MODELS;
const data = await client.get<{ models: ModelOption[] }>('/api/pi/models');
return data.models;
},
staleTime: 10 * 60 * 1000,
});
return models;
};
/** @deprecated Use useClaudeModels() instead */
export const claudeModels = CLAUDE_MODELS;
export const useOpenCodeModels = () => {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data: models = [] } = useQuery<ModelOption[]>({
queryKey: ['OC_MODELS'],
enabled: isAuthenticated,
queryFn: () => client.get<ModelOption[]>('/opencode/models'),
staleTime: 5 * 60 * 1000,
});
return models;
};
}
export const useVisibleClaudeModels = () => {
const models = useClaudeModels();
export function useVisiblePiModels() {
const models = usePiModels();
const { settings } = useSettings();
const enabled = settings.ai?.enabledModels ?? [];
return useMemo(() => models.filter((m) => enabled.includes(modelKey(m))), [models, enabled]);
};
export const useVisibleOpenCodeModels = () => {
const models = useOpenCodeModels();
const { settings } = useSettings();
const providers = settings.ai?.enabledProviders ?? [];
const enabled = settings.ai?.enabledModels ?? [];
return useMemo(
() => models.filter((m) => providers.includes(m.provider ?? '') && enabled.includes(modelKey(m))),
[models, providers, enabled],
);
};
export const usePiMonoModels = () => {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data: models = [] } = useQuery<ModelOption[]>({
queryKey: ['PI_MONO_MODELS'],
enabled: isAuthenticated,
queryFn: () => client.get<ModelOption[]>('/pi-mono/models'),
staleTime: 5 * 60 * 1000,
});
return models;
};
export const useVisiblePiMonoModels = () => {
const models = usePiMonoModels();
const { settings } = useSettings();
const enabled = settings.ai?.enabledModels ?? [];
return useMemo(() => {
const filtered = models.filter((m) => enabled.includes(modelKey(m)));
// If no models match the visibility filter, show all — the provider list
// changes dynamically based on API keys so the filter may be stale
return filtered.length > 0 ? filtered : models;
}, [models, enabled]);
};
const filtered = models.filter((m) => enabled.includes(modelKey(m)));
// If no models match the visibility filter, show all
// The provider list changes dynamically based on API keys so the filter may be stale
return filtered.length > 0 ? filtered : models;
}
@@ -19,7 +19,9 @@ export const useSlashCommands = ({ sessionId }: UseSlashCommandsParams) => {
switch (command) {
case 'rename':
return renameSession('claude', sessionId, args);
if (!sessionId || !args) return { handled: false };
await renameSession(sessionId, args);
return { handled: true, feedback: `Session renamed to "${args}"` };
default:
return { handled: false };
}
+3 -5
View File
@@ -1,7 +1,7 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import type { LegacyChatMessage } from './types';
import type { ChatMessage } from './types';
import { ToolActivity } from './ToolActivity';
import { QuestionActivity } from './QuestionActivity';
@@ -21,7 +21,7 @@ const CollapsibleBlock = ({ label, content }: { label: string; content: string }
);
type MessageBubbleProps = {
message: LegacyChatMessage;
message: ChatMessage;
onAnswer?: (text: string) => void;
};
@@ -73,9 +73,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
return (
<div className="flex justify-center py-1">
<span className="text-xs text-duck-dark/40">
Done · ${message.costUsd.toFixed(3)} · {(message.durationMs / 1000).toFixed(1)}s · {message.numTurns} turn
{message.numTurns !== 1 ? 's' : ''}
{message.isError ? ' (with errors)' : ''}
Done · ${message.cost.totalUSD.toFixed(3)} · {message.cost.inputTokens + message.cost.outputTokens} tokens
</span>
</div>
);
+2 -2
View File
@@ -1,10 +1,10 @@
import type { RefObject } from 'react';
import { ArrowDown } from 'lucide-react';
import type { LegacyChatMessage } from './types';
import type { ChatMessage } from './types';
import { MessageBubble, StreamingBubble } from './MessageBubble';
type MessageListProps = {
messages: LegacyChatMessage[];
messages: ChatMessage[];
streamingText: string;
isGenerating: boolean;
showJumpToBottom: boolean;
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { MessageCircleQuestion, Check } from 'lucide-react';
import type { LegacyChatMessage } from './types';
import type { ChatMessage } from './types';
type ToolMessage = Extract<LegacyChatMessage, { role: 'tool' }>;
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
type QuestionOption = {
label: string;
+2 -2
View File
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
import type { LegacyChatMessage } from './types';
import type { ChatMessage } from './types';
type ToolMessage = Extract<LegacyChatMessage, { role: 'tool' }>;
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
type ToolActivityProps = {
message: ToolMessage;
+1
View File
@@ -10,6 +10,7 @@ export type {
ServerMessage,
Message,
MessageCost,
ModelOption,
TaskInfo,
LegacyChatMessage,
LegacySessionEntry,
+8
View File
@@ -4,6 +4,14 @@ export type MessageCost = {
totalUSD: number;
};
export type ModelOption = {
id: string;
name: string;
provider: string;
contextWindow: number;
maxTokens: number;
};
export type SessionEntry = {
id: string;
title: string;
+1 -13
View File
@@ -1,26 +1,22 @@
import { Link } from 'react-router';
import { ArrowLeft, Archive, Trash2, Maximize2, Minimize2 } from 'lucide-react';
import { ArrowLeft, Trash2, Maximize2, Minimize2 } from 'lucide-react';
type SessionBarProps = {
listPath: string;
provider: 'claude' | 'opencode' | 'pi-mono';
sessionTitle: string | undefined;
isConnected: boolean;
isGenerating: boolean;
fullscreen: boolean;
onArchive: (() => void) | undefined;
onDelete: () => void;
onToggleFullscreen: () => void;
};
export const SessionBar = ({
listPath,
provider,
sessionTitle,
isConnected,
isGenerating,
fullscreen,
onArchive,
onDelete,
onToggleFullscreen,
}: SessionBarProps) => (
@@ -29,14 +25,6 @@ export const SessionBar = ({
<Link to={listPath} className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors">
<ArrowLeft className="h-4 w-4" />
</Link>
{provider === 'claude' && onArchive && (
<button
onClick={onArchive}
className="p-1 text-duck-dark/40 hover:text-duck-teal transition-colors cursor-pointer"
>
<Archive className="h-4 w-4" />
</button>
)}
<button onClick={onDelete} className="p-1 text-duck-dark/40 hover:text-red-500 transition-colors cursor-pointer">
<Trash2 className="h-4 w-4" />
</button>