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:
+10
-10
@@ -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 ✅
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,7 @@ export type {
|
||||
ServerMessage,
|
||||
Message,
|
||||
MessageCost,
|
||||
ModelOption,
|
||||
TaskInfo,
|
||||
LegacyChatMessage,
|
||||
LegacySessionEntry,
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user