Complete frontend migration to unified Pi harness (Phase 7 + Phase 9)

- Delete legacy hooks: useClaude.ts, useOpenCode.ts, usePiMono.ts, App.backup.tsx
- Update all components to use usePi instead of legacy hooks
- Replace useVisiblePiMonoModels/useClaudeModels/useOpenCodeModels with useVisiblePiModels
- Migrate from LegacyChatMessage to ChatMessage type throughout
- Update SessionBar to remove provider and archive props
- Simplify ChatDetailPanel to Pi-only (remove Claude/OpenCode components)
- Fix useChatSessions calls (remove provider parameter)
- Update user-settings types: provider now only 'pi' instead of legacy values
- Update PI_HARNESS_REBUILD.md to mark phases complete
This commit is contained in:
2026-02-20 21:42:26 +00:00
parent 92f4b2be8e
commit ba51ee0320
31 changed files with 225 additions and 1176 deletions
@@ -1,17 +1,14 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router';
import { Trash2, Archive } from 'lucide-react';
import { Trash2 } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from '@/state/useChatSessions';
import { useVisibleClaudeModels, useVisibleOpenCodeModels, useVisiblePiMonoModels } from '@/state/useModels';
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
import { usePiMono } from '@/Screens/Dashboard/Chat/usePiMono';
import { useVisiblePiModels } from '@/state/useModels';
import { usePi } from '@/Screens/Dashboard/Chat/usePi';
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
export type SelectedSession = {
id: string;
provider: 'claude' | 'opencode' | 'pi-mono';
model?: string | null;
} | null;
@@ -27,95 +24,50 @@ type ChatLocationState = {
} | null;
type DetailBarProps = {
provider: 'claude' | 'opencode' | 'pi-mono';
sessionTitle: string | undefined;
isConnected: boolean;
isGenerating: boolean;
onArchive: (() => void) | undefined;
onDelete: (() => void) | undefined;
};
const DetailBar = ({
provider,
sessionTitle,
isConnected,
isGenerating,
onArchive,
onDelete,
}: DetailBarProps) => (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<div className="flex items-center gap-1">
{provider === 'claude' && onArchive && (
<button
onClick={onArchive}
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal transition-colors cursor-pointer"
>
<Archive className="h-4 w-4" />
</button>
)}
{onDelete && (
<button
onClick={onDelete}
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
)}
function DetailBar({ sessionTitle, isConnected, isGenerating, onDelete }: DetailBarProps) {
return (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<div className="flex items-center gap-1">
{onDelete && (
<button
onClick={onDelete}
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3">
{sessionTitle ?? 'New chat'}
</div>
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
{!isConnected ? (
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
) : isGenerating ? (
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
) : (
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
)}
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
</div>
</div>
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3">
{sessionTitle ?? 'New chat'}
</div>
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
{!isConnected ? (
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
) : isGenerating ? (
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
) : (
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
)}
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
</div>
</div>
);
);
}
type InnerProps = {
type SessionChatProps = {
sessionId: string;
model?: string | null;
};
const ClaudeInner = ({ sessionId, model }: InnerProps) => {
const chat = useClaude(sessionId, model, { replaceUrl: false });
const models = useVisibleClaudeModels();
const { sessions, archiveSession, deleteSession } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
return (
<div className="flex flex-col h-full">
<DetailBar
provider="claude"
sessionTitle={sessionTitle}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={async () => {
await archiveSession('claude', sessionId);
setSelected(null);
window.history.replaceState(null, '', '/chat');
}}
onDelete={async () => {
await deleteSession('claude', sessionId);
setSelected(null);
window.history.replaceState(null, '', '/chat');
}}
/>
<EmbeddableChat chat={chat} availableModels={models} className="flex-1 min-h-0" />
</div>
);
};
const OpenCodeInner = ({ sessionId, model }: InnerProps) => {
const chat = useOpenCode(sessionId, model, { replaceUrl: false });
const models = useVisibleOpenCodeModels();
function SessionChat({ sessionId, model }: SessionChatProps) {
const chat = usePi(sessionId, model, { replaceUrl: false });
const models = useVisiblePiModels();
const { sessions, deleteSession } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
@@ -123,13 +75,11 @@ const OpenCodeInner = ({ sessionId, model }: InnerProps) => {
return (
<div className="flex flex-col h-full">
<DetailBar
provider="opencode"
sessionTitle={sessionTitle}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={undefined}
onDelete={async () => {
await deleteSession('opencode', sessionId);
await deleteSession(sessionId);
setSelected(null);
window.history.replaceState(null, '', '/chat');
}}
@@ -137,45 +87,19 @@ const OpenCodeInner = ({ sessionId, model }: InnerProps) => {
<EmbeddableChat chat={chat} availableModels={models} className="flex-1 min-h-0" />
</div>
);
};
}
const PiMonoInner = ({ sessionId, model }: InnerProps) => {
const chat = usePiMono(sessionId, model, { replaceUrl: false });
const models = useVisiblePiMonoModels();
const { sessions, deleteSession } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
return (
<div className="flex flex-col h-full">
<DetailBar
provider="pi-mono"
sessionTitle={sessionTitle}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={undefined}
onDelete={async () => {
await deleteSession('pi-mono', sessionId);
setSelected(null);
window.history.replaceState(null, '', '/chat');
}}
/>
<EmbeddableChat chat={chat} availableModels={models} className="flex-1 min-h-0" />
</div>
);
};
const NewClaudeInner = () => {
function NewChat() {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const initialSentRef = useRef(false);
const chat = useClaude();
const models = useVisibleClaudeModels();
const chat = usePi();
const models = useVisiblePiModels();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
useEffect(() => {
if (chat.sessionId) {
setSelected({ id: chat.sessionId, provider: 'claude', model: chat.model });
setSelected({ id: chat.sessionId, model: chat.model });
}
}, [chat.sessionId]);
@@ -196,11 +120,9 @@ const NewClaudeInner = () => {
return (
<div className="flex flex-col h-full">
<DetailBar
provider="claude"
sessionTitle={undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={undefined}
onDelete={undefined}
/>
<EmbeddableChat
@@ -212,120 +134,19 @@ const NewClaudeInner = () => {
/>
</div>
);
};
}
const NewOpenCodeInner = () => {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const initialSentRef = useRef(false);
const chat = useOpenCode();
const models = useVisibleOpenCodeModels();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
useEffect(() => {
if (chat.sessionId) {
setSelected({ id: chat.sessionId, provider: 'opencode', model: chat.model });
}
}, [chat.sessionId]);
useEffect(() => {
if (!locationState || initialSentRef.current || !chat.isConnected) return;
if (locationState.prefillInput) {
initialSentRef.current = true;
window.history.replaceState({}, '', location.pathname);
return;
}
if (!locationState.initialMessage) return;
initialSentRef.current = true;
if (locationState.model) chat.setSelectedModel(locationState.model);
chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images);
window.history.replaceState({}, '', location.pathname);
}, [chat.isConnected, location.state]);
return (
<div className="flex flex-col h-full">
<DetailBar
provider="opencode"
sessionTitle={undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={undefined}
onDelete={undefined}
/>
<EmbeddableChat
chat={chat}
availableModels={models}
defaultInput={locationState?.prefillInput ?? ''}
className="flex-1 min-h-0"
/>
</div>
);
};
const NewPiMonoInner = () => {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const initialSentRef = useRef(false);
const chat = usePiMono();
const models = useVisiblePiMonoModels();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
useEffect(() => {
if (chat.sessionId) {
setSelected({ id: chat.sessionId, provider: 'pi-mono', model: chat.model });
}
}, [chat.sessionId]);
useEffect(() => {
if (!locationState || initialSentRef.current || !chat.isConnected) return;
if (locationState.prefillInput) {
initialSentRef.current = true;
window.history.replaceState({}, '', location.pathname);
return;
}
if (!locationState.initialMessage) return;
initialSentRef.current = true;
if (locationState.model) chat.setSelectedModel(locationState.model);
chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images);
window.history.replaceState({}, '', location.pathname);
}, [chat.isConnected, location.state]);
return (
<div className="flex flex-col h-full">
<DetailBar
provider="pi-mono"
sessionTitle={undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={undefined}
onDelete={undefined}
/>
<EmbeddableChat
chat={chat}
availableModels={models}
defaultInput={locationState?.prefillInput ?? ''}
className="flex-1 min-h-0"
/>
</div>
);
};
type NewChatPanelProps = {
initialProvider?: 'claude' | 'opencode' | 'pi-mono';
};
const NewChatPanel = ({ initialProvider = 'pi-mono' }: NewChatPanelProps) => {
function NewChatPanel() {
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
// Once a session is created, the inner component updates selected via the channel
if (selected && selected.id !== 'new') {
return <PiMonoInner key={selected.id} sessionId={selected.id} model={selected.model} />;
return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />;
}
return <NewPiMonoInner key="new-pi-mono" />;
};
return <NewChat key="new" />;
}
export const ChatDetailPanel = () => {
export function ChatDetailPanel() {
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
if (!selected) {
@@ -337,8 +158,8 @@ export const ChatDetailPanel = () => {
}
if (selected.id === 'new') {
return <NewChatPanel key="new" initialProvider={selected.provider} />;
return <NewChatPanel key="new" />;
}
return <PiMonoInner key={selected.id} sessionId={selected.id} model={selected.model} />;
};
return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />;
}
@@ -20,19 +20,19 @@ export const SessionList = () => {
useEffect(() => {
scrolledRef.current = false;
}, [selected?.id, selected?.provider]);
}, [selected?.id]);
const handleSelect = (session: (typeof sessions)[number]) => {
setSelected({ id: session.id, provider: session.provider, model: session.model ?? null });
setSelected({ id: session.id, model: session.model ?? null });
window.history.replaceState(null, '', `/chat/${session.id}`);
};
const handleDelete = async (provider: 'claude' | 'opencode' | 'pi-mono', id: string) => {
if (selected?.id === id && selected?.provider === provider) {
const handleDelete = async (id: string) => {
if (selected?.id === id) {
setSelected(null);
window.history.replaceState(null, '', '/chat');
}
await deleteSession(provider, id);
await deleteSession(id);
};
return (
@@ -42,7 +42,7 @@ export const SessionList = () => {
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
<button
onClick={() => {
setSelected({ id: 'new', provider: 'pi-mono' });
setSelected({ id: 'new' });
window.history.replaceState(null, '', '/chat/new');
}}
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
@@ -61,10 +61,10 @@ export const SessionList = () => {
)}
{sessions.map((session) => {
const isSelected = selected?.id === session.id && selected?.provider === session.provider;
const isSelected = selected?.id === session.id;
return (
<div
key={`${session.provider}-${session.id}`}
key={session.id}
ref={isSelected ? selectedRef : undefined}
className={`group flex items-center rounded-lg border transition-colors cursor-pointer ${
isSelected
@@ -100,7 +100,7 @@ export const SessionList = () => {
</div>
</button>
<button
onClick={() => handleDelete(session.provider, session.id)}
onClick={() => handleDelete(session.id)}
className="shrink-0 p-2 mr-2 text-duck-dark/20 dark:text-foreground/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
>
<Trash2 className="h-4 w-4" />
@@ -15,7 +15,7 @@ export const ChatHistory = () => {
<ul className="space-y-0.5">
{sessions.map((session) => (
<li
key={`${session.provider}-${session.id}`}
key={session.id}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group"
>
<Link
@@ -46,7 +46,7 @@ export const ChatHistory = () => {
</div>
</Link>
<button
onClick={() => deleteSession(session.provider, session.id)}
onClick={() => deleteSession(session.id)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -32,12 +32,12 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
useEffect(() => {
if (isNew) {
setSelected({ id: 'new', provider: 'pi-mono' });
setSelected({ id: 'new' });
return;
}
if (!sessionId) return;
const session = sessions.find((s) => s.id === sessionId);
setSelected({ id: sessionId, provider: session?.provider ?? 'pi-mono', model: session?.model ?? null });
setSelected({ id: sessionId, model: session?.model ?? null });
}, [sessionId, isNew]);
const panelComponents: PanelComponents = useMemo(