From ba51ee032089929942440c90953ac6baa6dcd34a Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Fri, 20 Feb 2026 21:42:26 +0000 Subject: [PATCH] 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 --- PI_HARNESS_REBUILD.md | 20 +- src/apps/officer-web/App.backup.tsx | 73 ----- .../Screens/Dashboard/CapabilityPage.tsx | 18 +- .../Screens/Dashboard/Chat/ChatPanel.tsx | 20 +- .../Screens/Dashboard/Chat/EmbeddableChat.tsx | 4 +- .../Screens/Dashboard/Chat/InputArea.tsx | 4 +- .../Screens/Dashboard/Chat/Settings.tsx | 4 +- .../Screens/Dashboard/Chat/useClaude.ts | 231 --------------- .../Screens/Dashboard/Chat/useOpenCode.ts | 249 ---------------- .../Screens/Dashboard/Chat/usePiMono.ts | 178 ----------- .../Dashboard/ChatHistory/ChatDetailPanel.tsx | 279 ++++-------------- .../Screens/Dashboard/ChatHistory/Screen.tsx | 18 +- .../Screens/Dashboard/ChatHistory/Widget.tsx | 4 +- .../Screens/Dashboard/ChatHistory/index.tsx | 4 +- .../Files/Screen/TaskRunnerModal.tsx | 12 +- .../Screens/Dashboard/Home/ChatLauncher.tsx | 14 +- .../Settings/ProfileSettings/TaskDefaults.tsx | 40 +-- .../Dashboard/Settings/SystemSettings.tsx | 14 +- .../Screens/Dashboard/TaskLogs/index.tsx | 4 +- .../Dashboard/Workspaces/app-registry.tsx | 10 +- .../officer-web/state/types/user-settings.ts | 10 +- src/apps/officer-web/state/useChatGroups.ts | 53 ++++ src/apps/officer-web/state/useModels.ts | 91 ++---- .../officer-web/state/useSlashCommands.ts | 4 +- src/workspaces/apps/Chat/MessageBubble.tsx | 8 +- src/workspaces/apps/Chat/MessageList.tsx | 4 +- src/workspaces/apps/Chat/QuestionActivity.tsx | 4 +- src/workspaces/apps/Chat/ToolActivity.tsx | 4 +- src/workspaces/apps/Chat/index.ts | 1 + src/workspaces/apps/Chat/types.ts | 8 + .../apps/ChatHistory/SessionBar.tsx | 14 +- 31 files changed, 225 insertions(+), 1176 deletions(-) delete mode 100644 src/apps/officer-web/App.backup.tsx delete mode 100644 src/apps/officer-web/Screens/Dashboard/Chat/useClaude.ts delete mode 100644 src/apps/officer-web/Screens/Dashboard/Chat/useOpenCode.ts delete mode 100644 src/apps/officer-web/Screens/Dashboard/Chat/usePiMono.ts create mode 100644 src/apps/officer-web/state/useChatGroups.ts diff --git a/PI_HARNESS_REBUILD.md b/PI_HARNESS_REBUILD.md index 0e6b34d8..4f6e872e 100644 --- a/PI_HARNESS_REBUILD.md +++ b/PI_HARNESS_REBUILD.md @@ -1,6 +1,6 @@ # Pi Harness Rebuild โ€” Implementation Plan -**Status**: ๐Ÿšง IN PROGRESS โ€” Phase 7 (Frontend Migration) +**Status**: โœ… COMPLETE โ€” Phase 9 (Final Cleanup) **Date**: February 20, 2026 **Scope**: Replace all three legacy harnesses (Claude, OpenCode, Pi-Mono) with single, clean Pi harness @@ -17,21 +17,21 @@ - [x] **Phase 6**: Cleanup & Polish โ€” Complete - [x] **Phase 6.1**: Session Grouping โ€” Complete -### Frontend (In Progress) +### Frontend (Complete) - [x] **Phase 7.1**: Type Alignment โ€” Complete โœ… -- [ ] **Phase 7.2**: Unified Pi Hook (`usePi.ts`) โญ -- [ ] **Phase 7.3**: Unified Models Hook -- [ ] **Phase 7.4**: Session Management Migration -- [ ] **Phase 7.5**: Group Support Hooks +- [x] **Phase 7.2**: Unified Pi Hook (`usePi.ts`) โ€” Complete โœ… +- [x] **Phase 7.3**: Unified Models Hook โ€” Complete โœ… +- [x] **Phase 7.4**: Session Management Migration โ€” Complete โœ… +- [x] **Phase 7.5**: Group Support Hooks โ€” Complete โœ… -### UI Enhancements (Planned) +### UI Enhancements (Future) - [ ] **Phase 8.1**: Grouped ChatList UI - [ ] **Phase 8.2**: Group Management UI - [ ] **Phase 8.3**: Search Enhancements -### Final Cleanup (Planned) -- [ ] **Phase 9.1**: Frontend Legacy Cleanup -- [ ] **Phase 9.2**: Backend Final Cleanup +### Final Cleanup (Complete) +- [x] **Phase 9.1**: Frontend Legacy Cleanup โ€” Complete โœ… +- [x] **Phase 9.2**: Backend Final Cleanup โ€” Complete โœ… --- diff --git a/src/apps/officer-web/App.backup.tsx b/src/apps/officer-web/App.backup.tsx deleted file mode 100644 index 9ccb2743..00000000 --- a/src/apps/officer-web/App.backup.tsx +++ /dev/null @@ -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 ( - - {!isAuthenticated && ( - - } /> - } /> - } /> - - )} - {isAuthenticated && !onboardingComplete && ( - - } /> - } /> - } /> - - )} - {isAuthenticated && onboardingComplete && ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - )} - - ); -} diff --git a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx index 4ee48c1e..b26e3efc 100644 --- a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx +++ b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx @@ -8,9 +8,9 @@ import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search, ChevronRight } from import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { useClient } from 'hooks/useClient'; -import { useVisiblePiMonoModels } from '@/state/useModels'; +import { useVisiblePiModels } from '@/state/useModels'; import { Card } from '@/components/Card'; -import { usePiMono } from '@/Screens/Dashboard/Chat/usePiMono'; +import { usePi } from '@/Screens/Dashboard/Chat/usePi'; import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat'; type CapabilitySummary = { dirName: string; @@ -64,30 +64,30 @@ export const CapabilityChat = ({ description, onResponseEnd, }: CapabilityChatProps) => { - const piMonoModels = useVisiblePiMonoModels(); + const piModels = useVisiblePiModels(); const seedFile = `${kind.toUpperCase()}.md`; const promptFrontmatter = `\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`; const defaultInput = isNew ? description ?? `Help me create the content for this new ${kind} file` : `Help me understand and improve this ${kind} file`; - const piMono = usePiMono(undefined, undefined, { replaceUrl: false }); + const pi = usePi(undefined, undefined, { replaceUrl: false }); const onResponseEndRef = useRef(onResponseEnd); onResponseEndRef.current = onResponseEnd; const wasGenerating = useRef(false); useEffect(() => { - if (wasGenerating.current && !piMono.isGenerating) { + if (wasGenerating.current && !pi.isGenerating) { onResponseEndRef.current?.(); } - wasGenerating.current = piMono.isGenerating; - }, [piMono.isGenerating]); + wasGenerating.current = pi.isGenerating; + }, [pi.isGenerating]); return ( ; - provider?: 'claude' | 'opencode' | 'pi-mono'; + chat: ReturnType; availableModels?: ModelOption[]; }; -export const ChatPanel = ({ chat, provider = 'claude', availableModels = [] }: ChatPanelProps) => { +export const ChatPanel = ({ chat, availableModels = [] }: ChatPanelProps) => { const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat; const location = useLocation(); @@ -25,7 +24,7 @@ export const ChatPanel = ({ chat, provider = 'claude', availableModels = [] }: C const [fullscreen, setFullscreen] = useState(false); const initialSentRef = useRef(false); - const { sessions, archiveSession, deleteSession } = useChatSessions(); + const { sessions, deleteSession } = useChatSessions(); const slashCommands = useSlashCommands({ sessionId }); const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined; const listPath = '/chat'; @@ -78,22 +77,13 @@ export const ChatPanel = ({ chat, provider = 'claude', availableModels = [] }: C > { - await archiveSession(provider, sessionId); - navigate(listPath); - } - : undefined - } onDelete={async () => { if (!sessionId) return; - await deleteSession(provider, sessionId); + await deleteSession(sessionId); navigate(listPath); }} onToggleFullscreen={() => setFullscreen((f) => !f)} diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/EmbeddableChat.tsx b/src/apps/officer-web/Screens/Dashboard/Chat/EmbeddableChat.tsx index 8b9fcff4..3cfacd8d 100644 --- a/src/apps/officer-web/Screens/Dashboard/Chat/EmbeddableChat.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Chat/EmbeddableChat.tsx @@ -3,7 +3,7 @@ import { useRef, useEffect, useState } from 'react'; import { toast } from 'sonner'; import { useClient } from 'hooks/useClient'; import type { ModelOption } from '@/state/useModels'; -import type { useClaude } from './useClaude'; +import type { usePi } from './usePi'; import { MessageList } from 'apps/Chat'; import { InputArea } from './InputArea'; @@ -12,7 +12,7 @@ export type Attachment = | { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean }; type EmbeddableChatProps = { - chat: ReturnType; + chat: ReturnType; availableModels?: ModelOption[]; onBeforeSend?: (text: string) => boolean | Promise; commandFeedback?: string | null; diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/InputArea.tsx b/src/apps/officer-web/Screens/Dashboard/Chat/InputArea.tsx index 5b86a11c..7b2ed0cc 100644 --- a/src/apps/officer-web/Screens/Dashboard/Chat/InputArea.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Chat/InputArea.tsx @@ -11,7 +11,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import type { ModelOption } from '@/state/useModels'; -import type { LegacyChatMessage } from 'apps/Chat'; +import type { ChatMessage } from 'apps/Chat'; import type { Attachment } from './EmbeddableChat'; import { Settings } from './Settings'; @@ -61,7 +61,7 @@ type InputAreaProps = { isConnected: boolean; commandFeedback: string | null; textareaRef: RefObject; - messages: LegacyChatMessage[]; + messages: ChatMessage[]; availableModels: ModelOption[]; selectedModel: string | null; onModelChange: (modelId: string) => void; diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/Settings.tsx b/src/apps/officer-web/Screens/Dashboard/Chat/Settings.tsx index c1ea3116..4aa43108 100644 --- a/src/apps/officer-web/Screens/Dashboard/Chat/Settings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Chat/Settings.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import type { ModelOption } from '@/state/useModels'; -import type { LegacyChatMessage } from 'apps/Chat'; +import type { ChatMessage } from 'apps/Chat'; const PROVIDER_DISPLAY: Record = { anthropic: 'Anthropic', @@ -21,7 +21,7 @@ const PROVIDER_DISPLAY: Record = { }; type SettingsProps = { - messages: LegacyChatMessage[]; + messages: ChatMessage[]; availableModels: ModelOption[]; selectedModel: string | null; onModelChange: (modelId: string) => void; diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/useClaude.ts b/src/apps/officer-web/Screens/Dashboard/Chat/useClaude.ts deleted file mode 100644 index fe9024c9..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Chat/useClaude.ts +++ /dev/null @@ -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; -}; - -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([]); - const [streamingText, setStreamingText] = useState(''); - const [isGenerating, setIsGenerating] = useState(false); - const [sessionId, setSessionId] = useState(initialSessionId ?? null); - const [model, setModel] = useState(null); - const [selectedModel, setSelectedModel] = useState(initialModel ?? null); - - const streamingRef = useRef(''); - const rafRef = useRef(null); - const sessionIdRef = useRef(initialSessionId ?? null); - const saveTimerRef = useRef(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, - }; -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/useOpenCode.ts b/src/apps/officer-web/Screens/Dashboard/Chat/useOpenCode.ts deleted file mode 100644 index effd2be9..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Chat/useOpenCode.ts +++ /dev/null @@ -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 = /^([\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([]); - const [streamingText, setStreamingText] = useState(''); - const [isGenerating, setIsGenerating] = useState(false); - const [sessionId, setSessionId] = useState(initialSessionId ?? null); - const [model, setModel] = useState(null); - const [selectedModel, setSelectedModel] = useState(initialModel ?? null); - - const streamingRef = useRef(''); - const rafRef = useRef(null); - const sessionIdRef = useRef(initialSessionId ?? null); - const selectedModelRef = useRef(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, - }; -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Chat/usePiMono.ts b/src/apps/officer-web/Screens/Dashboard/Chat/usePiMono.ts deleted file mode 100644 index ebacd93b..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Chat/usePiMono.ts +++ /dev/null @@ -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([]); - const [streamingText, setStreamingText] = useState(''); - const [isGenerating, setIsGenerating] = useState(false); - const [sessionId, setSessionId] = useState(initialSessionId ?? null); - const [model, setModel] = useState(null); - const [selectedModel, setSelectedModel] = useState(initialModel ?? null); - - const streamingRef = useRef(''); - const rafRef = useRef(null); - const sessionIdRef = useRef(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, - }; -}; diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx index 7281bf0e..742a9369 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/ChatDetailPanel.tsx @@ -1,17 +1,14 @@ import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router'; -import { Trash2, Archive } from 'lucide-react'; +import { Trash2 } from 'lucide-react'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { useChatSessions } from '@/state/useChatSessions'; -import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels'; -import { useClaude } from '@/Screens/Dashboard/Chat/useClaude'; -import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode'; -import { usePiMono } from '@/Screens/Dashboard/Chat/usePiMono'; +import { useVisiblePiModels } from '@/state/useModels'; +import { usePi } from '@/Screens/Dashboard/Chat/usePi'; import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat'; export type SelectedSession = { id: string; - provider: 'claude' | 'opencode' | 'pi-mono'; model?: string | null; } | null; @@ -27,95 +24,50 @@ type ChatLocationState = { } | null; type DetailBarProps = { - provider: 'claude' | 'opencode' | 'pi-mono'; sessionTitle: string | undefined; isConnected: boolean; isGenerating: boolean; - onArchive: (() => void) | undefined; onDelete: (() => void) | undefined; }; -const DetailBar = ({ - provider, - sessionTitle, - isConnected, - isGenerating, - onArchive, - onDelete, -}: DetailBarProps) => ( -
-
- {provider === 'claude' && onArchive && ( - - )} - {onDelete && ( - - )} +function DetailBar({ sessionTitle, isConnected, isGenerating, onDelete }: DetailBarProps) { + return ( +
+
+ {onDelete && ( + + )} +
+
+ {sessionTitle ?? 'New chat'} +
+
+ {!isConnected ? ( + + ) : isGenerating ? ( + + ) : ( + + )} + {!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''} +
-
- {sessionTitle ?? 'New chat'} -
-
- {!isConnected ? ( - - ) : isGenerating ? ( - - ) : ( - - )} - {!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''} -
-
-); + ); +} -type InnerProps = { +type SessionChatProps = { sessionId: string; model?: string | null; }; -const ClaudeInner = ({ sessionId, model }: InnerProps) => { - const chat = useClaude(sessionId, model, { replaceUrl: false }); - const models = useVisibleClaudeModels(); - const { sessions, archiveSession, deleteSession } = useChatSessions(); - const [, setSelected] = usePanelChannel(CHANNEL, null); - const sessionTitle = sessions.find((s) => s.id === sessionId)?.title; - - return ( -
- { - await archiveSession('claude', sessionId); - setSelected(null); - window.history.replaceState(null, '', '/chat'); - }} - onDelete={async () => { - await deleteSession('claude', sessionId); - setSelected(null); - window.history.replaceState(null, '', '/chat'); - }} - /> - -
- ); -}; - -const OpenCodeInner = ({ sessionId, model }: InnerProps) => { - const chat = useOpenCode(sessionId, model, { replaceUrl: false }); - const models = useVisibleOpenCodeModels(); +function SessionChat({ sessionId, model }: SessionChatProps) { + const chat = usePi(sessionId, model, { replaceUrl: false }); + const models = useVisiblePiModels(); const { sessions, deleteSession } = useChatSessions(); const [, setSelected] = usePanelChannel(CHANNEL, null); const sessionTitle = sessions.find((s) => s.id === sessionId)?.title; @@ -123,13 +75,11 @@ const OpenCodeInner = ({ sessionId, model }: InnerProps) => { return (
{ - await deleteSession('opencode', sessionId); + await deleteSession(sessionId); setSelected(null); window.history.replaceState(null, '', '/chat'); }} @@ -137,45 +87,19 @@ const OpenCodeInner = ({ sessionId, model }: InnerProps) => {
); -}; +} -const PiMonoInner = ({ sessionId, model }: InnerProps) => { - const chat = usePiMono(sessionId, model, { replaceUrl: false }); - const models = useVisiblePiMonoModels(); - const { sessions, deleteSession } = useChatSessions(); - const [, setSelected] = usePanelChannel(CHANNEL, null); - const sessionTitle = sessions.find((s) => s.id === sessionId)?.title; - - return ( -
- { - await deleteSession('pi-mono', sessionId); - setSelected(null); - window.history.replaceState(null, '', '/chat'); - }} - /> - -
- ); -}; - -const NewClaudeInner = () => { +function NewChat() { const location = useLocation(); const locationState = location.state as ChatLocationState; const initialSentRef = useRef(false); - const chat = useClaude(); - const models = useVisibleClaudeModels(); + const chat = usePi(); + const models = useVisiblePiModels(); const [, setSelected] = usePanelChannel(CHANNEL, null); useEffect(() => { if (chat.sessionId) { - setSelected({ id: chat.sessionId, provider: 'claude', model: chat.model }); + setSelected({ id: chat.sessionId, model: chat.model }); } }, [chat.sessionId]); @@ -196,11 +120,9 @@ const NewClaudeInner = () => { return (
{ />
); -}; +} -const NewOpenCodeInner = () => { - const location = useLocation(); - const locationState = location.state as ChatLocationState; - const initialSentRef = useRef(false); - const chat = useOpenCode(); - const models = useVisibleOpenCodeModels(); - const [, setSelected] = usePanelChannel(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 ( -
- - -
- ); -}; - -const NewPiMonoInner = () => { - const location = useLocation(); - const locationState = location.state as ChatLocationState; - const initialSentRef = useRef(false); - const chat = usePiMono(); - const models = useVisiblePiMonoModels(); - const [, setSelected] = usePanelChannel(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 ( -
- - -
- ); -}; - -type NewChatPanelProps = { - initialProvider?: 'claude' | 'opencode' | 'pi-mono'; -}; - -const NewChatPanel = ({ initialProvider = 'pi-mono' }: NewChatPanelProps) => { +function NewChatPanel() { const [selected] = usePanelChannel(CHANNEL, null); - // Once a session is created, the inner component updates selected via the channel if (selected && selected.id !== 'new') { - return ; + return ; } - return ; -}; + return ; +} -export const ChatDetailPanel = () => { +export function ChatDetailPanel() { const [selected] = usePanelChannel(CHANNEL, null); if (!selected) { @@ -337,8 +158,8 @@ export const ChatDetailPanel = () => { } if (selected.id === 'new') { - return ; + return ; } - return ; -}; + return ; +} diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/Screen.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/Screen.tsx index b1e0cf5b..a05e10b4 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/Screen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/Screen.tsx @@ -20,19 +20,19 @@ export const SessionList = () => { useEffect(() => { scrolledRef.current = false; - }, [selected?.id, selected?.provider]); + }, [selected?.id]); const handleSelect = (session: (typeof sessions)[number]) => { - setSelected({ id: session.id, provider: session.provider, model: session.model ?? null }); + setSelected({ id: session.id, model: session.model ?? null }); window.history.replaceState(null, '', `/chat/${session.id}`); }; - const handleDelete = async (provider: 'claude' | 'opencode' | 'pi-mono', id: string) => { - if (selected?.id === id && selected?.provider === provider) { + const handleDelete = async (id: string) => { + if (selected?.id === id) { setSelected(null); window.history.replaceState(null, '', '/chat'); } - await deleteSession(provider, id); + await deleteSession(id); }; return ( @@ -42,7 +42,7 @@ export const SessionList = () => {

Sessions

- )}