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
|
# Pi Harness Rebuild — Implementation Plan
|
||||||
|
|
||||||
**Status**: 🚧 IN PROGRESS — Phase 7 (Frontend Migration)
|
**Status**: ✅ COMPLETE — Phase 9 (Final Cleanup)
|
||||||
**Date**: February 20, 2026
|
**Date**: February 20, 2026
|
||||||
**Scope**: Replace all three legacy harnesses (Claude, OpenCode, Pi-Mono) with single, clean Pi harness
|
**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**: Cleanup & Polish — Complete
|
||||||
- [x] **Phase 6.1**: Session Grouping — Complete
|
- [x] **Phase 6.1**: Session Grouping — Complete
|
||||||
|
|
||||||
### Frontend (In Progress)
|
### Frontend (Complete)
|
||||||
- [x] **Phase 7.1**: Type Alignment — Complete ✅
|
- [x] **Phase 7.1**: Type Alignment — Complete ✅
|
||||||
- [ ] **Phase 7.2**: Unified Pi Hook (`usePi.ts`) ⭐
|
- [x] **Phase 7.2**: Unified Pi Hook (`usePi.ts`) — Complete ✅
|
||||||
- [ ] **Phase 7.3**: Unified Models Hook
|
- [x] **Phase 7.3**: Unified Models Hook — Complete ✅
|
||||||
- [ ] **Phase 7.4**: Session Management Migration
|
- [x] **Phase 7.4**: Session Management Migration — Complete ✅
|
||||||
- [ ] **Phase 7.5**: Group Support Hooks
|
- [x] **Phase 7.5**: Group Support Hooks — Complete ✅
|
||||||
|
|
||||||
### UI Enhancements (Planned)
|
### UI Enhancements (Future)
|
||||||
- [ ] **Phase 8.1**: Grouped ChatList UI
|
- [ ] **Phase 8.1**: Grouped ChatList UI
|
||||||
- [ ] **Phase 8.2**: Group Management UI
|
- [ ] **Phase 8.2**: Group Management UI
|
||||||
- [ ] **Phase 8.3**: Search Enhancements
|
- [ ] **Phase 8.3**: Search Enhancements
|
||||||
|
|
||||||
### Final Cleanup (Planned)
|
### Final Cleanup (Complete)
|
||||||
- [ ] **Phase 9.1**: Frontend Legacy Cleanup
|
- [x] **Phase 9.1**: Frontend Legacy Cleanup — Complete ✅
|
||||||
- [ ] **Phase 9.2**: Backend Final Cleanup
|
- [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 { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useVisiblePiMonoModels } from '@/state/useModels';
|
import { useVisiblePiModels } from '@/state/useModels';
|
||||||
import { Card } from '@/components/Card';
|
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';
|
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||||
type CapabilitySummary = {
|
type CapabilitySummary = {
|
||||||
dirName: string;
|
dirName: string;
|
||||||
@@ -64,30 +64,30 @@ export const CapabilityChat = ({
|
|||||||
description,
|
description,
|
||||||
onResponseEnd,
|
onResponseEnd,
|
||||||
}: CapabilityChatProps) => {
|
}: CapabilityChatProps) => {
|
||||||
const piMonoModels = useVisiblePiMonoModels();
|
const piModels = useVisiblePiModels();
|
||||||
const seedFile = `${kind.toUpperCase()}.md`;
|
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 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
|
const defaultInput = isNew
|
||||||
? description ?? `Help me create the content for this new ${kind} file`
|
? description ?? `Help me create the content for this new ${kind} file`
|
||||||
: `Help me understand and improve this ${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);
|
const onResponseEndRef = useRef(onResponseEnd);
|
||||||
onResponseEndRef.current = onResponseEnd;
|
onResponseEndRef.current = onResponseEnd;
|
||||||
|
|
||||||
const wasGenerating = useRef(false);
|
const wasGenerating = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wasGenerating.current && !piMono.isGenerating) {
|
if (wasGenerating.current && !pi.isGenerating) {
|
||||||
onResponseEndRef.current?.();
|
onResponseEndRef.current?.();
|
||||||
}
|
}
|
||||||
wasGenerating.current = piMono.isGenerating;
|
wasGenerating.current = pi.isGenerating;
|
||||||
}, [piMono.isGenerating]);
|
}, [pi.isGenerating]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmbeddableChat
|
<EmbeddableChat
|
||||||
chat={piMono}
|
chat={pi}
|
||||||
availableModels={piMonoModels}
|
availableModels={piModels}
|
||||||
defaultInput={defaultInput}
|
defaultInput={defaultInput}
|
||||||
promptPrefix={promptFrontmatter}
|
promptPrefix={promptFrontmatter}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
|
|||||||
@@ -4,19 +4,18 @@ import { useChatSessions } from '@/state/useChatSessions';
|
|||||||
import { useSlashCommands } from '@/state/useSlashCommands';
|
import { useSlashCommands } from '@/state/useSlashCommands';
|
||||||
import { SessionBar } from 'apps/ChatHistory';
|
import { SessionBar } from 'apps/ChatHistory';
|
||||||
import type { ModelOption } from '@/state/useModels';
|
import type { ModelOption } from '@/state/useModels';
|
||||||
import type { useClaude } from './useClaude';
|
import type { usePi } from './usePi';
|
||||||
import { EmbeddableChat, type Attachment } from './EmbeddableChat';
|
import { EmbeddableChat, type Attachment } from './EmbeddableChat';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
|
|
||||||
export type { Attachment };
|
export type { Attachment };
|
||||||
|
|
||||||
type ChatPanelProps = {
|
type ChatPanelProps = {
|
||||||
chat: ReturnType<typeof useClaude>;
|
chat: ReturnType<typeof usePi>;
|
||||||
provider?: 'claude' | 'opencode' | 'pi-mono';
|
|
||||||
availableModels?: ModelOption[];
|
availableModels?: ModelOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChatPanel = ({ chat, provider = 'claude', availableModels = [] }: ChatPanelProps) => {
|
export const ChatPanel = ({ chat, availableModels = [] }: ChatPanelProps) => {
|
||||||
const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat;
|
const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat;
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -25,7 +24,7 @@ export const ChatPanel = ({ chat, provider = 'claude', availableModels = [] }: C
|
|||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const initialSentRef = useRef(false);
|
const initialSentRef = useRef(false);
|
||||||
|
|
||||||
const { sessions, archiveSession, deleteSession } = useChatSessions();
|
const { sessions, deleteSession } = useChatSessions();
|
||||||
const slashCommands = useSlashCommands({ sessionId });
|
const slashCommands = useSlashCommands({ sessionId });
|
||||||
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
|
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
|
||||||
const listPath = '/chat';
|
const listPath = '/chat';
|
||||||
@@ -78,22 +77,13 @@ export const ChatPanel = ({ chat, provider = 'claude', availableModels = [] }: C
|
|||||||
>
|
>
|
||||||
<SessionBar
|
<SessionBar
|
||||||
listPath={listPath}
|
listPath={listPath}
|
||||||
provider={provider}
|
|
||||||
sessionTitle={sessionTitle}
|
sessionTitle={sessionTitle}
|
||||||
isConnected={isConnected}
|
isConnected={isConnected}
|
||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
fullscreen={fullscreen}
|
fullscreen={fullscreen}
|
||||||
onArchive={
|
|
||||||
provider === 'claude' && sessionId
|
|
||||||
? async () => {
|
|
||||||
await archiveSession(provider, sessionId);
|
|
||||||
navigate(listPath);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
await deleteSession(provider, sessionId);
|
await deleteSession(sessionId);
|
||||||
navigate(listPath);
|
navigate(listPath);
|
||||||
}}
|
}}
|
||||||
onToggleFullscreen={() => setFullscreen((f) => !f)}
|
onToggleFullscreen={() => setFullscreen((f) => !f)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useRef, useEffect, useState } from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import type { ModelOption } from '@/state/useModels';
|
import type { ModelOption } from '@/state/useModels';
|
||||||
import type { useClaude } from './useClaude';
|
import type { usePi } from './usePi';
|
||||||
import { MessageList } from 'apps/Chat';
|
import { MessageList } from 'apps/Chat';
|
||||||
import { InputArea } from './InputArea';
|
import { InputArea } from './InputArea';
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export type Attachment =
|
|||||||
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean };
|
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean };
|
||||||
|
|
||||||
type EmbeddableChatProps = {
|
type EmbeddableChatProps = {
|
||||||
chat: ReturnType<typeof useClaude>;
|
chat: ReturnType<typeof usePi>;
|
||||||
availableModels?: ModelOption[];
|
availableModels?: ModelOption[];
|
||||||
onBeforeSend?: (text: string) => boolean | Promise<boolean>;
|
onBeforeSend?: (text: string) => boolean | Promise<boolean>;
|
||||||
commandFeedback?: string | null;
|
commandFeedback?: string | null;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import type { ModelOption } from '@/state/useModels';
|
import type { ModelOption } from '@/state/useModels';
|
||||||
import type { LegacyChatMessage } from 'apps/Chat';
|
import type { ChatMessage } from 'apps/Chat';
|
||||||
import type { Attachment } from './EmbeddableChat';
|
import type { Attachment } from './EmbeddableChat';
|
||||||
import { Settings } from './Settings';
|
import { Settings } from './Settings';
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ type InputAreaProps = {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
commandFeedback: string | null;
|
commandFeedback: string | null;
|
||||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||||
messages: LegacyChatMessage[];
|
messages: ChatMessage[];
|
||||||
availableModels: ModelOption[];
|
availableModels: ModelOption[];
|
||||||
selectedModel: string | null;
|
selectedModel: string | null;
|
||||||
onModelChange: (modelId: string) => void;
|
onModelChange: (modelId: string) => void;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import type { ModelOption } from '@/state/useModels';
|
import type { ModelOption } from '@/state/useModels';
|
||||||
import type { LegacyChatMessage } from 'apps/Chat';
|
import type { ChatMessage } from 'apps/Chat';
|
||||||
|
|
||||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||||
anthropic: 'Anthropic',
|
anthropic: 'Anthropic',
|
||||||
@@ -21,7 +21,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type SettingsProps = {
|
type SettingsProps = {
|
||||||
messages: LegacyChatMessage[];
|
messages: ChatMessage[];
|
||||||
availableModels: ModelOption[];
|
availableModels: ModelOption[];
|
||||||
selectedModel: string | null;
|
selectedModel: string | null;
|
||||||
onModelChange: (modelId: string) => void;
|
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 { useEffect, useRef } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { Trash2, Archive } from 'lucide-react';
|
import { Trash2 } from 'lucide-react';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useChatSessions } from '@/state/useChatSessions';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels';
|
import { useVisiblePiModels } from '@/state/useModels';
|
||||||
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
|
import { usePi } from '@/Screens/Dashboard/Chat/usePi';
|
||||||
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
|
|
||||||
import { usePiMono } from '@/Screens/Dashboard/Chat/usePiMono';
|
|
||||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||||
|
|
||||||
export type SelectedSession = {
|
export type SelectedSession = {
|
||||||
id: string;
|
id: string;
|
||||||
provider: 'claude' | 'opencode' | 'pi-mono';
|
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
@@ -27,95 +24,50 @@ type ChatLocationState = {
|
|||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
type DetailBarProps = {
|
type DetailBarProps = {
|
||||||
provider: 'claude' | 'opencode' | 'pi-mono';
|
|
||||||
sessionTitle: string | undefined;
|
sessionTitle: string | undefined;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
onArchive: (() => void) | undefined;
|
|
||||||
onDelete: (() => void) | undefined;
|
onDelete: (() => void) | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DetailBar = ({
|
function DetailBar({ sessionTitle, isConnected, isGenerating, onDelete }: DetailBarProps) {
|
||||||
provider,
|
return (
|
||||||
sessionTitle,
|
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
|
||||||
isConnected,
|
<div className="flex items-center gap-1">
|
||||||
isGenerating,
|
{onDelete && (
|
||||||
onArchive,
|
<button
|
||||||
onDelete,
|
onClick={onDelete}
|
||||||
}: DetailBarProps) => (
|
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer"
|
||||||
<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">
|
<Trash2 className="h-4 w-4" />
|
||||||
{provider === 'claude' && onArchive && (
|
</button>
|
||||||
<button
|
)}
|
||||||
onClick={onArchive}
|
</div>
|
||||||
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal transition-colors cursor-pointer"
|
<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'}
|
||||||
<Archive className="h-4 w-4" />
|
</div>
|
||||||
</button>
|
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||||
)}
|
{!isConnected ? (
|
||||||
{onDelete && (
|
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
|
||||||
<button
|
) : isGenerating ? (
|
||||||
onClick={onDelete}
|
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
|
||||||
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer"
|
) : (
|
||||||
>
|
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
|
||||||
<Trash2 className="h-4 w-4" />
|
)}
|
||||||
</button>
|
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
|
||||||
)}
|
</div>
|
||||||
</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;
|
sessionId: string;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClaudeInner = ({ sessionId, model }: InnerProps) => {
|
function SessionChat({ sessionId, model }: SessionChatProps) {
|
||||||
const chat = useClaude(sessionId, model, { replaceUrl: false });
|
const chat = usePi(sessionId, model, { replaceUrl: false });
|
||||||
const models = useVisibleClaudeModels();
|
const models = useVisiblePiModels();
|
||||||
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();
|
|
||||||
const { sessions, deleteSession } = useChatSessions();
|
const { sessions, deleteSession } = useChatSessions();
|
||||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
||||||
@@ -123,13 +75,11 @@ const OpenCodeInner = ({ sessionId, model }: InnerProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<DetailBar
|
<DetailBar
|
||||||
provider="opencode"
|
|
||||||
sessionTitle={sessionTitle}
|
sessionTitle={sessionTitle}
|
||||||
isConnected={chat.isConnected}
|
isConnected={chat.isConnected}
|
||||||
isGenerating={chat.isGenerating}
|
isGenerating={chat.isGenerating}
|
||||||
onArchive={undefined}
|
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
await deleteSession('opencode', sessionId);
|
await deleteSession(sessionId);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
window.history.replaceState(null, '', '/chat');
|
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" />
|
<EmbeddableChat chat={chat} availableModels={models} className="flex-1 min-h-0" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const PiMonoInner = ({ sessionId, model }: InnerProps) => {
|
function NewChat() {
|
||||||
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 = () => {
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const locationState = location.state as ChatLocationState;
|
const locationState = location.state as ChatLocationState;
|
||||||
const initialSentRef = useRef(false);
|
const initialSentRef = useRef(false);
|
||||||
const chat = useClaude();
|
const chat = usePi();
|
||||||
const models = useVisibleClaudeModels();
|
const models = useVisiblePiModels();
|
||||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chat.sessionId) {
|
if (chat.sessionId) {
|
||||||
setSelected({ id: chat.sessionId, provider: 'claude', model: chat.model });
|
setSelected({ id: chat.sessionId, model: chat.model });
|
||||||
}
|
}
|
||||||
}, [chat.sessionId]);
|
}, [chat.sessionId]);
|
||||||
|
|
||||||
@@ -196,11 +120,9 @@ const NewClaudeInner = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<DetailBar
|
<DetailBar
|
||||||
provider="claude"
|
|
||||||
sessionTitle={undefined}
|
sessionTitle={undefined}
|
||||||
isConnected={chat.isConnected}
|
isConnected={chat.isConnected}
|
||||||
isGenerating={chat.isGenerating}
|
isGenerating={chat.isGenerating}
|
||||||
onArchive={undefined}
|
|
||||||
onDelete={undefined}
|
onDelete={undefined}
|
||||||
/>
|
/>
|
||||||
<EmbeddableChat
|
<EmbeddableChat
|
||||||
@@ -212,120 +134,19 @@ const NewClaudeInner = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const NewOpenCodeInner = () => {
|
function NewChatPanel() {
|
||||||
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) => {
|
|
||||||
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
|
||||||
// Once a session is created, the inner component updates selected via the channel
|
|
||||||
if (selected && selected.id !== 'new') {
|
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);
|
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
@@ -337,8 +158,8 @@ export const ChatDetailPanel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selected.id === 'new') {
|
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(() => {
|
useEffect(() => {
|
||||||
scrolledRef.current = false;
|
scrolledRef.current = false;
|
||||||
}, [selected?.id, selected?.provider]);
|
}, [selected?.id]);
|
||||||
|
|
||||||
const handleSelect = (session: (typeof sessions)[number]) => {
|
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}`);
|
window.history.replaceState(null, '', `/chat/${session.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (provider: 'claude' | 'opencode' | 'pi-mono', id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (selected?.id === id && selected?.provider === provider) {
|
if (selected?.id === id) {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
window.history.replaceState(null, '', '/chat');
|
window.history.replaceState(null, '', '/chat');
|
||||||
}
|
}
|
||||||
await deleteSession(provider, id);
|
await deleteSession(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +42,7 @@ export const SessionList = () => {
|
|||||||
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
|
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelected({ id: 'new', provider: 'pi-mono' });
|
setSelected({ id: 'new' });
|
||||||
window.history.replaceState(null, '', '/chat/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"
|
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) => {
|
{sessions.map((session) => {
|
||||||
const isSelected = selected?.id === session.id && selected?.provider === session.provider;
|
const isSelected = selected?.id === session.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${session.provider}-${session.id}`}
|
key={session.id}
|
||||||
ref={isSelected ? selectedRef : undefined}
|
ref={isSelected ? selectedRef : undefined}
|
||||||
className={`group flex items-center rounded-lg border transition-colors cursor-pointer ${
|
className={`group flex items-center rounded-lg border transition-colors cursor-pointer ${
|
||||||
isSelected
|
isSelected
|
||||||
@@ -100,7 +100,7 @@ export const SessionList = () => {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const ChatHistory = () => {
|
|||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<li
|
<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"
|
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
@@ -46,7 +46,7 @@ export const ChatHistory = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<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"
|
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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
setSelected({ id: 'new', provider: 'pi-mono' });
|
setSelected({ id: 'new' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
const session = sessions.find((s) => s.id === sessionId);
|
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]);
|
}, [sessionId, isNew]);
|
||||||
|
|
||||||
const panelComponents: PanelComponents = useMemo(
|
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 * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { cardStyle } from '@/components/Card';
|
import { cardStyle } from '@/components/Card';
|
||||||
import type { TaskInfo } from 'apps/Chat';
|
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 { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||||
import { useVisiblePiMonoModels } from '@/state/useModels';
|
import { useVisiblePiModels } from '@/state/useModels';
|
||||||
import { useSettings } from '@/state/useSettings';
|
import { useSettings } from '@/state/useSettings';
|
||||||
import type { TaskSummary } from 'apps/FileBrowser';
|
import type { TaskSummary } from 'apps/FileBrowser';
|
||||||
|
|
||||||
@@ -47,8 +47,8 @@ const PiMonoInner = ({
|
|||||||
initialModel,
|
initialModel,
|
||||||
taskInfo,
|
taskInfo,
|
||||||
}: PiMonoInnerProps) => {
|
}: PiMonoInnerProps) => {
|
||||||
const chat = usePiMono(undefined, initialModel, { replaceUrl: false, taskInfo });
|
const chat = usePi(undefined, initialModel, { replaceUrl: false, taskInfo });
|
||||||
const models = useVisiblePiMonoModels();
|
const models = useVisiblePiModels();
|
||||||
|
|
||||||
const wasGenerating = useRef(false);
|
const wasGenerating = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -108,10 +108,10 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType
|
|||||||
|
|
||||||
{/* Chat */}
|
{/* Chat */}
|
||||||
<PiMonoInner
|
<PiMonoInner
|
||||||
key="pi-mono"
|
key="pi"
|
||||||
defaultInput={defaultInput}
|
defaultInput={defaultInput}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
initialModel={taskSettings.defaultProvider === 'pi-mono' ? taskSettings.defaultModel : null}
|
initialModel={taskSettings.defaultProvider === 'pi' ? taskSettings.defaultModel : null}
|
||||||
taskInfo={taskInfo}
|
taskInfo={taskInfo}
|
||||||
/>
|
/>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useSettings } from '@/state/useSettings';
|
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';
|
import type { Attachment } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||||
|
|
||||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||||
@@ -46,7 +46,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
|
|||||||
export const ChatLauncher = () => {
|
export const ChatLauncher = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const piMonoModels = useVisiblePiMonoModels();
|
const piModels = useVisiblePiModels();
|
||||||
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
||||||
@@ -62,17 +62,17 @@ export const ChatLauncher = () => {
|
|||||||
}, [settings.chat.defaultModel]);
|
}, [settings.chat.defaultModel]);
|
||||||
|
|
||||||
const providers = useMemo(
|
const providers = useMemo(
|
||||||
() => [...new Set(piMonoModels.map((m) => m.provider).filter(Boolean))] as string[],
|
() => [...new Set(piModels.map((m: ModelOption) => m.provider).filter(Boolean))] as string[],
|
||||||
[piMonoModels],
|
[piModels],
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeProvider = piMonoModels.find((m) => m.id === model)?.provider ?? providers[0];
|
const activeProvider = piModels.find((m: ModelOption) => m.id === model)?.provider ?? providers[0];
|
||||||
const providerModels = piMonoModels.filter((m) => m.provider === activeProvider);
|
const providerModels = piModels.filter((m: ModelOption) => m.provider === activeProvider);
|
||||||
|
|
||||||
const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider;
|
const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider;
|
||||||
|
|
||||||
const handleProviderClick = (provider: string) => {
|
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);
|
if (firstModel) setModel(firstModel.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,11 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useSettings } from '@/state/useSettings';
|
import { useSettings } from '@/state/useSettings';
|
||||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels';
|
import { useVisiblePiModels, type ModelOption } from '@/state/useModels';
|
||||||
|
|
||||||
export const TaskDefaults = () => {
|
export const TaskDefaults = () => {
|
||||||
const { settings, saveSettings } = useSettings();
|
const { settings, saveSettings } = useSettings();
|
||||||
const claudeModels = useVisibleClaudeModels();
|
const piModels = useVisiblePiModels();
|
||||||
const openCodeModels = useVisibleOpenCodeModels();
|
|
||||||
const piMonoModels = useVisiblePiMonoModels();
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
const [model, setModel] = useState<string | null>(settings.tasks.defaultModel);
|
const [model, setModel] = useState<string | null>(settings.tasks.defaultModel);
|
||||||
@@ -27,7 +25,7 @@ export const TaskDefaults = () => {
|
|||||||
setModel(settings.tasks.defaultModel);
|
setModel(settings.tasks.defaultModel);
|
||||||
}, [settings]);
|
}, [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 }[]> = {};
|
const groups: Record<string, { id: string; name: string }[]> = {};
|
||||||
for (const m of models) {
|
for (const m of models) {
|
||||||
const provider = m.provider ?? fallback;
|
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)) }));
|
.map(([provider, models]) => ({ provider, models: models.sort((a, b) => a.name.localeCompare(b.name)) }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCodeGroups = useMemo(() => buildGroups(openCodeModels, 'OpenCode'), [openCodeModels]);
|
const piGroups = useMemo(() => buildGroups(piModels, 'Pi'), [piModels]);
|
||||||
const piMonoGroups = useMemo(() => buildGroups(piMonoModels, 'Pi'), [piMonoModels]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const isPiMono = piMonoModels.some((m) => m.id === model);
|
await saveSettings({ ...settings, tasks: { defaultProvider: 'pi', defaultModel: 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 } });
|
|
||||||
toast.success('Task defaults saved');
|
toast.success('Task defaults saved');
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save settings');
|
toast.error('Failed to save settings');
|
||||||
@@ -67,29 +61,9 @@ export const TaskDefaults = () => {
|
|||||||
<SelectValue placeholder="Same as chat default" />
|
<SelectValue placeholder="Same as chat default" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[600] max-h-[300px]">
|
<SelectContent className="z-[600] max-h-[300px]">
|
||||||
{claudeModels.length > 0 && (
|
{piGroups.map(({ provider, models }) => (
|
||||||
<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 }) => (
|
|
||||||
<SelectGroup key={`pi-${provider}`}>
|
<SelectGroup key={`pi-${provider}`}>
|
||||||
<SelectLabel>{provider} (Pi)</SelectLabel>
|
<SelectLabel>{provider}</SelectLabel>
|
||||||
{models.map((m) => (
|
{models.map((m) => (
|
||||||
<SelectItem key={`pi:${provider}:${m.id}`} value={m.id}>
|
<SelectItem key={`pi:${provider}:${m.id}`} value={m.id}>
|
||||||
{m.name}
|
{m.name}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { appRegistry } from '../Workspaces/app-registry';
|
|||||||
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
|
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
|
||||||
import { useSettings } from '@/state/useSettings';
|
import { useSettings } from '@/state/useSettings';
|
||||||
import { useUserState } from '@/state/useUserState';
|
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 type { UserSettings } from '@/state/types/user-settings';
|
||||||
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
|
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
|
||||||
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel';
|
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel';
|
||||||
@@ -146,7 +146,7 @@ export const SystemSettings = () => {
|
|||||||
|
|
||||||
function ChatDefaultsSection() {
|
function ChatDefaultsSection() {
|
||||||
const { settings, saveSettings } = useSettings();
|
const { settings, saveSettings } = useSettings();
|
||||||
const piMonoModels = useVisiblePiMonoModels();
|
const piModels = useVisiblePiModels();
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
||||||
@@ -167,7 +167,7 @@ function ChatDefaultsSection() {
|
|||||||
try {
|
try {
|
||||||
const updated: UserSettings = {
|
const updated: UserSettings = {
|
||||||
...settings,
|
...settings,
|
||||||
chat: { defaultProvider: 'pi-mono', defaultModel: model, systemPrompt, temperature, defaultPwd },
|
chat: { defaultProvider: 'pi', defaultModel: model, systemPrompt, temperature, defaultPwd },
|
||||||
};
|
};
|
||||||
await saveSettings(updated);
|
await saveSettings(updated);
|
||||||
toast.success('Chat defaults saved');
|
toast.success('Chat defaults saved');
|
||||||
@@ -187,7 +187,7 @@ function ChatDefaultsSection() {
|
|||||||
<SelectValue placeholder="Default" />
|
<SelectValue placeholder="Default" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[600]">
|
<SelectContent className="z-[600]">
|
||||||
{piMonoModels.map((m) => (
|
{piModels.map((m: ModelOption) => (
|
||||||
<SelectItem key={m.id} value={m.id}>
|
<SelectItem key={m.id} value={m.id}>
|
||||||
<span className="font-bold">{m.name}</span>
|
<span className="font-bold">{m.name}</span>
|
||||||
{m.provider && <span className="text-duck-dark/50 ml-1">({m.provider})</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() {
|
function ModelVisibilitySection() {
|
||||||
const { settings, saveSettings } = useSettings();
|
const { settings, saveSettings } = useSettings();
|
||||||
const piMonoModels = usePiMonoModels();
|
const piModels = usePiModels();
|
||||||
const [activeProvider, setActiveProvider] = useUserState<string>('model-visibility-provider', '');
|
const [activeProvider, setActiveProvider] = useUserState<string>('model-visibility-provider', '');
|
||||||
|
|
||||||
const enabledModels = settings.ai?.enabledModels ?? [];
|
const enabledModels = settings.ai?.enabledModels ?? [];
|
||||||
|
|
||||||
const providerGroups = useMemo(() => {
|
const providerGroups = useMemo(() => {
|
||||||
const groups: Record<string, { id: string; name: string }[]> = {};
|
const groups: Record<string, { id: string; name: string }[]> = {};
|
||||||
for (const m of piMonoModels) {
|
for (const m of piModels) {
|
||||||
const provider = m.provider ?? 'Other';
|
const provider = m.provider ?? 'Other';
|
||||||
if (!groups[provider]) groups[provider] = [];
|
if (!groups[provider]) groups[provider] = [];
|
||||||
groups[provider].push({ id: m.id, name: m.name });
|
groups[provider].push({ id: m.id, name: m.name });
|
||||||
@@ -319,7 +319,7 @@ function ModelVisibilitySection() {
|
|||||||
return Object.entries(groups)
|
return Object.entries(groups)
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
.map(([provider, models]) => ({ provider, models: models.sort((a, b) => a.name.localeCompare(b.name)) }));
|
.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]);
|
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 { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { MessageBubble, type LegacyChatMessage } from 'apps/Chat';
|
import { MessageBubble, type ChatMessage } from 'apps/Chat';
|
||||||
|
|
||||||
type LogMetadata = {
|
type LogMetadata = {
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -18,7 +18,7 @@ type LogMetadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type FullLog = LogMetadata & {
|
type FullLog = LogMetadata & {
|
||||||
messages: LegacyChatMessage[];
|
messages: ChatMessage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (iso: string) => {
|
const formatDate = (iso: string) => {
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import { TerminalView } from 'apps/Terminal';
|
|||||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
|
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
|
||||||
import { usePiMono } from '../Chat/usePiMono';
|
import { usePi } from '../Chat/usePi';
|
||||||
import { ChatPanel } from '../Chat/ChatPanel';
|
import { ChatPanel } from '../Chat/ChatPanel';
|
||||||
import { useVisiblePiMonoModels } from '@/state/useModels';
|
import { useVisiblePiModels } from '@/state/useModels';
|
||||||
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
||||||
import { Files } from '../Files';
|
import { Files } from '../Files';
|
||||||
import { Catalog } from 'sounds';
|
import { Catalog } from 'sounds';
|
||||||
@@ -22,9 +22,9 @@ import { widgetRegistry } from 'widgets/widget-registry';
|
|||||||
import { WidgetPanel } from 'widgets/WidgetPanel';
|
import { WidgetPanel } from 'widgets/WidgetPanel';
|
||||||
|
|
||||||
const ChatWidget = () => {
|
const ChatWidget = () => {
|
||||||
const piMono = usePiMono();
|
const pi = usePi();
|
||||||
const models = useVisiblePiMonoModels();
|
const models = useVisiblePiModels();
|
||||||
return <ChatPanel chat={piMono} provider="pi-mono" availableModels={models} />;
|
return <ChatPanel chat={pi} availableModels={models} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
|
const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type UserSettings = {
|
export type UserSettings = {
|
||||||
chat: {
|
chat: {
|
||||||
defaultProvider: 'claude' | 'opencode' | 'pi-mono';
|
defaultProvider: 'pi';
|
||||||
defaultModel: string | null;
|
defaultModel: string | null;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
@@ -11,7 +11,7 @@ export type UserSettings = {
|
|||||||
enabledProviders: string[];
|
enabledProviders: string[];
|
||||||
};
|
};
|
||||||
tasks: {
|
tasks: {
|
||||||
defaultProvider: 'claude' | 'opencode' | 'pi-mono';
|
defaultProvider: 'pi';
|
||||||
defaultModel: string | null;
|
defaultModel: string | null;
|
||||||
};
|
};
|
||||||
appearance: {
|
appearance: {
|
||||||
@@ -29,18 +29,18 @@ export type UserState = Record<string, unknown>;
|
|||||||
|
|
||||||
export const DEFAULT_SETTINGS: UserSettings = {
|
export const DEFAULT_SETTINGS: UserSettings = {
|
||||||
chat: {
|
chat: {
|
||||||
defaultProvider: 'claude',
|
defaultProvider: 'pi',
|
||||||
defaultModel: null,
|
defaultModel: null,
|
||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
temperature: 1,
|
temperature: 1,
|
||||||
defaultPwd: '~',
|
defaultPwd: '~',
|
||||||
},
|
},
|
||||||
ai: {
|
ai: {
|
||||||
enabledModels: ['claude-sonnet-4-5', 'claude-opus-4-6', 'claude-haiku-4-5'],
|
enabledModels: [],
|
||||||
enabledProviders: [],
|
enabledProviders: [],
|
||||||
},
|
},
|
||||||
tasks: {
|
tasks: {
|
||||||
defaultProvider: 'claude',
|
defaultProvider: 'pi',
|
||||||
defaultModel: null,
|
defaultModel: null,
|
||||||
},
|
},
|
||||||
appearance: {
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useSettings } from './useSettings';
|
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
|
export function usePiModels() {
|
||||||
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 = () => {
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
const { data: models = CLAUDE_MODELS } = useQuery<ModelOption[]>({
|
const { data: models = [] } = useQuery<ModelOption[]>({
|
||||||
queryKey: ['CLAUDE_MODELS'],
|
queryKey: ['PI_MODELS'],
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await client.get<ModelOption[]>('/claude/models');
|
const data = await client.get<{ models: ModelOption[] }>('/api/pi/models');
|
||||||
return data.length > 0 ? data : CLAUDE_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,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
return models;
|
return models;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const useVisibleClaudeModels = () => {
|
export function useVisiblePiModels() {
|
||||||
const models = useClaudeModels();
|
const models = usePiModels();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const enabled = settings.ai?.enabledModels ?? [];
|
const enabled = settings.ai?.enabledModels ?? [];
|
||||||
return useMemo(() => models.filter((m) => enabled.includes(modelKey(m))), [models, enabled]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useVisibleOpenCodeModels = () => {
|
const filtered = models.filter((m) => enabled.includes(modelKey(m)));
|
||||||
const models = useOpenCodeModels();
|
// If no models match the visibility filter, show all
|
||||||
const { settings } = useSettings();
|
// The provider list changes dynamically based on API keys so the filter may be stale
|
||||||
const providers = settings.ai?.enabledProviders ?? [];
|
return filtered.length > 0 ? filtered : models;
|
||||||
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]);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export const useSlashCommands = ({ sessionId }: UseSlashCommandsParams) => {
|
|||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'rename':
|
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:
|
default:
|
||||||
return { handled: false };
|
return { handled: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import type { LegacyChatMessage } from './types';
|
import type { ChatMessage } from './types';
|
||||||
import { ToolActivity } from './ToolActivity';
|
import { ToolActivity } from './ToolActivity';
|
||||||
import { QuestionActivity } from './QuestionActivity';
|
import { QuestionActivity } from './QuestionActivity';
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ const CollapsibleBlock = ({ label, content }: { label: string; content: string }
|
|||||||
);
|
);
|
||||||
|
|
||||||
type MessageBubbleProps = {
|
type MessageBubbleProps = {
|
||||||
message: LegacyChatMessage;
|
message: ChatMessage;
|
||||||
onAnswer?: (text: string) => void;
|
onAnswer?: (text: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,9 +73,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-1">
|
<div className="flex justify-center py-1">
|
||||||
<span className="text-xs text-duck-dark/40">
|
<span className="text-xs text-duck-dark/40">
|
||||||
Done · ${message.costUsd.toFixed(3)} · {(message.durationMs / 1000).toFixed(1)}s · {message.numTurns} turn
|
Done · ${message.cost.totalUSD.toFixed(3)} · {message.cost.inputTokens + message.cost.outputTokens} tokens
|
||||||
{message.numTurns !== 1 ? 's' : ''}
|
|
||||||
{message.isError ? ' (with errors)' : ''}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
import { ArrowDown } from 'lucide-react';
|
import { ArrowDown } from 'lucide-react';
|
||||||
import type { LegacyChatMessage } from './types';
|
import type { ChatMessage } from './types';
|
||||||
import { MessageBubble, StreamingBubble } from './MessageBubble';
|
import { MessageBubble, StreamingBubble } from './MessageBubble';
|
||||||
|
|
||||||
type MessageListProps = {
|
type MessageListProps = {
|
||||||
messages: LegacyChatMessage[];
|
messages: ChatMessage[];
|
||||||
streamingText: string;
|
streamingText: string;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
showJumpToBottom: boolean;
|
showJumpToBottom: boolean;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { MessageCircleQuestion, Check } from 'lucide-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 = {
|
type QuestionOption = {
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-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 = {
|
type ToolActivityProps = {
|
||||||
message: ToolMessage;
|
message: ToolMessage;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type {
|
|||||||
ServerMessage,
|
ServerMessage,
|
||||||
Message,
|
Message,
|
||||||
MessageCost,
|
MessageCost,
|
||||||
|
ModelOption,
|
||||||
TaskInfo,
|
TaskInfo,
|
||||||
LegacyChatMessage,
|
LegacyChatMessage,
|
||||||
LegacySessionEntry,
|
LegacySessionEntry,
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ export type MessageCost = {
|
|||||||
totalUSD: number;
|
totalUSD: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ModelOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
contextWindow: number;
|
||||||
|
maxTokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type SessionEntry = {
|
export type SessionEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { ArrowLeft, Archive, Trash2, Maximize2, Minimize2 } from 'lucide-react';
|
import { ArrowLeft, Trash2, Maximize2, Minimize2 } from 'lucide-react';
|
||||||
|
|
||||||
type SessionBarProps = {
|
type SessionBarProps = {
|
||||||
listPath: string;
|
listPath: string;
|
||||||
provider: 'claude' | 'opencode' | 'pi-mono';
|
|
||||||
sessionTitle: string | undefined;
|
sessionTitle: string | undefined;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
fullscreen: boolean;
|
fullscreen: boolean;
|
||||||
onArchive: (() => void) | undefined;
|
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SessionBar = ({
|
export const SessionBar = ({
|
||||||
listPath,
|
listPath,
|
||||||
provider,
|
|
||||||
sessionTitle,
|
sessionTitle,
|
||||||
isConnected,
|
isConnected,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
fullscreen,
|
fullscreen,
|
||||||
onArchive,
|
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
}: SessionBarProps) => (
|
}: SessionBarProps) => (
|
||||||
@@ -29,14 +25,6 @@ export const SessionBar = ({
|
|||||||
<Link to={listPath} className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors">
|
<Link to={listPath} className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Link>
|
</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">
|
<button onClick={onDelete} className="p-1 text-duck-dark/40 hover:text-red-500 transition-colors cursor-pointer">
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user